diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt index 9816659..732633a 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt @@ -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>(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>(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) =