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
)