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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
Reference in New Issue
Block a user