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.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -126,40 +127,42 @@ private fun HiddenWordsList(
) {
val feedState by viewModel.feedContent.collectAsStateWithLifecycle()
when (val state = feedState) {
is StringFeedState.Loaded -> {
val items by state.feed.collectAsStateWithLifecycle()
if (items.isEmpty()) {
EmptyState(modifier, R.string.security_hidden_words_empty)
} else {
val listState = rememberLazyListState()
LazyColumn(
modifier = modifier.fillMaxSize(),
state = listState,
) {
items(items, key = { it }) { word ->
MutedWordRow(
tag = word,
isSelected = word in selected,
selectionMode = selected.isNotEmpty(),
onToggle = { onToggle(word) },
)
HorizontalDivider(thickness = DividerThickness)
Box(modifier.fillMaxSize()) {
when (val state = feedState) {
is StringFeedState.Loaded -> {
val items by state.feed.collectAsStateWithLifecycle()
if (items.isEmpty()) {
EmptyState(R.string.security_hidden_words_empty)
} else {
val listState = rememberLazyListState()
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = listState,
) {
items(items, key = { it }) { word ->
MutedWordRow(
tag = word,
isSelected = word in selected,
selectionMode = selected.isNotEmpty(),
onToggle = { onToggle(word) },
)
HorizontalDivider(thickness = DividerThickness)
}
}
}
}
}
is StringFeedState.Empty -> {
EmptyState(modifier, R.string.security_hidden_words_empty)
}
is StringFeedState.Empty -> {
EmptyState(R.string.security_hidden_words_empty)
}
is StringFeedState.Loading -> {
LoadingFeed()
}
is StringFeedState.Loading -> {
LoadingFeed()
}
is StringFeedState.FeedError -> {
FeedError(state.errorMessage) { viewModel.invalidateData() }
is StringFeedState.FeedError -> {
FeedError(state.errorMessage) { viewModel.invalidateData() }
}
}
}
}
@@ -20,6 +20,7 @@
*/
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.Row
import androidx.compose.foundation.layout.fillMaxSize
@@ -90,32 +91,34 @@ private fun MutedThreadsList(
) {
val feedState by viewModel.feedState.feedContent.collectAsStateWithLifecycle()
when (val state = feedState) {
is FeedState.Loaded -> {
val items by state.feed.collectAsStateWithLifecycle()
val listState = rememberLazyListState()
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = rememberFeedContentPadding(FeedPadding),
state = listState,
) {
items(items.list, key = { it.idHex }) { note ->
MutedThreadRow(note = note, accountViewModel = accountViewModel)
HorizontalDivider(thickness = DividerThickness)
Box(modifier.fillMaxSize()) {
when (val state = feedState) {
is FeedState.Loaded -> {
val items by state.feed.collectAsStateWithLifecycle()
val listState = rememberLazyListState()
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = rememberFeedContentPadding(FeedPadding),
state = listState,
) {
items(items.list, key = { it.idHex }) { note ->
MutedThreadRow(note = note, accountViewModel = accountViewModel)
HorizontalDivider(thickness = DividerThickness)
}
}
}
}
is FeedState.Empty -> {
EmptyState(modifier, R.string.settings_muted_threads_empty)
}
is FeedState.Empty -> {
EmptyState(R.string.settings_muted_threads_empty)
}
is FeedState.Loading -> {
LoadingFeed()
}
is FeedState.Loading -> {
LoadingFeed()
}
is FeedState.FeedError -> {
FeedError(state.errorMessage) { viewModel.invalidateData() }
is FeedState.FeedError -> {
FeedError(state.errorMessage) { viewModel.invalidateData() }
}
}
}
}
@@ -181,7 +181,7 @@ private fun WarnReportsTile(accountViewModel: AccountViewModel) {
enabled = warnReports,
) {
SettingsStepper(
value = threshold.coerceAtLeast(1),
value = threshold,
min = 1,
max = 999,
enabled = warnReports,
@@ -25,6 +25,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
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.fillMaxSize
@@ -141,76 +142,80 @@ internal fun SelectableUserList(
nav: INav,
enablePullRefresh: Boolean = true,
) {
RefresheableBox(viewModel, enablePullRefresh) {
val feedState by viewModel.feedContent.collectAsStateWithLifecycle()
val selectionMode = selected.isNotEmpty()
// Outer Box applies caller padding (Scaffold insets) so the Loading/Error
// states inside RefresheableBox don't render under the top bar.
Box(modifier.fillMaxSize()) {
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(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() }
}
}
}
}
@@ -218,11 +223,10 @@ internal fun SelectableUserList(
@Composable
internal fun EmptyState(
modifier: Modifier = Modifier,
@StringRes message: Int,
) {
Column(
modifier = modifier.fillMaxSize().padding(32.dp),
modifier = Modifier.fillMaxSize().padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
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
internal fun SettingsStepper(
value: Int,
@@ -362,6 +368,7 @@ internal fun SettingsStepper(
unsetLabel: String? = null,
onValueChange: (Int) -> Unit,
) {
val clamped = value.coerceIn(min, max)
Row(
modifier =
Modifier
@@ -370,8 +377,8 @@ internal fun SettingsStepper(
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(
enabled = enabled && value > min,
onClick = { onValueChange((value - 1).coerceAtLeast(min)) },
enabled = enabled && clamped > min,
onClick = { onValueChange((value - 1).coerceIn(min, max)) },
) {
Icon(
symbol = MaterialSymbols.Remove,
@@ -380,14 +387,14 @@ internal fun SettingsStepper(
)
}
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,
textAlign = TextAlign.Center,
modifier = Modifier.widthIn(min = 40.dp),
)
IconButton(
enabled = enabled && value < max,
onClick = { onValueChange((value + 1).coerceAtMost(max)) },
enabled = enabled && clamped < max,
onClick = { onValueChange((value + 1).coerceIn(min, max)) },
) {
Icon(
symbol = MaterialSymbols.Add,