refactor: address second-pass Security Filters audit

- Restore pull-to-refresh on BlockedUsers and SpammingUsers by wrapping
  SelectableUserList in RefresheableBox (regression from the first
  refactor; HiddenWords and MutedThreads remain non-pull-refresh to
  match the original behavior).
- Drop the redundant transientHiddenUsers subscription in
  InvalidateOnBlockListChange — hiddenUsers.flow already combines it.
  Also drop the meaningless accountViewModel key on LaunchedEffect.
- Move CountBadge and Stepper into SettingsSectionCard as
  SettingsCountBadge and SettingsStepper so other settings screens can
  reuse them. Widen the stepper value cell to min 40.dp so three-digit
  values like 999 fit comfortably.
- Drop the unused `enabled` flag from SettingsControlRow (sub-controls
  use SettingsSubControlRow for dimming). Tighten the docstring.
- Unify SwitchTile to take @StringRes Int params, matching the @StringRes
  annotations now applied to SettingsItem, SettingsSection, BlockListTopBar,
  SelectableUserList, EmptyState.
- Shorten property chains by binding `val security = …syncedSettings.security`
  per tile when more than one field is read; pass method references
  (accountViewModel::updateFilterSpam etc.) where possible.
- Hoist WarningType.entries to a local val so the segmented button row
  reads `count = options.size` instead of evaluating twice.
- MutedThreadsScreen: replace itemsIndexed with items (unused index) and
  use MaterialTheme.colorScheme.onPrimary instead of hardcoded Color.White
  for the "Unmute" button text.
- Add a @Preview for SecurityFiltersScreen, mirroring AllSettingsScreen.
This commit is contained in:
Claude
2026-05-13 22:00:38 +00:00
parent 6feca2cf17
commit 06e88cd30e
4 changed files with 214 additions and 208 deletions
@@ -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,
)
}
}
}
@@ -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())
}
}
@@ -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<String>,
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),
@@ -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,