feat: allow editing synced lists, not just device-only ones

The edit pencil was gated to local lists even though rename/delete already
worked end to end for synced collections. Read-only shares keep the icon
but show it disabled, with a hint on tap explaining why.

Refs #29
This commit is contained in:
2026-10-05 18:49:08 +02:00
parent d81e99084e
commit a4fbbf43c6
@@ -52,6 +52,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumTopAppBar import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
@@ -139,9 +140,12 @@ fun TaskListScreen(
val list = content?.list val list = content?.list
val listName = list?.name val listName = list?.name
val listId = (filter as? TaskFilter.OfList)?.listId val listId = (filter as? TaskFilter.OfList)?.listId
// Editing is offered for a device-only list. A collection that belongs to an // Editing is offered for any writable list, device or synced — renaming and
// account is the server's to rename or remove, not ours. // recolouring a synced collection is a server write the same sheet already
// handles. A read-only share keeps the icon but disables it: tapping it
// explains why rather than the button silently not being there.
var editingList by rememberSaveable { mutableStateOf(false) } var editingList by rememberSaveable { mutableStateOf(false) }
var readOnlyHint by rememberSaveable { mutableStateOf(false) }
val listWriteFailure by viewModel.listWriteFailure.collectAsStateWithLifecycle() val listWriteFailure by viewModel.listWriteFailure.collectAsStateWithLifecycle()
val listDeleted by viewModel.listDeleted.collectAsStateWithLifecycle() val listDeleted by viewModel.listDeleted.collectAsStateWithLifecycle()
// The list this screen is about is gone; there is nothing left to show. // The list this screen is about is gone; there is nothing left to show.
@@ -152,6 +156,12 @@ fun TaskListScreen(
viewModel.clearListWriteFailure() viewModel.clearListWriteFailure()
} }
} }
LaunchedEffect(readOnlyHint) {
if (readOnlyHint) {
delay(4_000)
readOnlyHint = false
}
}
// One add affordance, never two: a real list with the setting on gets a pinned // One add affordance, never two: a real list with the setting on gets a pinned
// bottom quick-add bar; everything else (incl. smart lists, which have no single // bottom quick-add bar; everything else (incl. smart lists, which have no single
// target list) gets the floating "New task" button. // target list) gets the floating "New task" button.
@@ -181,11 +191,20 @@ fun TaskListScreen(
IconButton(onClick = { showSort = true }, enabled = content != null) { IconButton(onClick = { showSort = true }, enabled = content != null) {
Icon(Icons.AutoMirrored.Rounded.Sort, contentDescription = stringResource(R.string.sort_title)) Icon(Icons.AutoMirrored.Rounded.Sort, contentDescription = stringResource(R.string.sort_title))
} }
if (list != null && list.isLocal) { if (list != null) {
IconButton(onClick = { editingList = true }) { IconButton(
onClick = {
if (list.isReadOnly) readOnlyHint = true else editingList = true
},
) {
Icon( Icon(
Icons.Rounded.Edit, Icons.Rounded.Edit,
contentDescription = stringResource(R.string.list_edit_title), contentDescription = stringResource(R.string.list_edit_title),
tint = if (list.isReadOnly) {
LocalContentColor.current.copy(alpha = 0.38f)
} else {
LocalContentColor.current
},
) )
} }
} }
@@ -230,23 +249,28 @@ fun TaskListScreen(
.height(SnackChipHeight), .height(SnackChipHeight),
contentAlignment = Alignment.CenterStart, contentAlignment = Alignment.CenterStart,
) { ) {
// One chip, one anchor: the undo receipt takes precedence, and a // One chip, one anchor, in priority order: the undo receipt first,
// refused list write reports itself once the undo window is clear. // then a refused list write once the undo window is clear, then the
// read-only edit hint — the quietest of the three, so it never
// outranks something the other two are actually reporting.
val failure = listWriteFailure val failure = listWriteFailure
if (undo != null || failure == null) { when {
SnackChip( undo != null -> SnackChip(
visible = undo != null, visible = true,
message = stringResource( message = stringResource(
if (undo is UndoReceipt.Completed) R.string.task_completed else R.string.task_deleted, if (undo is UndoReceipt.Completed) R.string.task_completed else R.string.task_deleted,
), ),
actionLabel = stringResource(R.string.undo), actionLabel = stringResource(R.string.undo),
onAction = viewModel::undo, onAction = viewModel::undo,
) )
} else { failure != null -> SnackChip(
SnackChip(
visible = true, visible = true,
message = stringResource(listWriteFailureMessage(failure)), message = stringResource(listWriteFailureMessage(failure)),
) )
else -> SnackChip(
visible = readOnlyHint,
message = stringResource(R.string.list_read_only),
)
} }
} }
} }