Coverage Summary for Class: PreferencesScreenKt (com.georgeci.moneysurfer.feature.settings.preferences)

Class Method, % Branch, % Line, % Instruction, %
PreferencesScreenKt 73.7% (14/19) 24.1% (20/83) 66.1% (125/189) 56.4% (1304/2312)
PreferencesScreenKt$ActivePickerSheet$5$1 0% (0/1) 0% (0/1) 0% (0/9)
PreferencesScreenKt$WhenMappings
Total 70% (14/20) 24.1% (20/83) 65.8% (125/190) 56.2% (1304/2321)


 package com.georgeci.moneysurfer.feature.settings.preferences
 
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.fillMaxSize
 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.material3.Scaffold
 import androidx.compose.material3.TopAppBarDefaults
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.vector.ImageVector
 import androidx.compose.ui.platform.testTag
 import androidx.compose.ui.tooling.preview.Preview
 import androidx.compose.ui.unit.dp
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import com.georgeci.moneysurfer.domain.model.Currency
 import com.georgeci.moneysurfer.domain.preferences.AppLanguage
 import com.georgeci.moneysurfer.domain.preferences.AppRegion
 import com.georgeci.moneysurfer.domain.preferences.DefaultTransactionType
 import com.georgeci.moneysurfer.domain.preferences.HourFormat
 import com.georgeci.moneysurfer.domain.preferences.NumberFormatStyle
 import com.georgeci.moneysurfer.domain.preferences.WeekStart
 import com.georgeci.moneysurfer.domain.primitives.CurrencyCode
 import com.georgeci.moneysurfer.uikit.components.SurferCurrencyBottomSheet
 import com.georgeci.moneysurfer.uikit.components.SurferCurrencyOption
 import com.georgeci.moneysurfer.uikit.components.base.SurferPaneTopBar
 import com.georgeci.moneysurfer.uikit.components.settings.SurferSettingsGroup
 import com.georgeci.moneysurfer.uikit.components.settings.SurferSettingsRow
 import com.georgeci.moneysurfer.uikit.components.settings.SurferSettingsSwitch
 import com.georgeci.moneysurfer.uikit.components.settings.SurferSettingsValuePill
 import com.georgeci.moneysurfer.uikit.icons.SurferIcons
 import com.georgeci.moneysurfer.uikit.modifier.surferContentContainer
 import com.georgeci.moneysurfer.uikit.modifier.surferSafeInsets
 import com.georgeci.moneysurfer.uikit.modifier.surferTestTagAsId
 import com.georgeci.moneysurfer.uikit.theme.AppTheme
 import com.georgeci.moneysurfer.utils.HandleSideEffect
 import moneysurfer.feature.settings.generated.resources.Res
 import moneysurfer.feature.settings.generated.resources.settings_preferences_auto_categorize_supporting
 import moneysurfer.feature.settings.generated.resources.settings_preferences_auto_categorize_title
 import moneysurfer.feature.settings.generated.resources.settings_preferences_behavior_footnote
 import moneysurfer.feature.settings.generated.resources.settings_preferences_currency_search_placeholder
 import moneysurfer.feature.settings.generated.resources.settings_preferences_currency_supporting
 import moneysurfer.feature.settings.generated.resources.settings_preferences_currency_title
 import moneysurfer.feature.settings.generated.resources.settings_preferences_default_txn_title
 import moneysurfer.feature.settings.generated.resources.settings_preferences_hide_supporting
 import moneysurfer.feature.settings.generated.resources.settings_preferences_hide_title
 import moneysurfer.feature.settings.generated.resources.settings_preferences_hour_title
 import moneysurfer.feature.settings.generated.resources.settings_preferences_language_supporting
 import moneysurfer.feature.settings.generated.resources.settings_preferences_language_title
 import moneysurfer.feature.settings.generated.resources.settings_preferences_locale_footnote
 import moneysurfer.feature.settings.generated.resources.settings_preferences_number_format_title
 import moneysurfer.feature.settings.generated.resources.settings_preferences_region_supporting
 import moneysurfer.feature.settings.generated.resources.settings_preferences_region_title
 import moneysurfer.feature.settings.generated.resources.settings_preferences_round_up_supporting
 import moneysurfer.feature.settings.generated.resources.settings_preferences_round_up_title
 import moneysurfer.feature.settings.generated.resources.settings_preferences_section_behavior
 import moneysurfer.feature.settings.generated.resources.settings_preferences_section_calendar
 import moneysurfer.feature.settings.generated.resources.settings_preferences_section_locale
 import moneysurfer.feature.settings.generated.resources.settings_preferences_section_money
 import moneysurfer.feature.settings.generated.resources.settings_preferences_title
 import moneysurfer.feature.settings.generated.resources.settings_preferences_week_title
 import org.jetbrains.compose.resources.stringResource
 import org.koin.compose.viewmodel.koinViewModel
 
 /**
  * Stable selectors for the Preferences screen — see docs/testing/testing-strategy.md.
  *
  * Rows and options are named after the [PreferencePicker] and the enum entry they store, not after
  * the localized label, so a re-worded row never breaks a flow.
  */
 object PreferencesTestTags {
     const val Root = "preferences:root"
 
     fun row(picker: PreferencePicker): String = "preferences:row:${picker.name.lowercase()}"
 
     fun option(picker: PreferencePicker, value: String): String =
         "preferences:option:${picker.name.lowercase()}:${value.lowercase()}"
 
     fun toggle(name: String): String = "preferences:toggle:$name"
 }
 
 @Composable
 fun PreferencesScreen(
     onNavigateBack: () -> Unit,
     viewModel: PreferencesViewModel = koinViewModel(),
 ) {
     val state by viewModel.collectAsStateWithLifecycle()
 
     viewModel.HandleSideEffect { effect ->
         when (effect) {
             PreferencesEffect.NavigateBack -> onNavigateBack()
         }
     }
 
     PreferencesContent(state = state, onEvent = viewModel::onEvent)
 }
 
 /**
  * Stateless half of the screen — public so `:composeApp` desktop UI tests can mount it with an
  * injected state, the way [DashboardCustomizeContent][com.georgeci.moneysurfer.feature.dashboard.customize]
  * is mounted. That cover is the point of this screen: the bug being fixed was pills that rendered a
  * fixed string, and only a real render can tell a pill bound to state from one that is not.
  */
 @Composable
 fun PreferencesContent(
     state: PreferencesState,
     onEvent: (PreferencesEvent) -> Unit,
 ) {
     Scaffold(
         modifier = Modifier
             .surferSafeInsets()
             .testTag(PreferencesTestTags.Root)
             .surferTestTagAsId(),
         containerColor = AppTheme.materialColors.surface,
         topBar = {
             SurferPaneTopBar(
                 title = stringResource(Res.string.settings_preferences_title),
                 onBack = { onEvent(PreferencesEvent.OnBackClick) },
                 colors = TopAppBarDefaults.topAppBarColors(
                     containerColor = AppTheme.materialColors.surface,
                     titleContentColor = AppTheme.materialColors.onSurface,
                 ),
             )
         },
     ) { padding ->
         Column(
             modifier = Modifier
                 .fillMaxSize()
                 .surferContentContainer()
                 .padding(top = padding.calculateTopPadding())
                 .verticalScroll(rememberScrollState()),
         ) {
             SurferSettingsGroup(
                 title = stringResource(Res.string.settings_preferences_section_locale),
                 footnote = stringResource(Res.string.settings_preferences_locale_footnote),
             ) {
                 PickerRow(
                     picker = PreferencePicker.Language,
                     icon = SurferIcons.Globe,
                     title = stringResource(Res.string.settings_preferences_language_title),
                     supportingText = stringResource(Res.string.settings_preferences_language_supporting),
                     value = state.language.label(),
                     onEvent = onEvent,
                 )
                 PickerRow(
                     picker = PreferencePicker.Region,
                     icon = SurferIcons.Sparkle,
                     title = stringResource(Res.string.settings_preferences_region_title),
                     supportingText = stringResource(Res.string.settings_preferences_region_supporting),
                     value = state.region.label(),
                     onEvent = onEvent,
                 )
             }
 
             SurferSettingsGroup(title = stringResource(Res.string.settings_preferences_section_money)) {
                 PickerRow(
                     picker = PreferencePicker.Currency,
                     icon = SurferIcons.Wallet,
                     title = stringResource(Res.string.settings_preferences_currency_title),
                     supportingText = stringResource(Res.string.settings_preferences_currency_supporting),
                     value = currencyLabel(state.currency, state.currencies),
                     onEvent = onEvent,
                 )
                 PickerRow(
                     picker = PreferencePicker.NumberFormat,
                     icon = SurferIcons.Code,
                     title = stringResource(Res.string.settings_preferences_number_format_title),
                     value = state.numberFormat.sample,
                     onEvent = onEvent,
                 )
                 ToggleRow(
                     name = "hide_amounts",
                     icon = SurferIcons.Visibility,
                     title = stringResource(Res.string.settings_preferences_hide_title),
                     supportingText = stringResource(Res.string.settings_preferences_hide_supporting),
                     checked = state.hideAmounts,
                     onCheckedChange = { onEvent(PreferencesEvent.OnHideAmountsToggle(it)) },
                 )
             }
 
             SurferSettingsGroup(title = stringResource(Res.string.settings_preferences_section_calendar)) {
                 PickerRow(
                     picker = PreferencePicker.WeekStart,
                     icon = SurferIcons.Calendar,
                     title = stringResource(Res.string.settings_preferences_week_title),
                     value = state.weekStart.label(),
                     onEvent = onEvent,
                 )
                 PickerRow(
                     picker = PreferencePicker.HourFormat,
                     icon = SurferIcons.Clock,
                     title = stringResource(Res.string.settings_preferences_hour_title),
                     value = state.hourFormat.label(),
                     onEvent = onEvent,
                 )
             }
 
             SurferSettingsGroup(
                 title = stringResource(Res.string.settings_preferences_section_behavior),
                 footnote = stringResource(Res.string.settings_preferences_behavior_footnote),
             ) {
                 PickerRow(
                     picker = PreferencePicker.DefaultTransactionType,
                     icon = SurferIcons.Add,
                     title = stringResource(Res.string.settings_preferences_default_txn_title),
                     value = state.defaultTransactionType.label(),
                     onEvent = onEvent,
                 )
                 ToggleRow(
                     name = "auto_categorize",
                     icon = SurferIcons.Sparkle,
                     title = stringResource(Res.string.settings_preferences_auto_categorize_title),
                     supportingText = stringResource(Res.string.settings_preferences_auto_categorize_supporting),
                     checked = state.autoCategorize,
                     onCheckedChange = { onEvent(PreferencesEvent.OnAutoCategorizeToggle(it)) },
                 )
                 ToggleRow(
                     name = "round_up",
                     icon = SurferIcons.Notifications,
                     title = stringResource(Res.string.settings_preferences_round_up_title),
                     supportingText = stringResource(Res.string.settings_preferences_round_up_supporting),
                     checked = state.roundUp,
                     onCheckedChange = { onEvent(PreferencesEvent.OnRoundUpToggle(it)) },
                 )
             }
             Spacer(Modifier.height(padding.calculateBottomPadding() + 24.dp))
         }
     }
 
     ActivePickerSheet(state = state, onEvent = onEvent)
 }
 
 /** Row whose pill shows the stored value and whose tap opens that value's chooser. */
 @Composable
 private fun PickerRow(
     picker: PreferencePicker,
     icon: ImageVector,
     title: String,
     value: String,
     onEvent: (PreferencesEvent) -> Unit,
     supportingText: String? = null,
 ) {
     SurferSettingsRow(
         icon = icon,
         title = title,
         supportingText = supportingText,
         onClick = { onEvent(PreferencesEvent.OnPickerOpen(picker)) },
         trailing = { SurferSettingsValuePill(value) },
         modifier = Modifier.testTag(PreferencesTestTags.row(picker)),
     )
 }
 
 @Composable
 private fun ToggleRow(
     name: String,
     icon: ImageVector,
     title: String,
     supportingText: String,
     checked: Boolean,
     onCheckedChange: (Boolean) -> Unit,
 ) {
     SurferSettingsRow(
         icon = icon,
         title = title,
         supportingText = supportingText,
         onClick = { onCheckedChange(!checked) },
         trailing = { SurferSettingsSwitch(checked = checked, onCheckedChange = onCheckedChange) },
         modifier = Modifier.testTag(PreferencesTestTags.toggle(name)),
     )
 }
 
 /**
  * The one open chooser, if any. Every sheet is driven from [PreferencesState.activePicker] rather
  * than from a side effect, so it survives a configuration change with the right row still open.
  */
 @Composable
 private fun ActivePickerSheet(
     state: PreferencesState,
     onEvent: (PreferencesEvent) -> Unit,
 ) {
     val onDismiss = { onEvent(PreferencesEvent.OnPickerDismiss) }
     when (state.activePicker) {
         null -> Unit
 
         PreferencePicker.Language -> PreferenceOptionsSheet(
             title = stringResource(Res.string.settings_preferences_language_title),
             options = AppLanguage.entries.map { it.asOption(PreferencePicker.Language, it.label()) },
             selected = state.language,
             onSelect = { onEvent(PreferencesEvent.OnLanguageSelect(it)) },
             onDismiss = onDismiss,
         )
 
         PreferencePicker.Region -> PreferenceOptionsSheet(
             title = stringResource(Res.string.settings_preferences_region_title),
             options = AppRegion.entries.map { it.asOption(PreferencePicker.Region, it.label()) },
             selected = state.region,
             onSelect = { onEvent(PreferencesEvent.OnRegionSelect(it)) },
             onDismiss = onDismiss,
         )
 
         PreferencePicker.Currency -> CurrencySheet(
             currencies = state.currencies,
             selected = state.currency,
             onSelect = { onEvent(PreferencesEvent.OnCurrencySelect(it)) },
             onDismiss = onDismiss,
         )
 
         PreferencePicker.NumberFormat -> PreferenceOptionsSheet(
             title = stringResource(Res.string.settings_preferences_number_format_title),
             options = NumberFormatStyle.entries.map { it.asOption(PreferencePicker.NumberFormat, it.sample) },
             selected = state.numberFormat,
             onSelect = { onEvent(PreferencesEvent.OnNumberFormatSelect(it)) },
             onDismiss = onDismiss,
         )
 
         PreferencePicker.WeekStart -> PreferenceOptionsSheet(
             title = stringResource(Res.string.settings_preferences_week_title),
             options = WeekStart.entries.map { it.asOption(PreferencePicker.WeekStart, it.label()) },
             selected = state.weekStart,
             onSelect = { onEvent(PreferencesEvent.OnWeekStartSelect(it)) },
             onDismiss = onDismiss,
         )
 
         PreferencePicker.HourFormat -> PreferenceOptionsSheet(
             title = stringResource(Res.string.settings_preferences_hour_title),
             options = HourFormat.entries.map { it.asOption(PreferencePicker.HourFormat, it.label()) },
             selected = state.hourFormat,
             onSelect = { onEvent(PreferencesEvent.OnHourFormatSelect(it)) },
             onDismiss = onDismiss,
         )
 
         PreferencePicker.DefaultTransactionType -> PreferenceOptionsSheet(
             title = stringResource(Res.string.settings_preferences_default_txn_title),
             options = DefaultTransactionType.entries.map {
                 it.asOption(PreferencePicker.DefaultTransactionType, it.label())
             },
             selected = state.defaultTransactionType,
             onSelect = { onEvent(PreferencesEvent.OnDefaultTransactionTypeSelect(it)) },
             onDismiss = onDismiss,
         )
     }
 }
 
 @Composable
 private fun CurrencySheet(
     currencies: List<Currency>,
     selected: CurrencyCode?,
     onSelect: (CurrencyCode) -> Unit,
     onDismiss: () -> Unit,
 ) {
     SurferCurrencyBottomSheet(
         title = stringResource(Res.string.settings_preferences_currency_title),
         searchPlaceholder = stringResource(Res.string.settings_preferences_currency_search_placeholder),
         currencies = currencies.map {
             SurferCurrencyOption(code = it.code.value, symbol = it.symbol, name = it.displayName)
         },
         selectedCode = selected?.value,
         onSelect = { onSelect(CurrencyCode(it.code)) },
         onDismiss = onDismiss,
     )
 }
 
 private fun <T : Enum<T>> T.asOption(picker: PreferencePicker, label: String): PreferenceOption<T> =
     PreferenceOption(value = this, label = label, tag = PreferencesTestTags.option(picker, name))
 
 @Preview
 @Composable
 private fun PreferencesScreenPreview() {
     AppTheme {
         PreferencesContent(
             state = PreferencesState(
                 region = AppRegion.Poland,
                 currency = CurrencyCode("PLN"),
                 currencies = listOf(Currency(CurrencyCode("PLN"), symbol = "zł", displayName = "Polish Zloty")),
                 numberFormat = NumberFormatStyle.SpaceGroupCommaDecimal,
                 hourFormat = HourFormat.TwentyFourHour,
             ),
             onEvent = {},
         )
     }
 }