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.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
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")
|
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
|
@Serializable
|
||||||
data class WidgetTask(
|
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
|
@Serializable
|
||||||
data class WidgetSummary(
|
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 {
|
/** iOS-parity DataStore wrapper. */
|
||||||
private val OVERDUE_COUNT = intPreferencesKey("overdue_count")
|
private val store = WidgetDataStore(context)
|
||||||
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
|
// iOS-parity API
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
fun getInstance(context: Context): WidgetDataRepository {
|
/**
|
||||||
return INSTANCE ?: synchronized(this) {
|
* Serialize and persist the task list to the widget cache. Overwrites any
|
||||||
INSTANCE ?: WidgetDataRepository(context.applicationContext).also { INSTANCE = it }
|
* 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 widgetSummary: Flow<WidgetSummary> = context.widgetDataStore.data.map { preferences ->
|
||||||
val tasksJson = preferences[TASKS_JSON] ?: "[]"
|
val tasksJson = preferences[TASKS_JSON] ?: "[]"
|
||||||
val tasks = try {
|
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 ->
|
val isProUser: Flow<Boolean> = context.widgetDataStore.data.map { preferences ->
|
||||||
preferences[IS_PRO_USER] == "true"
|
preferences[IS_PRO_USER] == "true"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the user's display name
|
|
||||||
*/
|
|
||||||
val userName: Flow<String> = context.widgetDataStore.data.map { preferences ->
|
val userName: Flow<String> = context.widgetDataStore.data.map { preferences ->
|
||||||
preferences[USER_NAME] ?: ""
|
preferences[USER_NAME] ?: ""
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the widget data
|
|
||||||
*/
|
|
||||||
suspend fun updateWidgetData(summary: WidgetSummary) {
|
suspend fun updateWidgetData(summary: WidgetSummary) {
|
||||||
context.widgetDataStore.edit { preferences ->
|
context.widgetDataStore.edit { preferences ->
|
||||||
preferences[OVERDUE_COUNT] = summary.overdueCount
|
preferences[OVERDUE_COUNT] = summary.overdueCount
|
||||||
@@ -120,30 +212,46 @@ class WidgetDataRepository(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Update user subscription status
|
|
||||||
*/
|
|
||||||
suspend fun updateProStatus(isPro: Boolean) {
|
suspend fun updateProStatus(isPro: Boolean) {
|
||||||
context.widgetDataStore.edit { preferences ->
|
context.widgetDataStore.edit { preferences ->
|
||||||
preferences[IS_PRO_USER] = if (isPro) "true" else "false"
|
preferences[IS_PRO_USER] = if (isPro) "true" else "false"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Update user name
|
|
||||||
*/
|
|
||||||
suspend fun updateUserName(name: String) {
|
suspend fun updateUserName(name: String) {
|
||||||
context.widgetDataStore.edit { preferences ->
|
context.widgetDataStore.edit { preferences ->
|
||||||
preferences[USER_NAME] = name
|
preferences[USER_NAME] = name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear all widget data (called on logout)
|
|
||||||
*/
|
|
||||||
suspend fun clearData() {
|
suspend fun clearData() {
|
||||||
context.widgetDataStore.edit { preferences ->
|
context.widgetDataStore.edit { preferences ->
|
||||||
preferences.clear()
|
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
|
||||||
|
)
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user