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:
+31
-28
@@ -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() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-21
@@ -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() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -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,
|
||||||
|
|||||||
+62
-58
@@ -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,
|
||||||
) {
|
) {
|
||||||
|
|||||||
+13
-6
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user