Compare commits
9 Commits
e4c8dbf2c1
...
211450bc46
| Author | SHA1 | Date | |
|---|---|---|---|
| 211450bc46 | |||
| 9d134be621 | |||
| 37e3c4e659 | |||
| 78632865f9 | |||
| e7a62e71df | |||
| 4c05c86e95 | |||
| 37662e83bb | |||
| 63c6191676 | |||
| 4994f5ec2c |
@@ -121,6 +121,8 @@ dependencies {
|
|||||||
implementation(libs.kotlinx.datetime)
|
implementation(libs.kotlinx.datetime)
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
implementation("de.jeanlucmakiola.floret:core-time")
|
implementation("de.jeanlucmakiola.floret:core-time")
|
||||||
|
implementation("de.jeanlucmakiola.floret:core-reminders")
|
||||||
|
implementation("de.jeanlucmakiola.floret:core-locale")
|
||||||
implementation("de.jeanlucmakiola.floret:core-crash")
|
implementation("de.jeanlucmakiola.floret:core-crash")
|
||||||
implementation("de.jeanlucmakiola.floret:identity")
|
implementation("de.jeanlucmakiola.floret:identity")
|
||||||
implementation("de.jeanlucmakiola.floret:components")
|
implementation("de.jeanlucmakiola.floret:components")
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import androidx.datastore.preferences.core.longPreferencesKey
|
|||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderOverrideCodec
|
||||||
|
import de.jeanlucmakiola.floret.reminders.applyReminderOverride
|
||||||
|
import de.jeanlucmakiola.floret.reminders.reminderLeadFor
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -16,16 +20,6 @@ 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,
|
||||||
@@ -47,11 +41,7 @@ data class Settings(
|
|||||||
) {
|
) {
|
||||||
/** The lead time for a task in [listId]: its override if set, else the global default. */
|
/** The lead time for a task in [listId]: its override if set, else the global default. */
|
||||||
fun reminderLeadFor(listId: Long): Int? =
|
fun reminderLeadFor(listId: Long): Int? =
|
||||||
if (perListReminderOverride.containsKey(listId)) {
|
perListReminderOverride.reminderLeadFor(listId, reminderLeadMinutes)
|
||||||
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. */
|
||||||
@@ -68,7 +58,7 @@ class SettingsPrefs @Inject constructor(
|
|||||||
reminderLeadMinutes = p[REMINDER_LEAD] ?: 0,
|
reminderLeadMinutes = p[REMINDER_LEAD] ?: 0,
|
||||||
remindersEnabled = p[REMINDERS_ENABLED] ?: true,
|
remindersEnabled = p[REMINDERS_ENABLED] ?: true,
|
||||||
showAddSubtaskRow = p[SHOW_ADD_SUBTASK_ROW] ?: true,
|
showAddSubtaskRow = p[SHOW_ADD_SUBTASK_ROW] ?: true,
|
||||||
perListReminderOverride = parseReminderOverrides(p[LIST_REMINDER_OVERRIDE]),
|
perListReminderOverride = reminderCodec.parse(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(),
|
||||||
@@ -92,15 +82,11 @@ class SettingsPrefs @Inject constructor(
|
|||||||
|
|
||||||
suspend fun setShowAddSubtaskRow(show: Boolean) = dataStore.edit { it[SHOW_ADD_SUBTASK_ROW] = show }
|
suspend fun setShowAddSubtaskRow(show: Boolean) = dataStore.edit { it[SHOW_ADD_SUBTASK_ROW] = show }
|
||||||
|
|
||||||
/** Set (or clear, via [ListReminderOverride.Inherit]) a list's reminder override. */
|
/** Set (or clear, via [ReminderOverride.Inherit]) a list's reminder override. */
|
||||||
suspend fun setListReminderOverride(listId: Long, override: ListReminderOverride) = dataStore.edit { p ->
|
suspend fun setListReminderOverride(listId: Long, override: ReminderOverride) = dataStore.edit { p ->
|
||||||
val current = parseReminderOverrides(p[LIST_REMINDER_OVERRIDE]).toMutableMap()
|
val current = reminderCodec.parse(p[LIST_REMINDER_OVERRIDE]).toMutableMap()
|
||||||
when (override) {
|
current.applyReminderOverride(listId, override)
|
||||||
ListReminderOverride.Inherit -> current.remove(listId)
|
p[LIST_REMINDER_OVERRIDE] = reminderCodec.serialize(current)
|
||||||
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 {
|
||||||
@@ -120,20 +106,9 @@ class SettingsPrefs @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val OVERRIDE_ENTRY_SEP = ";"
|
/**
|
||||||
private const val OVERRIDE_KV_SEP = ":"
|
* Agendula's stored dialect for the per-list override map: `id:minutes` entries
|
||||||
private const val OVERRIDE_NONE = "none"
|
* joined by `;` (the family default). Fixed at release — don't change without a
|
||||||
|
* data migration.
|
||||||
/** Decode "id:minutes;id:none;…" into a map (value null = explicit no-reminder). */
|
*/
|
||||||
private fun parseReminderOverrides(stored: String?): Map<Long, Int?> {
|
private val reminderCodec = ReminderOverrideCodec.DEFAULT
|
||||||
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}" }
|
|
||||||
|
|||||||
@@ -5,18 +5,11 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import de.jeanlucmakiola.agendula.R
|
import de.jeanlucmakiola.agendula.R
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
||||||
|
|
||||||
/** Common reminder lead times offered as quick picks in the reminder pickers. */
|
/** Common reminder lead times offered as quick picks in the reminder pickers. */
|
||||||
val REMINDER_PRESETS = listOf(0, 5, 10, 30, 60, 1_440)
|
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
|
@StringRes
|
||||||
fun reminderUnitLabel(unit: ReminderUnit): Int = when (unit) {
|
fun reminderUnitLabel(unit: ReminderUnit): Int = when (unit) {
|
||||||
ReminderUnit.Minutes -> R.string.reminder_unit_minutes
|
ReminderUnit.Minutes -> R.string.reminder_unit_minutes
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ package de.jeanlucmakiola.agendula.ui.common
|
|||||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
import de.jeanlucmakiola.floret.components.DialogAmountField
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
||||||
|
import de.jeanlucmakiola.floret.reminders.decomposeReminderMinutes
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.expandVertically
|
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||||
import androidx.compose.animation.fadeIn
|
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.animation.shrinkVertically
|
|
||||||
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.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -33,40 +34,38 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.agendula.R
|
import de.jeanlucmakiola.agendula.R
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.ListReminderOverride
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reminder lead-time picker, full-screen: the grouped list of preset lead times
|
* 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.
|
* plus a "Custom" row that expands an inline amount field + unit selector.
|
||||||
* [allowInherit] adds a "Use default" row (per-list overrides); [allowNone] adds
|
* [allowInherit] adds a "Use default" row (per-list overrides); [allowNone] adds
|
||||||
* a "None" row. Returns the choice as a [ListReminderOverride]. Ported from
|
* a "None" row. Returns the choice as a [ReminderOverride]. Ported from
|
||||||
* Calendula's ReminderDefaultPicker.
|
* Calendula's ReminderDefaultPicker.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ReminderLeadPicker(
|
fun ReminderLeadPicker(
|
||||||
title: String,
|
title: String,
|
||||||
selected: ListReminderOverride,
|
selected: ReminderOverride,
|
||||||
allowInherit: Boolean,
|
allowInherit: Boolean,
|
||||||
allowNone: Boolean,
|
allowNone: Boolean,
|
||||||
onSelect: (ListReminderOverride) -> Unit,
|
onSelect: (ReminderOverride) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
presets: List<Int> = REMINDER_PRESETS,
|
presets: List<Int> = REMINDER_PRESETS,
|
||||||
) {
|
) {
|
||||||
val selectedMinutes = (selected as? ListReminderOverride.Minutes)?.minutes
|
val selectedMinutes = (selected as? ReminderOverride.Minutes)?.minutes
|
||||||
val customSelected = selectedMinutes != null && selectedMinutes !in presets
|
val customSelected = selectedMinutes != null && selectedMinutes !in presets
|
||||||
val seed = decomposeReminder(selectedMinutes?.takeIf { customSelected })
|
val seed = decomposeReminderMinutes(selectedMinutes?.takeIf { customSelected })
|
||||||
|
|
||||||
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
||||||
var amountText by rememberSaveable { mutableStateOf(seed.first) }
|
var amountText by rememberSaveable { mutableStateOf(seed.amount?.toString() ?: "") }
|
||||||
var unit by rememberSaveable { mutableStateOf(seed.second) }
|
var unit by rememberSaveable { mutableStateOf(seed.unit) }
|
||||||
|
|
||||||
val options = buildList {
|
val options = buildList {
|
||||||
if (allowInherit) add(ListReminderOverride.Inherit)
|
if (allowInherit) add(ReminderOverride.Inherit)
|
||||||
if (allowNone) add(ListReminderOverride.None)
|
if (allowNone) add(ReminderOverride.None)
|
||||||
presets.forEach { add(ListReminderOverride.Minutes(it)) }
|
presets.forEach { add(ReminderOverride.Minutes(it)) }
|
||||||
}
|
}
|
||||||
val rowCount = options.size + 1 // + the custom row
|
val rowCount = options.size + 1 // + the custom row
|
||||||
|
|
||||||
@@ -97,8 +96,8 @@ fun ReminderLeadPicker(
|
|||||||
)
|
)
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = customExpanded,
|
visible = customExpanded,
|
||||||
enter = expandVertically() + fadeIn(),
|
enter = expandEnter(),
|
||||||
exit = shrinkVertically() + fadeOut(),
|
exit = collapseExit(),
|
||||||
) {
|
) {
|
||||||
CustomReminderEditor(
|
CustomReminderEditor(
|
||||||
amountText = amountText,
|
amountText = amountText,
|
||||||
@@ -106,7 +105,7 @@ fun ReminderLeadPicker(
|
|||||||
unit = unit,
|
unit = unit,
|
||||||
onUnitChange = { unit = it },
|
onUnitChange = { unit = it },
|
||||||
onConfirm = { minutes ->
|
onConfirm = { minutes ->
|
||||||
onSelect(ListReminderOverride.Minutes(minutes))
|
onSelect(ReminderOverride.Minutes(minutes))
|
||||||
onDismiss()
|
onDismiss()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -144,19 +143,7 @@ private fun CustomReminderEditor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Surface(
|
DialogAmountField(value = amountText, onValueChange = onAmountChange, placeholder = "10")
|
||||||
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))
|
Spacer(Modifier.width(16.dp))
|
||||||
Text(
|
Text(
|
||||||
text = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor) }
|
text = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor) }
|
||||||
@@ -178,17 +165,8 @@ private fun CustomReminderEditor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun reminderOverrideLabel(override: ListReminderOverride): String = when (override) {
|
private fun reminderOverrideLabel(override: ReminderOverride): String = when (override) {
|
||||||
ListReminderOverride.Inherit -> stringResource(R.string.reminder_use_default)
|
ReminderOverride.Inherit -> stringResource(R.string.reminder_use_default)
|
||||||
ListReminderOverride.None -> stringResource(R.string.reminder_none)
|
ReminderOverride.None -> stringResource(R.string.reminder_none)
|
||||||
is ListReminderOverride.Minutes -> reminderLeadTimeLabel(override.minutes)
|
is ReminderOverride.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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
package de.jeanlucmakiola.agendula.ui.edit
|
package de.jeanlucmakiola.agendula.ui.edit
|
||||||
|
|
||||||
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.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
@@ -95,6 +90,7 @@ import de.jeanlucmakiola.agendula.domain.TaskList
|
|||||||
import de.jeanlucmakiola.agendula.domain.TaskSection
|
import de.jeanlucmakiola.agendula.domain.TaskSection
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskSections
|
import de.jeanlucmakiola.agendula.domain.TaskSections
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.OptionalFormSection
|
||||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||||
import de.jeanlucmakiola.floret.components.OptionCard
|
import de.jeanlucmakiola.floret.components.OptionCard
|
||||||
import de.jeanlucmakiola.agendula.ui.common.priorityFill
|
import de.jeanlucmakiola.agendula.ui.common.priorityFill
|
||||||
@@ -545,17 +541,6 @@ private fun EditContent(
|
|||||||
* open instead of popping in. (Initially-visible sections render without
|
* open instead of popping in. (Initially-visible sections render without
|
||||||
* animating on the first frame they're added.)
|
* animating on the first frame they're added.)
|
||||||
*/
|
*/
|
||||||
@Composable
|
|
||||||
private fun OptionalFormSection(visible: Boolean, content: @Composable ColumnScope.() -> Unit) {
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = visible,
|
|
||||||
enter = expandVertically() + fadeIn(),
|
|
||||||
exit = shrinkVertically() + fadeOut(),
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.fillMaxWidth(), content = content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One info card mirroring the detail screen's card: tonal container, leading
|
* One info card mirroring the detail screen's card: tonal container, leading
|
||||||
* icon in the gutter, value to the right. Optionally clickable as a whole.
|
* icon in the gutter, value to the right. Optionally clickable as a whole.
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
package de.jeanlucmakiola.agendula.ui.navigation
|
package de.jeanlucmakiola.agendula.ui.navigation
|
||||||
|
|
||||||
import de.jeanlucmakiola.floret.identity.rememberNavSlideSpec
|
|
||||||
import androidx.compose.animation.EnterTransition
|
import androidx.compose.animation.EnterTransition
|
||||||
import androidx.compose.animation.ExitTransition
|
import androidx.compose.animation.ExitTransition
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.slideOutHorizontally
|
import androidx.compose.animation.scaleOut
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -35,7 +34,6 @@ import de.jeanlucmakiola.agendula.ui.tasklist.TaskListViewModel
|
|||||||
@Composable
|
@Composable
|
||||||
fun AgendulaNavHost(modifier: Modifier = Modifier) {
|
fun AgendulaNavHost(modifier: Modifier = Modifier) {
|
||||||
val nav = rememberNavController()
|
val nav = rememberNavController()
|
||||||
val slide = rememberNavSlideSpec()
|
|
||||||
|
|
||||||
NavHost(
|
NavHost(
|
||||||
navController = nav,
|
navController = nav,
|
||||||
@@ -43,12 +41,13 @@ fun AgendulaNavHost(modifier: Modifier = Modifier) {
|
|||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
// Only the screen on top moves; the background is always held static.
|
// Only the screen on top moves; the background is always held static.
|
||||||
// Forward is a plain fade-in over the held screen — calm, not a slide.
|
// Forward is a plain fade-in over the held screen — calm, not a slide.
|
||||||
// Back (and the predictive-back drag, which drives popExit) is the snappy
|
// Back is the predictive-back peek: navigation-compose composes the
|
||||||
// slide-out to the right, revealing the held screen unmoved.
|
// screen being revealed underneath and seeks popExit with the gesture, so
|
||||||
|
// the leaving screen scales down to reveal the (unmoved) previous screen.
|
||||||
enterTransition = { fadeIn() },
|
enterTransition = { fadeIn() },
|
||||||
exitTransition = { ExitTransition.None },
|
exitTransition = { ExitTransition.None },
|
||||||
popEnterTransition = { EnterTransition.None },
|
popEnterTransition = { EnterTransition.None },
|
||||||
popExitTransition = { slideOutHorizontally(slide) { it } + fadeOut() },
|
popExitTransition = { scaleOut(targetScale = 0.9f) + fadeOut() },
|
||||||
) {
|
) {
|
||||||
composable(Dest.LISTS) {
|
composable(Dest.LISTS) {
|
||||||
ListsScreen(
|
ListsScreen(
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.ui.permission
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agendula's onboarding hero: an icon in the brand squircle. Each app keeps its
|
||||||
|
* own hero; the surrounding shell comes from floret-kit's OnboardingScaffold.
|
||||||
|
*/
|
||||||
|
@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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
package de.jeanlucmakiola.agendula.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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -22,6 +22,9 @@ import androidx.compose.ui.text.style.TextAlign
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import de.jeanlucmakiola.agendula.R
|
import de.jeanlucmakiola.agendula.R
|
||||||
|
import de.jeanlucmakiola.floret.components.BenefitRow
|
||||||
|
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||||
|
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One-time onboarding step after the tasks-provider grant: explains that Agendula
|
* One-time onboarding step after the tasks-provider grant: explains that Agendula
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
package de.jeanlucmakiola.agendula.ui.settings
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
|
||||||
import androidx.core.os.LocaleListCompat
|
|
||||||
import de.jeanlucmakiola.agendula.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() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,10 +11,8 @@ import androidx.activity.compose.BackHandler
|
|||||||
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.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.expandVertically
|
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.shrinkVertically
|
|
||||||
import androidx.compose.animation.slideInHorizontally
|
import androidx.compose.animation.slideInHorizontally
|
||||||
import androidx.compose.animation.slideOutHorizontally
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@@ -22,7 +20,6 @@ import androidx.compose.foundation.isSystemInDarkTheme
|
|||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -52,10 +49,8 @@ import androidx.compose.material.icons.rounded.Circle
|
|||||||
import androidx.compose.material.icons.rounded.Flag
|
import androidx.compose.material.icons.rounded.Flag
|
||||||
import androidx.compose.material.icons.rounded.Percent
|
import androidx.compose.material.icons.rounded.Percent
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FilledTonalButton
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -84,16 +79,21 @@ import androidx.lifecycle.LifecycleEventObserver
|
|||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.agendula.R
|
import de.jeanlucmakiola.agendula.R
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.ListReminderOverride
|
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||||
|
import de.jeanlucmakiola.floret.components.AboutCard
|
||||||
|
import de.jeanlucmakiola.floret.components.AboutLink
|
||||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||||
|
import de.jeanlucmakiola.floret.components.LanguagePickerRow
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
import de.jeanlucmakiola.agendula.ui.common.ReminderLeadPicker
|
import de.jeanlucmakiola.agendula.ui.common.ReminderLeadPicker
|
||||||
import de.jeanlucmakiola.floret.components.pastelize
|
import de.jeanlucmakiola.floret.components.pastelize
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||||
|
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel
|
import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel
|
||||||
|
|
||||||
/** The settings sub-screens reached from the hub's category rows. */
|
/** The settings sub-screens reached from the hub's category rows. */
|
||||||
@@ -163,7 +163,30 @@ private fun SettingsHub(
|
|||||||
onOpenSection: (SettingsSection) -> Unit,
|
onOpenSection: (SettingsSection) -> Unit,
|
||||||
) {
|
) {
|
||||||
CollapsingScaffold(title = stringResource(R.string.settings_title), onBack = onBack) {
|
CollapsingScaffold(title = stringResource(R.string.settings_title), onBack = onBack) {
|
||||||
Box(Modifier.padding(horizontal = 16.dp)) { AboutCard() }
|
Box(Modifier.padding(horizontal = 16.dp)) {
|
||||||
|
AboutCard(
|
||||||
|
logo = { AppLogo() },
|
||||||
|
appName = stringResource(R.string.app_name),
|
||||||
|
author = stringResource(R.string.settings_about_author),
|
||||||
|
primaryLinks = listOf(
|
||||||
|
AboutLink(
|
||||||
|
icon = Icons.Default.Code,
|
||||||
|
label = stringResource(R.string.settings_about_source),
|
||||||
|
url = stringResource(R.string.about_source_url),
|
||||||
|
),
|
||||||
|
AboutLink(
|
||||||
|
icon = Icons.Default.Gavel,
|
||||||
|
label = stringResource(R.string.settings_license),
|
||||||
|
url = stringResource(R.string.about_license_url),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
highlightLink = AboutLink(
|
||||||
|
icon = Icons.Default.Favorite,
|
||||||
|
label = stringResource(R.string.settings_about_support),
|
||||||
|
url = stringResource(R.string.about_support_url),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
@@ -187,46 +210,19 @@ private fun SettingsHub(
|
|||||||
leading = { CategoryIcon(Icons.Default.Notifications, ChipAccent.Primary) },
|
leading = { CategoryIcon(Icons.Default.Notifications, ChipAccent.Primary) },
|
||||||
onClick = { onOpenSection(SettingsSection.Reminders) },
|
onClick = { onOpenSection(SettingsSection.Reminders) },
|
||||||
)
|
)
|
||||||
LanguageRow(position = Position.Middle)
|
LanguagePickerRow(
|
||||||
|
position = Position.Middle,
|
||||||
|
title = stringResource(R.string.settings_language),
|
||||||
|
autoLabel = stringResource(R.string.settings_language_auto),
|
||||||
|
localesConfig = R.xml.locales_config,
|
||||||
|
leading = { CategoryIcon(Icons.Default.Language, ChipAccent.Neutral) },
|
||||||
|
)
|
||||||
ReportProblemRow(position = Position.Bottom)
|
ReportProblemRow(position = Position.Bottom)
|
||||||
|
|
||||||
AppVersionText()
|
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. */
|
/** Opens the project's issue tracker; no data leaves the device until submitted. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun ReportProblemRow(position: Position) {
|
private fun ReportProblemRow(position: Position) {
|
||||||
@@ -241,10 +237,6 @@ private fun ReportProblemRow(position: Position) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun languageLabel(tag: String?): String =
|
|
||||||
if (tag == null) stringResource(R.string.settings_language_auto) else AppLanguage.displayName(tag)
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Sub-screens
|
// Sub-screens
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -452,8 +444,8 @@ private fun RemindersScreen(
|
|||||||
)
|
)
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = perListExpanded,
|
visible = perListExpanded,
|
||||||
enter = expandVertically() + fadeIn(),
|
enter = expandEnter(),
|
||||||
exit = shrinkVertically() + fadeOut(),
|
exit = collapseExit(),
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
lists.forEachIndexed { index, list ->
|
lists.forEachIndexed { index, list ->
|
||||||
@@ -479,10 +471,10 @@ private fun RemindersScreen(
|
|||||||
if (showOffset) {
|
if (showOffset) {
|
||||||
ReminderLeadPicker(
|
ReminderLeadPicker(
|
||||||
title = stringResource(R.string.settings_default_reminder),
|
title = stringResource(R.string.settings_default_reminder),
|
||||||
selected = ListReminderOverride.Minutes(state.settings.reminderLeadMinutes),
|
selected = ReminderOverride.Minutes(state.settings.reminderLeadMinutes),
|
||||||
allowInherit = false,
|
allowInherit = false,
|
||||||
allowNone = false,
|
allowNone = false,
|
||||||
onSelect = { if (it is ListReminderOverride.Minutes) viewModel.setReminderLeadMinutes(it.minutes) },
|
onSelect = { if (it is ReminderOverride.Minutes) viewModel.setReminderLeadMinutes(it.minutes) },
|
||||||
onDismiss = { showOffset = false },
|
onDismiss = { showOffset = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -500,84 +492,28 @@ private fun RemindersScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The stored override for [listId], as a picker choice (absent → inherit). */
|
/** The stored override for [listId], as a picker choice (absent → inherit). */
|
||||||
private fun listOverrideChoice(state: SettingsUiState, listId: Long): ListReminderOverride {
|
private fun listOverrideChoice(state: SettingsUiState, listId: Long): ReminderOverride {
|
||||||
val map = state.settings.perListReminderOverride
|
val map = state.settings.perListReminderOverride
|
||||||
return when {
|
return when {
|
||||||
!map.containsKey(listId) -> ListReminderOverride.Inherit
|
!map.containsKey(listId) -> ReminderOverride.Inherit
|
||||||
map[listId] == null -> ListReminderOverride.None
|
map[listId] == null -> ReminderOverride.None
|
||||||
else -> ListReminderOverride.Minutes(map.getValue(listId)!!)
|
else -> ReminderOverride.Minutes(map.getValue(listId)!!)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Row summary for a list: its override, or the inherited global default. */
|
/** Row summary for a list: its override, or the inherited global default. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun listOverrideSummary(choice: ListReminderOverride, globalDefault: Int): String = when (choice) {
|
private fun listOverrideSummary(choice: ReminderOverride, globalDefault: Int): String = when (choice) {
|
||||||
ListReminderOverride.Inherit ->
|
ReminderOverride.Inherit ->
|
||||||
stringResource(R.string.settings_list_reminder_inherits, reminderLeadTimeLabel(globalDefault))
|
stringResource(R.string.settings_list_reminder_inherits, reminderLeadTimeLabel(globalDefault))
|
||||||
ListReminderOverride.None -> stringResource(R.string.reminder_none)
|
ReminderOverride.None -> stringResource(R.string.reminder_none)
|
||||||
is ListReminderOverride.Minutes -> reminderLeadTimeLabel(choice.minutes)
|
is ReminderOverride.Minutes -> reminderLeadTimeLabel(choice.minutes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// About + shared building blocks
|
// 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
|
@Composable
|
||||||
private fun AppLogo() {
|
private fun AppLogo() {
|
||||||
Box(
|
Box(
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ package de.jeanlucmakiola.agendula.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.agendula.data.prefs.ListReminderOverride
|
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.Settings
|
import de.jeanlucmakiola.agendula.data.prefs.Settings
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
@@ -51,7 +51,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
prefs.setDefaultEditFields(if (enabled) current + field else current - field)
|
prefs.setDefaultEditFields(if (enabled) current + field else current - field)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Set (or clear, via [ListReminderOverride.Inherit]) a list's reminder override. */
|
/** Set (or clear, via [ReminderOverride.Inherit]) a list's reminder override. */
|
||||||
fun setListReminderOverride(listId: Long, override: ListReminderOverride) =
|
fun setListReminderOverride(listId: Long, override: ReminderOverride) =
|
||||||
viewModelScope.launch { prefs.setListReminderOverride(listId, override) }
|
viewModelScope.launch { prefs.setListReminderOverride(listId, override) }
|
||||||
}
|
}
|
||||||
|
|||||||
Submodule floret-kit updated: 0db0e3883f...86f44805d1
Reference in New Issue
Block a user