diff --git a/CHANGELOG.md b/CHANGELOG.md
index 469d17f..21827f9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,24 @@ the source of truth for version codes (see Calendula's `docs/RELEASING.md`).
## [Unreleased]
+### Added
+- M5 reminders onboarding & polish: a one-time reminder onboarding step after the
+ provider grant explains that Floret delivers due reminders itself and requests
+ `POST_NOTIFICATIONS` (API 33+). A new **Settings** screen (gear on the lists
+ overview), structured after Calendula as a category hub with sliding sub-screens
+ (`CollapsingScaffold` + grouped category rows + full-screen `OptionPicker`):
+ an About card, **Appearance** (theme, dynamic colour), **Task form** (which
+ optional edit-form fields show by default, default list, the add-a-subtask-row
+ opt-out), and **Reminders** (a master enable switch that re-requests the
+ notification permission, a "when to remind" default offset, and — on Android 12
+ only — an exact-alarm status row that deep-links to system settings).
+- The reminders master switch gates the whole engine: turning it off clears every
+ scheduled alarm and suppresses any that fire.
+- Expressive top-bar actions: a reusable `ShapedActionButton` wraps an action icon
+ in a tonal M3 Expressive `MaterialShapes` container (the settings entry uses the
+ faceted Gem shape, tertiary tones), with a small press scale/rotate flourish.
+ Future top-bar actions pick their own shape from the `ActionShapes` registry.
+
## [0.2.0] - 2026-06-27
### Added
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index a540cfb..74ed6f9 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -36,6 +36,7 @@
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
+ android:localeConfig="@xml/locales_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Floret"
@@ -76,6 +77,17 @@
+
+
+
+
+
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/floret/data/prefs/SettingsPrefs.kt
index 1d89041..01f9c44 100644
--- a/app/src/main/java/de/jeanlucmakiola/floret/data/prefs/SettingsPrefs.kt
+++ b/app/src/main/java/de/jeanlucmakiola/floret/data/prefs/SettingsPrefs.kt
@@ -16,6 +16,16 @@ import javax.inject.Singleton
enum class ThemeMode { SYSTEM, LIGHT, DARK }
+/** A list's override of the default reminder lead time (mirrors Calendula's per-calendar override). */
+sealed interface ListReminderOverride {
+ /** No override — the list uses the global default. */
+ data object Inherit : ListReminderOverride
+ /** Explicit "no reminder" for this list, regardless of the global default. */
+ data object None : ListReminderOverride
+ /** A specific lead time in minutes before due. */
+ data class Minutes(val minutes: Int) : ListReminderOverride
+}
+
data class Settings(
val themeMode: ThemeMode = ThemeMode.SYSTEM,
val dynamicColor: Boolean = true,
@@ -23,9 +33,26 @@ data class Settings(
val defaultListId: Long? = null,
/** Default minutes before due to remind; 0 = at due time. */
val reminderLeadMinutes: Int = 0,
+ /** Master switch for due reminders; off clears every scheduled alarm. */
+ val remindersEnabled: Boolean = true,
+ /** Whether the inline "add a subtask" row shows on expanded task-list groups. */
+ val showAddSubtaskRow: Boolean = true,
+ /**
+ * Per-list overrides of [reminderLeadMinutes]: a list present in the map
+ * overrides the global default (a null value = no reminder); absent = inherit.
+ */
+ val perListReminderOverride: Map = emptyMap(),
/** Optional edit-form fields shown by default; the rest sit behind "More fields". */
val defaultEditFields: Set = emptySet(),
-)
+) {
+ /** The lead time for a task in [listId]: its override if set, else the global default. */
+ fun reminderLeadFor(listId: Long): Int? =
+ if (perListReminderOverride.containsKey(listId)) {
+ perListReminderOverride[listId] // null = explicitly no reminder
+ } else {
+ reminderLeadMinutes
+ }
+}
/** App preferences, backed by DataStore. Mirrors Calendula's prefs shape. */
@Singleton
@@ -39,6 +66,9 @@ class SettingsPrefs @Inject constructor(
dynamicColor = p[DYNAMIC_COLOR] ?: true,
defaultListId = p[DEFAULT_LIST_ID]?.takeIf { it > 0 },
reminderLeadMinutes = p[REMINDER_LEAD] ?: 0,
+ remindersEnabled = p[REMINDERS_ENABLED] ?: true,
+ showAddSubtaskRow = p[SHOW_ADD_SUBTASK_ROW] ?: true,
+ perListReminderOverride = parseReminderOverrides(p[LIST_REMINDER_OVERRIDE]),
defaultEditFields = p[DEFAULT_EDIT_FIELDS].orEmpty()
.mapNotNull { name -> runCatching { TaskFormField.valueOf(name) }.getOrNull() }
.toSet(),
@@ -53,6 +83,26 @@ class SettingsPrefs @Inject constructor(
suspend fun setReminderLeadMinutes(minutes: Int) = dataStore.edit { it[REMINDER_LEAD] = minutes }
+ /** One-time reminder onboarding gate; false until the step has been shown. */
+ val reminderOnboardingDone: Flow = dataStore.data.map { it[REMINDER_ONBOARDING_DONE] ?: false }
+
+ suspend fun setReminderOnboardingDone() = dataStore.edit { it[REMINDER_ONBOARDING_DONE] = true }
+
+ suspend fun setRemindersEnabled(enabled: Boolean) = dataStore.edit { it[REMINDERS_ENABLED] = enabled }
+
+ suspend fun setShowAddSubtaskRow(show: Boolean) = dataStore.edit { it[SHOW_ADD_SUBTASK_ROW] = show }
+
+ /** Set (or clear, via [ListReminderOverride.Inherit]) a list's reminder override. */
+ suspend fun setListReminderOverride(listId: Long, override: ListReminderOverride) = dataStore.edit { p ->
+ val current = parseReminderOverrides(p[LIST_REMINDER_OVERRIDE]).toMutableMap()
+ when (override) {
+ ListReminderOverride.Inherit -> current.remove(listId)
+ ListReminderOverride.None -> current[listId] = null
+ is ListReminderOverride.Minutes -> current[listId] = override.minutes
+ }
+ p[LIST_REMINDER_OVERRIDE] = serializeReminderOverrides(current)
+ }
+
suspend fun setDefaultEditFields(fields: Set) = dataStore.edit {
it[DEFAULT_EDIT_FIELDS] = fields.mapTo(mutableSetOf()) { field -> field.name }
}
@@ -62,6 +112,28 @@ class SettingsPrefs @Inject constructor(
val DYNAMIC_COLOR = booleanPreferencesKey("dynamic_color")
val DEFAULT_LIST_ID = longPreferencesKey("default_list_id")
val REMINDER_LEAD = intPreferencesKey("reminder_lead_minutes")
+ val REMINDERS_ENABLED = booleanPreferencesKey("reminders_enabled")
+ val SHOW_ADD_SUBTASK_ROW = booleanPreferencesKey("show_add_subtask_row")
+ val REMINDER_ONBOARDING_DONE = booleanPreferencesKey("reminder_onboarding_done")
+ val LIST_REMINDER_OVERRIDE = stringPreferencesKey("list_reminder_override")
val DEFAULT_EDIT_FIELDS = stringSetPreferencesKey("default_edit_fields")
}
}
+
+private const val OVERRIDE_ENTRY_SEP = ";"
+private const val OVERRIDE_KV_SEP = ":"
+private const val OVERRIDE_NONE = "none"
+
+/** Decode "id:minutes;id:none;…" into a map (value null = explicit no-reminder). */
+private fun parseReminderOverrides(stored: String?): Map {
+ if (stored.isNullOrBlank()) return emptyMap()
+ return stored.split(OVERRIDE_ENTRY_SEP).mapNotNull { entry ->
+ val parts = entry.split(OVERRIDE_KV_SEP).takeIf { it.size == 2 } ?: return@mapNotNull null
+ val id = parts[0].toLongOrNull() ?: return@mapNotNull null
+ val value = if (parts[1] == OVERRIDE_NONE) null else parts[1].toIntOrNull() ?: return@mapNotNull null
+ id to value
+ }.toMap()
+}
+
+private fun serializeReminderOverrides(map: Map): String =
+ map.entries.joinToString(OVERRIDE_ENTRY_SEP) { (id, minutes) -> "$id$OVERRIDE_KV_SEP${minutes ?: OVERRIDE_NONE}" }
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/data/reminders/DueReminderReceiver.kt b/app/src/main/java/de/jeanlucmakiola/floret/data/reminders/DueReminderReceiver.kt
index 5307a31..e3bfd44 100644
--- a/app/src/main/java/de/jeanlucmakiola/floret/data/reminders/DueReminderReceiver.kt
+++ b/app/src/main/java/de/jeanlucmakiola/floret/data/reminders/DueReminderReceiver.kt
@@ -4,10 +4,12 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint
+import de.jeanlucmakiola.floret.data.prefs.SettingsPrefs
import de.jeanlucmakiola.floret.data.tasks.TasksDataSource
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -21,6 +23,7 @@ class DueReminderReceiver : BroadcastReceiver() {
@Inject lateinit var dataSource: TasksDataSource
@Inject lateinit var notifier: TaskNotifier
+ @Inject lateinit var settingsPrefs: SettingsPrefs
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@@ -30,6 +33,7 @@ class DueReminderReceiver : BroadcastReceiver() {
val pending = goAsync()
scope.launch {
try {
+ if (!settingsPrefs.settings.first().remindersEnabled) return@launch
val task = runCatching { dataSource.task(taskId) }.getOrNull()
if (task != null && !task.isClosed) notifier.postDue(task)
} finally {
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/data/reminders/ReminderScheduler.kt b/app/src/main/java/de/jeanlucmakiola/floret/data/reminders/ReminderScheduler.kt
index 9952afc..602d63d 100644
--- a/app/src/main/java/de/jeanlucmakiola/floret/data/reminders/ReminderScheduler.kt
+++ b/app/src/main/java/de/jeanlucmakiola/floret/data/reminders/ReminderScheduler.kt
@@ -33,11 +33,11 @@ class ReminderScheduler @Inject constructor(
) {
suspend fun sync() = withContext(io) {
val provider = providerResolver.resolve()
- if (provider == null || !providerResolver.hasPermission(provider)) {
+ val settings = settingsPrefs.settings.first()
+ if (provider == null || !providerResolver.hasPermission(provider) || !settings.remindersEnabled) {
clearAll()
return@withContext
}
- val lead = settingsPrefs.settings.first().reminderLeadMinutes.coerceAtLeast(0)
val now = System.currentTimeMillis()
val horizon = now + WINDOW_MS
val tasks = runCatching { dataSource.tasks(TaskQuery(includeCompleted = false)) }
@@ -45,7 +45,13 @@ class ReminderScheduler @Inject constructor(
val desired = tasks
.filter { !it.isClosed && it.due != null }
- .associate { it.taskId to (it.due!!.toEpochMilliseconds() - lead * 60_000L) }
+ .mapNotNull { task ->
+ // The task's list may override the global lead, or opt out entirely
+ // (override = null), in which case it gets no reminder at all.
+ val lead = settings.reminderLeadFor(task.listId) ?: return@mapNotNull null
+ task.taskId to (task.due!!.toEpochMilliseconds() - lead.coerceAtLeast(0) * 60_000L)
+ }
+ .toMap()
.filterValues { it in now..horizon }
val previous = store.all()
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/RootScreen.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/RootScreen.kt
index a967257..49290c2 100644
--- a/app/src/main/java/de/jeanlucmakiola/floret/ui/RootScreen.kt
+++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/RootScreen.kt
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.floret.ui
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
@@ -21,6 +22,8 @@ import de.jeanlucmakiola.floret.R
import de.jeanlucmakiola.floret.data.tasks.ProviderStatus
import de.jeanlucmakiola.floret.ui.navigation.FloretNavHost
import de.jeanlucmakiola.floret.ui.permission.PermissionViewModel
+import de.jeanlucmakiola.floret.ui.permission.ReminderOnboardingScreen
+import de.jeanlucmakiola.floret.ui.permission.ReminderOnboardingViewModel
/**
* App root: gates on the tasks-provider permission, then hands off to
@@ -49,7 +52,31 @@ fun RootScreen(
action = stringResource(R.string.onboarding_permission_button),
onAction = { launcher.launch(permission.permissionsToRequest.toTypedArray()) },
)
- ProviderStatus.READY -> FloretNavHost(modifier = modifier)
+ ProviderStatus.READY -> ReadyGate(modifier = modifier)
+ }
+}
+
+/**
+ * Second one-time gate after the provider grant: the reminder onboarding step.
+ * [ReminderOnboardingViewModel.onboardingDone] is null until DataStore's first
+ * emission — render nothing for that frame rather than flash the wrong screen.
+ * A cross-fade eases the hand-off to the app instead of snapping.
+ */
+@Composable
+private fun ReadyGate(
+ modifier: Modifier = Modifier,
+ onboardingViewModel: ReminderOnboardingViewModel = hiltViewModel(),
+) {
+ val done by onboardingViewModel.onboardingDone.collectAsStateWithLifecycle()
+ Crossfade(targetState = done, label = "reminderOnboardingGate") { state ->
+ when (state) {
+ true -> FloretNavHost(modifier = modifier)
+ false -> ReminderOnboardingScreen(
+ onFinished = onboardingViewModel::finish,
+ modifier = modifier,
+ )
+ null -> {}
+ }
}
}
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/common/CollapsingScaffold.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/common/CollapsingScaffold.kt
new file mode 100644
index 0000000..e51999f
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/common/CollapsingScaffold.kt
@@ -0,0 +1,84 @@
+package de.jeanlucmakiola.floret.ui.common
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.consumeWindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.ArrowBack
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LargeTopAppBar
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.rememberTopAppBarState
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import de.jeanlucmakiola.floret.R
+
+/**
+ * Shared collapsing scaffold for the settings hub, its sub-screens and the
+ * full-screen pickers: a [LargeTopAppBar] that collapses on scroll, a back
+ * button, and a vertically scrolling content column. Ported from Calendula so
+ * the two apps' settings read as one family. The content lays out the connected
+ * grouped rows.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun CollapsingScaffold(
+ title: String,
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier,
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ val scrollBehavior =
+ TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
+ Scaffold(
+ modifier = modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.surface)
+ .nestedScroll(scrollBehavior.nestedScrollConnection),
+ topBar = {
+ LargeTopAppBar(
+ title = { Text(title) },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(
+ Icons.AutoMirrored.Rounded.ArrowBack,
+ contentDescription = stringResource(R.string.back),
+ )
+ }
+ },
+ scrollBehavior = scrollBehavior,
+ colors = TopAppBarDefaults.largeTopAppBarColors(
+ scrolledContainerColor = MaterialTheme.colorScheme.surface,
+ ),
+ )
+ },
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier
+ .padding(innerPadding)
+ // Consume the scaffold's bar insets so imePadding adds only the
+ // keyboard height beyond them (max, not sum).
+ .consumeWindowInsets(innerPadding)
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.surface)
+ .imePadding()
+ .verticalScroll(rememberScrollState())
+ .padding(top = 8.dp, bottom = 24.dp),
+ content = content,
+ )
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/common/OptionPicker.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/common/OptionPicker.kt
new file mode 100644
index 0000000..a5746bf
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/common/OptionPicker.kt
@@ -0,0 +1,79 @@
+package de.jeanlucmakiola.floret.ui.common
+
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Check
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+
+/**
+ * Shared full-screen scaffold for selection pickers: a full-bleed [Dialog] that
+ * reuses [CollapsingScaffold] (collapsing title + back), so a picker is visually
+ * identical to a settings sub-page and uses the full width. [content] places the
+ * connected grouped rows; selecting one calls [onDismiss]. Ported from Calendula.
+ */
+@Composable
+fun FullScreenPicker(
+ title: String,
+ onDismiss: () -> Unit,
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ Dialog(
+ onDismissRequest = onDismiss,
+ properties = DialogProperties(
+ usePlatformDefaultWidth = false,
+ decorFitsSystemWindows = false,
+ ),
+ ) {
+ CollapsingScaffold(title = title, onBack = onDismiss, content = content)
+ }
+}
+
+/**
+ * General single-select picker, full-screen: each option is a connected grouped
+ * row and the current one carries a check. The drop-in for the family's option
+ * dialogs (theme, default list, reminder offset, …).
+ */
+@Composable
+fun OptionPicker(
+ title: String,
+ options: List,
+ selected: T,
+ label: @Composable (T) -> String,
+ onSelect: (T) -> Unit,
+ onDismiss: () -> Unit,
+ leading: (@Composable (T) -> Unit)? = null,
+) {
+ FullScreenPicker(title = title, onDismiss = onDismiss) {
+ options.forEachIndexed { index, option ->
+ val isSelected = option == selected
+ GroupedRow(
+ title = label(option),
+ position = positionOf(index, options.size),
+ selected = isSelected,
+ leading = leading?.let { { it(option) } },
+ trailing = if (isSelected) {
+ { SelectedCheck() }
+ } else {
+ null
+ },
+ onClick = {
+ onSelect(option)
+ onDismiss()
+ },
+ )
+ }
+ }
+}
+
+@Composable
+private fun SelectedCheck() {
+ Icon(
+ imageVector = Icons.Rounded.Check,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ )
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/common/ReminderFormatting.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/common/ReminderFormatting.kt
new file mode 100644
index 0000000..4640c16
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/common/ReminderFormatting.kt
@@ -0,0 +1,44 @@
+package de.jeanlucmakiola.floret.ui.common
+
+import androidx.annotation.StringRes
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.pluralStringResource
+import androidx.compose.ui.res.stringResource
+import de.jeanlucmakiola.floret.R
+
+/** Common reminder lead times offered as quick picks in the reminder pickers. */
+val REMINDER_PRESETS = listOf(0, 5, 10, 30, 60, 1_440)
+
+/** The unit of a custom reminder lead time; [minutesFactor] converts to minutes. */
+enum class ReminderUnit(val minutesFactor: Int) {
+ Minutes(1),
+ Hours(60),
+ Days(1_440),
+ Weeks(10_080),
+}
+
+@StringRes
+fun reminderUnitLabel(unit: ReminderUnit): Int = when (unit) {
+ ReminderUnit.Minutes -> R.string.reminder_unit_minutes
+ ReminderUnit.Hours -> R.string.reminder_unit_hours
+ ReminderUnit.Days -> R.string.reminder_unit_days
+ ReminderUnit.Weeks -> R.string.reminder_unit_weeks
+}
+
+/**
+ * Humanise a reminder lead time (minutes before due) into one line: "At time of
+ * task" (0), "10 minutes before", "1 hour before", … Shared by the reminder
+ * pickers and the settings summaries so the wording never drifts. Ported from
+ * Calendula.
+ */
+@Composable
+fun reminderLeadTimeLabel(minutes: Int): String = when {
+ minutes <= 0 -> stringResource(R.string.reminder_at_due)
+ minutes % 10_080 == 0 ->
+ pluralStringResource(R.plurals.reminder_weeks, minutes / 10_080, minutes / 10_080)
+ minutes % 1_440 == 0 ->
+ pluralStringResource(R.plurals.reminder_days, minutes / 1_440, minutes / 1_440)
+ minutes % 60 == 0 ->
+ pluralStringResource(R.plurals.reminder_hours, minutes / 60, minutes / 60)
+ else -> pluralStringResource(R.plurals.reminder_minutes, minutes, minutes)
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/common/ReminderLeadPicker.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/common/ReminderLeadPicker.kt
new file mode 100644
index 0000000..91bf55e
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/common/ReminderLeadPicker.kt
@@ -0,0 +1,189 @@
+package de.jeanlucmakiola.floret.ui.common
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SegmentedButton
+import androidx.compose.material3.SegmentedButtonDefaults
+import androidx.compose.material3.SingleChoiceSegmentedButtonRow
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
+import de.jeanlucmakiola.floret.R
+import de.jeanlucmakiola.floret.data.prefs.ListReminderOverride
+
+/**
+ * Reminder lead-time picker, full-screen: the grouped list of preset lead times
+ * plus a "Custom" row that expands an inline amount field + unit selector.
+ * [allowInherit] adds a "Use default" row (per-list overrides); [allowNone] adds
+ * a "None" row. Returns the choice as a [ListReminderOverride]. Ported from
+ * Calendula's ReminderDefaultPicker.
+ */
+@Composable
+fun ReminderLeadPicker(
+ title: String,
+ selected: ListReminderOverride,
+ allowInherit: Boolean,
+ allowNone: Boolean,
+ onSelect: (ListReminderOverride) -> Unit,
+ onDismiss: () -> Unit,
+ presets: List = REMINDER_PRESETS,
+) {
+ val selectedMinutes = (selected as? ListReminderOverride.Minutes)?.minutes
+ val customSelected = selectedMinutes != null && selectedMinutes !in presets
+ val seed = decomposeReminder(selectedMinutes?.takeIf { customSelected })
+
+ var customExpanded by rememberSaveable { mutableStateOf(false) }
+ var amountText by rememberSaveable { mutableStateOf(seed.first) }
+ var unit by rememberSaveable { mutableStateOf(seed.second) }
+
+ val options = buildList {
+ if (allowInherit) add(ListReminderOverride.Inherit)
+ if (allowNone) add(ListReminderOverride.None)
+ presets.forEach { add(ListReminderOverride.Minutes(it)) }
+ }
+ val rowCount = options.size + 1 // + the custom row
+
+ FullScreenPicker(title = title, onDismiss = onDismiss) {
+ options.forEachIndexed { index, option ->
+ val isSelected = option == selected
+ GroupedRow(
+ title = reminderOverrideLabel(option),
+ position = positionOf(index, rowCount),
+ selected = isSelected,
+ onClick = {
+ onSelect(option)
+ onDismiss()
+ },
+ )
+ }
+ // Expanded, the Custom row connects downward into the editor card so the
+ // two read as one grouped container.
+ GroupedRow(
+ title = if (customSelected) {
+ stringResource(R.string.reminder_custom_with_value, reminderLeadTimeLabel(selectedMinutes!!))
+ } else {
+ stringResource(R.string.reminder_custom)
+ },
+ position = if (customExpanded) Position.Top else positionOf(options.size, rowCount),
+ selected = customSelected,
+ onClick = { customExpanded = !customExpanded },
+ )
+ AnimatedVisibility(
+ visible = customExpanded,
+ enter = expandVertically() + fadeIn(),
+ exit = shrinkVertically() + fadeOut(),
+ ) {
+ CustomReminderEditor(
+ amountText = amountText,
+ onAmountChange = { amountText = it },
+ unit = unit,
+ onUnitChange = { unit = it },
+ onConfirm = { minutes ->
+ onSelect(ListReminderOverride.Minutes(minutes))
+ onDismiss()
+ },
+ )
+ }
+ }
+}
+
+@Composable
+private fun CustomReminderEditor(
+ amountText: String,
+ onAmountChange: (String) -> Unit,
+ unit: ReminderUnit,
+ onUnitChange: (ReminderUnit) -> Unit,
+ onConfirm: (Int) -> Unit,
+) {
+ val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 }
+ Surface(
+ color = MaterialTheme.colorScheme.surfaceContainerHigh,
+ // A Position.Bottom shape: tight top corners meeting the row, full bottom.
+ shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp),
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
+ ReminderUnit.entries.forEachIndexed { index, entry ->
+ SegmentedButton(
+ selected = unit == entry,
+ onClick = { onUnitChange(entry) },
+ shape = SegmentedButtonDefaults.itemShape(index, ReminderUnit.entries.size),
+ label = { Text(stringResource(reminderUnitLabel(entry))) },
+ )
+ }
+ }
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Surface(
+ color = MaterialTheme.colorScheme.surfaceContainerHighest,
+ shape = RoundedCornerShape(12.dp),
+ ) {
+ InlineTextField(
+ value = amountText,
+ onValueChange = { text -> if (text.length <= 3 && text.all(Char::isDigit)) onAmountChange(text) },
+ placeholder = "10",
+ textStyle = MaterialTheme.typography.titleMedium,
+ keyboardType = KeyboardType.Number,
+ modifier = Modifier.width(72.dp).padding(horizontal = 14.dp, vertical = 12.dp),
+ )
+ }
+ Spacer(Modifier.width(16.dp))
+ Text(
+ text = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor) }
+ ?: stringResource(R.string.reminder_custom_amount),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.weight(1f),
+ )
+ Spacer(Modifier.width(16.dp))
+ FilledTonalButton(
+ onClick = { amount?.let { onConfirm(it * unit.minutesFactor) } },
+ enabled = amount != null,
+ ) {
+ Text(stringResource(R.string.reminder_custom_set))
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun reminderOverrideLabel(override: ListReminderOverride): String = when (override) {
+ ListReminderOverride.Inherit -> stringResource(R.string.reminder_use_default)
+ ListReminderOverride.None -> stringResource(R.string.reminder_none)
+ is ListReminderOverride.Minutes -> reminderLeadTimeLabel(override.minutes)
+}
+
+/** Seed the custom editor: the largest exact unit for [minutes] (null → empty). */
+private fun decomposeReminder(minutes: Int?): Pair = when {
+ minutes == null || minutes <= 0 -> "" to ReminderUnit.Minutes
+ minutes % 10_080 == 0 -> (minutes / 10_080).toString() to ReminderUnit.Weeks
+ minutes % 1_440 == 0 -> (minutes / 1_440).toString() to ReminderUnit.Days
+ minutes % 60 == 0 -> (minutes / 60).toString() to ReminderUnit.Hours
+ else -> minutes.toString() to ReminderUnit.Minutes
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/common/ShapedActionButton.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/common/ShapedActionButton.kt
new file mode 100644
index 0000000..8a4da87
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/common/ShapedActionButton.kt
@@ -0,0 +1,85 @@
+package de.jeanlucmakiola.floret.ui.common
+
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsPressedAsState
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialShapes
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.toShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.rotate
+import androidx.compose.ui.draw.scale
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.graphics.shapes.RoundedPolygon
+
+/**
+ * A top-bar action whose icon sits in a tonal container clipped to one of the
+ * M3 Expressive [MaterialShapes] (cookie, clover, sunny, …) — Floret's playful
+ * take on a plain [androidx.compose.material3.IconButton]. Each action passes
+ * its own [shape] and [containerColor], so a row of them reads as a set of
+ * distinct little tokens rather than identical grey glyphs.
+ *
+ * Pressing springs the icon down a touch and gives it a small turn — a light
+ * expressive flourish, no library motion APIs needed.
+ */
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun ShapedActionButton(
+ shape: RoundedPolygon,
+ icon: ImageVector,
+ contentDescription: String?,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ containerColor: Color = MaterialTheme.colorScheme.tertiaryContainer,
+ contentColor: Color = MaterialTheme.colorScheme.onTertiaryContainer,
+ size: Dp = 40.dp,
+ iconSize: Dp = 22.dp,
+) {
+ val interaction = remember { MutableInteractionSource() }
+ val pressed by interaction.collectIsPressedAsState()
+ val scale by animateFloatAsState(if (pressed) 0.88f else 1f, label = "actionScale")
+ val rotation by animateFloatAsState(if (pressed) 24f else 0f, label = "actionRotation")
+
+ Surface(
+ onClick = onClick,
+ modifier = modifier.size(size),
+ shape = shape.toShape(),
+ color = containerColor,
+ contentColor = contentColor,
+ interactionSource = interaction,
+ ) {
+ Box(contentAlignment = Alignment.Center) {
+ Icon(
+ imageVector = icon,
+ contentDescription = contentDescription,
+ modifier = Modifier
+ .size(iconSize)
+ .scale(scale)
+ .rotate(rotation),
+ )
+ }
+ }
+}
+
+/**
+ * Named shapes for the app's top-bar actions, so each action is recognisably
+ * "its own" and new actions just pick the next unused shape. Expose the
+ * expressive [MaterialShapes] through a single opt-in site.
+ */
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+object ActionShapes {
+ /** Settings — a 4-sided cookie (rounded, scalloped square). */
+ val Settings: RoundedPolygon get() = MaterialShapes.Cookie4Sided
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/lists/ListsScreen.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/lists/ListsScreen.kt
index 6ecc915..a8323c3 100644
--- a/app/src/main/java/de/jeanlucmakiola/floret/ui/lists/ListsScreen.kt
+++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/lists/ListsScreen.kt
@@ -20,6 +20,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ListAlt
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.ErrorOutline
+import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.Today
import androidx.compose.material.icons.rounded.Upcoming
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -47,8 +48,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.floret.R
import de.jeanlucmakiola.floret.domain.SmartList
import de.jeanlucmakiola.floret.domain.TaskFilter
+import de.jeanlucmakiola.floret.ui.common.ActionShapes
import de.jeanlucmakiola.floret.ui.common.GroupedRow
import de.jeanlucmakiola.floret.ui.common.ListColorChip
+import de.jeanlucmakiola.floret.ui.common.ShapedActionButton
import de.jeanlucmakiola.floret.ui.common.positionOf
/**
@@ -61,6 +64,7 @@ import de.jeanlucmakiola.floret.ui.common.positionOf
fun ListsScreen(
onOpenFilter: (TaskFilter) -> Unit,
onNewTask: () -> Unit,
+ onOpenSettings: () -> Unit,
modifier: Modifier = Modifier,
viewModel: ListsViewModel = hiltViewModel(),
) {
@@ -72,6 +76,17 @@ fun ListsScreen(
topBar = {
MediumTopAppBar(
title = { Text(stringResource(R.string.app_name)) },
+ actions = {
+ ShapedActionButton(
+ shape = ActionShapes.Settings,
+ icon = Icons.Rounded.Settings,
+ contentDescription = stringResource(R.string.settings_title),
+ onClick = onOpenSettings,
+ modifier = Modifier.padding(end = 8.dp),
+ size = 48.dp,
+ iconSize = 26.dp,
+ )
+ },
scrollBehavior = scrollBehavior,
)
},
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/navigation/Destinations.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/navigation/Destinations.kt
index e4ee64a..91bee1d 100644
--- a/app/src/main/java/de/jeanlucmakiola/floret/ui/navigation/Destinations.kt
+++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/navigation/Destinations.kt
@@ -23,6 +23,9 @@ object Dest {
/** Home — smart lists + the user's lists. */
const val LISTS = "lists"
+ /** App preferences. */
+ const val SETTINGS = "settings"
+
/**
* One [TaskFilter]'s tasks. Carries *either* `listId` (a real list) *or*
* `smart` (a [SmartList] name); whichever is set decides the filter.
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/navigation/FloretNavHost.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/navigation/FloretNavHost.kt
index e5d032f..b421d9a 100644
--- a/app/src/main/java/de/jeanlucmakiola/floret/ui/navigation/FloretNavHost.kt
+++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/navigation/FloretNavHost.kt
@@ -18,6 +18,7 @@ import de.jeanlucmakiola.floret.ui.detail.TaskDetailViewModel
import de.jeanlucmakiola.floret.ui.edit.TaskEditScreen
import de.jeanlucmakiola.floret.ui.edit.TaskEditViewModel
import de.jeanlucmakiola.floret.ui.lists.ListsScreen
+import de.jeanlucmakiola.floret.ui.settings.SettingsScreen
import de.jeanlucmakiola.floret.ui.tasklist.TaskListScreen
import de.jeanlucmakiola.floret.ui.tasklist.TaskListViewModel
@@ -52,9 +53,14 @@ fun FloretNavHost(modifier: Modifier = Modifier) {
ListsScreen(
onOpenFilter = { filter -> nav.navigate(Dest.TaskList.build(filter)) },
onNewTask = { nav.navigate(Dest.TaskEdit.buildNew()) },
+ onOpenSettings = { nav.navigate(Dest.SETTINGS) },
)
}
+ composable(Dest.SETTINGS) {
+ SettingsScreen(onBack = { nav.popBackStack() })
+ }
+
composable(Dest.TaskList.route, arguments = Dest.TaskList.arguments) { entry ->
val args = entry.arguments
val filter = Dest.TaskList.filterOf(
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/permission/OnboardingScaffold.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/permission/OnboardingScaffold.kt
new file mode 100644
index 0000000..1c37304
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/permission/OnboardingScaffold.kt
@@ -0,0 +1,136 @@
+package de.jeanlucmakiola.floret.ui.permission
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.dp
+
+/** MD3 8dp spacing scale shared by the onboarding screens. */
+internal object OnboardingSpace {
+ val xs = 8.dp
+ val sm = 16.dp
+ val md = 24.dp
+ val lg = 32.dp
+ val xl = 48.dp
+}
+
+/**
+ * Shared onboarding shell: a scrollable, centred hero + body with the call(s)
+ * to action pinned to the bottom (clear of the navigation bar). The content
+ * slot is centred horizontally; benefit rows fill the width so their own
+ * content left-aligns. Ported from Calendula so the two apps read as one family.
+ */
+@Composable
+internal fun OnboardingScaffold(
+ hero: @Composable () -> Unit,
+ actions: @Composable ColumnScope.() -> Unit,
+ modifier: Modifier = Modifier,
+ body: @Composable ColumnScope.() -> Unit,
+) {
+ Scaffold(
+ modifier = modifier,
+ containerColor = MaterialTheme.colorScheme.surface,
+ bottomBar = {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .navigationBarsPadding()
+ .padding(horizontal = OnboardingSpace.md, vertical = OnboardingSpace.sm),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ content = actions,
+ )
+ },
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(innerPadding)
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = OnboardingSpace.md),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Spacer(Modifier.height(OnboardingSpace.xl))
+ hero()
+ Spacer(Modifier.height(OnboardingSpace.lg))
+ body()
+ Spacer(Modifier.height(OnboardingSpace.md))
+ }
+ }
+}
+
+/** An icon in the brand squircle — the shared onboarding hero silhouette. */
+@Composable
+internal fun SquircleHero(icon: ImageVector) {
+ Box(
+ modifier = Modifier
+ .size(128.dp)
+ .clip(RoundedCornerShape(34.dp))
+ .background(MaterialTheme.colorScheme.primaryContainer),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onPrimaryContainer,
+ modifier = Modifier.size(56.dp),
+ )
+ }
+}
+
+/** One trust point: a tonal icon chip on the left, title + supporting text right. */
+@Composable
+internal fun BenefitRow(icon: ImageVector, title: String, body: String) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(
+ modifier = Modifier
+ .size(44.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.secondaryContainer),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSecondaryContainer,
+ modifier = Modifier.size(22.dp),
+ )
+ }
+ Spacer(Modifier.width(OnboardingSpace.sm))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(text = title, style = MaterialTheme.typography.titleMedium)
+ Text(
+ text = body,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/permission/ReminderOnboardingScreen.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/permission/ReminderOnboardingScreen.kt
new file mode 100644
index 0000000..8cb4567
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/permission/ReminderOnboardingScreen.kt
@@ -0,0 +1,111 @@
+package de.jeanlucmakiola.floret.ui.permission
+
+import android.Manifest
+import android.os.Build
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Edit
+import androidx.compose.material.icons.rounded.Notifications
+import androidx.compose.material.icons.rounded.Schedule
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import de.jeanlucmakiola.floret.R
+
+/**
+ * One-time onboarding step after the tasks-provider grant: explains that Floret
+ * delivers due reminders itself (tasks providers don't broadcast reminders) and
+ * requests `POST_NOTIFICATIONS` (a system dialog on API 33+ only).
+ *
+ * Reminders default ON: [onFinished] gets true from the primary action even if
+ * the system dialog is declined — the OS permission is the real gate, and the
+ * Settings toggle re-requests it. "Not now" turns the in-app toggle off.
+ */
+@Composable
+fun ReminderOnboardingScreen(
+ onFinished: (remindersEnabled: Boolean) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val launcher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.RequestPermission(),
+ ) { onFinished(true) }
+
+ OnboardingScaffold(
+ modifier = modifier,
+ hero = { SquircleHero(Icons.Rounded.Notifications) },
+ actions = {
+ Button(
+ onClick = {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
+ } else {
+ onFinished(true)
+ }
+ },
+ modifier = Modifier.fillMaxWidth().height(56.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.reminder_onboarding_enable_button),
+ style = MaterialTheme.typography.titleMedium,
+ )
+ }
+ TextButton(
+ onClick = { onFinished(false) },
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(stringResource(R.string.reminder_onboarding_skip_button))
+ }
+ },
+ ) {
+ Text(
+ text = stringResource(R.string.app_name).uppercase(),
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.primary,
+ letterSpacing = 2.sp,
+ )
+ Spacer(Modifier.height(OnboardingSpace.xs))
+ Text(
+ text = stringResource(R.string.reminder_onboarding_title),
+ style = MaterialTheme.typography.headlineMedium,
+ textAlign = TextAlign.Center,
+ )
+ Spacer(Modifier.height(12.dp))
+ Text(
+ text = stringResource(R.string.reminder_onboarding_body),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ )
+
+ Spacer(Modifier.height(OnboardingSpace.xl))
+
+ BenefitRow(
+ icon = Icons.Rounded.Notifications,
+ title = stringResource(R.string.reminder_benefit_delivery_title),
+ body = stringResource(R.string.reminder_benefit_delivery_body),
+ )
+ Spacer(Modifier.height(OnboardingSpace.sm))
+ BenefitRow(
+ icon = Icons.Rounded.Schedule,
+ title = stringResource(R.string.reminder_benefit_timing_title),
+ body = stringResource(R.string.reminder_benefit_timing_body),
+ )
+ Spacer(Modifier.height(OnboardingSpace.sm))
+ BenefitRow(
+ icon = Icons.Rounded.Edit,
+ title = stringResource(R.string.reminder_benefit_reversible_title),
+ body = stringResource(R.string.reminder_benefit_reversible_body),
+ )
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/permission/ReminderOnboardingViewModel.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/permission/ReminderOnboardingViewModel.kt
new file mode 100644
index 0000000..8432a15
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/permission/ReminderOnboardingViewModel.kt
@@ -0,0 +1,39 @@
+package de.jeanlucmakiola.floret.ui.permission
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import de.jeanlucmakiola.floret.data.prefs.SettingsPrefs
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+/**
+ * Gates the one-time reminder onboarding step shown after the tasks-provider
+ * grant. [onboardingDone] is null until DataStore's first emission so the step
+ * neither flashes for users who completed it nor gets skipped.
+ */
+@HiltViewModel
+class ReminderOnboardingViewModel @Inject constructor(
+ private val prefs: SettingsPrefs,
+) : ViewModel() {
+
+ val onboardingDone: StateFlow = prefs.reminderOnboardingDone
+ .map { done -> done as Boolean? }
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(5_000L),
+ initialValue = null,
+ )
+
+ /** Close the step, recording whether due reminders stay on. */
+ fun finish(remindersEnabled: Boolean) {
+ viewModelScope.launch {
+ prefs.setRemindersEnabled(remindersEnabled)
+ prefs.setReminderOnboardingDone()
+ }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/settings/AppLanguage.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/settings/AppLanguage.kt
new file mode 100644
index 0000000..795cf8a
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/settings/AppLanguage.kt
@@ -0,0 +1,78 @@
+package de.jeanlucmakiola.floret.ui.settings
+
+import android.content.Context
+import androidx.appcompat.app.AppCompatDelegate
+import androidx.core.os.LocaleListCompat
+import de.jeanlucmakiola.floret.R
+import org.xmlpull.v1.XmlPullParser
+import java.util.Locale
+
+private const val ANDROID_NS = "http://schemas.android.com/apk/res/android"
+
+/**
+ * Per-app language via AppCompatDelegate, driven by res/xml/locales_config.xml.
+ *
+ * That file is the single source of truth for which languages we ship: dropping
+ * in a values- translation and adding a matching `` entry makes the
+ * language show up here and in the system per-app-language settings, with no
+ * other code change. The system-default choice is represented as `null`.
+ *
+ * On API 33+ this delegates to the platform per-app-languages API; below that
+ * the appcompat backport persists the choice itself (manifest `autoStoreLocales`
+ * service), so we don't mirror it in DataStore. Setting a locale recreates the
+ * activity, which re-reads the current value for the picker.
+ */
+object AppLanguage {
+
+ /**
+ * The BCP-47 tags the app ships translations for, in declaration order, as
+ * listed in locales_config.xml. Returns whatever could be parsed; a missing
+ * or malformed config yields an empty list (the picker then offers only the
+ * system-default entry rather than crashing).
+ */
+ fun supportedTags(context: Context): List {
+ val tags = mutableListOf()
+ val parser = context.resources.getXml(R.xml.locales_config)
+ try {
+ var event = parser.eventType
+ while (event != XmlPullParser.END_DOCUMENT) {
+ if (event == XmlPullParser.START_TAG && parser.name == "locale") {
+ parser.getAttributeValue(ANDROID_NS, "name")?.let(tags::add)
+ }
+ event = parser.next()
+ }
+ } catch (_: Exception) {
+ // Fall back to whatever was parsed before the failure.
+ } finally {
+ parser.close()
+ }
+ return tags
+ }
+
+ /** The applied app language as a BCP-47 tag, or `null` when following the system. */
+ fun currentTag(): String? {
+ val locales = AppCompatDelegate.getApplicationLocales()
+ return if (locales.isEmpty) null else locales[0]?.toLanguageTag()
+ }
+
+ /** Apply a BCP-47 tag, or `null` to follow the system languages. */
+ fun apply(tag: String?) {
+ val locales = if (tag == null) {
+ LocaleListCompat.getEmptyLocaleList()
+ } else {
+ LocaleListCompat.forLanguageTags(tag)
+ }
+ AppCompatDelegate.setApplicationLocales(locales)
+ }
+
+ /**
+ * The autonym for a tag — the language's own name in its own script, e.g.
+ * "Deutsch", "English", "Français" — so users find their language regardless
+ * of the current UI language. Capitalised per the language's own rules.
+ */
+ fun displayName(tag: String): String {
+ val locale = Locale.forLanguageTag(tag)
+ return locale.getDisplayName(locale)
+ .replaceFirstChar { if (it.isLowerCase()) it.titlecase(locale) else it.toString() }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/settings/SettingsScreen.kt
new file mode 100644
index 0000000..6e2f1f4
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/settings/SettingsScreen.kt
@@ -0,0 +1,699 @@
+package de.jeanlucmakiola.floret.ui.settings
+
+import android.Manifest
+import android.app.AlarmManager
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.Build
+import android.provider.Settings
+import androidx.activity.compose.BackHandler
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.animation.slideInHorizontally
+import androidx.compose.animation.slideOutHorizontally
+import androidx.compose.foundation.background
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.requiredSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.Image
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.Notes
+import androidx.compose.material.icons.filled.BugReport
+import androidx.compose.material.icons.filled.Code
+import androidx.compose.material.icons.filled.ExpandLess
+import androidx.compose.material.icons.filled.ExpandMore
+import androidx.compose.material.icons.filled.Favorite
+import androidx.compose.material.icons.filled.Gavel
+import androidx.compose.material.icons.filled.Language
+import androidx.compose.material.icons.filled.Notifications
+import androidx.compose.material.icons.filled.Palette
+import androidx.compose.material.icons.filled.Tune
+import androidx.compose.material.icons.rounded.AccountTree
+import androidx.compose.material.icons.rounded.Circle
+import androidx.compose.material.icons.rounded.Flag
+import androidx.compose.material.icons.rounded.Percent
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.colorResource
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.core.content.ContextCompat
+import androidx.core.net.toUri
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import de.jeanlucmakiola.floret.R
+import de.jeanlucmakiola.floret.data.prefs.ListReminderOverride
+import de.jeanlucmakiola.floret.data.prefs.ThemeMode
+import de.jeanlucmakiola.floret.domain.TaskFormField
+import de.jeanlucmakiola.floret.ui.common.CollapsingScaffold
+import de.jeanlucmakiola.floret.ui.common.GroupedRow
+import de.jeanlucmakiola.floret.ui.common.OptionPicker
+import de.jeanlucmakiola.floret.ui.common.Position
+import de.jeanlucmakiola.floret.ui.common.ReminderLeadPicker
+import de.jeanlucmakiola.floret.ui.common.pastelize
+import de.jeanlucmakiola.floret.ui.common.positionOf
+import de.jeanlucmakiola.floret.ui.common.reminderLeadTimeLabel
+
+/** The settings sub-screens reached from the hub's category rows. */
+private enum class SettingsSection { Appearance, TaskForm, Reminders }
+
+/**
+ * Token-based accent for a leading icon chip (container / on-container pair),
+ * so each accent stays correctly paired across theme, dark mode and dynamic
+ * colour.
+ */
+private enum class ChipAccent { Neutral, Primary, Tertiary }
+
+/**
+ * Settings, structured (after Calendula) as a category hub with sliding
+ * sub-screens. The hub and the sub-screens share [CollapsingScaffold] and the
+ * grouped-row card system; option lists use the full-screen [OptionPicker].
+ * A full-screen destination; [onBack] pops it.
+ */
+@Composable
+fun SettingsScreen(
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: SettingsViewModel = hiltViewModel(),
+) {
+ val state by viewModel.state.collectAsStateWithLifecycle()
+ var section by rememberSaveable { mutableStateOf(null) }
+
+ // Inside a sub-screen, system back (button or gesture) returns to the hub
+ // rather than popping the whole Settings destination to the lists overview.
+ BackHandler(enabled = section != null) { section = null }
+
+ Box(
+ modifier = modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.surface),
+ ) {
+ SettingsHub(onBack = onBack, onOpenSection = { section = it })
+
+ SlideInSection(visible = section == SettingsSection.Appearance) {
+ AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null })
+ }
+ SlideInSection(visible = section == SettingsSection.TaskForm) {
+ TaskFormScreen(state = state, viewModel = viewModel, onBack = { section = null })
+ }
+ SlideInSection(visible = section == SettingsSection.Reminders) {
+ RemindersScreen(state = state, viewModel = viewModel, onBack = { section = null })
+ }
+ }
+}
+
+@Composable
+private fun SlideInSection(visible: Boolean, content: @Composable () -> Unit) {
+ AnimatedVisibility(
+ visible = visible,
+ enter = slideInHorizontally { it } + fadeIn(),
+ exit = slideOutHorizontally { it } + fadeOut(),
+ ) { content() }
+}
+
+// ---------------------------------------------------------------------------
+// Hub
+// ---------------------------------------------------------------------------
+
+@Composable
+private fun SettingsHub(
+ onBack: () -> Unit,
+ onOpenSection: (SettingsSection) -> Unit,
+) {
+ CollapsingScaffold(title = stringResource(R.string.settings_title), onBack = onBack) {
+ Box(Modifier.padding(horizontal = 16.dp)) { AboutCard() }
+ Spacer(Modifier.height(16.dp))
+
+ GroupedRow(
+ title = stringResource(R.string.settings_section_appearance),
+ summary = stringResource(R.string.settings_appearance_subtitle),
+ position = Position.Top,
+ leading = { CategoryIcon(Icons.Default.Palette, ChipAccent.Neutral) },
+ onClick = { onOpenSection(SettingsSection.Appearance) },
+ )
+ GroupedRow(
+ title = stringResource(R.string.settings_section_task_form),
+ summary = stringResource(R.string.settings_task_form_subtitle),
+ position = Position.Middle,
+ leading = { CategoryIcon(Icons.Default.Tune, ChipAccent.Tertiary) },
+ onClick = { onOpenSection(SettingsSection.TaskForm) },
+ )
+ GroupedRow(
+ title = stringResource(R.string.settings_section_reminders),
+ summary = stringResource(R.string.settings_reminders_subtitle),
+ position = Position.Middle,
+ leading = { CategoryIcon(Icons.Default.Notifications, ChipAccent.Primary) },
+ onClick = { onOpenSection(SettingsSection.Reminders) },
+ )
+ LanguageRow(position = Position.Middle)
+ ReportProblemRow(position = Position.Bottom)
+
+ AppVersionText()
+ }
+}
+
+/** App-language picker row; setting a locale recreates the activity. */
+@Composable
+private fun LanguageRow(position: Position) {
+ val context = LocalContext.current
+ // Mirror the choice locally so the row updates instantly, before recreation.
+ var current by remember { mutableStateOf(AppLanguage.currentTag()) }
+ var showDialog by remember { mutableStateOf(false) }
+ // null = follow the system; the rest are BCP-47 tags from locales_config.xml.
+ val options = remember { listOf(null) + AppLanguage.supportedTags(context) }
+
+ GroupedRow(
+ title = stringResource(R.string.settings_language),
+ summary = languageLabel(current),
+ position = position,
+ leading = { CategoryIcon(Icons.Default.Language, ChipAccent.Neutral) },
+ onClick = { showDialog = true },
+ )
+
+ if (showDialog) {
+ OptionPicker(
+ title = stringResource(R.string.settings_language),
+ options = options,
+ selected = current,
+ label = { languageLabel(it) },
+ onSelect = {
+ current = it
+ AppLanguage.apply(it)
+ },
+ onDismiss = { showDialog = false },
+ )
+ }
+}
+
+/** Opens the project's issue tracker; no data leaves the device until submitted. */
+@Composable
+private fun ReportProblemRow(position: Position) {
+ val context = LocalContext.current
+ val issueUrl = stringResource(R.string.report_issue_url)
+ GroupedRow(
+ title = stringResource(R.string.settings_report_problem),
+ summary = stringResource(R.string.settings_report_problem_hint),
+ position = position,
+ leading = { CategoryIcon(Icons.Default.BugReport, ChipAccent.Neutral) },
+ onClick = { openUrl(context, issueUrl) },
+ )
+}
+
+@Composable
+private fun languageLabel(tag: String?): String =
+ if (tag == null) stringResource(R.string.settings_language_auto) else AppLanguage.displayName(tag)
+
+// ---------------------------------------------------------------------------
+// Sub-screens
+// ---------------------------------------------------------------------------
+
+@Composable
+private fun AppearanceScreen(
+ state: SettingsUiState,
+ viewModel: SettingsViewModel,
+ onBack: () -> Unit,
+) {
+ val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
+ var showTheme by remember { mutableStateOf(false) }
+
+ CollapsingScaffold(title = stringResource(R.string.settings_section_appearance), onBack = onBack) {
+ GroupedRow(
+ title = stringResource(R.string.settings_theme),
+ summary = stringResource(themeLabel(state.settings.themeMode)),
+ position = Position.Top,
+ onClick = { showTheme = true },
+ )
+ GroupedRow(
+ title = stringResource(R.string.settings_dynamic_color),
+ summary = if (dynamicColorAvailable) {
+ stringResource(R.string.settings_dynamic_color_hint)
+ } else {
+ stringResource(R.string.settings_dynamic_color_unavailable)
+ },
+ position = Position.Bottom,
+ trailing = {
+ Switch(
+ checked = state.settings.dynamicColor && dynamicColorAvailable,
+ onCheckedChange = viewModel::setDynamicColor,
+ enabled = dynamicColorAvailable,
+ )
+ },
+ onClick = if (dynamicColorAvailable) {
+ { viewModel.setDynamicColor(!state.settings.dynamicColor) }
+ } else {
+ null
+ },
+ )
+ }
+
+ if (showTheme) {
+ OptionPicker(
+ title = stringResource(R.string.settings_theme),
+ options = ThemeMode.entries,
+ selected = state.settings.themeMode,
+ label = { stringResource(themeLabel(it)) },
+ onSelect = viewModel::setThemeMode,
+ onDismiss = { showTheme = false },
+ )
+ }
+}
+
+@Composable
+private fun TaskFormScreen(
+ state: SettingsUiState,
+ viewModel: SettingsViewModel,
+ onBack: () -> Unit,
+) {
+ var showDefaultList by remember { mutableStateOf(false) }
+
+ CollapsingScaffold(title = stringResource(R.string.settings_section_task_form), onBack = onBack) {
+ Text(
+ text = stringResource(R.string.settings_form_fields_hint),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
+ )
+ Spacer(Modifier.height(8.dp))
+ val fields = TaskFormField.entries
+ fields.forEachIndexed { index, field ->
+ val checked = field in state.settings.defaultEditFields
+ GroupedRow(
+ title = stringResource(formFieldLabel(field)),
+ position = positionOf(index, fields.size),
+ leading = {
+ Icon(
+ imageVector = formFieldIcon(field),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ },
+ trailing = {
+ Switch(checked = checked, onCheckedChange = { viewModel.setFormFieldDefault(field, it) })
+ },
+ onClick = { viewModel.setFormFieldDefault(field, !checked) },
+ )
+ }
+
+ Spacer(Modifier.height(24.dp))
+ GroupedRow(
+ title = stringResource(R.string.settings_default_list),
+ summary = defaultListLabel(state),
+ position = Position.Top,
+ onClick = { showDefaultList = true },
+ )
+ GroupedRow(
+ title = stringResource(R.string.settings_add_subtask_row),
+ summary = stringResource(R.string.settings_add_subtask_row_hint),
+ position = Position.Bottom,
+ trailing = {
+ Switch(
+ checked = state.settings.showAddSubtaskRow,
+ onCheckedChange = viewModel::setShowAddSubtaskRow,
+ )
+ },
+ onClick = { viewModel.setShowAddSubtaskRow(!state.settings.showAddSubtaskRow) },
+ )
+ }
+
+ if (showDefaultList) {
+ val dark = isSystemInDarkTheme()
+ // null = first available list; the rest are real list ids.
+ val options = remember(state.lists) { listOf(null) + state.lists.map { it.id } }
+ OptionPicker(
+ title = stringResource(R.string.settings_default_list),
+ options = options,
+ selected = state.settings.defaultListId,
+ label = { id -> state.lists.firstOrNull { it.id == id }?.name ?: stringResource(R.string.settings_default_list_first) },
+ leading = { id ->
+ state.lists.firstOrNull { it.id == id }?.let { list ->
+ Icon(Icons.Rounded.Circle, contentDescription = null, tint = pastelize(list.color, dark))
+ }
+ },
+ onSelect = { viewModel.setDefaultList(it) },
+ onDismiss = { showDefaultList = false },
+ )
+ }
+}
+
+@Composable
+private fun RemindersScreen(
+ state: SettingsUiState,
+ viewModel: SettingsViewModel,
+ onBack: () -> Unit,
+) {
+ val context = LocalContext.current
+ val notifLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.RequestPermission(),
+ ) { /* pref already set; a denial just leaves the OS gate shut */ }
+ val toggleReminders: (Boolean) -> Unit = { enabled ->
+ viewModel.setRemindersEnabled(enabled)
+ if (enabled &&
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
+ ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) !=
+ PackageManager.PERMISSION_GRANTED
+ ) {
+ notifLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
+ }
+ }
+ val canExact = rememberExactAlarmAllowed(context)
+ val exactAlarmRow = showExactAlarmRow()
+ val dark = isSystemInDarkTheme()
+ var showOffset by remember { mutableStateOf(false) }
+ var perListExpanded by remember { mutableStateOf(false) }
+ var listPickerId by remember { mutableStateOf(null) }
+ val lists = state.lists.filter { it.id > 0L }
+
+ CollapsingScaffold(title = stringResource(R.string.settings_section_reminders), onBack = onBack) {
+ GroupedRow(
+ title = stringResource(R.string.settings_reminders),
+ summary = stringResource(R.string.settings_reminders_hint),
+ position = Position.Top,
+ trailing = { Switch(checked = state.settings.remindersEnabled, onCheckedChange = toggleReminders) },
+ onClick = { toggleReminders(!state.settings.remindersEnabled) },
+ )
+ GroupedRow(
+ title = stringResource(R.string.settings_default_reminder),
+ summary = reminderLeadTimeLabel(state.settings.reminderLeadMinutes),
+ position = if (exactAlarmRow) Position.Middle else Position.Bottom,
+ onClick = { showOffset = true },
+ )
+ if (exactAlarmRow) {
+ GroupedRow(
+ title = stringResource(R.string.settings_exact_alarms),
+ summary = stringResource(
+ if (canExact) R.string.settings_exact_alarms_allowed
+ else R.string.settings_exact_alarms_blocked,
+ ),
+ position = Position.Bottom,
+ onClick = if (canExact) null else ({ context.openExactAlarmSettings() }),
+ )
+ }
+
+ // Per-list overrides: the whole section folds behind one header. Each
+ // list may keep, drop, or replace the global default for its tasks.
+ if (lists.isNotEmpty()) {
+ Spacer(Modifier.height(24.dp))
+ // The header and the list rows form one connected run: the header
+ // opens its bottom (Top) when expanded, and the last list rounds it off.
+ GroupedRow(
+ title = stringResource(R.string.settings_list_reminders_title),
+ summary = stringResource(R.string.settings_list_reminders_hint),
+ position = if (perListExpanded) Position.Top else Position.Alone,
+ trailing = {
+ Icon(
+ imageVector = if (perListExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ },
+ onClick = { perListExpanded = !perListExpanded },
+ )
+ AnimatedVisibility(
+ visible = perListExpanded,
+ enter = expandVertically() + fadeIn(),
+ exit = shrinkVertically() + fadeOut(),
+ ) {
+ Column {
+ lists.forEachIndexed { index, list ->
+ GroupedRow(
+ title = list.name,
+ summary = listOverrideSummary(
+ listOverrideChoice(state, list.id),
+ state.settings.reminderLeadMinutes,
+ ),
+ // Every list is mid-run under the header; the last rounds off.
+ position = if (index == lists.lastIndex) Position.Bottom else Position.Middle,
+ leading = {
+ Icon(Icons.Rounded.Circle, contentDescription = null, tint = pastelize(list.color, dark))
+ },
+ onClick = { listPickerId = list.id },
+ )
+ }
+ }
+ }
+ }
+ }
+
+ if (showOffset) {
+ ReminderLeadPicker(
+ title = stringResource(R.string.settings_default_reminder),
+ selected = ListReminderOverride.Minutes(state.settings.reminderLeadMinutes),
+ allowInherit = false,
+ allowNone = false,
+ onSelect = { if (it is ListReminderOverride.Minutes) viewModel.setReminderLeadMinutes(it.minutes) },
+ onDismiss = { showOffset = false },
+ )
+ }
+ listPickerId?.let { id ->
+ ReminderLeadPicker(
+ title = state.lists.firstOrNull { it.id == id }?.name
+ ?: stringResource(R.string.settings_default_reminder),
+ selected = listOverrideChoice(state, id),
+ allowInherit = true,
+ allowNone = true,
+ onSelect = { viewModel.setListReminderOverride(id, it) },
+ onDismiss = { listPickerId = null },
+ )
+ }
+}
+
+/** The stored override for [listId], as a picker choice (absent → inherit). */
+private fun listOverrideChoice(state: SettingsUiState, listId: Long): ListReminderOverride {
+ val map = state.settings.perListReminderOverride
+ return when {
+ !map.containsKey(listId) -> ListReminderOverride.Inherit
+ map[listId] == null -> ListReminderOverride.None
+ else -> ListReminderOverride.Minutes(map.getValue(listId)!!)
+ }
+}
+
+/** Row summary for a list: its override, or the inherited global default. */
+@Composable
+private fun listOverrideSummary(choice: ListReminderOverride, globalDefault: Int): String = when (choice) {
+ ListReminderOverride.Inherit ->
+ stringResource(R.string.settings_list_reminder_inherits, reminderLeadTimeLabel(globalDefault))
+ ListReminderOverride.None -> stringResource(R.string.reminder_none)
+ is ListReminderOverride.Minutes -> reminderLeadTimeLabel(choice.minutes)
+}
+
+// ---------------------------------------------------------------------------
+// About + shared building blocks
+// ---------------------------------------------------------------------------
+
+@Composable
+private fun AboutCard() {
+ val context = LocalContext.current
+ val sourceUrl = stringResource(R.string.about_source_url)
+ val licenseUrl = stringResource(R.string.about_license_url)
+ val supportUrl = stringResource(R.string.about_support_url)
+
+ Surface(
+ color = MaterialTheme.colorScheme.surfaceContainerHigh,
+ shape = RoundedCornerShape(24.dp),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Column(Modifier.fillMaxWidth().padding(16.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ AppLogo()
+ Spacer(Modifier.width(16.dp))
+ Column(Modifier.weight(1f)) {
+ Text(stringResource(R.string.app_name), style = MaterialTheme.typography.titleLarge)
+ Text(
+ text = stringResource(R.string.settings_about_author),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ Spacer(Modifier.height(12.dp))
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ OutlinedButton(
+ onClick = { openUrl(context, sourceUrl) },
+ contentPadding = PaddingValues(horizontal = 12.dp),
+ modifier = Modifier.weight(1f),
+ ) {
+ Icon(Icons.Default.Code, contentDescription = null, modifier = Modifier.size(18.dp))
+ Spacer(Modifier.width(8.dp))
+ Text(stringResource(R.string.settings_about_source))
+ }
+ OutlinedButton(
+ onClick = { openUrl(context, licenseUrl) },
+ contentPadding = PaddingValues(horizontal = 12.dp),
+ modifier = Modifier.weight(1f),
+ ) {
+ Icon(Icons.Default.Gavel, contentDescription = null, modifier = Modifier.size(18.dp))
+ Spacer(Modifier.width(8.dp))
+ Text(stringResource(R.string.settings_license))
+ }
+ }
+ Spacer(Modifier.height(8.dp))
+ FilledTonalButton(onClick = { openUrl(context, supportUrl) }, modifier = Modifier.fillMaxWidth()) {
+ Icon(Icons.Default.Favorite, contentDescription = null, modifier = Modifier.size(18.dp))
+ Spacer(Modifier.width(8.dp))
+ Text(stringResource(R.string.settings_about_support))
+ }
+ }
+ }
+}
+
+@Composable
+private fun AppLogo() {
+ Box(
+ modifier = Modifier
+ .size(72.dp)
+ .clip(RoundedCornerShape(20.dp))
+ .background(colorResource(R.color.ic_launcher_background)),
+ contentAlignment = Alignment.Center,
+ ) {
+ Image(
+ painter = painterResource(R.drawable.ic_launcher_foreground),
+ contentDescription = stringResource(R.string.settings_about_logo_desc),
+ modifier = Modifier.requiredSize(108.dp),
+ )
+ }
+}
+
+@Composable
+private fun AppVersionText() {
+ val context = LocalContext.current
+ val versionName = remember {
+ runCatching { context.packageManager.getPackageInfo(context.packageName, 0).versionName }
+ .getOrNull() ?: "—"
+ }
+ Text(
+ text = stringResource(R.string.settings_about_version, versionName),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp),
+ )
+}
+
+/** Leading circular icon chip; colours come from the M3 scheme via a token pair. */
+@Composable
+private fun CategoryIcon(icon: ImageVector, accent: ChipAccent) {
+ val scheme = MaterialTheme.colorScheme
+ val (background, iconColor) = when (accent) {
+ ChipAccent.Neutral -> scheme.surfaceContainerHighest to scheme.onSurfaceVariant
+ ChipAccent.Primary -> scheme.primaryContainer to scheme.onPrimaryContainer
+ ChipAccent.Tertiary -> scheme.tertiaryContainer to scheme.onTertiaryContainer
+ }
+ Box(
+ modifier = Modifier.size(40.dp).clip(CircleShape).background(background),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(icon, contentDescription = null, tint = iconColor, modifier = Modifier.size(22.dp))
+ }
+}
+
+@Composable
+private fun defaultListLabel(state: SettingsUiState): String =
+ state.lists.firstOrNull { it.id == state.settings.defaultListId }?.name
+ ?: stringResource(R.string.settings_default_list_first)
+
+private fun openUrl(context: Context, url: String) {
+ runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri())) }
+}
+
+private fun themeLabel(mode: ThemeMode): Int = when (mode) {
+ ThemeMode.SYSTEM -> R.string.settings_theme_system
+ ThemeMode.LIGHT -> R.string.settings_theme_light
+ ThemeMode.DARK -> R.string.settings_theme_dark
+}
+
+private fun formFieldLabel(field: TaskFormField): Int = when (field) {
+ TaskFormField.Description -> R.string.field_description
+ TaskFormField.Priority -> R.string.edit_priority_label
+ TaskFormField.Progress -> R.string.edit_progress_label
+ TaskFormField.Parent -> R.string.edit_parent_label
+ TaskFormField.Reminder -> R.string.edit_reminder_label
+}
+
+private fun formFieldIcon(field: TaskFormField): ImageVector = when (field) {
+ TaskFormField.Description -> Icons.AutoMirrored.Rounded.Notes
+ TaskFormField.Priority -> Icons.Rounded.Flag
+ TaskFormField.Progress -> Icons.Rounded.Percent
+ TaskFormField.Parent -> Icons.Rounded.AccountTree
+ TaskFormField.Reminder -> Icons.Default.Notifications
+}
+
+/**
+ * Exact-alarm control is only meaningful on API 31-32 (SCHEDULE_EXACT_ALARM,
+ * revocable). On 33+ the app holds USE_EXACT_ALARM (always granted), so the row
+ * would be a permanent no-op — hide it there.
+ */
+private fun showExactAlarmRow(): Boolean =
+ Build.VERSION.SDK_INT in Build.VERSION_CODES.S..Build.VERSION_CODES.S_V2
+
+@Composable
+private fun rememberExactAlarmAllowed(context: Context): Boolean {
+ var allowed by remember {
+ mutableStateOf(
+ Build.VERSION.SDK_INT < Build.VERSION_CODES.S ||
+ context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms(),
+ )
+ }
+ val lifecycle = LocalLifecycleOwner.current.lifecycle
+ DisposableEffect(lifecycle) {
+ val obs = LifecycleEventObserver { _, event ->
+ if (event == Lifecycle.Event.ON_RESUME && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ allowed = context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms()
+ }
+ }
+ lifecycle.addObserver(obs)
+ onDispose { lifecycle.removeObserver(obs) }
+ }
+ return allowed
+}
+
+private fun Context.openExactAlarmSettings() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ runCatching {
+ startActivity(
+ Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM).setData("package:$packageName".toUri()),
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/settings/SettingsViewModel.kt
index 835a025..8b47058 100644
--- a/app/src/main/java/de/jeanlucmakiola/floret/ui/settings/SettingsViewModel.kt
+++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/settings/SettingsViewModel.kt
@@ -3,10 +3,12 @@ package de.jeanlucmakiola.floret.ui.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
+import de.jeanlucmakiola.floret.data.prefs.ListReminderOverride
import de.jeanlucmakiola.floret.data.prefs.Settings
import de.jeanlucmakiola.floret.data.prefs.SettingsPrefs
import de.jeanlucmakiola.floret.data.prefs.ThemeMode
import de.jeanlucmakiola.floret.data.tasks.TasksRepository
+import de.jeanlucmakiola.floret.domain.TaskFormField
import de.jeanlucmakiola.floret.domain.TaskList
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -40,4 +42,16 @@ class SettingsViewModel @Inject constructor(
fun setDynamicColor(enabled: Boolean) = viewModelScope.launch { prefs.setDynamicColor(enabled) }
fun setDefaultList(id: Long?) = viewModelScope.launch { prefs.setDefaultListId(id) }
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) }
+
+ /** Toggle whether [field] shows by default on a new task's edit form. */
+ fun setFormFieldDefault(field: TaskFormField, enabled: Boolean) = viewModelScope.launch {
+ val current = state.value.settings.defaultEditFields
+ prefs.setDefaultEditFields(if (enabled) current + field else current - field)
+ }
+
+ /** Set (or clear, via [ListReminderOverride.Inherit]) a list's reminder override. */
+ fun setListReminderOverride(listId: Long, override: ListReminderOverride) =
+ viewModelScope.launch { prefs.setListReminderOverride(listId, override) }
}
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/tasklist/TaskListScreen.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/tasklist/TaskListScreen.kt
index 3e4bd21..723bcf0 100644
--- a/app/src/main/java/de/jeanlucmakiola/floret/ui/tasklist/TaskListScreen.kt
+++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/tasklist/TaskListScreen.kt
@@ -311,15 +311,21 @@ private fun TaskListBody(
)
add(RenderRow(ListRow.Parent(task, canExpand, isExpanded), parentPosition))
if (isExpanded) {
- // Subtasks connect up to the parent (small top) and to
- // each other; the inline add row below closes the group,
- // so every subtask is mid-run (small top + bottom).
- children.sortedBy { it.isCompleted }.forEach { sub ->
- add(RenderRow(ListRow.Sub(sub), cornerPosition(topFull = false, bottomFull = false)))
+ // When the add row is hidden (M5 setting), the last subtask
+ // instead rounds the group off (full bottom).
+ val sorted = children.sortedBy { it.isCompleted }
+ sorted.forEachIndexed { c, sub ->
+ // Subtasks connect up to the parent (small top); with the
+ // add row present every subtask is mid-run, otherwise the
+ // last one closes the group (full bottom).
+ val closesGroup = !state.showAddSubtaskRow && c == sorted.lastIndex
+ add(RenderRow(ListRow.Sub(sub), cornerPosition(topFull = false, bottomFull = closesGroup)))
}
// The add row rounds the group off (full bottom),
// independent of whatever top-level task follows.
- add(RenderRow(ListRow.AddSub(task), cornerPosition(topFull = false, bottomFull = true)))
+ if (state.showAddSubtaskRow) {
+ add(RenderRow(ListRow.AddSub(task), cornerPosition(topFull = false, bottomFull = true)))
+ }
}
}
}
diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/tasklist/TaskListViewModel.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/tasklist/TaskListViewModel.kt
index 02b79df..5114030 100644
--- a/app/src/main/java/de/jeanlucmakiola/floret/ui/tasklist/TaskListViewModel.kt
+++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/tasklist/TaskListViewModel.kt
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.floret.ui.tasklist
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
+import de.jeanlucmakiola.floret.data.prefs.SettingsPrefs
import de.jeanlucmakiola.floret.data.tasks.TasksRepository
import de.jeanlucmakiola.floret.domain.Task
import de.jeanlucmakiola.floret.domain.TaskFilter
@@ -30,7 +31,12 @@ sealed interface TaskListUiState {
* [TaskFilter.OfList] (for the top-bar title), `null` for smart lists —
* the screen falls back to the smart label in that case.
*/
- data class Content(val tasks: List, val listName: String? = null) : TaskListUiState
+ data class Content(
+ val tasks: List,
+ val listName: String? = null,
+ /** Whether the inline "add a subtask" row shows on expanded groups (M5 setting). */
+ val showAddSubtaskRow: Boolean = true,
+ ) : TaskListUiState
}
/**
@@ -42,6 +48,7 @@ sealed interface TaskListUiState {
@HiltViewModel
class TaskListViewModel @Inject constructor(
private val repository: TasksRepository,
+ private val settingsPrefs: SettingsPrefs,
) : ViewModel() {
private val filter = MutableStateFlow(null)
@@ -62,10 +69,15 @@ class TaskListViewModel @Inject constructor(
tasks.map { TaskListUiState.Content(it) }
}
// Drop rows pending an undoable delete so the row vanishes on swipe
- // while the actual provider delete waits for the snackbar to commit.
- combine(content, pendingDeletes) { st, pending ->
+ // 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 ->
if (st is TaskListUiState.Content) {
- st.copy(tasks = st.tasks.filter { it.taskId !in pending })
+ st.copy(
+ tasks = st.tasks.filter { it.taskId !in pending },
+ showAddSubtaskRow = showRow,
+ )
} else {
st
}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 277fd9e..0f48a5c 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -98,6 +98,20 @@
1 hour before
1 day before
+
+ Custom
+ Custom (%1$s)
+ Set
+ Enter an amount
+ Use default
+ Minutes
+ Hours
+ Days
+ Weeks
+ Per-list reminders
+ Override the default reminder for individual lists
+ Default (%1$s)
+
Enter a title.
Choose a list.
@@ -128,4 +142,77 @@
Grant task access
Install OpenTasks
Install tasks.org
+
+
+ Never miss what\'s due
+ Tasks apps don\'t send reminders themselves, so Floret delivers them for you. Turn them on to get a notification when a task is due.
+ Enable reminders
+ Not now
+ Floret reminds you
+ It schedules a notification for each task with a due date.
+ On your schedule
+ Choose how far ahead to be reminded in Settings.
+ Change it anytime
+ Turn reminders off whenever you like — it\'s just a switch.
+
+
+ Settings
+ Appearance
+ Theme and colour
+ Task form
+ Default fields, list and subtasks
+ Notifications for due tasks
+ Fields shown by default on a new task. The rest sit behind \"More fields\".
+ Available on Android 12 and later
+
+ by Jean-Luc Makiola
+ Source
+ License
+ Support development
+ Version %1$s
+ Floret app icon
+ App language
+ System default
+ Report a problem
+ Open the issue tracker
+ https://gitea.jeanlucmakiola.de/makiolaj/floret
+ https://gitea.jeanlucmakiola.de/makiolaj/floret/issues/new
+ https://gitea.jeanlucmakiola.de/makiolaj/floret/src/branch/main/LICENSE
+ https://ko-fi.com/jeanlucmakiola
+ Theme
+ Follow system
+ Light
+ Dark
+ Dynamic colour
+ Use colours from your wallpaper
+ Reminders
+ Due reminders
+ Notify me when a task is due
+ When to remind
+ Exact timing
+ Reminders fire at the exact time
+ Blocked — tap to allow exact reminders
+ Tasks
+ Default list
+ First available list
+ Show \"add a subtask\" row
+ An add field at the end of an expanded task\'s subtasks
+
+
+
+ - %1$d minute before
+ - %1$d minutes before
+
+
+ - %1$d hour before
+ - %1$d hours before
+
+
+ - %1$d day before
+ - %1$d days before
+
+
+ - %1$d week before
+ - %1$d weeks before
+
diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml
new file mode 100644
index 0000000..8941693
--- /dev/null
+++ b/app/src/main/res/xml/locales_config.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 93a9144..23827d1 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -12,11 +12,14 @@ Status legend: ✅ done · 🚧 in progress · ⬜ not started
The full non-visual stack ("backoffice") over the OpenTasks `TaskContract`
provider is **done and unit-tested**, and the Material 3 Expressive UI is built
-through **M4**: lists → task list (swipe gestures, inline add, smart-list section
+through **M5**: lists → task list (swipe gestures, inline add, smart-list section
headers) → detail / edit with full CRUD, date-time pickers, priority,
percent-complete, conflict-safe saves, per-task reminders, and subtask
-create + reparent. Remaining work is M5 (notification / exact-alarm onboarding,
-default reminder UI) and M6 (Settings screen, Glance widget, i18n, release).
+create + reparent — plus a one-time reminder onboarding step and a Settings
+screen (theme, dynamic colour, due-reminder master toggle + default offset +
+exact-alarm status, default list, and the add-a-subtask-row opt-out). Remaining
+work is M6 (Glance widget, translations, F-Droid release; the Settings screen
+landed early with M5 and still needs a language entry).
---
@@ -85,7 +88,7 @@ ViewModels.
last segment while the next top-level task stays mid-run. Children are
full-width, set a step down in tone (`surfaceContainer` vs the parents'
`surfaceContainerHigh`) and with no colour bar (checkbox stays aligned). An expanded group ends with an inline "add a subtask"
- row (always on for now; an opt-out toggle lands with the M6 settings screen).
+ row (on by default; opt out in Settings → Tasks since M5).
Offered only where the list holds all the children (a real list; smart lists
that omit off-day children stay collapsed). `TaskDetail.parent` carries the
parent for the detail card.
@@ -95,25 +98,32 @@ ViewModels.
promote it); switching list clears the now-invalid parent. Candidates stay
active + top-level to keep nesting one level deep, matching the detail screen.
-### 🚧 M5 — Reminders onboarding & polish
+### ✅ M5 — Reminders onboarding & polish
The engine exists (M1: `ReminderScheduler` + boot / provider-change re-sync,
`DueReminderReceiver`, `TaskNotifier`).
- ✅ Per-task reminder-offset UI (`ReminderPickerDialog` → `TaskForm`
`reminderMinutesBeforeDue`).
-- ⬜ `POST_NOTIFICATIONS` + exact-alarm onboarding flow — currently only graceful
- runtime checks (`TaskNotifier.canPost`, `canScheduleExactAlarms`), no
- user-facing gate.
-- ⬜ Default reminder-offset settings UI — `SettingsPrefs`/`SettingsViewModel`
- back it, but no Composable exposes it.
-- ⬜ End-to-end verification on device.
+- ✅ `POST_NOTIFICATIONS` onboarding flow — a one-time `ReminderOnboardingScreen`
+ (Calendula's shell + copy adapted for tasks) gated in `RootScreen` after the
+ provider/permission grant; it requests the runtime permission (API 33+) and
+ records the choice (`reminderOnboardingDone`). Re-requestable from Settings.
+- ✅ Exact-alarm surface — a status row in Settings → Reminders, shown only on
+ Android 12 (where `SCHEDULE_EXACT_ALARM` is revocable), deep-linking to the
+ system grant screen; on 13+ the app holds `USE_EXACT_ALARM` (always granted).
+- ✅ Default reminder-offset settings UI — exposed as "When to remind" in
+ Settings → Reminders (`reminderLeadMinutes`), behind a master `remindersEnabled`
+ switch that gates the entire engine (`ReminderScheduler` clears all alarms when
+ off; `DueReminderReceiver` suppresses any in-flight fire).
+- ⬜ End-to-end verification on device (build + unit tests green; not yet run on
+ a device with a live provider).
### 🚧 M6 — Settings, widget, i18n, release
- ✅ F-Droid metadata scaffolded (`fdroid-metadata/`).
-- ⬜ Settings screen — `SettingsViewModel` exists but no `SettingsScreen`
- Composable and it is not in the nav graph (only used by `MainActivity` for
- app-level theming). Needs theme / dynamic-color / language, default list,
- default reminder, and the opt-out toggle for the task list's inline
- add-a-subtask row (on by default since M4).
+- ✅ Settings screen — landed early with M5 (`SettingsScreen` in the nav graph,
+ reached by the gear on the lists overview). Covers theme, dynamic colour, due
+ reminders (toggle + default offset + exact-alarm status), default list, and the
+ add-a-subtask-row opt-out. Still ⬜ a **language** entry (deferred until there
+ are translations to switch to).
- ⬜ Glance task-list widget — deps present in `build.gradle.kts`, zero impl.
- ⬜ Translations — only `res/values/` (English); no `values-XX`.
- ⬜ Finalize F-Droid metadata, confirm CI release flow.