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.