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