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