reminders + locale: draw prefs/reminders and language plumbing from floret-kit
Consume two newly extracted floret-kit modules and drop the in-app copies: - core-reminders: ReminderUnit / decomposeReminderMinutes, the ReminderOverride model and the override codec. SettingsPrefs keeps its own DataStore wiring and Agendula's ':' codec dialect; ReminderFormatting/ReminderLeadPicker keep the app's R.string labels and presets. - core-locale: AppLanguage. Deleted the app copy; SettingsScreen now passes R.xml.locales_config to supportedTags(). Bumps the floret-kit submodule to include both modules. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -121,6 +121,8 @@ dependencies {
|
||||
implementation(libs.kotlinx.datetime)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
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:identity")
|
||||
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.stringSetPreferencesKey
|
||||
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.map
|
||||
import javax.inject.Inject
|
||||
@@ -16,16 +20,6 @@ import javax.inject.Singleton
|
||||
|
||||
enum class ThemeMode { SYSTEM, LIGHT, DARK }
|
||||
|
||||
/** A list's override of the default reminder lead time (mirrors Calendula's per-calendar override). */
|
||||
sealed interface ListReminderOverride {
|
||||
/** No override — the list uses the global default. */
|
||||
data object Inherit : ListReminderOverride
|
||||
/** Explicit "no reminder" for this list, regardless of the global default. */
|
||||
data object None : ListReminderOverride
|
||||
/** A specific lead time in minutes before due. */
|
||||
data class Minutes(val minutes: Int) : ListReminderOverride
|
||||
}
|
||||
|
||||
data class Settings(
|
||||
val themeMode: ThemeMode = ThemeMode.SYSTEM,
|
||||
val dynamicColor: Boolean = true,
|
||||
@@ -47,11 +41,7 @@ data class Settings(
|
||||
) {
|
||||
/** 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
|
||||
}
|
||||
perListReminderOverride.reminderLeadFor(listId, reminderLeadMinutes)
|
||||
}
|
||||
|
||||
/** App preferences, backed by DataStore. Mirrors Calendula's prefs shape. */
|
||||
@@ -68,7 +58,7 @@ class SettingsPrefs @Inject constructor(
|
||||
reminderLeadMinutes = p[REMINDER_LEAD] ?: 0,
|
||||
remindersEnabled = p[REMINDERS_ENABLED] ?: 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()
|
||||
.mapNotNull { name -> runCatching { TaskFormField.valueOf(name) }.getOrNull() }
|
||||
.toSet(),
|
||||
@@ -92,15 +82,11 @@ class SettingsPrefs @Inject constructor(
|
||||
|
||||
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)
|
||||
/** Set (or clear, via [ReminderOverride.Inherit]) a list's reminder override. */
|
||||
suspend fun setListReminderOverride(listId: Long, override: ReminderOverride) = dataStore.edit { p ->
|
||||
val current = reminderCodec.parse(p[LIST_REMINDER_OVERRIDE]).toMutableMap()
|
||||
current.applyReminderOverride(listId, override)
|
||||
p[LIST_REMINDER_OVERRIDE] = reminderCodec.serialize(current)
|
||||
}
|
||||
|
||||
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 = ":"
|
||||
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}" }
|
||||
/**
|
||||
* Agendula's stored dialect for the per-list override map: `id:minutes` entries
|
||||
* joined by `;` (the family default). Fixed at release — don't change without a
|
||||
* data migration.
|
||||
*/
|
||||
private val reminderCodec = ReminderOverrideCodec.DEFAULT
|
||||
|
||||
@@ -5,18 +5,11 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
||||
|
||||
/** 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
|
||||
|
||||
@@ -5,6 +5,9 @@ import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||
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.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
@@ -36,37 +39,36 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
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
|
||||
* 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
|
||||
* a "None" row. Returns the choice as a [ReminderOverride]. Ported from
|
||||
* Calendula's ReminderDefaultPicker.
|
||||
*/
|
||||
@Composable
|
||||
fun ReminderLeadPicker(
|
||||
title: String,
|
||||
selected: ListReminderOverride,
|
||||
selected: ReminderOverride,
|
||||
allowInherit: Boolean,
|
||||
allowNone: Boolean,
|
||||
onSelect: (ListReminderOverride) -> Unit,
|
||||
onSelect: (ReminderOverride) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
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 seed = decomposeReminder(selectedMinutes?.takeIf { customSelected })
|
||||
val seed = decomposeReminderMinutes(selectedMinutes?.takeIf { customSelected })
|
||||
|
||||
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
||||
var amountText by rememberSaveable { mutableStateOf(seed.first) }
|
||||
var unit by rememberSaveable { mutableStateOf(seed.second) }
|
||||
var amountText by rememberSaveable { mutableStateOf(seed.amount?.toString() ?: "") }
|
||||
var unit by rememberSaveable { mutableStateOf(seed.unit) }
|
||||
|
||||
val options = buildList {
|
||||
if (allowInherit) add(ListReminderOverride.Inherit)
|
||||
if (allowNone) add(ListReminderOverride.None)
|
||||
presets.forEach { add(ListReminderOverride.Minutes(it)) }
|
||||
if (allowInherit) add(ReminderOverride.Inherit)
|
||||
if (allowNone) add(ReminderOverride.None)
|
||||
presets.forEach { add(ReminderOverride.Minutes(it)) }
|
||||
}
|
||||
val rowCount = options.size + 1 // + the custom row
|
||||
|
||||
@@ -106,7 +108,7 @@ fun ReminderLeadPicker(
|
||||
unit = unit,
|
||||
onUnitChange = { unit = it },
|
||||
onConfirm = { minutes ->
|
||||
onSelect(ListReminderOverride.Minutes(minutes))
|
||||
onSelect(ReminderOverride.Minutes(minutes))
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
@@ -178,17 +180,8 @@ private fun CustomReminderEditor(
|
||||
}
|
||||
|
||||
@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
|
||||
private fun reminderOverrideLabel(override: ReminderOverride): String = when (override) {
|
||||
ReminderOverride.Inherit -> stringResource(R.string.reminder_use_default)
|
||||
ReminderOverride.None -> stringResource(R.string.reminder_none)
|
||||
is ReminderOverride.Minutes -> reminderLeadTimeLabel(override.minutes)
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -84,7 +84,6 @@ import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.data.prefs.ListReminderOverride
|
||||
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
@@ -94,6 +93,8 @@ import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.agendula.ui.common.ReminderLeadPicker
|
||||
import de.jeanlucmakiola.floret.components.pastelize
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.locale.AppLanguage
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel
|
||||
|
||||
/** The settings sub-screens reached from the hub's category rows. */
|
||||
@@ -202,7 +203,7 @@ private fun LanguageRow(position: Position) {
|
||||
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) }
|
||||
val options = remember { listOf<String?>(null) + AppLanguage.supportedTags(context, R.xml.locales_config) }
|
||||
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_language),
|
||||
@@ -479,10 +480,10 @@ private fun RemindersScreen(
|
||||
if (showOffset) {
|
||||
ReminderLeadPicker(
|
||||
title = stringResource(R.string.settings_default_reminder),
|
||||
selected = ListReminderOverride.Minutes(state.settings.reminderLeadMinutes),
|
||||
selected = ReminderOverride.Minutes(state.settings.reminderLeadMinutes),
|
||||
allowInherit = 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 },
|
||||
)
|
||||
}
|
||||
@@ -500,22 +501,22 @@ private fun RemindersScreen(
|
||||
}
|
||||
|
||||
/** 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
|
||||
return when {
|
||||
!map.containsKey(listId) -> ListReminderOverride.Inherit
|
||||
map[listId] == null -> ListReminderOverride.None
|
||||
else -> ListReminderOverride.Minutes(map.getValue(listId)!!)
|
||||
!map.containsKey(listId) -> ReminderOverride.Inherit
|
||||
map[listId] == null -> ReminderOverride.None
|
||||
else -> ReminderOverride.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 ->
|
||||
private fun listOverrideSummary(choice: ReminderOverride, globalDefault: Int): String = when (choice) {
|
||||
ReminderOverride.Inherit ->
|
||||
stringResource(R.string.settings_list_reminder_inherits, reminderLeadTimeLabel(globalDefault))
|
||||
ListReminderOverride.None -> stringResource(R.string.reminder_none)
|
||||
is ListReminderOverride.Minutes -> reminderLeadTimeLabel(choice.minutes)
|
||||
ReminderOverride.None -> stringResource(R.string.reminder_none)
|
||||
is ReminderOverride.Minutes -> reminderLeadTimeLabel(choice.minutes)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -3,13 +3,13 @@ package de.jeanlucmakiola.agendula.ui.settings
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
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.SettingsPrefs
|
||||
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
@@ -51,7 +51,7 @@ class SettingsViewModel @Inject constructor(
|
||||
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) =
|
||||
/** Set (or clear, via [ReminderOverride.Inherit]) a list's reminder override. */
|
||||
fun setListReminderOverride(listId: Long, override: ReminderOverride) =
|
||||
viewModelScope.launch { prefs.setListReminderOverride(listId, override) }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user