diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fb7697f..2db835d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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") diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt index e0e9ab2..2d31085 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/prefs/SettingsPrefs.kt @@ -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) = 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 { - if (stored.isNullOrBlank()) return emptyMap() - return stored.split(OVERRIDE_ENTRY_SEP).mapNotNull { entry -> - val parts = entry.split(OVERRIDE_KV_SEP).takeIf { it.size == 2 } ?: return@mapNotNull null - val id = parts[0].toLongOrNull() ?: return@mapNotNull null - val value = if (parts[1] == OVERRIDE_NONE) null else parts[1].toIntOrNull() ?: return@mapNotNull null - id to value - }.toMap() -} - -private fun serializeReminderOverrides(map: Map): String = - map.entries.joinToString(OVERRIDE_ENTRY_SEP) { (id, minutes) -> "$id$OVERRIDE_KV_SEP${minutes ?: OVERRIDE_NONE}" } +/** + * 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 diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ReminderFormatting.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ReminderFormatting.kt index 9376a4f..26bb89b 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ReminderFormatting.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ReminderFormatting.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ReminderLeadPicker.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ReminderLeadPicker.kt index e848c17..5d6016a 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ReminderLeadPicker.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/ReminderLeadPicker.kt @@ -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 = 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 = 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) } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/AppLanguage.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/AppLanguage.kt deleted file mode 100644 index 4baed11..0000000 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/AppLanguage.kt +++ /dev/null @@ -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- translation and adding a matching `` entry makes the - * language show up here and in the system per-app-language settings, with no - * other code change. The system-default choice is represented as `null`. - * - * On API 33+ this delegates to the platform per-app-languages API; below that - * the appcompat backport persists the choice itself (manifest `autoStoreLocales` - * service), so we don't mirror it in DataStore. Setting a locale recreates the - * activity, which re-reads the current value for the picker. - */ -object AppLanguage { - - /** - * The BCP-47 tags the app ships translations for, in declaration order, as - * listed in locales_config.xml. Returns whatever could be parsed; a missing - * or malformed config yields an empty list (the picker then offers only the - * system-default entry rather than crashing). - */ - fun supportedTags(context: Context): List { - val tags = mutableListOf() - val parser = context.resources.getXml(R.xml.locales_config) - try { - var event = parser.eventType - while (event != XmlPullParser.END_DOCUMENT) { - if (event == XmlPullParser.START_TAG && parser.name == "locale") { - parser.getAttributeValue(ANDROID_NS, "name")?.let(tags::add) - } - event = parser.next() - } - } catch (_: Exception) { - // Fall back to whatever was parsed before the failure. - } finally { - parser.close() - } - return tags - } - - /** The applied app language as a BCP-47 tag, or `null` when following the system. */ - fun currentTag(): String? { - val locales = AppCompatDelegate.getApplicationLocales() - return if (locales.isEmpty) null else locales[0]?.toLanguageTag() - } - - /** Apply a BCP-47 tag, or `null` to follow the system languages. */ - fun apply(tag: String?) { - val locales = if (tag == null) { - LocaleListCompat.getEmptyLocaleList() - } else { - LocaleListCompat.forLanguageTags(tag) - } - AppCompatDelegate.setApplicationLocales(locales) - } - - /** - * The autonym for a tag — the language's own name in its own script, e.g. - * "Deutsch", "English", "Français" — so users find their language regardless - * of the current UI language. Capitalised per the language's own rules. - */ - fun displayName(tag: String): String { - val locale = Locale.forLanguageTag(tag) - return locale.getDisplayName(locale) - .replaceFirstChar { if (it.isLowerCase()) it.titlecase(locale) else it.toString() } - } -} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsScreen.kt index 598123f..a17e866 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsScreen.kt @@ -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(null) + AppLanguage.supportedTags(context) } + val options = remember { listOf(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) } // --------------------------------------------------------------------------- diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt index f9171f6..4737eb2 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsViewModel.kt @@ -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) } } diff --git a/floret-kit b/floret-kit index 0db0e38..4eb9809 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit 0db0e3883f03390b3d8938ad05df7aa253dcfa30 +Subproject commit 4eb9809993a715d58d5d13860cfa31a0f0b496fd