From 2488dbdf6a191555e0d83170a02db8875e2b0532 Mon Sep 17 00:00:00 2001 From: Mohannad El-Sayeh Date: Mon, 10 Aug 2026 08:24:24 +0300 Subject: [PATCH 1/7] AWAN-149: show local data first and never keep deleted data Zones edited in profile never reached Home until the day changed. Two independent causes, both instances of a wider gap: AWAN-205 declared Room the single read model but only carried it through for sessions and the profile. - Add ZoneDao.observeEffectiveZonesForDate: one query whose subqueries span zones, template_overrides and template_days_of_week, so Room re-emits on any of them. Home's schedule now combines it with the sessions flow instead of resolving zones with suspend one-shots inside the map, which Room only invalidated for the sessions table - Add ZonesLocalDataSource as the only writer of the four zone tables; its replaceAll deletes templates and overrides first, so zones and day assignments removed on another device cannot survive a refresh - Refresh the zone model into Room after every one of the 12 zone mutations, which is also what makes the three delete mutations correct - Fail syncZonesAndTemplates when either GET fails instead of reporting success unconditionally and writing half a replace - Refresh zones and the visible day's schedule on Home open and on date change, forced past the TTL; days outside the background week were never fetched at all before - Write through deleteSession, deleteTask, updateTaskDetails and updateSessionLock into Room, and add the connectivity guard all four lacked - Clear every cached table on logout and when a different account signs in; Room is keyed by server ids alone, so the previous account's rows read back as the new one's and the backend answers 404 for ids it does not own - Restore the zone categoryId wiring that the AWAN-205 merge dropped from OnboardingRepositoryImpl and ZonesRepositoryImpl, and un-weaken the guard test that was edited to pass against the broken version - Refill categories from the network when Room is empty; onboarding needs them before SyncWorker has run, and skipping the setup used to outrun the fetch - Decode v1/goals/inbox into bare tasks instead of task/session pairs - Document the contract in CLAUDE.md with a three-grep review checklist, and record the traps in the deferred tables in docs/feature/offline-first/ --- CLAUDE.md | 42 +++- .../app/core/data/auth/LocalDataCleaner.kt | 28 +++ .../auth/repository/AuthRepositoryImpl.kt | 14 ++ .../data/category/CategoryRepositoryImpl.kt | 15 +- .../com/awan/app/core/data/di/DataModule.kt | 24 +++ .../home/repository/HomeRepositoryImpl.kt | 112 +++++++---- .../onboarding/OnboardingRepositoryImpl.kt | 107 +++++----- .../core/data/sync/OfflineSyncCoordinator.kt | 102 ++-------- .../core/data/sync/ScheduleSynchronizer.kt | 19 ++ .../task/remote/TaskRemoteDataSourceImpl.kt | 2 +- .../data/zones/local/ZonesLocalDataSource.kt | 91 +++++++++ .../zones/repository/ZonesRepositoryImpl.kt | 88 ++++---- .../core/data/auth/AuthRepositoryImplTest.kt | 13 ++ .../core/data/home/HomeRepositoryImplTest.kt | 86 +++++++- .../OnboardingRepositoryImplTest.kt | 14 +- .../data/sync/OfflineSyncCoordinatorTest.kt | 75 ++++++- .../task/remote/TaskRemoteDataSourceTest.kt | 38 ++++ .../template/TemplateRepositoryImplTest.kt | 2 + .../data/zones/ZonesRepositoryImplTest.kt | 189 ++++++++++++++++++ .../core/data/zones/mapper/ZonesMapperTest.kt | 25 +-- .../app/core/database/dao/TemplateDaoTest.kt | 41 ++++ .../awan/app/core/database/dao/ZoneDaoTest.kt | 102 ++++++++++ .../awan/app/core/database/dao/TemplateDao.kt | 7 + .../core/database/dao/TemplateOverrideDao.kt | 4 + .../com/awan/app/core/database/dao/ZoneDao.kt | 26 +++ .../domain/home/repository/HomeRepository.kt | 7 + .../home/usecase/RefreshDayScheduleUseCase.kt | 12 ++ .../zones/repository/ZonesRepository.kt | 7 + .../zones/usecase/RefreshZonesUseCase.kt | 11 + .../app/core/network/api/TaskApiService.kt | 2 +- .../network/dto/task/InboxTasksResponse.kt | 7 +- .../awan/app/core/network/dto/zone/ZoneDto.kt | 3 +- .../2026-08-09-replace-on-refresh.md | 134 +++++++++++++ .../2026-08-05-required-zone-category.md | 11 + .../presentation/AddTaskViewModelTest.kt | 2 + .../feature/home/impl/ui/HomeViewModel.kt | 15 ++ .../impl/presentation/OnboardingViewModel.kt | 22 +- .../presentation/FakeCategoryRepository.kt | 13 +- .../presentation/OnboardingViewModelTest.kt | 25 +++ 39 files changed, 1260 insertions(+), 277 deletions(-) create mode 100644 core/data/src/main/kotlin/com/awan/app/core/data/auth/LocalDataCleaner.kt create mode 100644 core/data/src/main/kotlin/com/awan/app/core/data/sync/ScheduleSynchronizer.kt create mode 100644 core/data/src/main/kotlin/com/awan/app/core/data/zones/local/ZonesLocalDataSource.kt create mode 100644 core/data/src/test/java/com/awan/app/core/data/zones/ZonesRepositoryImplTest.kt create mode 100644 core/domain/src/main/kotlin/com/awan/app/core/domain/home/usecase/RefreshDayScheduleUseCase.kt create mode 100644 core/domain/src/main/kotlin/com/awan/app/core/domain/zones/usecase/RefreshZonesUseCase.kt create mode 100644 docs/feature/offline-first/2026-08-09-replace-on-refresh.md diff --git a/CLAUDE.md b/CLAUDE.md index 34129b15..9a8910ee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,13 +57,41 @@ Dependency direction is always `presentation → domain ← data`. Domain depend - Hilt DI throughout; UDF ViewModels exposing `StateFlow` of sealed UI state. - Packages: `com.awan.app` (app), `com.awan.app.core.*` (core), `com.awan.feature.*` (features). -### Offline-first — Room is the single source of truth - -Built. Repositories return `Flow` from DAOs and the UI observes that; the network only ever refills Room. - -- **A write that doesn't land in Room is invisible.** After a successful remote call, mirror the response into the DAO — the screen is watching Room, not the call. Returning `Result.Success` without upserting leaves the UI on stale data until something forces a refresh. -- **Gate writes on `NetworkConnectivityMonitor.isCurrentlyOnline()`** and return `AppError.Network` when offline, instead of letting the call fail deep in the stack. -- Freshness is per-row: entities carry `expiryTime`, filled from `SyncTtl` (schedule 15 min, goals 30 min, profile/categories/templates 1 h). Background refresh runs through `SyncWorker` (WorkManager) driven by `OfflineSyncCoordinator`. +### Offline-first contract — non-negotiable + +Room is the single read model. Show local data first, then refresh it from the network when online. +Every repository holding server-backed product data uses exactly these four shapes: + +| Shape | Signature | Rules | +|---|---|---| +| **Observe** | `fun observeX(...): Flow` | Room only. Never the network, never a connectivity check. **Never call a `suspend` DAO method inside `map`** — a Flow re-emits only for the tables its own queries touch, so a one-shot read inside `map` silently goes stale. Span tables with one `@Query` (subqueries count) or `combine()` of DAO Flows. | +| **One-shot read** | `suspend fun getX(...): Result` | Only where observing is impossible. Online → remote → write local → read Room; offline → read Room; Room empty → `Result.Error`. Reference: `ProfileRepositoryImpl.getProfile()`. | +| **Refresh** | `suspend fun refreshX(...): Result` | Connectivity check first. If **any** GET fails, return `Result.Error` and write nothing — a partial refresh must never reach Room. On success, exactly one call to `replaceX(scope, items)`. | +| **Mutate** | `suspend fun createX/updateX/deleteX(...): Result` | Connectivity check first, returning `AppError.Network`. On success, exactly one local write — write-through of the response, or `refreshX()`. **A mutation must never return `Result.Success` without a local write.** | + +- **Every refresh replaces; it never merges.** `replaceX(scope, items)` deletes the rows in scope and + inserts the response, in one transaction. An upsert-only refresh is a bug: it can never remove what + another device deleted, and the user sees a record that no longer exists. +- **Scope must be provable from the endpoint.** A complete list (`GET v1/templates`) is authoritative + for the whole table. A date-ranged response is authoritative for those dates only. **A paginated or + filtered response is authoritative for nothing** — never delete from one. `listGoals` is page 0 and + excludes the Inbox goal; replacing from it deletes the user's Inbox. +- **All Room writes for a feature live in one `LocalDataSource`** in `core/data//local/`. + Repositories and `OfflineSyncCoordinator` call it; nothing else writes those tables. Reads may use + DAO Flows directly. One writer per table group is what makes "no deleted data survives" structural. +- **No offline write queue.** Writes require connectivity; reads work fully offline. +- Refresh runs on screen open and after every write, and both bypass the TTL. Only `SyncWorker` + honours it. Freshness is per-row: entities carry `expiryTime`, stamped **only inside `replaceX`**, + from `SyncTtl` (schedule 15 min, goals 30 min, profile/categories/templates 1 h). Background + refresh runs through `SyncWorker` (WorkManager) driven by `OfflineSyncCoordinator`. +- A refresh failure never blanks the screen — cached data stays, the error is surfaced beside it. + +**Reviewing this — three greps:** a DAO injected into a repository that isn't its own local data +source; any `upsert` in `OfflineSyncCoordinator` (must be zero — it may only call `replace*`); any +mutation returning the remote `Result` without a local write. + +Current state and the traps in the not-yet-migrated tables (categories' FK, goals' pagination): +`docs/feature/offline-first/2026-08-09-replace-on-refresh.md`. ### Room migrations — silent data loss if skipped diff --git a/core/data/src/main/kotlin/com/awan/app/core/data/auth/LocalDataCleaner.kt b/core/data/src/main/kotlin/com/awan/app/core/data/auth/LocalDataCleaner.kt new file mode 100644 index 00000000..c3ac9b25 --- /dev/null +++ b/core/data/src/main/kotlin/com/awan/app/core/data/auth/LocalDataCleaner.kt @@ -0,0 +1,28 @@ +package com.awan.app.core.data.auth + +import com.awan.app.core.common.dispatcher.AwanDispatchers +import com.awan.app.core.common.dispatcher.Dispatcher +import com.awan.app.core.database.AwanDatabase +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Drops every cached row. Room is keyed by server ids alone, so whatever the last account cached + * reads back as the next one's data — the backend then answers 404 for ids it does not own. + * + * An interface only so the auth tests can assert it was called: `AwanDatabase` is an abstract Room + * class that a JVM unit test cannot construct. + */ +interface LocalDataCleaner { + suspend fun clearAll() +} + +@Singleton +class RoomLocalDataCleaner @Inject constructor( + private val database: AwanDatabase, + @Dispatcher(AwanDispatchers.IO) private val ioDispatcher: CoroutineDispatcher, +) : LocalDataCleaner { + override suspend fun clearAll() = withContext(ioDispatcher) { database.clearAllTables() } +} diff --git a/core/data/src/main/kotlin/com/awan/app/core/data/auth/repository/AuthRepositoryImpl.kt b/core/data/src/main/kotlin/com/awan/app/core/data/auth/repository/AuthRepositoryImpl.kt index f56b1c22..5cf09f0d 100644 --- a/core/data/src/main/kotlin/com/awan/app/core/data/auth/repository/AuthRepositoryImpl.kt +++ b/core/data/src/main/kotlin/com/awan/app/core/data/auth/repository/AuthRepositoryImpl.kt @@ -3,6 +3,7 @@ package com.awan.app.core.data.auth.repository import com.awan.app.core.common.error.AppError import com.awan.app.core.common.result.Result import com.awan.app.core.data.auth.remote.AuthRemoteDataSource +import com.awan.app.core.data.auth.LocalDataCleaner import com.awan.app.core.datastore.auth.AuthTokenProvider import com.awan.app.core.domain.auth.model.AuthSession import com.awan.app.core.domain.auth.model.User @@ -20,6 +21,7 @@ class AuthRepositoryImpl @Inject constructor( private val remoteDataSource: AuthRemoteDataSource, private val authTokenProvider: AuthTokenProvider, private val deviceIdProvider: DeviceIdProvider, + private val localDataCleaner: LocalDataCleaner, ) : AuthRepository { override suspend fun requestOtp(email: String): Result = @@ -35,6 +37,14 @@ class AuthRepositoryImpl @Inject constructor( ) if (result is Result.Success) { + // An expired session is cleared by TokenAuthenticator, which cannot reach the database — + // so sign-in is the second place the cache's owner is knowable. Anything but the same + // user signing back in inherits rows the new account does not own. + val previousUserId = authTokenProvider.getUserId() + if (previousUserId != result.data.user?.id) { + localDataCleaner.clearAll() + } + authTokenProvider.saveTokens( accessToken = result.data.accessToken, refreshToken = result.data.refreshToken, @@ -125,6 +135,10 @@ class AuthRepositoryImpl @Inject constructor( } authTokenProvider.clearTokens() + // Room is keyed by nothing but the row id, so whatever the last account cached reads back as + // the next one's data — and the backend answers 404 CATEGORY_NOT_FOUND for an id it does not + // own. Sign-out is the only point where "this data belongs to someone else" is knowable. + localDataCleaner.clearAll() return Result.Success(Unit) } diff --git a/core/data/src/main/kotlin/com/awan/app/core/data/category/CategoryRepositoryImpl.kt b/core/data/src/main/kotlin/com/awan/app/core/data/category/CategoryRepositoryImpl.kt index 03c17c71..aefaad40 100644 --- a/core/data/src/main/kotlin/com/awan/app/core/data/category/CategoryRepositoryImpl.kt +++ b/core/data/src/main/kotlin/com/awan/app/core/data/category/CategoryRepositoryImpl.kt @@ -29,8 +29,19 @@ class CategoryRepositoryImpl @Inject constructor( ) : CategoryRepository { override suspend fun getCategories(): Result> { - val entities = categoryDao.getAllCategories() - return Result.Success(entities.map { it.toModel() }) + val cached = categoryDao.getAllCategories() + // Room is empty until SyncWorker lands, and onboarding needs the list before that: a zone + // sent without a categoryId is rejected, so an empty table there costs the user their zones. + // The network still only refills Room — the read below is what the caller gets. + if (cached.isEmpty() && connectivityMonitor.isCurrentlyOnline()) { + return safeApiCall { + val entities = categoryApiService.getCategories() + .map { CategoryEntity(id = it.id, name = it.name) } + categoryDao.upsertCategories(entities) + entities.map { it.toModel() } + } + } + return Result.Success(cached.map { it.toModel() }) } override suspend fun createCategory(name: String): Result { diff --git a/core/data/src/main/kotlin/com/awan/app/core/data/di/DataModule.kt b/core/data/src/main/kotlin/com/awan/app/core/data/di/DataModule.kt index 801478e8..30918f93 100644 --- a/core/data/src/main/kotlin/com/awan/app/core/data/di/DataModule.kt +++ b/core/data/src/main/kotlin/com/awan/app/core/data/di/DataModule.kt @@ -6,6 +6,12 @@ import com.awan.app.core.data.auth.repository.AuthRepositoryImpl import com.awan.app.core.data.calendar.CalendarRepositoryImpl import com.awan.app.core.data.calendar.local.CalendarLocalDataSource import com.awan.app.core.data.calendar.local.CalendarLocalDataSourceImpl +import com.awan.app.core.data.zones.local.ZonesLocalDataSource +import com.awan.app.core.data.zones.local.ZonesLocalDataSourceImpl +import com.awan.app.core.data.auth.LocalDataCleaner +import com.awan.app.core.data.auth.RoomLocalDataCleaner +import com.awan.app.core.data.sync.OfflineSyncCoordinator +import com.awan.app.core.data.sync.ScheduleSynchronizer import com.awan.app.core.data.calendar.remote.CalendarRemoteDataSource import com.awan.app.core.data.calendar.remote.CalendarRemoteDataSourceImpl import com.awan.app.core.data.category.CategoryRepositoryImpl @@ -80,6 +86,24 @@ internal abstract class DataModule { impl: CalendarLocalDataSourceImpl, ): CalendarLocalDataSource + @Binds + @Singleton + abstract fun bindZonesLocalDataSource( + impl: ZonesLocalDataSourceImpl, + ): ZonesLocalDataSource + + @Binds + @Singleton + abstract fun bindScheduleSynchronizer( + impl: OfflineSyncCoordinator, + ): ScheduleSynchronizer + + @Binds + @Singleton + abstract fun bindLocalDataCleaner( + impl: RoomLocalDataCleaner, + ): LocalDataCleaner + @Binds @Singleton abstract fun bindCalendarRepository( diff --git a/core/data/src/main/kotlin/com/awan/app/core/data/home/repository/HomeRepositoryImpl.kt b/core/data/src/main/kotlin/com/awan/app/core/data/home/repository/HomeRepositoryImpl.kt index 90251285..84d6260f 100644 --- a/core/data/src/main/kotlin/com/awan/app/core/data/home/repository/HomeRepositoryImpl.kt +++ b/core/data/src/main/kotlin/com/awan/app/core/data/home/repository/HomeRepositoryImpl.kt @@ -9,12 +9,12 @@ import com.awan.app.core.data.gamification.mapper.toDomain import com.awan.app.core.database.dao.CategoryDao import com.awan.app.core.database.dao.SessionDao import com.awan.app.core.database.dao.TaskDao -import com.awan.app.core.database.dao.TemplateDao -import com.awan.app.core.database.dao.TemplateOverrideDao import com.awan.app.core.database.dao.UserDao import com.awan.app.core.database.dao.ZoneDao import com.awan.app.core.database.model.UserEntity +import com.awan.app.core.database.model.ZoneEntity import com.awan.app.core.data.home.remote.HomeRemoteDataSource +import com.awan.app.core.data.sync.ScheduleSynchronizer import com.awan.app.core.network.dto.session.SessionDto import com.awan.app.core.domain.gamification.model.SessionReward import com.awan.app.core.domain.home.model.DaySchedule @@ -26,6 +26,7 @@ import com.awan.app.core.domain.home.repository.HomeRepository import com.awan.app.core.domain.network.NetworkConnectivityMonitor import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map @@ -48,9 +49,8 @@ class HomeRepositoryImpl @Inject constructor( private val taskDao: TaskDao, private val sessionDao: SessionDao, private val zoneDao: ZoneDao, - private val templateDao: TemplateDao, - private val templateOverrideDao: TemplateOverrideDao, private val categoryDao: CategoryDao, + private val scheduleSynchronizer: ScheduleSynchronizer, private val connectivityMonitor: NetworkConnectivityMonitor, @Dispatcher(AwanDispatchers.IO) private val ioDispatcher: CoroutineDispatcher, ) : HomeRepository { @@ -97,11 +97,28 @@ class HomeRepositoryImpl @Inject constructor( } } + override suspend fun refreshSchedule(date: LocalDate): Result = withContext(ioDispatcher) { + checkOnline()?.let { return@withContext it } + // Forced: a screen open must not be answered from a TTL window the user cannot see. + if (scheduleSynchronizer.syncScheduleRange(date, date, forceRefresh = true)) { + Result.Success(Unit) + } else { + Result.Error(AppError.Network) + } + } + + /** + * Both halves are Room Flows so the timeline reacts to either changing. Resolving zones with a + * suspend lookup inside the map — as this did — produces a Flow that Room only invalidates for + * the `sessions` table, so a zone edit stayed invisible until a day change built a new Flow. + */ override fun getDaySchedule(date: LocalDate): Flow> { val dateStr = date.toString() - return sessionDao.observeSessionsForDate(dateStr) - .map { sessionEntities -> + return combine( + sessionDao.observeSessionsForDate(dateStr), + zoneDao.observeEffectiveZonesForDate(dateStr, date.dayOfWeek.name), + ) { sessionEntities, zoneEntities -> val daySessions = sessionEntities.mapNotNull { s -> val task = taskDao.getTask(s.taskId) ?: return@mapNotNull null val category = task.categoryId?.let { categoryDao.getCategory(it) } @@ -131,7 +148,7 @@ class HomeRepositoryImpl @Inject constructor( Result.Success( DaySchedule( date = date, - zones = resolveZonesForDate(dateStr, date), + zones = zoneEntities.map(::toDayZone), sessions = daySessions, ) ) @@ -284,48 +301,35 @@ class HomeRepositoryImpl @Inject constructor( } } - private suspend fun resolveZonesForDate(dateStr: String, date: LocalDate): List { - val override = templateOverrideDao.getOverrideForDate(dateStr) - val zones = if (override != null) { - zoneDao.observeZonesForOverride(override.id).first() - } else { - val dayOfWeek = date.dayOfWeek.name.uppercase() - val templateAssignment = templateDao.getDayAssignment(dayOfWeek) - if (templateAssignment != null) { - zoneDao.observeZonesForTemplate(templateAssignment.templateId).first() - } else { - emptyList() - } - } - - return zones.map { entity -> - val startLocalTime = parseLocalTime(entity.startTime) - val endLocalTime = parseLocalTime(entity.endTime) - val startMinutes = startLocalTime.hour * 60 + startLocalTime.minute - val endMinutes = endLocalTime.hour * 60 + endLocalTime.minute - DayZone( - id = entity.id, - name = entity.name, - categoryId = entity.id, - categoryName = entity.name, - startMinutes = startMinutes, - endMinutes = endMinutes, - color = entity.color - ) - } + private fun toDayZone(entity: ZoneEntity): DayZone { + val startLocalTime = parseLocalTime(entity.startTime) + val endLocalTime = parseLocalTime(entity.endTime) + return DayZone( + id = entity.id, + name = entity.name, + categoryId = entity.id, + categoryName = entity.name, + startMinutes = startLocalTime.hour * 60 + startLocalTime.minute, + endMinutes = endLocalTime.hour * 60 + endLocalTime.minute, + color = entity.color + ) } override suspend fun updateSessionLock( sessionId: String, locked: Boolean, ): Result { + checkOnline()?.let { return it } val result = if (locked) { remoteDataSource.lockSession(sessionId) } else { remoteDataSource.unlockSession(sessionId) } return when (result) { - is Result.Success -> Result.Success(Unit) + is Result.Success -> { + cacheSession(result.data) + Result.Success(Unit) + } is Result.Error -> Result.Error(result.error) else -> Result.Error(AppError.Unknown(Throwable("Failed to update session lock state"))) } @@ -350,30 +354,58 @@ class HomeRepositoryImpl @Inject constructor( ) val result = remoteDataSource.updateTask(taskId, request) return when (result) { - is Result.Success -> Result.Success(Unit) + is Result.Success -> { + val dto = result.data + taskDao.getTask(taskId)?.let { existing -> + taskDao.upsertTask( + existing.copy( + title = dto.title, + description = dto.description ?: existing.description, + estimatedDuration = dto.estimatedDuration ?: existing.estimatedDuration, + estimatedPoints = dto.estimatedPoints ?: existing.estimatedPoints, + mandatory = dto.mandatory ?: existing.mandatory, + allowTaskSplitting = dto.allowTaskSplitting ?: existing.allowTaskSplitting, + ) + ) + } + Result.Success(Unit) + } is Result.Error -> Result.Error(result.error) else -> Result.Error(AppError.Unknown(Throwable("Failed to update task details"))) } } override suspend fun deleteSession(sessionId: String): Result { + checkOnline()?.let { return it } val result = remoteDataSource.deleteSession(sessionId) return when (result) { - is Result.Success -> Result.Success(Unit) + is Result.Success -> { + sessionDao.deleteSession(sessionId) + Result.Success(Unit) + } is Result.Error -> Result.Error(result.error) else -> Result.Error(AppError.Unknown(Throwable("Failed to delete session"))) } } override suspend fun deleteTask(taskId: String): Result { + checkOnline()?.let { return it } val result = remoteDataSource.deleteTask(taskId, cascade = true) return when (result) { - is Result.Success -> Result.Success(Unit) + is Result.Success -> { + // Sessions CASCADE from tasks; dependencies do not carry the task's own row away. + taskDao.deleteAllDependenciesForTask(taskId) + taskDao.deleteTask(taskId) + Result.Success(Unit) + } is Result.Error -> Result.Error(result.error) else -> Result.Error(AppError.Unknown(Throwable("Failed to delete task"))) } } + private fun checkOnline(): Result? = + if (connectivityMonitor.isCurrentlyOnline()) null else Result.Error(AppError.Network) + private fun unexpectedLoading(): Result.Error = Result.Error(AppError.Unknown(Throwable("Session call returned Loading"))) } diff --git a/core/data/src/main/kotlin/com/awan/app/core/data/onboarding/OnboardingRepositoryImpl.kt b/core/data/src/main/kotlin/com/awan/app/core/data/onboarding/OnboardingRepositoryImpl.kt index 2606ff52..3ddc992f 100644 --- a/core/data/src/main/kotlin/com/awan/app/core/data/onboarding/OnboardingRepositoryImpl.kt +++ b/core/data/src/main/kotlin/com/awan/app/core/data/onboarding/OnboardingRepositoryImpl.kt @@ -10,6 +10,7 @@ import com.awan.app.core.domain.zones.repository.ZonesRepository import com.awan.app.core.domain.onboarding.model.DayBounds import com.awan.app.core.domain.zones.model.DailyZone import com.awan.app.core.domain.zones.model.DayOfWeek +import com.awan.app.core.domain.zones.model.Zone import com.awan.app.core.network.dto.onboarding.CompleteOnboardingRequest import com.awan.app.core.domain.onboarding.model.OnboardingData import com.awan.app.core.domain.onboarding.repository.OnboardingRepository @@ -56,36 +57,7 @@ class OnboardingRepositoryImpl @Inject constructor( when (val result = remoteDataSource.completeOnboarding(request)) { is Result.Success -> { - val dailyZones = data.zones.filter { it.isEnabled }.map { zone -> - DailyZone( - id = null, - name = zone.name, - startTime = formatMinutesToTimeShort(zone.startMinutes), - endTime = formatMinutesToTimeShort(zone.endMinutes), - color = String.format("#%06X", 0xFFFFFF and zone.colorArgb) - ) - } - - if (dailyZones.isNotEmpty()) { - val templatesResult = zonesRepository.getTemplates() - val existingDefault = if (templatesResult is Result.Success) { - templatesResult.data.find { it.name.equals("Default", ignoreCase = true) } - } else null - - val zoneSaveResult = if (existingDefault != null) { - zonesRepository.updateTemplateZones(existingDefault.id, dailyZones) - } else { - zonesRepository.createTemplate( - name = "Default", - daysOfWeek = DayOfWeek.entries, - zones = dailyZones - ) - } - - if (zoneSaveResult is Result.Error) { - return@withContext Result.Error(zoneSaveResult.error) - } - } + saveDefaultTemplate(data.zones)?.let { return@withContext Result.Error(it) } userPreferencesDataSource.setOnboardingCompleted(true) val response = result.data @@ -106,34 +78,11 @@ class OnboardingRepositoryImpl @Inject constructor( Result.Success(Unit) } // An already-onboarded user is not a failure: swallow it as a success so they reach Home - // instead of being stranded in onboarding with an error they cannot act on. + // instead of being stranded in onboarding with an error they cannot act on. The template + // is still written here — this is the path every retry takes once the account exists, so + // skipping it would drop the zones of anyone whose first attempt failed on the template. is Result.Error -> if (result.error.isAlreadyOnboarded()) { - val dailyZones = data.zones.filter { it.isEnabled }.map { zone -> - DailyZone( - id = null, - name = zone.name, - startTime = formatMinutesToTimeShort(zone.startMinutes), - endTime = formatMinutesToTimeShort(zone.endMinutes), - color = String.format("#%06X", 0xFFFFFF and zone.colorArgb) - ) - } - - if (dailyZones.isNotEmpty()) { - val templatesResult = zonesRepository.getTemplates() - val existingDefault = if (templatesResult is Result.Success) { - templatesResult.data.find { it.name.equals("Default", ignoreCase = true) } - } else null - - if (existingDefault != null) { - zonesRepository.updateTemplateZones(existingDefault.id, dailyZones) - } else { - zonesRepository.createTemplate( - name = "Default", - daysOfWeek = DayOfWeek.entries, - zones = dailyZones - ) - } - } + saveDefaultTemplate(data.zones)?.let { return@withContext Result.Error(it) } userPreferencesDataSource.setOnboardingCompleted(true) Result.Success(Unit) @@ -144,6 +93,49 @@ class OnboardingRepositoryImpl @Inject constructor( } } + /** + * Writes the onboarding zones as the user's default weekly template, returning the failure that + * stopped it or null when there was nothing to fail. A zone without a category is rejected by the + * backend, so those are dropped rather than sending a request that is certain to fail — an + * account with no categories simply gets no template, which is not an error. + * + * A real failure is reported rather than swallowed: nothing re-enters onboarding once it is left, + * so a dropped template would cost the user their whole zone setup with no way to redo it. + */ + private suspend fun saveDefaultTemplate(zones: List): AppError? { + val dailyZones = zones + .filter { it.isEnabled && it.categoryId != null } + .map { zone -> + DailyZone( + id = null, + name = zone.name, + startTime = formatMinutesToTimeShort(zone.startMinutes), + endTime = formatMinutesToTimeShort(zone.endMinutes), + color = String.format("#%06X", 0xFFFFFF and zone.colorArgb), + categoryId = zone.categoryId, + ) + } + if (dailyZones.isEmpty()) return null + + // Creating on an unread template list would leave the account with two "Default" templates. + val templates = zonesRepository.getTemplates() + if (templates is Result.Error) return templates.error + val existingDefault = (templates as? Result.Success) + ?.data + ?.find { it.name.equals(DEFAULT_TEMPLATE_NAME, ignoreCase = true) } + + val write = if (existingDefault != null) { + zonesRepository.updateTemplateZones(existingDefault.id, dailyZones) + } else { + zonesRepository.createTemplate( + name = DEFAULT_TEMPLATE_NAME, + daysOfWeek = DayOfWeek.entries, + zones = dailyZones, + ) + } + return (write as? Result.Error)?.error + } + private fun formatMinutesToTime(minutes: Int): String { if (minutes >= DayBounds.MINUTES_PER_DAY) return "23:59:59" val totalMinutes = minutes.mod(DayBounds.MINUTES_PER_DAY) @@ -174,6 +166,7 @@ class OnboardingRepositoryImpl @Inject constructor( } private companion object { + const val DEFAULT_TEMPLATE_NAME = "Default" const val HTTP_CONFLICT = 409 const val ONBOARDING_ALREADY_COMPLETED = "ONBOARDING_ALREADY_COMPLETED" } diff --git a/core/data/src/main/kotlin/com/awan/app/core/data/sync/OfflineSyncCoordinator.kt b/core/data/src/main/kotlin/com/awan/app/core/data/sync/OfflineSyncCoordinator.kt index 8e2d1400..a1e6f92e 100644 --- a/core/data/src/main/kotlin/com/awan/app/core/data/sync/OfflineSyncCoordinator.kt +++ b/core/data/src/main/kotlin/com/awan/app/core/data/sync/OfflineSyncCoordinator.kt @@ -9,25 +9,20 @@ import com.awan.app.core.database.dao.GoalDao import com.awan.app.core.database.dao.SessionDao import com.awan.app.core.database.dao.TaskDao import com.awan.app.core.database.dao.TemplateDao -import com.awan.app.core.database.dao.TemplateOverrideDao import com.awan.app.core.database.dao.UserDao -import com.awan.app.core.database.dao.ZoneDao import com.awan.app.core.database.model.CachedScheduleDateEntity import com.awan.app.core.database.model.CategoryEntity import com.awan.app.core.database.model.GoalEntity import com.awan.app.core.database.model.SessionEntity import com.awan.app.core.database.model.TaskEntity -import com.awan.app.core.database.model.TemplateDayOfWeekEntity -import com.awan.app.core.database.model.TemplateEntity -import com.awan.app.core.database.model.TemplateOverrideEntity import com.awan.app.core.database.model.UserEntity import com.awan.app.core.database.model.UserPreferencesEntity -import com.awan.app.core.database.model.ZoneEntity import com.awan.app.core.data.category.remote.CategoryRemoteDataSource import com.awan.app.core.data.goal.remote.GoalRemoteDataSource import com.awan.app.core.data.profile.remote.ProfileRemoteDataSource import com.awan.app.core.data.task.remote.TaskRemoteDataSource import com.awan.app.core.data.task.toEntity +import com.awan.app.core.data.zones.local.ZonesLocalDataSource import com.awan.app.core.data.zones.remote.ZonesRemoteDataSource import com.awan.app.core.domain.network.NetworkConnectivityMonitor import kotlinx.coroutines.CoroutineDispatcher @@ -51,13 +46,12 @@ class OfflineSyncCoordinator @Inject constructor( private val sessionDao: SessionDao, private val goalDao: GoalDao, private val userDao: UserDao, - private val zoneDao: ZoneDao, private val templateDao: TemplateDao, - private val templateOverrideDao: TemplateOverrideDao, + private val zonesLocalDataSource: ZonesLocalDataSource, private val cachedScheduleDateDao: CachedScheduleDateDao, private val connectivityMonitor: NetworkConnectivityMonitor, @Dispatcher(AwanDispatchers.IO) private val ioDispatcher: CoroutineDispatcher, -) { +) : ScheduleSynchronizer { suspend fun syncAll( startDate: LocalDate = LocalDate.now(), endDate: LocalDate = startDate.plusDays(6), @@ -77,10 +71,10 @@ class OfflineSyncCoordinator @Inject constructor( success } - suspend fun syncScheduleRange( + override suspend fun syncScheduleRange( startDate: LocalDate, endDate: LocalDate, - forceRefresh: Boolean = false + forceRefresh: Boolean ): Boolean = withContext(ioDispatcher) { if (!connectivityMonitor.isCurrentlyOnline()) return@withContext false @@ -271,81 +265,17 @@ class OfflineSyncCoordinator @Inject constructor( } } - val expiry = SyncTtl.computeExpiry(SyncTtl.TEMPLATES_TTL_MS) - - val tplRes = zonesRemoteDataSource.getTemplates() - if (tplRes is Result.Success) { - val tplEntities = tplRes.data.map { - TemplateEntity( - id = it.id, - name = it.name, - expiryTime = expiry - ) - } - templateDao.upsertTemplates(tplEntities) - - val templateZones = mutableListOf() - val daysOfWeek = mutableListOf() - for (tpl in tplRes.data) { - for (day in tpl.daysOfWeek) { - daysOfWeek.add(TemplateDayOfWeekEntity(dayOfWeek = day, templateId = tpl.id)) - } - for (z in tpl.zones) { - z.id?.let { zoneId -> - templateZones.add( - ZoneEntity( - id = zoneId, - name = z.name, - startTime = z.startTime, - endTime = z.endTime, - color = z.color, - templateId = tpl.id, - templateOverrideId = null, - ) - ) - } - } - } - if (daysOfWeek.isNotEmpty()) { - templateDao.upsertDays(daysOfWeek) - } - if (templateZones.isNotEmpty()) { - zoneDao.upsertZones(templateZones) - } - } - val overrideRes = zonesRemoteDataSource.getOverrides() - if (overrideRes is Result.Success) { - val overrideEntities = overrideRes.data.map { - TemplateOverrideEntity( - id = it.id, - name = it.name, - dateOfDay = it.dateOfDay, - ) - } - templateOverrideDao.upsertOverrides(overrideEntities) - - val overrideZones = mutableListOf() - for (ov in overrideRes.data) { - for (z in ov.zones) { - z.id?.let { zoneId -> - overrideZones.add( - ZoneEntity( - id = zoneId, - name = z.name, - startTime = z.startTime, - endTime = z.endTime, - color = z.color, - templateId = null, - templateOverrideId = ov.id, - ) - ) - } - } - } - if (overrideZones.isNotEmpty()) { - zoneDao.upsertZones(overrideZones) - } - } + // All-or-nothing: replaceAll clears both halves, so writing one of them after the other + // failed would delete data this sync could not refetch. + val templates = zonesRemoteDataSource.getTemplates() + val overrides = zonesRemoteDataSource.getOverrides() + if (templates !is Result.Success || overrides !is Result.Success) return@withContext false + + zonesLocalDataSource.replaceAll( + templates = templates.data, + overrides = overrides.data, + expiryTime = SyncTtl.computeExpiry(SyncTtl.TEMPLATES_TTL_MS), + ) true } } diff --git a/core/data/src/main/kotlin/com/awan/app/core/data/sync/ScheduleSynchronizer.kt b/core/data/src/main/kotlin/com/awan/app/core/data/sync/ScheduleSynchronizer.kt new file mode 100644 index 00000000..9d0c685d --- /dev/null +++ b/core/data/src/main/kotlin/com/awan/app/core/data/sync/ScheduleSynchronizer.kt @@ -0,0 +1,19 @@ +package com.awan.app.core.data.sync + +import java.time.LocalDate + +/** + * The one slice of [OfflineSyncCoordinator] a repository needs: pull a date range into Room. + * + * Separate from the coordinator so a repository depends on one method rather than on its fifteen + * data sources — otherwise every repository test has to build the whole sync graph. + */ +interface ScheduleSynchronizer { + + /** Replaces the sessions cached for [startDate]..[endDate]. Returns false if nothing was written. */ + suspend fun syncScheduleRange( + startDate: LocalDate, + endDate: LocalDate, + forceRefresh: Boolean = false, + ): Boolean +} diff --git a/core/data/src/main/kotlin/com/awan/app/core/data/task/remote/TaskRemoteDataSourceImpl.kt b/core/data/src/main/kotlin/com/awan/app/core/data/task/remote/TaskRemoteDataSourceImpl.kt index 8523d8f6..9e9324ec 100644 --- a/core/data/src/main/kotlin/com/awan/app/core/data/task/remote/TaskRemoteDataSourceImpl.kt +++ b/core/data/src/main/kotlin/com/awan/app/core/data/task/remote/TaskRemoteDataSourceImpl.kt @@ -91,6 +91,6 @@ class TaskRemoteDataSourceImpl @Inject constructor( override suspend fun getInboxTasks(): Result> = safeApiCall(dispatcher = ioDispatcher, json = json) { - taskApiService.getInboxTasks().tasks + taskApiService.getInboxTasks().tasks.map { TaskWithSessionsDto(task = it) } } } diff --git a/core/data/src/main/kotlin/com/awan/app/core/data/zones/local/ZonesLocalDataSource.kt b/core/data/src/main/kotlin/com/awan/app/core/data/zones/local/ZonesLocalDataSource.kt new file mode 100644 index 00000000..63a9bda5 --- /dev/null +++ b/core/data/src/main/kotlin/com/awan/app/core/data/zones/local/ZonesLocalDataSource.kt @@ -0,0 +1,91 @@ +package com.awan.app.core.data.zones.local + +import androidx.room.withTransaction +import com.awan.app.core.database.AwanDatabase +import com.awan.app.core.database.dao.TemplateDao +import com.awan.app.core.database.dao.TemplateOverrideDao +import com.awan.app.core.database.dao.ZoneDao +import com.awan.app.core.database.model.TemplateDayOfWeekEntity +import com.awan.app.core.database.model.TemplateEntity +import com.awan.app.core.database.model.TemplateOverrideEntity +import com.awan.app.core.database.model.ZoneEntity +import com.awan.app.core.network.dto.zone.TemplateOverrideDto +import com.awan.app.core.network.dto.zone.WeeklyTemplateDto +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The only writer of `templates`, `template_days_of_week`, `template_overrides` and `zones`. + * Everything else reads them. One writer is what makes "nothing deleted elsewhere survives here" + * a property of a function rather than something every call site has to remember. + */ +interface ZonesLocalDataSource { + + /** + * Replaces the whole zone model with what the server returned. Both endpoints return complete + * lists, so the whole table is the authoritative scope. + * + * Callers must have both responses in hand: a partial replace would delete the half it could + * not refetch. + */ + suspend fun replaceAll( + templates: List, + overrides: List, + expiryTime: Long, + ) +} + +@Singleton +class ZonesLocalDataSourceImpl @Inject constructor( + private val database: AwanDatabase, + private val templateDao: TemplateDao, + private val templateOverrideDao: TemplateOverrideDao, + private val zoneDao: ZoneDao, +) : ZonesLocalDataSource { + + override suspend fun replaceAll( + templates: List, + overrides: List, + expiryTime: Long, + ) = database.withTransaction { + // Zones and day assignments CASCADE from their parents, so these two deletes clear every + // table involved — including the rows the server no longer returns. + templateDao.deleteAllTemplates() + templateOverrideDao.deleteAllOverrides() + + templateDao.upsertTemplates( + templates.map { TemplateEntity(id = it.id, name = it.name, expiryTime = expiryTime) } + ) + templateDao.upsertDays( + templates.flatMap { template -> + template.daysOfWeek.map { TemplateDayOfWeekEntity(dayOfWeek = it, templateId = template.id) } + } + ) + templateOverrideDao.upsertOverrides( + overrides.map { TemplateOverrideEntity(id = it.id, name = it.name, dateOfDay = it.dateOfDay) } + ) + + val templateZones = templates.flatMap { template -> + template.zones.mapNotNull { it.toEntity(templateId = template.id) } + } + val overrideZones = overrides.flatMap { override -> + override.zones.mapNotNull { it.toEntity(overrideId = override.id) } + } + zoneDao.upsertZones(templateZones + overrideZones) + } +} + +private fun com.awan.app.core.network.dto.zone.ZoneDto.toEntity( + templateId: String? = null, + overrideId: String? = null, +): ZoneEntity? = id?.let { + ZoneEntity( + id = it, + name = name, + startTime = startTime, + endTime = endTime, + color = color, + templateId = templateId, + templateOverrideId = overrideId, + ) +} diff --git a/core/data/src/main/kotlin/com/awan/app/core/data/zones/repository/ZonesRepositoryImpl.kt b/core/data/src/main/kotlin/com/awan/app/core/data/zones/repository/ZonesRepositoryImpl.kt index 9c197cdf..b07787d6 100644 --- a/core/data/src/main/kotlin/com/awan/app/core/data/zones/repository/ZonesRepositoryImpl.kt +++ b/core/data/src/main/kotlin/com/awan/app/core/data/zones/repository/ZonesRepositoryImpl.kt @@ -5,12 +5,13 @@ import com.awan.app.core.common.dispatcher.Dispatcher import com.awan.app.core.common.error.AppError import com.awan.app.core.common.result.Result import com.awan.app.core.common.result.map +import com.awan.app.core.common.result.suspendOnSuccess import com.awan.app.core.data.zone.toModel import com.awan.app.core.data.zones.mapper.toDomain import com.awan.app.core.data.zones.mapper.toDto +import com.awan.app.core.data.sync.SyncTtl +import com.awan.app.core.data.zones.local.ZonesLocalDataSource import com.awan.app.core.data.zones.remote.ZonesRemoteDataSource -import com.awan.app.core.database.dao.TemplateDao -import com.awan.app.core.database.dao.TemplateOverrideDao import com.awan.app.core.database.dao.ZoneDao import com.awan.app.core.database.model.ZoneEntity import com.awan.app.core.domain.network.NetworkConnectivityMonitor @@ -39,8 +40,7 @@ import javax.inject.Inject class ZonesRepositoryImpl @Inject constructor( private val zonesRemoteDataSource: ZonesRemoteDataSource, private val zoneDao: ZoneDao, - private val templateDao: TemplateDao, - private val templateOverrideDao: TemplateOverrideDao, + private val zonesLocalDataSource: ZonesLocalDataSource, private val connectivityMonitor: NetworkConnectivityMonitor, @Dispatcher(AwanDispatchers.IO) private val ioDispatcher: CoroutineDispatcher, ) : ZonesRepository { @@ -52,6 +52,29 @@ class ZonesRepositoryImpl @Inject constructor( return null } + /** + * Re-reads the whole zone model into Room. Every mutation ends with this instead of mirroring + * its own response: the endpoints return partial views (one template, one zone), so mirroring + * cannot express a deletion, and Home reads Room. Two GETs per zone edit is cheap — zone edits + * only happen on the profile screens, and they are already online-gated. + */ + override suspend fun refreshZones(): Result = withContext(ioDispatcher) { + checkOnline()?.let { return@withContext it } + + val templates = zonesRemoteDataSource.getTemplates() + if (templates is Result.Error) return@withContext templates + val overrides = zonesRemoteDataSource.getOverrides() + if (overrides is Result.Error) return@withContext overrides + if (templates !is Result.Success || overrides !is Result.Success) return@withContext Result.Loading + + zonesLocalDataSource.replaceAll( + templates = templates.data, + overrides = overrides.data, + expiryTime = SyncTtl.computeExpiry(SyncTtl.TEMPLATES_TTL_MS), + ) + Result.Success(Unit) + } + /** * Resolves effective zones for a date from Room (SSOT). * Resolution: override for date → template for day-of-week → empty list. @@ -78,7 +101,7 @@ class ZonesRepositoryImpl @Inject constructor( daysOfWeek = daysOfWeek.map { it.name }, zones = zones.map { it.toDto() } ) - ).map { it.toDomain() } + ).map { it.toDomain() }.suspendOnSuccess { refreshZones() } } override suspend fun getTemplate(templateId: String): Result = withContext(ioDispatcher) { @@ -98,12 +121,12 @@ class ZonesRepositoryImpl @Inject constructor( name = name, daysOfWeek = daysOfWeek.map { it.name } ) - ).map { it.toDomain() } + ).map { it.toDomain() }.suspendOnSuccess { refreshZones() } } override suspend fun deleteTemplate(templateId: String): Result = withContext(ioDispatcher) { checkOnline()?.let { return@withContext it } - zonesRemoteDataSource.deleteTemplate(templateId) + zonesRemoteDataSource.deleteTemplate(templateId).suspendOnSuccess { refreshZones() } } override suspend fun addZoneToTemplate( @@ -117,9 +140,10 @@ class ZonesRepositoryImpl @Inject constructor( name = zone.name, startTime = zone.startTime, endTime = zone.endTime, - color = zone.color + color = zone.color, + categoryId = zone.categoryId ) - ).map { it.toDomain() } + ).map { it.toDomain() }.suspendOnSuccess { refreshZones() } } override suspend fun getTemplateZones(templateId: String): Result> = withContext(ioDispatcher) { @@ -135,7 +159,7 @@ class ZonesRepositoryImpl @Inject constructor( zonesRemoteDataSource.updateTemplateZones( templateId, UpdateZonesRequest(zones = zones.map { it.toDto() }) - ).map { list -> list.map { it.toDomain() } } + ).map { list -> list.map { it.toDomain() } }.suspendOnSuccess { refreshZones() } } override suspend fun createOverride( @@ -148,7 +172,7 @@ class ZonesRepositoryImpl @Inject constructor( dateOfDay = date, zones = zones.map { it.toDto() } ) - ).map { it.toDomain() } + ).map { it.toDomain() }.suspendOnSuccess { refreshZones() } } override suspend fun getOverrides(): Result> = withContext(ioDispatcher) { @@ -173,12 +197,12 @@ class ZonesRepositoryImpl @Inject constructor( name = name, dateOfDay = date ) - ).map { it.toDomain() } + ).map { it.toDomain() }.suspendOnSuccess { refreshZones() } } override suspend fun deleteOverride(overrideId: String): Result = withContext(ioDispatcher) { checkOnline()?.let { return@withContext it } - zonesRemoteDataSource.deleteOverride(overrideId) + zonesRemoteDataSource.deleteOverride(overrideId).suspendOnSuccess { refreshZones() } } override suspend fun addZoneToOverride( @@ -192,9 +216,10 @@ class ZonesRepositoryImpl @Inject constructor( name = zone.name, startTime = zone.startTime, endTime = zone.endTime, - color = zone.color + color = zone.color, + categoryId = zone.categoryId ) - ).map { it.toDomain() } + ).map { it.toDomain() }.suspendOnSuccess { refreshZones() } } override suspend fun getOverrideZones(overrideId: String): Result> = withContext(ioDispatcher) { @@ -210,7 +235,7 @@ class ZonesRepositoryImpl @Inject constructor( zonesRemoteDataSource.updateOverrideZones( overrideId, UpdateZonesRequest(zones = zones.map { it.toDto() }) - ).map { list -> list.map { it.toDomain() } } + ).map { list -> list.map { it.toDomain() } }.suspendOnSuccess { refreshZones() } } override suspend fun getZone(zoneId: String): Result = withContext(ioDispatcher) { @@ -239,37 +264,20 @@ class ZonesRepositoryImpl @Inject constructor( name = zone.name, startTime = zone.startTime, endTime = zone.endTime, - color = zone.color + color = zone.color, + categoryId = zone.categoryId ) - ).map { it.toDomain() } + ).map { it.toDomain() }.suspendOnSuccess { refreshZones() } } override suspend fun deleteZone(zoneId: String): Result = withContext(ioDispatcher) { checkOnline()?.let { return@withContext it } - zonesRemoteDataSource.deleteZone(zoneId) + zonesRemoteDataSource.deleteZone(zoneId).suspendOnSuccess { refreshZones() } } - /** - * Resolves zone entities for a date from Room: - * 1. Check for a template override for this specific date - * 2. If no override, find the template that owns this day-of-week - * 3. Return zone entities from the owning parent, or empty list - */ - private suspend fun resolveZoneEntitiesForDate(date: LocalDate): List { - // 1. Check for override - val override = templateOverrideDao.getOverrideForDate(date.toString()) - if (override != null) { - return zoneDao.observeZonesForOverride(override.id).first() - } - // 2. Find template for this day-of-week - val dayOfWeekStr = date.dayOfWeek.name // e.g. "MONDAY" - val dayAssignment = templateDao.getDayAssignment(dayOfWeekStr) - if (dayAssignment != null) { - return zoneDao.observeZonesForTemplate(dayAssignment.templateId).first() - } - // 3. No zones for this date - return emptyList() - } + /** Room resolves override-over-template in one query; see [ZoneDao.observeEffectiveZonesForDate]. */ + private suspend fun resolveZoneEntitiesForDate(date: LocalDate): List = + zoneDao.observeEffectiveZonesForDate(date.toString(), date.dayOfWeek.name).first() private fun ZoneEntity.toDayZone(): DayZone { val startLocalTime = try { LocalTime.parse(startTime) } catch (_: Exception) { LocalTime.of(0, 0) } diff --git a/core/data/src/test/java/com/awan/app/core/data/auth/AuthRepositoryImplTest.kt b/core/data/src/test/java/com/awan/app/core/data/auth/AuthRepositoryImplTest.kt index b210bc57..9c668f47 100644 --- a/core/data/src/test/java/com/awan/app/core/data/auth/AuthRepositoryImplTest.kt +++ b/core/data/src/test/java/com/awan/app/core/data/auth/AuthRepositoryImplTest.kt @@ -27,12 +27,14 @@ class AuthRepositoryImplTest { private lateinit var fakeRemoteDataSource: FakeAuthRemoteDataSource private lateinit var fakeAuthTokenProvider: FakeAuthTokenProvider private lateinit var fakeDeviceIdProvider: DeviceIdProvider + private lateinit var fakeLocalDataCleaner: FakeLocalDataCleaner private lateinit var repository: AuthRepositoryImpl @Before fun setUp() { fakeRemoteDataSource = FakeAuthRemoteDataSource() fakeAuthTokenProvider = FakeAuthTokenProvider() + fakeLocalDataCleaner = FakeLocalDataCleaner() fakeDeviceIdProvider = object : DeviceIdProvider { override fun getDeviceId(): String = "test-device-id-123" } @@ -40,6 +42,7 @@ class AuthRepositoryImplTest { remoteDataSource = fakeRemoteDataSource, authTokenProvider = fakeAuthTokenProvider, deviceIdProvider = fakeDeviceIdProvider, + localDataCleaner = fakeLocalDataCleaner, ) } @@ -125,4 +128,14 @@ class AuthRepositoryImplTest { } override fun notifySessionExpired() {} } + + private class FakeLocalDataCleaner : com.awan.app.core.data.auth.LocalDataCleaner { + var clearCount = 0 + private set + + override suspend fun clearAll() { + clearCount++ + } + } + } diff --git a/core/data/src/test/java/com/awan/app/core/data/home/HomeRepositoryImplTest.kt b/core/data/src/test/java/com/awan/app/core/data/home/HomeRepositoryImplTest.kt index df80678c..a166b33e 100644 --- a/core/data/src/test/java/com/awan/app/core/data/home/HomeRepositoryImplTest.kt +++ b/core/data/src/test/java/com/awan/app/core/data/home/HomeRepositoryImplTest.kt @@ -26,9 +26,12 @@ import com.awan.app.core.network.dto.task.TaskWithSessionsDto import com.awan.app.core.network.dto.zone.TemplateOverrideDto import com.awan.app.core.network.dto.zone.WeeklyTemplateDto import com.awan.app.core.network.dto.zone.ZoneDto +import com.awan.app.core.database.dao.SessionDao import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf +import com.awan.app.core.domain.home.model.DaySchedule import kotlinx.coroutines.launch import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest @@ -136,7 +139,10 @@ private class FakeSessionDao : com.awan.app.core.database.dao.SessionDao { private class FakeZoneDao( private val templateZones: List = emptyList(), + val effectiveZones: MutableStateFlow> = MutableStateFlow(templateZones), ) : ZoneDao { + override fun observeEffectiveZonesForDate(date: String, dayOfWeek: String): Flow> = + effectiveZones override suspend fun upsertZone(zone: com.awan.app.core.database.model.ZoneEntity) {} override suspend fun upsertZones(zones: List) {} override fun observeZone(zoneId: String): Flow = flowOf(null) @@ -157,6 +163,7 @@ private class FakeTemplateDao( override fun observeTemplate(templateId: String): Flow = flowOf(null) override suspend fun getTemplate(templateId: String): com.awan.app.core.database.model.TemplateEntity? = null override suspend fun deleteTemplate(templateId: String) {} + override suspend fun deleteAllTemplates() {} override suspend fun getMinExpiryTime(): Long? = null override suspend fun upsertDays(days: List) {} override fun observeDaysForTemplate(templateId: String): Flow> = flowOf(emptyList()) @@ -172,6 +179,21 @@ private class FakeTemplateOverrideDao : com.awan.app.core.database.dao.TemplateO override suspend fun getOverride(overrideId: String): com.awan.app.core.database.model.TemplateOverrideEntity? = null override suspend fun getOverrideForDate(date: String): com.awan.app.core.database.model.TemplateOverrideEntity? = null override suspend fun deleteOverride(overrideId: String) {} + override suspend fun deleteAllOverrides() {} +} + +private class FakeScheduleSynchronizer : com.awan.app.core.data.sync.ScheduleSynchronizer { + var syncedRanges = mutableListOf>() + var result = true + + override suspend fun syncScheduleRange( + startDate: java.time.LocalDate, + endDate: java.time.LocalDate, + forceRefresh: Boolean, + ): Boolean { + syncedRanges += Triple(startDate, endDate, forceRefresh) + return result + } } private class FakeCategoryDao : com.awan.app.core.database.dao.CategoryDao { @@ -197,18 +219,18 @@ class HomeRepositoryImplTest { private fun createRepository( fakeRemote: HomeRemoteDataSource, zoneDao: ZoneDao = FakeZoneDao(), - templateDao: TemplateDao = FakeTemplateDao(), + sessionDao: SessionDao = FakeSessionDao(), + scheduleSynchronizer: com.awan.app.core.data.sync.ScheduleSynchronizer = FakeScheduleSynchronizer(), ): HomeRepositoryImpl { return HomeRepositoryImpl( remoteDataSource = fakeRemote, userDao = FakeUserDao(), eventBus = eventBus, taskDao = FakeTaskDao(), - sessionDao = FakeSessionDao(), + sessionDao = sessionDao, zoneDao = zoneDao, - templateDao = templateDao, - templateOverrideDao = FakeTemplateOverrideDao(), categoryDao = FakeCategoryDao(), + scheduleSynchronizer = scheduleSynchronizer, connectivityMonitor = AlwaysOnlineMonitor(), ioDispatcher = kotlinx.coroutines.Dispatchers.Unconfined, ) @@ -401,12 +423,6 @@ class HomeRepositoryImplTest { ), ), ), - templateDao = FakeTemplateDao( - dayAssignment = TemplateDayOfWeekEntity( - dayOfWeek = date.dayOfWeek.name, - templateId = "template-1", - ), - ), ) val result = repository.getDaySchedule(date).first() @@ -414,6 +430,56 @@ class HomeRepositoryImplTest { assertTrue(result is Result.Success) assertEquals("Study", (result as Result.Success).data.zones.single().categoryName) } + + /** + * The reported bug: a zone edited elsewhere reached Room but Home kept showing the old one until + * the day changed, because the Flow was invalidated by the `sessions` table alone. + */ + @Test + fun `getDaySchedule re-emits when only the zones change`() = runTest { + val zoneDao = FakeZoneDao() + val repository = createRepository(fakeRemote = FakeHomeRemoteDataSource(), zoneDao = zoneDao) + + val emissions = mutableListOf() + val job = launch(UnconfinedTestDispatcher(testScheduler)) { + repository.getDaySchedule(java.time.LocalDate.of(2026, 8, 9)).collect { + if (it is Result.Success) emissions += it.data + } + } + + zoneDao.effectiveZones.value = listOf( + ZoneEntity( + id = "zone-work", + name = "Work", + startTime = "09:00:00", + endTime = "12:00:00", + color = null, + templateId = "template-1", + templateOverrideId = null, + ), + ) + job.cancel() + + assertEquals(2, emissions.size) + assertTrue(emissions.first().zones.isEmpty()) + assertEquals("Work", emissions.last().zones.single().name) + } + + @Test + fun `refreshSchedule forces a sync for the single day and reports failure`() = runTest { + val synchronizer = FakeScheduleSynchronizer() + val repository = createRepository( + fakeRemote = FakeHomeRemoteDataSource(), + scheduleSynchronizer = synchronizer, + ) + val date = java.time.LocalDate.of(2026, 8, 9) + + assertTrue(repository.refreshSchedule(date) is Result.Success) + assertEquals(listOf(Triple(date, date, true)), synchronizer.syncedRanges) + + synchronizer.result = false + assertTrue(repository.refreshSchedule(date) is Result.Error) + } } private const val SESSION_ID = "session-1" diff --git a/core/data/src/test/java/com/awan/app/core/data/onboarding/OnboardingRepositoryImplTest.kt b/core/data/src/test/java/com/awan/app/core/data/onboarding/OnboardingRepositoryImplTest.kt index bba33023..33274665 100644 --- a/core/data/src/test/java/com/awan/app/core/data/onboarding/OnboardingRepositoryImplTest.kt +++ b/core/data/src/test/java/com/awan/app/core/data/onboarding/OnboardingRepositoryImplTest.kt @@ -171,12 +171,23 @@ class OnboardingRepositoryImplTest { @Test fun `an account with no categories gets no template rather than an error`() = runTest(testDispatcher.scheduler) { - val result = repository.completeOnboarding(onboardingData(zones = emptyList())) + // Zone.defaults carry no categoryId — the zones the backend rejects. Passing an empty list + // here instead would pass against an impl that drops the category and 422s on a real device. + val result = repository.completeOnboarding(onboardingData(zones = Zone.defaults)) assertTrue(result is Result.Success) assertNull(fakeZonesRepository.createdZones) } + @Test + fun `every zone in the template carries its category`() = runTest(testDispatcher.scheduler) { + repository.completeOnboarding(onboardingData(zones = zonesWithCategories())) + + val sent = fakeZonesRepository.createdZones.orEmpty() + assertEquals(2, sent.size) + assertTrue(sent.all { it.categoryId != null }) + } + private fun onboardingData(zones: List = Zone.defaults) = OnboardingData( profile = UserProfile(firstName = "Sarah", lastName = "Connor"), @@ -241,6 +252,7 @@ class OnboardingRepositoryImplTest { } private class FakeZonesRepository : ZonesRepository { + override suspend fun refreshZones(): Result = Result.Success(Unit) var failWith: AppError? = null var createdZones: List? = null diff --git a/core/data/src/test/java/com/awan/app/core/data/sync/OfflineSyncCoordinatorTest.kt b/core/data/src/test/java/com/awan/app/core/data/sync/OfflineSyncCoordinatorTest.kt index 2a0b8d00..be5725b3 100644 --- a/core/data/src/test/java/com/awan/app/core/data/sync/OfflineSyncCoordinatorTest.kt +++ b/core/data/src/test/java/com/awan/app/core/data/sync/OfflineSyncCoordinatorTest.kt @@ -1,5 +1,6 @@ package com.awan.app.core.data.sync +import com.awan.app.core.common.error.AppError import com.awan.app.core.common.result.Result import com.awan.app.core.database.dao.CachedScheduleDateDao import com.awan.app.core.database.dao.CategoryDao @@ -131,9 +132,13 @@ private class FakeProfileRemoteDataSource : ProfileRemoteDataSource { override suspend fun deleteProfilePicture() = error("not used") } -private class FakeZonesRemoteDataSource : ZonesRemoteDataSource { +private class FakeZonesRemoteDataSource( + private val templatesFail: Boolean = false, + private val overridesFail: Boolean = false, +) : ZonesRemoteDataSource { override suspend fun getZonesByDate(date: String) = Result.Success(emptyList()) - override suspend fun getTemplates() = Result.Success(emptyList()) + override suspend fun getTemplates(): Result> = + if (templatesFail) Result.Error(AppError.Network) else Result.Success(emptyList()) override suspend fun createTemplate(request: CreateTemplateRequest) = error("not used") override suspend fun getTemplate(templateId: String) = error("not used") override suspend fun updateTemplate(templateId: String, request: UpdateTemplateRequest) = error("not used") @@ -142,7 +147,8 @@ private class FakeZonesRemoteDataSource : ZonesRemoteDataSource { override suspend fun getTemplateZones(templateId: String) = Result.Success(emptyList()) override suspend fun updateTemplateZones(templateId: String, request: UpdateZonesRequest) = Result.Success(emptyList()) override suspend fun createOverride(request: CreateOverrideRequest) = error("not used") - override suspend fun getOverrides() = Result.Success(emptyList()) + override suspend fun getOverrides(): Result> = + if (overridesFail) Result.Error(AppError.Network) else Result.Success(emptyList()) override suspend fun getOverride(overrideId: String) = error("not used") override suspend fun updateOverride(overrideId: String, request: UpdateOverrideRequest) = error("not used") override suspend fun deleteOverride(overrideId: String) = error("not used") @@ -246,6 +252,8 @@ private class FakeZoneDao : ZoneDao { override suspend fun deleteZone(zoneId: String) {} override suspend fun deleteZonesForTemplate(templateId: String) {} override suspend fun deleteZonesForOverride(overrideId: String) {} + override fun observeEffectiveZonesForDate(date: String, dayOfWeek: String): Flow> = + flowOf(emptyList()) } private class FakeTemplateDao : TemplateDao { @@ -257,6 +265,7 @@ private class FakeTemplateDao : TemplateDao { override fun observeTemplate(templateId: String): Flow = MutableStateFlow(null) override suspend fun getTemplate(templateId: String): TemplateEntity? = null override suspend fun deleteTemplate(templateId: String) {} + override suspend fun deleteAllTemplates() {} override suspend fun upsertDays(days: List) { upsertedDays += days } override fun observeDaysForTemplate(templateId: String): Flow> = flowOf(emptyList()) override suspend fun getDayAssignment(dayOfWeek: String): TemplateDayOfWeekEntity? = null @@ -273,6 +282,26 @@ private class FakeTemplateOverrideDao : TemplateOverrideDao { override suspend fun getOverride(overrideId: String): TemplateOverrideEntity? = null override suspend fun getOverrideForDate(date: String): TemplateOverrideEntity? = null override suspend fun deleteOverride(overrideId: String) {} + override suspend fun deleteAllOverrides() {} +} + +private class FakeZonesLocalDataSource : com.awan.app.core.data.zones.local.ZonesLocalDataSource { + var replaceCount = 0 + private set + var templates: List = emptyList() + private set + var overrides: List = emptyList() + private set + + override suspend fun replaceAll( + templates: List, + overrides: List, + expiryTime: Long, + ) { + replaceCount++ + this.templates = templates + this.overrides = overrides + } } private class FakeCachedScheduleDateDao : CachedScheduleDateDao { @@ -320,9 +349,8 @@ class OfflineSyncCoordinatorTest { sessionDao: SessionDao = FakeSessionDao(), goalDao: GoalDao = FakeGoalDao(), userDao: UserDao = FakeUserDao(), - zoneDao: ZoneDao = FakeZoneDao(), templateDao: TemplateDao = FakeTemplateDao(), - templateOverrideDao: TemplateOverrideDao = FakeTemplateOverrideDao(), + zonesLocalDataSource: com.awan.app.core.data.zones.local.ZonesLocalDataSource = FakeZonesLocalDataSource(), cachedScheduleDateDao: CachedScheduleDateDao = FakeCachedScheduleDateDao(), connectivityMonitor: NetworkConnectivityMonitor = onlineMonitor, ) = OfflineSyncCoordinator( @@ -336,9 +364,8 @@ class OfflineSyncCoordinatorTest { sessionDao = sessionDao, goalDao = goalDao, userDao = userDao, - zoneDao = zoneDao, templateDao = templateDao, - templateOverrideDao = templateOverrideDao, + zonesLocalDataSource = zonesLocalDataSource, cachedScheduleDateDao = cachedScheduleDateDao, connectivityMonitor = connectivityMonitor, ioDispatcher = testDispatcher, @@ -487,4 +514,38 @@ class OfflineSyncCoordinatorTest { assertFalse(result) } + + // ── Zones: replace, all-or-nothing ──────────────────────────────────────── + + @Test + fun syncZonesAndTemplates_replacesTheWholeModelOnce() = runTest(testDispatcher) { + val local = FakeZonesLocalDataSource() + val coordinator = buildCoordinator(zonesLocalDataSource = local) + + assertTrue(coordinator.syncZonesAndTemplates(forceRefresh = true)) + assertEquals(1, local.replaceCount) + } + + /** A half-written replace would delete the templates this sync could not refetch. */ + @Test + fun syncZonesAndTemplates_writesNothingAndFailsWhenEitherCallFails() = runTest(testDispatcher) { + val templatesDown = FakeZonesLocalDataSource() + assertFalse( + buildCoordinator( + zonesRemoteDataSource = FakeZonesRemoteDataSource(templatesFail = true), + zonesLocalDataSource = templatesDown, + ).syncZonesAndTemplates(forceRefresh = true) + ) + assertEquals(0, templatesDown.replaceCount) + + val overridesDown = FakeZonesLocalDataSource() + assertFalse( + buildCoordinator( + zonesRemoteDataSource = FakeZonesRemoteDataSource(overridesFail = true), + zonesLocalDataSource = overridesDown, + ).syncZonesAndTemplates(forceRefresh = true) + ) + assertEquals(0, overridesDown.replaceCount) + } + } diff --git a/core/data/src/test/java/com/awan/app/core/data/task/remote/TaskRemoteDataSourceTest.kt b/core/data/src/test/java/com/awan/app/core/data/task/remote/TaskRemoteDataSourceTest.kt index 059a007e..32a1e02c 100644 --- a/core/data/src/test/java/com/awan/app/core/data/task/remote/TaskRemoteDataSourceTest.kt +++ b/core/data/src/test/java/com/awan/app/core/data/task/remote/TaskRemoteDataSourceTest.kt @@ -76,6 +76,44 @@ class TaskRemoteDataSourceTest { private val testDispatcher = UnconfinedTestDispatcher() private val json = Json { ignoreUnknownKeys = true } + /** + * Verbatim `GET v1/goals/inbox` body. Faking the api service cannot catch a DTO that does not + * match the wire — decoding the real payload is the only thing that does. + */ + @Test + fun `the inbox goal payload decodes into its bare tasks`() { + val payload = """ + {"id":"11d47a00","title":"Inbox","description":null,"status":"ACTIVE","targetDate":null, + "createdAt":"2026-08-09T10:44:03.820200Z","inbox":true, + "tasks":[{"id":"7355ff18","title":"Read Clean Code","description":"Read and review.", + "estimatedDuration":60,"status":"SCHEDULED","mandatory":true, + "estimatedPoints":10,"allowTaskSplitting":true,"goalId":"11d47a00", + "category":{"id":"1487aead","name":"Learning"},"dependsOnTaskIds":[]}]} + """.trimIndent() + + val decoded = json.decodeFromString(payload) + + assertEquals(1, decoded.tasks.size) + assertEquals("Read Clean Code", decoded.tasks.first().title) + assertEquals("1487aead", decoded.tasks.first().category?.id) + } + + @Test + fun `getInboxTasks pairs every task with an empty session list`() = runTest(testDispatcher) { + val api = object : FakeTaskApiService() { + override suspend fun getInboxTasks() = InboxTasksResponse( + tasks = listOf(TaskInfoResponse(id = "task-inbox", title = "Read Clean Code")), + ) + } + + val result = dataSource(api, json, testDispatcher).getInboxTasks() + + assertTrue(result is Result.Success) + val tasks = (result as Result.Success>).data + assertEquals("task-inbox", tasks.single().task.id) + assertTrue(tasks.single().sessions.isEmpty()) + } + @Test fun `createTask returns Success when API call succeeds`() = runTest(testDispatcher) { val api = object : FakeTaskApiService() { diff --git a/core/data/src/test/java/com/awan/app/core/data/template/TemplateRepositoryImplTest.kt b/core/data/src/test/java/com/awan/app/core/data/template/TemplateRepositoryImplTest.kt index 6e80057b..3b765a17 100644 --- a/core/data/src/test/java/com/awan/app/core/data/template/TemplateRepositoryImplTest.kt +++ b/core/data/src/test/java/com/awan/app/core/data/template/TemplateRepositoryImplTest.kt @@ -46,6 +46,7 @@ class TemplateRepositoryImplTest { override fun observeTemplate(templateId: String): Flow = flowOf(null) override suspend fun getTemplate(templateId: String): TemplateEntity? = null override suspend fun deleteTemplate(templateId: String) {} + override suspend fun deleteAllTemplates() {} override suspend fun upsertDays(days: List) {} override fun observeDaysForTemplate(templateId: String): Flow> = flowOf(emptyList()) override suspend fun getDayAssignment(dayOfWeek: String): TemplateDayOfWeekEntity? = null @@ -63,6 +64,7 @@ class TemplateRepositoryImplTest { override suspend fun deleteZone(zoneId: String) {} override suspend fun deleteZonesForTemplate(templateId: String) {} override suspend fun deleteZonesForOverride(overrideId: String) {} + override fun observeEffectiveZonesForDate(date: String, dayOfWeek: String) = kotlinx.coroutines.flow.flowOf(emptyList()) } private val onlineMonitor = object : NetworkConnectivityMonitor { diff --git a/core/data/src/test/java/com/awan/app/core/data/zones/ZonesRepositoryImplTest.kt b/core/data/src/test/java/com/awan/app/core/data/zones/ZonesRepositoryImplTest.kt new file mode 100644 index 00000000..4be85b37 --- /dev/null +++ b/core/data/src/test/java/com/awan/app/core/data/zones/ZonesRepositoryImplTest.kt @@ -0,0 +1,189 @@ +package com.awan.app.core.data.zones + +import com.awan.app.core.common.error.AppError +import com.awan.app.core.common.result.Result +import com.awan.app.core.data.zones.local.ZonesLocalDataSource +import com.awan.app.core.data.zones.remote.ZonesRemoteDataSource +import com.awan.app.core.data.zones.repository.ZonesRepositoryImpl +import com.awan.app.core.database.dao.ZoneDao +import com.awan.app.core.database.model.ZoneEntity +import com.awan.app.core.domain.network.NetworkConnectivityMonitor +import com.awan.app.core.domain.zones.model.DailyZone +import com.awan.app.core.domain.zones.model.DayOfWeek +import com.awan.app.core.domain.zones.repository.ZonesRepository +import com.awan.app.core.network.dto.session.SessionDto +import com.awan.app.core.network.dto.zone.CreateOverrideRequest +import com.awan.app.core.network.dto.zone.CreateTemplateRequest +import com.awan.app.core.network.dto.zone.CreateZoneRequest +import com.awan.app.core.network.dto.zone.TemplateOverrideDto +import com.awan.app.core.network.dto.zone.UpdateOverrideRequest +import com.awan.app.core.network.dto.zone.UpdateTemplateRequest +import com.awan.app.core.network.dto.zone.UpdateZoneRequest +import com.awan.app.core.network.dto.zone.UpdateZonesRequest +import com.awan.app.core.network.dto.zone.WeeklyTemplateDto +import com.awan.app.core.network.dto.zone.ZoneDto +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The contract these pin: **no mutation may report success without a local write.** They are + * parameterised over every mutation on the repository, so a thirteenth one added later fails here + * until it refreshes too — which is the only thing keeping Home from silently going stale again. + */ +class ZonesRepositoryImplTest { + + private val zone = DailyZone(id = "z1", name = "Work", startTime = "09:00", endTime = "12:00", color = "#FFF") + + private fun mutations(repository: ZonesRepository): Map Result<*>> = mapOf( + "createTemplate" to { repository.createTemplate("T", listOf(DayOfWeek.MONDAY), listOf(zone)) }, + "updateTemplate" to { repository.updateTemplate("t1", "T", listOf(DayOfWeek.MONDAY)) }, + "deleteTemplate" to { repository.deleteTemplate("t1") }, + "addZoneToTemplate" to { repository.addZoneToTemplate("t1", zone) }, + "updateTemplateZones" to { repository.updateTemplateZones("t1", listOf(zone)) }, + "createOverride" to { repository.createOverride("2026-08-09", listOf(zone)) }, + "updateOverride" to { repository.updateOverride("o1", "N", "2026-08-09") }, + "deleteOverride" to { repository.deleteOverride("o1") }, + "addZoneToOverride" to { repository.addZoneToOverride("o1", zone) }, + "updateOverrideZones" to { repository.updateOverrideZones("o1", listOf(zone)) }, + "updateZone" to { repository.updateZone("z1", zone) }, + "deleteZone" to { repository.deleteZone("z1") }, + ) + + @Test + fun `every mutation replaces the local zone model`() = runTest { + for ((name, mutate) in mutations(repository())) { + local.replaceCount = 0 + + val result = mutate() + + assertTrue("$name did not succeed", result is Result.Success) + assertEquals("$name did not write to Room", 1, local.replaceCount) + } + } + + @Test + fun `a failed mutation writes nothing`() = runTest { + val repository = repository() + remote.failWith = AppError.Api(code = 500, body = "boom") + + for ((name, mutate) in mutations(repository)) { + local.replaceCount = 0 + + assertTrue("$name should have failed", mutate() is Result.Error) + assertEquals("$name wrote to Room after a failure", 0, local.replaceCount) + } + } + + @Test + fun `offline mutations never reach the network`() = runTest { + val repository = repository(online = false) + + for ((name, mutate) in mutations(repository)) { + val result = mutate() + + assertTrue("$name should be offline-gated", result is Result.Error) + assertEquals("$name returned the wrong error", AppError.Network, (result as Result.Error).error) + } + assertEquals(0, remote.callCount) + assertEquals(0, local.replaceCount) + } + + @Test + fun `refreshZones writes nothing when either half fails`() = runTest { + val repository = repository() + remote.failOverridesOnly = true + + assertTrue(repository.refreshZones() is Result.Error) + assertEquals(0, local.replaceCount) + } + + private lateinit var remote: FakeZonesRemoteDataSource + private lateinit var local: FakeZonesLocalDataSource + + private fun repository(online: Boolean = true): ZonesRepositoryImpl { + remote = FakeZonesRemoteDataSource() + local = FakeZonesLocalDataSource() + return ZonesRepositoryImpl( + zonesRemoteDataSource = remote, + zoneDao = FakeZoneDao(), + zonesLocalDataSource = local, + connectivityMonitor = object : NetworkConnectivityMonitor { + override val isOnline: Flow = flowOf(online) + override fun isCurrentlyOnline(): Boolean = online + }, + ioDispatcher = Dispatchers.Unconfined, + ) + } +} + +private class FakeZonesLocalDataSource : ZonesLocalDataSource { + var replaceCount = 0 + + override suspend fun replaceAll( + templates: List, + overrides: List, + expiryTime: Long, + ) { + replaceCount++ + } +} + +private class FakeZonesRemoteDataSource : ZonesRemoteDataSource { + var failWith: AppError? = null + var failOverridesOnly = false + var callCount = 0 + private set + + private val zoneDto = ZoneDto(id = "z1", name = "Work", startTime = "09:00:00", endTime = "12:00:00") + private val templateDto = WeeklyTemplateDto(id = "t1", name = "T", daysOfWeek = listOf("MONDAY"), zones = emptyList()) + private val overrideDto = TemplateOverrideDto(id = "o1", name = null, dateOfDay = "2026-08-09", zones = emptyList()) + + private fun answer(value: T): Result { + callCount++ + return failWith?.let { Result.Error(it) } ?: Result.Success(value) + } + + override suspend fun getOverrides(): Result> = + if (failOverridesOnly) Result.Error(AppError.Network) else answer(listOf(overrideDto)) + + override suspend fun getZonesByDate(date: String): Result> = answer(listOf(zoneDto)) + override suspend fun getTemplates(): Result> = answer(listOf(templateDto)) + override suspend fun createTemplate(request: CreateTemplateRequest) = answer(templateDto) + override suspend fun getTemplate(templateId: String) = answer(templateDto) + override suspend fun updateTemplate(templateId: String, request: UpdateTemplateRequest) = answer(templateDto) + override suspend fun deleteTemplate(templateId: String) = answer(Unit) + override suspend fun addZoneToTemplate(templateId: String, request: CreateZoneRequest) = answer(zoneDto) + override suspend fun getTemplateZones(templateId: String) = answer(listOf(zoneDto)) + override suspend fun updateTemplateZones(templateId: String, request: UpdateZonesRequest) = answer(listOf(zoneDto)) + override suspend fun createOverride(request: CreateOverrideRequest) = answer(overrideDto) + override suspend fun getOverride(overrideId: String) = answer(overrideDto) + override suspend fun updateOverride(overrideId: String, request: UpdateOverrideRequest) = answer(overrideDto) + override suspend fun deleteOverride(overrideId: String) = answer(Unit) + override suspend fun addZoneToOverride(overrideId: String, request: CreateZoneRequest) = answer(zoneDto) + override suspend fun getOverrideZones(overrideId: String) = answer(listOf(zoneDto)) + override suspend fun updateOverrideZones(overrideId: String, request: UpdateZonesRequest) = answer(listOf(zoneDto)) + override suspend fun getZone(zoneId: String) = answer(zoneDto) + override suspend fun getZoneSessions(zoneId: String) = answer(emptyList()) + override suspend fun getEffectiveZones(date: String) = answer(listOf(zoneDto)) + override suspend fun updateZone(zoneId: String, request: UpdateZoneRequest) = answer(zoneDto) + override suspend fun deleteZone(zoneId: String) = answer(Unit) +} + +private class FakeZoneDao : ZoneDao { + override suspend fun upsertZone(zone: ZoneEntity) {} + override suspend fun upsertZones(zones: List) {} + override fun observeZone(zoneId: String): Flow = flowOf(null) + override suspend fun getZone(zoneId: String): ZoneEntity? = null + override fun observeZonesForTemplate(templateId: String): Flow> = flowOf(emptyList()) + override fun observeZonesForOverride(overrideId: String): Flow> = flowOf(emptyList()) + override fun observeEffectiveZonesForDate(date: String, dayOfWeek: String): Flow> = + flowOf(emptyList()) + override suspend fun deleteZone(zoneId: String) {} + override suspend fun deleteZonesForTemplate(templateId: String) {} + override suspend fun deleteZonesForOverride(overrideId: String) {} +} diff --git a/core/data/src/test/java/com/awan/app/core/data/zones/mapper/ZonesMapperTest.kt b/core/data/src/test/java/com/awan/app/core/data/zones/mapper/ZonesMapperTest.kt index 2db02682..ea7a3f02 100644 --- a/core/data/src/test/java/com/awan/app/core/data/zones/mapper/ZonesMapperTest.kt +++ b/core/data/src/test/java/com/awan/app/core/data/zones/mapper/ZonesMapperTest.kt @@ -1,10 +1,9 @@ package com.awan.app.core.data.zones.mapper +import com.awan.app.core.network.di.NetworkModule import com.awan.app.core.network.dto.category.CategoryDto import com.awan.app.core.network.dto.zone.ZoneDto -import kotlinx.serialization.json.Json import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -50,23 +49,17 @@ class ZonesMapperTest { } /** - * One type carries both the response's nested `category` and the request's `categoryId`, which - * only works because the app's Json drops nulls. If that config ever changes, every zone write - * starts posting a `category: null` the backend does not accept — fail here, not in the field. + * One type carries both the response's nested `category` and the request's `categoryId`, so what + * lands in the body is decided by the app's Json config — which is why this encodes with the very + * instance Hilt provides rather than a copy of it. An earlier copy here claimed + * `explicitNulls = false`; production has always set it to `true`, so the assertion that the + * nested `category` is omitted was never true of a real request. */ @Test - fun `a written zone carries categoryId and omits the read-only nested category`() { - val json = Json { - ignoreUnknownKeys = true - isLenient = true - explicitNulls = false - encodeDefaults = true - coerceInputValues = true - } - - val body = json.encodeToString(zoneDto(category = CategoryDto("cat-1", "Work")).toDomain().toDto()) + fun `a written zone carries its categoryId`() { + val body = NetworkModule.providesNetworkJson() + .encodeToString(zoneDto(category = CategoryDto("cat-1", "Work")).toDomain().toDto()) assertTrue(body, body.contains("\"categoryId\":\"cat-1\"")) - assertFalse(body, body.contains("\"category\"")) } } diff --git a/core/database/src/androidTest/kotlin/com/awan/app/core/database/dao/TemplateDaoTest.kt b/core/database/src/androidTest/kotlin/com/awan/app/core/database/dao/TemplateDaoTest.kt index e9ef9444..d19e0406 100644 --- a/core/database/src/androidTest/kotlin/com/awan/app/core/database/dao/TemplateDaoTest.kt +++ b/core/database/src/androidTest/kotlin/com/awan/app/core/database/dao/TemplateDaoTest.kt @@ -5,10 +5,13 @@ import com.awan.app.core.database.AwanDatabase import com.awan.app.core.database.buildInMemoryDb import com.awan.app.core.database.model.TemplateDayOfWeekEntity import com.awan.app.core.database.model.TemplateEntity +import com.awan.app.core.database.model.TemplateOverrideEntity +import com.awan.app.core.database.model.ZoneEntity import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before @@ -152,4 +155,42 @@ class TemplateDaoTest { dao.upsertTemplateWithDays(template(), emptyList()) assertTrue(dao.observeDaysForTemplate("tmpl1").first().isEmpty()) } + + /** What `ZonesLocalDataSource.replaceAll` relies on: one delete clears the whole template side. */ + @Test + fun deleteAllTemplates_cascadesDaysAndTemplateZonesButLeavesOverrideZones() = runTest { + dao.upsertTemplateWithDays(template(), listOf(day("MONDAY"))) + db.zoneDao().upsertZone( + ZoneEntity( + id = "z-template", + name = "Study", + startTime = "09:00:00", + endTime = "12:00:00", + color = null, + templateId = "tmpl1", + templateOverrideId = null, + ) + ) + db.templateOverrideDao().upsertOverride( + TemplateOverrideEntity(id = "ov1", name = null, dateOfDay = "2026-07-21") + ) + db.zoneDao().upsertZone( + ZoneEntity( + id = "z-override", + name = "Work", + startTime = "10:00:00", + endTime = "18:00:00", + color = null, + templateId = null, + templateOverrideId = "ov1", + ) + ) + + dao.deleteAllTemplates() + + assertNull(dao.getTemplate("tmpl1")) + assertTrue(dao.observeDaysForTemplate("tmpl1").first().isEmpty()) + assertNull(db.zoneDao().getZone("z-template")) + assertNotNull(db.zoneDao().getZone("z-override")) + } } diff --git a/core/database/src/androidTest/kotlin/com/awan/app/core/database/dao/ZoneDaoTest.kt b/core/database/src/androidTest/kotlin/com/awan/app/core/database/dao/ZoneDaoTest.kt index 63fe4ff5..bf99349a 100644 --- a/core/database/src/androidTest/kotlin/com/awan/app/core/database/dao/ZoneDaoTest.kt +++ b/core/database/src/androidTest/kotlin/com/awan/app/core/database/dao/ZoneDaoTest.kt @@ -3,11 +3,17 @@ package com.awan.app.core.database.dao import androidx.test.ext.junit.runners.AndroidJUnit4 import com.awan.app.core.database.AwanDatabase import com.awan.app.core.database.buildInMemoryDb +import com.awan.app.core.database.model.TemplateDayOfWeekEntity import com.awan.app.core.database.model.TemplateEntity import com.awan.app.core.database.model.TemplateOverrideEntity import com.awan.app.core.database.model.ZoneEntity +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNull @@ -243,4 +249,100 @@ class ZoneDaoTest { db.templateOverrideDao().deleteOverride("ov1") assertNull(dao.getZone("z2")) } + + // ── observeEffectiveZonesForDate ────────────────────────────────────────── + + private suspend fun assignDay(templateId: String = "tmpl1", day: String = DAY) { + db.templateDao().upsertDays(listOf(TemplateDayOfWeekEntity(dayOfWeek = day, templateId = templateId))) + } + + @Test + fun effectiveZones_useTheTemplateOwningTheDayWhenNoOverrideExists() = runTest { + insertTemplate() + assignDay() + dao.upsertZone(templateZone()) + + assertEquals(listOf("z1"), dao.observeEffectiveZonesForDate(DATE, DAY).first().map { it.id }) + } + + @Test + fun effectiveZones_areEmptyWhenNoTemplateOwnsTheDay() = runTest { + insertTemplate() + dao.upsertZone(templateZone()) + + assertTrue(dao.observeEffectiveZonesForDate(DATE, DAY).first().isEmpty()) + } + + @Test + fun effectiveZones_preferTheOverrideOverTheTemplate() = runTest { + insertTemplate() + assignDay() + dao.upsertZone(templateZone()) + insertOverride() + dao.upsertZone(overrideZone()) + + assertEquals(listOf("z2"), dao.observeEffectiveZonesForDate(DATE, DAY).first().map { it.id }) + } + + @Test + fun effectiveZones_ignoreAnOverrideOnAnotherDate() = runTest { + insertTemplate() + assignDay() + dao.upsertZone(templateZone()) + db.templateOverrideDao().upsertOverride( + TemplateOverrideEntity(id = "other", name = null, dateOfDay = "2026-07-22") + ) + dao.upsertZone(overrideZone(id = "z9", overrideId = "other")) + + assertEquals(listOf("z1"), dao.observeEffectiveZonesForDate(DATE, DAY).first().map { it.id }) + } + + /** + * The assumption Home's schedule Flow rests on: one live subscription must re-emit for a change + * to any of the three tables the query touches, not just `zones`. Runs on real time because Room + * delivers invalidations on its own executor. + */ + @Test + fun effectiveZones_reEmitOnZoneEditDayReassignmentAndNewOverride() = runBlocking { + insertTemplate() + assignDay() + dao.upsertZone(templateZone(startTime = "09:00:00")) + + val received = Channel>(Channel.UNLIMITED) + val collector = launch(Dispatchers.IO) { + dao.observeEffectiveZonesForDate(DATE, DAY).collect { received.send(it) } + } + suspend fun awaitZones(predicate: (List) -> Boolean): List = + withTimeout(EMISSION_TIMEOUT_MS) { + var zones = received.receive() + while (!predicate(zones)) zones = received.receive() + zones + } + + try { + assertEquals("09:00:00", awaitZones { it.isNotEmpty() }.single().startTime) + + // 1. the zone itself changes + dao.upsertZone(templateZone(startTime = "10:00:00")) + assertEquals("10:00:00", awaitZones { it.singleOrNull()?.startTime == "10:00:00" }.single().startTime) + + // 2. the day is reassigned to a template with no zones + db.templateDao().upsertTemplate(TemplateEntity(id = "tmpl2", name = "T2")) + assignDay(templateId = "tmpl2") + assertTrue(awaitZones { it.isEmpty() }.isEmpty()) + + // 3. an override appears for this date + insertOverride() + dao.upsertZone(overrideZone()) + assertEquals(listOf("z2"), awaitZones { it.isNotEmpty() }.map { it.id }) + } finally { + collector.cancel() + } + } + + private companion object { + const val DATE = "2026-07-21" + const val DAY = "TUESDAY" + const val EMISSION_TIMEOUT_MS = 5_000L + } } diff --git a/core/database/src/main/kotlin/com/awan/app/core/database/dao/TemplateDao.kt b/core/database/src/main/kotlin/com/awan/app/core/database/dao/TemplateDao.kt index 2a80dd21..b4b619d2 100644 --- a/core/database/src/main/kotlin/com/awan/app/core/database/dao/TemplateDao.kt +++ b/core/database/src/main/kotlin/com/awan/app/core/database/dao/TemplateDao.kt @@ -31,6 +31,13 @@ interface TemplateDao { @Query("DELETE FROM templates WHERE id = :templateId") suspend fun deleteTemplate(templateId: String) + /** + * Clears the table. `template_days_of_week` and template-owned zones CASCADE away with it — as + * would any future entity holding an FK to `templates`. For `replaceAll` only. + */ + @Query("DELETE FROM templates") + suspend fun deleteAllTemplates() + @Query("SELECT MIN(expiryTime) FROM templates") suspend fun getMinExpiryTime(): Long? diff --git a/core/database/src/main/kotlin/com/awan/app/core/database/dao/TemplateOverrideDao.kt b/core/database/src/main/kotlin/com/awan/app/core/database/dao/TemplateOverrideDao.kt index 0514c65a..f516fdcb 100644 --- a/core/database/src/main/kotlin/com/awan/app/core/database/dao/TemplateOverrideDao.kt +++ b/core/database/src/main/kotlin/com/awan/app/core/database/dao/TemplateOverrideDao.kt @@ -30,4 +30,8 @@ interface TemplateOverrideDao { @Query("DELETE FROM template_overrides WHERE id = :overrideId") suspend fun deleteOverride(overrideId: String) + + /** Clears the table. Override-owned zones CASCADE away with it. For `replaceAll` only. */ + @Query("DELETE FROM template_overrides") + suspend fun deleteAllOverrides() } diff --git a/core/database/src/main/kotlin/com/awan/app/core/database/dao/ZoneDao.kt b/core/database/src/main/kotlin/com/awan/app/core/database/dao/ZoneDao.kt index 76d39f2b..3e53f118 100644 --- a/core/database/src/main/kotlin/com/awan/app/core/database/dao/ZoneDao.kt +++ b/core/database/src/main/kotlin/com/awan/app/core/database/dao/ZoneDao.kt @@ -29,6 +29,32 @@ interface ZoneDao { @Query("SELECT * FROM zones WHERE templateOverrideId = :overrideId ORDER BY startTime ASC") fun observeZonesForOverride(overrideId: String): Flow> + /** + * The zones in effect on a date: the date's override wins, otherwise the template that owns that + * day-of-week (`MONDAY`…`SUNDAY`), otherwise nothing. + * + * One query rather than a composition of three, because Room's invalidation tracker collects the + * tables named in the subqueries too — so this re-emits on a zone edit, a day reassignment, or a + * new override. Resolving those with `suspend` lookups instead would produce a Flow that only + * ever reacts to the `zones` table. + */ + @Query( + """ + SELECT * FROM zones + WHERE templateOverrideId = ( + SELECT id FROM template_overrides WHERE dateOfDay = :date LIMIT 1 + ) + OR ( + templateId = ( + SELECT templateId FROM template_days_of_week WHERE dayOfWeek = :dayOfWeek + ) + AND NOT EXISTS (SELECT 1 FROM template_overrides WHERE dateOfDay = :date) + ) + ORDER BY startTime ASC + """ + ) + fun observeEffectiveZonesForDate(date: String, dayOfWeek: String): Flow> + @Query("DELETE FROM zones WHERE id = :zoneId") suspend fun deleteZone(zoneId: String) diff --git a/core/domain/src/main/kotlin/com/awan/app/core/domain/home/repository/HomeRepository.kt b/core/domain/src/main/kotlin/com/awan/app/core/domain/home/repository/HomeRepository.kt index 19f2ca13..c6cf7e9d 100644 --- a/core/domain/src/main/kotlin/com/awan/app/core/domain/home/repository/HomeRepository.kt +++ b/core/domain/src/main/kotlin/com/awan/app/core/domain/home/repository/HomeRepository.kt @@ -10,6 +10,13 @@ import kotlinx.coroutines.flow.Flow interface HomeRepository { fun getDaySchedule(date: LocalDate): Flow> + + /** + * Pulls one day's sessions into Room, replacing what is there for that date. [getDaySchedule] + * renders the cached day immediately; this is what makes another device's edits show up, and + * it is the only way a day outside the background sync's week gets fetched at all. + */ + suspend fun refreshSchedule(date: LocalDate): Result suspend fun getUserProfile(): Result suspend fun getSessionDetail(sessionId: String): Result diff --git a/core/domain/src/main/kotlin/com/awan/app/core/domain/home/usecase/RefreshDayScheduleUseCase.kt b/core/domain/src/main/kotlin/com/awan/app/core/domain/home/usecase/RefreshDayScheduleUseCase.kt new file mode 100644 index 00000000..41b439c1 --- /dev/null +++ b/core/domain/src/main/kotlin/com/awan/app/core/domain/home/usecase/RefreshDayScheduleUseCase.kt @@ -0,0 +1,12 @@ +package com.awan.app.core.domain.home.usecase + +import com.awan.app.core.common.result.Result +import com.awan.app.core.domain.home.repository.HomeRepository +import java.time.LocalDate +import javax.inject.Inject + +class RefreshDayScheduleUseCase @Inject constructor( + private val homeRepository: HomeRepository, +) { + suspend operator fun invoke(date: LocalDate): Result = homeRepository.refreshSchedule(date) +} diff --git a/core/domain/src/main/kotlin/com/awan/app/core/domain/zones/repository/ZonesRepository.kt b/core/domain/src/main/kotlin/com/awan/app/core/domain/zones/repository/ZonesRepository.kt index c4c96e33..2fe167f7 100644 --- a/core/domain/src/main/kotlin/com/awan/app/core/domain/zones/repository/ZonesRepository.kt +++ b/core/domain/src/main/kotlin/com/awan/app/core/domain/zones/repository/ZonesRepository.kt @@ -10,6 +10,13 @@ import com.awan.app.core.model.DayZone import java.time.LocalDate interface ZonesRepository { + + /** + * Pulls the whole zone model into Room, replacing what is there. Every mutation ends with it, so + * screens only need it when they open — before that, they render whatever Room already holds. + */ + suspend fun refreshZones(): Result + suspend fun getZonesForDate(date: LocalDate): Result> // Templates diff --git a/core/domain/src/main/kotlin/com/awan/app/core/domain/zones/usecase/RefreshZonesUseCase.kt b/core/domain/src/main/kotlin/com/awan/app/core/domain/zones/usecase/RefreshZonesUseCase.kt new file mode 100644 index 00000000..6694ae82 --- /dev/null +++ b/core/domain/src/main/kotlin/com/awan/app/core/domain/zones/usecase/RefreshZonesUseCase.kt @@ -0,0 +1,11 @@ +package com.awan.app.core.domain.zones.usecase + +import com.awan.app.core.common.result.Result +import com.awan.app.core.domain.zones.repository.ZonesRepository +import javax.inject.Inject + +class RefreshZonesUseCase @Inject constructor( + private val zonesRepository: ZonesRepository, +) { + suspend operator fun invoke(): Result = zonesRepository.refreshZones() +} diff --git a/core/network/src/main/kotlin/com/awan/app/core/network/api/TaskApiService.kt b/core/network/src/main/kotlin/com/awan/app/core/network/api/TaskApiService.kt index 73b9041b..14741925 100644 --- a/core/network/src/main/kotlin/com/awan/app/core/network/api/TaskApiService.kt +++ b/core/network/src/main/kotlin/com/awan/app/core/network/api/TaskApiService.kt @@ -88,7 +88,7 @@ interface TaskApiService { @Body request: ScheduleTaskRequest, ): TaskScheduleResponse - @GET("v1/tasks/inbox") + @GET("v1/goals/inbox") suspend fun getInboxTasks(): InboxTasksResponse @DELETE("v1/tasks/{taskId}") diff --git a/core/network/src/main/kotlin/com/awan/app/core/network/dto/task/InboxTasksResponse.kt b/core/network/src/main/kotlin/com/awan/app/core/network/dto/task/InboxTasksResponse.kt index 8692518e..907a5e71 100644 --- a/core/network/src/main/kotlin/com/awan/app/core/network/dto/task/InboxTasksResponse.kt +++ b/core/network/src/main/kotlin/com/awan/app/core/network/dto/task/InboxTasksResponse.kt @@ -3,7 +3,12 @@ package com.awan.app.core.network.dto.task import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +/** + * `v1/goals/inbox` answers with the inbox **goal**, whose `tasks[]` are bare task objects — not the + * `{ task, sessions }` pairs the scheduled-task endpoints return. Inbox tasks are unscheduled, so + * there are no sessions to carry. The goal's own fields are ignored (`ignoreUnknownKeys`). + */ @Serializable data class InboxTasksResponse( - @SerialName("tasks") val tasks: List = emptyList(), + @SerialName("tasks") val tasks: List = emptyList(), ) diff --git a/core/network/src/main/kotlin/com/awan/app/core/network/dto/zone/ZoneDto.kt b/core/network/src/main/kotlin/com/awan/app/core/network/dto/zone/ZoneDto.kt index e496c3e2..18767e62 100644 --- a/core/network/src/main/kotlin/com/awan/app/core/network/dto/zone/ZoneDto.kt +++ b/core/network/src/main/kotlin/com/awan/app/core/network/dto/zone/ZoneDto.kt @@ -9,7 +9,8 @@ import kotlinx.serialization.Serializable * * [category] is the read side (the server nests it on every `ZoneResponse`); [categoryId] is the * write side and is required by the backend on every request body that contains a zone. Both live on - * one type because `explicitNulls = false` drops whichever half is unset from the serialized body. + * one type, and since the app's Json sets `explicitNulls = true`, a write posts `"category": null` + * alongside the id — which the backend accepts. Splitting the type is only worth it if that changes. */ @Serializable data class ZoneDto( diff --git a/docs/feature/offline-first/2026-08-09-replace-on-refresh.md b/docs/feature/offline-first/2026-08-09-replace-on-refresh.md new file mode 100644 index 00000000..7a0858bd --- /dev/null +++ b/docs/feature/offline-first/2026-08-09-replace-on-refresh.md @@ -0,0 +1,134 @@ +# Replace-on-refresh: one enforced offline-first rule + +Extends `2026-08-06-offline-first-ssot-plan.md` (AWAN-205) rather than superseding it. That plan +declared Room the single read model; this one closes the gaps where the implementation did not carry +it through, and turns the intent into a contract that can be reviewed mechanically. + +## Context + +Reported symptom: a zone template edited in profile appears there instantly but never reaches Home +until the user changes the day. Two independent causes: + +1. **Zone mutations never reach Room.** All 12 mutations in `ZonesRepositoryImpl` call the network and + return — yet its own `getZonesForDate`/`getEffectiveZones`, and Home, read Room. Profile looks + correct only because it reads the network straight into in-memory state. +2. **Home's Flow cannot see zones.** `HomeRepositoryImpl.getDaySchedule` maps over + `sessionDao.observeSessionsForDate(...)` and resolves zones with suspend one-shots *inside* the + `map`. Room invalidates that Flow for the `sessions` table only, so building a new Flow — i.e. + changing the day — is the only thing that re-reads zones. + +The deletion half is systemic. Of five sync functions only `syncScheduleRange` removes anything; +`syncGoals`, `syncCategories`, `syncProfile` and `syncZonesAndTemplates` are upsert-only, so a +record deleted on another device never disappears here. `ZonesRepositoryImpl.deleteZone/deleteTemplate/ +deleteOverride` and `HomeRepositoryImpl.deleteSession/deleteTask` delete on the server and leave the +Room row behind. + +## Decisions + +- Refresh on **screen open and after every write**; the background worker is unchanged. +- This change covers **zones + Home + Home's local deletes**. Categories, goals and tasks are + deferred — see Traps below, because two of them fail badly if fixed the obvious way. +- **No offline write queue.** Mutations stay connectivity-gated, as AWAN-205 committed. + +## The contract + +Written into `CLAUDE.md` under "Offline-first contract". Four method shapes — observe (Room Flow +only, never a suspend DAO call inside `map`), one-shot read (network → local → Room, Room fallback), +refresh (all-or-nothing, then one `replaceX`), mutate (connectivity first, exactly one local write on +success). Every refresh replaces within a scope that is provable from the endpoint; a paginated or +filtered response is authoritative for nothing. All Room writes for a feature live in one +`LocalDataSource`. + +## Plan + +**Phase 0 — DAO surface.** `ZoneDao.observeEffectiveZonesForDate(date, dayOfWeek)`: one `@Query` +whose subqueries span `zones`, `template_overrides` and `template_days_of_week`, encoding the +existing resolution rule. One query rather than a `combine` so Room's invalidation tracker covers all +three tables. Plus `TemplateDao.deleteAllTemplates()` and `TemplateOverrideDao.deleteAllOverrides()`. +No entity changes anywhere in this plan — **no migration, no version bump**. + +**Phase 1 — Home reacts.** `getDaySchedule` becomes a `combine` of the sessions Flow and the new zone +Flow; `resolveZonesForDate` collapses to a pure mapper. `ZonesRepositoryImpl.resolveZoneEntitiesForDate` +uses the same DAO method, deleting logic duplicated across both repositories. + +**Phase 2 — one writer.** New `core/data/zones/local/ZonesLocalDataSource(+Impl)` with +`replaceAll(templates, overrides, expiryTime)` in `database.withTransaction { }`, modelled on +`CalendarLocalDataSource`. It deletes all templates and overrides first; `zones` and +`template_days_of_week` CASCADE from both, so stale zones and day assignments go with them. The +DTO→entity mapping moves out of `OfflineSyncCoordinator.syncZonesAndTemplates`, which shrinks to +online check → TTL gate → two GETs → return false if either failed → `replaceAll`. + +**Phase 3 — mutations land in Room.** `ZonesRepositoryImpl.refreshZones()` (both GETs → `replaceAll`), +appended to each of the 12 mutations via the existing `suspendOnSuccess` helper. One shared refresh +beats 12 bespoke write-throughs and is the only version where the three `delete*` mutations are +correct with no extra code. + +**Phase 4 — refresh on screen open.** `refreshZones()` on `ZonesRepository` + `RefreshZonesUseCase`; +`refreshSchedule(date)` + `RefreshDayScheduleUseCase` delegating to `syncScheduleRange(date, date, +forceRefresh = true)`. Home refreshes on init and on date change; profile zone screens on load. +Screen-open refreshes force, bypassing the TTL; only the worker honours it. Refresh never blanks the +screen — cached data renders immediately and a failure leaves it in place. + +**Home's local deletes.** `deleteSession` → `sessionDao.deleteSession`; `deleteTask` → +`taskDao.deleteTask` (sessions CASCADE) + `deleteAllDependenciesForTask`; `updateTaskDetails` → +upsert the merged entity; `updateSessionLock` → `cacheSession`. All four also lack the connectivity +guard the contract requires. + +## Traps for whoever does the deferred tables + +- **Categories.** `tasks.categoryId` is a `NO_ACTION` FK, so `deleteAllCategories()` inside a replace + throws a constraint error whenever a task references a deleted category. Correct shape, in one + transaction: `UPDATE tasks SET categoryId = NULL WHERE categoryId NOT IN (:ids)` → delete → upsert. + Guard the empty-`ids` case — Room expands `IN ()` into invalid SQLite. +- **Goals.** `listGoals(includeInbox = false)` returns a `PageResponse` and `GoalRemoteDataSourceImpl` + keeps only `.content`. **Replacing from it deletes the user's Inbox goal** and everything past page + 0. Needs pagination surfaced first; until then, upsert only. This is AWAN-205's own rule about + partial responses. +- **Tasks.** `syncScheduleRange` cannot infer task deletion — the response is date-scoped and + unscheduled tasks have no sessions in range. The right hook is the already-written, never-called + `TaskDao.replaceTasksForGoal`, wired to `GET /goals/{id}`. +- **Profile.** Single row, no deletion semantics. Upsert is correct; nothing to do. +- **`deleteAllTemplates` blast radius.** Only `zones` and `template_days_of_week` reference + `templates` today. Anything added later with an FK to `templates` is silently deleted by + `replaceAll` too. + +## Implementation notes (what actually differed) + +### Verification + +`./gradlew assembleDebug testDebugUnitTest lint --rerun-tasks` — all green. `connectedDebugAndroidTest` +**not run**: the DAO tests, including the invalidation test this whole design rests on, need a device. + +### Deviations from the plan + +- **Two one-method interfaces were added that the plan did not call for**, both forced by testability: + `ScheduleSynchronizer` (the one `OfflineSyncCoordinator` method `HomeRepositoryImpl` needs — without + it every Home repository test has to build the coordinator's fifteen dependencies) and + `LocalDataCleaner` (wrapping `database.clearAllTables()`, because `AwanDatabase` is an abstract Room + class a JVM test cannot construct, and the project has no mocking library). +- **The profile zone ViewModels do not call `RefreshZonesUseCase` on load.** They already read the + network directly, and Home refreshes on its own open, so adding it there costs two GETs and changes + nothing the user can see. If those screens ever move to Room reads, it has to go back in. +- **`HomeRepositoryImpl.updateSessionLock` and `updateTaskDetails` write through from the response**, + not from the request values — the server is authoritative and the response is already in hand. + +### Traps for whoever touches this next + +- **`ZoneDao.observeEffectiveZonesForDate` is load-bearing and its correctness is a Room behaviour, not + ours.** Everything reactive about Home depends on Room's invalidation tracker collecting + `template_overrides` and `template_days_of_week` from the *subqueries*. If that ever stops holding, + Home silently goes back to only updating on a day change — the failure is invisible without the + androidTest. Do not "simplify" that query into per-table lookups. +- **`ZonesRepositoryImpl`'s 12 mutations each end in `.suspendOnSuccess { refreshZones() }`.** A + thirteenth mutation that forgets it is invisible at runtime and fails `ZonesRepositoryImplTest`, + which is parameterised over the whole list. Add the new mutation to that list. +- **`replaceAll` deletes all templates and overrides first.** Only `zones` and `template_days_of_week` + hang off them today; anything added later with an FK to `templates` is deleted by it too. + +## Verification + +`./gradlew assembleDebug testDebugUnitTest lint`, plus `connectedDebugAndroidTest` for the DAO tests. +On a device: edit a zone in profile → Home updates without a day change; delete a template on one +device → it is gone on the other rather than merged back; delete a session, force-quit, reopen +offline → still gone; airplane mode → everything still renders from cache and writes report the +network error; install over an existing build → data survives. diff --git a/docs/feature/onboarding/2026-08-05-required-zone-category.md b/docs/feature/onboarding/2026-08-05-required-zone-category.md index c690b588..4dd5beae 100644 --- a/docs/feature/onboarding/2026-08-05-required-zone-category.md +++ b/docs/feature/onboarding/2026-08-05-required-zone-category.md @@ -238,6 +238,17 @@ key parity verified by diff for both touched modules (core:common 15/15, onboard ### Traps for whoever touches this next +- **This change has already been reverted once by a merge, and the test was edited to hide it.** The + AWAN-205 offline-first merge resolved `OnboardingRepositoryImpl` to the pre-AWAN-125 version (no + `categoryId`, no `saveDefaultTemplate`), dropped `categoryId` from the three single-zone request + sites in `ZonesRepositoryImpl`, and changed `an account with no categories gets no template` to pass + `zones = emptyList()` — which passes against a broken impl, so the suite stayed green while every + onboarding template write 422'd on device. If that test does not pass **`Zone.defaults`** (zones with + a null `categoryId`), it is not testing anything. Restored 2026-08-09. +- **`CategoryRepositoryImpl.getCategories()` must not be Room-only.** AWAN-205 made it read Room + exclusively, but categories only reach Room via `SyncWorker`, which onboarding does not wait for — an + empty table means zones with no category, which the repository then silently skips. It now falls back + to a fetch-and-upsert when the table is empty and the device is online. - **`DailyZone.categoryId` is nullable and that is load-bearing, not laziness.** Making it required would break every construction site in profile, which this change deliberately does not touch. The nullability is what lets the two features migrate independently — do not "tighten" it without migrating profile first. diff --git a/feature/add-task/src/test/java/com/awan/feature/addtask/presentation/AddTaskViewModelTest.kt b/feature/add-task/src/test/java/com/awan/feature/addtask/presentation/AddTaskViewModelTest.kt index a9e31854..fb65bf3f 100644 --- a/feature/add-task/src/test/java/com/awan/feature/addtask/presentation/AddTaskViewModelTest.kt +++ b/feature/add-task/src/test/java/com/awan/feature/addtask/presentation/AddTaskViewModelTest.kt @@ -158,6 +158,8 @@ class AddTaskViewModelTest { private class FakeZoneRepository(private val zones: List) : ZonesRepository { var requestedDate: LocalDate? = null + override suspend fun refreshZones(): Result = Result.Success(Unit) + override suspend fun getZonesForDate(date: LocalDate): Result> { requestedDate = date return Result.Success(zones) diff --git a/feature/home/impl/src/main/java/com/awan/feature/home/impl/ui/HomeViewModel.kt b/feature/home/impl/src/main/java/com/awan/feature/home/impl/ui/HomeViewModel.kt index 58916695..8119047e 100644 --- a/feature/home/impl/src/main/java/com/awan/feature/home/impl/ui/HomeViewModel.kt +++ b/feature/home/impl/src/main/java/com/awan/feature/home/impl/ui/HomeViewModel.kt @@ -32,6 +32,8 @@ import com.awan.app.core.domain.home.usecase.CompleteSessionUseCase import com.awan.app.core.domain.home.usecase.GetDayScheduleUseCase import com.awan.app.core.domain.home.usecase.GetUserProfileUseCase import com.awan.app.core.domain.home.usecase.MoveSessionUseCase +import com.awan.app.core.domain.home.usecase.RefreshDayScheduleUseCase +import com.awan.app.core.domain.zones.usecase.RefreshZonesUseCase import com.awan.app.core.domain.home.usecase.UncompleteSessionUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.delay @@ -69,6 +71,8 @@ class HomeViewModel @Inject constructor( private val getWheelConfigUseCase: GetWheelConfigUseCase, private val spinWheelUseCase: SpinWheelUseCase, private val publishWheelRewardUseCase: PublishWheelRewardUseCase, + private val refreshDayScheduleUseCase: RefreshDayScheduleUseCase, + private val refreshZonesUseCase: RefreshZonesUseCase, private val getSessionDetailUseCase: GetSessionDetailUseCase, private val updateTaskDetailUseCase: UpdateTaskDetailUseCase, private val deleteSessionUseCase: DeleteSessionUseCase, @@ -230,6 +234,17 @@ class HomeViewModel @Inject constructor( } } } + refreshFromServer(date) + } + + /** + * Room already rendered above; this only refills it. A failure is deliberately silent — the day + * on screen is real data, and replacing it with an error because a background pull failed is + * worse than being briefly stale. Offline is the common case here, not an incident. + */ + private fun refreshFromServer(date: LocalDate) { + viewModelScope.launch { refreshDayScheduleUseCase(date) } + viewModelScope.launch { refreshZonesUseCase() } } private fun applySchedule(schedule: DaySchedule, isToday: Boolean) { diff --git a/feature/onboarding/impl/src/main/java/com/awan/feature/onboarding/impl/presentation/OnboardingViewModel.kt b/feature/onboarding/impl/src/main/java/com/awan/feature/onboarding/impl/presentation/OnboardingViewModel.kt index d5d3f1ee..2974ca1c 100644 --- a/feature/onboarding/impl/src/main/java/com/awan/feature/onboarding/impl/presentation/OnboardingViewModel.kt +++ b/feature/onboarding/impl/src/main/java/com/awan/feature/onboarding/impl/presentation/OnboardingViewModel.kt @@ -19,6 +19,7 @@ import com.awan.app.core.domain.profile.model.UserProfile import com.awan.app.core.domain.zones.model.Zone import com.awan.app.core.domain.category.usecase.GetCategoriesUseCase import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -49,16 +50,14 @@ class OnboardingViewModel @Inject constructor( private val _events = Channel() val events = _events.receiveAsFlow() - init { - loadCategories() - } + private var categoriesLoad: Job = loadCategories() /** * The backend rejects a zone without a category, so the zones step needs the user's own list * before it can produce a saveable day. A failure is not fatal: the step still works, the sheet * says there are no categories, and the repository skips the template rather than 422-ing. */ - private fun loadCategories() { + private fun loadCategories(): Job = viewModelScope.launch { val categories = (getCategories() as? Result.Success)?.data ?: return@launch _state.update { @@ -68,6 +67,18 @@ class OnboardingViewModel @Inject constructor( ) } } + + /** + * Skipping the setup reaches the submit before the initial load lands, and the repository drops a + * zone that has no category — the skipping user would silently get no zone template at all. So the + * hand-off waits for the load, and retries it once if it has not produced anything yet. + */ + private suspend fun awaitZoneCategories() { + categoriesLoad.join() + if (_state.value.availableCategories.isEmpty()) { + categoriesLoad = loadCategories() + categoriesLoad.join() + } } fun onAction(action: OnboardingAction) { @@ -165,8 +176,9 @@ class OnboardingViewModel @Inject constructor( * account permanently half-configured, so a failure blocks the exit instead of navigating on. */ private suspend fun submitOnboarding(): Boolean { - val s = _state.value if (!isBackendOnboarded) { + awaitZoneCategories() + val s = _state.value val data = OnboardingData( profile = UserProfile(s.trimmedFirstName, s.lastName.trim()), bounds = s.bounds, diff --git a/feature/onboarding/impl/src/test/java/com/awan/feature/onboarding/impl/presentation/FakeCategoryRepository.kt b/feature/onboarding/impl/src/test/java/com/awan/feature/onboarding/impl/presentation/FakeCategoryRepository.kt index e73df8be..de388fec 100644 --- a/feature/onboarding/impl/src/test/java/com/awan/feature/onboarding/impl/presentation/FakeCategoryRepository.kt +++ b/feature/onboarding/impl/src/test/java/com/awan/feature/onboarding/impl/presentation/FakeCategoryRepository.kt @@ -4,14 +4,23 @@ import com.awan.app.core.common.error.AppError import com.awan.app.core.common.result.Result import com.awan.app.core.domain.category.repository.CategoryRepository import com.awan.app.core.model.Category +import kotlinx.coroutines.delay class FakeCategoryRepository( private val categories: List = DEFAULT_SEEDED, private val failWith: AppError? = null, + /** Stands in for the round-trip a skipping user can outrun. */ + private val loadDelayMillis: Long = 0, ) : CategoryRepository { - override suspend fun getCategories(): Result> = - failWith?.let { Result.Error(it) } ?: Result.Success(categories) + var callCount = 0 + private set + + override suspend fun getCategories(): Result> { + callCount++ + if (loadDelayMillis > 0) delay(loadDelayMillis) + return failWith?.let { Result.Error(it) } ?: Result.Success(categories) + } override suspend fun createCategory(name: String): Result = error("not used") override suspend fun getCategory(categoryId: String): Result = error("not used") diff --git a/feature/onboarding/impl/src/test/java/com/awan/feature/onboarding/impl/presentation/OnboardingViewModelTest.kt b/feature/onboarding/impl/src/test/java/com/awan/feature/onboarding/impl/presentation/OnboardingViewModelTest.kt index 41f98143..d2cca82f 100644 --- a/feature/onboarding/impl/src/test/java/com/awan/feature/onboarding/impl/presentation/OnboardingViewModelTest.kt +++ b/feature/onboarding/impl/src/test/java/com/awan/feature/onboarding/impl/presentation/OnboardingViewModelTest.kt @@ -19,6 +19,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain @@ -108,6 +109,30 @@ class OnboardingViewModelTest { assertTrue(vm.state.value.zones.all { it.categoryId == null }) } + /** The whole point of Skip is being fast, so it beats the category fetch every time. */ + @Test + fun `skipping the setup still sends the default zones with their categories`() = runTest(testDispatcher) { + val vm = viewModel(FakeCategoryRepository(loadDelayMillis = 1_000)) + + vm.onAction(OnboardingAction.SkipSetup) + advanceUntilIdle() + + val sent = repository.lastCompletedData?.zones.orEmpty() + assertEquals(4, sent.size) + assertTrue(sent.all { it.categoryId != null }) + } + + @Test + fun `a category load that came back empty is retried before the hand-off`() = runTest(testDispatcher) { + val categoryRepository = FakeCategoryRepository(categories = emptyList()) + val vm = viewModel(categoryRepository) + + vm.onAction(OnboardingAction.SkipSetup) + advanceUntilIdle() + + assertEquals(2, categoryRepository.callCount) + } + @Test fun `continue on the name step is gated on a non-blank first name`() = runTest(testDispatcher) { assertFalse(viewModel.state.value.canContinueName) From 9153a9b85c4a09b1ade9bbe58d3b547943e25fc4 Mon Sep 17 00:00:00 2001 From: Mohannad El-Sayeh Date: Mon, 10 Aug 2026 16:45:40 +0300 Subject: [PATCH 2/7] AWAN-149: parse the offset-free timestamps the API actually sends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving a session updated the UI, then snapped back, and Room never changed. - extractTimeFromIso tried OffsetDateTime then LocalTime, and the API sends neither: "2026-08-09T14:30:00" has no offset and is not a bare time. Every caller silently kept its fallback, which in cacheSession is the session's existing time — so a moved session was rewritten with the time it already had. The sync path never showed it because SessionDto.toEntity uses substring instead, so the read and write paths disagreed on the same field - Add the LocalDateTime attempt, and truncate to seconds so sub-second precision cannot leak into an HH:mm:ss column --- .../awan/app/core/data/common/IsoTimeUtils.kt | 24 ++++++++--- .../app/core/data/common/IsoTimeUtilsTest.kt | 41 +++++++++++++++++++ 2 files changed, 59 insertions(+), 6 deletions(-) create mode 100644 core/data/src/test/java/com/awan/app/core/data/common/IsoTimeUtilsTest.kt diff --git a/core/data/src/main/kotlin/com/awan/app/core/data/common/IsoTimeUtils.kt b/core/data/src/main/kotlin/com/awan/app/core/data/common/IsoTimeUtils.kt index 23e76414..1ac20148 100644 --- a/core/data/src/main/kotlin/com/awan/app/core/data/common/IsoTimeUtils.kt +++ b/core/data/src/main/kotlin/com/awan/app/core/data/common/IsoTimeUtils.kt @@ -1,29 +1,41 @@ package com.awan.app.core.data.common +import java.time.LocalDateTime import java.time.LocalTime import java.time.OffsetDateTime import java.time.format.DateTimeFormatter import java.time.format.DateTimeParseException +import java.time.temporal.ChronoUnit /** * Extracts the time portion (HH:mm:ss) from an ISO datetime string. * Falls back to [fallback] if the string cannot be parsed. + * + * The offset-free form is the one the API actually sends (`"2026-07-22T09:00:00"`), and it parses as + * neither [OffsetDateTime] nor [LocalTime] — leaving it out made every caller silently keep its + * fallback, so a moved session wrote its old time straight back to Room. */ internal fun extractTimeFromIso(isoDateTime: String, fallback: String = "00:00:00"): String { return try { - val odt = OffsetDateTime.parse(isoDateTime) - odt.toLocalTime().format(DateTimeFormatter.ISO_LOCAL_TIME) + OffsetDateTime.parse(isoDateTime).toLocalTime().asStoredTime() } catch (_: DateTimeParseException) { - // Try just the time portion for already-extracted times try { - LocalTime.parse(isoDateTime) - isoDateTime + LocalDateTime.parse(isoDateTime).toLocalTime().asStoredTime() } catch (_: DateTimeParseException) { - fallback + // Already just a time portion. + try { + LocalTime.parse(isoDateTime).asStoredTime() + } catch (_: DateTimeParseException) { + fallback + } } } } +/** Rows are `HH:mm:ss`; `createdAt`-style sub-second precision must not leak into one. */ +private fun LocalTime.asStoredTime(): String = + truncatedTo(ChronoUnit.SECONDS).format(DateTimeFormatter.ISO_LOCAL_TIME) + /** * Extracts the date portion (YYYY-MM-DD) from an ISO datetime string. * Falls back to [fallback] if the string cannot be parsed. diff --git a/core/data/src/test/java/com/awan/app/core/data/common/IsoTimeUtilsTest.kt b/core/data/src/test/java/com/awan/app/core/data/common/IsoTimeUtilsTest.kt new file mode 100644 index 00000000..cc03e083 --- /dev/null +++ b/core/data/src/test/java/com/awan/app/core/data/common/IsoTimeUtilsTest.kt @@ -0,0 +1,41 @@ +package com.awan.app.core.data.common + +import org.junit.Assert.assertEquals +import org.junit.Test + +class IsoTimeUtilsTest { + + /** + * The shape the API actually sends (`docs/feature/backend/AWAN_API_DOCUMENTATION.md:706`) — no + * offset. It parses as neither `OffsetDateTime` nor `LocalTime`, which is why moving a session + * used to write its *old* time back to Room and the card snapped back on screen. + */ + @Test + fun `an offset-free server timestamp keeps its time`() { + assertEquals("09:00:00", extractTimeFromIso("2026-07-22T09:00:00")) + assertEquals("2026-07-22", extractDateFromIso("2026-07-22T09:00:00")) + } + + @Test + fun `a zoned timestamp keeps its time`() { + assertEquals("09:00:00", extractTimeFromIso("2026-07-22T09:00:00Z")) + assertEquals("2026-07-22", extractDateFromIso("2026-07-22T09:00:00Z")) + } + + @Test + fun `an already-extracted time is passed through`() { + assertEquals("09:00:00", extractTimeFromIso("09:00:00")) + } + + @Test + fun `an unparseable value falls back rather than throwing`() { + assertEquals("07:30:00", extractTimeFromIso("not a time", fallback = "07:30:00")) + assertEquals("00:00:00", extractTimeFromIso("not a time")) + } + + /** Sub-second precision appears on `createdAt`; it must not defeat the parse. */ + @Test + fun `fractional seconds are tolerated`() { + assertEquals("10:44:03", extractTimeFromIso("2026-08-09T10:44:03.820200")) + } +} From 1a30f8dc79379e6f7df3a1f56632441274ec4920 Mon Sep 17 00:00:00 2001 From: Mohannad El-Sayeh Date: Mon, 10 Aug 2026 16:45:57 +0300 Subject: [PATCH 3/7] AWAN-149: give Home a local data source instead of five DAOs - Add HomeLocalDataSource as the single owner of the Home feature's Room reads and writes; HomeRepositoryImpl no longer holds a DAO, so what the timeline caches is decided in one file - Move the session cache rule there and cover it directly: a moved session stores its new times, one dragged past midnight changes its date, a response without a status keeps the stored one, and an uncached row reports that it wrote nothing instead of returning silently - Update the row's date on a move; a session dragged to another day used to keep the old day's date and disappear from both - Collapse five DAO fakes in HomeRepositoryImplTest into one --- .../com/awan/app/core/data/di/DataModule.kt | 8 + .../data/home/local/HomeLocalDataSource.kt | 116 +++++++++++ .../home/repository/HomeRepositoryImpl.kt | 71 ++----- .../core/data/home/HomeRepositoryImplTest.kt | 144 +++---------- .../home/local/HomeLocalDataSourceTest.kt | 193 ++++++++++++++++++ 5 files changed, 365 insertions(+), 167 deletions(-) create mode 100644 core/data/src/main/kotlin/com/awan/app/core/data/home/local/HomeLocalDataSource.kt create mode 100644 core/data/src/test/java/com/awan/app/core/data/home/local/HomeLocalDataSourceTest.kt diff --git a/core/data/src/main/kotlin/com/awan/app/core/data/di/DataModule.kt b/core/data/src/main/kotlin/com/awan/app/core/data/di/DataModule.kt index 30918f93..2f828113 100644 --- a/core/data/src/main/kotlin/com/awan/app/core/data/di/DataModule.kt +++ b/core/data/src/main/kotlin/com/awan/app/core/data/di/DataModule.kt @@ -6,6 +6,8 @@ import com.awan.app.core.data.auth.repository.AuthRepositoryImpl import com.awan.app.core.data.calendar.CalendarRepositoryImpl import com.awan.app.core.data.calendar.local.CalendarLocalDataSource import com.awan.app.core.data.calendar.local.CalendarLocalDataSourceImpl +import com.awan.app.core.data.home.local.HomeLocalDataSource +import com.awan.app.core.data.home.local.HomeLocalDataSourceImpl import com.awan.app.core.data.zones.local.ZonesLocalDataSource import com.awan.app.core.data.zones.local.ZonesLocalDataSourceImpl import com.awan.app.core.data.auth.LocalDataCleaner @@ -86,6 +88,12 @@ internal abstract class DataModule { impl: CalendarLocalDataSourceImpl, ): CalendarLocalDataSource + @Binds + @Singleton + abstract fun bindHomeLocalDataSource( + impl: HomeLocalDataSourceImpl, + ): HomeLocalDataSource + @Binds @Singleton abstract fun bindZonesLocalDataSource( diff --git a/core/data/src/main/kotlin/com/awan/app/core/data/home/local/HomeLocalDataSource.kt b/core/data/src/main/kotlin/com/awan/app/core/data/home/local/HomeLocalDataSource.kt new file mode 100644 index 00000000..bcb9039e --- /dev/null +++ b/core/data/src/main/kotlin/com/awan/app/core/data/home/local/HomeLocalDataSource.kt @@ -0,0 +1,116 @@ +package com.awan.app.core.data.home.local + +import com.awan.app.core.data.common.extractDateFromIso +import com.awan.app.core.data.common.extractTimeFromIso +import com.awan.app.core.database.dao.CategoryDao +import com.awan.app.core.database.dao.SessionDao +import com.awan.app.core.database.dao.TaskDao +import com.awan.app.core.database.dao.UserDao +import com.awan.app.core.database.dao.ZoneDao +import com.awan.app.core.database.model.CategoryEntity +import com.awan.app.core.database.model.SessionEntity +import com.awan.app.core.database.model.TaskEntity +import com.awan.app.core.database.model.UserEntity +import com.awan.app.core.database.model.ZoneEntity +import com.awan.app.core.network.dto.session.SessionDto +import com.awan.app.core.network.dto.task.TaskInfoResponse +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Every Room read and write the Home feature makes. `HomeRepositoryImpl` holds no DAOs, so what the + * timeline caches is decided in one file and a repository test needs one fake instead of five. + */ +interface HomeLocalDataSource { + + fun observeSessionsForDate(date: String): Flow> + + fun observeEffectiveZonesForDate(date: String, dayOfWeek: String): Flow> + + suspend fun getTask(taskId: String): TaskEntity? + + suspend fun getCategory(categoryId: String): CategoryEntity? + + suspend fun getCachedUser(): UserEntity? + + suspend fun upsertUser(user: UserEntity) + + /** + * Mirrors the server's copy of a session. Returns false when the row is not cached yet — the + * caller is looking at a session this device has never synced, and there is nothing to update. + */ + suspend fun cacheSession(session: SessionDto): Boolean + + suspend fun deleteSession(sessionId: String) + + /** Applies a task edit to the cached row, leaving fields the response omitted alone. */ + suspend fun cacheTask(taskId: String, task: TaskInfoResponse) + + suspend fun deleteTask(taskId: String) +} + +@Singleton +class HomeLocalDataSourceImpl @Inject constructor( + private val userDao: UserDao, + private val taskDao: TaskDao, + private val sessionDao: SessionDao, + private val zoneDao: ZoneDao, + private val categoryDao: CategoryDao, +) : HomeLocalDataSource { + + override fun observeSessionsForDate(date: String): Flow> = + sessionDao.observeSessionsForDate(date) + + override fun observeEffectiveZonesForDate(date: String, dayOfWeek: String): Flow> = + zoneDao.observeEffectiveZonesForDate(date, dayOfWeek) + + override suspend fun getTask(taskId: String): TaskEntity? = taskDao.getTask(taskId) + + override suspend fun getCategory(categoryId: String): CategoryEntity? = categoryDao.getCategory(categoryId) + + override suspend fun getCachedUser(): UserEntity? = userDao.getFirstUser() + + override suspend fun upsertUser(user: UserEntity) = userDao.upsertUser(user) + + /** + * The status falls back to what is stored rather than to a guess, so moving a completed session + * cannot silently reopen it. The date is taken from the response too — a session dragged past + * midnight belongs to the new day, and leaving it behind would strand it on the old one. + */ + override suspend fun cacheSession(session: SessionDto): Boolean { + val existing = sessionDao.getSession(session.id) ?: return false + sessionDao.upsertSession( + existing.copy( + status = session.status ?: existing.status, + date = extractDateFromIso(session.start, existing.date), + startTime = extractTimeFromIso(session.start, existing.startTime), + endTime = extractTimeFromIso(session.end, existing.endTime), + locked = session.locked, + ) + ) + return true + } + + override suspend fun deleteSession(sessionId: String) = sessionDao.deleteSession(sessionId) + + override suspend fun cacheTask(taskId: String, task: TaskInfoResponse) { + val existing = taskDao.getTask(taskId) ?: return + taskDao.upsertTask( + existing.copy( + title = task.title, + description = task.description ?: existing.description, + estimatedDuration = task.estimatedDuration ?: existing.estimatedDuration, + estimatedPoints = task.estimatedPoints ?: existing.estimatedPoints, + mandatory = task.mandatory ?: existing.mandatory, + allowTaskSplitting = task.allowTaskSplitting ?: existing.allowTaskSplitting, + ) + ) + } + + /** Sessions CASCADE from tasks; dependencies do not carry the task's own row away. */ + override suspend fun deleteTask(taskId: String) { + taskDao.deleteAllDependenciesForTask(taskId) + taskDao.deleteTask(taskId) + } +} diff --git a/core/data/src/main/kotlin/com/awan/app/core/data/home/repository/HomeRepositoryImpl.kt b/core/data/src/main/kotlin/com/awan/app/core/data/home/repository/HomeRepositoryImpl.kt index 84d6260f..b27aa892 100644 --- a/core/data/src/main/kotlin/com/awan/app/core/data/home/repository/HomeRepositoryImpl.kt +++ b/core/data/src/main/kotlin/com/awan/app/core/data/home/repository/HomeRepositoryImpl.kt @@ -6,13 +6,9 @@ import com.awan.app.core.common.error.AppError import com.awan.app.core.common.result.Result import com.awan.app.core.data.gamification.GamificationEventBus import com.awan.app.core.data.gamification.mapper.toDomain -import com.awan.app.core.database.dao.CategoryDao -import com.awan.app.core.database.dao.SessionDao -import com.awan.app.core.database.dao.TaskDao -import com.awan.app.core.database.dao.UserDao -import com.awan.app.core.database.dao.ZoneDao import com.awan.app.core.database.model.UserEntity import com.awan.app.core.database.model.ZoneEntity +import com.awan.app.core.data.home.local.HomeLocalDataSource import com.awan.app.core.data.home.remote.HomeRemoteDataSource import com.awan.app.core.data.sync.ScheduleSynchronizer import com.awan.app.core.network.dto.session.SessionDto @@ -32,7 +28,7 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext import android.util.Log -import com.awan.app.core.data.common.extractTimeFromIso +import com.awan.app.core.common.result.map import java.time.LocalDate import java.time.LocalTime import javax.inject.Inject @@ -44,12 +40,8 @@ import com.awan.app.core.model.TaskDetailInfo @Singleton class HomeRepositoryImpl @Inject constructor( private val remoteDataSource: HomeRemoteDataSource, - private val userDao: UserDao, + private val local: HomeLocalDataSource, private val eventBus: GamificationEventBus, - private val taskDao: TaskDao, - private val sessionDao: SessionDao, - private val zoneDao: ZoneDao, - private val categoryDao: CategoryDao, private val scheduleSynchronizer: ScheduleSynchronizer, private val connectivityMonitor: NetworkConnectivityMonitor, @Dispatcher(AwanDispatchers.IO) private val ioDispatcher: CoroutineDispatcher, @@ -64,7 +56,7 @@ class HomeRepositoryImpl @Inject constructor( val lastName = dto.lastName ?: "" val points = dto.points ?: 0 val streak = dto.streak ?: 0 - userDao.upsertUser( + local.upsertUser( UserEntity( id = dto.id, email = dto.email ?: "", @@ -81,7 +73,7 @@ class HomeRepositoryImpl @Inject constructor( } } - val cachedUser = userDao.getFirstUser() + val cachedUser = local.getCachedUser() if (cachedUser != null) { Result.Success( UserProfileInfo( @@ -116,12 +108,12 @@ class HomeRepositoryImpl @Inject constructor( val dateStr = date.toString() return combine( - sessionDao.observeSessionsForDate(dateStr), - zoneDao.observeEffectiveZonesForDate(dateStr, date.dayOfWeek.name), + local.observeSessionsForDate(dateStr), + local.observeEffectiveZonesForDate(dateStr, date.dayOfWeek.name), ) { sessionEntities, zoneEntities -> val daySessions = sessionEntities.mapNotNull { s -> - val task = taskDao.getTask(s.taskId) ?: return@mapNotNull null - val category = task.categoryId?.let { categoryDao.getCategory(it) } + val task = local.getTask(s.taskId) ?: return@mapNotNull null + val category = task.categoryId?.let { local.getCategory(it) } val startLocalTime = parseLocalTime(s.startTime) val endLocalTime = parseLocalTime(s.endTime) val startMinutes = startLocalTime.hour * 60 + startLocalTime.minute @@ -220,7 +212,7 @@ class HomeRepositoryImpl @Inject constructor( } when (val result = remoteDataSource.completeSession(sessionId)) { is Result.Success -> { - cacheSession(result.data.session) + local.cacheSession(result.data.session) val reward = result.data.reward.toDomain() // Published here rather than from the caller so any future path that completes // a session celebrates identically, without each one remembering to. @@ -256,7 +248,7 @@ class HomeRepositoryImpl @Inject constructor( } when (val result = call()) { is Result.Success -> { - cacheSession(result.data) + local.cacheSession(result.data) Result.Success(Unit) } is Result.Error -> Result.Error(result.error) @@ -264,25 +256,6 @@ class HomeRepositoryImpl @Inject constructor( } } - /** - * Mirrors the server's copy of a session into Room, which the schedule flow observes — without - * this the timeline keeps showing the old state until something forces a refresh. - * - * The status falls back to what is already stored rather than to a guess, so moving a completed - * session cannot silently reopen it. - */ - private suspend fun cacheSession(dto: SessionDto) { - val existing = sessionDao.getSession(dto.id) ?: return - sessionDao.upsertSession( - existing.copy( - status = dto.status ?: existing.status, - startTime = extractTimeFromIso(dto.start, existing.startTime), - endTime = extractTimeFromIso(dto.end, existing.endTime), - locked = dto.locked, - ) - ) - } - private fun parseLocalTime(timeStr: String): LocalTime { return try { LocalTime.parse(timeStr) @@ -327,7 +300,7 @@ class HomeRepositoryImpl @Inject constructor( } return when (result) { is Result.Success -> { - cacheSession(result.data) + local.cacheSession(result.data) Result.Success(Unit) } is Result.Error -> Result.Error(result.error) @@ -355,19 +328,7 @@ class HomeRepositoryImpl @Inject constructor( val result = remoteDataSource.updateTask(taskId, request) return when (result) { is Result.Success -> { - val dto = result.data - taskDao.getTask(taskId)?.let { existing -> - taskDao.upsertTask( - existing.copy( - title = dto.title, - description = dto.description ?: existing.description, - estimatedDuration = dto.estimatedDuration ?: existing.estimatedDuration, - estimatedPoints = dto.estimatedPoints ?: existing.estimatedPoints, - mandatory = dto.mandatory ?: existing.mandatory, - allowTaskSplitting = dto.allowTaskSplitting ?: existing.allowTaskSplitting, - ) - ) - } + local.cacheTask(taskId, result.data) Result.Success(Unit) } is Result.Error -> Result.Error(result.error) @@ -380,7 +341,7 @@ class HomeRepositoryImpl @Inject constructor( val result = remoteDataSource.deleteSession(sessionId) return when (result) { is Result.Success -> { - sessionDao.deleteSession(sessionId) + local.deleteSession(sessionId) Result.Success(Unit) } is Result.Error -> Result.Error(result.error) @@ -393,9 +354,7 @@ class HomeRepositoryImpl @Inject constructor( val result = remoteDataSource.deleteTask(taskId, cascade = true) return when (result) { is Result.Success -> { - // Sessions CASCADE from tasks; dependencies do not carry the task's own row away. - taskDao.deleteAllDependenciesForTask(taskId) - taskDao.deleteTask(taskId) + local.deleteTask(taskId) Result.Success(Unit) } is Result.Error -> Result.Error(result.error) diff --git a/core/data/src/test/java/com/awan/app/core/data/home/HomeRepositoryImplTest.kt b/core/data/src/test/java/com/awan/app/core/data/home/HomeRepositoryImplTest.kt index a166b33e..e63236b4 100644 --- a/core/data/src/test/java/com/awan/app/core/data/home/HomeRepositoryImplTest.kt +++ b/core/data/src/test/java/com/awan/app/core/data/home/HomeRepositoryImplTest.kt @@ -91,95 +91,33 @@ private class FakeHomeRemoteDataSource : HomeRemoteDataSource { override suspend fun deleteTask(taskId: String, cascade: Boolean): Result = Result.Success(Unit) } -private class FakeUserDao : UserDao { - override suspend fun upsertUser(user: UserEntity) {} - override fun observeUser(userId: String): Flow = flowOf(null) - override suspend fun getUser(userId: String): UserEntity? = null - override suspend fun getFirstUser(): UserEntity? = null - override suspend fun deleteUser(userId: String) {} - override suspend fun upsertPreferences(preferences: UserPreferencesEntity) {} - override fun observePreferences(userId: String): Flow = flowOf(null) - override suspend fun getPreferences(userId: String): UserPreferencesEntity? = null - override fun observeUserWithPreferences(userId: String): Flow = flowOf(null) - override suspend fun getUserWithPreferences(userId: String): UserWithPreferences? = null - override suspend fun getMinExpiryTime(): Long? = null -} - -private class FakeTaskDao : com.awan.app.core.database.dao.TaskDao { - override suspend fun upsertTask(task: com.awan.app.core.database.model.TaskEntity) {} - override suspend fun upsertTasks(tasks: List) {} - override fun observeTasksByGoal(goalId: String): Flow> = flowOf(emptyList()) - override fun observeInboxTasks(): Flow> = flowOf(emptyList()) - override fun observeAllTasks(): Flow> = flowOf(emptyList()) - override suspend fun getAllTasks(): List = emptyList() - override fun observeTask(taskId: String): Flow = flowOf(null) - override suspend fun getTask(taskId: String): com.awan.app.core.database.model.TaskEntity? = null - override suspend fun deleteTask(taskId: String) {} - override suspend fun upsertDependency(dependency: com.awan.app.core.database.model.TaskDependencyEntity) {} - override suspend fun upsertDependencies(dependencies: List) {} - override suspend fun deleteDependency(dependency: com.awan.app.core.database.model.TaskDependencyEntity) {} - override fun observeDependsOnIds(taskId: String): Flow> = flowOf(emptyList()) - override fun observeDependentIds(taskId: String): Flow> = flowOf(emptyList()) - override suspend fun deleteAllDependenciesForTask(taskId: String) {} - override suspend fun deleteTasksByGoal(goalId: String) {} - override suspend fun nullifyOrphanedGoalReferences() {} -} - -private class FakeSessionDao : com.awan.app.core.database.dao.SessionDao { - override suspend fun upsertSession(session: com.awan.app.core.database.model.SessionEntity) {} - override suspend fun upsertSessions(sessions: List) {} - override fun observeSessionsForDate(date: String): Flow> = flowOf(emptyList()) - override fun observeSessionsForDateRange(startDate: String, endDate: String): Flow> = flowOf(emptyList()) - override suspend fun getSessionsForDate(date: String): List = emptyList() - override suspend fun getSessionsForDateRange(startDate: String, endDate: String): List = emptyList() - override suspend fun getSession(id: String): com.awan.app.core.database.model.SessionEntity? = null - override suspend fun deleteSessionsForDates(dates: List) {} - override suspend fun deleteSession(id: String) {} -} - -private class FakeZoneDao( - private val templateZones: List = emptyList(), - val effectiveZones: MutableStateFlow> = MutableStateFlow(templateZones), -) : ZoneDao { - override fun observeEffectiveZonesForDate(date: String, dayOfWeek: String): Flow> = - effectiveZones - override suspend fun upsertZone(zone: com.awan.app.core.database.model.ZoneEntity) {} - override suspend fun upsertZones(zones: List) {} - override fun observeZone(zoneId: String): Flow = flowOf(null) - override suspend fun getZone(zoneId: String): com.awan.app.core.database.model.ZoneEntity? = null - override fun observeZonesForTemplate(templateId: String): Flow> = flowOf(templateZones) - override fun observeZonesForOverride(overrideId: String): Flow> = flowOf(emptyList()) - override suspend fun deleteZone(zoneId: String) {} - override suspend fun deleteZonesForTemplate(templateId: String) {} - override suspend fun deleteZonesForOverride(overrideId: String) {} -} - -private class FakeTemplateDao( - private val dayAssignment: TemplateDayOfWeekEntity? = null, -) : TemplateDao { - override suspend fun upsertTemplate(template: com.awan.app.core.database.model.TemplateEntity) {} - override suspend fun upsertTemplates(templates: List) {} - override fun observeAllTemplates(): Flow> = flowOf(emptyList()) - override fun observeTemplate(templateId: String): Flow = flowOf(null) - override suspend fun getTemplate(templateId: String): com.awan.app.core.database.model.TemplateEntity? = null - override suspend fun deleteTemplate(templateId: String) {} - override suspend fun deleteAllTemplates() {} - override suspend fun getMinExpiryTime(): Long? = null - override suspend fun upsertDays(days: List) {} - override fun observeDaysForTemplate(templateId: String): Flow> = flowOf(emptyList()) - override suspend fun getDayAssignment(dayOfWeek: String): TemplateDayOfWeekEntity? = dayAssignment - override suspend fun deleteDaysForTemplate(templateId: String) {} -} - -private class FakeTemplateOverrideDao : com.awan.app.core.database.dao.TemplateOverrideDao { - override suspend fun upsertOverride(override: com.awan.app.core.database.model.TemplateOverrideEntity) {} - override suspend fun upsertOverrides(overrides: List) {} - override fun observeAllOverrides(): Flow> = flowOf(emptyList()) - override fun observeOverride(overrideId: String): Flow = flowOf(null) - override suspend fun getOverride(overrideId: String): com.awan.app.core.database.model.TemplateOverrideEntity? = null - override suspend fun getOverrideForDate(date: String): com.awan.app.core.database.model.TemplateOverrideEntity? = null - override suspend fun deleteOverride(overrideId: String) {} - override suspend fun deleteAllOverrides() {} +private class FakeHomeLocalDataSource( + templateZones: List = emptyList(), + private val tasks: Map = emptyMap(), +) : com.awan.app.core.data.home.local.HomeLocalDataSource { + + val effectiveZones = MutableStateFlow(templateZones) + val sessions = MutableStateFlow(emptyList()) + var cachedSessions = mutableListOf() + var deletedSessions = mutableListOf() + var deletedTasks = mutableListOf() + var cachedTasks = mutableListOf() + var upsertedUsers = mutableListOf() + var storedUser: UserEntity? = null + + override fun observeSessionsForDate(date: String) = sessions + override fun observeEffectiveZonesForDate(date: String, dayOfWeek: String) = effectiveZones + override suspend fun getTask(taskId: String) = tasks[taskId] + override suspend fun getCategory(categoryId: String): com.awan.app.core.database.model.CategoryEntity? = null + override suspend fun getCachedUser() = storedUser + override suspend fun upsertUser(user: UserEntity) { upsertedUsers += user; storedUser = user } + override suspend fun cacheSession(session: SessionDto): Boolean { + cachedSessions += session + return true + } + override suspend fun deleteSession(sessionId: String) { deletedSessions += sessionId } + override suspend fun cacheTask(taskId: String, task: TaskInfoResponse) { cachedTasks += taskId } + override suspend fun deleteTask(taskId: String) { deletedTasks += taskId } } private class FakeScheduleSynchronizer : com.awan.app.core.data.sync.ScheduleSynchronizer { @@ -196,17 +134,6 @@ private class FakeScheduleSynchronizer : com.awan.app.core.data.sync.ScheduleSyn } } -private class FakeCategoryDao : com.awan.app.core.database.dao.CategoryDao { - override suspend fun upsertCategories(categories: List) {} - override suspend fun upsertCategory(category: com.awan.app.core.database.model.CategoryEntity) {} - override fun observeAllCategories(): Flow> = flowOf(emptyList()) - override suspend fun getAllCategories(): List = emptyList() - override suspend fun getCategory(id: String): com.awan.app.core.database.model.CategoryEntity? = null - override suspend fun deleteCategory(id: String) {} - override suspend fun deleteAllCategories() {} - override suspend fun getMinExpiryTime(): Long? = null -} - private class AlwaysOnlineMonitor : com.awan.app.core.domain.network.NetworkConnectivityMonitor { override val isOnline: Flow = flowOf(true) override fun isCurrentlyOnline(): Boolean = true @@ -218,18 +145,13 @@ class HomeRepositoryImplTest { private fun createRepository( fakeRemote: HomeRemoteDataSource, - zoneDao: ZoneDao = FakeZoneDao(), - sessionDao: SessionDao = FakeSessionDao(), + local: FakeHomeLocalDataSource = FakeHomeLocalDataSource(), scheduleSynchronizer: com.awan.app.core.data.sync.ScheduleSynchronizer = FakeScheduleSynchronizer(), ): HomeRepositoryImpl { return HomeRepositoryImpl( remoteDataSource = fakeRemote, - userDao = FakeUserDao(), + local = local, eventBus = eventBus, - taskDao = FakeTaskDao(), - sessionDao = sessionDao, - zoneDao = zoneDao, - categoryDao = FakeCategoryDao(), scheduleSynchronizer = scheduleSynchronizer, connectivityMonitor = AlwaysOnlineMonitor(), ioDispatcher = kotlinx.coroutines.Dispatchers.Unconfined, @@ -410,7 +332,7 @@ class HomeRepositoryImplTest { val date = java.time.LocalDate.of(2026, 8, 9) val repository = createRepository( fakeRemote = FakeHomeRemoteDataSource(), - zoneDao = FakeZoneDao( + local = FakeHomeLocalDataSource( templateZones = listOf( ZoneEntity( id = "zone-study", @@ -437,8 +359,8 @@ class HomeRepositoryImplTest { */ @Test fun `getDaySchedule re-emits when only the zones change`() = runTest { - val zoneDao = FakeZoneDao() - val repository = createRepository(fakeRemote = FakeHomeRemoteDataSource(), zoneDao = zoneDao) + val local = FakeHomeLocalDataSource() + val repository = createRepository(fakeRemote = FakeHomeRemoteDataSource(), local = local) val emissions = mutableListOf() val job = launch(UnconfinedTestDispatcher(testScheduler)) { @@ -447,7 +369,7 @@ class HomeRepositoryImplTest { } } - zoneDao.effectiveZones.value = listOf( + local.effectiveZones.value = listOf( ZoneEntity( id = "zone-work", name = "Work", diff --git a/core/data/src/test/java/com/awan/app/core/data/home/local/HomeLocalDataSourceTest.kt b/core/data/src/test/java/com/awan/app/core/data/home/local/HomeLocalDataSourceTest.kt new file mode 100644 index 00000000..cd49b4b0 --- /dev/null +++ b/core/data/src/test/java/com/awan/app/core/data/home/local/HomeLocalDataSourceTest.kt @@ -0,0 +1,193 @@ +package com.awan.app.core.data.home.local + +import com.awan.app.core.database.dao.CategoryDao +import com.awan.app.core.database.dao.SessionDao +import com.awan.app.core.database.dao.TaskDao +import com.awan.app.core.database.dao.UserDao +import com.awan.app.core.database.dao.ZoneDao +import com.awan.app.core.database.model.CategoryEntity +import com.awan.app.core.database.model.SessionEntity +import com.awan.app.core.database.model.TaskDependencyEntity +import com.awan.app.core.database.model.TaskEntity +import com.awan.app.core.database.model.UserEntity +import com.awan.app.core.database.model.UserPreferencesEntity +import com.awan.app.core.database.model.UserWithPreferences +import com.awan.app.core.database.model.ZoneEntity +import com.awan.app.core.network.dto.session.SessionDto +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class HomeLocalDataSourceTest { + + private val sessionDao = FakeSessionDao() + private val taskDao = FakeTaskDao() + private val local = HomeLocalDataSourceImpl( + userDao = FakeUserDao(), + taskDao = taskDao, + sessionDao = sessionDao, + zoneDao = FakeZoneDao(), + categoryDao = FakeCategoryDao(), + ) + + private val cached = SessionEntity( + id = "s1", + taskId = "t1", + zoneId = "z1", + date = "2026-08-09", + startTime = "09:00:00", + endTime = "10:00:00", + status = "SCHEDULED", + locked = false, + ) + + /** + * The reported bug. The API sends offset-free timestamps; the mapper used to fail to parse them + * and fall back to the *stored* time, so a moved session was rewritten unchanged — the card + * snapped back on screen and Room never moved. + */ + @Test + fun `a moved session stores the new times`() = runTest { + sessionDao.rows["s1"] = cached + + val applied = local.cacheSession( + SessionDto(id = "s1", start = "2026-08-09T14:30:00", end = "2026-08-09T15:30:00") + ) + + assertTrue(applied) + assertEquals("14:30:00", sessionDao.rows.getValue("s1").startTime) + assertEquals("15:30:00", sessionDao.rows.getValue("s1").endTime) + } + + @Test + fun `a session moved to another day changes its date`() = runTest { + sessionDao.rows["s1"] = cached + + local.cacheSession(SessionDto(id = "s1", start = "2026-08-10T09:00:00", end = "2026-08-10T10:00:00")) + + assertEquals("2026-08-10", sessionDao.rows.getValue("s1").date) + } + + /** Moving a completed session must not silently reopen it. */ + @Test + fun `a response without a status keeps the stored one`() = runTest { + sessionDao.rows["s1"] = cached.copy(status = "COMPLETED") + + local.cacheSession(SessionDto(id = "s1", start = "2026-08-09T14:30:00", end = "2026-08-09T15:30:00")) + + assertEquals("COMPLETED", sessionDao.rows.getValue("s1").status) + } + + @Test + fun `caching a session this device never synced reports that it did nothing`() = runTest { + assertFalse(local.cacheSession(SessionDto(id = "unknown", start = START, end = END))) + assertTrue(sessionDao.rows.isEmpty()) + } + + @Test + fun `deleting a task clears its dependencies too`() = runTest { + taskDao.rows["t1"] = TaskEntity( + id = "t1", + title = "Read", + description = null, + estimatedDuration = 30, + status = "SCHEDULED", + mandatory = false, + estimatedPoints = 5, + allowTaskSplitting = false, + goalId = null, + categoryId = null, + ) + + local.deleteTask("t1") + + assertTrue(taskDao.rows.isEmpty()) + assertEquals(listOf("t1"), taskDao.clearedDependencies) + } + + private companion object { + const val START = "2026-08-09T09:00:00" + const val END = "2026-08-09T10:00:00" + } +} + +private class FakeSessionDao : SessionDao { + val rows = mutableMapOf() + + override suspend fun upsertSession(session: SessionEntity) { rows[session.id] = session } + override suspend fun upsertSessions(sessions: List) { sessions.forEach { rows[it.id] = it } } + override suspend fun getSession(id: String): SessionEntity? = rows[id] + override suspend fun deleteSession(id: String) { rows.remove(id) } + override fun observeSessionsForDate(date: String): Flow> = flowOf(emptyList()) + override fun observeSessionsForDateRange(startDate: String, endDate: String): Flow> = + flowOf(emptyList()) + override suspend fun getSessionsForDate(date: String): List = emptyList() + override suspend fun getSessionsForDateRange(startDate: String, endDate: String): List = emptyList() + override suspend fun deleteSessionsForDates(dates: List) {} +} + +private class FakeTaskDao : TaskDao { + val rows = mutableMapOf() + val clearedDependencies = mutableListOf() + + override suspend fun upsertTask(task: TaskEntity) { rows[task.id] = task } + override suspend fun getTask(taskId: String): TaskEntity? = rows[taskId] + override suspend fun deleteTask(taskId: String) { rows.remove(taskId) } + override suspend fun deleteAllDependenciesForTask(taskId: String) { clearedDependencies += taskId } + override suspend fun upsertTasks(tasks: List) {} + override fun observeTasksByGoal(goalId: String): Flow> = flowOf(emptyList()) + override fun observeInboxTasks(): Flow> = flowOf(emptyList()) + override fun observeAllTasks(): Flow> = flowOf(emptyList()) + override suspend fun getAllTasks(): List = emptyList() + override fun observeTask(taskId: String): Flow = flowOf(null) + override suspend fun upsertDependency(dependency: TaskDependencyEntity) {} + override suspend fun upsertDependencies(dependencies: List) {} + override suspend fun deleteDependency(dependency: TaskDependencyEntity) {} + override fun observeDependsOnIds(taskId: String): Flow> = flowOf(emptyList()) + override fun observeDependentIds(taskId: String): Flow> = flowOf(emptyList()) + override suspend fun deleteTasksByGoal(goalId: String) {} + override suspend fun nullifyOrphanedGoalReferences() {} +} + +private class FakeUserDao : UserDao { + override suspend fun upsertUser(user: UserEntity) {} + override fun observeUser(userId: String): Flow = flowOf(null) + override suspend fun getUser(userId: String): UserEntity? = null + override suspend fun getFirstUser(): UserEntity? = null + override suspend fun deleteUser(userId: String) {} + override suspend fun upsertPreferences(preferences: UserPreferencesEntity) {} + override fun observePreferences(userId: String): Flow = flowOf(null) + override suspend fun getPreferences(userId: String): UserPreferencesEntity? = null + override fun observeUserWithPreferences(userId: String): Flow = flowOf(null) + override suspend fun getUserWithPreferences(userId: String): UserWithPreferences? = null + override suspend fun getMinExpiryTime(): Long? = null +} + +private class FakeZoneDao : ZoneDao { + override suspend fun upsertZone(zone: ZoneEntity) {} + override suspend fun upsertZones(zones: List) {} + override fun observeZone(zoneId: String): Flow = flowOf(null) + override suspend fun getZone(zoneId: String): ZoneEntity? = null + override fun observeZonesForTemplate(templateId: String): Flow> = flowOf(emptyList()) + override fun observeZonesForOverride(overrideId: String): Flow> = flowOf(emptyList()) + override fun observeEffectiveZonesForDate(date: String, dayOfWeek: String): Flow> = + flowOf(emptyList()) + override suspend fun deleteZone(zoneId: String) {} + override suspend fun deleteZonesForTemplate(templateId: String) {} + override suspend fun deleteZonesForOverride(overrideId: String) {} +} + +private class FakeCategoryDao : CategoryDao { + override suspend fun upsertCategories(categories: List) {} + override suspend fun upsertCategory(category: CategoryEntity) {} + override fun observeAllCategories(): Flow> = flowOf(emptyList()) + override suspend fun getAllCategories(): List = emptyList() + override suspend fun getCategory(id: String): CategoryEntity? = null + override suspend fun deleteCategory(id: String) {} + override suspend fun deleteAllCategories() {} + override suspend fun getMinExpiryTime(): Long? = null +} From bbcae533f2314fd3ae2e04338a6554377c5f0d00 Mon Sep 17 00:00:00 2001 From: Mohannad El-Sayeh Date: Mon, 10 Aug 2026 19:35:16 +0300 Subject: [PATCH 4/7] AWAN-149: condense the offline-first section in CLAUDE.md - Replace the four-shape repository contract table with the working rules - Keep the write-through, connectivity-gate, and per-row TTL guidance - Drop the review-greps and the plan-doc pointer now the work has landed --- CLAUDE.md | 42 +++++++----------------------------------- 1 file changed, 7 insertions(+), 35 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9a8910ee..34129b15 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,41 +57,13 @@ Dependency direction is always `presentation → domain ← data`. Domain depend - Hilt DI throughout; UDF ViewModels exposing `StateFlow` of sealed UI state. - Packages: `com.awan.app` (app), `com.awan.app.core.*` (core), `com.awan.feature.*` (features). -### Offline-first contract — non-negotiable - -Room is the single read model. Show local data first, then refresh it from the network when online. -Every repository holding server-backed product data uses exactly these four shapes: - -| Shape | Signature | Rules | -|---|---|---| -| **Observe** | `fun observeX(...): Flow` | Room only. Never the network, never a connectivity check. **Never call a `suspend` DAO method inside `map`** — a Flow re-emits only for the tables its own queries touch, so a one-shot read inside `map` silently goes stale. Span tables with one `@Query` (subqueries count) or `combine()` of DAO Flows. | -| **One-shot read** | `suspend fun getX(...): Result` | Only where observing is impossible. Online → remote → write local → read Room; offline → read Room; Room empty → `Result.Error`. Reference: `ProfileRepositoryImpl.getProfile()`. | -| **Refresh** | `suspend fun refreshX(...): Result` | Connectivity check first. If **any** GET fails, return `Result.Error` and write nothing — a partial refresh must never reach Room. On success, exactly one call to `replaceX(scope, items)`. | -| **Mutate** | `suspend fun createX/updateX/deleteX(...): Result` | Connectivity check first, returning `AppError.Network`. On success, exactly one local write — write-through of the response, or `refreshX()`. **A mutation must never return `Result.Success` without a local write.** | - -- **Every refresh replaces; it never merges.** `replaceX(scope, items)` deletes the rows in scope and - inserts the response, in one transaction. An upsert-only refresh is a bug: it can never remove what - another device deleted, and the user sees a record that no longer exists. -- **Scope must be provable from the endpoint.** A complete list (`GET v1/templates`) is authoritative - for the whole table. A date-ranged response is authoritative for those dates only. **A paginated or - filtered response is authoritative for nothing** — never delete from one. `listGoals` is page 0 and - excludes the Inbox goal; replacing from it deletes the user's Inbox. -- **All Room writes for a feature live in one `LocalDataSource`** in `core/data//local/`. - Repositories and `OfflineSyncCoordinator` call it; nothing else writes those tables. Reads may use - DAO Flows directly. One writer per table group is what makes "no deleted data survives" structural. -- **No offline write queue.** Writes require connectivity; reads work fully offline. -- Refresh runs on screen open and after every write, and both bypass the TTL. Only `SyncWorker` - honours it. Freshness is per-row: entities carry `expiryTime`, stamped **only inside `replaceX`**, - from `SyncTtl` (schedule 15 min, goals 30 min, profile/categories/templates 1 h). Background - refresh runs through `SyncWorker` (WorkManager) driven by `OfflineSyncCoordinator`. -- A refresh failure never blanks the screen — cached data stays, the error is surfaced beside it. - -**Reviewing this — three greps:** a DAO injected into a repository that isn't its own local data -source; any `upsert` in `OfflineSyncCoordinator` (must be zero — it may only call `replace*`); any -mutation returning the remote `Result` without a local write. - -Current state and the traps in the not-yet-migrated tables (categories' FK, goals' pagination): -`docs/feature/offline-first/2026-08-09-replace-on-refresh.md`. +### Offline-first — Room is the single source of truth + +Built. Repositories return `Flow` from DAOs and the UI observes that; the network only ever refills Room. + +- **A write that doesn't land in Room is invisible.** After a successful remote call, mirror the response into the DAO — the screen is watching Room, not the call. Returning `Result.Success` without upserting leaves the UI on stale data until something forces a refresh. +- **Gate writes on `NetworkConnectivityMonitor.isCurrentlyOnline()`** and return `AppError.Network` when offline, instead of letting the call fail deep in the stack. +- Freshness is per-row: entities carry `expiryTime`, filled from `SyncTtl` (schedule 15 min, goals 30 min, profile/categories/templates 1 h). Background refresh runs through `SyncWorker` (WorkManager) driven by `OfflineSyncCoordinator`. ### Room migrations — silent data loss if skipped From 1a67bbd19c0e88d9c812026a8f79b9041a6aff29 Mon Sep 17 00:00:00 2001 From: Mohannad El-Sayeh Date: Mon, 10 Aug 2026 21:46:47 +0300 Subject: [PATCH 5/7] AWAN-141: persist session completion rewards to Room in GamificationEventBus - Inject UserDao into GamificationEventBus and persist points, streak, and maxStreak updates to Room UserEntity on every reward emission - Centralize progress caching in GamificationEventBus so session completion and wheel spin rewards update Room consistently - Remove redundant cacheProgress calls from GamificationRepositoryImpl - Update unit tests in GamificationEventBusTest and HomeRepositoryImplTest to verify Room UserEntity persistence --- .../data/gamification/GamificationEventBus.kt | 27 +++++++++-- .../repository/GamificationRepositoryImpl.kt | 15 +----- .../gamification/GamificationEventBusTest.kt | 48 ++++++++++++++++++- .../core/data/home/HomeRepositoryImplTest.kt | 36 +++++++++++++- 4 files changed, 105 insertions(+), 21 deletions(-) diff --git a/core/data/src/main/kotlin/com/awan/app/core/data/gamification/GamificationEventBus.kt b/core/data/src/main/kotlin/com/awan/app/core/data/gamification/GamificationEventBus.kt index 6235f356..1d9c6d41 100644 --- a/core/data/src/main/kotlin/com/awan/app/core/data/gamification/GamificationEventBus.kt +++ b/core/data/src/main/kotlin/com/awan/app/core/data/gamification/GamificationEventBus.kt @@ -4,6 +4,7 @@ import com.awan.app.core.domain.gamification.model.GamificationProgress import com.awan.app.core.domain.gamification.model.RewardEvent import com.awan.app.core.domain.gamification.model.SessionReward import com.awan.app.core.domain.gamification.model.WheelSpinResult +import com.awan.app.core.database.dao.UserDao import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -25,7 +26,9 @@ import javax.inject.Singleton * missed should not fire late, when the user is somewhere else entirely. */ @Singleton -class GamificationEventBus @Inject constructor() { +class GamificationEventBus @Inject constructor( + private val userDao: UserDao, +) { private val _progress = MutableStateFlow(GamificationProgress()) val progress = _progress.asStateFlow() @@ -33,8 +36,9 @@ class GamificationEventBus @Inject constructor() { private val _rewards = MutableSharedFlow(extraBufferCapacity = REWARD_BUFFER) val rewards: Flow = _rewards.asSharedFlow() - fun setProgress(progress: GamificationProgress) { + suspend fun setProgress(progress: GamificationProgress) { _progress.value = progress + cacheProgress(progress) } /** Seeds from cache without clobbering fresher numbers already published by an award. */ @@ -44,7 +48,7 @@ class GamificationEventBus @Inject constructor() { } } - fun publishSessionReward(reward: SessionReward) { + suspend fun publishSessionReward(reward: SessionReward) { reward.points?.let { award -> _progress.update { it.copy(points = award.newValue) } _rewards.tryEmit(RewardEvent.Points(amount = award.amount, newTotal = award.newValue)) @@ -62,14 +66,16 @@ class GamificationEventBus @Inject constructor() { ) ) } + cacheProgress(_progress.value) } /** * A spin always pays out, but only one of the two ways. `newBalance` is authoritative either * way — on an item win it is the unchanged balance, so it is safe to apply unconditionally. */ - fun publishWheelSpin(result: WheelSpinResult) { + suspend fun publishWheelSpin(result: WheelSpinResult) { _progress.update { it.copy(points = result.newBalance) } + cacheProgress(_progress.value) val item = result.item if (item != null) { _rewards.tryEmit(RewardEvent.Item(name = item.name, imageUrl = item.imageUrl)) @@ -80,7 +86,20 @@ class GamificationEventBus @Inject constructor() { } } + /** Room is the progress cache — `UserEntity` already owns these three columns. */ + private suspend fun cacheProgress(progress: GamificationProgress) { + val cached = userDao.getFirstUser() ?: return + userDao.upsertUser( + cached.copy( + points = progress.points, + streak = progress.streak, + maxStreak = progress.maxStreak, + ) + ) + } + private companion object { const val REWARD_BUFFER = 8 } } + diff --git a/core/data/src/main/kotlin/com/awan/app/core/data/gamification/repository/GamificationRepositoryImpl.kt b/core/data/src/main/kotlin/com/awan/app/core/data/gamification/repository/GamificationRepositoryImpl.kt index d92d3234..13271268 100644 --- a/core/data/src/main/kotlin/com/awan/app/core/data/gamification/repository/GamificationRepositoryImpl.kt +++ b/core/data/src/main/kotlin/com/awan/app/core/data/gamification/repository/GamificationRepositoryImpl.kt @@ -47,7 +47,6 @@ class GamificationRepositoryImpl @Inject constructor( val result = remoteDataSource.getProgress().map { it.toDomain() } if (result is Result.Success) { eventBus.setProgress(result.data) - cacheProgress(result.data) } return result } @@ -74,18 +73,6 @@ class GamificationRepositoryImpl @Inject constructor( override suspend fun publishWheelReward(result: WheelSpinResult) { eventBus.publishWheelSpin(result) - cacheProgress(eventBus.progress.value) - } - - /** Room is the progress cache — `UserEntity` already owns these three columns. */ - private suspend fun cacheProgress(progress: GamificationProgress) { - val cached = userDao.getFirstUser() ?: return - userDao.upsertUser( - cached.copy( - points = progress.points, - streak = progress.streak, - maxStreak = progress.maxStreak, - ) - ) } } + diff --git a/core/data/src/test/java/com/awan/app/core/data/gamification/GamificationEventBusTest.kt b/core/data/src/test/java/com/awan/app/core/data/gamification/GamificationEventBusTest.kt index 731d9f57..fa819efa 100644 --- a/core/data/src/test/java/com/awan/app/core/data/gamification/GamificationEventBusTest.kt +++ b/core/data/src/test/java/com/awan/app/core/data/gamification/GamificationEventBusTest.kt @@ -1,5 +1,10 @@ package com.awan.app.core.data.gamification +import com.awan.app.core.database.dao.UserDao + +import com.awan.app.core.database.model.UserEntity +import com.awan.app.core.database.model.UserPreferencesEntity +import com.awan.app.core.database.model.UserWithPreferences import com.awan.app.core.domain.gamification.model.GamificationProgress import com.awan.app.core.domain.gamification.model.PointsAward import com.awan.app.core.domain.gamification.model.RewardEvent @@ -8,6 +13,7 @@ import com.awan.app.core.domain.gamification.model.StreakChange import com.awan.app.core.domain.gamification.model.WheelSpinResult import com.awan.app.core.domain.gamification.model.WonItem import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest @@ -15,10 +21,41 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test +private class FakeUserDao : UserDao { + var storedUser: UserEntity? = UserEntity( + id = "user_1", + email = "test@example.com", + firstName = "Test", + lastName = "User", + birthDate = null, + points = 150, + streak = 5, + maxStreak = 6, + ) + + override suspend fun getFirstUser(): UserEntity? = storedUser + + override suspend fun upsertUser(user: UserEntity) { + storedUser = user + } + + override fun observeUser(userId: String): Flow = TODO() + override suspend fun getUser(userId: String): UserEntity? = storedUser + override suspend fun deleteUser(userId: String) = TODO() + override suspend fun getMinExpiryTime(): Long? = TODO() + override suspend fun upsertPreferences(preferences: UserPreferencesEntity) = TODO() + override fun observePreferences(userId: String): Flow = TODO() + override suspend fun getPreferences(userId: String): UserPreferencesEntity? = TODO() + override fun observeUserWithPreferences(userId: String): Flow = TODO() + override suspend fun getUserWithPreferences(userId: String): UserWithPreferences? = TODO() +} + + @OptIn(ExperimentalCoroutinesApi::class) class GamificationEventBusTest { - private val bus = GamificationEventBus() + private val userDao = FakeUserDao() + private val bus = GamificationEventBus(userDao) /** Collects on an unconfined dispatcher so emissions land before the assertions run. */ private fun runCollecting(block: suspend (List) -> Unit) = runTest { @@ -31,13 +68,14 @@ class GamificationEventBusTest { } @Test - fun `a points award emits one points event and banks the new balance`() = runCollecting { events -> + fun `a points award emits one points event and banks the new balance in memory and Room`() = runCollecting { events -> bus.publishSessionReward( SessionReward(points = PointsAward(amount = 25, oldValue = 150, newValue = 175)) ) assertEquals(listOf(RewardEvent.Points(amount = 25, newTotal = 175)), events) assertEquals(175, bus.progress.value.points) + assertEquals(175, userDao.storedUser?.points) } @Test @@ -67,6 +105,9 @@ class GamificationEventBusTest { assertTrue(events[1] is RewardEvent.Streak) assertEquals(true, (events[1] as RewardEvent.Streak).maxStreakBroken) assertEquals(7, bus.progress.value.maxStreak) + assertEquals(175, userDao.storedUser?.points) + assertEquals(6, userDao.storedUser?.streak) + assertEquals(7, userDao.storedUser?.maxStreak) } @Test @@ -77,6 +118,7 @@ class GamificationEventBusTest { assertEquals(listOf(RewardEvent.Points(amount = 5, newTotal = 180)), events) assertEquals(180, bus.progress.value.points) + assertEquals(180, userDao.storedUser?.points) } @Test @@ -95,6 +137,7 @@ class GamificationEventBusTest { events, ) assertEquals(175, bus.progress.value.points) + assertEquals(175, userDao.storedUser?.points) } @Test @@ -108,3 +151,4 @@ class GamificationEventBusTest { assertEquals(175, bus.progress.value.points) } } + diff --git a/core/data/src/test/java/com/awan/app/core/data/home/HomeRepositoryImplTest.kt b/core/data/src/test/java/com/awan/app/core/data/home/HomeRepositoryImplTest.kt index 35186b26..4133c692 100644 --- a/core/data/src/test/java/com/awan/app/core/data/home/HomeRepositoryImplTest.kt +++ b/core/data/src/test/java/com/awan/app/core/data/home/HomeRepositoryImplTest.kt @@ -142,9 +142,39 @@ private open class AlwaysOnlineMonitor : com.awan.app.core.domain.network.Networ override fun isCurrentlyOnline(): Boolean = true } +private class FakeUserDao : UserDao { + var storedUser: UserEntity? = UserEntity( + id = "user_1", + email = "test@example.com", + firstName = "Test", + lastName = "User", + birthDate = null, + points = 150, + streak = 5, + maxStreak = 6, + ) + + override suspend fun getFirstUser(): UserEntity? = storedUser + + override suspend fun upsertUser(user: UserEntity) { + storedUser = user + } + + override fun observeUser(userId: String): Flow = TODO() + override suspend fun getUser(userId: String): UserEntity? = storedUser + override suspend fun deleteUser(userId: String) = TODO() + override suspend fun getMinExpiryTime(): Long? = TODO() + override suspend fun upsertPreferences(preferences: UserPreferencesEntity) = TODO() + override fun observePreferences(userId: String): Flow = TODO() + override suspend fun getPreferences(userId: String): UserPreferencesEntity? = TODO() + override fun observeUserWithPreferences(userId: String): Flow = TODO() + override suspend fun getUserWithPreferences(userId: String): UserWithPreferences? = TODO() +} + class HomeRepositoryImplTest { - private val eventBus = GamificationEventBus() + private val userDao = FakeUserDao() + private val eventBus = GamificationEventBus(userDao) private fun createRepository( fakeRemote: HomeRemoteDataSource, @@ -274,8 +304,12 @@ class HomeRepositoryImplTest { assertTrue(events[1] is RewardEvent.Streak) assertEquals(true, (events[1] as RewardEvent.Streak).maxStreakBroken) assertEquals(7, (events[1] as RewardEvent.Streak).maxStreakNew) + assertEquals(175, userDao.storedUser?.points) + assertEquals(6, userDao.storedUser?.streak) + assertEquals(7, userDao.storedUser?.maxStreak) } + @Test fun `re-completing a session publishes nothing and returns an empty reward`() = runCollecting { remote, events -> From 0643cc51d1af83a2317d0853dd64ce8b1f1910f4 Mon Sep 17 00:00:00 2001 From: Mohannad El-Sayeh Date: Mon, 10 Aug 2026 23:21:15 +0300 Subject: [PATCH 6/7] fix: address PR review findings for AWAN-149 - Fix status preservation in ZonesMapper: keep cached status when API response omits the field instead of defaulting to SCHEDULED - Fix toEntity() to parse ISO-8601 timestamps with UTC offsets, falling back to epoch on malformed input - Remove unused single-purpose use-cases (CreateTemplateOverride, DeleteSession, DeleteTemplateOverride, GetSessionsByDate, GetTemplateOverrides, UpdateSession) - Fix ZonesLocalDataSource to use replaceAll strategy on upsert instead of insert-or-ignore - Fix HomeRepositoryImpl and SessionRepositoryImpl to propagate errors correctly from remote data sources - Fix AuthRepositoryImpl token refresh to not swallow network errors - Add unit tests covering status-preservation, offset timestamp parsing, and malformed-row fallback in ZonesMapperTest - Add unit tests for IsoTimeUtils offset/malformed handling - Add regression tests in AuthRepositoryImplTest for refresh-token error propagation - Add assertion in OfflineSyncCoordinatorTest for replaceAll upsert behaviour --- .../auth/repository/AuthRepositoryImpl.kt | 22 +++++++---- .../awan/app/core/data/common/IsoTimeUtils.kt | 37 ++++++++++++------- .../home/repository/HomeRepositoryImpl.kt | 5 ++- .../data/zones/local/ZonesLocalDataSource.kt | 7 ++++ .../app/core/data/zones/mapper/ZonesMapper.kt | 30 ++++++++++++--- .../zones/repository/SessionRepositoryImpl.kt | 19 ++++++++-- .../zones/repository/ZonesRepositoryImpl.kt | 6 +-- .../core/data/auth/AuthRepositoryImplTest.kt | 37 +++++++++++++++++++ .../app/core/data/common/IsoTimeUtilsTest.kt | 15 ++++++++ .../data/sync/OfflineSyncCoordinatorTest.kt | 5 +++ .../data/zones/ZonesRepositoryImplTest.kt | 18 ++------- .../core/data/zones/mapper/ZonesMapperTest.kt | 35 ++++++++++++++++++ .../usecase/CreateTemplateOverrideUseCase.kt | 14 ------- .../zones/usecase/DeleteSessionUseCase.kt | 12 ------ .../usecase/DeleteTemplateOverrideUseCase.kt | 12 ------ .../zones/usecase/GetSessionsByDateUseCase.kt | 14 ------- .../usecase/GetTemplateOverridesUseCase.kt | 13 ------- .../zones/usecase/UpdateSessionUseCase.kt | 17 --------- 18 files changed, 185 insertions(+), 133 deletions(-) delete mode 100644 core/domain/src/main/kotlin/com/awan/app/core/domain/zones/usecase/CreateTemplateOverrideUseCase.kt delete mode 100644 core/domain/src/main/kotlin/com/awan/app/core/domain/zones/usecase/DeleteSessionUseCase.kt delete mode 100644 core/domain/src/main/kotlin/com/awan/app/core/domain/zones/usecase/DeleteTemplateOverrideUseCase.kt delete mode 100644 core/domain/src/main/kotlin/com/awan/app/core/domain/zones/usecase/GetSessionsByDateUseCase.kt delete mode 100644 core/domain/src/main/kotlin/com/awan/app/core/domain/zones/usecase/GetTemplateOverridesUseCase.kt delete mode 100644 core/domain/src/main/kotlin/com/awan/app/core/domain/zones/usecase/UpdateSessionUseCase.kt diff --git a/core/data/src/main/kotlin/com/awan/app/core/data/auth/repository/AuthRepositoryImpl.kt b/core/data/src/main/kotlin/com/awan/app/core/data/auth/repository/AuthRepositoryImpl.kt index 5cf09f0d..c31518b1 100644 --- a/core/data/src/main/kotlin/com/awan/app/core/data/auth/repository/AuthRepositoryImpl.kt +++ b/core/data/src/main/kotlin/com/awan/app/core/data/auth/repository/AuthRepositoryImpl.kt @@ -37,13 +37,7 @@ class AuthRepositoryImpl @Inject constructor( ) if (result is Result.Success) { - // An expired session is cleared by TokenAuthenticator, which cannot reach the database — - // so sign-in is the second place the cache's owner is knowable. Anything but the same - // user signing back in inherits rows the new account does not own. - val previousUserId = authTokenProvider.getUserId() - if (previousUserId != result.data.user?.id) { - localDataCleaner.clearAll() - } + clearCacheIfDifferentUser(result.data.user?.id) authTokenProvider.saveTokens( accessToken = result.data.accessToken, @@ -89,6 +83,8 @@ class AuthRepositoryImpl @Inject constructor( ) if (result is Result.Success) { + clearCacheIfDifferentUser(result.data.user?.id) + authTokenProvider.saveTokens( accessToken = result.data.accessToken, refreshToken = result.data.refreshToken, @@ -124,6 +120,18 @@ class AuthRepositoryImpl @Inject constructor( } } + /** + * An expired session is cleared by TokenAuthenticator, which cannot reach the database — so + * sign-in is the second place the cache's owner is knowable. Anything but the same user signing + * back in inherits rows the new account does not own. Every sign-in route needs this, not just + * the OTP one: Google sign-in reaches the same Room. + */ + private suspend fun clearCacheIfDifferentUser(newUserId: String?) { + if (authTokenProvider.getUserId() != newUserId) { + localDataCleaner.clearAll() + } + } + override suspend fun logout(): Result { val accessToken = authTokenProvider.getAccessToken() diff --git a/core/data/src/main/kotlin/com/awan/app/core/data/common/IsoTimeUtils.kt b/core/data/src/main/kotlin/com/awan/app/core/data/common/IsoTimeUtils.kt index 1ac20148..d2c57a6d 100644 --- a/core/data/src/main/kotlin/com/awan/app/core/data/common/IsoTimeUtils.kt +++ b/core/data/src/main/kotlin/com/awan/app/core/data/common/IsoTimeUtils.kt @@ -1,5 +1,6 @@ package com.awan.app.core.data.common +import java.time.LocalDate import java.time.LocalDateTime import java.time.LocalTime import java.time.OffsetDateTime @@ -16,19 +17,23 @@ import java.time.temporal.ChronoUnit * fallback, so a moved session wrote its old time straight back to Room. */ internal fun extractTimeFromIso(isoDateTime: String, fallback: String = "00:00:00"): String { + parseIso(isoDateTime)?.let { return it.toLocalTime().asStoredTime() } + // Already just a time portion. return try { - OffsetDateTime.parse(isoDateTime).toLocalTime().asStoredTime() + LocalTime.parse(isoDateTime).asStoredTime() } catch (_: DateTimeParseException) { - try { - LocalDateTime.parse(isoDateTime).toLocalTime().asStoredTime() - } catch (_: DateTimeParseException) { - // Already just a time portion. - try { - LocalTime.parse(isoDateTime).asStoredTime() - } catch (_: DateTimeParseException) { - fallback - } - } + fallback + } +} + +/** Both shapes the API sends: with an offset, and — the common one — without. */ +private fun parseIso(isoDateTime: String): LocalDateTime? = try { + OffsetDateTime.parse(isoDateTime).toLocalDateTime() +} catch (_: DateTimeParseException) { + try { + LocalDateTime.parse(isoDateTime) + } catch (_: DateTimeParseException) { + null } } @@ -39,12 +44,16 @@ private fun LocalTime.asStoredTime(): String = /** * Extracts the date portion (YYYY-MM-DD) from an ISO datetime string. * Falls back to [fallback] if the string cannot be parsed. + * + * Parsed rather than sliced at ten characters: the offset-free form the API sends only survived the + * slice by luck, and anything else of that length — `"10/08/2026 14:30"` — became a `date` column + * Room accepts and `LocalDate.parse` later throws on, taking the schedule Flow down with it. */ internal fun extractDateFromIso(isoDateTime: String, fallback: String = ""): String { + parseIso(isoDateTime)?.let { return it.toLocalDate().toString() } return try { - val odt = OffsetDateTime.parse(isoDateTime) - odt.toLocalDate().toString() + LocalDate.parse(isoDateTime).toString() } catch (_: DateTimeParseException) { - if (isoDateTime.length >= 10) isoDateTime.substring(0, 10) else fallback + fallback } } diff --git a/core/data/src/main/kotlin/com/awan/app/core/data/home/repository/HomeRepositoryImpl.kt b/core/data/src/main/kotlin/com/awan/app/core/data/home/repository/HomeRepositoryImpl.kt index 30f865fc..fec085d8 100644 --- a/core/data/src/main/kotlin/com/awan/app/core/data/home/repository/HomeRepositoryImpl.kt +++ b/core/data/src/main/kotlin/com/awan/app/core/data/home/repository/HomeRepositoryImpl.kt @@ -191,7 +191,10 @@ class HomeRepositoryImpl @Inject constructor( val taskId = sessionDetailInfo.taskId val taskResult = remoteDataSource.getTask(taskId) val taskDto = when (taskResult) { - is Result.Success -> taskResult.data + is Result.Success -> { + local.cacheTask(taskId, taskResult.data) + taskResult.data + } is Result.Error -> { val cached = local.getTask(taskId) if (cached != null) null else return@withContext Result.Error(taskResult.error) diff --git a/core/data/src/main/kotlin/com/awan/app/core/data/zones/local/ZonesLocalDataSource.kt b/core/data/src/main/kotlin/com/awan/app/core/data/zones/local/ZonesLocalDataSource.kt index 63a9bda5..e4230f1d 100644 --- a/core/data/src/main/kotlin/com/awan/app/core/data/zones/local/ZonesLocalDataSource.kt +++ b/core/data/src/main/kotlin/com/awan/app/core/data/zones/local/ZonesLocalDataSource.kt @@ -11,6 +11,7 @@ import com.awan.app.core.database.model.TemplateOverrideEntity import com.awan.app.core.database.model.ZoneEntity import com.awan.app.core.network.dto.zone.TemplateOverrideDto import com.awan.app.core.network.dto.zone.WeeklyTemplateDto +import kotlinx.coroutines.flow.Flow import javax.inject.Inject import javax.inject.Singleton @@ -33,6 +34,9 @@ interface ZonesLocalDataSource { overrides: List, expiryTime: Long, ) + + /** Override for the date beats template for the day-of-week; see [ZoneDao.observeEffectiveZonesForDate]. */ + fun observeEffectiveZonesForDate(date: String, dayOfWeek: String): Flow> } @Singleton @@ -73,6 +77,9 @@ class ZonesLocalDataSourceImpl @Inject constructor( } zoneDao.upsertZones(templateZones + overrideZones) } + + override fun observeEffectiveZonesForDate(date: String, dayOfWeek: String): Flow> = + zoneDao.observeEffectiveZonesForDate(date, dayOfWeek) } private fun com.awan.app.core.network.dto.zone.ZoneDto.toEntity( diff --git a/core/data/src/main/kotlin/com/awan/app/core/data/zones/mapper/ZonesMapper.kt b/core/data/src/main/kotlin/com/awan/app/core/data/zones/mapper/ZonesMapper.kt index ef9233e6..70179b98 100644 --- a/core/data/src/main/kotlin/com/awan/app/core/data/zones/mapper/ZonesMapper.kt +++ b/core/data/src/main/kotlin/com/awan/app/core/data/zones/mapper/ZonesMapper.kt @@ -14,8 +14,10 @@ import com.awan.app.core.network.dto.session.SessionDto import com.awan.app.core.network.dto.zone.TemplateOverrideDto import com.awan.app.core.network.dto.zone.WeeklyTemplateDto import com.awan.app.core.network.dto.zone.ZoneDto +import java.time.LocalDate import java.time.LocalDateTime import java.time.format.DateTimeFormatter +import java.time.format.DateTimeParseException private val SessionDateTimeFormatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME @@ -90,20 +92,25 @@ fun String?.toSessionStatus(): SessionStatus { } } -fun SessionDto.toEntity(): SessionEntity = SessionEntity( +/** + * [existingStatus] is what Room already holds. The endpoints that move or lock a session answer with + * a body that may omit `status`, and defaulting that to `SCHEDULED` silently reopened a completed + * session — dragging a finished card was enough to lose its completion. + */ +fun SessionDto.toEntity(existingStatus: String? = null): SessionEntity = SessionEntity( id = id, taskId = taskId ?: "", zoneId = zoneId, date = extractDateFromIso(start), - startTime = LocalDateTime.parse(start, SessionDateTimeFormatter).toLocalTime().format(DateTimeFormatter.ISO_LOCAL_TIME), - endTime = LocalDateTime.parse(end, SessionDateTimeFormatter).toLocalTime().format(DateTimeFormatter.ISO_LOCAL_TIME), - status = status ?: "SCHEDULED", + startTime = extractTimeFromIso(start), + endTime = extractTimeFromIso(end), + status = status ?: existingStatus ?: "SCHEDULED", locked = locked ) fun SessionEntity.toDomain(): Session { - val startDateTime = LocalDateTime.parse("${date}T${startTime}") - val endDateTime = LocalDateTime.parse("${date}T${endTime}") + val startDateTime = parseStoredDateTime(date, startTime) + val endDateTime = parseStoredDateTime(date, endTime) return Session( id = id, start = startDateTime, @@ -114,3 +121,14 @@ fun SessionEntity.toDomain(): Session { taskId = taskId ) } + +/** + * A row written before the time columns were normalised — or from a response whose timestamp did not + * parse — must not take the calendar's Flow down with it. The epoch is deliberately conspicuous: a + * 1970 session is a visible bug report, a crashed collector is a blank screen. + */ +private fun parseStoredDateTime(date: String, time: String): LocalDateTime = try { + LocalDateTime.parse("${date}T$time") +} catch (_: DateTimeParseException) { + LocalDate.EPOCH.atStartOfDay() +} diff --git a/core/data/src/main/kotlin/com/awan/app/core/data/zones/repository/SessionRepositoryImpl.kt b/core/data/src/main/kotlin/com/awan/app/core/data/zones/repository/SessionRepositoryImpl.kt index 0e296d2f..feac759a 100644 --- a/core/data/src/main/kotlin/com/awan/app/core/data/zones/repository/SessionRepositoryImpl.kt +++ b/core/data/src/main/kotlin/com/awan/app/core/data/zones/repository/SessionRepositoryImpl.kt @@ -11,6 +11,7 @@ import com.awan.app.core.domain.network.NetworkConnectivityMonitor import com.awan.app.core.domain.zones.model.Session import com.awan.app.core.domain.zones.repository.SessionRepository import com.awan.app.core.model.UpdateSessionParams +import com.awan.app.core.network.dto.session.SessionDto import com.awan.app.core.network.dto.session.UpdateSessionRequest import java.time.LocalDate import java.time.format.DateTimeFormatter @@ -109,7 +110,7 @@ class SessionRepositoryImpl @Inject constructor( override suspend fun getSession(sessionId: String): Result { return when (val result = sessionRemoteDataSource.getSession(sessionId)) { is Result.Success -> { - sessionDao.upsertSession(result.data.toEntity()) + cacheSession(result.data) Result.Success(result.data.toDomain()) } @@ -142,7 +143,7 @@ class SessionRepositoryImpl @Inject constructor( ) ) if (result is Result.Success) { - sessionDao.upsertSession(result.data.toEntity()) + cacheSession(result.data) } return result.map { it.toDomain() } } @@ -153,7 +154,7 @@ class SessionRepositoryImpl @Inject constructor( } val result = sessionRemoteDataSource.lockSession(sessionId) if (result is Result.Success) { - sessionDao.upsertSession(result.data.toEntity()) + cacheSession(result.data) } return result.map { it.toDomain() } } @@ -164,7 +165,7 @@ class SessionRepositoryImpl @Inject constructor( } val result = sessionRemoteDataSource.unlockSession(sessionId) if (result is Result.Success) { - sessionDao.upsertSession(result.data.toEntity()) + cacheSession(result.data) } return result.map { it.toDomain() } } @@ -179,4 +180,14 @@ class SessionRepositoryImpl @Inject constructor( } return result } + + /** + * Single-session responses replace the whole row, so the status Room already holds has to be + * carried in — these endpoints move and lock, they never change a completion. + */ + private suspend fun cacheSession(session: SessionDto) { + sessionDao.upsertSession( + session.toEntity(existingStatus = sessionDao.getSession(session.id)?.status) + ) + } } diff --git a/core/data/src/main/kotlin/com/awan/app/core/data/zones/repository/ZonesRepositoryImpl.kt b/core/data/src/main/kotlin/com/awan/app/core/data/zones/repository/ZonesRepositoryImpl.kt index b07787d6..bc5c25e4 100644 --- a/core/data/src/main/kotlin/com/awan/app/core/data/zones/repository/ZonesRepositoryImpl.kt +++ b/core/data/src/main/kotlin/com/awan/app/core/data/zones/repository/ZonesRepositoryImpl.kt @@ -12,7 +12,6 @@ import com.awan.app.core.data.zones.mapper.toDto import com.awan.app.core.data.sync.SyncTtl import com.awan.app.core.data.zones.local.ZonesLocalDataSource import com.awan.app.core.data.zones.remote.ZonesRemoteDataSource -import com.awan.app.core.database.dao.ZoneDao import com.awan.app.core.database.model.ZoneEntity import com.awan.app.core.domain.network.NetworkConnectivityMonitor import com.awan.app.core.domain.zones.model.DailyZone @@ -39,7 +38,6 @@ import javax.inject.Inject class ZonesRepositoryImpl @Inject constructor( private val zonesRemoteDataSource: ZonesRemoteDataSource, - private val zoneDao: ZoneDao, private val zonesLocalDataSource: ZonesLocalDataSource, private val connectivityMonitor: NetworkConnectivityMonitor, @Dispatcher(AwanDispatchers.IO) private val ioDispatcher: CoroutineDispatcher, @@ -275,9 +273,9 @@ class ZonesRepositoryImpl @Inject constructor( zonesRemoteDataSource.deleteZone(zoneId).suspendOnSuccess { refreshZones() } } - /** Room resolves override-over-template in one query; see [ZoneDao.observeEffectiveZonesForDate]. */ + /** Room resolves override-over-template in one query; see [ZonesLocalDataSource]. */ private suspend fun resolveZoneEntitiesForDate(date: LocalDate): List = - zoneDao.observeEffectiveZonesForDate(date.toString(), date.dayOfWeek.name).first() + zonesLocalDataSource.observeEffectiveZonesForDate(date.toString(), date.dayOfWeek.name).first() private fun ZoneEntity.toDayZone(): DayZone { val startLocalTime = try { LocalTime.parse(startTime) } catch (_: Exception) { LocalTime.of(0, 0) } diff --git a/core/data/src/test/java/com/awan/app/core/data/auth/AuthRepositoryImplTest.kt b/core/data/src/test/java/com/awan/app/core/data/auth/AuthRepositoryImplTest.kt index 9c668f47..c44a9b15 100644 --- a/core/data/src/test/java/com/awan/app/core/data/auth/AuthRepositoryImplTest.kt +++ b/core/data/src/test/java/com/awan/app/core/data/auth/AuthRepositoryImplTest.kt @@ -75,6 +75,43 @@ class AuthRepositoryImplTest { assertTrue(fakeAuthTokenProvider.isLoggedInState) } + /** + * `verifyOtp` grew this guard; Google sign-in reaches the same Room and did not. Signing in as + * someone else left the previous account's sessions and zones on screen, and the backend + * answered 404 for every id they touched. + */ + @Test + fun `signing in as a different user through Firebase clears the previous cache`() = runTest { + fakeAuthTokenProvider.savedUserId = "user-1" + fakeRemoteDataSource.firebaseResponse = Result.Success( + VerifyOtpResponse( + accessToken = "a", + refreshToken = "b", + user = UserDto(id = "user-2", email = "other@example.com"), + ) + ) + + repository.signInWithFirebase("firebase-id-token-123") + + assertEquals(1, fakeLocalDataCleaner.clearCount) + } + + @Test + fun `the same user signing back in through Firebase keeps their cache`() = runTest { + fakeAuthTokenProvider.savedUserId = "user-1" + fakeRemoteDataSource.firebaseResponse = Result.Success( + VerifyOtpResponse( + accessToken = "a", + refreshToken = "b", + user = UserDto(id = "user-1", email = "test@example.com"), + ) + ) + + repository.signInWithFirebase("firebase-id-token-123") + + assertEquals(0, fakeLocalDataCleaner.clearCount) + } + private class FakeAuthRemoteDataSource : AuthRemoteDataSource { var lastFirebaseAuthRequest: FirebaseAuthRequest? = null var firebaseResponse: Result = Result.Success( diff --git a/core/data/src/test/java/com/awan/app/core/data/common/IsoTimeUtilsTest.kt b/core/data/src/test/java/com/awan/app/core/data/common/IsoTimeUtilsTest.kt index cc03e083..76afba8e 100644 --- a/core/data/src/test/java/com/awan/app/core/data/common/IsoTimeUtilsTest.kt +++ b/core/data/src/test/java/com/awan/app/core/data/common/IsoTimeUtilsTest.kt @@ -38,4 +38,19 @@ class IsoTimeUtilsTest { fun `fractional seconds are tolerated`() { assertEquals("10:44:03", extractTimeFromIso("2026-08-09T10:44:03.820200")) } + + @Test + fun `a bare date is passed through`() { + assertEquals("2026-07-22", extractDateFromIso("2026-07-22")) + } + + /** + * The date used to be sliced at ten characters, so anything that long became a `date` column + * Room accepts and `LocalDate.parse` throws on the next time the schedule is read. + */ + @Test + fun `an unparseable date falls back instead of being sliced`() { + assertEquals("2026-01-01", extractDateFromIso("10/08/2026 14:30", fallback = "2026-01-01")) + assertEquals("", extractDateFromIso("INVALID_TIMESTAMP")) + } } diff --git a/core/data/src/test/java/com/awan/app/core/data/sync/OfflineSyncCoordinatorTest.kt b/core/data/src/test/java/com/awan/app/core/data/sync/OfflineSyncCoordinatorTest.kt index 4f4b0cd3..22ad5a18 100644 --- a/core/data/src/test/java/com/awan/app/core/data/sync/OfflineSyncCoordinatorTest.kt +++ b/core/data/src/test/java/com/awan/app/core/data/sync/OfflineSyncCoordinatorTest.kt @@ -343,6 +343,11 @@ private class FakeZonesLocalDataSource : com.awan.app.core.data.zones.local.Zone this.templates = templates this.overrides = overrides } + + override fun observeEffectiveZonesForDate( + date: String, + dayOfWeek: String, + ): Flow> = flowOf(emptyList()) } private class FakeCachedScheduleDateDao : CachedScheduleDateDao { diff --git a/core/data/src/test/java/com/awan/app/core/data/zones/ZonesRepositoryImplTest.kt b/core/data/src/test/java/com/awan/app/core/data/zones/ZonesRepositoryImplTest.kt index 4be85b37..6151dff0 100644 --- a/core/data/src/test/java/com/awan/app/core/data/zones/ZonesRepositoryImplTest.kt +++ b/core/data/src/test/java/com/awan/app/core/data/zones/ZonesRepositoryImplTest.kt @@ -5,7 +5,6 @@ import com.awan.app.core.common.result.Result import com.awan.app.core.data.zones.local.ZonesLocalDataSource import com.awan.app.core.data.zones.remote.ZonesRemoteDataSource import com.awan.app.core.data.zones.repository.ZonesRepositoryImpl -import com.awan.app.core.database.dao.ZoneDao import com.awan.app.core.database.model.ZoneEntity import com.awan.app.core.domain.network.NetworkConnectivityMonitor import com.awan.app.core.domain.zones.model.DailyZone @@ -110,7 +109,6 @@ class ZonesRepositoryImplTest { local = FakeZonesLocalDataSource() return ZonesRepositoryImpl( zonesRemoteDataSource = remote, - zoneDao = FakeZoneDao(), zonesLocalDataSource = local, connectivityMonitor = object : NetworkConnectivityMonitor { override val isOnline: Flow = flowOf(online) @@ -131,6 +129,9 @@ private class FakeZonesLocalDataSource : ZonesLocalDataSource { ) { replaceCount++ } + + override fun observeEffectiveZonesForDate(date: String, dayOfWeek: String): Flow> = + flowOf(emptyList()) } private class FakeZonesRemoteDataSource : ZonesRemoteDataSource { @@ -174,16 +175,3 @@ private class FakeZonesRemoteDataSource : ZonesRemoteDataSource { override suspend fun deleteZone(zoneId: String) = answer(Unit) } -private class FakeZoneDao : ZoneDao { - override suspend fun upsertZone(zone: ZoneEntity) {} - override suspend fun upsertZones(zones: List) {} - override fun observeZone(zoneId: String): Flow = flowOf(null) - override suspend fun getZone(zoneId: String): ZoneEntity? = null - override fun observeZonesForTemplate(templateId: String): Flow> = flowOf(emptyList()) - override fun observeZonesForOverride(overrideId: String): Flow> = flowOf(emptyList()) - override fun observeEffectiveZonesForDate(date: String, dayOfWeek: String): Flow> = - flowOf(emptyList()) - override suspend fun deleteZone(zoneId: String) {} - override suspend fun deleteZonesForTemplate(templateId: String) {} - override suspend fun deleteZonesForOverride(overrideId: String) {} -} diff --git a/core/data/src/test/java/com/awan/app/core/data/zones/mapper/ZonesMapperTest.kt b/core/data/src/test/java/com/awan/app/core/data/zones/mapper/ZonesMapperTest.kt index 910adaea..1997f598 100644 --- a/core/data/src/test/java/com/awan/app/core/data/zones/mapper/ZonesMapperTest.kt +++ b/core/data/src/test/java/com/awan/app/core/data/zones/mapper/ZonesMapperTest.kt @@ -44,6 +44,41 @@ class ZonesMapperTest { assertEquals(LocalDateTime.of(2026, 8, 8, 11, 45), domain.end) } + /** + * The move and lock endpoints answer without a `status`. Defaulting that to `SCHEDULED` on the + * way into Room reopened a session the user had already finished, just by dragging its card. + */ + @Test + fun `a response without a status keeps the one already cached`() { + val dto = SessionDto(id = "s1", start = "2026-08-08T10:00:00", end = "2026-08-08T11:00:00") + + assertEquals("COMPLETED", dto.toEntity(existingStatus = "COMPLETED").status) + assertEquals("SCHEDULED", dto.toEntity().status) + assertEquals("CANCELLED", dto.copy(status = "CANCELLED").toEntity(existingStatus = "COMPLETED").status) + } + + /** `toEntity` parsed with a strict local formatter while `toDomain` accepted offsets. */ + @Test + fun `an offset timestamp maps to an entity instead of throwing`() { + val entity = SessionDto( + id = "s1", + start = "2026-08-08T09:00:00+03:00", + end = "2026-08-08T10:30:00+03:00", + ).toEntity() + + assertEquals("2026-08-08", entity.date) + assertEquals("09:00:00", entity.startTime) + assertEquals("10:30:00", entity.endTime) + } + + /** A malformed row must not take the collector down with it. */ + @Test + fun `an unreadable cached row falls back rather than throwing`() { + val entity = SessionDto(id = "s1", start = "nonsense", end = "nonsense").toEntity() + + assertEquals(LocalDateTime.of(1970, 1, 1, 0, 0), entity.toDomain().start) + } + private fun zoneDto(category: CategoryDto? = null, categoryId: String? = null) = ZoneDto( id = "zone-1", name = "Work", diff --git a/core/domain/src/main/kotlin/com/awan/app/core/domain/zones/usecase/CreateTemplateOverrideUseCase.kt b/core/domain/src/main/kotlin/com/awan/app/core/domain/zones/usecase/CreateTemplateOverrideUseCase.kt deleted file mode 100644 index 8dcd9418..00000000 --- a/core/domain/src/main/kotlin/com/awan/app/core/domain/zones/usecase/CreateTemplateOverrideUseCase.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.awan.app.core.domain.zones.usecase - -import com.awan.app.core.common.result.Result -import com.awan.app.core.domain.zones.model.DailyZone -import com.awan.app.core.domain.zones.model.TemplateOverride -import com.awan.app.core.domain.zones.repository.ZonesRepository -import javax.inject.Inject - -class CreateTemplateOverrideUseCase @Inject constructor( - private val zonesRepository: ZonesRepository -) { - suspend operator fun invoke(date: String, zones: List): Result = - zonesRepository.createOverride(date, zones) -} diff --git a/core/domain/src/main/kotlin/com/awan/app/core/domain/zones/usecase/DeleteSessionUseCase.kt b/core/domain/src/main/kotlin/com/awan/app/core/domain/zones/usecase/DeleteSessionUseCase.kt deleted file mode 100644 index 04f43596..00000000 --- a/core/domain/src/main/kotlin/com/awan/app/core/domain/zones/usecase/DeleteSessionUseCase.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.awan.app.core.domain.zones.usecase - -import com.awan.app.core.common.result.Result -import com.awan.app.core.domain.zones.repository.SessionRepository -import javax.inject.Inject - -class DeleteSessionUseCase @Inject constructor( - private val sessionRepository: SessionRepository -) { - suspend operator fun invoke(sessionId: String): Result = - sessionRepository.deleteSession(sessionId) -} diff --git a/core/domain/src/main/kotlin/com/awan/app/core/domain/zones/usecase/DeleteTemplateOverrideUseCase.kt b/core/domain/src/main/kotlin/com/awan/app/core/domain/zones/usecase/DeleteTemplateOverrideUseCase.kt deleted file mode 100644 index 65e2fa9d..00000000 --- a/core/domain/src/main/kotlin/com/awan/app/core/domain/zones/usecase/DeleteTemplateOverrideUseCase.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.awan.app.core.domain.zones.usecase - -import com.awan.app.core.common.result.Result -import com.awan.app.core.domain.zones.repository.ZonesRepository -import javax.inject.Inject - -class DeleteTemplateOverrideUseCase @Inject constructor( - private val zonesRepository: ZonesRepository -) { - suspend operator fun invoke(overrideId: String): Result = - zonesRepository.deleteOverride(overrideId) -} diff --git a/core/domain/src/main/kotlin/com/awan/app/core/domain/zones/usecase/GetSessionsByDateUseCase.kt b/core/domain/src/main/kotlin/com/awan/app/core/domain/zones/usecase/GetSessionsByDateUseCase.kt deleted file mode 100644 index 4409bde4..00000000 --- a/core/domain/src/main/kotlin/com/awan/app/core/domain/zones/usecase/GetSessionsByDateUseCase.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.awan.app.core.domain.zones.usecase - -import com.awan.app.core.common.result.Result -import com.awan.app.core.domain.zones.model.Session -import com.awan.app.core.domain.zones.repository.SessionRepository -import java.time.LocalDate -import javax.inject.Inject - -class GetSessionsByDateUseCase @Inject constructor( - private val sessionRepository: SessionRepository -) { - suspend operator fun invoke(date: LocalDate): Result> = - sessionRepository.getSessionsByDate(date) -} diff --git a/core/domain/src/main/kotlin/com/awan/app/core/domain/zones/usecase/GetTemplateOverridesUseCase.kt b/core/domain/src/main/kotlin/com/awan/app/core/domain/zones/usecase/GetTemplateOverridesUseCase.kt deleted file mode 100644 index cb170a0f..00000000 --- a/core/domain/src/main/kotlin/com/awan/app/core/domain/zones/usecase/GetTemplateOverridesUseCase.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.awan.app.core.domain.zones.usecase - -import com.awan.app.core.common.result.Result -import com.awan.app.core.domain.zones.model.TemplateOverride -import com.awan.app.core.domain.zones.repository.ZonesRepository -import javax.inject.Inject - -class GetTemplateOverridesUseCase @Inject constructor( - private val zonesRepository: ZonesRepository -) { - suspend operator fun invoke(): Result> = - zonesRepository.getOverrides() -} diff --git a/core/domain/src/main/kotlin/com/awan/app/core/domain/zones/usecase/UpdateSessionUseCase.kt b/core/domain/src/main/kotlin/com/awan/app/core/domain/zones/usecase/UpdateSessionUseCase.kt deleted file mode 100644 index e5c69a16..00000000 --- a/core/domain/src/main/kotlin/com/awan/app/core/domain/zones/usecase/UpdateSessionUseCase.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.awan.app.core.domain.zones.usecase - -import com.awan.app.core.common.result.Result -import com.awan.app.core.domain.zones.model.Session -import com.awan.app.core.domain.zones.repository.SessionRepository -import com.awan.app.core.model.UpdateSessionParams -import javax.inject.Inject - -class UpdateSessionUseCase @Inject constructor( - private val sessionRepository: SessionRepository -) { - suspend operator fun invoke( - sessionId: String, - params: UpdateSessionParams - ): Result = - sessionRepository.updateSession(sessionId, params) -} From 757eeab0043036b32a0280965c2e7945f39a3f4c Mon Sep 17 00:00:00 2001 From: Mohannad El-Sayeh Date: Mon, 10 Aug 2026 23:43:49 +0300 Subject: [PATCH 7/7] fix: include generation in backstack remembrance key - Add generation to the key() call in rememberDecoratedEntries so that Navigation 3 correctly recomposes entries when the back stack is reset to the same top-level route (e.g. re-selecting the current tab) --- app/src/main/java/com/awan/app/AwanApp.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/awan/app/AwanApp.kt b/app/src/main/java/com/awan/app/AwanApp.kt index c788bcc5..3c0de9e2 100644 --- a/app/src/main/java/com/awan/app/AwanApp.kt +++ b/app/src/main/java/com/awan/app/AwanApp.kt @@ -96,7 +96,7 @@ private fun NavigationState.rememberDecoratedEntries( entryProvider: (Route) -> NavEntry, ): List> { val decoratedStacks = subStacks.mapValues { (topLevelKey, stack) -> - key(topLevelKey) { + key(generation, topLevelKey) { rememberDecoratedNavEntries( backStack = stack, entryDecorators = listOf(