Skip to content

Commit 4d63eff

Browse files
dkhalifeCopilot
andauthored
Add local sync of tasks and labels with server priority + outbox override (#304)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 55375e5 commit 4d63eff

34 files changed

Lines changed: 1374 additions & 164 deletions

android/app/build.gradle.kts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,10 @@ dependencies {
165165

166166
implementation(libs.androidx.work.runtime.ktx)
167167

168+
implementation(libs.androidx.room.runtime)
169+
implementation(libs.androidx.room.ktx)
170+
kapt(libs.androidx.room.compiler)
171+
168172
implementation(libs.androidx.glance.appwidget)
169173
implementation(libs.androidx.glance.material3)
170174

@@ -173,6 +177,8 @@ dependencies {
173177
implementation(libs.opentelemetry.exporter.logging)
174178

175179
testImplementation(libs.junit)
180+
testImplementation(libs.androidx.room.testing)
181+
testImplementation(libs.kotlinx.coroutines.test)
176182
androidTestImplementation(libs.androidx.junit)
177183
androidTestImplementation(libs.androidx.espresso.core)
178184
androidTestImplementation(platform(libs.androidx.compose.bom))

android/app/src/main/AndroidManifest.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
xmlns:tools="http://schemas.android.com/tools">
44

55
<uses-permission android:name="android.permission.INTERNET" />
6+
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
67
<uses-permission android:name="android.permission.READ_CALENDAR" />
78
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
89
<uses-permission android:name="android.permission.AUTHENTICATE_ACCOUNTS" />

android/app/src/main/java/com/dkhalife/tasks/TaskWizardApplication.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package com.dkhalife.tasks
33
import android.app.Application
44
import androidx.work.Configuration
55
import com.dkhalife.tasks.auth.AuthManager
6+
import com.dkhalife.tasks.data.network.NetworkMonitor
7+
import com.dkhalife.tasks.data.sync.SyncCoordinator
68
import com.dkhalife.tasks.data.sync.TaskSyncWorkerFactory
79
import com.dkhalife.tasks.data.sync.WebSocketLifecycleManager
810
import com.dkhalife.tasks.telemetry.TelemetryManager
@@ -28,6 +30,12 @@ class TaskWizardApplication : Application(), Configuration.Provider {
2830
@Inject
2931
lateinit var webSocketLifecycleManager: WebSocketLifecycleManager
3032

33+
@Inject
34+
lateinit var syncCoordinator: SyncCoordinator
35+
36+
@Inject
37+
lateinit var networkMonitor: NetworkMonitor
38+
3139
override val workManagerConfiguration: Configuration
3240
get() = Configuration.Builder()
3341
.setWorkerFactory(taskSyncWorkerFactory)
@@ -39,6 +47,8 @@ class TaskWizardApplication : Application(), Configuration.Provider {
3947
setupCrashHandler()
4048
initializeMsal()
4149
webSocketLifecycleManager.start()
50+
networkMonitor.addOnAvailableListener { syncCoordinator.syncOnce() }
51+
syncCoordinator.syncOnce()
4252
}
4353

4454
private fun setupCrashHandler() {

android/app/src/main/java/com/dkhalife/tasks/data/AppPreferences.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ object AppPreferences {
1414
const val KEY_TELEMETRY_ENABLED = "telemetry_enabled"
1515
const val KEY_DEBUG_LOGGING_ENABLED = "debug_logging_enabled"
1616
const val KEY_DEVICE_IDENTIFIER = "device_identifier"
17+
const val KEY_LOCAL_ID_COUNTER = "local_id_counter"
1718
}
1819

1920
enum class ThemeMode {
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package com.dkhalife.tasks.data
2+
3+
/**
4+
* Generates unique negative placeholder ids for records created offline, so they never collide
5+
* with server-assigned positive ids. Persisted in SharedPreferences so ids survive process death.
6+
*/
7+
class LocalIdGenerator(private val prefs: android.content.SharedPreferences) {
8+
fun nextId(): Int {
9+
synchronized(this) {
10+
val current = prefs.getInt(AppPreferences.KEY_LOCAL_ID_COUNTER, -1)
11+
val next = if (current >= 0) -1 else current - 1
12+
prefs.edit().putInt(AppPreferences.KEY_LOCAL_ID_COUNTER, next).apply()
13+
return next
14+
}
15+
}
16+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package com.dkhalife.tasks.data.db
2+
3+
import androidx.room.TypeConverter
4+
import com.dkhalife.tasks.model.Frequency
5+
import com.dkhalife.tasks.model.NotificationTriggerOptions
6+
import com.google.gson.Gson
7+
8+
class Converters {
9+
private val gson = Gson()
10+
11+
@TypeConverter
12+
fun frequencyToJson(value: Frequency?): String? = value?.let { gson.toJson(it) }
13+
14+
@TypeConverter
15+
fun frequencyFromJson(value: String?): Frequency? =
16+
value?.let { gson.fromJson(it, Frequency::class.java) }
17+
18+
@TypeConverter
19+
fun notificationToJson(value: NotificationTriggerOptions?): String? = value?.let { gson.toJson(it) }
20+
21+
@TypeConverter
22+
fun notificationFromJson(value: String?): NotificationTriggerOptions? =
23+
value?.let { gson.fromJson(it, NotificationTriggerOptions::class.java) }
24+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package com.dkhalife.tasks.data.db
2+
3+
object LocalState {
4+
const val SYNCED = "SYNCED"
5+
const val PENDING_CREATE = "PENDING_CREATE"
6+
const val PENDING_UPDATE = "PENDING_UPDATE"
7+
const val PENDING_DELETE = "PENDING_DELETE"
8+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package com.dkhalife.tasks.data.db
2+
3+
import com.dkhalife.tasks.data.db.entity.LabelEntity
4+
import com.dkhalife.tasks.data.db.entity.TaskEntity
5+
import com.dkhalife.tasks.data.db.entity.TaskWithLabels
6+
import com.dkhalife.tasks.model.Label
7+
import com.dkhalife.tasks.model.Task
8+
9+
fun TaskWithLabels.toDomain(): Task = Task(
10+
id = task.id,
11+
title = task.title,
12+
nextDueDate = task.nextDueDate,
13+
endDate = task.endDate,
14+
isRolling = task.isRolling,
15+
frequency = task.frequency,
16+
notification = task.notification,
17+
labels = labels.map { it.toDomain() },
18+
createdAt = task.createdAt,
19+
updatedAt = task.updatedAt,
20+
)
21+
22+
fun LabelEntity.toDomain(): Label = Label(
23+
id = id,
24+
name = name,
25+
color = color,
26+
createdAt = createdAt,
27+
updatedAt = updatedAt,
28+
)
29+
30+
fun Task.toEntity(
31+
localId: String? = null,
32+
localState: String = LocalState.SYNCED,
33+
): TaskEntity = TaskEntity(
34+
id = id,
35+
localId = localId,
36+
title = title,
37+
nextDueDate = nextDueDate,
38+
endDate = endDate,
39+
isRolling = isRolling,
40+
frequency = frequency,
41+
notification = notification,
42+
createdAt = createdAt,
43+
updatedAt = updatedAt,
44+
localState = localState,
45+
)
46+
47+
fun Label.toEntity(
48+
localId: String? = null,
49+
localState: String = LocalState.SYNCED,
50+
): LabelEntity = LabelEntity(
51+
id = id,
52+
localId = localId,
53+
name = name,
54+
color = color,
55+
createdAt = createdAt,
56+
updatedAt = updatedAt,
57+
localState = localState,
58+
)
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package com.dkhalife.tasks.data.db
2+
3+
import androidx.room.Database
4+
import androidx.room.RoomDatabase
5+
import androidx.room.TypeConverters
6+
import com.dkhalife.tasks.data.db.dao.LabelDao
7+
import com.dkhalife.tasks.data.db.dao.OutboxDao
8+
import com.dkhalife.tasks.data.db.dao.TaskDao
9+
import com.dkhalife.tasks.data.db.entity.LabelEntity
10+
import com.dkhalife.tasks.data.db.entity.OutboxEntity
11+
import com.dkhalife.tasks.data.db.entity.TaskEntity
12+
import com.dkhalife.tasks.data.db.entity.TaskLabelCrossRef
13+
14+
@Database(
15+
entities = [
16+
TaskEntity::class,
17+
LabelEntity::class,
18+
TaskLabelCrossRef::class,
19+
OutboxEntity::class,
20+
],
21+
version = 1,
22+
exportSchema = false,
23+
)
24+
@TypeConverters(Converters::class)
25+
abstract class TaskWizardDatabase : RoomDatabase() {
26+
abstract fun taskDao(): TaskDao
27+
abstract fun labelDao(): LabelDao
28+
abstract fun outboxDao(): OutboxDao
29+
30+
companion object {
31+
const val DB_NAME = "task_wizard.db"
32+
}
33+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package com.dkhalife.tasks.data.db.dao
2+
3+
import androidx.room.Dao
4+
import androidx.room.Insert
5+
import androidx.room.OnConflictStrategy
6+
import androidx.room.Query
7+
import com.dkhalife.tasks.data.db.entity.LabelEntity
8+
import kotlinx.coroutines.flow.Flow
9+
10+
@Dao
11+
interface LabelDao {
12+
@Query("SELECT * FROM labels WHERE localState != 'PENDING_DELETE' ORDER BY name")
13+
fun observeLabels(): Flow<List<LabelEntity>>
14+
15+
@Query("SELECT * FROM labels WHERE id = :id LIMIT 1")
16+
suspend fun getById(id: Int): LabelEntity?
17+
18+
@Query("SELECT * FROM labels WHERE localId = :localId LIMIT 1")
19+
suspend fun getByLocalId(localId: String): LabelEntity?
20+
21+
@Query("SELECT MIN(id) FROM labels")
22+
suspend fun getMinId(): Int?
23+
24+
@Insert(onConflict = OnConflictStrategy.REPLACE)
25+
suspend fun upsert(entity: LabelEntity)
26+
27+
@Query("UPDATE labels SET id = :newId, localId = NULL, localState = 'SYNCED' WHERE id = :oldId")
28+
suspend fun remapId(oldId: Int, newId: Int)
29+
30+
@Query("DELETE FROM labels WHERE id = :id")
31+
suspend fun deleteById(id: Int)
32+
33+
@Query("SELECT id FROM labels")
34+
suspend fun allIds(): List<Int>
35+
36+
@Query("SELECT id FROM labels WHERE localState != 'SYNCED'")
37+
suspend fun dirtyIds(): List<Int>
38+
39+
@Query("DELETE FROM labels")
40+
suspend fun deleteAll()
41+
}

0 commit comments

Comments
 (0)