From 0b7399feb0c66a8af7296e6f18940c4c34784d73 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 13 May 2026 23:09:06 +0000 Subject: [PATCH] fix: pad Loading/Error states and tighten Stepper resync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- .../loggedIn/settings/HiddenWordsScreen.kt | 59 +++++---- .../loggedIn/settings/MutedThreadsScreen.kt | 45 ++++--- .../settings/SecurityFiltersScreen.kt | 2 +- .../loggedIn/settings/SecurityListsCommon.kt | 120 +++++++++--------- .../loggedIn/settings/SettingsSectionCard.kt | 19 ++- 5 files changed, 131 insertions(+), 114 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/HiddenWordsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/HiddenWordsScreen.kt index ff64b1022..2500e8078 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/HiddenWordsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/HiddenWordsScreen.kt @@ -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() } + } } } } 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 de6e5eac6..677be4dd3 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 @@ -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() } + } } } } 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 e66f171c3..c040be314 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 @@ -181,7 +181,7 @@ private fun WarnReportsTile(accountViewModel: AccountViewModel) { enabled = warnReports, ) { SettingsStepper( - value = threshold.coerceAtLeast(1), + value = threshold, min = 1, max = 999, enabled = warnReports, 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 8463f3386..c2043f54e 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 @@ -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, ) { 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 10f7edd6e..d190d1880 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 @@ -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,