tasks: consolidate add affordance into a setting (FAB or bottom bar)
All checks were successful
CI / ci (push) Successful in 4m8s

Removes the doubled add idiom (top inline-add bar + FAB). A new "Bottom quick-add bar" setting (default off) picks one affordance per screen: off keeps the floating New task button everywhere; on shows a quick-add field pinned to the bottom of a real list (smart lists keep the button, as they have no single target list).

The bottom field floats on the brightest container tone with a shadow so it stands off the task cards, rides above the keyboard (ime ∪ navigation-bar insets), and drops focus when the IME hides so no stray cursor lingers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-28 22:01:32 +02:00
parent ccc07e86c7
commit 2f5012dc34
6 changed files with 98 additions and 21 deletions

View File

@@ -31,6 +31,12 @@ data class Settings(
val remindersEnabled: Boolean = true, val remindersEnabled: Boolean = true,
/** Whether the inline "add a subtask" row shows on expanded task-list groups. */ /** Whether the inline "add a subtask" row shows on expanded task-list groups. */
val showAddSubtaskRow: Boolean = true, val showAddSubtaskRow: Boolean = true,
/**
* Add affordance for a list: `false` = the floating "New task" button (opens
* the editor); `true` = a quick-add bar pinned to the bottom of a real list.
* Smart lists always use the button (they have no single list to add into).
*/
val bottomAddBar: Boolean = false,
/** /**
* Per-list overrides of [reminderLeadMinutes]: a list present in the map * Per-list overrides of [reminderLeadMinutes]: a list present in the map
* overrides the global default (a null value = no reminder); absent = inherit. * overrides the global default (a null value = no reminder); absent = inherit.
@@ -58,6 +64,7 @@ class SettingsPrefs @Inject constructor(
reminderLeadMinutes = p[REMINDER_LEAD] ?: 0, reminderLeadMinutes = p[REMINDER_LEAD] ?: 0,
remindersEnabled = p[REMINDERS_ENABLED] ?: true, remindersEnabled = p[REMINDERS_ENABLED] ?: true,
showAddSubtaskRow = p[SHOW_ADD_SUBTASK_ROW] ?: true, showAddSubtaskRow = p[SHOW_ADD_SUBTASK_ROW] ?: true,
bottomAddBar = p[BOTTOM_ADD_BAR] ?: false,
perListReminderOverride = reminderCodec.parse(p[LIST_REMINDER_OVERRIDE]), perListReminderOverride = reminderCodec.parse(p[LIST_REMINDER_OVERRIDE]),
defaultEditFields = p[DEFAULT_EDIT_FIELDS].orEmpty() defaultEditFields = p[DEFAULT_EDIT_FIELDS].orEmpty()
.mapNotNull { name -> runCatching { TaskFormField.valueOf(name) }.getOrNull() } .mapNotNull { name -> runCatching { TaskFormField.valueOf(name) }.getOrNull() }
@@ -82,6 +89,8 @@ class SettingsPrefs @Inject constructor(
suspend fun setShowAddSubtaskRow(show: Boolean) = dataStore.edit { it[SHOW_ADD_SUBTASK_ROW] = show } suspend fun setShowAddSubtaskRow(show: Boolean) = dataStore.edit { it[SHOW_ADD_SUBTASK_ROW] = show }
suspend fun setBottomAddBar(enabled: Boolean) = dataStore.edit { it[BOTTOM_ADD_BAR] = enabled }
/** Set (or clear, via [ReminderOverride.Inherit]) a list's reminder override. */ /** Set (or clear, via [ReminderOverride.Inherit]) a list's reminder override. */
suspend fun setListReminderOverride(listId: Long, override: ReminderOverride) = dataStore.edit { p -> suspend fun setListReminderOverride(listId: Long, override: ReminderOverride) = dataStore.edit { p ->
val current = reminderCodec.parse(p[LIST_REMINDER_OVERRIDE]).toMutableMap() val current = reminderCodec.parse(p[LIST_REMINDER_OVERRIDE]).toMutableMap()
@@ -100,6 +109,7 @@ class SettingsPrefs @Inject constructor(
val REMINDER_LEAD = intPreferencesKey("reminder_lead_minutes") val REMINDER_LEAD = intPreferencesKey("reminder_lead_minutes")
val REMINDERS_ENABLED = booleanPreferencesKey("reminders_enabled") val REMINDERS_ENABLED = booleanPreferencesKey("reminders_enabled")
val SHOW_ADD_SUBTASK_ROW = booleanPreferencesKey("show_add_subtask_row") val SHOW_ADD_SUBTASK_ROW = booleanPreferencesKey("show_add_subtask_row")
val BOTTOM_ADD_BAR = booleanPreferencesKey("bottom_add_bar")
val REMINDER_ONBOARDING_DONE = booleanPreferencesKey("reminder_onboarding_done") val REMINDER_ONBOARDING_DONE = booleanPreferencesKey("reminder_onboarding_done")
val LIST_REMINDER_OVERRIDE = stringPreferencesKey("list_reminder_override") val LIST_REMINDER_OVERRIDE = stringPreferencesKey("list_reminder_override")
val DEFAULT_EDIT_FIELDS = stringSetPreferencesKey("default_edit_fields") val DEFAULT_EDIT_FIELDS = stringSetPreferencesKey("default_edit_fields")

View File

@@ -338,7 +338,7 @@ private fun TaskFormScreen(
GroupedRow( GroupedRow(
title = stringResource(R.string.settings_add_subtask_row), title = stringResource(R.string.settings_add_subtask_row),
summary = stringResource(R.string.settings_add_subtask_row_hint), summary = stringResource(R.string.settings_add_subtask_row_hint),
position = Position.Bottom, position = Position.Middle,
trailing = { trailing = {
Switch( Switch(
checked = state.settings.showAddSubtaskRow, checked = state.settings.showAddSubtaskRow,
@@ -347,6 +347,18 @@ private fun TaskFormScreen(
}, },
onClick = { viewModel.setShowAddSubtaskRow(!state.settings.showAddSubtaskRow) }, onClick = { viewModel.setShowAddSubtaskRow(!state.settings.showAddSubtaskRow) },
) )
GroupedRow(
title = stringResource(R.string.settings_bottom_add_bar),
summary = stringResource(R.string.settings_bottom_add_bar_hint),
position = Position.Bottom,
trailing = {
Switch(
checked = state.settings.bottomAddBar,
onCheckedChange = viewModel::setBottomAddBar,
)
},
onClick = { viewModel.setBottomAddBar(!state.settings.bottomAddBar) },
)
} }
if (showDefaultList) { if (showDefaultList) {

View File

@@ -44,6 +44,7 @@ class SettingsViewModel @Inject constructor(
fun setReminderLeadMinutes(minutes: Int) = viewModelScope.launch { prefs.setReminderLeadMinutes(minutes) } fun setReminderLeadMinutes(minutes: Int) = viewModelScope.launch { prefs.setReminderLeadMinutes(minutes) }
fun setRemindersEnabled(enabled: Boolean) = viewModelScope.launch { prefs.setRemindersEnabled(enabled) } fun setRemindersEnabled(enabled: Boolean) = viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
fun setShowAddSubtaskRow(show: Boolean) = viewModelScope.launch { prefs.setShowAddSubtaskRow(show) } fun setShowAddSubtaskRow(show: Boolean) = viewModelScope.launch { prefs.setShowAddSubtaskRow(show) }
fun setBottomAddBar(enabled: Boolean) = viewModelScope.launch { prefs.setBottomAddBar(enabled) }
/** Toggle whether [field] shows by default on a new task's edit form. */ /** Toggle whether [field] shows by default on a new task's edit form. */
fun setFormFieldDefault(field: TaskFormField, enabled: Boolean) = viewModelScope.launch { fun setFormFieldDefault(field: TaskFormField, enabled: Boolean) = viewModelScope.launch {

View File

@@ -21,6 +21,12 @@ import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.isImeVisible
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.union
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@@ -68,6 +74,7 @@ import kotlinx.coroutines.delay
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
@@ -115,7 +122,13 @@ fun TaskListScreen(
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
val listName = (state as? TaskListUiState.Content)?.listName val content = state as? TaskListUiState.Content
val listName = content?.listName
val listId = (filter as? TaskFilter.OfList)?.listId
// 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
// target list) gets the floating "New task" button.
val showBottomAddBar = content?.bottomAddBar == true && listId != null
// Swipe-delete hides the row at once, then a floating "Deleted · Undo" chip // Swipe-delete hides the row at once, then a floating "Deleted · Undo" chip
// gives an undo window: Undo restores it, otherwise the delete commits. A // gives an undo window: Undo restores it, otherwise the delete commits. A
@@ -150,11 +163,18 @@ fun TaskListScreen(
) )
}, },
floatingActionButton = { floatingActionButton = {
if (!showBottomAddBar) {
ExtendedFloatingActionButton( ExtendedFloatingActionButton(
onClick = onNewTask, onClick = onNewTask,
icon = { Icon(Icons.Rounded.Add, contentDescription = null) }, icon = { Icon(Icons.Rounded.Add, contentDescription = null) },
text = { Text(stringResource(R.string.new_task)) }, text = { Text(stringResource(R.string.new_task)) },
) )
}
},
bottomBar = {
if (showBottomAddBar) {
listId?.let { id -> QuickAddBar(onAdd = { title -> viewModel.quickAdd(title, id) }) }
}
}, },
) { inner -> ) { inner ->
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
@@ -277,19 +297,16 @@ private fun TaskListBody(
return if (inList.size == task.subtaskTotal) inList else lazyChildren[task.taskId].orEmpty() return if (inList.size == task.subtaskTotal) inList else lazyChildren[task.taskId].orEmpty()
} }
// When the bottom quick-add bar is shown it already lives in the scaffold's
// bottomBar (so `inner` covers it); otherwise reserve room to clear the FAB.
val bottomBarShown = state.bottomAddBar && listId != null
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues( contentPadding = PaddingValues(
top = inner.calculateTopPadding() + 8.dp, top = inner.calculateTopPadding() + 8.dp,
bottom = inner.calculateBottomPadding() + 96.dp, bottom = inner.calculateBottomPadding() + if (bottomBarShown) 16.dp else 96.dp,
), ),
) { ) {
if (listId != null) {
item(key = "inline-add") {
InlineAdd(onAdd = { title -> viewModel.quickAdd(title, listId) })
}
}
if (state.tasks.isEmpty()) { if (state.tasks.isEmpty()) {
item(key = "empty") { EmptyState(stringResource(R.string.task_list_empty)) } item(key = "empty") { EmptyState(stringResource(R.string.task_list_empty)) }
} }
@@ -409,9 +426,9 @@ private fun TaskListBody(
} }
/** Inline "add a task" — title only, into the current list. */ /** Inline "add a task" — title only, into the current list. */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
private fun InlineAdd(onAdd: (String) -> Unit) { private fun InlineAdd(onAdd: (String) -> Unit, elevated: Boolean = false) {
var text by remember { mutableStateOf("") } var text by remember { mutableStateOf("") }
fun submit() { fun submit() {
if (text.isNotBlank()) { if (text.isNotBlank()) {
@@ -419,10 +436,25 @@ private fun InlineAdd(onAdd: (String) -> Unit) {
text = "" text = ""
} }
} }
// Compose keeps a TextField focused (cursor blinking) even after the keyboard
// is dismissed by a tap outside it — so when the IME hides, drop focus too.
val focusManager = LocalFocusManager.current
val imeVisible = WindowInsets.isImeVisible
LaunchedEffect(imeVisible) {
if (!imeVisible) focusManager.clearFocus()
}
Surface( Surface(
shape = RoundedCornerShape(22.dp), shape = RoundedCornerShape(22.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh, // Floating use (bottom quick-add) steps up to the brightest container tone
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), // — a real step above the surfaceContainerHigh task cards — and lifts off
// the list with a heavier shadow, so it doesn't blend into the rows.
color = if (elevated) {
MaterialTheme.colorScheme.surfaceContainerHighest
} else {
MaterialTheme.colorScheme.surfaceContainerHigh
},
shadowElevation = if (elevated) 8.dp else 0.dp,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
) { ) {
TextField( TextField(
value = text, value = text,
@@ -450,6 +482,23 @@ private fun InlineAdd(onAdd: (String) -> Unit) {
} }
} }
/**
* The bottom quick-add field: [InlineAdd] pinned as the scaffold's bottomBar, but
* with no bar plate of its own — the rounded field floats on the screen background
* (its own shadow lifting it off the list). Rides above the keyboard and clears the
* navigation bar (ime navigation-bar insets).
*/
@Composable
private fun QuickAddBar(onAdd: (String) -> Unit) {
Box(
modifier = Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.ime.union(WindowInsets.navigationBars)),
) {
InlineAdd(onAdd = onAdd, elevated = true)
}
}
/** /**
* One task row: a tonal grouped-card surface (corners from [position], morphing * One task row: a tonal grouped-card surface (corners from [position], morphing
* rounder on press — the app's shape family) wrapped in a swipe box. Swiping * rounder on press — the app's shape family) wrapped in a swipe box. Swiping

View File

@@ -37,6 +37,8 @@ sealed interface TaskListUiState {
val listName: String? = null, val listName: String? = null,
/** Whether the inline "add a subtask" row shows on expanded groups (M5 setting). */ /** Whether the inline "add a subtask" row shows on expanded groups (M5 setting). */
val showAddSubtaskRow: Boolean = true, val showAddSubtaskRow: Boolean = true,
/** Whether a real list uses the bottom quick-add bar instead of the FAB. */
val bottomAddBar: Boolean = false,
) : TaskListUiState ) : TaskListUiState
} }
@@ -71,13 +73,14 @@ class TaskListViewModel @Inject constructor(
} }
// Drop rows pending an undoable delete so the row vanishes on swipe // Drop rows pending an undoable delete so the row vanishes on swipe
// while the actual provider delete waits for the snackbar to commit, // while the actual provider delete waits for the snackbar to commit,
// and fold in the live "show add-subtask row" setting. // and fold in the live UI settings (add-subtask row, bottom add bar).
val showAddSub = settingsPrefs.settings.map { it.showAddSubtaskRow } val uiPrefs = settingsPrefs.settings.map { it.showAddSubtaskRow to it.bottomAddBar }
combine(content, pendingDeletes, showAddSub) { st, pending, showRow -> combine(content, pendingDeletes, uiPrefs) { st, pending, (showRow, bottomBar) ->
if (st is TaskListUiState.Content) { if (st is TaskListUiState.Content) {
st.copy( st.copy(
tasks = st.tasks.filter { it.taskId !in pending }, tasks = st.tasks.filter { it.taskId !in pending },
showAddSubtaskRow = showRow, showAddSubtaskRow = showRow,
bottomAddBar = bottomBar,
) )
} else { } else {
st st

View File

@@ -198,6 +198,8 @@
<string name="settings_default_list_first">First available list</string> <string name="settings_default_list_first">First available list</string>
<string name="settings_add_subtask_row">Show \"add a subtask\" row</string> <string name="settings_add_subtask_row">Show \"add a subtask\" row</string>
<string name="settings_add_subtask_row_hint">An add field at the end of an expanded task\'s subtasks</string> <string name="settings_add_subtask_row_hint">An add field at the end of an expanded task\'s subtasks</string>
<string name="settings_bottom_add_bar">Bottom quick-add bar</string>
<string name="settings_bottom_add_bar_hint">Add tasks from a bar pinned to the bottom of a list, instead of the floating button</string>
<!-- Reminder lead times (custom amounts) --> <!-- Reminder lead times (custom amounts) -->
<plurals name="reminder_minutes"> <plurals name="reminder_minutes">