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( 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..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 @@ -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,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, @@ -79,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, @@ -114,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() @@ -125,6 +143,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 30561064..48fcc543 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/common/IsoTimeUtils.kt b/core/data/src/main/kotlin/com/awan/app/core/data/common/IsoTimeUtils.kt index 23e76414..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,38 +1,59 @@ package com.awan.app.core.data.common +import java.time.LocalDate +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 { + parseIso(isoDateTime)?.let { return it.toLocalTime().asStoredTime() } + // Already just a time portion. return try { - val odt = OffsetDateTime.parse(isoDateTime) - odt.toLocalTime().format(DateTimeFormatter.ISO_LOCAL_TIME) + LocalTime.parse(isoDateTime).asStoredTime() } catch (_: DateTimeParseException) { - // Try just the time portion for already-extracted times - try { - LocalTime.parse(isoDateTime) - isoDateTime - } 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 + } +} + +/** 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. + * + * 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/di/DataModule.kt b/core/data/src/main/kotlin/com/awan/app/core/data/di/DataModule.kt index 5141cd52..63db0830 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,14 @@ 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 +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 @@ -84,6 +92,30 @@ internal abstract class DataModule { impl: CalendarLocalDataSourceImpl, ): CalendarLocalDataSource + @Binds + @Singleton + abstract fun bindHomeLocalDataSource( + impl: HomeLocalDataSourceImpl, + ): HomeLocalDataSource + + @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/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/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..1fc376d6 --- /dev/null +++ b/core/data/src/main/kotlin/com/awan/app/core/data/home/local/HomeLocalDataSource.kt @@ -0,0 +1,120 @@ +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 getSession(sessionId: String): SessionEntity? + + 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 getSession(sessionId: String): SessionEntity? = sessionDao.getSession(sessionId) + + 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 e5b7e0e0..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 @@ -6,16 +6,11 @@ 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.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.local.HomeLocalDataSource import com.awan.app.core.data.home.remote.HomeRemoteDataSource -import com.awan.app.core.data.common.extractTimeFromIso +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,12 +21,15 @@ 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 import kotlinx.coroutines.withContext import android.util.Log +import com.awan.app.core.common.result.map import java.time.LocalDate +import java.time.LocalDateTime import java.time.LocalTime import javax.inject.Inject import javax.inject.Singleton @@ -44,14 +42,9 @@ import com.awan.app.core.model.TaskStatus @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 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 { @@ -65,7 +58,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 ?: "", @@ -82,7 +75,7 @@ class HomeRepositoryImpl @Inject constructor( } } - val cachedUser = userDao.getFirstUser() + val cachedUser = local.getCachedUser() if (cachedUser != null) { Result.Success( UserProfileInfo( @@ -98,14 +91,31 @@ 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( + 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 @@ -132,7 +142,7 @@ class HomeRepositoryImpl @Inject constructor( Result.Success( DaySchedule( date = date, - zones = resolveZonesForDate(dateStr, date), + zones = zoneEntities.map(::toDayZone), sessions = daySessions, ) ) @@ -145,11 +155,11 @@ class HomeRepositoryImpl @Inject constructor( val sessionResult = remoteDataSource.getSession(sessionId) val sessionDto = when (sessionResult) { is Result.Success -> { - cacheSession(sessionResult.data) + local.cacheSession(sessionResult.data) sessionResult.data } is Result.Error -> { - val cached = sessionDao.getSession(sessionId) + val cached = local.getSession(sessionId) if (cached != null) null else return@withContext Result.Error(sessionResult.error) } Result.Loading -> return@withContext Result.Loading @@ -158,19 +168,19 @@ class HomeRepositoryImpl @Inject constructor( val sessionDetailInfo = if (sessionDto != null) { SessionDetailInfo( id = sessionDto.id, - start = java.time.LocalDateTime.parse(sessionDto.start), - end = java.time.LocalDateTime.parse(sessionDto.end), + start = LocalDateTime.parse(sessionDto.start), + end = LocalDateTime.parse(sessionDto.end), status = mapStatus(sessionDto.status), locked = sessionDto.locked, zoneId = sessionDto.zoneId, taskId = sessionDto.taskId ?: "", ) } else { - val cached = sessionDao.getSession(sessionId)!! + val cached = local.getSession(sessionId)!! SessionDetailInfo( id = cached.id, - start = java.time.LocalDateTime.parse("${cached.date}T${cached.startTime}"), - end = java.time.LocalDateTime.parse("${cached.date}T${cached.endTime}"), + start = LocalDateTime.parse("${cached.date}T${cached.startTime}"), + end = LocalDateTime.parse("${cached.date}T${cached.endTime}"), status = mapStatus(cached.status), locked = cached.locked, zoneId = cached.zoneId, @@ -181,9 +191,12 @@ 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 = taskDao.getTask(taskId) + val cached = local.getTask(taskId) if (cached != null) null else return@withContext Result.Error(taskResult.error) } Result.Loading -> return@withContext Result.Loading @@ -204,8 +217,8 @@ class HomeRepositoryImpl @Inject constructor( dependsOnTaskIds = taskDto.dependsOnTaskIds ?: emptyList(), ) } else { - val cached = taskDao.getTask(taskId)!! - val category = cached.categoryId?.let { categoryDao.getCategory(it) } + val cached = local.getTask(taskId)!! + val category = cached.categoryId?.let { local.getCategory(it) } TaskDetailInfo( id = cached.id, title = cached.title, @@ -236,7 +249,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. @@ -264,31 +277,6 @@ class HomeRepositoryImpl @Inject constructor( remoteDataSource.moveSession(sessionId, startIso, endIso) } - override suspend fun updateSessionLock( - sessionId: String, - locked: Boolean, - ): Result = applySessionChange { - if (locked) { - remoteDataSource.lockSession(sessionId) - } else { - remoteDataSource.unlockSession(sessionId) - } - } - - override suspend fun deleteSession(sessionId: String): Result = withContext(ioDispatcher) { - if (!connectivityMonitor.isCurrentlyOnline()) { - return@withContext Result.Error(AppError.Network) - } - when (val result = remoteDataSource.deleteSession(sessionId)) { - is Result.Success -> { - sessionDao.deleteSession(sessionId) - Result.Success(Unit) - } - is Result.Error -> Result.Error(result.error) - Result.Loading -> unexpectedLoading() - } - } - private suspend fun applySessionChange( call: suspend () -> Result, ): Result = withContext(ioDispatcher) { @@ -297,7 +285,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) @@ -305,25 +293,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) @@ -351,34 +320,28 @@ 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() + 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 = applySessionChange { + if (locked) { + remoteDataSource.lockSession(sessionId) } 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 - ) + remoteDataSource.unlockSession(sessionId) } } @@ -390,10 +353,7 @@ class HomeRepositoryImpl @Inject constructor( estimatedPoints: Int?, mandatory: Boolean?, allowTaskSplitting: Boolean?, - ): Result = withContext(ioDispatcher) { - if (!connectivityMonitor.isCurrentlyOnline()) { - return@withContext Result.Error(AppError.Network) - } + ): Result { val request = com.awan.app.core.network.dto.task.TaskUpdateRequest( title = title, description = description, @@ -403,25 +363,45 @@ class HomeRepositoryImpl @Inject constructor( allowTaskSplitting = allowTaskSplitting, ) val result = remoteDataSource.updateTask(taskId, request) - when (result) { - is Result.Success -> Result.Success(Unit) + return when (result) { + is Result.Success -> { + local.cacheTask(taskId, result.data) + Result.Success(Unit) + } is Result.Error -> Result.Error(result.error) else -> Result.Error(AppError.Unknown(Throwable("Failed to update task details"))) } } - override suspend fun deleteTask(taskId: String): Result = withContext(ioDispatcher) { - if (!connectivityMonitor.isCurrentlyOnline()) { - return@withContext Result.Error(AppError.Network) + override suspend fun deleteSession(sessionId: String): Result { + checkOnline()?.let { return it } + val result = remoteDataSource.deleteSession(sessionId) + return when (result) { + is Result.Success -> { + local.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) - when (result) { - is Result.Success -> Result.Success(Unit) + return when (result) { + is Result.Success -> { + local.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 90ed6c0a..4438a0e0 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 f85d0947..28a5d221 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 @@ -12,7 +12,6 @@ import com.awan.app.core.database.dao.TemplateDao import com.awan.app.core.database.dao.TemplateOverrideDao import com.awan.app.core.database.dao.StoreDao 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.EquippedItemEntity @@ -21,18 +20,15 @@ import com.awan.app.core.database.model.OwnedItemEntity import com.awan.app.core.database.model.SessionEntity import com.awan.app.core.database.model.StoreItemEntity 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.marketplace.remote.StoreRemoteDataSource 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 @@ -59,13 +55,12 @@ class OfflineSyncCoordinator @Inject constructor( private val goalDao: GoalDao, private val storeDao: StoreDao, 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), @@ -86,10 +81,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 @@ -280,81 +275,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..e4230f1d --- /dev/null +++ b/core/data/src/main/kotlin/com/awan/app/core/data/zones/local/ZonesLocalDataSource.kt @@ -0,0 +1,98 @@ +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 kotlinx.coroutines.flow.Flow +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, + ) + + /** Override for the date beats template for the day-of-week; see [ZoneDao.observeEffectiveZonesForDate]. */ + fun observeEffectiveZonesForDate(date: String, dayOfWeek: String): Flow> +} + +@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) + } + + override fun observeEffectiveZonesForDate(date: String, dayOfWeek: String): Flow> = + zoneDao.observeEffectiveZonesForDate(date, dayOfWeek) +} + +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/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 9c197cdf..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 @@ -5,13 +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 import com.awan.app.core.domain.zones.model.DailyZone @@ -38,9 +38,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 +50,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 +99,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 +119,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 +138,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 +157,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 +170,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 +195,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 +214,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 +233,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 +262,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 [ZonesLocalDataSource]. */ + private suspend fun resolveZoneEntitiesForDate(date: LocalDate): List = + 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 b210bc57..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 @@ -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, ) } @@ -72,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( @@ -125,4 +165,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/common/IsoTimeUtilsTest.kt b/core/data/src/test/java/com/awan/app/core/data/common/IsoTimeUtilsTest.kt new file mode 100644 index 00000000..76afba8e --- /dev/null +++ b/core/data/src/test/java/com/awan/app/core/data/common/IsoTimeUtilsTest.kt @@ -0,0 +1,56 @@ +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")) + } + + @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/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 2c280688..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 @@ -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 @@ -89,102 +92,49 @@ 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) {} - override suspend fun replaceSessionsForDates(dates: List, sessions: List) {} -} - -private class FakeZoneDao( - private val templateZones: List = emptyList(), -) : ZoneDao { - 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 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) {} +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 getSession(sessionId: String): com.awan.app.core.database.model.SessionEntity? = + sessions.value.firstOrNull { it.id == sessionId } + 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 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 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 open class AlwaysOnlineMonitor : com.awan.app.core.domain.network.NetworkConnectivityMonitor { @@ -192,26 +142,51 @@ 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, - zoneDao: ZoneDao = FakeZoneDao(), - templateDao: TemplateDao = FakeTemplateDao(), + local: FakeHomeLocalDataSource = FakeHomeLocalDataSource(), + scheduleSynchronizer: com.awan.app.core.data.sync.ScheduleSynchronizer = FakeScheduleSynchronizer(), connectivityMonitor: com.awan.app.core.domain.network.NetworkConnectivityMonitor = AlwaysOnlineMonitor(), ): HomeRepositoryImpl { return HomeRepositoryImpl( remoteDataSource = fakeRemote, - userDao = FakeUserDao(), + local = local, eventBus = eventBus, - taskDao = FakeTaskDao(), - sessionDao = FakeSessionDao(), - zoneDao = zoneDao, - templateDao = templateDao, - templateOverrideDao = FakeTemplateOverrideDao(), - categoryDao = FakeCategoryDao(), + scheduleSynchronizer = scheduleSynchronizer, connectivityMonitor = connectivityMonitor, ioDispatcher = kotlinx.coroutines.Dispatchers.Unconfined, ) @@ -329,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 -> @@ -391,7 +370,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", @@ -404,12 +383,6 @@ class HomeRepositoryImplTest { ), ), ), - templateDao = FakeTemplateDao( - dayAssignment = TemplateDayOfWeekEntity( - dayOfWeek = date.dayOfWeek.name, - templateId = "template-1", - ), - ), ) val result = repository.getDaySchedule(date).first() @@ -418,6 +391,56 @@ class HomeRepositoryImplTest { 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 local = FakeHomeLocalDataSource() + val repository = createRepository(fakeRemote = FakeHomeRemoteDataSource(), local = local) + + 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 + } + } + + local.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) + } + @Test fun `deleteSession returns AppError Network when offline`() = runTest { val remote = FakeHomeRemoteDataSource() 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 +} 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 ea7778d8..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 @@ -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 @@ -149,9 +150,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") @@ -160,7 +165,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") @@ -287,6 +293,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 { @@ -298,6 +306,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 @@ -314,6 +323,31 @@ 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 + } + + override fun observeEffectiveZonesForDate( + date: String, + dayOfWeek: String, + ): Flow> = flowOf(emptyList()) } private class FakeCachedScheduleDateDao : CachedScheduleDateDao { @@ -363,9 +397,8 @@ class OfflineSyncCoordinatorTest { goalDao: GoalDao = FakeGoalDao(), storeDao: StoreDao = FakeStoreDao(), 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( @@ -381,9 +414,8 @@ class OfflineSyncCoordinatorTest { goalDao = goalDao, storeDao = storeDao, userDao = userDao, - zoneDao = zoneDao, templateDao = templateDao, - templateOverrideDao = templateOverrideDao, + zonesLocalDataSource = zonesLocalDataSource, cachedScheduleDateDao = cachedScheduleDateDao, connectivityMonitor = connectivityMonitor, ioDispatcher = testDispatcher, @@ -532,4 +564,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..6151dff0 --- /dev/null +++ b/core/data/src/test/java/com/awan/app/core/data/zones/ZonesRepositoryImplTest.kt @@ -0,0 +1,177 @@ +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.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, + 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++ + } + + override fun observeEffectiveZonesForDate(date: String, dayOfWeek: String): Flow> = + flowOf(emptyList()) +} + +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) +} + 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 2499addc..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 @@ -1,12 +1,11 @@ package com.awan.app.core.data.zones.mapper import com.awan.app.core.model.SessionStatus +import com.awan.app.core.network.di.NetworkModule import com.awan.app.core.network.dto.category.CategoryDto import com.awan.app.core.network.dto.session.SessionDto 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 @@ -45,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", @@ -84,23 +118,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 8262fc1d..7e3d9280 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 @@ -12,6 +12,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/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/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/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) -} 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 e994b028..90ca68e8 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 @@ -24,6 +24,8 @@ import com.awan.app.core.domain.home.model.DaySchedule import com.awan.app.core.model.SessionStatus import com.awan.app.core.domain.home.usecase.GetDayScheduleUseCase import com.awan.app.core.domain.home.usecase.GetSessionDetailUseCase +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.UpdateTaskDetailUseCase import com.awan.app.core.domain.home.usecase.DeleteSessionUseCase import com.awan.app.core.domain.home.usecase.DeleteTaskUseCase @@ -59,6 +61,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, @@ -229,6 +233,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) diff --git a/feature/profile/impl/src/test/java/com/awan/feature/profile/impl/presentation/FakeZonesRepository.kt b/feature/profile/impl/src/test/java/com/awan/feature/profile/impl/presentation/FakeZonesRepository.kt index 0bd7030a..d705e642 100644 --- a/feature/profile/impl/src/test/java/com/awan/feature/profile/impl/presentation/FakeZonesRepository.kt +++ b/feature/profile/impl/src/test/java/com/awan/feature/profile/impl/presentation/FakeZonesRepository.kt @@ -31,6 +31,8 @@ class FakeZonesRepository : ZonesRepository { return Result.Success(zones) } + override suspend fun refreshZones(): Result = Result.Success(Unit) + override suspend fun getZonesForDate(date: LocalDate): Result> = Result.Success(emptyList()) override suspend fun createTemplate(name: String, daysOfWeek: List, zones: List): Result {