diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/MutedThreadsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/MutedThreadsScreen.kt index e90a7d76c..de6e5eac6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/MutedThreadsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/MutedThreadsScreen.kt @@ -26,7 +26,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -38,7 +38,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -100,7 +99,7 @@ private fun MutedThreadsList( contentPadding = rememberFeedContentPadding(FeedPadding), state = listState, ) { - itemsIndexed(items.list, key = { _, item -> item.idHex }) { _, note -> + items(items.list, key = { it.idHex }) { note -> MutedThreadRow(note = note, accountViewModel = accountViewModel) HorizontalDivider(thickness = DividerThickness) } @@ -175,7 +174,10 @@ private fun MutedThreadRow( ), contentPadding = ButtonPadding, ) { - Text(text = stringRes(R.string.action_unmute), color = Color.White) + Text( + text = stringRes(R.string.action_unmute), + color = MaterialTheme.colorScheme.onPrimary, + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt index a175ccf50..e66f171c3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt @@ -20,20 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings -import androidx.compose.foundation.background +import androidx.annotation.StringRes import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults @@ -42,23 +35,23 @@ import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.model.WarningType import com.vitorpamplona.amethyst.model.parseWarningType +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn @Composable fun SecurityFiltersScreen( @@ -101,21 +94,22 @@ private fun SecurityPreferencesSection(accountViewModel: AccountViewModel) { @Composable private fun SensitiveContentTile(accountViewModel: AccountViewModel) { - val showSensitive by accountViewModel.account.settings.syncedSettings.security.showSensitiveContent - .collectAsStateWithLifecycle() + val security = accountViewModel.account.settings.syncedSettings.security + val showSensitive by security.showSensitiveContent.collectAsStateWithLifecycle() + val current = parseWarningType(showSensitive) + val options = WarningType.entries SettingsBlockTile( icon = MaterialSymbols.Visibility, title = stringRes(R.string.show_sensitive_content_title), description = stringRes(R.string.show_sensitive_content_explainer), ) { - val current = parseWarningType(showSensitive) SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { - WarningType.entries.forEachIndexed { index, type -> + options.forEachIndexed { index, type -> SegmentedButton( selected = current == type, onClick = { accountViewModel.updateShowSensitiveContent(type.prefCode) }, - shape = SegmentedButtonDefaults.itemShape(index = index, count = WarningType.entries.size), + shape = SegmentedButtonDefaults.itemShape(index = index, count = options.size), ) { Text(stringRes(type.resourceId)) } @@ -126,15 +120,16 @@ private fun SensitiveContentTile(accountViewModel: AccountViewModel) { @Composable private fun FilterSpamTile(accountViewModel: AccountViewModel) { - val filterSpam by accountViewModel.account.settings.syncedSettings.security.filterSpamFromStrangers + val filterSpam by accountViewModel.account.settings.syncedSettings.security + .filterSpamFromStrangers .collectAsStateWithLifecycle() SwitchTile( icon = MaterialSymbols.FilterAlt, - titleRes = R.string.filter_spam_from_strangers_title, - descriptionRes = R.string.filter_spam_from_strangers_explainer, + title = R.string.filter_spam_from_strangers_title, + description = R.string.filter_spam_from_strangers_explainer, checked = filterSpam, - onCheckedChange = { accountViewModel.updateFilterSpam(it) }, + onCheckedChange = accountViewModel::updateFilterSpam, ) } @@ -145,8 +140,8 @@ private fun HideCommunityViolationsTile(accountViewModel: AccountViewModel) { SwitchTile( icon = MaterialSymbols.Shield, - titleRes = R.string.hide_community_rules_violations_title, - descriptionRes = R.string.hide_community_rules_violations_explainer, + title = R.string.hide_community_rules_violations_title, + description = R.string.hide_community_rules_violations_explainer, checked = hideViolations, onCheckedChange = { accountViewModel.account.settings.changeHideCommunityRulesViolations(it) }, ) @@ -154,50 +149,51 @@ private fun HideCommunityViolationsTile(accountViewModel: AccountViewModel) { @Composable private fun DisableClientTagTile(accountViewModel: AccountViewModel) { - val disableClientTag by accountViewModel.account.settings.syncedSettings.security.disableClientTag + val disableClientTag by accountViewModel.account.settings.syncedSettings.security + .disableClientTag .collectAsStateWithLifecycle() SwitchTile( icon = MaterialSymbols.Code, - titleRes = R.string.disable_client_tag_title, - descriptionRes = R.string.disable_client_tag_explainer, + title = R.string.disable_client_tag_title, + description = R.string.disable_client_tag_explainer, checked = disableClientTag, - onCheckedChange = { accountViewModel.updateDisableClientTag(it) }, + onCheckedChange = accountViewModel::updateDisableClientTag, ) } @Composable private fun WarnReportsTile(accountViewModel: AccountViewModel) { - val warnReports by accountViewModel.account.settings.syncedSettings.security.warnAboutPostsWithReports - .collectAsStateWithLifecycle() - val threshold by accountViewModel.account.settings.syncedSettings.security.reportWarningThreshold - .collectAsStateWithLifecycle() + val security = accountViewModel.account.settings.syncedSettings.security + val warnReports by security.warnAboutPostsWithReports.collectAsStateWithLifecycle() + val threshold by security.reportWarningThreshold.collectAsStateWithLifecycle() SwitchTile( icon = MaterialSymbols.Report, - titleRes = R.string.warn_when_posts_have_reports_from_your_follows_title, - descriptionRes = R.string.warn_when_posts_have_reports_from_your_follows_explainer, + title = R.string.warn_when_posts_have_reports_from_your_follows_title, + description = R.string.warn_when_posts_have_reports_from_your_follows_explainer, checked = warnReports, - onCheckedChange = { accountViewModel.updateWarnReports(it) }, + onCheckedChange = accountViewModel::updateWarnReports, ) SettingsSubControlRow( title = stringRes(R.string.report_warning_threshold_title), description = stringRes(R.string.report_warning_threshold_explainer), enabled = warnReports, ) { - Stepper( + SettingsStepper( value = threshold.coerceAtLeast(1), min = 1, max = 999, enabled = warnReports, - onValueChange = { accountViewModel.updateReportWarningThreshold(it) }, + onValueChange = accountViewModel::updateReportWarningThreshold, ) } } @Composable private fun MaxHashtagsTile(accountViewModel: AccountViewModel) { - val maxHashtags by accountViewModel.account.settings.syncedSettings.security.maxHashtagLimit + val maxHashtags by accountViewModel.account.settings.syncedSettings.security + .maxHashtagLimit .collectAsStateWithLifecycle() SettingsControlRow( @@ -205,12 +201,12 @@ private fun MaxHashtagsTile(accountViewModel: AccountViewModel) { title = stringRes(R.string.max_hashtag_limit_title), description = stringRes(R.string.max_hashtag_limit_explainer), ) { - Stepper( + SettingsStepper( value = maxHashtags, min = 0, max = 99, unsetLabel = stringRes(R.string.security_unlimited), - onValueChange = { accountViewModel.updateMaxHashtagLimit(it) }, + onValueChange = accountViewModel::updateMaxHashtagLimit, ) } } @@ -218,15 +214,15 @@ private fun MaxHashtagsTile(accountViewModel: AccountViewModel) { @Composable private fun SwitchTile( icon: MaterialSymbol, - titleRes: Int, - descriptionRes: Int, + @StringRes title: Int, + @StringRes description: Int, checked: Boolean, onCheckedChange: (Boolean) -> Unit, ) { SettingsControlRow( icon = icon, - title = stringRes(titleRes), - description = stringRes(descriptionRes), + title = stringRes(title), + description = stringRes(description), onClick = { onCheckedChange(!checked) }, ) { Switch(checked = checked, onCheckedChange = onCheckedChange) @@ -245,99 +241,37 @@ private fun BlockedContentSection( SettingsItem( title = R.string.blocked_users, icon = MaterialSymbols.PersonOff, - trailing = { CountBadge(hidden.hiddenUsers.size) }, + trailing = { SettingsCountBadge(hidden.hiddenUsers.size) }, onClick = { nav.nav(Route.BlockedUsers) }, ) SettingsDivider() SettingsItem( title = R.string.spamming_users, icon = MaterialSymbols.Block, - trailing = { CountBadge(hidden.spammers.size) }, + trailing = { SettingsCountBadge(hidden.spammers.size) }, onClick = { nav.nav(Route.SpammingUsers) }, ) SettingsDivider() SettingsItem( title = R.string.hidden_words, icon = MaterialSymbols.VisibilityOff, - trailing = { CountBadge(hidden.hiddenWords.size) }, + trailing = { SettingsCountBadge(hidden.hiddenWords.size) }, onClick = { nav.nav(Route.HiddenWords) }, ) SettingsDivider() SettingsItem( title = R.string.settings_muted_threads_title, icon = MaterialSymbols.Forum, - trailing = { CountBadge(hidden.mutedThreads.size) }, + trailing = { SettingsCountBadge(hidden.mutedThreads.size) }, onClick = { nav.nav(Route.MutedThreads) }, ) } } +@Preview @Composable -private fun CountBadge(count: Int) { - // Reserve space so transitions to/from 0 don't shift the row. - Box( - modifier = Modifier.widthIn(min = 28.dp), - contentAlignment = Alignment.Center, - ) { - if (count > 0) { - Box( - modifier = - Modifier - .clip(RoundedCornerShape(50)) - .background(MaterialTheme.colorScheme.secondaryContainer) - .padding(horizontal = 10.dp, vertical = 2.dp), - ) { - Text( - text = count.toString(), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSecondaryContainer, - ) - } - } - } -} - -@Composable -private fun Stepper( - value: Int, - min: Int, - max: Int, - enabled: Boolean = true, - unsetLabel: String? = null, - onValueChange: (Int) -> Unit, -) { - Row( - modifier = - Modifier - .clip(RoundedCornerShape(50)) - .background(MaterialTheme.colorScheme.surfaceContainerHigh), - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton( - enabled = enabled && value > min, - onClick = { onValueChange((value - 1).coerceAtLeast(min)) }, - ) { - Icon( - symbol = MaterialSymbols.Remove, - contentDescription = "−", - modifier = Modifier.size(18.dp), - ) - } - Text( - text = if (unsetLabel != null && value <= 0) unsetLabel else value.toString(), - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center, - modifier = Modifier.widthIn(min = 28.dp), - ) - IconButton( - enabled = enabled && value < max, - onClick = { onValueChange((value + 1).coerceAtMost(max)) }, - ) { - Icon( - symbol = MaterialSymbols.Add, - contentDescription = "+", - modifier = Modifier.size(18.dp), - ) - } +fun SecurityFiltersScreenPreview() { + ThemeComparisonColumn { + SecurityFiltersScreen(mockAccountViewModel(), EmptyNav()) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityListsCommon.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityListsCommon.kt index 2fba72afd..8463f3386 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityListsCommon.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityListsCommon.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings +import androidx.annotation.StringRes import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable @@ -54,6 +55,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.ui.feeds.FeedError import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed +import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor @@ -74,25 +76,25 @@ import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size15dp import com.vitorpamplona.amethyst.ui.theme.Size55dp +/** + * Re-runs [invalidate] whenever the user's block/mute state changes. `hiddenUsers.flow` + * already combines transient spammers, so subscribing to that alone covers both. + */ @Composable internal fun InvalidateOnBlockListChange( accountViewModel: AccountViewModel, invalidate: () -> Unit, ) { - val transientSpammers by accountViewModel.account.hiddenUsers.transientHiddenUsers - .collectAsStateWithLifecycle() val blockListState by accountViewModel.account.hiddenUsers.flow .collectAsStateWithLifecycle() - LaunchedEffect(accountViewModel, transientSpammers, blockListState) { - invalidate() - } + LaunchedEffect(blockListState) { invalidate() } } @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun BlockListTopBar( - title: Int, + @StringRes title: Int, selectedCount: Int, onCancel: () -> Unit, onUnblock: () -> Unit, @@ -132,81 +134,84 @@ internal fun BlockListTopBar( internal fun SelectableUserList( modifier: Modifier = Modifier, viewModel: UserFeedViewModel, - emptyMessage: Int, + @StringRes emptyMessage: Int, selected: Set, onToggle: (String) -> Unit, accountViewModel: AccountViewModel, nav: INav, + enablePullRefresh: Boolean = true, ) { - val feedState by viewModel.feedContent.collectAsStateWithLifecycle() - val selectionMode = selected.isNotEmpty() + RefresheableBox(viewModel, enablePullRefresh) { + val feedState by viewModel.feedContent.collectAsStateWithLifecycle() + val selectionMode = selected.isNotEmpty() - when (val state = feedState) { - is UserFeedState.Loaded -> { - val items by state.feed.collectAsStateWithLifecycle() - val listState = rememberLazyListState() + when (val state = feedState) { + is UserFeedState.Loaded -> { + val items by state.feed.collectAsStateWithLifecycle() + val listState = rememberLazyListState() - LazyColumn( - modifier = modifier.fillMaxSize(), - contentPadding = rememberFeedContentPadding(FeedPadding), - state = listState, - ) { - items(items, key = { it.pubkeyHex }) { user -> - val isSelected = user.pubkeyHex in selected - val rowModifier = - Modifier - .fillMaxWidth() - .combinedClickable( - onClick = { - if (selectionMode) { - onToggle(user.pubkeyHex) + LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = rememberFeedContentPadding(FeedPadding), + state = listState, + ) { + items(items, key = { it.pubkeyHex }) { user -> + val isSelected = user.pubkeyHex in selected + val rowModifier = + Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + if (selectionMode) { + onToggle(user.pubkeyHex) + } else { + nav.nav(routeFor(user)) + } + }, + onLongClick = { onToggle(user.pubkeyHex) }, + ).let { + if (isSelected) { + it.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)) } else { - nav.nav(routeFor(user)) + it } - }, - onLongClick = { onToggle(user.pubkeyHex) }, - ).let { - if (isSelected) { - it.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)) - } else { - it } - } - Row( - modifier = rowModifier.padding(horizontal = Size15dp, vertical = Size10dp), - verticalAlignment = Alignment.CenterVertically, - ) { - UserPicture(user, Size55dp, accountViewModel = accountViewModel, nav = nav) - Column( - modifier = - Modifier - .padding(start = 10.dp) - .weight(1f), + Row( + modifier = rowModifier.padding(horizontal = Size15dp, vertical = Size10dp), + verticalAlignment = Alignment.CenterVertically, ) { - UsernameDisplay(user, accountViewModel = accountViewModel) - } - if (selectionMode) { - Checkbox(checked = isSelected, onCheckedChange = { onToggle(user.pubkeyHex) }) - } else { - ShowUserButton { accountViewModel.show(user) } + UserPicture(user, Size55dp, accountViewModel = accountViewModel, nav = nav) + Column( + modifier = + Modifier + .padding(start = 10.dp) + .weight(1f), + ) { + UsernameDisplay(user, accountViewModel = accountViewModel) + } + if (selectionMode) { + Checkbox(checked = isSelected, onCheckedChange = { onToggle(user.pubkeyHex) }) + } else { + ShowUserButton { accountViewModel.show(user) } + } } + HorizontalDivider(thickness = DividerThickness) } - HorizontalDivider(thickness = DividerThickness) } } - } - is UserFeedState.Empty -> { - EmptyState(modifier, emptyMessage) - } + is UserFeedState.Empty -> { + EmptyState(modifier, emptyMessage) + } - is UserFeedState.Loading -> { - LoadingFeed() - } + is UserFeedState.Loading -> { + LoadingFeed() + } - is UserFeedState.FeedError -> { - FeedError(state.errorMessage) { viewModel.invalidateData() } + is UserFeedState.FeedError -> { + FeedError(state.errorMessage) { viewModel.invalidateData() } + } } } } @@ -214,7 +219,7 @@ internal fun SelectableUserList( @Composable internal fun EmptyState( modifier: Modifier = Modifier, - message: Int, + @StringRes message: Int, ) { Column( modifier = modifier.fillMaxSize().padding(32.dp), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsSectionCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsSectionCard.kt index 7da0f5410..10f7edd6e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsSectionCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsSectionCard.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings +import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -30,10 +31,12 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -43,6 +46,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol @@ -53,7 +57,7 @@ import androidx.compose.material3.Icon as Material3Icon @Composable internal fun SettingsSection( - title: Int, + @StringRes title: Int, isDanger: Boolean = false, content: @Composable ColumnScope.() -> Unit, ) { @@ -93,10 +97,10 @@ internal fun SettingsDivider() { ) } -/** Navigation row: icon + title + optional `trailing` (count badge, etc.) + chevron. */ +/** Navigation row: icon + title + optional [trailing] (count badge, etc.) + chevron. */ @Composable internal fun SettingsItem( - title: Int, + @StringRes title: Int, icon: MaterialSymbol, isDanger: Boolean = false, trailing: @Composable () -> Unit = {}, @@ -120,7 +124,7 @@ internal fun SettingsItem( /** Navigation row variant taking a drawable painter as the leading icon. */ @Composable internal fun SettingsItem( - title: Int, + @StringRes title: Int, iconPainter: Int, iconPainterRef: Int, isDanger: Boolean = false, @@ -145,7 +149,7 @@ internal fun SettingsItem( @Composable private fun SettingsItemRow( - title: Int, + @StringRes title: Int, isDanger: Boolean, trailing: @Composable () -> Unit, onClick: () -> Unit, @@ -199,28 +203,24 @@ private fun SettingsItemRow( } /** - * Control row: icon + title + description, with an inline `trailing` control (switch, stepper). - * Clicking anywhere on the row invokes [onClick] — pass `null` to disable row clicks - * (useful when the control alone should toggle state). + * Control row: icon + title + description, with an inline [trailing] control (switch, stepper). + * When [onClick] is non-null the whole row becomes clickable — useful for `Switch` rows where + * tapping anywhere should toggle the state. */ @Composable internal fun SettingsControlRow( icon: MaterialSymbol, title: String, description: String, - enabled: Boolean = true, onClick: (() -> Unit)? = null, trailing: @Composable () -> Unit, ) { - val rowModifier = - Modifier - .fillMaxWidth() - .let { if (onClick != null && enabled) it.clickable(onClick = onClick) else it } - .padding(horizontal = 16.dp, vertical = 12.dp) - val alpha = if (enabled) 1f else 0.5f - Row( - modifier = rowModifier, + modifier = + Modifier + .fillMaxWidth() + .let { if (onClick != null) it.clickable(onClick = onClick) else it } + .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { SettingsIconBox(MaterialTheme.colorScheme.primaryContainer) { @@ -228,7 +228,7 @@ internal fun SettingsControlRow( symbol = icon, contentDescription = null, modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = alpha), + tint = MaterialTheme.colorScheme.onPrimaryContainer, ) } Column( @@ -237,15 +237,11 @@ internal fun SettingsControlRow( .weight(1f) .padding(start = 16.dp, end = 12.dp), ) { - Text( - text = title, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = alpha), - ) + Text(text = title, style = MaterialTheme.typography.bodyLarge) Text( text = description, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha), + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } trailing() @@ -255,7 +251,7 @@ internal fun SettingsControlRow( /** * Sub-row variant of [SettingsControlRow]: indented in place of a leading icon, * used for controls hierarchically grouped under the row above (e.g. a threshold - * that only matters when the parent toggle is on). + * that only matters when the parent toggle is on). Text dims when [enabled] is false. */ @Composable internal fun SettingsSubControlRow( @@ -272,9 +268,7 @@ internal fun SettingsSubControlRow( .padding(start = 68.dp, end = 16.dp, top = 8.dp, bottom = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { - Column( - modifier = Modifier.weight(1f).padding(end = 12.dp), - ) { + Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) { Text( text = title, style = MaterialTheme.typography.bodyMedium, @@ -333,6 +327,77 @@ internal fun SettingsBlockTile( } } +/** Pill-shaped count for [SettingsItem.trailing]. Renders a space placeholder when zero. */ +@Composable +internal fun SettingsCountBadge(count: Int) { + Box( + modifier = Modifier.widthIn(min = 28.dp), + contentAlignment = Alignment.Center, + ) { + if (count > 0) { + Box( + modifier = + Modifier + .clip(RoundedCornerShape(50)) + .background(MaterialTheme.colorScheme.secondaryContainer) + .padding(horizontal = 10.dp, vertical = 2.dp), + ) { + Text( + text = count.toString(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } + } +} + +/** Numeric stepper: `-` value `+`. [unsetLabel] is shown when value <= 0 (e.g. "∞"). */ +@Composable +internal fun SettingsStepper( + value: Int, + min: Int, + max: Int, + enabled: Boolean = true, + unsetLabel: String? = null, + onValueChange: (Int) -> Unit, +) { + Row( + modifier = + Modifier + .clip(RoundedCornerShape(50)) + .background(MaterialTheme.colorScheme.surfaceContainerHigh), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + enabled = enabled && value > min, + onClick = { onValueChange((value - 1).coerceAtLeast(min)) }, + ) { + Icon( + symbol = MaterialSymbols.Remove, + contentDescription = "−", + modifier = Modifier.size(18.dp), + ) + } + Text( + text = if (unsetLabel != null && value <= 0) unsetLabel else value.toString(), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + modifier = Modifier.widthIn(min = 40.dp), + ) + IconButton( + enabled = enabled && value < max, + onClick = { onValueChange((value + 1).coerceAtMost(max)) }, + ) { + Icon( + symbol = MaterialSymbols.Add, + contentDescription = "+", + modifier = Modifier.size(18.dp), + ) + } + } +} + @Composable private fun SettingsIconBox( containerColor: Color,