Skip to content
Merged
2 changes: 1 addition & 1 deletion app/src/main/java/com/awan/app/AwanApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ private fun NavigationState.rememberDecoratedEntries(
entryProvider: (Route) -> NavEntry<Route>,
): List<NavEntry<Route>> {
val decoratedStacks = subStacks.mapValues { (topLevelKey, stack) ->
key(topLevelKey) {
key(generation, topLevelKey) {
rememberDecoratedNavEntries(
backStack = stack,
entryDecorators = listOf(
Expand Down
Original file line number Diff line number Diff line change
@@ -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() }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Unit> =
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<Unit> {
val accessToken = authTokenProvider.getAccessToken()

Expand All @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,19 @@ class CategoryRepositoryImpl @Inject constructor(
) : CategoryRepository {

override suspend fun getCategories(): Result<List<Category>> {
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<Category> {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -25,16 +26,19 @@ 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()

private val _rewards = MutableSharedFlow<RewardEvent>(extraBufferCapacity = REWARD_BUFFER)
val rewards: Flow<RewardEvent> = _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. */
Expand All @@ -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))
Expand All @@ -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))
Expand All @@ -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
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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,
)
)
}
}

Loading
Loading