diff --git a/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt b/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt index cf0ec9d..1242b57 100644 --- a/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt +++ b/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt @@ -84,6 +84,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.SettingsRoute import com.interlinedlist.android.ui.home.HomeViewModel /** Navigation route keys. */ @@ -141,6 +142,9 @@ object Routes { const val ACCOUNT_BLOCKED_MUTED = "account/blocked-muted" const val ACCOUNT_SETTINGS = "account/settings" + // Preferences (mirrors the web Settings page), reached from the Account hub. + const val SETTINGS = "settings" + // Notifications / organizations / integrations (reached from the Account hub). const val NOTIFICATIONS = "notifications" const val NOTIFICATION_PREFERENCES = "notifications/preferences" @@ -484,9 +488,13 @@ private fun MainShell( onOpenConnectedAccounts = { tabNav.navigate(Routes.ACCOUNT_CONNECTED) }, onOpenBlockedMuted = { tabNav.navigate(Routes.ACCOUNT_BLOCKED_MUTED) }, onOpenAccountSettings = { tabNav.navigate(Routes.ACCOUNT_SETTINGS) }, + onOpenSettings = { tabNav.navigate(Routes.SETTINGS) }, onSignOut = { logoutViewModel.logout(onLoggedOut) }, ) } + composable(Routes.SETTINGS) { + SettingsRoute(onBack = { tabNav.popBackStack() }) + } composable(Routes.ACCOUNT_BLOCKED_MUTED) { BlockedMutedRoute(onBack = { tabNav.popBackStack() }) } diff --git a/feature/profile/src/androidTest/kotlin/com/interlinedlist/android/feature/profile/ui/SettingsScreenTest.kt b/feature/profile/src/androidTest/kotlin/com/interlinedlist/android/feature/profile/ui/SettingsScreenTest.kt new file mode 100644 index 0000000..f8607f0 --- /dev/null +++ b/feature/profile/src/androidTest/kotlin/com/interlinedlist/android/feature/profile/ui/SettingsScreenTest.kt @@ -0,0 +1,130 @@ +package com.interlinedlist.android.feature.profile.ui + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsOff +import androidx.compose.ui.test.assertIsOn +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.profile.domain.UserSettings +import com.interlinedlist.android.feature.profile.domain.ViewingPreference +import com.interlinedlist.android.feature.profile.ui.settings.SettingsScreen +import com.interlinedlist.android.feature.profile.ui.settings.SettingsTestTags +import com.interlinedlist.android.feature.profile.ui.settings.SettingsUiState +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SettingsScreenTest { + + @get:Rule + val composeRule = createComposeRule() + + private fun setContent( + state: SettingsUiState, + onSelectViewingPreference: (ViewingPreference) -> Unit = {}, + onToggleShowPreviews: (Boolean) -> Unit = {}, + onRetry: () -> Unit = {}, + onDismissError: () -> Unit = {}, + ) { + composeRule.setContent { + InterlinedListTheme { + SettingsScreen( + state = state, + onBack = {}, + onRetry = onRetry, + onSelectViewingPreference = onSelectViewingPreference, + onToggleShowPreviews = onToggleShowPreviews, + onDismissError = onDismissError, + ) + } + } + } + + @Test + fun viewPreferences_showsTheStoredSelectionAndPreviewToggle() { + setContent( + SettingsUiState( + settings = UserSettings( + viewingPreference = ViewingPreference.FOLLOWING, + showPreviews = false, + ), + ), + ) + + composeRule.onNodeWithTag(SettingsTestTags.VIEW_PREFERENCES).assertIsDisplayed() + composeRule.onNodeWithTag(SettingsTestTags.viewingPreference(ViewingPreference.FOLLOWING)) + .assertIsSelected() + composeRule.onNodeWithTag(SettingsTestTags.SHOW_PREVIEWS).assertIsOff() + } + + @Test + fun choosingAnotherViewPreference_reportsTheSelection() { + var chosen: ViewingPreference? = null + setContent( + state = SettingsUiState(settings = UserSettings(viewingPreference = ViewingPreference.ALL)), + onSelectViewingPreference = { chosen = it }, + ) + + composeRule.onNodeWithTag(SettingsTestTags.viewingPreference(ViewingPreference.FOLLOWERS)) + .performClick() + + assert(chosen == ViewingPreference.FOLLOWERS) + } + + @Test + fun togglingLinkPreviews_reportsTheNewValue() { + var toggled: Boolean? = null + setContent( + state = SettingsUiState(settings = UserSettings(showPreviews = true)), + onToggleShowPreviews = { toggled = it }, + ) + + composeRule.onNodeWithTag(SettingsTestTags.SHOW_PREVIEWS).assertIsOn().performClick() + + assert(toggled == false) + } + + @Test + fun loadFailure_showsTheErrorAndRetries() { + var retried = false + setContent( + state = SettingsUiState(settings = null, isLoading = false, errorMessage = "No connection."), + onRetry = { retried = true }, + ) + + composeRule.onNodeWithTag(SettingsTestTags.ERROR).assertIsDisplayed() + composeRule.onNodeWithTag(SettingsTestTags.RETRY).performClick() + + assert(retried) + } + + @Test + fun saveFailure_showsADismissibleBannerAboveTheGroups() { + var dismissed = false + setContent( + state = SettingsUiState( + settings = UserSettings(showPreviews = true), + errorMessage = "Couldn't save that.", + ), + onDismissError = { dismissed = true }, + ) + + composeRule.onNodeWithText("Couldn't save that.").assertIsDisplayed() + composeRule.onNodeWithTag(SettingsTestTags.DISMISS_ERROR).performClick() + + assert(dismissed) + } + + @Test + fun loading_showsTheProgressIndicator() { + setContent(SettingsUiState(settings = null, isLoading = true)) + + composeRule.onNodeWithTag(SettingsTestTags.PROGRESS).assertIsDisplayed() + } +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/DefaultSettingsRepository.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/DefaultSettingsRepository.kt new file mode 100644 index 0000000..699f715 --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/DefaultSettingsRepository.kt @@ -0,0 +1,64 @@ +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.network.error.safeApiCall +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.UserSettings +import com.interlinedlist.android.feature.profile.domain.UserSettingsUpdate +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Network-backed settings with a process-scoped in-memory cache: every successful + * read or write publishes the new value to [observeSettings], so the Settings screen + * and the feed see the same preferences without either re-fetching. + */ +@Singleton +class DefaultSettingsRepository @Inject constructor( + private val api: ProfileApi, + private val json: Json, + private val dispatchers: DispatcherProvider, +) : SettingsRepository { + + private val cached = MutableStateFlow(null) + + override fun observeSettings(): Flow = cached.asStateFlow() + + override suspend fun refresh(): ApiResult = withContext(dispatchers.io) { + when (val result = safeApiCall(json) { api.getCurrentUser().userOrSelf }) { + is ApiResult.Success -> { + val dto = result.data + ?: return@withContext ApiResult.Failure(AppError.Unknown("No user in response")) + ApiResult.Success(publish(dto.toUserSettings())) + } + is ApiResult.Failure -> result + } + } + + override suspend fun update(update: UserSettingsUpdate): ApiResult = + 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 + } + } + + private fun publish(settings: UserSettings): UserSettings { + cached.value = settings + return settings + } +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/SettingsRepository.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/SettingsRepository.kt new file mode 100644 index 0000000..cd89518 --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/SettingsRepository.kt @@ -0,0 +1,31 @@ +package com.interlinedlist.android.feature.profile.data + +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.profile.domain.UserSettings +import com.interlinedlist.android.feature.profile.domain.UserSettingsUpdate +import kotlinx.coroutines.flow.Flow + +/** + * The current user's preferences, read from `GET /api/user` and written with + * `PATCH /api/user/update`. + * + * Kept separate from [ProfileRepository] because the consumers differ: Settings + * screens and the messages feed care about preference values, not about the profile + * cache. Preferences are a small always-fresh surface, so they are held in memory + * (process-scoped) rather than in Room — [observeSettings] replays the latest known + * value to every collector and re-emits after each successful refresh or update. + */ +interface SettingsRepository { + + /** The last known settings, or null until the first successful [refresh]. */ + fun observeSettings(): Flow + + /** Re-reads the settings from `GET /api/user` and publishes them to [observeSettings]. */ + suspend fun refresh(): ApiResult + + /** + * Applies a partial [update] via `PATCH /api/user/update`. Fields left null in + * [update] are omitted from the request, so other preferences are untouched. + */ + suspend fun update(update: UserSettingsUpdate): ApiResult +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/mapper/SettingsMappers.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/mapper/SettingsMappers.kt new file mode 100644 index 0000000..1ea22aa --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/mapper/SettingsMappers.kt @@ -0,0 +1,50 @@ +package com.interlinedlist.android.feature.profile.data.mapper + +import com.interlinedlist.android.feature.profile.data.remote.dto.ProfileUserDto +import com.interlinedlist.android.feature.profile.data.remote.dto.UpdateProfileRequest +import com.interlinedlist.android.feature.profile.domain.UserSettings +import com.interlinedlist.android.feature.profile.domain.UserSettingsUpdate +import com.interlinedlist.android.feature.profile.domain.ViewingPreference + +/** + * Maps the wire user onto the settings the Settings screen and the feed read. + * An absent or unrecognised `viewingPreference`/`showPreviews` falls back to the + * server default rather than failing — these two drive the feed, so they must + * always resolve to something usable. + */ +fun ProfileUserDto.toUserSettings(): UserSettings = UserSettings( + theme = theme, + maxMessageLength = maxMessageLength, + defaultPubliclyVisible = defaultPubliclyVisible, + messagesPerPage = messagesPerPage, + viewingPreference = ViewingPreference.fromWireOrDefault(viewingPreference), + showPreviews = showPreviews ?: true, + showAdvancedPostSettings = showAdvancedPostSettings, + latitude = latitude, + longitude = longitude, + isPrivateAccount = isPrivateAccount, + githubDefaultRepo = githubDefaultRepo, + notificationTrayLimit = notificationTrayLimit, +) + +/** + * Maps a partial settings update onto the PATCH body. Untouched (null) fields stay + * null and are dropped from the JSON, so the request only carries what changed. + */ +fun UserSettingsUpdate.toRequest(): UpdateProfileRequest = UpdateProfileRequest( + displayName = displayName, + bio = bio, + avatar = avatar, + theme = theme, + maxMessageLength = maxMessageLength, + defaultPubliclyVisible = defaultPubliclyVisible, + messagesPerPage = messagesPerPage, + viewingPreference = viewingPreference?.wire, + showPreviews = showPreviews, + showAdvancedPostSettings = showAdvancedPostSettings, + latitude = latitude, + longitude = longitude, + isPrivateAccount = isPrivateAccount, + githubDefaultRepo = githubDefaultRepo, + notificationTrayLimit = notificationTrayLimit, +) diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/ProfileRequests.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/ProfileRequests.kt index a0c1c77..1250cf9 100644 --- a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/ProfileRequests.kt +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/ProfileRequests.kt @@ -3,15 +3,33 @@ package com.interlinedlist.android.feature.profile.data.remote.dto import kotlinx.serialization.Serializable /** - * Body for `PATCH /api/user/update`. Only the profile fields this module edits are - * modelled; null fields are omitted (see the module's `explicitNulls = false` - * JSON config) so the server leaves the rest unchanged. + * Body for `PATCH /api/user/update`. Every field the endpoint accepts is modelled + * (verified against the OpenAPI spec) and every one is optional: null fields are + * omitted by the module's `explicitNulls = false` JSON config, so a partial PATCH + * only ever touches what the caller set. + * + * The values are sent with their natural JSON types (`Int`, `Boolean`, `Double`), + * matching the types the same fields come back with on `UserWire`. The generated + * spec types several of them as `string` because the route handler coerces its + * input, which native types satisfy too. */ @Serializable data class UpdateProfileRequest( val displayName: String? = null, val bio: String? = null, val avatar: String? = null, + val theme: String? = null, + val maxMessageLength: Int? = null, + val defaultPubliclyVisible: Boolean? = null, + val messagesPerPage: Int? = null, + val viewingPreference: String? = null, + val showPreviews: Boolean? = null, + val showAdvancedPostSettings: Boolean? = null, + val latitude: Double? = null, + val longitude: Double? = null, + val isPrivateAccount: Boolean? = null, + val githubDefaultRepo: String? = null, + val notificationTrayLimit: Int? = null, ) /** Body for `POST /api/user/avatar/from-url`. */ diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/ProfileUserDto.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/ProfileUserDto.kt index cfc8a8c..5efb7f2 100644 --- a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/ProfileUserDto.kt +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/ProfileUserDto.kt @@ -17,6 +17,20 @@ data class ProfileUserDto( val avatar: String? = null, val bio: String? = null, val customerStatus: String? = null, + // --- Preference fields (present on `GET /api/user` for the signed-in user + // only; other users' public profiles omit them, hence all-nullable). --- + val theme: String? = null, + val maxMessageLength: Int? = null, + val defaultPubliclyVisible: Boolean? = null, + val messagesPerPage: Int? = null, + val viewingPreference: String? = null, + val showPreviews: Boolean? = null, + val showAdvancedPostSettings: Boolean? = null, + val latitude: Double? = null, + val longitude: Double? = null, + val isPrivateAccount: Boolean? = null, + val githubDefaultRepo: String? = null, + val notificationTrayLimit: Int? = null, ) { /** The avatar URL under whichever field the endpoint populated. */ val avatarOrNull: String? get() = avatarUrl ?: avatar diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/di/ProfileModule.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/di/ProfileModule.kt index 72b4cfc..9f11b93 100644 --- a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/di/ProfileModule.kt +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/di/ProfileModule.kt @@ -3,7 +3,9 @@ package com.interlinedlist.android.feature.profile.di import android.content.Context import androidx.room.Room import com.interlinedlist.android.feature.profile.data.DefaultProfileRepository +import com.interlinedlist.android.feature.profile.data.DefaultSettingsRepository import com.interlinedlist.android.feature.profile.data.ProfileRepository +import com.interlinedlist.android.feature.profile.data.SettingsRepository import com.interlinedlist.android.feature.profile.data.local.ProfileDao import com.interlinedlist.android.feature.profile.data.local.ProfileDatabase import com.interlinedlist.android.feature.profile.data.remote.ProfileApi @@ -16,7 +18,7 @@ import dagger.hilt.components.SingletonComponent import retrofit2.Retrofit import javax.inject.Singleton -/** Binds the repository interface to its default implementation. */ +/** Binds the repository interfaces to their default implementations. */ @Module @InstallIn(SingletonComponent::class) abstract class ProfileRepositoryModule { @@ -24,6 +26,11 @@ abstract class ProfileRepositoryModule { @Binds @Singleton abstract fun bindProfileRepository(impl: DefaultProfileRepository): ProfileRepository + + /** Singleton so the in-memory settings cache is shared app-wide (Settings + feed). */ + @Binds + @Singleton + abstract fun bindSettingsRepository(impl: DefaultSettingsRepository): SettingsRepository } /** Provides this feature's API, its own Room database, and DAO. */ diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/domain/UserSettings.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/domain/UserSettings.kt new file mode 100644 index 0000000..86488f0 --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/domain/UserSettings.kt @@ -0,0 +1,107 @@ +package com.interlinedlist.android.feature.profile.domain + +/** + * Which messages the Home feed shows — the `viewingPreference` field of + * `GET /api/user` / `PATCH /api/user/update`. + * + * The OpenAPI spec types the field as a bare `string` (it is a plain Prisma column, + * not an enum), so the wire values below come from the live API instead: `GET /api/user` + * returns `"all_messages"`, and PATCHing a bogus value answers with the server's own + * allow-list — `viewingPreference must be one of: my_messages, all_messages, + * followers_only, following_only`. + * + * [fromWire] stays deliberately tolerant — any casing plus the obvious shorthands — + * so an unexpected spelling degrades to the right selection rather than silently + * resetting the user's feed. + */ +enum class ViewingPreference(val wire: String) { + /** Your messages plus all public messages. */ + ALL("all_messages"), + + /** Only your own messages. */ + MINE("my_messages"), + + /** Messages from people you follow, plus your own. */ + FOLLOWING("following_only"), + + /** Messages from people who follow you, plus your own. */ + FOLLOWERS("followers_only"), + ; + + companion object { + /** What the API defaults to for a new account, and our fallback for an unknown value. */ + val DEFAULT = ALL + + /** + * Parses a wire value, returning null when it matches no known option. Casing + * and separators are ignored, so the canonical values (listed first below) and + * the obvious shorthands both resolve. + */ + fun fromWire(value: String?): ViewingPreference? { + val normalised = value?.lowercase()?.filter { it.isLetter() } ?: return null + return when (normalised) { + "allmessages", "all", "everyone" -> ALL + "mymessages", "mine", "my", "onlymine", "me" -> MINE + "followingonly", "following" -> FOLLOWING + "followersonly", "followers" -> FOLLOWERS + else -> null + } + } + + /** Parses a wire value, falling back to [DEFAULT] when it is missing or unknown. */ + fun fromWireOrDefault(value: String?): ViewingPreference = fromWire(value) ?: DEFAULT + } +} + +/** + * The current user's preference fields as returned by `GET /api/user`. + * + * Every field the Settings surface can show is modelled up front so the remaining + * settings groups only have to add UI. Fields are nullable where the API may omit + * them; [viewingPreference] and [showPreviews] carry the server defaults because the + * feed consumes them directly and must never be left undecided. + * + * Profile fields (display name, bio, avatar) intentionally live on [ProfileUser] — + * they are edited by the edit-profile screen, not by Settings. + */ +data class UserSettings( + val theme: String? = null, + val maxMessageLength: Int? = null, + val defaultPubliclyVisible: Boolean? = null, + val messagesPerPage: Int? = null, + val viewingPreference: ViewingPreference = ViewingPreference.DEFAULT, + val showPreviews: Boolean = true, + val showAdvancedPostSettings: Boolean? = null, + val latitude: Double? = null, + val longitude: Double? = null, + val isPrivateAccount: Boolean? = null, + val githubDefaultRepo: String? = null, + val notificationTrayLimit: Int? = null, +) + +/** + * A partial update for `PATCH /api/user/update`: every field the endpoint accepts, + * all optional. A null field means "leave it alone" — it is omitted from the request + * body entirely, so changing one preference can never clobber another. + * + * (Consequence to be aware of: a nullable server field such as `latitude` cannot be + * *cleared* through this type. Nothing needs that yet; whichever settings group does + * will have to model the clear explicitly.) + */ +data class UserSettingsUpdate( + val displayName: String? = null, + val bio: String? = null, + val avatar: String? = null, + val theme: String? = null, + val maxMessageLength: Int? = null, + val defaultPubliclyVisible: Boolean? = null, + val messagesPerPage: Int? = null, + val viewingPreference: ViewingPreference? = null, + val showPreviews: Boolean? = null, + val showAdvancedPostSettings: Boolean? = null, + val latitude: Double? = null, + val longitude: Double? = null, + val isPrivateAccount: Boolean? = null, + val githubDefaultRepo: String? = null, + val notificationTrayLimit: Int? = null, +) diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileScreen.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileScreen.kt index 4b16768..77c2e73 100644 --- a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileScreen.kt +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.material.icons.filled.ManageAccounts import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Tune import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -62,6 +63,7 @@ object AccountMenuTestTags { const val CONNECTED_ACCOUNTS = "accountMenuConnectedAccounts" const val BLOCKED_MUTED = "accountMenuBlockedMuted" const val ACCOUNT_SETTINGS = "accountMenuAccountSettings" + const val SETTINGS = "accountMenuSettings" } /** @@ -85,6 +87,7 @@ object AccountMenuTestTags { * @param onOpenConnectedAccounts navigate to the Connected Accounts screen (within this module). * @param onOpenBlockedMuted navigate to the "Blocked & muted" screen (within this module). * @param onOpenAccountSettings navigate to the Account settings screen (within this module). + * @param onOpenSettings navigate to the Settings (preferences) screen (within this module). * @param onSignOut invoked after the caller performs sign-out; the profile module does * not own session state, so the app wires this to the auth logout + navigation. */ @@ -105,6 +108,8 @@ fun ProfileRoute( // Defaulted so existing app nav wiring compiles unchanged; wire this to the // `account/blocked-muted` route to enable the Blocked & muted screen (Milestone D). onOpenBlockedMuted: () -> Unit = {}, + // Defaulted likewise; wire this to the `settings` route for the preferences screen. + onOpenSettings: () -> Unit = {}, modifier: Modifier = Modifier, viewModel: ProfileViewModel = hiltViewModel(), ) { @@ -125,6 +130,7 @@ fun ProfileRoute( onOpenConnectedAccounts = onOpenConnectedAccounts, onOpenBlockedMuted = onOpenBlockedMuted, onOpenAccountSettings = onOpenAccountSettings, + onOpenSettings = onOpenSettings, onSignOut = onSignOut, onRetry = viewModel::refresh, modifier = modifier, @@ -150,6 +156,7 @@ fun ProfileScreen( onSignOut: () -> Unit, onRetry: () -> Unit, onOpenBlockedMuted: () -> Unit = {}, + onOpenSettings: () -> Unit = {}, modifier: Modifier = Modifier, ) { Scaffold( @@ -240,6 +247,12 @@ fun ProfileScreen( onClick = onOpenBlockedMuted, tag = AccountMenuTestTags.BLOCKED_MUTED, ) + AccountMenuRow( + icon = Icons.Default.Tune, + label = "Settings", + onClick = onOpenSettings, + tag = AccountMenuTestTags.SETTINGS, + ) AccountMenuRow( icon = Icons.Default.ManageAccounts, label = "Account settings", diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/settings/SettingsComponents.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/settings/SettingsComponents.kt new file mode 100644 index 0000000..e3f76e2 --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/settings/SettingsComponents.kt @@ -0,0 +1,125 @@ +package com.interlinedlist.android.feature.profile.ui.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.selectable +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp + +/** + * A titled block of related settings, mirroring the grouping of the web Settings page. + * Each settings group (view preferences, message settings, notifications, …) is one of + * these, so groups added later look and behave the same. + */ +@Composable +fun SettingsGroup( + title: String, + modifier: Modifier = Modifier, + description: String? = null, + content: @Composable ColumnScope.() -> Unit, +) { + Column(modifier.fillMaxWidth().padding(vertical = 8.dp)) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp), + ) + if (description != null) { + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 24.dp), + ) + Spacer(Modifier.height(4.dp)) + } + content() + } +} + +/** One choice in a group of mutually exclusive settings (e.g. the feed filter). */ +@Composable +fun SettingsRadioRow( + label: String, + description: String, + selected: Boolean, + onSelect: () -> Unit, + tag: String, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .selectable(selected = selected, role = Role.RadioButton, onClick = onSelect) + .testTag(tag) + .padding(horizontal = 24.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = selected, onClick = null) + Spacer(Modifier.width(16.dp)) + Column(Modifier.weight(1f)) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +/** An on/off setting with a label and a short explanation. */ +@Composable +fun SettingsSwitchRow( + label: String, + description: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + tag: String, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.width(16.dp)) + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + modifier = Modifier.testTag(tag), + ) + } +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/settings/SettingsScreen.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/settings/SettingsScreen.kt new file mode 100644 index 0000000..40738f3 --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/settings/SettingsScreen.kt @@ -0,0 +1,243 @@ +package com.interlinedlist.android.feature.profile.ui.settings + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.profile.domain.UserSettings +import com.interlinedlist.android.feature.profile.domain.ViewingPreference + +/** Stable test tags for the Settings screen. */ +object SettingsTestTags { + const val BACK = "settingsBack" + const val PROGRESS = "settingsProgress" + const val ERROR = "settingsError" + const val DISMISS_ERROR = "settingsDismissError" + const val RETRY = "settingsRetry" + const val VIEW_PREFERENCES = "settingsGroupViewPreferences" + const val SHOW_PREVIEWS = "settingsShowPreviews" + + /** Tag for one feed-filter option, keyed on its wire value. */ + fun viewingPreference(option: ViewingPreference): String = + "settingsViewingPreference_${option.wire}" +} + +/** The label shown for each feed filter (wording follows the web help centre). */ +private fun ViewingPreference.label(): String = when (this) { + ViewingPreference.ALL -> "All messages" + ViewingPreference.MINE -> "My messages" + ViewingPreference.FOLLOWING -> "Following only" + ViewingPreference.FOLLOWERS -> "Followers only" +} + +/** The one-line explanation shown under each feed filter. */ +private fun ViewingPreference.description(): String = when (this) { + ViewingPreference.ALL -> "Your messages plus all public messages" + ViewingPreference.MINE -> "Only your own messages" + ViewingPreference.FOLLOWING -> "Messages from people you follow, plus your own" + ViewingPreference.FOLLOWERS -> "Messages from people who follow you, plus your own" +} + +/** + * Hilt-wired Settings entry point (route `settings`), reached from the Account hub. + * Mirrors the web Settings page: titled groups of preferences that save as you change + * them. Only the groups the app supports are rendered. + * + * @param onBack pops Settings off the back stack. + */ +@Composable +fun SettingsRoute( + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: SettingsViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + SettingsScreen( + state = state, + onBack = onBack, + onRetry = viewModel::refresh, + onSelectViewingPreference = viewModel::setViewingPreference, + onToggleShowPreviews = viewModel::setShowPreviews, + onDismissError = viewModel::dismissError, + modifier = modifier, + ) +} + +/** Stateless Settings UI — one titled group per area of the web Settings page. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + state: SettingsUiState, + onBack: () -> Unit, + onRetry: () -> Unit, + onSelectViewingPreference: (ViewingPreference) -> Unit, + onToggleShowPreviews: (Boolean) -> Unit, + onDismissError: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text("Settings") }, + navigationIcon = { + IconButton(onClick = onBack, modifier = Modifier.testTag(SettingsTestTags.BACK)) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + val settings = state.settings + when { + settings != null -> Column( + Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()), + ) { + if (state.errorMessage != null) { + SaveErrorBanner(message = state.errorMessage, onDismiss = onDismissError) + } + ViewPreferencesGroup( + settings = settings, + onSelectViewingPreference = onSelectViewingPreference, + onToggleShowPreviews = onToggleShowPreviews, + ) + Spacer(Modifier.height(24.dp)) + } + + state.isLoading -> Box( + Modifier.fillMaxSize().padding(padding), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(Modifier.testTag(SettingsTestTags.PROGRESS)) + } + + else -> Box( + Modifier.fillMaxSize().padding(padding).padding(24.dp), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = state.errorMessage ?: "Couldn't load your settings.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.testTag(SettingsTestTags.ERROR), + ) + Spacer(Modifier.height(16.dp)) + Button(onClick = onRetry, modifier = Modifier.testTag(SettingsTestTags.RETRY)) { + Text("Retry") + } + } + } + } + } +} + +/** + * "View preferences": which messages the Home feed shows, and whether link-preview + * cards render at all. + */ +@Composable +private fun ViewPreferencesGroup( + settings: UserSettings, + onSelectViewingPreference: (ViewingPreference) -> Unit, + onToggleShowPreviews: (Boolean) -> Unit, +) { + SettingsGroup( + title = "View preferences", + description = "Control what appears in your Home feed.", + modifier = Modifier.testTag(SettingsTestTags.VIEW_PREFERENCES), + ) { + ViewingPreference.entries.forEach { option -> + SettingsRadioRow( + label = option.label(), + description = option.description(), + selected = settings.viewingPreference == option, + onSelect = { onSelectViewingPreference(option) }, + tag = SettingsTestTags.viewingPreference(option), + ) + } + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + SettingsSwitchRow( + label = "Show link previews", + description = "Show preview cards for links in messages.", + checked = settings.showPreviews, + onCheckedChange = onToggleShowPreviews, + tag = SettingsTestTags.SHOW_PREVIEWS, + ) + } +} + +/** A failed save reported inline above the groups, dismissible by the user. */ +@Composable +private fun SaveErrorBanner(message: String, onDismiss: () -> Unit) { + Row( + Modifier + .fillMaxWidth() + .padding(start = 24.dp, end = 8.dp, top = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.weight(1f).testTag(SettingsTestTags.ERROR), + ) + TextButton(onClick = onDismiss, modifier = Modifier.testTag(SettingsTestTags.DISMISS_ERROR)) { + Text("Dismiss") + } + } +} + +@Preview(showBackground = true) +@Composable +private fun SettingsScreenPreview() { + InterlinedListTheme { + SettingsScreen( + state = SettingsUiState( + settings = UserSettings( + viewingPreference = ViewingPreference.FOLLOWING, + showPreviews = true, + ), + ), + onBack = {}, + onRetry = {}, + onSelectViewingPreference = {}, + onToggleShowPreviews = {}, + onDismissError = {}, + ) + } +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/settings/SettingsViewModel.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/settings/SettingsViewModel.kt new file mode 100644 index 0000000..e808d6f --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/settings/SettingsViewModel.kt @@ -0,0 +1,116 @@ +package com.interlinedlist.android.feature.profile.ui.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.profile.data.SettingsRepository +import com.interlinedlist.android.feature.profile.domain.UserSettings +import com.interlinedlist.android.feature.profile.domain.UserSettingsUpdate +import com.interlinedlist.android.feature.profile.domain.ViewingPreference +import com.interlinedlist.android.feature.profile.ui.common.toUserMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Settings screen state. [settings] is null until the first load succeeds (the screen + * shows the loading/error state then); [isSaving] covers an in-flight preference write, + * which the screen uses to keep the controls responsive but visibly pending. + */ +data class SettingsUiState( + val settings: UserSettings? = null, + val isLoading: Boolean = false, + val isSaving: Boolean = false, + val errorMessage: String? = null, +) + +/** + * Drives the Settings screen: loads the current user's preferences and writes a single + * changed preference at a time as a partial PATCH. Each change is applied optimistically + * and rolled back if the API rejects it, so a failed save never leaves the toggle lying + * about the stored value. + */ +@HiltViewModel +class SettingsViewModel @Inject constructor( + private val repository: SettingsRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(SettingsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + // The repository cache is shared with the rest of the app, so follow it — + // a refresh triggered elsewhere keeps this screen current. + viewModelScope.launch { + repository.observeSettings().collect { settings -> + if (settings != null) _uiState.update { it.copy(settings = settings) } + } + } + refresh() + } + + /** Loads (or reloads) the settings from the API. */ + fun refresh() { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + viewModelScope.launch { + when (val result = repository.refresh()) { + is ApiResult.Success -> _uiState.update { + it.copy(settings = result.data, isLoading = false, errorMessage = null) + } + is ApiResult.Failure -> _uiState.update { + it.copy(isLoading = false, errorMessage = result.error.toUserMessage()) + } + } + } + } + + /** Switches which messages the Home feed shows. */ + fun setViewingPreference(preference: ViewingPreference) { + val current = _uiState.value.settings ?: return + if (current.viewingPreference == preference) return + save( + optimistic = current.copy(viewingPreference = preference), + previous = current, + update = UserSettingsUpdate(viewingPreference = preference), + ) + } + + /** Turns link-preview cards on or off. */ + fun setShowPreviews(enabled: Boolean) { + val current = _uiState.value.settings ?: return + if (current.showPreviews == enabled) return + save( + optimistic = current.copy(showPreviews = enabled), + previous = current, + update = UserSettingsUpdate(showPreviews = enabled), + ) + } + + fun dismissError() = _uiState.update { it.copy(errorMessage = null) } + + private fun save( + optimistic: UserSettings, + previous: UserSettings, + update: UserSettingsUpdate, + ) { + _uiState.update { it.copy(settings = optimistic, isSaving = true, errorMessage = null) } + viewModelScope.launch { + when (val result = repository.update(update)) { + is ApiResult.Success -> _uiState.update { + it.copy(settings = result.data, isSaving = false) + } + is ApiResult.Failure -> _uiState.update { + it.copy( + settings = previous, + isSaving = false, + errorMessage = result.error.toUserMessage(), + ) + } + } + } + } +} diff --git a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepositoryTest.kt b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepositoryTest.kt index 918b4e2..f274df5 100644 --- a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepositoryTest.kt +++ b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepositoryTest.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.mockwebserver.MockResponse @@ -158,6 +159,23 @@ class DefaultProfileRepositoryTest { assertThat(repository.observeCurrentUser().first()?.displayName).isEqualTo("New Name") } + @Test + fun `updateProfile PATCHes only the profile fields, never the preference fields`() = + runTest(testDispatcher) { + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """{ "user": { "id": "u1", "username": "adron", "displayName": "New Name", "bio": "New bio" } }""", + ), + ) + + repository.updateProfile(displayName = "New Name", bio = "New bio") + + // Edit-profile must not send (and so must not reset) the settings fields + // the Settings screen owns. + val body = json.parseToJsonElement(server.takeRequest().body.readUtf8()) as JsonObject + assertThat(body.keys).containsExactly("displayName", "bio") + } + @Test fun `updateProfile re-fetches when the server echoes a thin body`() = runTest(testDispatcher) { // PATCH returns nothing useful... diff --git a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/DefaultSettingsRepositoryTest.kt b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/DefaultSettingsRepositoryTest.kt new file mode 100644 index 0000000..67e4ba5 --- /dev/null +++ b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/DefaultSettingsRepositoryTest.kt @@ -0,0 +1,219 @@ +package com.interlinedlist.android.feature.profile.data + +import com.google.common.truth.Truth.assertThat +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.feature.profile.data.remote.ProfileApi +import com.interlinedlist.android.feature.profile.domain.UserSettingsUpdate +import com.interlinedlist.android.feature.profile.domain.ViewingPreference +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.Test +import retrofit2.Retrofit + +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultSettingsRepositoryTest { + + private lateinit var server: MockWebServer + private lateinit var api: ProfileApi + private lateinit var repository: DefaultSettingsRepository + + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false } + private val testDispatcher = StandardTestDispatcher() + private val dispatchers = object : DispatcherProvider { + override val io: CoroutineDispatcher = testDispatcher + override val default: CoroutineDispatcher = testDispatcher + override val main: CoroutineDispatcher = testDispatcher + } + + @Before + fun setUp() { + server = MockWebServer() + server.start() + val retrofit = Retrofit.Builder() + .baseUrl(server.url("/")) + .client(OkHttpClient.Builder().build()) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + api = retrofit.create(ProfileApi::class.java) + repository = DefaultSettingsRepository(api, json, dispatchers) + } + + @After + fun tearDown() = server.shutdown() + + private fun enqueueUser( + viewingPreference: String = "all_messages", + showPreviews: Boolean = true, + ) = server.enqueue( + MockResponse().setResponseCode(200).setBody( + """ + { + "user": { + "id": "u1", + "username": "adron", + "displayName": "Adron Hall", + "theme": "dark", + "maxMessageLength": 666, + "defaultPubliclyVisible": true, + "messagesPerPage": 20, + "viewingPreference": "$viewingPreference", + "showPreviews": $showPreviews, + "showAdvancedPostSettings": false, + "latitude": 45.52, + "longitude": -122.68, + "isPrivateAccount": false, + "githubDefaultRepo": "adron/notes", + "notificationTrayLimit": 25 + } + } + """.trimIndent(), + ), + ) + + /** Reads the body of the next recorded request as a JSON object. */ + private fun MockWebServer.takeJsonBody() = + json.parseToJsonElement(takeRequest().body.readUtf8()) as JsonObject + + @Test + fun `refresh reads every preference field from the user endpoint`() = runTest(testDispatcher) { + enqueueUser(viewingPreference = "following_only", showPreviews = false) + + val result = repository.refresh() + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val settings = (result as ApiResult.Success).data + assertThat(settings.viewingPreference).isEqualTo(ViewingPreference.FOLLOWING) + assertThat(settings.showPreviews).isFalse() + assertThat(settings.theme).isEqualTo("dark") + assertThat(settings.maxMessageLength).isEqualTo(666) + assertThat(settings.defaultPubliclyVisible).isTrue() + assertThat(settings.messagesPerPage).isEqualTo(20) + assertThat(settings.showAdvancedPostSettings).isFalse() + assertThat(settings.latitude).isEqualTo(45.52) + assertThat(settings.longitude).isEqualTo(-122.68) + assertThat(settings.isPrivateAccount).isFalse() + assertThat(settings.githubDefaultRepo).isEqualTo("adron/notes") + assertThat(settings.notificationTrayLimit).isEqualTo(25) + + val recorded = server.takeRequest() + assertThat(recorded.method).isEqualTo("GET") + assertThat(recorded.path).isEqualTo("/api/user") + } + + @Test + fun `refresh publishes the settings to observers`() = runTest(testDispatcher) { + assertThat(repository.observeSettings().first()).isNull() + enqueueUser(viewingPreference = "followers_only") + + repository.refresh() + + assertThat(repository.observeSettings().first()?.viewingPreference) + .isEqualTo(ViewingPreference.FOLLOWERS) + } + + @Test + fun `update sends only the touched field so a partial PATCH cannot clobber the rest`() = + runTest(testDispatcher) { + enqueueUser(viewingPreference = "my_messages") + + repository.update(UserSettingsUpdate(viewingPreference = ViewingPreference.MINE)) + + val recorded = server.takeRequest() + assertThat(recorded.method).isEqualTo("PATCH") + assertThat(recorded.path).isEqualTo("/api/user/update") + val body = json.parseToJsonElement(recorded.body.readUtf8()) as JsonObject + assertThat(body.keys).containsExactly("viewingPreference") + assertThat(body.getValue("viewingPreference").jsonPrimitive.content) + .isEqualTo("my_messages") + } + + @Test + fun `viewingPreference round-trips through the update call`() = runTest(testDispatcher) { + enqueueUser(viewingPreference = "followers_only") + + val result = repository.update(UserSettingsUpdate(viewingPreference = ViewingPreference.FOLLOWERS)) + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat((result as ApiResult.Success).data.viewingPreference) + .isEqualTo(ViewingPreference.FOLLOWERS) + assertThat(server.takeJsonBody().getValue("viewingPreference").jsonPrimitive.content) + .isEqualTo("followers_only") + assertThat(repository.observeSettings().first()?.viewingPreference) + .isEqualTo(ViewingPreference.FOLLOWERS) + } + + @Test + fun `showPreviews round-trips through the update call`() = runTest(testDispatcher) { + enqueueUser(showPreviews = false) + + val result = repository.update(UserSettingsUpdate(showPreviews = false)) + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat((result as ApiResult.Success).data.showPreviews).isFalse() + val body = server.takeJsonBody() + assertThat(body.keys).containsExactly("showPreviews") + assertThat(body.getValue("showPreviews").jsonPrimitive.content).isEqualTo("false") + assertThat(repository.observeSettings().first()?.showPreviews).isFalse() + } + + @Test + fun `update re-reads the user when the server echoes a thin body`() = runTest(testDispatcher) { + // PATCH answers without a user... + server.enqueue(MockResponse().setResponseCode(200).setBody("""{ "message": "User updated successfully" }""")) + // ...so the repository falls back to GET /api/user. + enqueueUser(viewingPreference = "my_messages") + + val result = repository.update(UserSettingsUpdate(viewingPreference = ViewingPreference.MINE)) + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat((result as ApiResult.Success).data.viewingPreference).isEqualTo(ViewingPreference.MINE) + assertThat(server.takeRequest().path).isEqualTo("/api/user/update") + assertThat(server.takeRequest().path).isEqualTo("/api/user") + } + + @Test + fun `update failure surfaces the error and leaves the cached settings alone`() = + runTest(testDispatcher) { + enqueueUser(showPreviews = true) + repository.refresh() + server.takeRequest() + + server.enqueue(MockResponse().setResponseCode(500).setBody("""{ "error": "boom" }""")) + val result = repository.update(UserSettingsUpdate(showPreviews = false)) + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + assertThat((result as ApiResult.Failure).error).isInstanceOf(AppError.Server::class.java) + assertThat(repository.observeSettings().first()?.showPreviews).isTrue() + } + + @Test + fun `refresh tolerates a user without preference fields by falling back to defaults`() = + runTest(testDispatcher) { + server.enqueue( + MockResponse().setResponseCode(200) + .setBody("""{ "user": { "id": "u1", "username": "adron" } }"""), + ) + + val result = repository.refresh() + + val settings = (result as ApiResult.Success).data + assertThat(settings.viewingPreference).isEqualTo(ViewingPreference.ALL) + assertThat(settings.showPreviews).isTrue() + assertThat(settings.notificationTrayLimit).isNull() + } +} diff --git a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/SettingsMappersTest.kt b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/SettingsMappersTest.kt new file mode 100644 index 0000000..d9cdebd --- /dev/null +++ b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/SettingsMappersTest.kt @@ -0,0 +1,114 @@ +package com.interlinedlist.android.feature.profile.data + +import com.google.common.truth.Truth.assertThat +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.dto.ProfileUserDto +import com.interlinedlist.android.feature.profile.domain.UserSettingsUpdate +import com.interlinedlist.android.feature.profile.domain.ViewingPreference +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import org.junit.Test + +class SettingsMappersTest { + + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false } + + /** + * The four values the live API accepts, quoted from the server's own allow-list: + * `viewingPreference must be one of: my_messages, all_messages, followers_only, + * following_only` (and `GET /api/user` returns `all_messages`). + */ + @Test + fun `each viewing preference maps to the wire value the API accepts`() { + assertThat(ViewingPreference.ALL.wire).isEqualTo("all_messages") + assertThat(ViewingPreference.MINE.wire).isEqualTo("my_messages") + assertThat(ViewingPreference.FOLLOWING.wire).isEqualTo("following_only") + assertThat(ViewingPreference.FOLLOWERS.wire).isEqualTo("followers_only") + } + + @Test + fun `each wire value the API accepts round-trips back to its option`() { + assertThat(ViewingPreference.fromWire("all_messages")).isEqualTo(ViewingPreference.ALL) + assertThat(ViewingPreference.fromWire("my_messages")).isEqualTo(ViewingPreference.MINE) + assertThat(ViewingPreference.fromWire("following_only")).isEqualTo(ViewingPreference.FOLLOWING) + assertThat(ViewingPreference.fromWire("followers_only")).isEqualTo(ViewingPreference.FOLLOWERS) + // ...and nothing drifts: every constant parses back to itself. + ViewingPreference.entries.forEach { preference -> + assertThat(ViewingPreference.fromWire(preference.wire)).isEqualTo(preference) + } + } + + @Test + fun `parsing tolerates casing, separators and the shorthand spellings`() { + assertThat(ViewingPreference.fromWire("ALL_MESSAGES")).isEqualTo(ViewingPreference.ALL) + assertThat(ViewingPreference.fromWire("all")).isEqualTo(ViewingPreference.ALL) + assertThat(ViewingPreference.fromWire("my")).isEqualTo(ViewingPreference.MINE) + assertThat(ViewingPreference.fromWire("My Messages")).isEqualTo(ViewingPreference.MINE) + assertThat(ViewingPreference.fromWire("following-only")).isEqualTo(ViewingPreference.FOLLOWING) + assertThat(ViewingPreference.fromWire("Followers Only")).isEqualTo(ViewingPreference.FOLLOWERS) + } + + @Test + fun `an unknown or missing wire value falls back to the default`() { + assertThat(ViewingPreference.fromWire("nonsense")).isNull() + assertThat(ViewingPreference.fromWire(null)).isNull() + assertThat(ViewingPreference.fromWireOrDefault("nonsense")).isEqualTo(ViewingPreference.ALL) + assertThat(ViewingPreference.fromWireOrDefault(null)).isEqualTo(ViewingPreference.ALL) + } + + @Test + fun `wire user maps onto the settings the feed and settings screen read`() { + val settings = ProfileUserDto( + id = "u1", + username = "adron", + viewingPreference = "following_only", + showPreviews = false, + notificationTrayLimit = 10, + ).toUserSettings() + + assertThat(settings.viewingPreference).isEqualTo(ViewingPreference.FOLLOWING) + assertThat(settings.showPreviews).isFalse() + assertThat(settings.notificationTrayLimit).isEqualTo(10) + } + + @Test + fun `an update serialises only the fields it set`() { + val body = json.encodeToString( + UserSettingsUpdate(showPreviews = true).toRequest(), + ) + + assertThat(body).isEqualTo("""{"showPreviews":true}""") + } + + @Test + fun `an update carries every field when they are all set`() { + val request = UserSettingsUpdate( + displayName = "Adron", + bio = "bio", + avatar = "https://cdn/a.png", + theme = "dark", + maxMessageLength = 666, + defaultPubliclyVisible = true, + messagesPerPage = 20, + viewingPreference = ViewingPreference.MINE, + showPreviews = false, + showAdvancedPostSettings = true, + latitude = 45.52, + longitude = -122.68, + isPrivateAccount = true, + githubDefaultRepo = "adron/notes", + notificationTrayLimit = 25, + ).toRequest() + + val body = json.parseToJsonElement(json.encodeToString(request)) + assertThat((body as JsonObject).keys).containsExactly( + "displayName", "bio", "avatar", "theme", "maxMessageLength", + "defaultPubliclyVisible", "messagesPerPage", "viewingPreference", "showPreviews", + "showAdvancedPostSettings", "latitude", "longitude", "isPrivateAccount", + "githubDefaultRepo", "notificationTrayLimit", + ) + assertThat(request.viewingPreference).isEqualTo("my_messages") + } +} diff --git a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/FakeSettingsRepository.kt b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/FakeSettingsRepository.kt new file mode 100644 index 0000000..fedc7c2 --- /dev/null +++ b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/FakeSettingsRepository.kt @@ -0,0 +1,49 @@ +package com.interlinedlist.android.feature.profile.ui + +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.profile.data.SettingsRepository +import com.interlinedlist.android.feature.profile.domain.UserSettings +import com.interlinedlist.android.feature.profile.domain.UserSettingsUpdate +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +/** + * In-memory [SettingsRepository] for ViewModel tests. [refreshResult] and + * [updateResult] drive the success/failure paths; on success the fake behaves like + * the real one — it publishes the new settings to [observeSettings] — and every + * update is recorded so tests can assert exactly which fields were sent. + */ +class FakeSettingsRepository : SettingsRepository { + + private val settings = MutableStateFlow(null) + + var refreshResult: ApiResult = ApiResult.Success(UserSettings()) + var updateResult: ((UserSettingsUpdate) -> ApiResult)? = null + + var refreshCount = 0 + val updates = mutableListOf() + + override fun observeSettings(): Flow = settings + + override suspend fun refresh(): ApiResult { + refreshCount++ + return refreshResult.also { if (it is ApiResult.Success) settings.value = it.data } + } + + override suspend fun update(update: UserSettingsUpdate): ApiResult { + updates += update + // By default the server accepts the change and echoes the merged settings. + val result = updateResult?.invoke(update) ?: ApiResult.Success(merge(update)) + if (result is ApiResult.Success) settings.value = result.data + return result + } + + /** Applies the touched fields of [update] to the current settings, as the API would. */ + private fun merge(update: UserSettingsUpdate): UserSettings { + val current = settings.value ?: UserSettings() + return current.copy( + viewingPreference = update.viewingPreference ?: current.viewingPreference, + showPreviews = update.showPreviews ?: current.showPreviews, + ) + } +} diff --git a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/SettingsViewModelTest.kt b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/SettingsViewModelTest.kt new file mode 100644 index 0000000..f1a8203 --- /dev/null +++ b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/SettingsViewModelTest.kt @@ -0,0 +1,191 @@ +package com.interlinedlist.android.feature.profile.ui + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.profile.domain.UserSettings +import com.interlinedlist.android.feature.profile.domain.ViewingPreference +import com.interlinedlist.android.feature.profile.ui.settings.SettingsViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class SettingsViewModelTest { + + private val dispatcher = StandardTestDispatcher() + private lateinit var repo: FakeSettingsRepository + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + repo = FakeSettingsRepository() + } + + @After + fun tearDown() = Dispatchers.resetMain() + + @Test + fun `load surfaces the fetched settings and clears loading`() = runTest(dispatcher) { + repo.refreshResult = ApiResult.Success( + UserSettings(viewingPreference = ViewingPreference.FOLLOWING, showPreviews = false), + ) + val vm = SettingsViewModel(repo) + + vm.uiState.test { + advanceUntilIdle() + val state = expectMostRecentItem() + assertThat(repo.refreshCount).isEqualTo(1) + assertThat(state.isLoading).isFalse() + assertThat(state.settings?.viewingPreference).isEqualTo(ViewingPreference.FOLLOWING) + assertThat(state.settings?.showPreviews).isFalse() + assertThat(state.errorMessage).isNull() + } + } + + @Test + fun `load failure surfaces a mapped error and no settings`() = runTest(dispatcher) { + repo.refreshResult = ApiResult.Failure(AppError.Network("offline")) + val vm = SettingsViewModel(repo) + + vm.uiState.test { + advanceUntilIdle() + val state = expectMostRecentItem() + assertThat(state.isLoading).isFalse() + assertThat(state.settings).isNull() + assertThat(state.errorMessage).isEqualTo("No connection. Check your network and try again.") + } + } + + @Test + fun `retry after a failure reloads`() = runTest(dispatcher) { + repo.refreshResult = ApiResult.Failure(AppError.Network("offline")) + val vm = SettingsViewModel(repo) + + vm.uiState.test { + advanceUntilIdle() + repo.refreshResult = ApiResult.Success(UserSettings(showPreviews = false)) + vm.refresh() + advanceUntilIdle() + + val state = expectMostRecentItem() + assertThat(repo.refreshCount).isEqualTo(2) + assertThat(state.errorMessage).isNull() + assertThat(state.settings?.showPreviews).isFalse() + } + } + + @Test + fun `choosing a viewing preference saves only that field`() = runTest(dispatcher) { + repo.refreshResult = ApiResult.Success(UserSettings(viewingPreference = ViewingPreference.ALL)) + val vm = SettingsViewModel(repo) + + vm.uiState.test { + advanceUntilIdle() + vm.setViewingPreference(ViewingPreference.FOLLOWERS) + advanceUntilIdle() + + val state = expectMostRecentItem() + assertThat(state.settings?.viewingPreference).isEqualTo(ViewingPreference.FOLLOWERS) + assertThat(state.isSaving).isFalse() + assertThat(repo.updates).hasSize(1) + val sent = repo.updates.single() + assertThat(sent.viewingPreference).isEqualTo(ViewingPreference.FOLLOWERS) + assertThat(sent.showPreviews).isNull() + assertThat(sent.displayName).isNull() + assertThat(sent.theme).isNull() + } + } + + @Test + fun `re-choosing the selected viewing preference does not call the API`() = runTest(dispatcher) { + repo.refreshResult = ApiResult.Success(UserSettings(viewingPreference = ViewingPreference.MINE)) + val vm = SettingsViewModel(repo) + + vm.uiState.test { + advanceUntilIdle() + vm.setViewingPreference(ViewingPreference.MINE) + advanceUntilIdle() + + assertThat(repo.updates).isEmpty() + assertThat(expectMostRecentItem().settings?.viewingPreference) + .isEqualTo(ViewingPreference.MINE) + } + } + + @Test + fun `toggling link previews saves only that field`() = runTest(dispatcher) { + repo.refreshResult = ApiResult.Success(UserSettings(showPreviews = true)) + val vm = SettingsViewModel(repo) + + vm.uiState.test { + advanceUntilIdle() + vm.setShowPreviews(false) + advanceUntilIdle() + + assertThat(expectMostRecentItem().settings?.showPreviews).isFalse() + val sent = repo.updates.single() + assertThat(sent.showPreviews).isFalse() + assertThat(sent.viewingPreference).isNull() + } + } + + @Test + fun `a failed save rolls the value back and surfaces the error`() = runTest(dispatcher) { + repo.refreshResult = ApiResult.Success(UserSettings(showPreviews = true)) + repo.updateResult = { ApiResult.Failure(AppError.Server("boom")) } + val vm = SettingsViewModel(repo) + + vm.uiState.test { + advanceUntilIdle() + vm.setShowPreviews(false) + advanceUntilIdle() + + val state = expectMostRecentItem() + assertThat(state.settings?.showPreviews).isTrue() + assertThat(state.isSaving).isFalse() + assertThat(state.errorMessage) + .isEqualTo("InterlinedList is having trouble right now. Try again shortly.") + } + } + + @Test + fun `dismissing the error clears it`() = runTest(dispatcher) { + repo.refreshResult = ApiResult.Failure(AppError.Network("offline")) + val vm = SettingsViewModel(repo) + + vm.uiState.test { + advanceUntilIdle() + vm.dismissError() + advanceUntilIdle() + + assertThat(expectMostRecentItem().errorMessage).isNull() + } + } + + @Test + fun `settings published by the repository are reflected without a reload`() = runTest(dispatcher) { + repo.refreshResult = ApiResult.Success(UserSettings(viewingPreference = ViewingPreference.ALL)) + val vm = SettingsViewModel(repo) + + vm.uiState.test { + advanceUntilIdle() + // Another surface (e.g. the feed) refreshes the shared repository... + repo.refreshResult = ApiResult.Success(UserSettings(viewingPreference = ViewingPreference.MINE)) + repo.refresh() + advanceUntilIdle() + + // ...and this screen follows along. + assertThat(expectMostRecentItem().settings?.viewingPreference) + .isEqualTo(ViewingPreference.MINE) + } + } +}