M5: reminders onboarding, Settings screen & per-list overrides
Reminders onboarding & polish, plus the Settings screen (landed early). - One-time reminder onboarding gate after the provider grant (ReminderOnboardingScreen + OnboardingScaffold), requesting POST_NOTIFICATIONS on API 33+; choice recorded in prefs. - Settings screen, structured after Calendula as a category hub with sliding sub-screens (CollapsingScaffold + grouped rows + full-screen OptionPicker): About card, Appearance (theme, dynamic colour), Task form (default edit-form fields, default list, add-a-subtask-row opt-out), Reminders, Language, and Report a problem. Reached via an expressive shaped action button (MaterialShapes.Cookie4Sided) on the lists overview. Back from a sub-screen returns to the hub. - Reminders: master enable toggle (gates the whole engine; re-requests the notification permission), a default lead time with a Custom amount/unit editor, an Android-12 exact-alarm status row, and per-list overrides of the default (Inherit / None / custom lead), honoured by ReminderScheduler per task. - Per-app language (AppLanguage + locales_config.xml + appcompat locales service); add-a-subtask-row opt-out wired into the task list. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
18
CHANGELOG.md
18
CHANGELOG.md
@@ -6,6 +6,24 @@ the source of truth for version codes (see Calendula's `docs/RELEASING.md`).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.2.0] - 2026-06-27
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
|
android:localeConfig="@xml/locales_config"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.Floret"
|
android:theme="@style/Theme.Floret"
|
||||||
@@ -76,6 +77,17 @@
|
|||||||
<data android:scheme="content" android:host="org.dmfs.tasks" />
|
<data android:scheme="content" android:host="org.dmfs.tasks" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
|
<!-- Persists the per-app language on API < 33, where the platform
|
||||||
|
per-app-languages API is unavailable. On 33+ this is a no-op. -->
|
||||||
|
<service
|
||||||
|
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
|
||||||
|
android:enabled="false"
|
||||||
|
android:exported="false">
|
||||||
|
<meta-data
|
||||||
|
android:name="autoStoreLocales"
|
||||||
|
android:value="true" />
|
||||||
|
</service>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -16,6 +16,16 @@ import javax.inject.Singleton
|
|||||||
|
|
||||||
enum class ThemeMode { SYSTEM, LIGHT, DARK }
|
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(
|
data class Settings(
|
||||||
val themeMode: ThemeMode = ThemeMode.SYSTEM,
|
val themeMode: ThemeMode = ThemeMode.SYSTEM,
|
||||||
val dynamicColor: Boolean = true,
|
val dynamicColor: Boolean = true,
|
||||||
@@ -23,9 +33,26 @@ data class Settings(
|
|||||||
val defaultListId: Long? = null,
|
val defaultListId: Long? = null,
|
||||||
/** Default minutes before due to remind; 0 = at due time. */
|
/** Default minutes before due to remind; 0 = at due time. */
|
||||||
val reminderLeadMinutes: Int = 0,
|
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<Long, Int?> = emptyMap(),
|
||||||
/** Optional edit-form fields shown by default; the rest sit behind "More fields". */
|
/** Optional edit-form fields shown by default; the rest sit behind "More fields". */
|
||||||
val defaultEditFields: Set<TaskFormField> = emptySet(),
|
val defaultEditFields: Set<TaskFormField> = 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. */
|
/** App preferences, backed by DataStore. Mirrors Calendula's prefs shape. */
|
||||||
@Singleton
|
@Singleton
|
||||||
@@ -39,6 +66,9 @@ class SettingsPrefs @Inject constructor(
|
|||||||
dynamicColor = p[DYNAMIC_COLOR] ?: true,
|
dynamicColor = p[DYNAMIC_COLOR] ?: true,
|
||||||
defaultListId = p[DEFAULT_LIST_ID]?.takeIf { it > 0 },
|
defaultListId = p[DEFAULT_LIST_ID]?.takeIf { it > 0 },
|
||||||
reminderLeadMinutes = p[REMINDER_LEAD] ?: 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()
|
defaultEditFields = p[DEFAULT_EDIT_FIELDS].orEmpty()
|
||||||
.mapNotNull { name -> runCatching { TaskFormField.valueOf(name) }.getOrNull() }
|
.mapNotNull { name -> runCatching { TaskFormField.valueOf(name) }.getOrNull() }
|
||||||
.toSet(),
|
.toSet(),
|
||||||
@@ -53,6 +83,26 @@ class SettingsPrefs @Inject constructor(
|
|||||||
|
|
||||||
suspend fun setReminderLeadMinutes(minutes: Int) = dataStore.edit { it[REMINDER_LEAD] = minutes }
|
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<Boolean> = 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<TaskFormField>) = dataStore.edit {
|
suspend fun setDefaultEditFields(fields: Set<TaskFormField>) = dataStore.edit {
|
||||||
it[DEFAULT_EDIT_FIELDS] = fields.mapTo(mutableSetOf()) { field -> field.name }
|
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 DYNAMIC_COLOR = booleanPreferencesKey("dynamic_color")
|
||||||
val DEFAULT_LIST_ID = longPreferencesKey("default_list_id")
|
val DEFAULT_LIST_ID = longPreferencesKey("default_list_id")
|
||||||
val REMINDER_LEAD = intPreferencesKey("reminder_lead_minutes")
|
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")
|
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<Long, Int?> {
|
||||||
|
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<Long, Int?>): String =
|
||||||
|
map.entries.joinToString(OVERRIDE_ENTRY_SEP) { (id, minutes) -> "$id$OVERRIDE_KV_SEP${minutes ?: OVERRIDE_NONE}" }
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import android.content.BroadcastReceiver
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import de.jeanlucmakiola.floret.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.floret.data.tasks.TasksDataSource
|
import de.jeanlucmakiola.floret.data.tasks.TasksDataSource
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -21,6 +23,7 @@ class DueReminderReceiver : BroadcastReceiver() {
|
|||||||
|
|
||||||
@Inject lateinit var dataSource: TasksDataSource
|
@Inject lateinit var dataSource: TasksDataSource
|
||||||
@Inject lateinit var notifier: TaskNotifier
|
@Inject lateinit var notifier: TaskNotifier
|
||||||
|
@Inject lateinit var settingsPrefs: SettingsPrefs
|
||||||
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
@@ -30,6 +33,7 @@ class DueReminderReceiver : BroadcastReceiver() {
|
|||||||
val pending = goAsync()
|
val pending = goAsync()
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
|
if (!settingsPrefs.settings.first().remindersEnabled) return@launch
|
||||||
val task = runCatching { dataSource.task(taskId) }.getOrNull()
|
val task = runCatching { dataSource.task(taskId) }.getOrNull()
|
||||||
if (task != null && !task.isClosed) notifier.postDue(task)
|
if (task != null && !task.isClosed) notifier.postDue(task)
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -33,11 +33,11 @@ class ReminderScheduler @Inject constructor(
|
|||||||
) {
|
) {
|
||||||
suspend fun sync() = withContext(io) {
|
suspend fun sync() = withContext(io) {
|
||||||
val provider = providerResolver.resolve()
|
val provider = providerResolver.resolve()
|
||||||
if (provider == null || !providerResolver.hasPermission(provider)) {
|
val settings = settingsPrefs.settings.first()
|
||||||
|
if (provider == null || !providerResolver.hasPermission(provider) || !settings.remindersEnabled) {
|
||||||
clearAll()
|
clearAll()
|
||||||
return@withContext
|
return@withContext
|
||||||
}
|
}
|
||||||
val lead = settingsPrefs.settings.first().reminderLeadMinutes.coerceAtLeast(0)
|
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
val horizon = now + WINDOW_MS
|
val horizon = now + WINDOW_MS
|
||||||
val tasks = runCatching { dataSource.tasks(TaskQuery(includeCompleted = false)) }
|
val tasks = runCatching { dataSource.tasks(TaskQuery(includeCompleted = false)) }
|
||||||
@@ -45,7 +45,13 @@ class ReminderScheduler @Inject constructor(
|
|||||||
|
|
||||||
val desired = tasks
|
val desired = tasks
|
||||||
.filter { !it.isClosed && it.due != null }
|
.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 }
|
.filterValues { it in now..horizon }
|
||||||
|
|
||||||
val previous = store.all()
|
val previous = store.all()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.floret.ui
|
|||||||
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.animation.Crossfade
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.data.tasks.ProviderStatus
|
||||||
import de.jeanlucmakiola.floret.ui.navigation.FloretNavHost
|
import de.jeanlucmakiola.floret.ui.navigation.FloretNavHost
|
||||||
import de.jeanlucmakiola.floret.ui.permission.PermissionViewModel
|
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
|
* 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),
|
action = stringResource(R.string.onboarding_permission_button),
|
||||||
onAction = { launcher.launch(permission.permissionsToRequest.toTypedArray()) },
|
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 -> {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <T> OptionPicker(
|
||||||
|
title: String,
|
||||||
|
options: List<T>,
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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<Int> = 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<String, ReminderUnit> = 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import androidx.compose.material.icons.Icons
|
|||||||
import androidx.compose.material.icons.automirrored.rounded.ListAlt
|
import androidx.compose.material.icons.automirrored.rounded.ListAlt
|
||||||
import androidx.compose.material.icons.rounded.Add
|
import androidx.compose.material.icons.rounded.Add
|
||||||
import androidx.compose.material.icons.rounded.ErrorOutline
|
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.Today
|
||||||
import androidx.compose.material.icons.rounded.Upcoming
|
import androidx.compose.material.icons.rounded.Upcoming
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@@ -47,8 +48,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import de.jeanlucmakiola.floret.R
|
import de.jeanlucmakiola.floret.R
|
||||||
import de.jeanlucmakiola.floret.domain.SmartList
|
import de.jeanlucmakiola.floret.domain.SmartList
|
||||||
import de.jeanlucmakiola.floret.domain.TaskFilter
|
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.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.ui.common.ListColorChip
|
import de.jeanlucmakiola.floret.ui.common.ListColorChip
|
||||||
|
import de.jeanlucmakiola.floret.ui.common.ShapedActionButton
|
||||||
import de.jeanlucmakiola.floret.ui.common.positionOf
|
import de.jeanlucmakiola.floret.ui.common.positionOf
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -61,6 +64,7 @@ import de.jeanlucmakiola.floret.ui.common.positionOf
|
|||||||
fun ListsScreen(
|
fun ListsScreen(
|
||||||
onOpenFilter: (TaskFilter) -> Unit,
|
onOpenFilter: (TaskFilter) -> Unit,
|
||||||
onNewTask: () -> Unit,
|
onNewTask: () -> Unit,
|
||||||
|
onOpenSettings: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: ListsViewModel = hiltViewModel(),
|
viewModel: ListsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
@@ -72,6 +76,17 @@ fun ListsScreen(
|
|||||||
topBar = {
|
topBar = {
|
||||||
MediumTopAppBar(
|
MediumTopAppBar(
|
||||||
title = { Text(stringResource(R.string.app_name)) },
|
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,
|
scrollBehavior = scrollBehavior,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ object Dest {
|
|||||||
/** Home — smart lists + the user's lists. */
|
/** Home — smart lists + the user's lists. */
|
||||||
const val LISTS = "lists"
|
const val LISTS = "lists"
|
||||||
|
|
||||||
|
/** App preferences. */
|
||||||
|
const val SETTINGS = "settings"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One [TaskFilter]'s tasks. Carries *either* `listId` (a real list) *or*
|
* One [TaskFilter]'s tasks. Carries *either* `listId` (a real list) *or*
|
||||||
* `smart` (a [SmartList] name); whichever is set decides the filter.
|
* `smart` (a [SmartList] name); whichever is set decides the filter.
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import de.jeanlucmakiola.floret.ui.detail.TaskDetailViewModel
|
|||||||
import de.jeanlucmakiola.floret.ui.edit.TaskEditScreen
|
import de.jeanlucmakiola.floret.ui.edit.TaskEditScreen
|
||||||
import de.jeanlucmakiola.floret.ui.edit.TaskEditViewModel
|
import de.jeanlucmakiola.floret.ui.edit.TaskEditViewModel
|
||||||
import de.jeanlucmakiola.floret.ui.lists.ListsScreen
|
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.TaskListScreen
|
||||||
import de.jeanlucmakiola.floret.ui.tasklist.TaskListViewModel
|
import de.jeanlucmakiola.floret.ui.tasklist.TaskListViewModel
|
||||||
|
|
||||||
@@ -52,9 +53,14 @@ fun FloretNavHost(modifier: Modifier = Modifier) {
|
|||||||
ListsScreen(
|
ListsScreen(
|
||||||
onOpenFilter = { filter -> nav.navigate(Dest.TaskList.build(filter)) },
|
onOpenFilter = { filter -> nav.navigate(Dest.TaskList.build(filter)) },
|
||||||
onNewTask = { nav.navigate(Dest.TaskEdit.buildNew()) },
|
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 ->
|
composable(Dest.TaskList.route, arguments = Dest.TaskList.arguments) { entry ->
|
||||||
val args = entry.arguments
|
val args = entry.arguments
|
||||||
val filter = Dest.TaskList.filterOf(
|
val filter = Dest.TaskList.filterOf(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Boolean?> = 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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-<tag> translation and adding a matching `<locale>` 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<String> {
|
||||||
|
val tags = mutableListOf<String>()
|
||||||
|
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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SettingsSection?>(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<String?>(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<Long?>(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<Long?>(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()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,10 +3,12 @@ package de.jeanlucmakiola.floret.ui.settings
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
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.Settings
|
||||||
import de.jeanlucmakiola.floret.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.floret.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.floret.data.prefs.ThemeMode
|
import de.jeanlucmakiola.floret.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.floret.data.tasks.TasksRepository
|
import de.jeanlucmakiola.floret.data.tasks.TasksRepository
|
||||||
|
import de.jeanlucmakiola.floret.domain.TaskFormField
|
||||||
import de.jeanlucmakiola.floret.domain.TaskList
|
import de.jeanlucmakiola.floret.domain.TaskList
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -40,4 +42,16 @@ class SettingsViewModel @Inject constructor(
|
|||||||
fun setDynamicColor(enabled: Boolean) = viewModelScope.launch { prefs.setDynamicColor(enabled) }
|
fun setDynamicColor(enabled: Boolean) = viewModelScope.launch { prefs.setDynamicColor(enabled) }
|
||||||
fun setDefaultList(id: Long?) = viewModelScope.launch { prefs.setDefaultListId(id) }
|
fun setDefaultList(id: Long?) = viewModelScope.launch { prefs.setDefaultListId(id) }
|
||||||
fun setReminderLeadMinutes(minutes: Int) = viewModelScope.launch { prefs.setReminderLeadMinutes(minutes) }
|
fun setReminderLeadMinutes(minutes: Int) = viewModelScope.launch { prefs.setReminderLeadMinutes(minutes) }
|
||||||
|
fun setRemindersEnabled(enabled: Boolean) = viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
|
||||||
|
fun 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) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -311,18 +311,24 @@ private fun TaskListBody(
|
|||||||
)
|
)
|
||||||
add(RenderRow(ListRow.Parent(task, canExpand, isExpanded), parentPosition))
|
add(RenderRow(ListRow.Parent(task, canExpand, isExpanded), parentPosition))
|
||||||
if (isExpanded) {
|
if (isExpanded) {
|
||||||
// Subtasks connect up to the parent (small top) and to
|
// When the add row is hidden (M5 setting), the last subtask
|
||||||
// each other; the inline add row below closes the group,
|
// instead rounds the group off (full bottom).
|
||||||
// so every subtask is mid-run (small top + bottom).
|
val sorted = children.sortedBy { it.isCompleted }
|
||||||
children.sortedBy { it.isCompleted }.forEach { sub ->
|
sorted.forEachIndexed { c, sub ->
|
||||||
add(RenderRow(ListRow.Sub(sub), cornerPosition(topFull = false, bottomFull = false)))
|
// 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),
|
// The add row rounds the group off (full bottom),
|
||||||
// independent of whatever top-level task follows.
|
// independent of whatever top-level task follows.
|
||||||
|
if (state.showAddSubtaskRow) {
|
||||||
add(RenderRow(ListRow.AddSub(task), cornerPosition(topFull = false, bottomFull = true)))
|
add(RenderRow(ListRow.AddSub(task), cornerPosition(topFull = false, bottomFull = true)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
itemsIndexed(rows, key = { _, r -> r.row.key }) { index, r ->
|
itemsIndexed(rows, key = { _, r -> r.row.key }) { index, r ->
|
||||||
val lastInRun = index == rows.lastIndex
|
val lastInRun = index == rows.lastIndex
|
||||||
when (val row = r.row) {
|
when (val row = r.row) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.floret.ui.tasklist
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import de.jeanlucmakiola.floret.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.floret.data.tasks.TasksRepository
|
import de.jeanlucmakiola.floret.data.tasks.TasksRepository
|
||||||
import de.jeanlucmakiola.floret.domain.Task
|
import de.jeanlucmakiola.floret.domain.Task
|
||||||
import de.jeanlucmakiola.floret.domain.TaskFilter
|
import de.jeanlucmakiola.floret.domain.TaskFilter
|
||||||
@@ -30,7 +31,12 @@ sealed interface TaskListUiState {
|
|||||||
* [TaskFilter.OfList] (for the top-bar title), `null` for smart lists —
|
* [TaskFilter.OfList] (for the top-bar title), `null` for smart lists —
|
||||||
* the screen falls back to the smart label in that case.
|
* the screen falls back to the smart label in that case.
|
||||||
*/
|
*/
|
||||||
data class Content(val tasks: List<Task>, val listName: String? = null) : TaskListUiState
|
data class Content(
|
||||||
|
val tasks: List<Task>,
|
||||||
|
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
|
@HiltViewModel
|
||||||
class TaskListViewModel @Inject constructor(
|
class TaskListViewModel @Inject constructor(
|
||||||
private val repository: TasksRepository,
|
private val repository: TasksRepository,
|
||||||
|
private val settingsPrefs: SettingsPrefs,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val filter = MutableStateFlow<TaskFilter?>(null)
|
private val filter = MutableStateFlow<TaskFilter?>(null)
|
||||||
@@ -62,10 +69,15 @@ class TaskListViewModel @Inject constructor(
|
|||||||
tasks.map { TaskListUiState.Content(it) }
|
tasks.map { TaskListUiState.Content(it) }
|
||||||
}
|
}
|
||||||
// Drop rows pending an undoable delete so the row vanishes on swipe
|
// Drop rows pending an undoable delete so the row vanishes on swipe
|
||||||
// while the actual provider delete waits for the snackbar to commit.
|
// while the actual provider delete waits for the snackbar to commit,
|
||||||
combine(content, pendingDeletes) { st, pending ->
|
// 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) {
|
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 {
|
} else {
|
||||||
st
|
st
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,20 @@
|
|||||||
<string name="reminder_1_hour">1 hour before</string>
|
<string name="reminder_1_hour">1 hour before</string>
|
||||||
<string name="reminder_1_day">1 day before</string>
|
<string name="reminder_1_day">1 day before</string>
|
||||||
|
|
||||||
|
<!-- Reminder custom lead time + per-list overrides -->
|
||||||
|
<string name="reminder_custom">Custom</string>
|
||||||
|
<string name="reminder_custom_with_value">Custom (%1$s)</string>
|
||||||
|
<string name="reminder_custom_set">Set</string>
|
||||||
|
<string name="reminder_custom_amount">Enter an amount</string>
|
||||||
|
<string name="reminder_use_default">Use default</string>
|
||||||
|
<string name="reminder_unit_minutes">Minutes</string>
|
||||||
|
<string name="reminder_unit_hours">Hours</string>
|
||||||
|
<string name="reminder_unit_days">Days</string>
|
||||||
|
<string name="reminder_unit_weeks">Weeks</string>
|
||||||
|
<string name="settings_list_reminders_title">Per-list reminders</string>
|
||||||
|
<string name="settings_list_reminders_hint">Override the default reminder for individual lists</string>
|
||||||
|
<string name="settings_list_reminder_inherits">Default (%1$s)</string>
|
||||||
|
|
||||||
<!-- Form validation -->
|
<!-- Form validation -->
|
||||||
<string name="error_blank_title">Enter a title.</string>
|
<string name="error_blank_title">Enter a title.</string>
|
||||||
<string name="error_no_list">Choose a list.</string>
|
<string name="error_no_list">Choose a list.</string>
|
||||||
@@ -128,4 +142,77 @@
|
|||||||
<string name="onboarding_permission_button">Grant task access</string>
|
<string name="onboarding_permission_button">Grant task access</string>
|
||||||
<string name="onboarding_install_opentasks">Install OpenTasks</string>
|
<string name="onboarding_install_opentasks">Install OpenTasks</string>
|
||||||
<string name="onboarding_install_tasksorg">Install tasks.org</string>
|
<string name="onboarding_install_tasksorg">Install tasks.org</string>
|
||||||
|
|
||||||
|
<!-- Reminder onboarding (one-time, after the provider grant) -->
|
||||||
|
<string name="reminder_onboarding_title">Never miss what\'s due</string>
|
||||||
|
<string name="reminder_onboarding_body">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.</string>
|
||||||
|
<string name="reminder_onboarding_enable_button">Enable reminders</string>
|
||||||
|
<string name="reminder_onboarding_skip_button">Not now</string>
|
||||||
|
<string name="reminder_benefit_delivery_title">Floret reminds you</string>
|
||||||
|
<string name="reminder_benefit_delivery_body">It schedules a notification for each task with a due date.</string>
|
||||||
|
<string name="reminder_benefit_timing_title">On your schedule</string>
|
||||||
|
<string name="reminder_benefit_timing_body">Choose how far ahead to be reminded in Settings.</string>
|
||||||
|
<string name="reminder_benefit_reversible_title">Change it anytime</string>
|
||||||
|
<string name="reminder_benefit_reversible_body">Turn reminders off whenever you like — it\'s just a switch.</string>
|
||||||
|
|
||||||
|
<!-- Settings -->
|
||||||
|
<string name="settings_title">Settings</string>
|
||||||
|
<string name="settings_section_appearance">Appearance</string>
|
||||||
|
<string name="settings_appearance_subtitle">Theme and colour</string>
|
||||||
|
<string name="settings_section_task_form">Task form</string>
|
||||||
|
<string name="settings_task_form_subtitle">Default fields, list and subtasks</string>
|
||||||
|
<string name="settings_reminders_subtitle">Notifications for due tasks</string>
|
||||||
|
<string name="settings_form_fields_hint">Fields shown by default on a new task. The rest sit behind \"More fields\".</string>
|
||||||
|
<string name="settings_dynamic_color_unavailable">Available on Android 12 and later</string>
|
||||||
|
<!-- About -->
|
||||||
|
<string name="settings_about_author">by Jean-Luc Makiola</string>
|
||||||
|
<string name="settings_about_source">Source</string>
|
||||||
|
<string name="settings_license">License</string>
|
||||||
|
<string name="settings_about_support">Support development</string>
|
||||||
|
<string name="settings_about_version">Version %1$s</string>
|
||||||
|
<string name="settings_about_logo_desc">Floret app icon</string>
|
||||||
|
<string name="settings_language">App language</string>
|
||||||
|
<string name="settings_language_auto">System default</string>
|
||||||
|
<string name="settings_report_problem">Report a problem</string>
|
||||||
|
<string name="settings_report_problem_hint">Open the issue tracker</string>
|
||||||
|
<string name="about_source_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/floret</string>
|
||||||
|
<string name="report_issue_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/floret/issues/new</string>
|
||||||
|
<string name="about_license_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/floret/src/branch/main/LICENSE</string>
|
||||||
|
<string name="about_support_url" translatable="false">https://ko-fi.com/jeanlucmakiola</string>
|
||||||
|
<string name="settings_theme">Theme</string>
|
||||||
|
<string name="settings_theme_system">Follow system</string>
|
||||||
|
<string name="settings_theme_light">Light</string>
|
||||||
|
<string name="settings_theme_dark">Dark</string>
|
||||||
|
<string name="settings_dynamic_color">Dynamic colour</string>
|
||||||
|
<string name="settings_dynamic_color_hint">Use colours from your wallpaper</string>
|
||||||
|
<string name="settings_section_reminders">Reminders</string>
|
||||||
|
<string name="settings_reminders">Due reminders</string>
|
||||||
|
<string name="settings_reminders_hint">Notify me when a task is due</string>
|
||||||
|
<string name="settings_default_reminder">When to remind</string>
|
||||||
|
<string name="settings_exact_alarms">Exact timing</string>
|
||||||
|
<string name="settings_exact_alarms_allowed">Reminders fire at the exact time</string>
|
||||||
|
<string name="settings_exact_alarms_blocked">Blocked — tap to allow exact reminders</string>
|
||||||
|
<string name="settings_section_tasks">Tasks</string>
|
||||||
|
<string name="settings_default_list">Default list</string>
|
||||||
|
<string name="settings_default_list_first">First available list</string>
|
||||||
|
<string name="settings_add_subtask_row">Show \"add a subtask\" row</string>
|
||||||
|
<string name="settings_add_subtask_row_hint">An add field at the end of an expanded task\'s subtasks</string>
|
||||||
|
|
||||||
|
<!-- Reminder lead times (custom amounts) -->
|
||||||
|
<plurals name="reminder_minutes">
|
||||||
|
<item quantity="one">%1$d minute before</item>
|
||||||
|
<item quantity="other">%1$d minutes before</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_hours">
|
||||||
|
<item quantity="one">%1$d hour before</item>
|
||||||
|
<item quantity="other">%1$d hours before</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_days">
|
||||||
|
<item quantity="one">%1$d day before</item>
|
||||||
|
<item quantity="other">%1$d days before</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_weeks">
|
||||||
|
<item quantity="one">%1$d week before</item>
|
||||||
|
<item quantity="other">%1$d weeks before</item>
|
||||||
|
</plurals>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
11
app/src/main/res/xml/locales_config.xml
Normal file
11
app/src/main/res/xml/locales_config.xml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
The languages Floret ships translations for. Single source of truth: each
|
||||||
|
entry needs a matching res/values-<tag>/strings.xml, and is surfaced in both
|
||||||
|
the in-app language picker (parsed at runtime by AppLanguage) and the system
|
||||||
|
per-app language settings (Android 13+, via android:localeConfig). To add a
|
||||||
|
translation, drop in the values-<tag> folder and add one <locale> line here.
|
||||||
|
-->
|
||||||
|
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<locale android:name="en" />
|
||||||
|
</locale-config>
|
||||||
@@ -12,11 +12,14 @@ Status legend: ✅ done · 🚧 in progress · ⬜ not started
|
|||||||
|
|
||||||
The full non-visual stack ("backoffice") over the OpenTasks `TaskContract`
|
The full non-visual stack ("backoffice") over the OpenTasks `TaskContract`
|
||||||
provider is **done and unit-tested**, and the Material 3 Expressive UI is built
|
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,
|
headers) → detail / edit with full CRUD, date-time pickers, priority,
|
||||||
percent-complete, conflict-safe saves, per-task reminders, and subtask
|
percent-complete, conflict-safe saves, per-task reminders, and subtask
|
||||||
create + reparent. Remaining work is M5 (notification / exact-alarm onboarding,
|
create + reparent — plus a one-time reminder onboarding step and a Settings
|
||||||
default reminder UI) and M6 (Settings screen, Glance widget, i18n, release).
|
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
|
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'
|
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"
|
`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
|
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
|
that omit off-day children stay collapsed). `TaskDetail.parent` carries the
|
||||||
parent for the detail card.
|
parent for the detail card.
|
||||||
@@ -95,25 +98,32 @@ ViewModels.
|
|||||||
promote it); switching list clears the now-invalid parent. Candidates stay
|
promote it); switching list clears the now-invalid parent. Candidates stay
|
||||||
active + top-level to keep nesting one level deep, matching the detail screen.
|
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,
|
The engine exists (M1: `ReminderScheduler` + boot / provider-change re-sync,
|
||||||
`DueReminderReceiver`, `TaskNotifier`).
|
`DueReminderReceiver`, `TaskNotifier`).
|
||||||
- ✅ Per-task reminder-offset UI (`ReminderPickerDialog` → `TaskForm`
|
- ✅ Per-task reminder-offset UI (`ReminderPickerDialog` → `TaskForm`
|
||||||
`reminderMinutesBeforeDue`).
|
`reminderMinutesBeforeDue`).
|
||||||
- ⬜ `POST_NOTIFICATIONS` + exact-alarm onboarding flow — currently only graceful
|
- ✅ `POST_NOTIFICATIONS` onboarding flow — a one-time `ReminderOnboardingScreen`
|
||||||
runtime checks (`TaskNotifier.canPost`, `canScheduleExactAlarms`), no
|
(Calendula's shell + copy adapted for tasks) gated in `RootScreen` after the
|
||||||
user-facing gate.
|
provider/permission grant; it requests the runtime permission (API 33+) and
|
||||||
- ⬜ Default reminder-offset settings UI — `SettingsPrefs`/`SettingsViewModel`
|
records the choice (`reminderOnboardingDone`). Re-requestable from Settings.
|
||||||
back it, but no Composable exposes it.
|
- ✅ Exact-alarm surface — a status row in Settings → Reminders, shown only on
|
||||||
- ⬜ End-to-end verification on device.
|
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
|
### 🚧 M6 — Settings, widget, i18n, release
|
||||||
- ✅ F-Droid metadata scaffolded (`fdroid-metadata/`).
|
- ✅ F-Droid metadata scaffolded (`fdroid-metadata/`).
|
||||||
- ⬜ Settings screen — `SettingsViewModel` exists but no `SettingsScreen`
|
- ✅ Settings screen — landed early with M5 (`SettingsScreen` in the nav graph,
|
||||||
Composable and it is not in the nav graph (only used by `MainActivity` for
|
reached by the gear on the lists overview). Covers theme, dynamic colour, due
|
||||||
app-level theming). Needs theme / dynamic-color / language, default list,
|
reminders (toggle + default offset + exact-alarm status), default list, and the
|
||||||
default reminder, and the opt-out toggle for the task list's inline
|
add-a-subtask-row opt-out. Still ⬜ a **language** entry (deferred until there
|
||||||
add-a-subtask row (on by default since M4).
|
are translations to switch to).
|
||||||
- ⬜ Glance task-list widget — deps present in `build.gradle.kts`, zero impl.
|
- ⬜ Glance task-list widget — deps present in `build.gradle.kts`, zero impl.
|
||||||
- ⬜ Translations — only `res/values/` (English); no `values-XX`.
|
- ⬜ Translations — only `res/values/` (English); no `values-XX`.
|
||||||
- ⬜ Finalize F-Droid metadata, confirm CI release flow.
|
- ⬜ Finalize F-Droid metadata, confirm CI release flow.
|
||||||
|
|||||||
Reference in New Issue
Block a user