P3 Stream J: widget data repository (DataStore-backed)

Ports iOS WidgetDataManager.swift semantics. DTO + JSON serialization +
pending-completion tracking + stats (overdueCount / dueWithin7 / dueWithin8To30).
Same-process DataStore is sufficient for Glance widgets.

Unblocks Streams K (widgets) / L (scheduler) / M (actions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-18 12:40:48 -05:00
parent 7aab8b0f29
commit 6d7b5ee990
4 changed files with 566 additions and 41 deletions

View File

@@ -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<Preferences> 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<WidgetTaskDto>) {
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<WidgetTaskDto> {
val raw = store.readTasksJson()
val all = try {
json.decodeFromString<List<WidgetTaskDto>>(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<WidgetSummary> = 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<Boolean> = context.widgetDataStore.data.map { preferences ->
preferences[IS_PRO_USER] == "true"
}
/**
* Get the user's display name
*/
val userName: Flow<String> = 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)
}
}

View File

@@ -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<WidgetTaskDto>
* - 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<Preferences> 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<Long> {
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<Long>) {
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)
}
}
}

View File

@@ -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
)

View File

@@ -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 })
}
}