Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ import com.interlinedlist.android.feature.profile.ui.profile.PublicDocumentRoute
import com.interlinedlist.android.feature.profile.ui.profile.PublicListRoute
import com.interlinedlist.android.feature.profile.ui.profile.UserProfileRoute
import com.interlinedlist.android.feature.profile.ui.search.UserSearchRoute
import com.interlinedlist.android.feature.profile.ui.settings.AccountThemeSyncEffect
import com.interlinedlist.android.feature.profile.ui.settings.SettingsRoute
import com.interlinedlist.android.ui.home.HomeViewModel

Expand Down Expand Up @@ -212,6 +213,10 @@ fun InterlinedListNavHost(
}
composable(Routes.MAIN) {
val context = LocalContext.current
// Entered both by a cold start with a session and by a fresh sign-in, so
// this is where the account's theme is adopted (and any change made
// offline is pushed) without waiting for the user to open Settings.
AccountThemeSyncEffect()
MainShell(
notificationRoute = notificationRoute,
onLoggedOut = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,24 +17,71 @@ enum class ThemeMode {
}

/**
* Persists the user's appearance preference (System / Light / Dark) in plain
* SharedPreferences — it carries no secrets, so it does not need the encrypted
* session store. Exposes the current value as a [StateFlow] so the theme
* recomposes the moment the setting changes.
* Holds the device's appearance preference (System / Light / Dark) and remembers
* whether that preference has reached the account yet.
*
* The preference is *also* an account field (`theme` on `GET /api/user` /
* `PATCH /api/user/update`), so this store is deliberately the **device's** side of a
* two-way sync rather than a plain local setting:
*
* - [setThemeMode] records a choice the user made *here*. It takes effect
* immediately — online or not — and leaves [hasUnsyncedChange] true until the
* account confirms it, so a change made offline survives a process death and is
* still pushed on the next sync.
* - [setSyncedThemeMode] records the value the *account* holds, clearing the flag.
* It is used both when adopting the account's choice (fresh install, or changed on
* the web) and when a push of a local choice succeeds.
*
* Modelled behind an interface — like `LastSeenNotificationStore` — so the sync logic
* that drives it can be unit-tested against an in-memory fake with no Android
* dependency. The value carries no secrets, so the implementation uses plain
* SharedPreferences rather than the encrypted session store, and exposes the current
* mode as a [StateFlow] so the theme recomposes the moment it changes.
*/
interface ThemeSettingsStore {

/** The appearance currently in force on this device. */
val themeMode: StateFlow<ThemeMode>

/**
* True when [themeMode] holds a choice made on this device that the account has
* not confirmed yet — the flag that makes an offline change win the next
* reconciliation instead of being overwritten by the stale account value.
*/
val hasUnsyncedChange: Boolean

/** Records a choice made on this device, pending a push to the account. */
fun setThemeMode(mode: ThemeMode)

/** Records [mode] as the value the account holds, clearing [hasUnsyncedChange]. */
fun setSyncedThemeMode(mode: ThemeMode)
}

/** SharedPreferences-backed [ThemeSettingsStore]. */
@Singleton
class ThemeSettingsStore @Inject constructor(
class SharedPrefsThemeSettingsStore @Inject constructor(
@ApplicationContext context: Context,
) {
) : ThemeSettingsStore {

private val prefs: SharedPreferences =
context.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE)

private val _themeMode = MutableStateFlow(readThemeMode())
val themeMode: StateFlow<ThemeMode> = _themeMode.asStateFlow()
override val themeMode: StateFlow<ThemeMode> = _themeMode.asStateFlow()

override val hasUnsyncedChange: Boolean
get() = prefs.getBoolean(KEY_UNSYNCED, false)

override fun setThemeMode(mode: ThemeMode) = store(mode, unsynced = true)

override fun setSyncedThemeMode(mode: ThemeMode) = store(mode, unsynced = false)

/** Persists [mode] and pushes it to observers immediately. */
fun setThemeMode(mode: ThemeMode) {
prefs.edit().putString(KEY_THEME_MODE, mode.name).apply()
/** Persists [mode] plus its sync state and pushes it to observers immediately. */
private fun store(mode: ThemeMode, unsynced: Boolean) {
prefs.edit()
.putString(KEY_THEME_MODE, mode.name)
.putBoolean(KEY_UNSYNCED, unsynced)
.apply()
_themeMode.value = mode
}

Expand All @@ -46,5 +93,6 @@ class ThemeSettingsStore @Inject constructor(
companion object {
private const val PREFS_FILE = "il_settings.prefs"
private const val KEY_THEME_MODE = "theme_mode"
private const val KEY_UNSYNCED = "theme_mode_unsynced"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import android.content.Context
import android.content.SharedPreferences
import com.interlinedlist.android.core.common.session.SessionTokenProvider
import com.interlinedlist.android.core.datastore.SessionStore
import com.interlinedlist.android.core.datastore.SharedPrefsThemeSettingsStore
import com.interlinedlist.android.core.datastore.ThemeSettingsStore
import dagger.Binds
import dagger.Module
import dagger.Provides
Expand All @@ -30,4 +32,13 @@ abstract class SessionBindsModule {

@Binds
abstract fun bindSessionTokenProvider(impl: SessionStore): SessionTokenProvider

/**
* Singleton so the activity's theme observer and the settings sync that writes to
* it are the same store — otherwise an adopted account theme would not reach the
* running UI until the process restarted.
*/
@Binds
@Singleton
abstract fun bindThemeSettingsStore(impl: SharedPrefsThemeSettingsStore): ThemeSettingsStore
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import androidx.compose.ui.test.performImeAction
import androidx.compose.ui.test.performTextClearance
import androidx.compose.ui.test.performTextInput
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import com.interlinedlist.android.core.datastore.ThemeMode
import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme
import com.interlinedlist.android.feature.profile.domain.UserSettings
import com.interlinedlist.android.feature.profile.domain.ViewingPreference
Expand All @@ -38,6 +40,7 @@ class SettingsScreenTest {

private fun setContent(
state: SettingsUiState,
onSelectThemeMode: (ThemeMode) -> Unit = {},
onSelectViewingPreference: (ViewingPreference) -> Unit = {},
onToggleShowPreviews: (Boolean) -> Unit = {},
onSetMessagesPerPage: (Int) -> Unit = {},
Expand All @@ -55,6 +58,7 @@ class SettingsScreenTest {
state = state,
onBack = {},
onRetry = onRetry,
onSelectThemeMode = onSelectThemeMode,
onSelectViewingPreference = onSelectViewingPreference,
onToggleShowPreviews = onToggleShowPreviews,
onSetMessagesPerPage = onSetMessagesPerPage,
Expand Down Expand Up @@ -367,6 +371,7 @@ class SettingsScreenTest {
state = SettingsUiState(settings = settings, errorMessage = null),
onBack = {},
onRetry = {},
onSelectThemeMode = {},
onSelectViewingPreference = {},
onToggleShowPreviews = {},
onSetMessagesPerPage = {},
Expand Down Expand Up @@ -453,6 +458,7 @@ class SettingsScreenTest {
state = SettingsUiState(settings = settings),
onBack = {},
onRetry = {},
onSelectThemeMode = {},
onSelectViewingPreference = {},
onToggleShowPreviews = {},
onSetMessagesPerPage = {},
Expand All @@ -475,4 +481,54 @@ class SettingsScreenTest {

composeRule.onNodeWithTag(SettingsTestTags.PRIVATE_ACCOUNT).assertIsOff()
}

// --- Theme (issue #36) ---------------------------------------------------
// Filed under Profile because that is where the web files it: `/help/settings`
// lists Theme under "Profile settings", between Avatar and Max message length.

@Test
fun theme_showsTheDeviceSelectionInsideTheProfileGroup() {
setContent(SettingsUiState(settings = UserSettings(), themeMode = ThemeMode.DARK))

composeRule.onNodeWithTag(SettingsTestTags.PROFILE).assertIsDisplayed()
composeRule.onNodeWithTag(SettingsTestTags.themeMode(ThemeMode.DARK)).assertIsSelected()
}

/**
* The account field and the device store can disagree while an offline change is
* still owed; the control must show what the app is actually rendering.
*/
@Test
fun theme_prefersTheDeviceStoreOverTheAccountField() {
setContent(
SettingsUiState(
settings = UserSettings(theme = "light"),
themeMode = ThemeMode.DARK,
),
)

composeRule.onNodeWithTag(SettingsTestTags.themeMode(ThemeMode.DARK)).assertIsSelected()
}

@Test
fun choosingATheme_reportsTheSelection() {
var chosen: ThemeMode? = null
setContent(
SettingsUiState(settings = UserSettings(), themeMode = ThemeMode.SYSTEM),
onSelectThemeMode = { chosen = it },
)

composeRule.onNodeWithTag(SettingsTestTags.themeMode(ThemeMode.DARK)).performClick()

assertThat(chosen).isEqualTo(ThemeMode.DARK)
}

/** "Follow the device" is an option the account carries too, so it is offered. */
@Test
fun theme_offersFollowTheSystem() {
setContent(SettingsUiState(settings = UserSettings(), themeMode = ThemeMode.LIGHT))

composeRule.onNodeWithTag(SettingsTestTags.themeMode(ThemeMode.SYSTEM)).assertIsDisplayed()
composeRule.onNodeWithTag(SettingsTestTags.themeMode(ThemeMode.SYSTEM)).performClick()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,18 @@ package com.interlinedlist.android.feature.profile.data
import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider
import com.interlinedlist.android.core.common.result.ApiResult
import com.interlinedlist.android.core.common.result.AppError
import com.interlinedlist.android.core.datastore.ThemeMode
import com.interlinedlist.android.core.datastore.ThemeSettingsStore
import com.interlinedlist.android.core.network.error.safeApiCall
import com.interlinedlist.android.core.network.preferences.NotificationTrayLimitStore
import com.interlinedlist.android.feature.profile.data.mapper.toRequest
import com.interlinedlist.android.feature.profile.data.mapper.toUserSettings
import com.interlinedlist.android.feature.profile.data.remote.ProfileApi
import com.interlinedlist.android.feature.profile.domain.ThemeReconciliation
import com.interlinedlist.android.feature.profile.domain.UserSettings
import com.interlinedlist.android.feature.profile.domain.UserSettingsUpdate
import com.interlinedlist.android.feature.profile.domain.reconcileTheme
import com.interlinedlist.android.feature.profile.domain.wire
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
Expand All @@ -28,11 +33,18 @@ import javax.inject.Singleton
* group by that preference and no feature module here may depend on another. Without
* the forward, a limit changed in Settings would not take effect until the process
* restarted.
*
* `theme` is the one preference that is **not** read straight off the account: the
* device's [ThemeSettingsStore] is what the app renders from, so the appearance holds
* with no connection, and [refresh] reconciles the two. See
* [com.interlinedlist.android.feature.profile.domain.reconcileTheme] for the rule that
* decides which side wins.
*/
@Singleton
class DefaultSettingsRepository @Inject constructor(
private val api: ProfileApi,
private val trayLimitStore: NotificationTrayLimitStore,
private val themeStore: ThemeSettingsStore,
private val json: Json,
private val dispatchers: DispatcherProvider,
) : SettingsRepository {
Expand All @@ -41,28 +53,85 @@ class DefaultSettingsRepository @Inject constructor(

override fun observeSettings(): Flow<UserSettings?> = cached.asStateFlow()

override fun observeThemeMode(): Flow<ThemeMode> = themeStore.themeMode

override suspend fun refresh(): ApiResult<UserSettings> = withContext(dispatchers.io) {
when (val result = fetch()) {
is ApiResult.Success -> ApiResult.Success(reconcileTheme(result.data))
is ApiResult.Failure -> result
}
}

override suspend fun setThemeMode(mode: ThemeMode): ApiResult<UserSettings> =
withContext(dispatchers.io) {
// Local first, and regardless of what the network does next: the app must
// re-theme immediately and keep the choice while offline. The store marks
// it unsynced, which is what makes the next refresh push it rather than
// overwrite it with the account's stale value.
themeStore.setThemeMode(mode)
patch(UserSettingsUpdate(theme = mode.wire)).also { result ->
if (result is ApiResult.Success) themeStore.setSyncedThemeMode(mode)
}
}

override suspend fun update(update: UserSettingsUpdate): ApiResult<UserSettings> =
withContext(dispatchers.io) { patch(update) }

/** `GET /api/user` into the cache, with no theme reconciliation. */
private suspend fun fetch(): ApiResult<UserSettings> =
when (val result = safeApiCall(json) { api.getCurrentUser().userOrSelf }) {
is ApiResult.Success -> result.data
?.let { ApiResult.Success(publish(it.toUserSettings())) }
?: ApiResult.Failure(AppError.Unknown("No user in response"))
is ApiResult.Failure -> result
}

/**
* `PATCH /api/user/update` into the cache. Falls back to [fetch] — not [refresh] —
* when the endpoint echoes a thin body, so a push issued *by* reconciliation can
* never loop back into reconciliation.
*/
private suspend fun patch(update: UserSettingsUpdate): ApiResult<UserSettings> =
when (val result = safeApiCall(json) { api.updateProfile(update.toRequest()).userOrSelf }) {
is ApiResult.Success -> {
// The endpoint echoes the full updated user; if a thin body comes
// back instead, re-read so the cache still holds server truth.
val dto = result.data
?: return@withContext ApiResult.Failure(AppError.Unknown("No user in response"))
ApiResult.Success(publish(dto.toUserSettings()))
if (dto != null) ApiResult.Success(publish(dto.toUserSettings())) else fetch()
}
is ApiResult.Failure -> result
}
}

override suspend fun update(update: UserSettingsUpdate): ApiResult<UserSettings> =
withContext(dispatchers.io) {
when (val result = safeApiCall(json) { api.updateProfile(update.toRequest()).userOrSelf }) {
is ApiResult.Success -> {
// The endpoint echoes the full updated user; if a thin body comes
// back instead, re-read so the cache still holds server truth.
val dto = result.data
if (dto != null) ApiResult.Success(publish(dto.toUserSettings())) else refresh()
}
is ApiResult.Failure -> result
/**
* Brings the device's appearance and the account's `theme` back into agreement and
* answers with the settings that hold afterwards (a successful push re-reads them).
*
* A failed push is swallowed on purpose: the refresh that triggered it still
* succeeded, and the choice stays marked unsynced so the next refresh tries again.
*/
private suspend fun reconcileTheme(settings: UserSettings): UserSettings =
when (
val outcome = reconcileTheme(
local = themeStore.themeMode.value,
hasUnsyncedLocalChange = themeStore.hasUnsyncedChange,
accountTheme = settings.theme,
)
) {
ThemeReconciliation.InSync -> settings

is ThemeReconciliation.AdoptAccount -> {
themeStore.setSyncedThemeMode(outcome.mode)
settings
}

is ThemeReconciliation.PushLocal ->
when (val pushed = patch(UserSettingsUpdate(theme = outcome.mode.wire))) {
is ApiResult.Success -> {
themeStore.setSyncedThemeMode(outcome.mode)
pushed.data
}
is ApiResult.Failure -> settings
}
}

private fun publish(settings: UserSettings): UserSettings {
Expand Down
Loading
Loading