fix: pad Loading/Error states and tighten Stepper resync

- Wrap the when-branches in SelectableUserList, HiddenWordsList, and
  MutedThreadsList in Box(modifier.fillMaxSize()) so the Scaffold
  padding is applied to every state — Loading/Error/Empty/Loaded — not
  just the LazyColumn. Fixes a regression where the loading spinner
  and error UI rendered under the top bar.
- SettingsStepper now clamps `value` once into `[min, max]` and uses
  the raw `value` (re-clamped) in the +/- handlers. If the model
  starts below `min`, the first tap of `+` resyncs it to `min`
  instead of jumping `min+1` (skipping a step). Display still falls
  back to `unsetLabel` when `value <= 0`.
- WarnReportsTile no longer pre-coerces threshold to >= 1 at the call
  site; the stepper handles it.
- EmptyState drops its now-unused modifier parameter.
This commit is contained in:
Claude
2026-05-13 23:09:06 +00:00
parent 06e88cd30e
commit 0b7399feb0
5 changed files with 131 additions and 114 deletions
@@ -24,6 +24,7 @@ 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
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -126,40 +127,42 @@ private fun HiddenWordsList(
) { ) {
val feedState by viewModel.feedContent.collectAsStateWithLifecycle() val feedState by viewModel.feedContent.collectAsStateWithLifecycle()
when (val state = feedState) { Box(modifier.fillMaxSize()) {
is StringFeedState.Loaded -> { when (val state = feedState) {
val items by state.feed.collectAsStateWithLifecycle() is StringFeedState.Loaded -> {
if (items.isEmpty()) { val items by state.feed.collectAsStateWithLifecycle()
EmptyState(modifier, R.string.security_hidden_words_empty) if (items.isEmpty()) {
} else { EmptyState(R.string.security_hidden_words_empty)
val listState = rememberLazyListState() } else {
LazyColumn( val listState = rememberLazyListState()
modifier = modifier.fillMaxSize(), LazyColumn(
state = listState, modifier = Modifier.fillMaxSize(),
) { state = listState,
items(items, key = { it }) { word -> ) {
MutedWordRow( items(items, key = { it }) { word ->
tag = word, MutedWordRow(
isSelected = word in selected, tag = word,
selectionMode = selected.isNotEmpty(), isSelected = word in selected,
onToggle = { onToggle(word) }, selectionMode = selected.isNotEmpty(),
) onToggle = { onToggle(word) },
HorizontalDivider(thickness = DividerThickness) )
HorizontalDivider(thickness = DividerThickness)
}
} }
} }
} }
}
is StringFeedState.Empty -> { is StringFeedState.Empty -> {
EmptyState(modifier, R.string.security_hidden_words_empty) EmptyState(R.string.security_hidden_words_empty)
} }
is StringFeedState.Loading -> { is StringFeedState.Loading -> {
LoadingFeed() LoadingFeed()
} }
is StringFeedState.FeedError -> { is StringFeedState.FeedError -> {
FeedError(state.errorMessage) { viewModel.invalidateData() } FeedError(state.errorMessage) { viewModel.invalidateData() }
}
} }
} }
} }
@@ -20,6 +20,7 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings
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.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -90,32 +91,34 @@ private fun MutedThreadsList(
) { ) {
val feedState by viewModel.feedState.feedContent.collectAsStateWithLifecycle() val feedState by viewModel.feedState.feedContent.collectAsStateWithLifecycle()
when (val state = feedState) { Box(modifier.fillMaxSize()) {
is FeedState.Loaded -> { when (val state = feedState) {
val items by state.feed.collectAsStateWithLifecycle() is FeedState.Loaded -> {
val listState = rememberLazyListState() val items by state.feed.collectAsStateWithLifecycle()
LazyColumn( val listState = rememberLazyListState()
modifier = modifier.fillMaxSize(), LazyColumn(
contentPadding = rememberFeedContentPadding(FeedPadding), modifier = Modifier.fillMaxSize(),
state = listState, contentPadding = rememberFeedContentPadding(FeedPadding),
) { state = listState,
items(items.list, key = { it.idHex }) { note -> ) {
MutedThreadRow(note = note, accountViewModel = accountViewModel) items(items.list, key = { it.idHex }) { note ->
HorizontalDivider(thickness = DividerThickness) MutedThreadRow(note = note, accountViewModel = accountViewModel)
HorizontalDivider(thickness = DividerThickness)
}
} }
} }
}
is FeedState.Empty -> { is FeedState.Empty -> {
EmptyState(modifier, R.string.settings_muted_threads_empty) EmptyState(R.string.settings_muted_threads_empty)
} }
is FeedState.Loading -> { is FeedState.Loading -> {
LoadingFeed() LoadingFeed()
} }
is FeedState.FeedError -> { is FeedState.FeedError -> {
FeedError(state.errorMessage) { viewModel.invalidateData() } FeedError(state.errorMessage) { viewModel.invalidateData() }
}
} }
} }
} }
@@ -181,7 +181,7 @@ private fun WarnReportsTile(accountViewModel: AccountViewModel) {
enabled = warnReports, enabled = warnReports,
) { ) {
SettingsStepper( SettingsStepper(
value = threshold.coerceAtLeast(1), value = threshold,
min = 1, min = 1,
max = 999, max = 999,
enabled = warnReports, enabled = warnReports,
@@ -25,6 +25,7 @@ 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
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.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -141,76 +142,80 @@ internal fun SelectableUserList(
nav: INav, nav: INav,
enablePullRefresh: Boolean = true, enablePullRefresh: Boolean = true,
) { ) {
RefresheableBox(viewModel, enablePullRefresh) { // Outer Box applies caller padding (Scaffold insets) so the Loading/Error
val feedState by viewModel.feedContent.collectAsStateWithLifecycle() // states inside RefresheableBox don't render under the top bar.
val selectionMode = selected.isNotEmpty() Box(modifier.fillMaxSize()) {
RefresheableBox(viewModel, enablePullRefresh) {
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(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() }
}
} }
} }
} }
@@ -218,11 +223,10 @@ internal fun SelectableUserList(
@Composable @Composable
internal fun EmptyState( internal fun EmptyState(
modifier: Modifier = Modifier,
@StringRes message: Int, @StringRes message: Int,
) { ) {
Column( Column(
modifier = modifier.fillMaxSize().padding(32.dp), modifier = Modifier.fillMaxSize().padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
@@ -352,7 +352,13 @@ internal fun SettingsCountBadge(count: Int) {
} }
} }
/** Numeric stepper: `-` value `+`. [unsetLabel] is shown when value <= 0 (e.g. "∞"). */ /**
* Numeric stepper: `-` value `+`. Display is clamped to `[min, max]`, and `-`/`+`
* operate on the raw `value` then re-clamp, so a model that starts out below `min`
* lifts to `min` on the first tap of `+` (no skipped step). [unsetLabel] is shown
* when `value <= 0` (typically the model uses `0` to mean "unlimited"); pass
* `null` to always show the number.
*/
@Composable @Composable
internal fun SettingsStepper( internal fun SettingsStepper(
value: Int, value: Int,
@@ -362,6 +368,7 @@ internal fun SettingsStepper(
unsetLabel: String? = null, unsetLabel: String? = null,
onValueChange: (Int) -> Unit, onValueChange: (Int) -> Unit,
) { ) {
val clamped = value.coerceIn(min, max)
Row( Row(
modifier = modifier =
Modifier Modifier
@@ -370,8 +377,8 @@ internal fun SettingsStepper(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
IconButton( IconButton(
enabled = enabled && value > min, enabled = enabled && clamped > min,
onClick = { onValueChange((value - 1).coerceAtLeast(min)) }, onClick = { onValueChange((value - 1).coerceIn(min, max)) },
) { ) {
Icon( Icon(
symbol = MaterialSymbols.Remove, symbol = MaterialSymbols.Remove,
@@ -380,14 +387,14 @@ internal fun SettingsStepper(
) )
} }
Text( Text(
text = if (unsetLabel != null && value <= 0) unsetLabel else value.toString(), text = if (unsetLabel != null && value <= 0) unsetLabel else clamped.toString(),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier.widthIn(min = 40.dp), modifier = Modifier.widthIn(min = 40.dp),
) )
IconButton( IconButton(
enabled = enabled && value < max, enabled = enabled && clamped < max,
onClick = { onValueChange((value + 1).coerceAtMost(max)) }, onClick = { onValueChange((value + 1).coerceIn(min, max)) },
) { ) {
Icon( Icon(
symbol = MaterialSymbols.Add, symbol = MaterialSymbols.Add,