diff --git a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetDataRepository.kt b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetDataRepository.kt index ee091f3..cd7c00d 100644 --- a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetDataRepository.kt +++ b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetDataRepository.kt @@ -14,11 +14,16 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -// DataStore instance +/** + * Legacy DataStore instance used by the existing Android widgets prior to + * iOS parity. Retained so currently-shipped widgets continue to compile + * while Streams K/L/M roll out. + */ private val Context.widgetDataStore: DataStore by preferencesDataStore(name = "widget_data") /** - * Data class representing a task for the widget + * Legacy widget task model (pre-iOS-parity). Prefer [WidgetTaskDto] for new + * code — this type remains only to keep the current widget UI compiling. */ @Serializable data class WidgetTask( @@ -32,7 +37,8 @@ data class WidgetTask( ) /** - * Data class representing widget summary data + * Legacy summary model (pre-iOS-parity). Prefer [WidgetStats] + [WidgetTaskDto] + * via the iOS-parity API below. */ @Serializable data class WidgetSummary( @@ -45,35 +51,130 @@ data class WidgetSummary( ) /** - * Repository for managing widget data persistence + * Repository for widget data persistence. + * + * This class exposes two APIs: + * + * 1. **iOS-parity API** (preferred): + * [saveTasks], [loadTasks], [markPendingCompletion], + * [clearPendingCompletion], [computeStats], [saveTierState], + * [loadTierState]. Mirrors the semantics of + * `iosApp/iosApp/Helpers/WidgetDataManager.swift`. Backed by + * [WidgetDataStore]. + * + * 2. **Legacy API** (retained for current widgets): + * [widgetSummary], [isProUser], [userName], [updateWidgetData], + * [updateProStatus], [updateUserName], [clearData]. These will be + * removed once Streams K/L/M land. + * + * Singleton accessors: [get] (new) and [getInstance] (legacy) return the + * same underlying instance. */ -class WidgetDataRepository(private val context: Context) { +class WidgetDataRepository internal constructor(private val context: Context) { - private val json = Json { ignoreUnknownKeys = true } + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } - companion object { - private val OVERDUE_COUNT = intPreferencesKey("overdue_count") - private val DUE_SOON_COUNT = intPreferencesKey("due_soon_count") - private val IN_PROGRESS_COUNT = intPreferencesKey("in_progress_count") - private val TOTAL_TASKS_COUNT = intPreferencesKey("total_tasks_count") - private val TASKS_JSON = stringPreferencesKey("tasks_json") - private val LAST_UPDATED = longPreferencesKey("last_updated") - private val IS_PRO_USER = stringPreferencesKey("is_pro_user") - private val USER_NAME = stringPreferencesKey("user_name") + /** iOS-parity DataStore wrapper. */ + private val store = WidgetDataStore(context) - @Volatile - private var INSTANCE: WidgetDataRepository? = null + // ===================================================================== + // iOS-parity API + // ===================================================================== - fun getInstance(context: Context): WidgetDataRepository { - return INSTANCE ?: synchronized(this) { - INSTANCE ?: WidgetDataRepository(context.applicationContext).also { INSTANCE = it } - } - } + /** + * Serialize and persist the task list to the widget cache. Overwrites any + * previous list (matches iOS file-write semantics — the JSON blob is the + * entire cache, not an append). + */ + suspend fun saveTasks(tasks: List) { + val encoded = json.encodeToString(tasks) + store.writeTasksJson(encoded, refreshTimeMs = System.currentTimeMillis()) } /** - * Get the widget summary as a Flow + * Load the cached task list, excluding any ids present in the + * pending-completion set. + * + * iOS semantics: the widget's `loadTasks()` returns whatever is on disk; + * pending completions are filtered by a separate `PendingTaskState` file. + * Here we fold that filter into [loadTasks] so callers don't have to + * remember to apply it. */ + suspend fun loadTasks(): List { + val raw = store.readTasksJson() + val all = try { + json.decodeFromString>(raw) + } catch (e: Exception) { + emptyList() + } + val pending = store.readPendingCompletionIds() + if (pending.isEmpty()) return all + return all.filterNot { it.id in pending } + } + + /** Queue a task id for optimistic completion. See [loadTasks]. */ + suspend fun markPendingCompletion(taskId: Long) { + val current = store.readPendingCompletionIds().toMutableSet() + current.add(taskId) + store.writePendingCompletionIds(current) + } + + /** Remove a task id from the pending-completion set. */ + suspend fun clearPendingCompletion(taskId: Long) { + val current = store.readPendingCompletionIds().toMutableSet() + current.remove(taskId) + store.writePendingCompletionIds(current) + } + + /** + * Compute the three summary counters shown on the widget: + * - overdueCount — tasks with `isOverdue == true` + * - dueWithin7 — tasks with `0 <= daysUntilDue <= 7` + * - dueWithin8To30 — tasks with `8 <= daysUntilDue <= 30` + * + * Pending-completion tasks are excluded (via [loadTasks]). + */ + suspend fun computeStats(): WidgetStats { + val tasks = loadTasks() + var overdue = 0 + var within7 = 0 + var within8To30 = 0 + for (t in tasks) { + if (t.isOverdue) overdue += 1 + val d = t.daysUntilDue + when { + d in 0..7 -> within7 += 1 + d in 8..30 -> within8To30 += 1 + } + } + return WidgetStats( + overdueCount = overdue, + dueWithin7 = within7, + dueWithin8To30 = within8To30 + ) + } + + /** Persist the subscription tier ("free" | "premium"). */ + suspend fun saveTierState(tier: String) { + store.writeTier(tier) + } + + /** Read the persisted tier. Defaults to "free" if never set. */ + suspend fun loadTierState(): String = store.readTier() + + /** Clear every key in both the iOS-parity store and the legacy store. */ + internal suspend fun clearAll() { + store.clearAll() + context.widgetDataStore.edit { it.clear() } + } + + // ===================================================================== + // Legacy API (kept until Streams K/L/M replace the widget UI) + // ===================================================================== + val widgetSummary: Flow = context.widgetDataStore.data.map { preferences -> val tasksJson = preferences[TASKS_JSON] ?: "[]" val tasks = try { @@ -92,23 +193,14 @@ class WidgetDataRepository(private val context: Context) { ) } - /** - * Check if user is a Pro subscriber - */ val isProUser: Flow = context.widgetDataStore.data.map { preferences -> preferences[IS_PRO_USER] == "true" } - /** - * Get the user's display name - */ val userName: Flow = context.widgetDataStore.data.map { preferences -> preferences[USER_NAME] ?: "" } - /** - * Update the widget data - */ suspend fun updateWidgetData(summary: WidgetSummary) { context.widgetDataStore.edit { preferences -> preferences[OVERDUE_COUNT] = summary.overdueCount @@ -120,30 +212,46 @@ class WidgetDataRepository(private val context: Context) { } } - /** - * Update user subscription status - */ suspend fun updateProStatus(isPro: Boolean) { context.widgetDataStore.edit { preferences -> preferences[IS_PRO_USER] = if (isPro) "true" else "false" } } - /** - * Update user name - */ suspend fun updateUserName(name: String) { context.widgetDataStore.edit { preferences -> preferences[USER_NAME] = name } } - /** - * Clear all widget data (called on logout) - */ suspend fun clearData() { context.widgetDataStore.edit { preferences -> preferences.clear() } } + + companion object { + // Legacy keys — preserved for on-disk compatibility. + private val OVERDUE_COUNT = intPreferencesKey("overdue_count") + private val DUE_SOON_COUNT = intPreferencesKey("due_soon_count") + private val IN_PROGRESS_COUNT = intPreferencesKey("in_progress_count") + private val TOTAL_TASKS_COUNT = intPreferencesKey("total_tasks_count") + private val TASKS_JSON = stringPreferencesKey("tasks_json") + private val LAST_UPDATED = longPreferencesKey("last_updated") + private val IS_PRO_USER = stringPreferencesKey("is_pro_user") + private val USER_NAME = stringPreferencesKey("user_name") + + @Volatile + private var INSTANCE: WidgetDataRepository? = null + + /** Preferred accessor — matches iOS `WidgetDataManager.shared`. */ + fun get(context: Context): WidgetDataRepository { + return INSTANCE ?: synchronized(this) { + INSTANCE ?: WidgetDataRepository(context.applicationContext).also { INSTANCE = it } + } + } + + /** Legacy accessor — delegates to [get]. */ + fun getInstance(context: Context): WidgetDataRepository = get(context) + } } diff --git a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetDataStore.kt b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetDataStore.kt new file mode 100644 index 0000000..8ebea7f --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetDataStore.kt @@ -0,0 +1,95 @@ +package com.tt.honeyDue.widget + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.first + +/** + * DataStore-backed key/value store for widget task data. + * + * iOS uses an App Group shared container with UserDefaults + JSON files to + * bridge main app and widget extension processes. On Android, Glance widgets + * run in the same process as the hosting app, so a simple DataStore instance + * is sufficient. + * + * Keys: + * - widget_tasks_json — JSON-serialized List + * - pending_completion_ids — comma-separated Long ids queued for sync + * - last_refresh_time — Long epoch millis of the most recent save + * - user_tier — "free" | "premium" + */ +internal val Context.widgetIosParityDataStore: DataStore by preferencesDataStore( + name = "widget_data_ios_parity" +) + +internal object WidgetDataStoreKeys { + val WIDGET_TASKS_JSON = stringPreferencesKey("widget_tasks_json") + val PENDING_COMPLETION_IDS = stringPreferencesKey("pending_completion_ids") + val LAST_REFRESH_TIME = longPreferencesKey("last_refresh_time") + val USER_TIER = stringPreferencesKey("user_tier") +} + +/** + * Thin suspend-fun wrapper around [widgetIosParityDataStore]. All reads resolve + * the current snapshot; all writes are transactional via [edit]. + */ +class WidgetDataStore(private val context: Context) { + + private val store get() = context.widgetIosParityDataStore + + suspend fun readTasksJson(): String = + store.data.first()[WidgetDataStoreKeys.WIDGET_TASKS_JSON] ?: "[]" + + suspend fun writeTasksJson(json: String, refreshTimeMs: Long) { + store.edit { prefs -> + prefs[WidgetDataStoreKeys.WIDGET_TASKS_JSON] = json + prefs[WidgetDataStoreKeys.LAST_REFRESH_TIME] = refreshTimeMs + } + } + + suspend fun readPendingCompletionIds(): Set { + val raw = store.data.first()[WidgetDataStoreKeys.PENDING_COMPLETION_IDS] ?: return emptySet() + if (raw.isBlank()) return emptySet() + return raw.split(',') + .mapNotNull { it.trim().toLongOrNull() } + .toSet() + } + + suspend fun writePendingCompletionIds(ids: Set) { + val encoded = ids.joinToString(",") + store.edit { prefs -> + if (encoded.isEmpty()) { + prefs.remove(WidgetDataStoreKeys.PENDING_COMPLETION_IDS) + } else { + prefs[WidgetDataStoreKeys.PENDING_COMPLETION_IDS] = encoded + } + } + } + + suspend fun readLastRefreshTime(): Long = + store.data.first()[WidgetDataStoreKeys.LAST_REFRESH_TIME] ?: 0L + + suspend fun readTier(): String = + store.data.first()[WidgetDataStoreKeys.USER_TIER] ?: "free" + + suspend fun writeTier(tier: String) { + store.edit { prefs -> + prefs[WidgetDataStoreKeys.USER_TIER] = tier + } + } + + /** Remove every key owned by this store. Used on logout / test teardown. */ + suspend fun clearAll() { + store.edit { prefs -> + prefs.remove(WidgetDataStoreKeys.WIDGET_TASKS_JSON) + prefs.remove(WidgetDataStoreKeys.PENDING_COMPLETION_IDS) + prefs.remove(WidgetDataStoreKeys.LAST_REFRESH_TIME) + prefs.remove(WidgetDataStoreKeys.USER_TIER) + } + } +} diff --git a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetTaskDto.kt b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetTaskDto.kt new file mode 100644 index 0000000..832174d --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/widget/WidgetTaskDto.kt @@ -0,0 +1,49 @@ +package com.tt.honeyDue.widget + +import kotlinx.serialization.Serializable + +/** + * DTO persisted to the widget DataStore as JSON, mirroring iOS + * `WidgetDataManager.swift`'s on-disk task representation. + * + * iOS field map (for reference — keep in sync): + * - id Int (task id) + * - title String + * - priority Int (priority id) + * - dueDate String? ISO-8601 ("yyyy-MM-dd" or full datetime) + * - isOverdue Bool + * - daysUntilDue Int + * - residenceId Int + * - residenceName String + * - categoryIcon String SF-symbol-style identifier + * - completed Bool + * + * Kotlin uses [Long] for ids to accommodate any server-side auto-increment range. + */ +@Serializable +data class WidgetTaskDto( + val id: Long, + val title: String, + val priority: Long, + val dueDate: String?, + val isOverdue: Boolean, + val daysUntilDue: Int, + val residenceId: Long, + val residenceName: String, + val categoryIcon: String, + val completed: Boolean +) + +/** + * Summary metrics computed from the cached task list. + * + * Windows match iOS `calculateMetrics` semantics: + * - overdueCount tasks with isOverdue == true + * - dueWithin7 tasks with 0 <= daysUntilDue <= 7 + * - dueWithin8To30 tasks with 8 <= daysUntilDue <= 30 + */ +data class WidgetStats( + val overdueCount: Int, + val dueWithin7: Int, + val dueWithin8To30: Int +) diff --git a/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/widget/WidgetDataRepositoryTest.kt b/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/widget/WidgetDataRepositoryTest.kt new file mode 100644 index 0000000..2f288aa --- /dev/null +++ b/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/widget/WidgetDataRepositoryTest.kt @@ -0,0 +1,273 @@ +package com.tt.honeyDue.widget + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.Clock +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.TimeZone +import kotlinx.datetime.plus +import kotlinx.datetime.toLocalDateTime +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Unit tests for [WidgetDataRepository]. + * + * Mirrors iOS WidgetDataManager.swift semantics: + * - Save/load tasks as JSON + * - Pending-completion tracking (optimistic UI while server sync in flight) + * - Stats computation: overdueCount / dueWithin7 / dueWithin8To30 + * - Tier state persistence ("free" vs "premium") + */ +@RunWith(RobolectricTestRunner::class) +class WidgetDataRepositoryTest { + + private lateinit var context: Context + private lateinit var repo: WidgetDataRepository + + @Before + fun setUp() = runTest { + context = ApplicationProvider.getApplicationContext() + repo = WidgetDataRepository.get(context) + // Ensure a clean slate between tests — DataStore singletons persist per-process. + repo.clearAll() + } + + @After + fun tearDown() = runTest { + repo.clearAll() + } + + // ---------- Helpers ---------- + + /** Produce an ISO-8601 date string N days from now (at midnight local). */ + private fun isoDateDaysFromNow(days: Int): String { + val now = Clock.System.now() + val future = now.plus(days, DateTimeUnit.DAY, TimeZone.currentSystemDefault()) + val local = future.toLocalDateTime(TimeZone.currentSystemDefault()) + // YYYY-MM-DD + val month = local.monthNumber.toString().padStart(2, '0') + val day = local.dayOfMonth.toString().padStart(2, '0') + return "${local.year}-$month-$day" + } + + private fun task( + id: Long, + title: String = "Task $id", + dueInDays: Int? = null, + isOverdue: Boolean = false, + residenceId: Long = 1L, + residenceName: String = "Home", + categoryIcon: String = "house.fill", + priority: Long = 2L, + completed: Boolean = false + ): WidgetTaskDto { + val dueDate = dueInDays?.let { isoDateDaysFromNow(it) } + val daysUntilDue = dueInDays ?: 0 + return WidgetTaskDto( + id = id, + title = title, + priority = priority, + dueDate = dueDate, + isOverdue = isOverdue, + daysUntilDue = daysUntilDue, + residenceId = residenceId, + residenceName = residenceName, + categoryIcon = categoryIcon, + completed = completed + ) + } + + // ---------- Tests ---------- + + @Test + fun saveTasks_then_loadTasks_roundTrip() = runTest { + val input = listOf( + task(id = 1L, title = "Change air filter", dueInDays = 3), + task(id = 2L, title = "Clean gutters", dueInDays = 14), + task(id = 3L, title = "Check smoke detector", isOverdue = true, dueInDays = -2) + ) + + repo.saveTasks(input) + val output = repo.loadTasks() + + assertEquals(3, output.size) + val byId = output.associateBy { it.id } + assertEquals("Change air filter", byId[1L]?.title) + assertEquals("Clean gutters", byId[2L]?.title) + assertEquals("Check smoke detector", byId[3L]?.title) + assertEquals(true, byId[3L]?.isOverdue) + assertEquals(1L, byId[1L]?.residenceId) + assertEquals("Home", byId[1L]?.residenceName) + assertEquals("house.fill", byId[1L]?.categoryIcon) + assertNotNull(byId[1L]?.dueDate) + } + + @Test + fun empty_initial_state_returns_empty_list() = runTest { + val loaded = repo.loadTasks() + assertTrue("Expected empty list from fresh store", loaded.isEmpty()) + } + + @Test + fun markPendingCompletion_excludes_task_from_loadTasks() = runTest { + val input = listOf( + task(id = 10L, dueInDays = 2), + task(id = 20L, dueInDays = 5), + task(id = 30L, dueInDays = 9) + ) + repo.saveTasks(input) + + repo.markPendingCompletion(20L) + val loaded = repo.loadTasks() + + assertEquals(2, loaded.size) + assertFalse(loaded.any { it.id == 20L }) + assertTrue(loaded.any { it.id == 10L }) + assertTrue(loaded.any { it.id == 30L }) + } + + @Test + fun clearPendingCompletion_restores_task_to_loadTasks() = runTest { + val input = listOf( + task(id = 100L, dueInDays = 1), + task(id = 200L, dueInDays = 4) + ) + repo.saveTasks(input) + + repo.markPendingCompletion(200L) + assertEquals(1, repo.loadTasks().size) + + repo.clearPendingCompletion(200L) + val restored = repo.loadTasks() + assertEquals(2, restored.size) + assertTrue(restored.any { it.id == 200L }) + } + + @Test + fun computeStats_overdueCount() = runTest { + val input = listOf( + task(id = 1L, isOverdue = true, dueInDays = -1), + task(id = 2L, isOverdue = true, dueInDays = -5), + task(id = 3L, dueInDays = 3) + ) + repo.saveTasks(input) + + val stats = repo.computeStats() + assertEquals(2, stats.overdueCount) + } + + @Test + fun computeStats_dueWithin7() = runTest { + val input = listOf( + task(id = 1L, dueInDays = 1), + task(id = 2L, dueInDays = 3), + task(id = 3L, dueInDays = 6) + ) + repo.saveTasks(input) + + val stats = repo.computeStats() + assertEquals(3, stats.dueWithin7) + } + + @Test + fun computeStats_dueWithin8To30() = runTest { + val input = listOf( + task(id = 1L, dueInDays = 10), + task(id = 2L, dueInDays = 25) + ) + repo.saveTasks(input) + + val stats = repo.computeStats() + assertEquals(2, stats.dueWithin8To30) + } + + @Test + fun computeStats_boundaries() = runTest { + val input = listOf( + task(id = 1L, dueInDays = 7), // inclusive upper bound of 7-day window + task(id = 2L, dueInDays = 8), // inclusive lower bound of 8-30 window + task(id = 3L, dueInDays = 30), // inclusive upper bound of 8-30 window + task(id = 4L, dueInDays = 31) // outside both windows + ) + repo.saveTasks(input) + + val stats = repo.computeStats() + assertEquals("7-day task should count in dueWithin7", 1, stats.dueWithin7) + assertEquals("8-day and 30-day tasks should count in dueWithin8To30", 2, stats.dueWithin8To30) + assertEquals("No overdue tasks in this set", 0, stats.overdueCount) + } + + @Test + fun saveTierState_loadTierState_roundTrip() = runTest { + // Default should be "free". + assertEquals("free", repo.loadTierState()) + + repo.saveTierState("premium") + assertEquals("premium", repo.loadTierState()) + + repo.saveTierState("free") + assertEquals("free", repo.loadTierState()) + } + + @Test + fun overwrite_tasks_replaces_not_appends() = runTest { + val first = listOf( + task(id = 1L, dueInDays = 1), + task(id = 2L, dueInDays = 2), + task(id = 3L, dueInDays = 3) + ) + repo.saveTasks(first) + assertEquals(3, repo.loadTasks().size) + + val second = listOf( + task(id = 4L, dueInDays = 4), + task(id = 5L, dueInDays = 5) + ) + repo.saveTasks(second) + + val loaded = repo.loadTasks() + assertEquals(2, loaded.size) + assertFalse("Previous tasks should be gone after overwrite", loaded.any { it.id == 1L }) + assertTrue(loaded.any { it.id == 4L }) + assertTrue(loaded.any { it.id == 5L }) + } + + @Test + fun saveTasks_empty_list_clears_previous() = runTest { + repo.saveTasks(listOf(task(id = 1L, dueInDays = 1), task(id = 2L, dueInDays = 2))) + assertEquals(2, repo.loadTasks().size) + + repo.saveTasks(emptyList()) + assertTrue(repo.loadTasks().isEmpty()) + } + + @Test + fun multiple_pending_completions_all_excluded() = runTest { + val input = listOf( + task(id = 1L, dueInDays = 1), + task(id = 2L, dueInDays = 2), + task(id = 3L, dueInDays = 3), + task(id = 4L, dueInDays = 4) + ) + repo.saveTasks(input) + + repo.markPendingCompletion(2L) + repo.markPendingCompletion(4L) + + val loaded = repo.loadTasks() + assertEquals(2, loaded.size) + assertTrue(loaded.any { it.id == 1L }) + assertTrue(loaded.any { it.id == 3L }) + assertFalse(loaded.any { it.id == 2L }) + assertFalse(loaded.any { it.id == 4L }) + } +}