From 2f5012dc34a7d068f237439f67cb83dfe5d4b354 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 28 Jun 2026 22:01:32 +0200 Subject: [PATCH] tasks: consolidate add affordance into a setting (FAB or bottom bar) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../agendula/data/prefs/SettingsPrefs.kt | 10 +++ .../agendula/ui/settings/SettingsScreen.kt | 14 +++- .../agendula/ui/settings/SettingsViewModel.kt | 1 + .../agendula/ui/tasklist/TaskListScreen.kt | 83 +++++++++++++++---- .../agendula/ui/tasklist/TaskListViewModel.kt | 9 +- app/src/main/res/values/strings.xml | 2 + 6 files changed, 98 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt index 2d31085..0f60923 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt @@ -31,6 +31,12 @@ data class Settings( val remindersEnabled: Boolean = true, /** Whether the inline "add a subtask" row shows on expanded task-list groups. */ 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 * overrides the global default (a null value = no reminder); absent = inherit. @@ -58,6 +64,7 @@ class SettingsPrefs @Inject constructor( reminderLeadMinutes = p[REMINDER_LEAD] ?: 0, remindersEnabled = p[REMINDERS_ENABLED] ?: true, showAddSubtaskRow = p[SHOW_ADD_SUBTASK_ROW] ?: true, + bottomAddBar = p[BOTTOM_ADD_BAR] ?: false, perListReminderOverride = reminderCodec.parse(p[LIST_REMINDER_OVERRIDE]), defaultEditFields = p[DEFAULT_EDIT_FIELDS].orEmpty() .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 setBottomAddBar(enabled: Boolean) = dataStore.edit { it[BOTTOM_ADD_BAR] = enabled } + /** Set (or clear, via [ReminderOverride.Inherit]) a list's reminder override. */ suspend fun setListReminderOverride(listId: Long, override: ReminderOverride) = dataStore.edit { p -> 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 REMINDERS_ENABLED = booleanPreferencesKey("reminders_enabled") 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 LIST_REMINDER_OVERRIDE = stringPreferencesKey("list_reminder_override") val DEFAULT_EDIT_FIELDS = stringSetPreferencesKey("default_edit_fields") diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsScreen.kt index 4e3e661..3f518a4 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsScreen.kt @@ -338,7 +338,7 @@ private fun TaskFormScreen( GroupedRow( title = stringResource(R.string.settings_add_subtask_row), summary = stringResource(R.string.settings_add_subtask_row_hint), - position = Position.Bottom, + position = Position.Middle, trailing = { Switch( checked = state.settings.showAddSubtaskRow, @@ -347,6 +347,18 @@ private fun TaskFormScreen( }, 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) { diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt index 4737eb2..aa2fb24 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt @@ -44,6 +44,7 @@ class SettingsViewModel @Inject constructor( fun setReminderLeadMinutes(minutes: Int) = viewModelScope.launch { prefs.setReminderLeadMinutes(minutes) } fun setRemindersEnabled(enabled: Boolean) = viewModelScope.launch { prefs.setRemindersEnabled(enabled) } 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. */ fun setFormFieldDefault(field: TaskFormField, enabled: Boolean) = viewModelScope.launch { diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt index b49f35b..8cff056 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt @@ -21,6 +21,12 @@ import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row 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.fillMaxWidth import androidx.compose.foundation.layout.height @@ -68,6 +74,7 @@ import kotlinx.coroutines.delay import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor @@ -115,7 +122,13 @@ fun TaskListScreen( ) { val state by viewModel.state.collectAsStateWithLifecycle() 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 // gives an undo window: Undo restores it, otherwise the delete commits. A @@ -150,11 +163,18 @@ fun TaskListScreen( ) }, floatingActionButton = { - ExtendedFloatingActionButton( - onClick = onNewTask, - icon = { Icon(Icons.Rounded.Add, contentDescription = null) }, - text = { Text(stringResource(R.string.new_task)) }, - ) + if (!showBottomAddBar) { + ExtendedFloatingActionButton( + onClick = onNewTask, + icon = { Icon(Icons.Rounded.Add, contentDescription = null) }, + text = { Text(stringResource(R.string.new_task)) }, + ) + } + }, + bottomBar = { + if (showBottomAddBar) { + listId?.let { id -> QuickAddBar(onAdd = { title -> viewModel.quickAdd(title, id) }) } + } }, ) { inner -> Box(modifier = Modifier.fillMaxSize()) { @@ -277,19 +297,16 @@ private fun TaskListBody( 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( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues( 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()) { 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. */ -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable -private fun InlineAdd(onAdd: (String) -> Unit) { +private fun InlineAdd(onAdd: (String) -> Unit, elevated: Boolean = false) { var text by remember { mutableStateOf("") } fun submit() { if (text.isNotBlank()) { @@ -419,10 +436,25 @@ private fun InlineAdd(onAdd: (String) -> Unit) { 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( shape = RoundedCornerShape(22.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh, - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), + // Floating use (bottom quick-add) steps up to the brightest container tone + // — 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( 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 * rounder on press — the app's shape family) wrapped in a swipe box. Swiping diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt index ef233f9..3120e14 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt @@ -37,6 +37,8 @@ sealed interface TaskListUiState { val listName: String? = null, /** Whether the inline "add a subtask" row shows on expanded groups (M5 setting). */ val showAddSubtaskRow: Boolean = true, + /** Whether a real list uses the bottom quick-add bar instead of the FAB. */ + val bottomAddBar: Boolean = false, ) : TaskListUiState } @@ -71,13 +73,14 @@ class TaskListViewModel @Inject constructor( } // Drop rows pending an undoable delete so the row vanishes on swipe // while the actual provider delete waits for the snackbar to commit, - // and fold in the live "show add-subtask row" setting. - val showAddSub = settingsPrefs.settings.map { it.showAddSubtaskRow } - combine(content, pendingDeletes, showAddSub) { st, pending, showRow -> + // and fold in the live UI settings (add-subtask row, bottom add bar). + val uiPrefs = settingsPrefs.settings.map { it.showAddSubtaskRow to it.bottomAddBar } + combine(content, pendingDeletes, uiPrefs) { st, pending, (showRow, bottomBar) -> if (st is TaskListUiState.Content) { st.copy( tasks = st.tasks.filter { it.taskId !in pending }, showAddSubtaskRow = showRow, + bottomAddBar = bottomBar, ) } else { st diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6e4f52d..4bdc865 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -198,6 +198,8 @@ First available list Show \"add a subtask\" row An add field at the end of an expanded task\'s subtasks + Bottom quick-add bar + Add tasks from a bar pinned to the bottom of a list, instead of the floating button