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