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 @@ -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. */
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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() })
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
@@ -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<UserSettings?>(null)

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

override suspend fun refresh(): ApiResult<UserSettings> = 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<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
}
}

private fun publish(settings: UserSettings): UserSettings {
cached.value = settings
return settings
}
}
Original file line number Diff line number Diff line change
@@ -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<UserSettings?>

/** Re-reads the settings from `GET /api/user` and publishes them to [observeSettings]. */
suspend fun refresh(): ApiResult<UserSettings>

/**
* 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<UserSettings>
}
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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`. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -16,14 +18,19 @@ 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 {

@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. */
Expand Down
Loading
Loading