Delete one session without putting the rest through loading

Pressing Delete refetched the whole list on success, so every other row
went back through its loading state and the reader got a blank screen
for the length of a round trip -- to report on something that was never
in doubt. Now the row being deleted fades, says so where its status
goes, and stops responding to taps; when the server answers, that one
row is removed and nothing else moves.

A refusal keeps the row, because it is still there: the server answered
and said no, so the session it said no about is exactly as it was, and
the error goes on its own card as it already did.

Faded rather than removed on the way out, deliberately. Taking the row
away when Delete is pressed is a promise about a request that has not
been answered, and putting it back when the server refuses is worse than
never having taken it away.

Looked at rather than reasoned about: the in-flight state lasts
milliseconds against a local server, so I slowed the delete route to
four seconds, watched the faded row and its spinner, watched it removed
on success, then killed the server and watched a refusal leave the row
in place with the reason on it.
This commit is contained in:
iris committed 2026-08-29 16:11:18 -04:00
1 parent d3fff3d229
commit cae04c2559
1 file changed
+66 -3
@@ -29,6 +29,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -67,6 +68,11 @@ fun SessionListScreen(
// fetched next.
var deleteErrors by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
// Which sessions have a delete in flight. A set of ids rather than a flag on the row,
// because the rows are rebuilt from whatever the server last said and this belongs to the
// request rather than to the session.
var deleting by remember { mutableStateOf<Set<String>>(emptySet()) }
fun refresh() {
listState = LoadState.Loading
scope.launch {
@@ -138,6 +144,7 @@ fun SessionListScreen(
SessionCard(
session = session,
error = deleteErrors[session.id],
deleting = session.id in deleting,
onOpen = { onOpen(session) },
onLongPress = { confirmingDelete = session },
)
@@ -177,13 +184,32 @@ fun SessionListScreen(
TextButton(
onClick = {
confirmingDelete = null
// Marked here rather than after the request returns: the row has to say
// something is happening to it from the moment it is asked for, which
// is the whole of what this state is for.
deleting = deleting + session.id
deleteErrors = deleteErrors - session.id
scope.launch {
try {
withContext(Dispatchers.IO) { deleteSession(settings, session.id) }
refresh()
// Only this row, and only what changed. Refetching the list
// instead put every other session back through loading and
// handed the reader an empty screen -- to report on something
// that was never in doubt.
val loaded = listState
if (loaded is LoadState.Loaded) {
listState =
LoadState.Loaded(
loaded.value.filterNot { it.id == session.id }
)
}
} catch (e: ApiException) {
// Kept, because it is still there: the server refused, so the
// session it refused about is exactly as it was.
deleteErrors =
deleteErrors + (session.id to (e.message ?: "Delete failed"))
} finally {
deleting = deleting - session.id
}
}
}
@@ -204,10 +230,25 @@ private fun SessionCard(
session: SessionSummary,
/** What went wrong acting on *this* session, if anything has. */
error: String?,
/**
* Whether this session is being deleted right now.
*
* Faded and inert while it is, which says the row is on its way out without claiming it has
* gone: a row removed the moment Delete is pressed is a promise about a request that has not
* been answered yet, and putting it back when the server refuses is worse than never having
* taken it away.
*/
deleting: Boolean,
onOpen: () -> Unit,
onLongPress: () -> Unit,
) {
Card(Modifier.fillMaxWidth().combinedClickable(onClick = onOpen, onLongClick = onLongPress)) {
Card(
Modifier.fillMaxWidth()
.alpha(if (deleting) 0.45f else 1f)
// Not just faded: a card that still opens a session it is deleting is a race the
// reader can start by tapping.
.combinedClickable(enabled = !deleting, onClick = onOpen, onLongClick = onLongPress)
) {
Column(Modifier.padding(16.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -218,7 +259,9 @@ private fun SessionCard(
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
StatusText(session.status)
// In the status's own place, because that is what it is: what this session is
// doing now, which is being deleted.
if (deleting) DeletingMark() else StatusText(session.status)
}
Spacer(Modifier.height(4.dp))
Row(modifier = Modifier.fillMaxWidth()) {
@@ -255,6 +298,26 @@ private fun SessionCard(
}
}
/** A session on its way out: the same shape as a status, because that is the slot it fills. */
@Composable
private fun DeletingMark() {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(
modifier = Modifier.width(14.dp).height(14.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(6.dp))
// Not in the error colour, though it is destructive: red here means something went
// wrong on its own, and this is going exactly as asked.
Text(
"deleting",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
fun StatusText(status: String) {
val (label, color) =