Compare commits

..
Author SHA1 Message Date
makiolaj 95c40c9d88 fix(#517): review round 2 - back off failed pref reads, one derivation for the drawer's hidden-in-view set 2026-11-14 12:09:19 +01:00
makiolaj 8df21b965a fix(#517): review round 1 - keep reading after a failed pref read, drawer count and notes agree, entry row not 'selected' 2026-11-14 12:00:29 +01:00
makiolaj 1aa66ffc99 feat(#517): move the per-view hide entry from the top bar into the drawer 2026-11-14 11:52:48 +01:00
makiolaj d5b51f2c01 fix(#517): review round 3 - close picker in each request handler, keep hidden set alive across page swaps 2026-11-14 03:41:14 +01:00
makiolaj dda0164c9f fix(#517): review round 2 - close picker for every external request, clearer title, provider write first 2026-11-14 03:31:43 +01:00
makiolaj bbcb8e00ed fix(#517): review round 1 - close picker on external navigation, proper load/failure states, wait for stored hidden views 2026-11-14 03:22:35 +01:00
makiolaj d943937b3a feat(#517): quick per-view calendar toggle in the top bar 2026-11-14 03:14:47 +01:00
makiolaj dad1f2cd9c fix(#270): review round 3 - one write per editor save, best-effort pref tidy-up 2026-11-13 19:11:33 +01:00
makiolaj db8d649467 fix(#270): review round 2 - safe pref reads, diff-only editor saves, optimistic picker 2026-11-13 19:04:25 +01:00
makiolaj 91fdaba6a2 fix(#270): review round 1 - atomic toggles, no stale editor write, shared per-view filter 2026-11-13 18:54:10 +01:00
makiolaj e3e9865d3f feat(#270): hide calendars in chosen views 2026-11-13 18:45:17 +01:00
37 changed files with 749 additions and 1823 deletions

No files matched your search

-1
View File
@@ -177,7 +177,6 @@ dependencies {
implementation("de.jeanlucmakiola.floret:core-crash")
implementation("de.jeanlucmakiola.floret:core-di")
implementation("de.jeanlucmakiola.floret:core-prefs")
implementation("de.jeanlucmakiola.floret:core-backup")
implementation("de.jeanlucmakiola.floret:core-reminders")
implementation("de.jeanlucmakiola.floret:core-android")
implementation("de.jeanlucmakiola.floret:identity")
@@ -1,198 +0,0 @@
package de.jeanlucmakiola.calendula.data.backup
import androidx.datastore.preferences.core.Preferences
import de.jeanlucmakiola.calendula.data.appname.LauncherName
import de.jeanlucmakiola.calendula.data.calendar.ManagedCalendarRow
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefKeys
import de.jeanlucmakiola.calendula.data.prefs.SettingsKeys
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.floret.backup.BackupDocument
import de.jeanlucmakiola.floret.backup.BackupResult
import de.jeanlucmakiola.floret.backup.PrefsSection
import de.jeanlucmakiola.floret.backup.invalidBackup
import de.jeanlucmakiola.floret.backup.optBoolean
import de.jeanlucmakiola.floret.backup.optIntList
import de.jeanlucmakiola.floret.backup.optObject
import de.jeanlucmakiola.floret.backup.optObjectList
import de.jeanlucmakiola.floret.backup.optInt
import de.jeanlucmakiola.floret.backup.optString
import de.jeanlucmakiola.floret.backup.readBackup
import de.jeanlucmakiola.floret.backup.requireString
import de.jeanlucmakiola.floret.prefs.Pref
import de.jeanlucmakiola.floret.prefs.get
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
/**
* A calendar as another device can find it again: provider ids are per
* device, so a calendar is named by its account and display name, and a
* special-dates mirror by the date type it mirrors.
*/
data class CalendarIdentity(
val accountName: String,
val accountType: String,
val displayName: String,
val specialDates: SpecialDateType? = null,
)
/** The per-calendar settings of one calendar; null fields inherit the global default. */
data class CalendarSettings(
val calendar: CalendarIdentity,
val hidden: Boolean = false,
val lastUsed: Boolean = false,
val eventDurationMinutes: Int? = null,
/** Timed reminder override; an empty list is "no reminder". */
val reminders: List<Int>? = null,
val allDayReminders: List<Int>? = null,
)
/** The app language a file carries; [tag] null follows the system. */
data class LanguageChoice(val tag: String?)
/** Everything a settings backup holds. Null [language]/[launcherName]: the file didn't say. */
data class SettingsBackup(
val prefs: Map<Pref<*>, Any>,
val calendars: List<CalendarSettings>,
val language: LanguageChoice? = null,
val launcherName: LauncherName? = null,
)
internal val plainSection = PrefsSection(SettingsBackupSchema.plain)
/**
* The backup of [snapshot]. Per-calendar entries are named through
* [calendars] and [managed]; an id neither knows (a deleted calendar's
* leftover) is dropped.
*/
fun buildSettingsBackup(
snapshot: Preferences,
calendars: List<CalendarSource>,
managed: List<ManagedCalendarRow>,
language: LanguageChoice?,
launcherName: LauncherName?,
): SettingsBackup {
val hidden = snapshot[CalendarPrefKeys.hiddenCalendarIds]
val lastUsed = snapshot[CalendarPrefKeys.lastUsedCalendarId]
val durations = snapshot[SettingsKeys.perCalendarEventDuration]
val reminders = snapshot[SettingsKeys.perCalendarReminderOverride]
val allDay = snapshot[SettingsKeys.perCalendarAllDayReminderOverride]
val ids = hidden + listOfNotNull(lastUsed) + durations.keys + reminders.keys + allDay.keys
val byId = calendars.associateBy { it.id }
val managedType = managed.associate { it.id to it.type }
val entries = ids.sorted().mapNotNull { id ->
val calendar = byId[id] ?: return@mapNotNull null
CalendarSettings(
calendar = CalendarIdentity(
accountName = calendar.accountName,
accountType = calendar.accountType,
displayName = calendar.displayName,
specialDates = managedType[id],
),
hidden = id in hidden,
lastUsed = id == lastUsed,
eventDurationMinutes = durations[id],
reminders = reminders[id],
allDayReminders = allDay[id],
)
}
return SettingsBackup(
prefs = plainSection.snapshot(snapshot),
calendars = entries,
language = language,
launcherName = launcherName,
)
}
fun encodeSettingsBackup(backup: SettingsBackup, exportedAtMillis: Long, appVersion: String?): String {
val body = buildJsonObject {
put(KEY_SETTINGS, plainSection.encode(backup.prefs))
put(KEY_CALENDARS, JsonArray(backup.calendars.map { it.toJson() }))
put(
KEY_APP,
buildJsonObject {
backup.language?.let { put(KEY_LANGUAGE, it.tag?.let(::JsonPrimitive) ?: JsonNull) }
backup.launcherName?.let { put(KEY_LAUNCHER_NAME, JsonPrimitive(it.name)) }
},
)
}
val document = BackupDocument(
version = SettingsBackupSchema.format.version,
exportedAtMillis = exportedAtMillis,
appVersion = appVersion,
body = body,
)
return SettingsBackupSchema.format.encode(document)
}
/** Validates the whole file before anything is applied; see docs/SETTINGS_BACKUP.md. */
fun decodeSettingsBackup(text: String): BackupResult<SettingsBackup> {
val document = when (val decoded = SettingsBackupSchema.format.decode(text)) {
is BackupResult.Failure -> return decoded
is BackupResult.Success -> decoded.value
}
return readBackup {
val body = document.body
val app = body.optObject(KEY_APP)
SettingsBackup(
prefs = plainSection.read(body.optObject(KEY_SETTINGS) ?: invalidBackup("$KEY_SETTINGS: missing")),
calendars = body.optObjectList(KEY_CALENDARS).orEmpty().map { it.toCalendarSettings() },
language = app?.takeIf { KEY_LANGUAGE in it }?.let { LanguageChoice(it.optString(KEY_LANGUAGE)) },
launcherName = app?.optString(KEY_LAUNCHER_NAME)
?.let { name -> LauncherName.entries.firstOrNull { it.name == name } },
)
}
}
private fun CalendarSettings.toJson(): JsonObject = buildJsonObject {
put(KEY_ACCOUNT_NAME, JsonPrimitive(calendar.accountName))
put(KEY_ACCOUNT_TYPE, JsonPrimitive(calendar.accountType))
put(KEY_DISPLAY_NAME, JsonPrimitive(calendar.displayName))
calendar.specialDates?.let { put(KEY_SPECIAL_DATES, JsonPrimitive(it.name)) }
if (hidden) put(KEY_HIDDEN, JsonPrimitive(true))
if (lastUsed) put(KEY_LAST_USED, JsonPrimitive(true))
eventDurationMinutes?.let { put(KEY_EVENT_DURATION, JsonPrimitive(it)) }
reminders?.let { put(KEY_REMINDERS, JsonArray(it.map(::JsonPrimitive))) }
allDayReminders?.let { put(KEY_ALL_DAY_REMINDERS, JsonArray(it.map(::JsonPrimitive))) }
}
/**
* Strict about what it reads. A special-dates type this build doesn't know
* reads as an ordinary calendar, which then simply finds no match.
*/
private fun JsonObject.toCalendarSettings(): CalendarSettings {
return CalendarSettings(
calendar = CalendarIdentity(
accountName = requireString(KEY_ACCOUNT_NAME),
accountType = requireString(KEY_ACCOUNT_TYPE),
displayName = requireString(KEY_DISPLAY_NAME),
specialDates = optString(KEY_SPECIAL_DATES)?.let { name -> SpecialDateType.entries.firstOrNull { it.name == name } },
),
hidden = optBoolean(KEY_HIDDEN) ?: false,
lastUsed = optBoolean(KEY_LAST_USED) ?: false,
eventDurationMinutes = optInt(KEY_EVENT_DURATION),
reminders = optIntList(KEY_REMINDERS)?.cleanReminders(),
allDayReminders = optIntList(KEY_ALL_DAY_REMINDERS)?.cleanReminders(),
)
}
/** As the reminder setters store them: no negative leads, each once, ascending. */
private fun List<Int>.cleanReminders(): List<Int> = filter { it >= 0 }.distinct().sorted()
private const val KEY_SETTINGS = "settings"
private const val KEY_CALENDARS = "calendars"
private const val KEY_APP = "app"
private const val KEY_LANGUAGE = "language"
private const val KEY_LAUNCHER_NAME = "launcher_name"
private const val KEY_ACCOUNT_NAME = "account_name"
private const val KEY_ACCOUNT_TYPE = "account_type"
private const val KEY_DISPLAY_NAME = "display_name"
private const val KEY_SPECIAL_DATES = "special_dates"
private const val KEY_HIDDEN = "hidden"
private const val KEY_LAST_USED = "last_used"
private const val KEY_EVENT_DURATION = "event_duration_minutes"
private const val KEY_REMINDERS = "reminders"
private const val KEY_ALL_DAY_REMINDERS = "all_day_reminders"
@@ -1,240 +0,0 @@
package de.jeanlucmakiola.calendula.data.backup
import android.content.Context
import android.util.Log
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.BuildConfig
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.appname.LauncherNameManager
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
import de.jeanlucmakiola.calendula.data.contacts.ContactSpecialDatesDataSource
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncEngine
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncResult
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.SpecialDatesStalledReason
import de.jeanlucmakiola.calendula.data.fonts.CustomFontStore
import de.jeanlucmakiola.calendula.data.prefs.SettingsKeys
import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner
import de.jeanlucmakiola.calendula.domain.FontRole
import de.jeanlucmakiola.calendula.widget.requestWidgetRedraw
import de.jeanlucmakiola.floret.backup.BackupProblem
import de.jeanlucmakiola.floret.backup.BackupResult
import de.jeanlucmakiola.floret.di.IoDispatcher
import de.jeanlucmakiola.floret.locale.AppLanguage
import de.jeanlucmakiola.floret.prefs.get
import de.jeanlucmakiola.floret.prefs.rawValue
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* Exports and imports Calendula's settings (#481). The file format and what
* it holds are [SettingsBackupSchema]'s; the import is planned purely by
* [planSettingsImport], written in one DataStore edit, and then followed by
* the side effects the settings screens would have run themselves.
*/
@Singleton
class SettingsBackupEngine @Inject constructor(
private val store: DataStore<Preferences>,
private val calendars: CalendarDataSource,
private val contacts: ContactSpecialDatesDataSource,
private val specialDates: SpecialDatesSyncEngine,
private val settingsPrefs: SettingsPrefs,
private val reminderScanner: ReminderScanner,
private val launcherNames: LauncherNameManager,
@ApplicationContext private val context: Context,
@IoDispatcher private val io: CoroutineDispatcher,
) {
suspend fun export(): String = withContext(io) {
val supported = supportedLanguages()
val snapshot = store.data.first()
// Without the list the per-calendar settings would silently drop out of the file.
val list = if (snapshot.hasCalendarSettings()) calendars.calendars() else emptyList()
val backup = buildSettingsBackup(
snapshot = snapshot,
calendars = list,
managed = if (list.any { it.isManaged }) calendars.findManagedCalendars() else emptyList(),
language = LanguageChoice(AppLanguage.currentTag(context, supported)),
launcherName = launcherNames.current(),
)
encodeSettingsBackup(backup, System.currentTimeMillis(), BuildConfig.VERSION_NAME)
}
/** Validates [text] in full, then replaces the settings; nothing is written for an invalid file. */
suspend fun import(text: String): BackupResult<SettingsImportSummary> = withContext(io) {
val backup = when (val decoded = decodeSettingsBackup(text)) {
is BackupResult.Failure -> return@withContext decoded
is BackupResult.Success -> decoded.value
}
val current = store.data.first()
val supported = supportedLanguages()
val environment = environment(current, backup, supported)
val plan = planSettingsImport(backup, current, environment)
val existingMirrors = environment.managed.map { it.id }.toSet()
// Once the write starts, the side effects have to follow it, whoever cancels.
withContext(NonCancellable) {
try {
store.edit { plan.applyTo(it) }
} catch (e: Exception) {
Log.w(TAG, "Settings import could not be written", e)
return@withContext BackupResult.Failure(BackupProblem.NOT_APPLIED, e.message.orEmpty())
}
val lateSkips = afterImport(plan, current, supported, existingMirrors)
BackupResult.Success(plan.summary(plan.calendars.orEmpty().size + lateSkips.matched, lateSkips.skipped))
}
}
private class LateMatches(val matched: Int, val skipped: List<SkippedSetting>)
/**
* Brings what reads the settings outside DataStore in line: the mirror,
* reminder alarms, scheduled backups, the launcher alias and the language.
* Each step stands alone, so one failing never undoes the import.
*/
private suspend fun afterImport(
plan: SettingsImportPlan,
before: Preferences,
supported: List<String>,
existingMirrors: Set<Long>,
): LateMatches {
val after = store.data.first()
val wasMirroring = before[SettingsKeys.specialDatesEnabled]
val mirroring = after[SettingsKeys.specialDatesEnabled]
var late = LateMatches(0, plan.pendingSpecialDates.map { it.calendar.skipped() })
step("special dates") {
if (mirroring) {
SpecialDatesScheduler.apply(context, enabled = true)
val result = specialDates.sync()
if (result != SpecialDatesSyncResult.Disabled) {
val stalled = SpecialDatesStalledReason.PermissionRevoked
.takeIf { result == SpecialDatesSyncResult.PermissionMissing }
settingsPrefs.recordSpecialDatesRun(System.currentTimeMillis(), stalled)
}
late = matchSpecialDates(plan, before, existingMirrors, synced = result == SpecialDatesSyncResult.Success)
} else if (wasMirroring) {
SpecialDatesScheduler.apply(context, enabled = false)
specialDates.teardown()
}
}
step("reminders") { reminderScanner.scan() }
step("auto backup") {
BackupScheduler.apply(
context = context,
enabled = after[SettingsKeys.autoBackupEnabled],
intervalMinutes = after[SettingsKeys.autoBackupIntervalMinutes],
hasFolder = after[SettingsKeys.autoBackupFolderUri] != null,
)
}
plan.launcherName?.let { name ->
step("launcher name") { if (launcherNames.current() != name) launcherNames.set(name) }
}
plan.language?.let { choice ->
step("language") {
if (AppLanguage.currentTag(context, supported) != choice.tag) {
withContext(Dispatchers.Main) { AppLanguage.apply(context, choice.tag) }
requestWidgetRedraw(context)
}
}
}
return late
}
/**
* Gives late-created mirror calendars their entries (only after a
* successful [synced] run), then rewrites each mirror whose all-day
* reminders differ from what its events carry: the pre-import override for
* a calendar that already existed, the sync's seed for a new one. A mirror
* the file doesn't override inherits again.
*/
private suspend fun matchSpecialDates(
plan: SettingsImportPlan,
before: Preferences,
existingMirrors: Set<Long>,
synced: Boolean,
): LateMatches {
val managed = calendars.findManagedCalendars()
val skipped = mutableListOf<SkippedSetting>()
var matched = 0
val resolved = if (!synced) {
skipped += plan.pendingSpecialDates.map { it.calendar.skipped() }
emptyList()
} else {
plan.pendingSpecialDates.mapNotNull { entry ->
val id = managed.firstOrNull { it.type == entry.calendar.specialDates }?.id
if (id == null) skipped += entry.calendar.skipped() else matched++
id?.let { it to entry }
}
}
if (resolved.isNotEmpty()) {
store.edit { prefs ->
resolved.forEach { (id, entry) -> prefs.addCalendarSettings(id, entry.copy(allDayReminders = null)) }
}
}
if (plan.calendars != null) {
val wanted = (plan.calendars.values + resolved.map { it.second })
.mapNotNull { entry -> entry.calendar.specialDates?.let { it to entry.allDayReminders } }
.toMap()
val carried = before[SettingsKeys.perCalendarAllDayReminderOverride]
val seeded = store.data.first()[SettingsKeys.perCalendarAllDayReminderOverride]
managed.distinctBy { it.type }.forEach { (id, type) ->
val minutes = wanted[type]
val onEvents = if (id in existingMirrors) carried[id] else seeded[id]
if (onEvents == minutes) return@forEach
val desired = minutes?.let { mapOf(id to it) }.orEmpty()
specialDates.applyReminders(type, desired.reminderOverrideFor(id))
}
}
return LateMatches(matched, skipped)
}
/** The highest byte count [import] is worth reading. */
val maxImportBytes: Int get() = SettingsBackupSchema.format.maxChars
private fun environment(
current: Preferences,
backup: SettingsBackup,
supported: List<String>,
): ImportEnvironment = ImportEnvironment(
// An empty list here is a failed read as far as the import can tell, never "wipe them all".
calendars = if (backup.calendars.isEmpty()) {
emptyList()
} else {
runCatching { calendars.calendars() }.getOrNull()?.takeIf { it.isNotEmpty() }
},
managed = runCatching { calendars.findManagedCalendars() }.getOrDefault(emptyList()),
contactsPermission = contacts.hasPermission(),
customFonts = FontRole.entries.filter { CustomFontStore.exists(context, it) }.toSet(),
hasBackupFolder = current[SettingsKeys.autoBackupFolderUri] != null,
supportedLanguages = supported,
)
private fun Preferences.hasCalendarSettings(): Boolean =
SettingsBackupSchema.perCalendar.any { rawValue(it) != null }
private fun supportedLanguages(): List<String> = AppLanguage.supportedTags(context, R.xml.locales_config)
private suspend fun step(name: String, block: suspend () -> Unit) {
try {
block()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, "Settings import: $name could not be refreshed", e)
}
}
private companion object {
const val TAG = "SettingsBackup"
}
}
@@ -1,106 +0,0 @@
package de.jeanlucmakiola.calendula.data.backup
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefKeys
import de.jeanlucmakiola.calendula.data.prefs.ReminderStateKeys
import de.jeanlucmakiola.calendula.data.prefs.SettingsKeys
import de.jeanlucmakiola.floret.backup.BackupFormat
import de.jeanlucmakiola.floret.prefs.Pref
/**
* Which stored keys a settings backup carries (#481), and how. Every key of
* the settings store is in exactly one of the three lists; a test holds that,
* so a new setting cannot ship without deciding. See docs/SETTINGS_BACKUP.md.
*/
internal object SettingsBackupSchema {
val format = BackupFormat(name = "calendula-settings", version = 1)
/** Carried as stored and restored over the current value; an absent key restores the default. */
val plain: List<Pref<*>> = listOf(
SettingsKeys.themeMode,
SettingsKeys.dynamicColor,
SettingsKeys.weekStart,
SettingsKeys.softenCalendarColors,
SettingsKeys.brandFont,
SettingsKeys.plainFont,
SettingsKeys.timeFormat,
SettingsKeys.showHourGrid,
SettingsKeys.pastEventDisplay,
SettingsKeys.dimCompletedEvents,
SettingsKeys.showWeekNumbers,
SettingsKeys.monthShowStartTimes,
SettingsKeys.monthViewStyle,
SettingsKeys.timelineScale,
SettingsKeys.multiDayCount,
SettingsKeys.timelineHoursEnabled,
SettingsKeys.timelineHoursStart,
SettingsKeys.timelineHoursEnd,
SettingsKeys.todayButtonInToolbar,
SettingsKeys.dragToReschedule,
SettingsKeys.tapOpensEditor,
SettingsKeys.agendaScreenRange,
SettingsKeys.agendaShowRangeBar,
SettingsKeys.agendaShowToday,
SettingsKeys.defaultView,
SettingsKeys.quickSwitchConfig,
SettingsKeys.drawerViewOrder,
SettingsKeys.defaultFormFields,
SettingsKeys.autofocusEventTitle,
SettingsKeys.defaultEventDurationMinutes,
SettingsKeys.remindersEnabled,
SettingsKeys.allowColorOnUnsupportedCalendars,
SettingsKeys.defaultReminderMinutes,
SettingsKeys.defaultAllDayReminderMinutes,
SettingsKeys.allDayReminderTimeMinutes,
SettingsKeys.snoozeMinutes,
SettingsKeys.autoBackupEnabled,
SettingsKeys.autoBackupIntervalMinutes,
SettingsKeys.specialDatesEnabled,
SettingsKeys.specialDatesShowYear,
) + SettingsKeys.specialDateTypeEnabled.values + SettingsKeys.specialDatesTitleTemplate.values
/**
* Keyed by calendar id on disk, so carried per calendar under a portable
* identity instead ([CalendarSettings]) and re-keyed to this device's ids.
*/
val perCalendar: List<Pref<*>> = listOf(
CalendarPrefKeys.hiddenCalendarIds,
CalendarPrefKeys.lastUsedCalendarId,
SettingsKeys.perCalendarEventDuration,
SettingsKeys.perCalendarReminderOverride,
SettingsKeys.perCalendarAllDayReminderOverride,
)
/** Never exported: this install's own state, bookkeeping, or meaningless on another device. */
val excluded: List<Pref<*>> = (
listOf(
// Cache-busting counters for the locally imported font file.
SettingsKeys.brandFontStamp,
SettingsKeys.plainFontStamp,
// Legacy defaults for unconfigured widgets; widget configuration stays out.
SettingsKeys.agendaWidgetRange,
SettingsKeys.widgetSize,
// Usage history, not a setting.
SettingsKeys.recentTimeZones,
// First-run wizard progress of this install.
SettingsKeys.onboardingWizardArmed,
CalendarPrefKeys.visibilityNoticePending,
// A SAF grant, which does not survive a reinstall or another device.
SettingsKeys.autoBackupFolderUri,
// Run status.
SettingsKeys.autoBackupLastRun,
SettingsKeys.autoBackupLastSuccess,
SettingsKeys.autoBackupFailures,
SettingsKeys.specialDatesLastRun,
SettingsKeys.specialDatesStalled,
SettingsKeys.specialDatesLastForegroundSync,
// Switch-offs still owed to the provider on this device.
CalendarPrefKeys.pendingDisabledCalendarIds,
// The reminder delivery watermark.
ReminderStateKeys.lastScanMillis,
) +
SettingsKeys.onboardingDone.values +
// The mirror re-adopts its calendars by their provider marker.
SettingsKeys.specialDatesCalendarId.values
).distinct()
}
@@ -1,214 +0,0 @@
package de.jeanlucmakiola.calendula.data.backup
import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.Preferences
import de.jeanlucmakiola.calendula.data.appname.LauncherName
import de.jeanlucmakiola.calendula.data.calendar.ManagedCalendarRow
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefKeys
import de.jeanlucmakiola.calendula.data.prefs.SettingsKeys
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.FontRole
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
import de.jeanlucmakiola.floret.locale.AppLanguage
import de.jeanlucmakiola.floret.prefs.Pref
import de.jeanlucmakiola.floret.prefs.get
import de.jeanlucmakiola.floret.prefs.rawValue
import de.jeanlucmakiola.floret.prefs.set
import de.jeanlucmakiola.floret.prefs.setRawValue
/** What this device can take of a backup, read before planning. */
data class ImportEnvironment(
/** Null when the calendar list can't be read: the per-calendar settings are then left alone. */
val calendars: List<CalendarSource>?,
val managed: List<ManagedCalendarRow>,
val contactsPermission: Boolean,
val customFonts: Set<FontRole>,
val hasBackupFolder: Boolean,
val supportedLanguages: List<String>,
)
/** Something in the file this device could not take, for the import summary. */
sealed interface SkippedSetting {
/** No calendar on this device matches the file's, or [ambiguous]ly more than one does. */
data class Calendar(
val displayName: String,
val accountName: String,
val ambiguous: Boolean = false,
) : SkippedSetting
/** The calendars couldn't be read, so the per-calendar settings were left as they are. */
data object CalendarsUnavailable : SkippedSetting
/** A custom font: the font file itself is not part of the backup. */
data class CustomFont(val role: FontRole) : SkippedSetting
/** Contact special dates were on, but this device hasn't granted the contacts permission. */
data object SpecialDates : SkippedSetting
/** Automatic backup was on, but its folder has to be chosen again here. */
data object AutoBackupFolder : SkippedSetting
/** The file's app language isn't one this build ships. */
data class Language(val tag: String) : SkippedSetting
}
data class SettingsImportSummary(
val calendarsMatched: Int,
val skipped: List<SkippedSetting>,
)
/**
* A validated backup laid onto this device: the stored values to write in one
* edit, the side effects the engine runs after it, and the summary.
* [calendars] null leaves the per-calendar keys untouched.
* [pendingSpecialDates] are mirror calendars that don't exist yet; the engine
* matches them once the mirror's sync has created them.
*/
data class SettingsImportPlan(
val prefs: Map<Pref<*>, Any?>,
val calendars: Map<Long, CalendarSettings>?,
val pendingSpecialDates: List<CalendarSettings>,
val language: LanguageChoice?,
val launcherName: LauncherName?,
val skipped: List<SkippedSetting>,
)
/** Plans an import that replaces every backed-up setting; see docs/SETTINGS_BACKUP.md. */
fun planSettingsImport(
backup: SettingsBackup,
current: Preferences,
environment: ImportEnvironment,
): SettingsImportPlan {
val skipped = mutableListOf<SkippedSetting>()
val prefs = backup.prefs.toMutableMap<Pref<*>, Any?>()
fun keepCurrent(pref: Pref<*>) {
prefs[pref] = current.rawValue(pref)
}
FontRole.entries.forEach { role ->
val pref = role.fontPref()
if (prefs[pref] == FONT_CUSTOM_TOKEN && role !in environment.customFonts) {
keepCurrent(pref)
skipped += SkippedSetting.CustomFont(role)
}
}
if (prefs[SettingsKeys.specialDatesEnabled] == true && !environment.contactsPermission) {
keepCurrent(SettingsKeys.specialDatesEnabled)
skipped += SkippedSetting.SpecialDates
}
if (prefs[SettingsKeys.autoBackupEnabled] == true && !environment.hasBackupFolder) {
keepCurrent(SettingsKeys.autoBackupEnabled)
skipped += SkippedSetting.AutoBackupFolder
}
val language = backup.language?.let { choice ->
val tag = choice.tag ?: return@let choice
val resolved = AppLanguage.resolveTag(tag, environment.supportedLanguages)
if (resolved in environment.supportedLanguages) {
LanguageChoice(resolved)
} else {
skipped += SkippedSetting.Language(tag)
null
}
}
// The mirror's calendars are torn down when the imported settings leave it off.
val mirroring = prefs[SettingsKeys.specialDatesEnabled] == true
val available = environment.calendars
val calendars = linkedMapOf<Long, CalendarSettings>()
val pending = mutableListOf<CalendarSettings>()
if (available == null) {
if (backup.calendars.isNotEmpty()) skipped += SkippedSetting.CalendarsUnavailable
} else {
val repeated = backup.calendars.groupingBy { it.calendar }.eachCount().filterValues { it > 1 }.keys
backup.calendars.forEach { entry ->
val type = entry.calendar.specialDates
if (type != null) {
// The mirror only keeps calendars for the types the imported settings switch on.
val typeOn = prefs[SettingsKeys.specialDateTypeEnabled.getValue(type)] != false
if (!mirroring || !typeOn) return@forEach
val id = environment.managed.firstOrNull { it.type == type }?.id
if (id != null) calendars[id] = entry else pending += entry
return@forEach
}
if (entry.calendar in repeated) {
// Two calendars on the exporting phone that this one can't tell apart.
val reported = SkippedSetting.Calendar(entry.calendar.displayName, entry.calendar.accountName, ambiguous = true)
if (reported !in skipped) skipped += reported
return@forEach
}
val candidates = available.filter { !it.isManaged && it.matches(entry.calendar) }
when (candidates.size) {
1 -> calendars[candidates.single().id] = entry
else -> skipped += entry.calendar.skipped(ambiguous = candidates.size > 1)
}
}
}
return SettingsImportPlan(
prefs = prefs,
calendars = calendars.takeIf { available != null },
pendingSpecialDates = pending,
language = language,
launcherName = backup.launcherName,
skipped = skipped,
)
}
/** Writes the plan's stored values, replacing every backed-up setting and all per-calendar ones. */
fun SettingsImportPlan.applyTo(target: MutablePreferences) {
plainSection.apply(target, prefs)
calendars?.let(target::writeCalendarSettings)
}
/**
* Replaces the per-calendar keys with [calendars], by this device's ids. A
* last-used calendar only counts from the first entry claiming it.
*/
fun MutablePreferences.writeCalendarSettings(calendars: Map<Long, CalendarSettings>) {
SettingsBackupSchema.perCalendar.forEach { setRawValue(it, null) }
calendars.forEach { (id, entry) -> addCalendarSettings(id, entry) }
}
/** Adds one late-matched calendar's settings to what [writeCalendarSettings] stored. */
fun MutablePreferences.addCalendarSettings(id: Long, entry: CalendarSettings) {
if (entry.hidden) this[CalendarPrefKeys.hiddenCalendarIds] = this[CalendarPrefKeys.hiddenCalendarIds] + id
if (entry.lastUsed && this[CalendarPrefKeys.lastUsedCalendarId] == null) {
this[CalendarPrefKeys.lastUsedCalendarId] = id
}
entry.eventDurationMinutes?.let {
this[SettingsKeys.perCalendarEventDuration] =
this[SettingsKeys.perCalendarEventDuration] + (id to it.clampDuration())
}
entry.reminders?.let {
this[SettingsKeys.perCalendarReminderOverride] = this[SettingsKeys.perCalendarReminderOverride] + (id to it)
}
entry.allDayReminders?.let {
this[SettingsKeys.perCalendarAllDayReminderOverride] =
this[SettingsKeys.perCalendarAllDayReminderOverride] + (id to it)
}
}
fun CalendarIdentity.skipped(ambiguous: Boolean = false): SkippedSetting =
SkippedSetting.Calendar(displayName, accountName, ambiguous)
fun SettingsImportPlan.summary(calendarsMatched: Int, more: List<SkippedSetting> = emptyList()) =
SettingsImportSummary(
calendarsMatched = calendarsMatched,
skipped = skipped + more,
)
private fun CalendarSource.matches(identity: CalendarIdentity): Boolean =
accountType == identity.accountType &&
accountName == identity.accountName &&
displayName == identity.displayName
/** The same range the per-calendar duration setter clamps to. */
private fun Int.clampDuration(): Int = coerceIn(SettingsPrefs.MIN_EVENT_DURATION, SettingsPrefs.MAX_EVENT_DURATION)
private fun FontRole.fontPref(): Pref<String> = when (this) {
FontRole.BRAND -> SettingsKeys.brandFont
FontRole.PLAIN -> SettingsKeys.plainFont
}
@@ -28,9 +28,9 @@ class CalendarPrefs @Inject constructor(
) {
private val prefs = PrefStore(store)
val hiddenCalendarIds: Flow<Set<Long>> = prefs.flow(CalendarPrefKeys.hiddenCalendarIds)
val hiddenCalendarIds: Flow<Set<Long>> = prefs.flow(HIDDEN_IDS)
suspend fun setHiddenCalendarIds(ids: Set<Long>) = prefs.set(CalendarPrefKeys.hiddenCalendarIds, ids)
suspend fun setHiddenCalendarIds(ids: Set<Long>) = prefs.set(HIDDEN_IDS, ids)
/**
* Switch-offs the provider does not know about yet, because writing
@@ -41,10 +41,10 @@ class CalendarPrefs @Inject constructor(
* second visibility model: `CalendarVisibilityReconciler` drains it entry by
* entry as soon as the app may write, and nothing adds to it while it may.
*/
val pendingDisabledCalendarIds: Flow<Set<Long>> = prefs.flow(CalendarPrefKeys.pendingDisabledCalendarIds)
val pendingDisabledCalendarIds: Flow<Set<Long>> = prefs.flow(DISABLED_IDS)
suspend fun addPendingDisabledCalendarIds(ids: Collection<Long>) {
prefs.update(CalendarPrefKeys.pendingDisabledCalendarIds) { it + ids }
prefs.update(DISABLED_IDS) { it + ids }
}
/**
@@ -52,7 +52,7 @@ class CalendarPrefs @Inject constructor(
* them, so a run that fails part-way never re-applies what already landed.
*/
suspend fun removePendingDisabledCalendarIds(ids: Collection<Long>) {
prefs.update(CalendarPrefKeys.pendingDisabledCalendarIds) { it - ids.toSet() }
prefs.update(DISABLED_IDS) { it - ids.toSet() }
}
/**
@@ -60,26 +60,24 @@ class CalendarPrefs @Inject constructor(
* owed. Null until the reconciler has evaluated it (which needs the calendar
* permission), false once it has been shown or was never needed.
*/
val visibilityNoticePending: Flow<Boolean?> = prefs.flow(CalendarPrefKeys.visibilityNoticePending)
val visibilityNoticePending: Flow<Boolean?> = prefs.flow(VISIBILITY_NOTICE)
suspend fun setVisibilityNoticePending(pending: Boolean) = prefs.set(CalendarPrefKeys.visibilityNoticePending, pending)
suspend fun setVisibilityNoticePending(pending: Boolean) = prefs.set(VISIBILITY_NOTICE, pending)
/**
* The calendar the user last created an event in; preselected in the
* event form. Null until the first event is created.
*/
val lastUsedCalendarId: Flow<Long?> = prefs.flow(CalendarPrefKeys.lastUsedCalendarId)
val lastUsedCalendarId: Flow<Long?> = prefs.flow(LAST_USED_CALENDAR)
suspend fun setLastUsedCalendarId(id: Long) = prefs.set(CalendarPrefKeys.lastUsedCalendarId, id)
suspend fun setLastUsedCalendarId(id: Long) = prefs.set(LAST_USED_CALENDAR, id)
}
/** Every key [CalendarPrefs] stores; the names are on-disk data. */
internal object CalendarPrefKeys {
val hiddenCalendarIds: Pref<Set<Long>> = idSetPref("hidden_calendar_ids")
val pendingDisabledCalendarIds: Pref<Set<Long>> = idSetPref("disabled_calendar_ids")
val visibilityNoticePending: Pref<Boolean?> = nullableBooleanPref("visibility_notice_pending")
val lastUsedCalendarId: Pref<Long?> = nullableLongPref("last_used_calendar_id")
private companion object {
val HIDDEN_IDS = idSetPref("hidden_calendar_ids")
val DISABLED_IDS = idSetPref("disabled_calendar_ids")
val VISIBILITY_NOTICE = nullableBooleanPref("visibility_notice_pending")
val LAST_USED_CALENDAR = nullableLongPref("last_used_calendar_id")
}
}
/** A comma-joined id set; non-numeric tokens are dropped, and an empty set removes the key. */
@@ -2,7 +2,6 @@ package de.jeanlucmakiola.calendula.data.prefs
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import de.jeanlucmakiola.floret.prefs.Pref
import de.jeanlucmakiola.floret.prefs.PrefStore
import de.jeanlucmakiola.floret.prefs.nullableLongPref
import javax.inject.Inject
@@ -24,13 +23,11 @@ class ReminderStatePrefs @Inject constructor(
private val prefs = PrefStore(store)
/** The watermark, or `null` before the first scan has ever run. */
suspend fun lastScanMillis(): Long? = prefs.get(ReminderStateKeys.lastScanMillis)
suspend fun lastScanMillis(): Long? = prefs.get(LAST_SCAN)
suspend fun setLastScanMillis(millis: Long) = prefs.set(ReminderStateKeys.lastScanMillis, millis)
suspend fun setLastScanMillis(millis: Long) = prefs.set(LAST_SCAN, millis)
}
/** The key [ReminderStatePrefs] stores. */
internal object ReminderStateKeys {
val lastScanMillis: Pref<Long?> = nullableLongPref("reminder_last_scan_millis")
private companion object {
val LAST_SCAN = nullableLongPref("reminder_last_scan_millis")
}
}
@@ -165,6 +165,7 @@ class SettingsPrefs @Inject constructor(
val autofocusEventTitle: Flow<Boolean> = prefs.flow(SettingsKeys.autofocusEventTitle)
val defaultEventDurationMinutes: Flow<Int> = prefs.flow(SettingsKeys.defaultEventDurationMinutes)
val perCalendarEventDuration: Flow<Map<Long, Int>> = prefs.flow(SettingsKeys.perCalendarEventDuration)
val perCalendarHiddenViews: Flow<Map<Long, Set<CalendarView>>> = prefs.flow(SettingsKeys.perCalendarHiddenViews)
val remindersEnabled: Flow<Boolean> = prefs.flow(SettingsKeys.remindersEnabled)
val allowColorOnUnsupportedCalendars: Flow<Boolean> =
prefs.flow(SettingsKeys.allowColorOnUnsupportedCalendars)
@@ -271,6 +272,25 @@ class SettingsPrefs @Inject constructor(
}
}
/** Show [calendarId] in every view again (#270), e.g. once it is deleted. */
suspend fun clearCalendarHiddenViews(calendarId: Long) {
prefs.update(SettingsKeys.perCalendarHiddenViews) { current -> current - calendarId }
}
/** Hide or show [calendarId] in one [view], against the stored set rather than a snapshot of it. */
suspend fun setCalendarHiddenIn(calendarId: Long, view: CalendarView, hidden: Boolean) =
applyCalendarHiddenChanges(calendarId, mapOf(view to hidden))
/** Hide (true) or show (false) [calendarId] in each view of [changes], in one write; other views keep theirs. */
suspend fun applyCalendarHiddenChanges(calendarId: Long, changes: Map<CalendarView, Boolean>) {
if (changes.isEmpty()) return
prefs.update(SettingsKeys.perCalendarHiddenViews) { current ->
val views = current[calendarId].orEmpty()
val next = views + changes.filterValues { it }.keys - changes.filterValues { !it }.keys
current.withHiddenViews(calendarId, next)
}
}
suspend fun setRemindersEnabled(enabled: Boolean) = prefs.set(SettingsKeys.remindersEnabled, enabled)
suspend fun setAllowColorOnUnsupportedCalendars(enabled: Boolean) =
@@ -588,6 +608,15 @@ object SettingsKeys {
val perCalendarEventDuration: Pref<Map<Long, Int>> =
nullableStringPref("per_calendar_event_duration").map(::parseDurationOverrides) { it.toStoredDurations() }
/**
* Views each calendar is hidden in (#270): a display-only filter on top of
* `Calendars.VISIBLE` and the drawer's filter, so reminders and search never
* see it. A calendar absent shows in every view. Serialised as
* `id=Month,Agenda;id=Week`.
*/
val perCalendarHiddenViews: Pref<Map<Long, Set<CalendarView>>> =
nullableStringPref("per_calendar_hidden_views").map(::parseHiddenViews) { it.toStoredHiddenViews() }
/**
* Whether Calendula posts reminder notifications (v1.4). On by default — for
* users whose only calendar app this is, reminders are essential.
@@ -817,9 +846,11 @@ private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored
private fun parseViewOrder(stored: String?): List<CalendarView> =
completeViewOrder(
stored?.split(',').orEmpty()
.mapNotNull { name -> CalendarView.entries.firstOrNull { it.name == name.trim() } },
.mapNotNull { it.trim().toCalendarViewOrNull() },
)
private fun String.toCalendarViewOrNull(): CalendarView? = CalendarView.entries.firstOrNull { it.name == this }
/** Parse the quick-switch config; "!"-prefixed names are disabled. */
private fun parseQuickSwitch(stored: String?): QuickSwitchConfig {
if (stored == null) return QuickSwitchConfig.Default
@@ -827,7 +858,7 @@ private fun parseQuickSwitch(stored: String?): QuickSwitchConfig {
val token = raw.trim()
val disabled = token.startsWith("!")
val name = if (disabled) token.drop(1) else token
CalendarView.entries.firstOrNull { it.name == name }?.let { it to disabled }
name.toCalendarViewOrNull()?.let { it to disabled }
}
val order = completeViewOrder(parsed.map { it.first })
// Only views explicitly stored disabled are excluded; anything appended
@@ -977,6 +1008,33 @@ private fun parseDurationOverrides(stored: String?): Map<Long, Int> =
calendarId to minutes
}.toMap()
/**
* Parse the per-calendar hidden-views map (`id=View,View` entries joined by `;`).
* Unknown view names and malformed entries are dropped; an entry left with no
* views is dropped with them.
*/
internal fun parseHiddenViews(stored: String?): Map<Long, Set<CalendarView>> =
stored?.split(ENTRY_SEP).orEmpty().mapNotNull { entry ->
val parts = entry.split(KEY_VALUE_SEP)
if (parts.size != 2) return@mapNotNull null
val calendarId = parts[0].trim().toLongOrNull() ?: return@mapNotNull null
val views = parts[1].split(LIST_SEP)
.mapNotNullTo(mutableSetOf()) { it.trim().toCalendarViewOrNull() }
if (views.isEmpty()) null else calendarId to views
}.toMap()
private fun Map<Long, Set<CalendarView>>.withHiddenViews(
calendarId: Long,
views: Set<CalendarView>,
): Map<Long, Set<CalendarView>> = if (views.isEmpty()) this - calendarId else this + (calendarId to views)
internal fun Map<Long, Set<CalendarView>>.toStoredHiddenViews(): String? =
entries.filter { it.value.isNotEmpty() }.sortedBy { it.key }
.joinToString(ENTRY_SEP) { (id, views) ->
"$id$KEY_VALUE_SEP" + views.sortedBy { it.ordinal }.joinToString(LIST_SEP) { it.name }
}
.ifEmpty { null }
private fun Map<Long, Int>.toStoredDurations(): String =
entries.sortedBy { it.key }.joinToString(ENTRY_SEP) { (id, minutes) ->
"$id$KEY_VALUE_SEP$minutes"
@@ -309,3 +309,7 @@ enum class FailureReason {
EventNotFound,
Unknown,
}
/** [this] without the events of [hidden] calendars. */
fun List<EventInstance>.withoutCalendars(hidden: Set<Long>): List<EventInstance> =
if (hidden.isEmpty()) this else filterNot { it.calendarId in hidden }
@@ -97,6 +97,8 @@ import de.jeanlucmakiola.calendula.ui.month.MonthScreen
import de.jeanlucmakiola.calendula.ui.search.SearchScreen
import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen
import de.jeanlucmakiola.calendula.ui.week.TimelineScreen
import de.jeanlucmakiola.calendula.ui.filter.FilterViewModel
import de.jeanlucmakiola.calendula.ui.filter.ViewCalendarsPicker
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
import kotlinx.coroutines.launch
@@ -192,10 +194,15 @@ fun CalendarHost(
var detailKey by rememberSaveable { mutableStateOf<LongArray?>(null) }
val onOpenDetail: (EventInstance) -> Unit = { event -> detailKey = event.occurrenceKey() }
// The drawer's "Hide in this view" picker (#517). It is its own window over
// everything here, so each external request below closes it first.
var showViewCalendars by rememberSaveable { mutableStateOf(false) }
// A tapped reminder notification asks for a specific occurrence.
val requestedDetailKey = requests.detailKey
LaunchedEffect(requestedDetailKey) {
if (requestedDetailKey != null) {
showViewCalendars = false
detailKey = requestedDetailKey
requests.detailKey = null
}
@@ -211,6 +218,7 @@ fun CalendarHost(
// top bar. Sits below the detail/edit overlays so tapping a result reveals
// the detail on top and backing out returns to the results.
var showSearch by rememberSaveable { mutableStateOf(false) }
val filterViewModel: FilterViewModel = hiltViewModel()
val onOpenSearch = { showSearch = true }
// Calendar manager (reached from Settings) — its own overlay so it slides
@@ -316,6 +324,7 @@ fun CalendarHost(
requests.importUri = null
return@LaunchedEffect
}
showViewCalendars = false
importUri = requestedImportUri
importForceMany = false
importSession++
@@ -333,6 +342,7 @@ fun CalendarHost(
requests.insert = null
return@LaunchedEffect
}
showViewCalendars = false
if (importForm == null) {
openImportForm(requestedInsert.form, requestedInsert.source, fromRequest = true)
} else {
@@ -349,6 +359,7 @@ fun CalendarHost(
fun dismissCoveringOverlays(keepCreate: Boolean = false) {
showSettings = false
showSearch = false
showViewCalendars = false
showCalendars = false
showBackup = false
detailKey = null
@@ -558,6 +569,15 @@ fun CalendarHost(
// (#239); nothing else in the calendar surfaces navigates here.
LocalManageCalendars provides remember { { showCalendars = true } },
) {
if (showViewCalendars) {
val filterState by filterViewModel.state.collectAsStateWithLifecycle()
ViewCalendarsPicker(
view = view,
state = filterState,
onSetHidden = { id, hidden -> filterViewModel.setHiddenIn(id, view, hidden) },
onDismiss = { showViewCalendars = false },
)
}
// One drawer, top bar and FAB for every screen (#184): a switch changes
// what they say, from what the screen on show publishes, rather than
// swapping them for another screen's.
@@ -588,6 +608,10 @@ fun CalendarHost(
onOpenSettings()
switchScope.launch { drawerState.close() }
},
onHideInView = {
showViewCalendars = true
switchScope.launch { drawerState.close() }
},
)
},
) {
@@ -15,6 +15,9 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.calendarListFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.calendarsHiddenIn
import de.jeanlucmakiola.calendula.ui.common.instancesWithout
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher
@@ -46,6 +49,8 @@ class AgendaViewModel @Inject constructor(
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
private val hiddenHere = settingsPrefs.perCalendarHiddenViews.calendarsHiddenIn(CalendarView.Agenda, viewModelScope)
// The saved agenda range plus the range-bar visibility toggle.
private val agendaSettings = combine(
settingsPrefs.agendaScreenRange,
@@ -138,7 +143,7 @@ class AgendaViewModel @Inject constructor(
)
combine(
repository.calendars(),
repository.instances(window),
repository.instancesWithout(window, hiddenHere),
today,
) { calendars, instances, today ->
buildState(params, calendars, instances, today)
@@ -10,7 +10,6 @@ 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.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
@@ -77,9 +76,7 @@ private val RESTORE_MIME_TYPES = arrayOf(
/**
* Backup & restore (#69): `.ics` export and restore for local calendars, with
* optional automatic backup, and the app's settings as JSON (#481). Shares
* [CalendarsViewModel] with the manager; the settings block works whatever
* state the calendar list is in.
* optional automatic backup. Shares [CalendarsViewModel] with the manager.
*
* A full-screen destination hoisted in `CalendarHost`; [onBack] pops it,
* [onImport] hands a picked file to the app's normal .ics import flow.
@@ -90,7 +87,6 @@ fun BackupScreen(
onImport: (Uri) -> Unit,
visible: Boolean = true,
viewModel: CalendarsViewModel = hiltViewModel(),
settingsBackup: SettingsBackupViewModel = hiltViewModel(),
) {
val state by viewModel.backupState.collectAsStateWithLifecycle()
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
@@ -136,27 +132,20 @@ fun BackupScreen(
}
}
val settings = rememberSettingsBackupActions(settingsBackup)
CollapsingScaffold(
title = stringResource(R.string.settings_section_backup),
onBack = onBack,
// Loading and failure fill the screen and centre themselves, which they
// can only do in an unscrolled column — the scrolling one measures them
// against an unbounded height and leaves them hanging under the header.
scrollable = state is BackupUiState.Ready,
snackbarHost = { SnackbarHost(snackbarHostState) },
predictiveBack = true,
visible = visible,
) {
// Loading and failure get a fixed-height slot in the scrolling column (it
// can't centre them in an unbounded height); the settings block below
// works in every state.
when (val s = state) {
BackupUiState.Loading -> {
StateSlot { BackupLoading() }
SettingsBackupSection(settings)
}
is BackupUiState.Failure -> {
StateSlot { CalendarFailure(reason = s.reason, onRetry = onBack) }
SettingsBackupSection(settings)
}
BackupUiState.Loading -> BackupLoading()
is BackupUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onBack)
is BackupUiState.Ready -> BackupContent(
exportable = s.exportable,
canImport = s.canImport,
@@ -169,7 +158,6 @@ fun BackupScreen(
},
onExportPick = { showExportPicker = true },
onEditInterval = { showInterval = true },
settings = settings,
)
}
}
@@ -185,7 +173,6 @@ fun BackupScreen(
)
}
}
SettingsBackupDialogs(settings, settingsBackup, snackbarHostState)
if (showInterval) {
BackupIntervalDialog(
currentMinutes = autoBackup.intervalMinutes,
@@ -195,14 +182,6 @@ fun BackupScreen(
}
}
@Composable
private fun StateSlot(content: @Composable () -> Unit) {
Box(
modifier = Modifier.fillMaxWidth().heightIn(min = STATE_MIN_HEIGHT),
contentAlignment = Alignment.Center,
) { content() }
}
/** The screen's loading state, centred in the scaffold's content column. */
@Composable
private fun BackupLoading() {
@@ -234,7 +213,6 @@ private fun BackupContent(
onExportAll: () -> Unit,
onExportPick: () -> Unit,
onEditInterval: () -> Unit,
settings: SettingsBackupActions,
) {
val manageCalendars = LocalManageCalendars.current
// Restore never depends on export eligibility, and never disappears: when no
@@ -255,7 +233,6 @@ private fun BackupContent(
leading = { LeadingAvatar(Icons.Default.FileUpload) },
onClick = onRestoreClick,
)
SettingsBackupSection(settings)
return
}
@@ -300,7 +277,6 @@ private fun BackupContent(
)
GroupedHint(backupStatusText(autoBackup.status), style = MaterialTheme.typography.bodySmall)
}
SettingsBackupSection(settings)
}
/**
@@ -457,6 +433,5 @@ private fun BackupIntervalDialog(
)
}
private val STATE_MIN_HEIGHT = 360.dp
private const val MINUTES_PER_DAY = 1_440L
private const val MINUTES_PER_WEEK = 10_080L
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.ui.calendars
import android.accounts.AccountManager
import android.content.Context
import android.content.Intent
import android.icu.text.ListFormatter
import android.provider.Settings
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
@@ -45,6 +46,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
@@ -54,6 +56,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
@@ -72,6 +75,10 @@ import de.jeanlucmakiola.calendula.domain.stateLabels
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
import de.jeanlucmakiola.calendula.ui.common.AccountKey
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.icon
import de.jeanlucmakiola.calendula.ui.common.label
import de.jeanlucmakiola.calendula.ui.common.CalendarVisibilitySwitch
import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
@@ -92,6 +99,7 @@ import de.jeanlucmakiola.floret.identity.expandEnter
import de.jeanlucmakiola.floret.identity.predictiveBack
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.MultiOptionPicker
import de.jeanlucmakiola.floret.components.Position
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.heightIn
@@ -130,14 +138,19 @@ fun CalendarsScreen(
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
val deleteLockedIds by viewModel.deleteLockedCalendarIds.collectAsStateWithLifecycle()
val error by viewModel.error.collectAsStateWithLifecycle()
val loadedHiddenViews by viewModel.hiddenViews.collectAsStateWithLifecycle()
val hiddenViews = loadedHiddenViews.orEmpty()
// null = list; NEW_CALENDAR_ID = create; any other id = edit that calendar.
// [editorSession] bumps on every open so the editor's field state resets for
// a fresh open while still surviving configuration changes within one open.
var editorId by rememberSaveable { mutableStateOf<Long?>(null) }
var editorSession by rememberSaveable { mutableStateOf(0) }
// A synced calendar has no editor; tapping it opens its hidden-views picker.
var hidingId by rememberSaveable { mutableStateOf<Long?>(null) }
if (editorId != null) {
// The editor and the picker seed from the stored hidden views, so both wait for the first read.
if (editorId != null && loadedHiddenViews != null) {
val editing = calendars.firstOrNull { it.id == editorId }
CalendarEditor(
sessionKey = editorSession,
@@ -145,13 +158,15 @@ fun CalendarsScreen(
initialName = editing?.displayName.orEmpty(),
initialColor = editing?.color ?: CalendarColorPalette.all.first(),
initialDescription = editing?.description.orEmpty(),
showHiddenViews = true,
initialHiddenViews = editing?.let { hiddenViews[it.id] }.orEmpty(),
deleteLocked = editing != null && editing.id in deleteLockedIds,
onSave = { name, color, description ->
onSave = { name, color, description, views ->
val id = editorId
if (id == null || id == NEW_CALENDAR_ID) {
viewModel.createCalendar(name, color, description)
viewModel.createCalendar(name, color, description, views)
} else {
viewModel.updateCalendar(id, name, color, description)
viewModel.updateCalendar(id, name, color, description, views)
}
editorId = null
},
@@ -165,6 +180,7 @@ fun CalendarsScreen(
CalendarsList(
local = calendars.filter { it.isLocal },
synced = calendars.filterNot { it.isLocal },
hiddenViews = hiddenViews,
error = error,
onConsumeError = viewModel::consumeError,
onOpenBackup = onOpenBackup,
@@ -172,9 +188,26 @@ fun CalendarsScreen(
visible = visible,
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
onEdit = { calendar -> editorSession++; editorId = calendar.id },
onEditHiddenViews = { calendar -> hidingId = calendar.id },
onSetVisible = viewModel::setCalendarVisible,
onSetAccountVisible = viewModel::setAccountVisible,
)
hidingId?.let { id ->
val calendar = calendars.firstOrNull { it.id == id }
// Gone while the picker was up; an empty list is one still loading, not a deletion.
if (calendar == null && calendars.isNotEmpty()) {
LaunchedEffect(id) { hidingId = null }
} else if (calendar != null && loadedHiddenViews != null) {
key(id) {
HiddenViewsPicker(
calendarName = calendar.displayName.orUnnamed(),
hidden = hiddenViews[id].orEmpty(),
onToggle = { view, hide -> viewModel.setHiddenIn(id, view, hide) },
onDismiss = { hidingId = null },
)
}
}
}
}
}
@@ -182,6 +215,7 @@ fun CalendarsScreen(
private fun CalendarsList(
local: List<CalendarSource>,
synced: List<CalendarSource>,
hiddenViews: Map<Long, Set<CalendarView>>,
error: Boolean,
onConsumeError: () -> Unit,
onOpenBackup: () -> Unit,
@@ -189,6 +223,7 @@ private fun CalendarsList(
visible: Boolean,
onAdd: () -> Unit,
onEdit: (CalendarSource) -> Unit,
onEditHiddenViews: (CalendarSource) -> Unit,
onSetVisible: (Long, Boolean) -> Unit,
onSetAccountVisible: (Collection<Long>, Boolean) -> Unit,
) {
@@ -241,6 +276,7 @@ private fun CalendarsList(
local.forEachIndexed { index, calendar ->
CalendarManagerRow(
calendar = calendar,
hiddenViews = hiddenViews[calendar.id].orEmpty(),
last = index == local.lastIndex,
onSetVisible = onSetVisible,
onClick = { onEdit(calendar) },
@@ -315,8 +351,10 @@ private fun CalendarsList(
ordered.forEachIndexed { index, calendar ->
CalendarManagerRow(
calendar = calendar,
hiddenViews = hiddenViews[calendar.id].orEmpty(),
last = index == ordered.lastIndex,
onSetVisible = onSetVisible,
onClick = { onEditHiddenViews(calendar) },
)
}
}
@@ -339,19 +377,32 @@ internal fun CalendarEditor(
initialName: String,
initialColor: Int,
initialDescription: String,
onSave: (name: String, color: Int, description: String?) -> Unit,
onSave: (name: String, color: Int, description: String?, hiddenChanges: Map<CalendarView, Boolean>) -> Unit,
onDelete: () -> Unit,
onClose: () -> Unit,
deleteLocked: Boolean = false,
showHiddenViews: Boolean = false,
initialHiddenViews: Set<CalendarView> = emptySet(),
) {
var name by rememberSaveable(sessionKey) { mutableStateOf(initialName) }
val seededHiddenViews = rememberSaveable(sessionKey) { initialHiddenViews }
var hiddenViews by rememberSaveable(sessionKey) { mutableStateOf(initialHiddenViews) }
var pickHiddenViews by rememberSaveable(sessionKey) { mutableStateOf(false) }
var color by rememberSaveable(sessionKey) { mutableStateOf(initialColor) }
var description by rememberSaveable(sessionKey) { mutableStateOf(initialDescription) }
var confirmDelete by remember { mutableStateOf(false) }
val focusRequester = remember { FocusRequester() }
val valid = name.isNotBlank()
val commit = { if (valid) onSave(name.trim(), color, description.trim().ifEmpty { null }) }
val commit = {
if (valid) {
// Only the views toggled here are written, so a save never resets ones it didn't load.
val changes = IMPLEMENTED_VIEWS
.filter { (it in hiddenViews) != (it in seededHiddenViews) }
.associateWith { it in hiddenViews }
onSave(name.trim(), color, description.trim().ifEmpty { null }, changes)
}
}
FullScreenPicker(
title = stringResource(
@@ -403,6 +454,16 @@ internal fun CalendarEditor(
}
}
if (showHiddenViews) {
Spacer(Modifier.height(20.dp))
GroupedRow(
title = stringResource(R.string.calendars_hide_in_views),
summary = hiddenViewsSummary(hiddenViews),
position = Position.Alone,
onClick = { pickHiddenViews = true },
)
}
if (!isNew && !deleteLocked) {
Spacer(Modifier.height(24.dp))
DeleteCalendarRow(onClick = { confirmDelete = true })
@@ -410,6 +471,17 @@ internal fun CalendarEditor(
Spacer(Modifier.height(24.dp))
}
if (pickHiddenViews) {
HiddenViewsPicker(
calendarName = name.trim().orUnnamed(),
hidden = hiddenViews,
onToggle = { view, hide ->
hiddenViews = if (hide) hiddenViews + view else hiddenViews - view
},
onDismiss = { pickHiddenViews = false },
)
}
if (confirmDelete) {
AlertDialog(
onDismissRequest = { confirmDelete = false },
@@ -636,7 +708,7 @@ private fun EditorSectionLabel(text: String) {
* plain writable one (#76), then its own description.
*/
@Composable
private fun calendarRowSummary(calendar: CalendarSource): String? {
private fun calendarRowSummary(calendar: CalendarSource, hiddenViews: Set<CalendarView>): String? {
val states = calendar.stateLabels().map { label ->
stringResource(
when (label) {
@@ -646,7 +718,8 @@ private fun calendarRowSummary(calendar: CalendarSource): String? {
},
)
}
val parts = states + listOfNotNull(calendar.description?.takeIf { it.isNotBlank() })
val hidden = if (hiddenViews.isEmpty()) null else hiddenViewsSummary(hiddenViews)
val parts = states + listOfNotNull(hidden, calendar.description?.takeIf { it.isNotBlank() })
return parts.joinToString(" · ").ifEmpty { null }
}
@@ -662,6 +735,7 @@ private fun calendarRowSummary(calendar: CalendarSource): String? {
@Composable
private fun CalendarManagerRow(
calendar: CalendarSource,
hiddenViews: Set<CalendarView>,
last: Boolean,
onSetVisible: (Long, Boolean) -> Unit,
onClick: (() -> Unit)? = null,
@@ -669,7 +743,7 @@ private fun CalendarManagerRow(
val disabled = !calendar.isVisibleInSystem || calendar.isNotSynced
GroupedRow(
title = calendar.displayName.orUnnamed(),
summary = calendarRowSummary(calendar),
summary = calendarRowSummary(calendar, hiddenViews),
position = if (last) Position.Bottom else Position.Middle,
container = MaterialTheme.colorScheme.surfaceContainerHighest,
dimmed = disabled,
@@ -689,6 +763,51 @@ private fun CalendarManagerRow(
)
}
/**
* Which views one calendar stays out of (#270); display only, so reminders and
* search keep the calendar. [onToggle] decides when a toggle lands: at once for
* a synced calendar, on Save inside the local editor.
*/
@Composable
private fun HiddenViewsPicker(
calendarName: String,
hidden: Set<CalendarView>,
onToggle: (CalendarView, Boolean) -> Unit,
onDismiss: () -> Unit,
) {
// Seeded once and then ahead of the store: the picker is the only writer while
// it is open, so following each write back would only flicker a quick second tap.
var shown by remember { mutableStateOf(hidden) }
MultiOptionPicker(
title = stringResource(R.string.calendars_hide_in_views),
options = IMPLEMENTED_VIEWS,
selected = shown,
label = { it.label() },
onToggle = { view, hide ->
shown = if (hide) shown + view else shown - view
onToggle(view, hide)
},
onDismiss = onDismiss,
leading = { Icon(it.icon, contentDescription = null) },
header = {
GroupedHint(
stringResource(R.string.calendars_hide_in_views_hint, calendarName),
verticalPadding = 8.dp,
)
},
predictiveBack = true,
)
}
/** "Shown in every view", or the views [hidden] names, in the app's view order. */
@Composable
private fun hiddenViewsSummary(hidden: Set<CalendarView>): String {
if (hidden.isEmpty()) return stringResource(R.string.calendars_hide_in_views_none)
val names = IMPLEMENTED_VIEWS.filter { it in hidden }.map { it.label() }
val locale = LocalConfiguration.current.locales[0]
return stringResource(R.string.calendars_hidden_in, ListFormatter.getInstance(locale).format(names))
}
/** Fade a leading element to the M3 disabled emphasis when [disabled]. */
private fun dimIf(disabled: Boolean): Modifier =
if (disabled) Modifier.alpha(0.38f) else Modifier
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.ui.calendars
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -15,6 +16,8 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.hidingNothingOnError
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher
@@ -104,6 +107,19 @@ class CalendarsViewModel @Inject constructor(
initialValue = emptySet(),
)
/** The views each calendar is hidden in (#270); null until first read. */
val hiddenViews: StateFlow<Map<Long, Set<CalendarView>>?> =
settingsPrefs.perCalendarHiddenViews
.hidingNothingOnError()
.stateWhileSubscribed(
scope = viewModelScope,
initialValue = null,
)
fun setHiddenIn(id: Long, view: CalendarView, hidden: Boolean) = write {
settingsPrefs.setCalendarHiddenIn(id, view, hidden)
}
private val _error = MutableStateFlow(false)
val error: StateFlow<Boolean> = _error.asStateFlow()
@@ -139,16 +155,41 @@ class CalendarsViewModel @Inject constructor(
}
}
fun createCalendar(displayName: String, color: Int, description: String?) = write {
repository.createLocalCalendar(displayName, color, description)
fun createCalendar(
displayName: String,
color: Int,
description: String?,
hiddenChanges: Map<CalendarView, Boolean>,
) = write {
val id = repository.createLocalCalendar(displayName, color, description)
settingsPrefs.applyCalendarHiddenChanges(id, hiddenChanges)
}
fun updateCalendar(id: Long, displayName: String, color: Int, description: String?) = write {
fun updateCalendar(
id: Long,
displayName: String,
color: Int,
description: String?,
hiddenChanges: Map<CalendarView, Boolean>,
) = write {
repository.updateCalendar(id, displayName, color, description)
settingsPrefs.applyCalendarHiddenChanges(id, hiddenChanges)
}
fun deleteCalendar(id: Long) = write {
repository.deleteCalendar(id)
// Tidy-up only: the delete already landed, so a failure here isn't its failure.
bestEffort { settingsPrefs.clearCalendarHiddenViews(id) }
}
private suspend fun bestEffort(block: suspend () -> Unit) {
try {
block()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w("CalendarsViewModel", "Hidden-views write failed", e)
}
}
/**
@@ -1,224 +0,0 @@
package de.jeanlucmakiola.calendula.ui.calendars
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FileDownload
import androidx.compose.material.icons.filled.FileUpload
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.backup.SettingsImportSummary
import de.jeanlucmakiola.calendula.data.backup.SkippedSetting
import de.jeanlucmakiola.calendula.domain.FontRole
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
import de.jeanlucmakiola.floret.backup.BackupProblem
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.locale.AppLanguage
import java.time.LocalDate
// What the import picker accepts: providers label .json inconsistently.
private val SETTINGS_MIME_TYPES = arrayOf("application/json", "text/plain", "application/octet-stream")
/** The Settings block's pickers and the import waiting for its confirmation. */
class SettingsBackupActions internal constructor(
val busy: Boolean,
internal val pendingImport: MutableState<String?>,
val export: () -> Unit,
val import: () -> Unit,
)
@Composable
fun rememberSettingsBackupActions(viewModel: SettingsBackupViewModel): SettingsBackupActions {
val busy by viewModel.busy.collectAsStateWithLifecycle()
val pendingImport = rememberSaveable { mutableStateOf<String?>(null) }
val createFile = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument("application/json"),
) { uri -> uri?.let(viewModel::export) }
val openFile = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument(),
) { uri -> pendingImport.value = uri?.toString() }
return SettingsBackupActions(
busy = busy,
pendingImport = pendingImport,
export = { runCatching { createFile.launch("calendula-settings-${LocalDate.now()}.json") } },
import = { runCatching { openFile.launch(SETTINGS_MIME_TYPES) } },
)
}
/** Settings export and import (#481), below the events' backup. */
@Composable
fun SettingsBackupSection(actions: SettingsBackupActions) {
GroupedSectionHeader(stringResource(R.string.settings_backup_header))
GroupedHint(stringResource(R.string.settings_backup_hint), style = MaterialTheme.typography.bodySmall)
GroupedRow(
title = stringResource(R.string.settings_backup_export),
summary = stringResource(R.string.settings_backup_export_summary),
position = Position.Top,
leading = { LeadingAvatar(Icons.Default.FileDownload) },
dimmed = actions.busy,
onClick = { if (!actions.busy) actions.export() },
)
GroupedRow(
title = stringResource(R.string.settings_backup_import),
summary = stringResource(R.string.settings_backup_import_summary),
position = Position.Bottom,
leading = { LeadingAvatar(Icons.Default.FileUpload) },
dimmed = actions.busy,
onClick = { if (!actions.busy) actions.import() },
)
if (actions.busy) {
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = GroupedListInset, vertical = 16.dp),
)
}
}
/**
* The import's confirmation and summary dialogs, and the snackbars for
* everything else.
*/
@Composable
fun SettingsBackupDialogs(
actions: SettingsBackupActions,
viewModel: SettingsBackupViewModel,
snackbarHostState: SnackbarHostState,
) {
val result by viewModel.result.collectAsStateWithLifecycle()
val exported = stringResource(R.string.settings_backup_export_done)
val exportFailed = stringResource(R.string.settings_backup_export_failed)
val importFailed = BackupProblem.entries.associateWith { stringResource(it.messageRes()) }
LaunchedEffect(result) {
val message = when (val r = result) {
is SettingsBackupResult.Exported -> exported
is SettingsBackupResult.ExportFailed -> exportFailed
is SettingsBackupResult.ImportFailed -> importFailed.getValue(r.problem)
is SettingsBackupResult.Imported, null -> return@LaunchedEffect
}
snackbarHostState.showSnackbar(message)
viewModel.consumeResult()
}
(result as? SettingsBackupResult.Imported)?.let { imported ->
ImportSummaryDialog(imported.summary, onDismiss = viewModel::consumeResult)
}
actions.pendingImport.value?.let { uri ->
AlertDialog(
onDismissRequest = { actions.pendingImport.value = null },
title = { Text(stringResource(R.string.settings_backup_import_confirm_title)) },
text = { Text(stringResource(R.string.settings_backup_import_confirm_message)) },
confirmButton = {
TextButton(
onClick = {
actions.pendingImport.value = null
viewModel.import(uri.toUri())
},
) { Text(stringResource(R.string.settings_backup_import_confirm_action)) }
},
dismissButton = {
TextButton(onClick = { actions.pendingImport.value = null }) {
Text(stringResource(R.string.dialog_cancel))
}
},
)
}
}
@Composable
private fun ImportSummaryDialog(summary: SettingsImportSummary, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.settings_backup_imported_title)) },
text = {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.verticalScroll(rememberScrollState()),
) {
Text(stringResource(R.string.settings_backup_imported_body))
if (summary.calendarsMatched > 0) {
Text(
pluralStringResource(
R.plurals.settings_backup_imported_calendars,
summary.calendarsMatched,
summary.calendarsMatched,
),
)
}
if (summary.skipped.isNotEmpty()) {
Text(
stringResource(R.string.settings_backup_imported_skipped),
style = MaterialTheme.typography.titleSmall,
)
summary.skipped.forEach { skipped ->
Text(
"• " + skipped.label(),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_ok)) }
},
)
}
@Composable
private fun SkippedSetting.label(): String = when (this) {
is SkippedSetting.Calendar -> stringResource(
if (ambiguous) R.string.settings_backup_skipped_calendar_ambiguous else R.string.settings_backup_skipped_calendar,
displayName,
accountName,
)
SkippedSetting.CalendarsUnavailable -> stringResource(R.string.settings_backup_skipped_calendars_unavailable)
is SkippedSetting.CustomFont -> stringResource(
when (role) {
FontRole.BRAND -> R.string.settings_backup_skipped_font_headings
FontRole.PLAIN -> R.string.settings_backup_skipped_font_body
},
)
SkippedSetting.SpecialDates -> stringResource(R.string.settings_backup_skipped_special_dates)
SkippedSetting.AutoBackupFolder -> stringResource(R.string.settings_backup_skipped_auto_backup)
is SkippedSetting.Language -> stringResource(R.string.settings_backup_skipped_language, AppLanguage.displayName(tag))
}
private fun BackupProblem.messageRes(): Int = when (this) {
BackupProblem.NOT_A_BACKUP -> R.string.settings_backup_import_failed_not_backup
BackupProblem.NEWER_VERSION -> R.string.settings_backup_import_failed_newer
BackupProblem.INVALID_DATA -> R.string.settings_backup_import_failed_invalid
BackupProblem.TOO_LARGE -> R.string.settings_backup_import_failed_too_large
BackupProblem.UNREADABLE -> R.string.settings_backup_import_failed_unreadable
BackupProblem.NOT_APPLIED -> R.string.settings_backup_import_failed
}
@@ -1,108 +0,0 @@
package de.jeanlucmakiola.calendula.ui.calendars
import android.content.Context
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.data.backup.SettingsBackupEngine
import de.jeanlucmakiola.calendula.data.backup.SettingsImportSummary
import de.jeanlucmakiola.floret.android.DocumentTooLargeException
import de.jeanlucmakiola.floret.android.readDocumentBytes
import de.jeanlucmakiola.floret.android.writeDocumentBytes
import de.jeanlucmakiola.floret.backup.BackupProblem
import de.jeanlucmakiola.floret.backup.BackupResult
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
/**
* The outcome of a settings export or import, held until the screen has shown
* it. Plain classes, not data ones: two equal outcomes in a row must each count.
*/
sealed interface SettingsBackupResult {
class Exported : SettingsBackupResult
class ExportFailed : SettingsBackupResult
class Imported(val summary: SettingsImportSummary) : SettingsBackupResult
class ImportFailed(val problem: BackupProblem) : SettingsBackupResult
}
/**
* Settings export/import on the Backup & restore screen (#481). The file is a
* single SAF document the user picked; the engine knows nothing about it.
* The result is state rather than an event, so an import that changes the
* app language — and recreates the activity — still shows its summary.
*/
@HiltViewModel
class SettingsBackupViewModel @Inject constructor(
private val engine: SettingsBackupEngine,
@ApplicationContext private val context: Context,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
private val _busy = MutableStateFlow(false)
val busy: StateFlow<Boolean> = _busy.asStateFlow()
private val _result = MutableStateFlow<SettingsBackupResult?>(null)
val result: StateFlow<SettingsBackupResult?> = _result.asStateFlow()
fun export(uri: Uri) = launchWork {
try {
val text = engine.export()
withContext(io) { context.contentResolver.writeDocumentBytes(uri, text.toByteArray(Charsets.UTF_8)) }
SettingsBackupResult.Exported()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
SettingsBackupResult.ExportFailed()
}
}
/** Call only once the user has confirmed replacing their settings. */
fun import(uri: Uri) = launchWork {
val text = try {
withContext(io) {
context.contentResolver.readDocumentBytes(uri, engine.maxImportBytes).toString(Charsets.UTF_8)
}
} catch (e: CancellationException) {
throw e
} catch (e: DocumentTooLargeException) {
return@launchWork SettingsBackupResult.ImportFailed(BackupProblem.TOO_LARGE)
} catch (e: Exception) {
return@launchWork SettingsBackupResult.ImportFailed(BackupProblem.UNREADABLE)
}
try {
when (val imported = engine.import(text)) {
is BackupResult.Success -> SettingsBackupResult.Imported(imported.value)
is BackupResult.Failure -> SettingsBackupResult.ImportFailed(imported.problem)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
SettingsBackupResult.ImportFailed(BackupProblem.NOT_APPLIED)
}
}
fun consumeResult() {
_result.value = null
}
private fun launchWork(work: suspend () -> SettingsBackupResult) {
if (_busy.value) return
_busy.value = true
viewModelScope.launch {
try {
_result.value = work()
} finally {
_busy.value = false
}
}
}
}
@@ -70,6 +70,7 @@ fun CalendarDrawer(
onSelectView: (CalendarView) -> Unit,
onJumpToDate: (LocalDate) -> Unit,
onSettings: () -> Unit,
onHideInView: () -> Unit,
viewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
) {
var showDatePicker by remember { mutableStateOf(false) }
@@ -112,7 +113,7 @@ fun CalendarDrawer(
Spacer(Modifier.height(16.dp))
DrawerSectionHeader(stringResource(R.string.filter_title))
CalendarFilterList()
CalendarFilterList(currentView = currentView, onHideInView = onHideInView)
Spacer(Modifier.height(16.dp))
GroupedRow(
@@ -0,0 +1,63 @@
package de.jeanlucmakiola.calendula.ui.common
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.withoutCalendars
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.retryWhen
import kotlinx.coroutines.flow.shareIn
import java.io.IOException
import kotlin.time.Instant
/** The calendars hidden in [view] under a per-calendar hidden-views map (#270). */
fun Map<Long, Set<CalendarView>>.calendarsHiddenIn(view: CalendarView): Set<Long> =
filterValues { view in it }.keys
/**
* The calendars hidden in [view], shared by everything one view model shows so
* the setting is read once rather than per page. A failed read hides nothing
* rather than failing the view; the replay expires with the last subscriber, so
* a returning view filters against a fresh read.
*/
fun Flow<Map<Long, Set<CalendarView>>>.calendarsHiddenIn(
view: CalendarView,
scope: CoroutineScope,
): Flow<Set<Long>> =
hidingNothingOnError()
.map { it.calendarsHiddenIn(view) }
.distinctUntilChanged()
// The grace period outlasts a flatMapLatest swap, so paging doesn't re-read it.
.shareIn(scope, SharingStarted.WhileSubscribed(5_000, replayExpirationMillis = 0), replay = 1)
/**
* A failed read of the per-view setting hides nothing rather than failing what
* shows it, and keeps reading, so the next change still lands.
*/
fun Flow<Map<Long, Set<CalendarView>>>.hidingNothingOnError(): Flow<Map<Long, Set<CalendarView>>> =
retryWhen { cause, attempt ->
if (cause !is IOException) return@retryWhen false
emit(emptyMap())
// Backs off, so a file that keeps failing (corrupt) isn't re-read every second.
delay((RETRY_BASE_DELAY_MS shl attempt.coerceAtMost(6).toInt()).coerceAtMost(RETRY_MAX_DELAY_MS))
true
}
private const val RETRY_BASE_DELAY_MS = 1_000L
private const val RETRY_MAX_DELAY_MS = 60_000L
/**
* [CalendarRepository.instances] as one view shows them: also without the
* [hidden] calendars the user keeps out of it. Display only — reminders, search
* and widgets read the repository directly.
*/
fun CalendarRepository.instancesWithout(
range: ClosedRange<Instant>,
hidden: Flow<Set<Long>>,
): Flow<List<EventInstance>> =
combine(instances(range), hidden) { instances, ids -> instances.withoutCalendars(ids) }
@@ -4,15 +4,23 @@ 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.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@@ -22,20 +30,26 @@ import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.ui.common.CalendarAccountGroup
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf
/**
* Calendar-visibility filter (M3), rendered inline in the navigation drawer.
* Every calendar grouped by account, each with a colour swatch and a visibility
* switch; toggling writes straight to DataStore and every calendar view
* re-filters live. Three states (Loading / Failure / Success).
* re-filters live. Three states (Loading / Failure / Success). A calendar hidden
* in [currentView] through its per-view setting (#270) says so on its row, and
* the entry below the list opens that setting for the view (#517).
*/
@Composable
fun CalendarFilterList(
currentView: CalendarView,
onHideInView: () -> Unit,
modifier: Modifier = Modifier,
viewModel: FilterViewModel = hiltViewModel(),
) {
@@ -43,12 +57,18 @@ fun CalendarFilterList(
when (val s = state) {
FilterUiState.Loading -> FilterLoading(modifier)
is FilterUiState.Failure -> FilterMessage(s.reason, modifier)
is FilterUiState.Success -> FilterList(
groups = s.groups,
hidden = s.hidden,
onSetVisible = viewModel::setVisible,
modifier = modifier,
)
is FilterUiState.Success -> {
val hiddenInView = remember(s, currentView) { s.hiddenOnlyIn(currentView) }
FilterList(
groups = s.groups,
hidden = s.hidden,
hiddenInView = hiddenInView,
hiddenOnlyHere = hiddenInView.size,
onHideInView = onHideInView,
onSetVisible = viewModel::setVisible,
modifier = modifier,
)
}
}
}
@@ -61,6 +81,9 @@ fun CalendarFilterList(
private fun FilterList(
groups: List<CalendarAccountGroup>,
hidden: Set<Long>,
hiddenInView: Set<Long>,
hiddenOnlyHere: Int,
onHideInView: () -> Unit,
onSetVisible: (Long, Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
@@ -81,6 +104,7 @@ private fun FilterList(
val visible = cal.id !in hidden
GroupedRow(
title = cal.displayName.orUnnamed(),
summary = if (cal.id in hiddenInView) stringResource(R.string.filter_hidden_in_view) else null,
position = positionOf(index, group.calendars.size),
minHeight = 56.dp,
leading = { CalendarColorChip(cal.color) },
@@ -94,11 +118,32 @@ private fun FilterList(
)
}
}
// The per-view hide (#517): the drawer filter above hides everywhere, this only here.
Spacer(Modifier.height(8.dp))
GroupedRow(
title = stringResource(R.string.view_calendars_title),
summary = if (hiddenOnlyHere > 0) {
pluralStringResource(R.plurals.drawer_hidden_in_view_count, hiddenOnlyHere, hiddenOnlyHere)
} else {
stringResource(R.string.drawer_hidden_in_view_none)
},
position = Position.Alone,
minHeight = 56.dp,
// Tinted, not "selected": it is an entry point, not a chosen option.
container = if (hiddenOnlyHere > 0) MaterialTheme.colorScheme.secondaryContainer else null,
contentColor = if (hiddenOnlyHere > 0) MaterialTheme.colorScheme.onSecondaryContainer else null,
leading = {
Box(Modifier.size(40.dp), contentAlignment = Alignment.Center) {
Icon(Icons.Filled.FilterList, contentDescription = null)
}
},
onClick = onHideInView,
)
}
}
@Composable
private fun FilterLoading(modifier: Modifier = Modifier) {
internal fun FilterLoading(modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
@@ -119,7 +164,7 @@ private fun FilterLoading(modifier: Modifier = Modifier) {
}
@Composable
private fun FilterMessage(reason: FailureReason, modifier: Modifier = Modifier) {
internal fun FilterMessage(reason: FailureReason, modifier: Modifier = Modifier) {
val msg = when (reason) {
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars
FailureReason.AllCalendarsHidden -> R.string.state_failure_all_hidden
@@ -2,6 +2,8 @@ package de.jeanlucmakiola.calendula.ui.filter
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.ui.common.CalendarAccountGroup
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.calendarsHiddenIn
/**
* State for the calendar-filter sheet (M3). The user toggles per-calendar
@@ -11,9 +13,22 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarAccountGroup
sealed interface FilterUiState {
data object Loading : FilterUiState
data class Failure(val reason: FailureReason) : FilterUiState
/** A calendar shows when its id is *not* in [hidden]. */
/**
* A calendar shows when its id is *not* in [hidden]; [hiddenViews] are the
* views each calendar is hidden in regardless (#270).
*/
data class Success(
val groups: List<CalendarAccountGroup>,
val hidden: Set<Long>,
) : FilterUiState
val hiddenViews: Map<Long, Set<CalendarView>> = emptyMap(),
) : FilterUiState {
/**
* The listed calendars [view] hides on its own (#517); ones already
* hidden everywhere from the drawer, or not listed, aren't included.
*/
fun hiddenOnlyIn(view: CalendarView): Set<Long> {
val listed = groups.flatMapTo(mutableSetOf()) { group -> group.calendars.map { it.id } }
return hiddenViews.calendarsHiddenIn(view).filterTo(mutableSetOf()) { it in listed && it !in hidden }
}
}
}
@@ -1,15 +1,20 @@
package de.jeanlucmakiola.calendula.ui.filter
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.calendarListFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.hidingNothingOnError
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
@@ -23,6 +28,7 @@ import javax.inject.Inject
class FilterViewModel @Inject constructor(
private val repository: CalendarRepository,
private val prefs: CalendarPrefs,
private val settingsPrefs: SettingsPrefs,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
@@ -30,7 +36,9 @@ class FilterViewModel @Inject constructor(
combine(
repository.calendars(),
prefs.hiddenCalendarIds,
) { calendars, hidden ->
// Only feeds a note on the rows; a failed read leaves it off rather than failing the list.
settingsPrefs.perCalendarHiddenViews.hidingNothingOnError(),
) { calendars, hidden, hiddenViews ->
// Calendars switched off device-wide don't belong in the drawer's
// hide/show list; they live only in Settings → Calendars.
val enabled = calendars.filter { it.isVisibleInSystem }
@@ -38,7 +46,7 @@ class FilterViewModel @Inject constructor(
if (failure != null) {
FilterUiState.Failure(failure)
} else {
FilterUiState.Success(enabled.groupByAccount(), hidden)
FilterUiState.Success(enabled.groupByAccount(), hidden, hiddenViews)
}
}
.catch { emit(FilterUiState.Failure(FailureReason.ProviderUnavailable)) }
@@ -48,6 +56,19 @@ class FilterViewModel @Inject constructor(
initialValue = FilterUiState.Loading,
)
/** Hide or show one calendar in [view] only (#517), the same setting as Settings → Calendars. */
fun setHiddenIn(calendarId: Long, view: CalendarView, hidden: Boolean) {
viewModelScope.launch {
try {
settingsPrefs.setCalendarHiddenIn(calendarId, view, hidden)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w("FilterViewModel", "Could not change hidden views of $calendarId", e)
}
}
}
/** Show or hide a single calendar; persists the new hidden set. */
fun setVisible(calendarId: Long, visible: Boolean) {
viewModelScope.launch {
@@ -0,0 +1,65 @@
package de.jeanlucmakiola.calendula.ui.filter
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle
import de.jeanlucmakiola.calendula.ui.common.calendarsHiddenIn
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.components.MultiOptionRows
/**
* The calendars hidden in [view] (#517), opened from the drawer while on it: the
* per-view setting from Settings → Calendars (#270), seen from the view's side.
* Toggles apply at once.
*/
@Composable
fun ViewCalendarsPicker(
view: CalendarView,
state: FilterUiState,
onSetHidden: (calendarId: Long, hidden: Boolean) -> Unit,
onDismiss: () -> Unit,
) {
FullScreenPicker(
title = stringResource(R.string.view_calendars_title),
onDismiss = onDismiss,
predictiveBack = true,
) {
when (state) {
FilterUiState.Loading -> FilterLoading()
is FilterUiState.Failure -> FilterMessage(state.reason)
is FilterUiState.Success -> {
// Seeded once, then ahead of the store: the picker is the only
// writer while open, so a quick second tap still flips.
var hidden by remember(view) { mutableStateOf(state.hiddenViews.calendarsHiddenIn(view)) }
GroupedHint(stringResource(R.string.view_calendars_hint), verticalPadding = 8.dp)
state.groups.forEach { group ->
GroupedSectionHeader(accountGroupTitle(group))
MultiOptionRows(
options = group.calendars,
selected = group.calendars.filterTo(mutableSetOf()) { it.id in hidden },
label = { it.displayName.orUnnamed() },
onToggle = { calendar, hide ->
hidden = if (hide) hidden + calendar.id else hidden - calendar.id
onSetHidden(calendar.id, hide)
},
summary = { calendar ->
if (calendar.id in state.hidden) stringResource(R.string.view_calendars_hidden_everywhere) else null
},
leading = { CalendarColorChip(it.color) },
)
}
}
}
}
}
@@ -19,7 +19,10 @@ import de.jeanlucmakiola.calendula.domain.calendarListFailure
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay
import de.jeanlucmakiola.calendula.ui.agenda.anchorTodayIfMissing
import de.jeanlucmakiola.calendula.ui.common.CalendarSpan
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.PageStateCache
import de.jeanlucmakiola.calendula.ui.common.calendarsHiddenIn
import de.jeanlucmakiola.calendula.ui.common.instancesWithout
import de.jeanlucmakiola.calendula.domain.isCrossedOut
import de.jeanlucmakiola.calendula.domain.sortedForDisplay
import de.jeanlucmakiola.calendula.ui.week.coversDay
@@ -68,6 +71,8 @@ class MonthViewModel @Inject constructor(
private val zone = TimeZone.currentSystemDefault()
private val hiddenHere = settingsPrefs.perCalendarHiddenViews.calendarsHiddenIn(CalendarView.Month, viewModelScope)
/** First day of the week, from the Settings preference (AUTO → locale). */
val weekStart: StateFlow<DayOfWeek> = settingsPrefs.firstDayOfWeek(viewModelScope)
@@ -152,7 +157,7 @@ class MonthViewModel @Inject constructor(
) { (ym, ws) ->
combine(
repository.calendars(),
repository.instances(monthGridRange(ym, ws, zone)),
repository.instancesWithout(monthGridRange(ym, ws, zone), hiddenHere),
today,
) { calendars, instances, today ->
buildState(ym, ws, calendars, instances, today)
@@ -224,7 +229,7 @@ class MonthViewModel @Inject constructor(
val range = dayRange(first, last, zone)
combine(
repository.calendars(),
repository.instances(range),
repository.instancesWithout(range, hiddenHere),
today,
) { calendars, instances, today ->
buildContinuousState(window, ws, style, calendars, instances, today)
@@ -89,7 +89,7 @@ internal fun CalendarsStep(
initialName = "",
initialColor = CalendarColorPalette.all.first(),
initialDescription = "",
onSave = { name, color, description ->
onSave = { name, color, description, _ ->
if (!submitted) {
submitted = true
onCreateLocal(name, color, description)
@@ -19,7 +19,10 @@ import de.jeanlucmakiola.calendula.domain.displayPriority
import de.jeanlucmakiola.calendula.domain.displayStart
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.calendarListFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.PageStateCache
import de.jeanlucmakiola.calendula.ui.common.calendarsHiddenIn
import de.jeanlucmakiola.calendula.ui.common.instancesWithout
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher
@@ -89,8 +92,11 @@ abstract class PagedTimelineViewModel(
private val io: CoroutineDispatcher,
gridRule: Flow<(LocalDate) -> DaysGrid>,
initialRule: (LocalDate) -> DaysGrid,
view: CalendarView,
) : ViewModel() {
private val hiddenHere = settingsPrefs.perCalendarHiddenViews.calendarsHiddenIn(view, viewModelScope)
/**
* The zone every page is laid out in; drags and drops resolve in it too, and
* pages lay out again when the device moves zones (#387).
@@ -167,7 +173,7 @@ abstract class PagedTimelineViewModel(
zone.flatMapLatest { zone ->
combine(
repository.calendars(),
repository.instances(spanRange(span.start, span.count, zone)),
repository.instancesWithout(spanRange(span.start, span.count, zone), hiddenHere),
today,
) { calendars, instances, today ->
buildState(span, calendars, instances, today, zone)
@@ -240,6 +246,7 @@ class WeekViewModel @Inject constructor(
rule
},
initialRule = { DaysGrid.weeks(DayOfWeek.MONDAY) },
view = CalendarView.Week,
)
/**
@@ -264,6 +271,7 @@ class MultiDayViewModel @Inject constructor(
{ anchor: LocalDate -> DaysGrid.startingAt(anchor, count) }
},
initialRule = { DaysGrid.startingAt(it, SettingsPrefs.DEFAULT_MULTI_DAY_COUNT) },
view = CalendarView.MultiDay,
)
/** The day view: the timeline one day at a time. */
@@ -282,6 +290,7 @@ class DayViewModel @Inject constructor(
io = io,
gridRule = flowOf { _: LocalDate -> DaysGrid.days },
initialRule = { DaysGrid.days },
view = CalendarView.Day,
)
/**
@@ -11,7 +11,6 @@ import androidx.glance.GlanceId
import androidx.glance.appwidget.state.getAppWidgetState
import androidx.glance.state.PreferencesGlanceStateDefinition
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
@@ -116,8 +115,5 @@ internal fun MutablePreferences.putHiddenCalendars(ids: Set<Long>) {
this[HiddenCalendarsKey] = ids.map(Long::toString).toSet()
}
internal fun List<EventInstance>.withoutCalendars(hidden: Set<Long>): List<EventInstance> =
if (hidden.isEmpty()) this else filterNot { it.calendarId in hidden }
internal suspend fun Context.widgetState(id: GlanceId): Preferences =
getAppWidgetState(this, PreferencesGlanceStateDefinition, id)
@@ -14,6 +14,7 @@ import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.withoutCalendars
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.agendaRange
@@ -12,6 +12,7 @@ import de.jeanlucmakiola.floret.glance.updateWithRefresh
import kotlinx.coroutines.CancellationException
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.withoutCalendars
import de.jeanlucmakiola.calendula.widget.Themed
import de.jeanlucmakiola.calendula.widget.WidgetKind
import de.jeanlucmakiola.calendula.widget.WidgetLoadContext
@@ -22,7 +23,6 @@ import de.jeanlucmakiola.calendula.widget.today
import de.jeanlucmakiola.calendula.widget.widgetEntryPoint
import de.jeanlucmakiola.calendula.widget.widgetLoadContext
import de.jeanlucmakiola.calendula.widget.widgetState
import de.jeanlucmakiola.calendula.widget.withoutCalendars
import de.jeanlucmakiola.floret.prefs.toEnum
import kotlinx.coroutines.flow.first
import kotlinx.datetime.LocalDate
+14 -36
View File
@@ -849,7 +849,7 @@
<string name="calendars_state_managed">Filled from your contacts</string>
<string name="calendars_managed_delete_locked">This calendar is filled from your contacts, so Calendula would create it again on the next sync. Turn special dates off under Settings → Special dates to delete it.</string>
<string name="calendars_synced_header">Synced calendars</string>
<string name="calendars_synced_hint">These come from accounts on your device. Create and edit them in their own app.</string>
<string name="calendars_synced_hint">These come from accounts on your device. Create and edit them in their own app; tap one here to hide it in some views.</string>
<string name="calendars_manage_in_app">Manage in app</string>
<!-- Account header when two accounts share a name: %1$s is the account, %2$s the app it comes from. -->
<string name="calendars_account_from_source">%1$s (%2$s)</string>
@@ -863,6 +863,19 @@
<string name="calendars_color_label">Color</string>
<string name="calendars_description_label">Description</string>
<string name="calendars_description_hint">Add a description</string>
<string name="calendars_hide_in_views">Hide in views</string>
<string name="calendars_hide_in_views_hint">Events from “%1$s” stay out of the views checked here. Reminders, search and widgets still include them.</string>
<string name="calendars_hide_in_views_none">Shown in every view</string>
<string name="calendars_hidden_in">Hidden in %1$s</string>
<string name="filter_hidden_in_view">Hidden in this view</string>
<string name="view_calendars_title">Hide in this view</string>
<plurals name="drawer_hidden_in_view_count">
<item quantity="one">%d calendar hidden in this view</item>
<item quantity="other">%d calendars hidden in this view</item>
</plurals>
<string name="drawer_hidden_in_view_none">Leave calendars out of this view only</string>
<string name="view_calendars_hint">Checked calendars stay out of this view only. Reminders, search and widgets still include them.</string>
<string name="view_calendars_hidden_everywhere">Hidden in every view from the menu</string>
<string name="color_name_mauve">Mauve</string>
<string name="color_name_red">Red</string>
<string name="color_name_orange">Orange</string>
@@ -910,41 +923,6 @@
<item>Weeks</item>
</string-array>
<string name="calendars_backup_failed">Couldn\'t export the backup.</string>
<string name="settings_backup_header">Settings</string>
<string name="settings_backup_hint">Save Calendula\'s settings to a file and restore them after reinstalling or on a new phone. Events aren\'t part of it.</string>
<string name="settings_backup_export">Export settings</string>
<string name="settings_backup_export_summary">Save every setting to a .json file</string>
<string name="settings_backup_import">Import settings</string>
<string name="settings_backup_import_summary">Replace the current settings with a saved file</string>
<string name="settings_backup_export_done">Settings exported.</string>
<string name="settings_backup_export_failed">Couldn\'t export the settings.</string>
<string name="settings_backup_import_confirm_title">Replace your settings?</string>
<string name="settings_backup_import_confirm_message">Every setting is replaced with the one in the file. Your events and calendars stay as they are, apart from contact special dates, which follow the imported setting.</string>
<string name="settings_backup_import_confirm_action">Replace</string>
<string name="settings_backup_import_failed_not_backup">That file isn\'t a Calendula settings backup.</string>
<string name="settings_backup_import_failed_newer">That backup is from a newer Calendula. Update the app to import it.</string>
<string name="settings_backup_import_failed_invalid">That settings backup is damaged and wasn\'t imported.</string>
<string name="settings_backup_import_failed_too_large">That file is too large to be a settings backup.</string>
<string name="settings_backup_import_failed_unreadable">Couldn\'t read that file.</string>
<string name="settings_backup_import_failed">Couldn\'t import the settings.</string>
<string name="settings_backup_imported_title">Settings imported</string>
<string name="settings_backup_imported_body">Your settings now match the ones in the file.</string>
<plurals name="settings_backup_imported_calendars">
<item quantity="one">Calendar settings were applied to %d calendar.</item>
<item quantity="other">Calendar settings were applied to %d calendars.</item>
</plurals>
<string name="settings_backup_imported_skipped">Not imported</string>
<!-- %1$s: calendar name, %2$s: its account -->
<string name="settings_backup_skipped_calendar">%1$s (%2$s): no matching calendar on this phone</string>
<!-- %1$s: calendar name, %2$s: its account -->
<string name="settings_backup_skipped_calendar_ambiguous">%1$s (%2$s): more than one calendar on this phone has that name</string>
<string name="settings_backup_skipped_calendars_unavailable">Calendar settings: Calendula can\'t read your calendars, so they were left as they are</string>
<string name="settings_backup_skipped_font_headings">Custom headings font: choose the font file again</string>
<string name="settings_backup_skipped_font_body">Custom body font: choose the font file again</string>
<string name="settings_backup_skipped_special_dates">Contact special dates: turn them on again to allow access to contacts</string>
<string name="settings_backup_skipped_auto_backup">Automatic backup: choose the backup folder again</string>
<!-- %1$s: the language's name -->
<string name="settings_backup_skipped_language">%1$s isn\'t available in this version</string>
<plurals name="calendars_backup_done">
<item quantity="one">Exported %d event.</item>
<item quantity="other">Exported %d events.</item>
@@ -1,45 +0,0 @@
package de.jeanlucmakiola.calendula.data.backup
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefKeys
import de.jeanlucmakiola.calendula.data.prefs.ReminderStateKeys
import de.jeanlucmakiola.calendula.data.prefs.SettingsKeys
import de.jeanlucmakiola.floret.prefs.Pref
import org.junit.jupiter.api.Test
/**
* Every key of the settings store is either backed up or deliberately left
* out. A new key fails here until [SettingsBackupSchema] says which.
*/
class SettingsBackupSchemaTest {
private val schema = SettingsBackupSchema
private fun keysOf(holder: Any): List<String> = holder.javaClass.declaredFields
.filter { java.lang.reflect.Modifier.isStatic(it.modifiers) && it.name != "INSTANCE" }
.flatMap { field ->
field.isAccessible = true
when (val value = field.get(null)) {
is Pref<*> -> listOf(value.name)
is Map<*, *> -> value.values.filterIsInstance<Pref<*>>().map { it.name }
else -> emptyList()
}
}
@Test
fun `every stored key is classified`() {
val stored = (keysOf(SettingsKeys) + keysOf(CalendarPrefKeys) + keysOf(ReminderStateKeys)).toSet()
val classified = (schema.plain + schema.perCalendar + schema.excluded).map { it.name }
assertThat(stored).isNotEmpty()
assertThat(classified).containsAtLeastElementsIn(stored)
assertThat(stored).containsAtLeastElementsIn(classified)
}
@Test
fun `no key is classified twice`() {
val classified = (schema.plain + schema.perCalendar + schema.excluded).map { it.name }
assertThat(classified).containsNoDuplicates()
}
}
@@ -1,408 +0,0 @@
package de.jeanlucmakiola.calendula.data.backup
import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.mutablePreferencesOf
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.data.appname.LauncherName
import de.jeanlucmakiola.calendula.data.calendar.ManagedCalendarRow
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefKeys
import de.jeanlucmakiola.calendula.data.prefs.SettingsKeys
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.FontRole
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
import de.jeanlucmakiola.floret.backup.BackupProblem
import de.jeanlucmakiola.floret.backup.BackupResult
import de.jeanlucmakiola.floret.prefs.ThemeMode
import de.jeanlucmakiola.floret.prefs.get
import de.jeanlucmakiola.floret.prefs.set
import org.junit.jupiter.api.Test
class SettingsBackupTest {
private fun calendar(id: Long, name: String, account: String = "me@example.org", managed: Boolean = false) =
CalendarSource(
id = id,
displayName = name,
accountName = account,
accountType = "com.google",
color = 0,
isVisibleInSystem = true,
isManaged = managed,
)
private val work = calendar(1, "Work")
private val home = calendar(2, "Home")
private val birthdays = calendar(3, "Birthdays", account = "Calendula", managed = true)
private fun environment(
calendars: List<CalendarSource>? = listOf(work, home, birthdays),
managed: List<ManagedCalendarRow> = listOf(ManagedCalendarRow(3, SpecialDateType.Birthday)),
contacts: Boolean = true,
fonts: Set<FontRole> = emptySet(),
folder: Boolean = false,
languages: List<String> = listOf("de", "en-US", "fr"),
) = ImportEnvironment(calendars, managed, contacts, fonts, folder, languages)
private fun roundTrip(source: MutablePreferences, calendars: List<CalendarSource> = listOf(work, home, birthdays)): SettingsBackup {
val backup = buildSettingsBackup(
snapshot = source,
calendars = calendars,
managed = listOf(ManagedCalendarRow(3, SpecialDateType.Birthday)),
language = LanguageChoice("de"),
launcherName = LauncherName.CALENDAR,
)
val decoded = decodeSettingsBackup(encodeSettingsBackup(backup, 1_700_000_000_000, "2.25.0"))
return (decoded as BackupResult.Success).value
}
private fun import(backup: SettingsBackup, current: MutablePreferences, env: ImportEnvironment = environment()) =
planSettingsImport(backup, current, env).also { it.applyTo(current) }
@Test
fun `plain settings survive the round trip`() {
val source = mutablePreferencesOf()
source[SettingsKeys.themeMode] = ThemeMode.BLACK
source[SettingsKeys.multiDayCount] = 5
source[SettingsKeys.defaultView] = CalendarView.Month
source[SettingsKeys.defaultReminderMinutes] = listOf(10, 60)
val target = mutablePreferencesOf()
import(roundTrip(source), target)
assertThat(target[SettingsKeys.themeMode]).isEqualTo(ThemeMode.BLACK)
assertThat(target[SettingsKeys.multiDayCount]).isEqualTo(5)
assertThat(target[SettingsKeys.defaultView]).isEqualTo(CalendarView.Month)
assertThat(target[SettingsKeys.defaultReminderMinutes]).containsExactly(10, 60).inOrder()
}
@Test
fun `a setting the file leaves at its default resets here`() {
val target = mutablePreferencesOf()
target[SettingsKeys.showWeekNumbers] = true
import(roundTrip(mutablePreferencesOf()), target)
assertThat(target[SettingsKeys.showWeekNumbers]).isFalse()
}
@Test
fun `device state is neither exported nor touched`() {
val source = mutablePreferencesOf()
source[SettingsKeys.autoBackupFolderUri] = "content://tree/backup"
source[SettingsKeys.recentTimeZones] = listOf("Europe/Berlin")
val target = mutablePreferencesOf()
target[SettingsKeys.autoBackupLastRun] = 42L
val backup = roundTrip(source)
import(backup, target)
assertThat(backup.prefs.keys.map { it.name }).doesNotContain("auto_backup_folder_uri")
assertThat(target[SettingsKeys.autoBackupFolderUri]).isNull()
assertThat(target[SettingsKeys.autoBackupLastRun]).isEqualTo(42L)
}
@Test
fun `per-calendar settings follow the calendar to its new id`() {
val source = mutablePreferencesOf()
source[CalendarPrefKeys.hiddenCalendarIds] = setOf(1L)
source[CalendarPrefKeys.lastUsedCalendarId] = 2L
source[SettingsKeys.perCalendarEventDuration] = mapOf(1L to 30)
source[SettingsKeys.perCalendarReminderOverride] = mapOf(2L to emptyList())
val moved = listOf(calendar(11, "Work"), calendar(12, "Home"))
val target = mutablePreferencesOf()
val plan = import(roundTrip(source), target, environment(calendars = moved, managed = emptyList()))
assertThat(target[CalendarPrefKeys.hiddenCalendarIds]).containsExactly(11L)
assertThat(target[CalendarPrefKeys.lastUsedCalendarId]).isEqualTo(12L)
assertThat(target[SettingsKeys.perCalendarEventDuration]).containsExactly(11L, 30)
assertThat(target[SettingsKeys.perCalendarReminderOverride]).containsExactly(12L, emptyList<Int>())
assertThat(plan.calendars).hasSize(2)
}
@Test
fun `a calendar missing here is reported, and its old id is not reused`() {
val source = mutablePreferencesOf()
source[SettingsKeys.perCalendarEventDuration] = mapOf(1L to 30)
val target = mutablePreferencesOf()
target[SettingsKeys.perCalendarEventDuration] = mapOf(1L to 45)
val plan = import(roundTrip(source), target, environment(calendars = listOf(calendar(1, "Other"))))
assertThat(target[SettingsKeys.perCalendarEventDuration]).isEmpty()
assertThat(plan.skipped).containsExactly(SkippedSetting.Calendar("Work", "me@example.org"))
}
@Test
fun `two calendars with the same name and account match neither`() {
val source = mutablePreferencesOf()
source[CalendarPrefKeys.hiddenCalendarIds] = setOf(1L)
val target = mutablePreferencesOf()
val plan = import(roundTrip(source), target, environment(calendars = listOf(calendar(5, "Work"), calendar(6, "Work"))))
assertThat(target[CalendarPrefKeys.hiddenCalendarIds]).isEmpty()
assertThat(plan.skipped).containsExactly(SkippedSetting.Calendar("Work", "me@example.org", ambiguous = true))
}
@Test
fun `every per-calendar key travels with its calendar`() {
val source = mutablePreferencesOf()
source[CalendarPrefKeys.hiddenCalendarIds] = setOf(1L)
source[CalendarPrefKeys.lastUsedCalendarId] = 1L
source[SettingsKeys.perCalendarEventDuration] = mapOf(1L to 30)
source[SettingsKeys.perCalendarReminderOverride] = mapOf(1L to listOf(5))
source[SettingsKeys.perCalendarAllDayReminderOverride] = mapOf(1L to listOf(0))
val target = mutablePreferencesOf()
import(roundTrip(source), target, environment(calendars = listOf(calendar(9, "Work")), managed = emptyList()))
// One assertion per key in the schema: a new per-calendar key fails here until it is carried.
SettingsBackupSchema.perCalendar.forEach { pref ->
assertThat(target.asMap().keys.map { it.name }).contains(pref.name)
}
assertThat(target[SettingsKeys.perCalendarAllDayReminderOverride]).containsExactly(9L, listOf(0))
}
@Test
fun `a mirror type the import switches off is left out`() {
val source = mutablePreferencesOf()
source[SettingsKeys.specialDatesEnabled] = true
source[SettingsKeys.specialDateTypeEnabled.getValue(SpecialDateType.Birthday)] = false
source[CalendarPrefKeys.hiddenCalendarIds] = setOf(3L)
val target = mutablePreferencesOf()
val plan = import(roundTrip(source), target)
assertThat(target[CalendarPrefKeys.hiddenCalendarIds]).isEmpty()
assertThat(plan.pendingSpecialDates).isEmpty()
assertThat(plan.skipped).isEmpty()
}
@Test
fun `an out-of-range calendar length is clamped, not refused`() {
val text = """{"format":"calendula-settings","version":1,"exportedAt":1,"settings":{},"calendars":[
{"account_name":"me@example.org","account_type":"com.google","display_name":"Work","event_duration_minutes":5000}]}"""
val backup = (decodeSettingsBackup(text) as BackupResult.Success).value
val target = mutablePreferencesOf()
import(backup, target)
assertThat(target[SettingsKeys.perCalendarEventDuration]).containsExactly(1L, 1440)
}
@Test
fun `unreadable calendars leave the per-calendar settings alone`() {
val source = mutablePreferencesOf()
source[CalendarPrefKeys.hiddenCalendarIds] = setOf(1L)
val target = mutablePreferencesOf()
target[SettingsKeys.perCalendarEventDuration] = mapOf(4L to 45)
val plan = import(roundTrip(source), target, environment(calendars = null))
assertThat(target[SettingsKeys.perCalendarEventDuration]).containsExactly(4L, 45)
assertThat(plan.skipped).containsExactly(SkippedSetting.CalendarsUnavailable)
}
@Test
fun `mirror calendars are left out when the import turns the mirror off`() {
val source = mutablePreferencesOf()
source[CalendarPrefKeys.hiddenCalendarIds] = setOf(3L)
val target = mutablePreferencesOf()
val plan = import(roundTrip(source), target)
assertThat(target[CalendarPrefKeys.hiddenCalendarIds]).isEmpty()
assertThat(plan.pendingSpecialDates).isEmpty()
assertThat(plan.skipped).isEmpty()
}
@Test
fun `a stale id in the prefs is dropped on export`() {
val source = mutablePreferencesOf()
source[CalendarPrefKeys.hiddenCalendarIds] = setOf(1L, 99L)
assertThat(roundTrip(source).calendars.map { it.calendar.displayName }).containsExactly("Work")
}
@Test
fun `a mirror calendar is matched by its date type`() {
val source = mutablePreferencesOf()
source[SettingsKeys.specialDatesEnabled] = true
source[SettingsKeys.perCalendarAllDayReminderOverride] = mapOf(3L to listOf(0, 10080))
val target = mutablePreferencesOf()
val renamed = listOf(calendar(7, "Geburtstage", account = "Calendula", managed = true))
import(roundTrip(source), target, environment(calendars = renamed, managed = listOf(ManagedCalendarRow(7, SpecialDateType.Birthday))))
assertThat(target[SettingsKeys.perCalendarAllDayReminderOverride]).containsExactly(7L, listOf(0, 10080))
}
@Test
fun `a mirror calendar that doesn't exist yet waits for the sync`() {
val source = mutablePreferencesOf()
source[SettingsKeys.specialDatesEnabled] = true
source[SettingsKeys.perCalendarAllDayReminderOverride] = mapOf(3L to listOf(0))
val target = mutablePreferencesOf()
val plan = import(roundTrip(source), target, environment(managed = emptyList()))
plan.pendingSpecialDates.forEach { target.addCalendarSettings(8L, it) }
assertThat(plan.skipped).isEmpty()
assertThat(target[SettingsKeys.perCalendarAllDayReminderOverride]).containsExactly(8L, listOf(0))
}
@Test
fun `a custom font without its file keeps the current font`() {
val source = mutablePreferencesOf()
source[SettingsKeys.brandFont] = FONT_CUSTOM_TOKEN
source[SettingsKeys.plainFont] = FONT_CUSTOM_TOKEN
val target = mutablePreferencesOf()
target[SettingsKeys.brandFont] = "lora"
val plan = import(roundTrip(source), target, environment(fonts = setOf(FontRole.PLAIN)))
assertThat(target[SettingsKeys.brandFont]).isEqualTo("lora")
assertThat(target[SettingsKeys.plainFont]).isEqualTo(FONT_CUSTOM_TOKEN)
assertThat(plan.skipped).containsExactly(SkippedSetting.CustomFont(FontRole.BRAND))
}
@Test
fun `contact dates without the permission stay as they are`() {
val source = mutablePreferencesOf()
source[SettingsKeys.specialDatesEnabled] = true
val target = mutablePreferencesOf()
val plan = import(roundTrip(source), target, environment(contacts = false))
assertThat(target[SettingsKeys.specialDatesEnabled]).isFalse()
assertThat(plan.skipped).contains(SkippedSetting.SpecialDates)
}
@Test
fun `automatic backup without a folder here stays off, its interval comes along`() {
val source = mutablePreferencesOf()
source[SettingsKeys.autoBackupEnabled] = true
source[SettingsKeys.autoBackupIntervalMinutes] = 120L
val target = mutablePreferencesOf()
val plan = import(roundTrip(source), target)
assertThat(target[SettingsKeys.autoBackupEnabled]).isFalse()
assertThat(target[SettingsKeys.autoBackupIntervalMinutes]).isEqualTo(120L)
assertThat(plan.skipped).contains(SkippedSetting.AutoBackupFolder)
}
@Test
fun `automatic backup keeps going where a folder is already set`() {
val source = mutablePreferencesOf()
source[SettingsKeys.autoBackupEnabled] = true
val target = mutablePreferencesOf()
import(roundTrip(source), target, environment(folder = true))
assertThat(target[SettingsKeys.autoBackupEnabled]).isTrue()
}
@Test
fun `the language resolves against what this build ships`() {
val backup = roundTrip(mutablePreferencesOf())
assertThat(planSettingsImport(backup.copy(language = LanguageChoice("en-US")), mutablePreferencesOf(), environment()).language)
.isEqualTo(LanguageChoice("en-US"))
assertThat(planSettingsImport(backup.copy(language = LanguageChoice(null)), mutablePreferencesOf(), environment()).language)
.isEqualTo(LanguageChoice(null))
val missing = planSettingsImport(backup.copy(language = LanguageChoice("ja")), mutablePreferencesOf(), environment())
assertThat(missing.language).isNull()
assertThat(missing.skipped).containsExactly(SkippedSetting.Language("ja"))
}
@Test
fun `app-level choices travel along`() {
val backup = roundTrip(mutablePreferencesOf())
assertThat(backup.language).isEqualTo(LanguageChoice("de"))
assertThat(backup.launcherName).isEqualTo(LauncherName.CALENDAR)
}
@Test
fun `unknown fields from a later build are ignored`() {
val text = """
{"format":"calendula-settings","version":1,"exportedAt":1,"future":{"x":1},
"settings":{"multi_day_count":4,"some_new_key":true},
"calendars":[{"account_name":"a","account_type":"t","display_name":"d","colour":"red"}],
"app":{"launcher_name":"SOMETHING_NEW"}}
""".trimIndent()
val backup = (decodeSettingsBackup(text) as BackupResult.Success).value
assertThat(backup.prefs.values).containsExactly(4)
assertThat(backup.calendars).hasSize(1)
assertThat(backup.launcherName).isNull()
assertThat(backup.language).isNull()
}
@Test
fun `a malformed known field rejects the whole file`() {
listOf(
"""{"format":"calendula-settings","version":1,"exportedAt":1,"settings":{"multi_day_count":"4"}}""",
"""{"format":"calendula-settings","version":1,"exportedAt":1,"calendars":[{"account_name":"a"}]}""",
"""{"format":"calendula-settings","version":1,"exportedAt":1,"calendars":[{"account_name":"a","account_type":"t","display_name":"d","reminders":["x"]}]}""",
).forEach { text ->
val result = decodeSettingsBackup(text) as BackupResult.Failure
assertThat(result.problem).isEqualTo(BackupProblem.INVALID_DATA)
}
}
@Test
fun `a file without its settings section is refused`() {
val result = decodeSettingsBackup("""{"format":"calendula-settings","version":1,"exportedAt":1,"calendars":[]}""")
assertThat((result as BackupResult.Failure).problem).isEqualTo(BackupProblem.INVALID_DATA)
}
@Test
fun `an import without per-calendar settings leaves no empty keys behind`() {
val target = mutablePreferencesOf()
target[SettingsKeys.perCalendarEventDuration] = mapOf(1L to 30)
import(roundTrip(mutablePreferencesOf()), target)
SettingsBackupSchema.perCalendar.forEach { pref ->
assertThat(target.asMap().keys.map { it.name }).doesNotContain(pref.name)
}
}
@Test
fun `two file entries naming the same calendar are both skipped`() {
val text = """{"format":"calendula-settings","version":1,"exportedAt":1,"settings":{},"calendars":[
{"account_name":"me@example.org","account_type":"com.google","display_name":"Work","hidden":true},
{"account_name":"me@example.org","account_type":"com.google","display_name":"Work","event_duration_minutes":90}]}"""
val backup = (decodeSettingsBackup(text) as BackupResult.Success).value
val target = mutablePreferencesOf()
val plan = import(backup, target)
assertThat(target[CalendarPrefKeys.hiddenCalendarIds]).isEmpty()
assertThat(plan.skipped).containsExactly(SkippedSetting.Calendar("Work", "me@example.org", ambiguous = true))
}
@Test
fun `reminder lists are cleaned the way the setters store them`() {
val text = """{"format":"calendula-settings","version":1,"exportedAt":1,"settings":{},"calendars":[
{"account_name":"a","account_type":"t","display_name":"d","reminders":[60,-5,30,30]}]}"""
val backup = (decodeSettingsBackup(text) as BackupResult.Success).value
assertThat(backup.calendars.single().reminders).containsExactly(30, 60).inOrder()
}
@Test
fun `a newer schema is refused`() {
val result = decodeSettingsBackup("""{"format":"calendula-settings","version":2,"exportedAt":1}""")
assertThat((result as BackupResult.Failure).problem).isEqualTo(BackupProblem.NEWER_VERSION)
}
}
@@ -616,6 +616,66 @@ class SettingsPrefsTest {
assertThat(prefs.perCalendarEventDuration.first()).containsExactly(9L, 45)
}
@Test
fun `per-calendar hidden views round-trip and clear back to every view`(
@TempDir tempDir: Path,
) = runTest {
val prefs = testSettings(tempDir)
assertThat(prefs.perCalendarHiddenViews.first()).isEmpty()
prefs.applyCalendarHiddenChanges(7L, listOf(CalendarView.Month, CalendarView.Agenda).associateWith { true })
prefs.applyCalendarHiddenChanges(9L, listOf(CalendarView.MultiDay).associateWith { true })
assertThat(prefs.perCalendarHiddenViews.first()).containsExactly(
7L, setOf(CalendarView.Month, CalendarView.Agenda),
9L, setOf(CalendarView.MultiDay),
)
prefs.clearCalendarHiddenViews(7L)
assertThat(prefs.perCalendarHiddenViews.first()).containsExactly(9L, setOf(CalendarView.MultiDay))
// One write can hide some views and show others, leaving the rest alone.
prefs.applyCalendarHiddenChanges(9L, mapOf(CalendarView.MultiDay to false, CalendarView.Day to true))
assertThat(prefs.perCalendarHiddenViews.first()).containsExactly(9L, setOf(CalendarView.Day))
}
@Test
fun `toggling one hidden view keeps the others and clears the entry when empty`(
@TempDir tempDir: Path,
) = runTest {
val prefs = testSettings(tempDir)
prefs.setCalendarHiddenIn(7L, CalendarView.Month, hidden = true)
prefs.setCalendarHiddenIn(7L, CalendarView.Week, hidden = true)
assertThat(prefs.perCalendarHiddenViews.first())
.containsExactly(7L, setOf(CalendarView.Month, CalendarView.Week))
prefs.setCalendarHiddenIn(7L, CalendarView.Month, hidden = false)
prefs.setCalendarHiddenIn(7L, CalendarView.Week, hidden = false)
assertThat(prefs.perCalendarHiddenViews.first()).isEmpty()
}
@Test
fun `garbage hidden-views entries are dropped, the rest survive`(
@TempDir tempDir: Path,
) = runTest {
val store = testDataStore(tempDir, "test_settings")
val prefs = SettingsPrefs(store)
store.updateData { p ->
val m = p.toMutablePreferences()
// A bad id, only an unknown view, no separator, and a good entry with one unknown view.
m[stringPreferencesKey("per_calendar_hidden_views")] = "x=Month;7=Year;8;9=Month,Year,Agenda"
m
}
assertThat(prefs.perCalendarHiddenViews.first())
.containsExactly(9L, setOf(CalendarView.Month, CalendarView.Agenda))
}
@Test
fun `hidden views serialise in view order and drop empty entries`() {
val stored = mapOf(
3L to setOf(CalendarView.Agenda, CalendarView.Month),
1L to emptySet(),
).toStoredHiddenViews()
assertThat(stored).isEqualTo("3=Month,Agenda")
assertThat(emptyMap<Long, Set<CalendarView>>().toStoredHiddenViews()).isNull()
assertThat(parseHiddenViews(stored)).containsExactly(3L, setOf(CalendarView.Month, CalendarView.Agenda))
}
@Test
fun `resolveDefaultEventDuration prefers the calendar's own length`() {
val overrides = mapOf(7L to 480)
@@ -0,0 +1,100 @@
package de.jeanlucmakiola.calendula.ui.common
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.testEvent
import de.jeanlucmakiola.calendula.testRepository
import de.jeanlucmakiola.calendula.testSettings
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import kotlin.time.Instant
@OptIn(ExperimentalCoroutinesApi::class)
class ViewHiddenCalendarsTest {
private val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000_000_000L)
private fun event(id: Long, calendarId: Long) = testEvent(
id = id,
calendarId = calendarId,
title = "E $id",
start = Instant.fromEpochMilliseconds(1_000_000_000L),
end = Instant.fromEpochMilliseconds(1_000_003_600L),
color = 0,
)
private fun calendar(id: Long) = CalendarSource(id, "Cal $id", "x@y", "LOCAL", 0, true)
@Test
fun `calendarsHiddenIn picks only the calendars hidden in that view`() {
val map = mapOf(
1L to setOf(CalendarView.Month),
2L to setOf(CalendarView.Month, CalendarView.Agenda),
3L to setOf(CalendarView.Week),
)
assertThat(map.calendarsHiddenIn(CalendarView.Month)).containsExactly(1L, 2L)
assertThat(map.calendarsHiddenIn(CalendarView.Agenda)).containsExactly(2L)
assertThat(map.calendarsHiddenIn(CalendarView.Day)).isEmpty()
}
@Test
fun `instancesWithout drops a calendar only in the views it is hidden in`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(calendar(1L), calendar(2L))
instancesResult = { _, _ -> listOf(event(10L, 1L), event(20L, 2L)) }
}
val settings = testSettings(tempDir)
val repo = testRepository(fake, tempDir, UnconfinedTestDispatcher(testScheduler), settings = settings)
settings.applyCalendarHiddenChanges(2L, listOf(CalendarView.Month).associateWith { true })
val hidden = settings.perCalendarHiddenViews
assertThat(repo.instancesWithout(range, hidden.calendarsHiddenIn(CalendarView.Month, backgroundScope)).first().map { it.eventId })
.containsExactly(10L)
assertThat(repo.instancesWithout(range, hidden.calendarsHiddenIn(CalendarView.Week, backgroundScope)).first().map { it.eventId })
.containsExactly(10L, 20L)
// The repository itself, which reminders and search build on, is untouched.
assertThat(repo.instances(range).first().map { it.eventId }).containsExactly(10L, 20L)
}
@Test
fun `instancesWithout re-filters live when the setting changes`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(calendar(1L), calendar(2L))
instancesResult = { _, _ -> listOf(event(10L, 1L), event(20L, 2L)) }
}
val settings = testSettings(tempDir)
val repo = testRepository(fake, tempDir, UnconfinedTestDispatcher(testScheduler), settings = settings)
val hidden = settings.perCalendarHiddenViews.calendarsHiddenIn(CalendarView.Agenda, backgroundScope)
repo.instancesWithout(range, hidden).test {
assertThat(awaitItem().map { it.eventId }).containsExactly(10L, 20L)
settings.setCalendarHiddenIn(1L, CalendarView.Agenda, hidden = true)
assertThat(awaitItem().map { it.eventId }).containsExactly(20L)
settings.setCalendarHiddenIn(1L, CalendarView.Agenda, hidden = false)
assertThat(awaitItem().map { it.eventId }).containsExactly(10L, 20L)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `a failed read hides nothing and keeps reading`() = runTest {
var reads = 0
val source = kotlinx.coroutines.flow.flow {
reads++
if (reads == 1) throw java.io.IOException("disk")
emit(mapOf(1L to setOf(CalendarView.Month)))
}
source.hidingNothingOnError().test {
assertThat(awaitItem()).isEmpty()
assertThat(awaitItem()).containsExactly(1L, setOf(CalendarView.Month))
awaitComplete()
}
}
}
@@ -0,0 +1,31 @@
package de.jeanlucmakiola.calendula.ui.filter
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
import org.junit.jupiter.api.Test
class FilterUiStateTest {
private fun calendar(id: Long) = CalendarSource(id, "Cal $id", "x@y", "LOCAL", 0, true)
@Test
fun `hiddenOnlyIn lists the calendars the view hides on its own`() {
val state = FilterUiState.Success(
groups = listOf(calendar(1L), calendar(2L), calendar(3L)).groupByAccount(),
// 3 is hidden everywhere from the drawer already.
hidden = setOf(3L),
hiddenViews = mapOf(
1L to setOf(CalendarView.Month),
2L to setOf(CalendarView.Month, CalendarView.Week),
3L to setOf(CalendarView.Month),
// Not listed (switched off device-wide).
9L to setOf(CalendarView.Month),
),
)
assertThat(state.hiddenOnlyIn(CalendarView.Month)).containsExactly(1L, 2L)
assertThat(state.hiddenOnlyIn(CalendarView.Week)).containsExactly(2L)
assertThat(state.hiddenOnlyIn(CalendarView.Agenda)).isEmpty()
}
}
+6 -12
View File
@@ -389,7 +389,12 @@ pre-permission launch); `CalendarVisibilityReconciler` drains it entry by entry,
and until it does, the repository and `ReminderNotifier.post` honour it. That
gate also covers a snooze re-shown from our own alarm after its calendar was
switched off. The drawer's filter sheet (`CalendarPrefs.hiddenCalendarIds`) is a
separate in-app declutter that never touches reminders.
separate in-app declutter that never touches reminders. Narrower still is the per-view hide
(`SettingsPrefs.perCalendarHiddenViews`, #270): each view model asks for its
events through `instancesWithout(range, calendarsHiddenIn(view, …))`, which drops
the calendars hidden in that one view on top of the repository's filters; reminders, search and widgets read
the repository and never see it. The drawer's "Hide in this view" entry (#517) edits the same
set for the view on show, and the drawer's per-row "Hidden in this view" notes read it.
Deliberately absent: a fallback to the provider's `EVENT_REMINDER` broadcast.
Keeping both would double-post wherever the provider works, and Etar's way out —
@@ -492,17 +497,6 @@ one the calendar accepts. The single-event review form deliberately keeps no col
first question is which calendar to use, and answering it clears the colour
anyway.
## Settings backup
Backup & restore also exports the app's settings as versioned JSON (#481),
with the envelope and the generic preference section from floret-kit's
`core-backup`. Every stored key is either carried or deliberately left out
(`SettingsBackupSchema`, enforced by a test); per-calendar settings travel
under the calendar's account and name, since provider ids are per device. An
import replaces rather than merges, in one DataStore edit, then re-runs what
reads settings outside DataStore. Format and per-key decisions:
docs/SETTINGS_BACKUP.md.
## Testing
JUnit 5 + Truth + Turbine on the JVM. The seams that make it work:
-135
View File
@@ -1,135 +0,0 @@
# Settings backup format
Backup & restore → Settings exports Calendula's settings to a JSON file and
imports one back (#481). Events are not part of it; they have the `.ics`
backup. This file is the schema's source of truth. The envelope and the
`settings` section come from floret-kit's `core-backup`
(`BackupFormat`, `PrefsSection`); `data/backup/SettingsBackup*.kt` implements
the rest.
## Top level
```json
{
"format": "calendula-settings",
"version": 1,
"exportedAt": 1700000000000,
"appVersion": "2.25.0",
"settings": { … },
"calendars": [ … ],
"app": { … }
}
```
- `format` must be exactly `"calendula-settings"`, or the file is not a
settings backup.
- `version` is `1`. A higher version is refused as "from a newer Calendula";
a change that an older build could misread bumps it.
- `exportedAt` (epoch ms) and `appVersion` are informational.
- The file is limited to 8 MiB; a leading byte-order mark is ignored.
- The `settings` section is required: a file without it would reset every setting.
## Parsing rules
- **Unknown fields are ignored**, at any level, so a file from a later build
imports as far as this one understands it.
- **Known fields are strict.** A known key holding the wrong JSON type rejects
the whole file before anything is written.
- **Values are not range-checked.** They are stored as they are and go through
the same reads every setting already does (clamps, enum fallbacks), so an
out-of-range or unknown value degrades to the nearest valid one or the
default.
## Import semantics
An import **replaces** every setting the backup covers; it does not merge. A
backed-up setting the file doesn't carry is reset to its default (that is what
the exporting install had). Everything is written in one DataStore edit. After
that the import refreshes what reads settings outside DataStore: the
contact-dates mirror (turned on and synced, or off and torn down, as the
settings toggle does), the reminder scan, the automatic-backup schedule, the
launcher name and the app language. Theme and widgets follow the stored
values on their own. A summary lists what could not be imported.
## `settings`
The settings stored under their own on-disk key names, in their stored form,
e.g. `"theme_mode": "DARK"`, `"multi_day_count": 4`,
`"default_reminder_minutes": "10,60"`. Only keys that have been set are
written. `SettingsBackupSchema.plain` lists them: appearance, fonts,
views, the agenda screen, the event form, reminders, automatic backup
(switch and interval) and contact special dates (switch, types, title
templates, year).
Three are taken only where this device can honour them, otherwise the current
value stays and the summary says why:
- `brand_font` / `plain_font` set to `custom`: the font file is not in the
backup, so it is taken only if this install already has one for that role.
- `special_dates_enabled` on: needs the contacts permission here.
- `auto_backup_enabled` on: needs a backup folder chosen on this install.
## `calendars`
Per-calendar settings are stored by provider id, which means nothing on
another device. Each calendar with any of them is written with a portable
identity instead:
| Field | Type | Notes |
|---|---|---|
| `account_name` | String | Required. |
| `account_type` | String | Required. |
| `display_name` | String | Required. |
| `special_dates` | String? | `Birthday`/`Anniversary`/`Custom` for a contact-dates mirror calendar. |
| `hidden` | Boolean | Hidden in the drawer's filter. |
| `last_used` | Boolean | Preselected for new events. |
| `event_duration_minutes` | Int? | Per-calendar default length, clamped to 1–1440 like the setting. |
| `reminders` | Int[]? | Timed reminder override; `[]` is "no reminder". |
| `all_day_reminders` | Int[]? | All-day reminder override. |
On import an entry matches the one calendar here with the same account name,
account type and display name. No match, or more than one, skips it and the
summary names it, as does a calendar the file itself lists twice under one
identity. Reminder lists are stored the way the setters store them (no
negative values, each once, ascending). A mirror calendar matches by `special_dates` instead, since
its name is localised, and only when the imported settings keep the mirror on
and the type (otherwise the import tears that calendar down). When the mirror has not created it yet,
it is matched after the import's sync. Every mirror's all-day reminders are
applied through the mirror so its existing events follow; one the file doesn't
override inherits the default again. An id in the prefs whose calendar no
longer exists is left out of the export.
If this device's calendars can't be read, the per-calendar settings here are
left untouched and the summary says so; an export in that state fails rather
than writing a file without them.
## `app`
| Field | Type | Notes |
|---|---|---|
| `language` | String? | The app language tag; `null` follows the system. Absent: left as is. A language this build doesn't ship is skipped. |
| `launcher_name` | String? | `CALENDULA` or `CALENDAR`. Unknown: left as is. |
## Not exported
`SettingsBackupSchema.excluded`, each for a reason: this install's own state,
bookkeeping, or meaningless on another device.
- Font stamps (`*_font_stamp`): cache keys for the local font file.
- `agenda_widget_range`, `widget_size`: legacy defaults for unconfigured
widgets. Widget configuration is not part of the backup at all.
- `recent_time_zones`: usage history.
- Onboarding progress and `visibility_notice_pending`.
- `auto_backup_folder_uri`: a SAF grant does not survive a reinstall or
another device.
- Run status: `auto_backup_last_*`, `auto_backup_failures`,
`special_dates_last_run`, `special_dates_stalled_reason`,
`special_dates_last_foreground_sync`.
- `special_dates_calendar_*`: the mirror re-adopts its calendars by their
provider marker.
- `disabled_calendar_ids`: switch-offs not yet written to this device's
provider. Whether a calendar is switched on lives in the provider.
- `reminder_last_scan_millis`: the reminder delivery watermark.
`SettingsBackupSchemaTest` fails for any stored key that is in none of the
three lists, so a new setting has to be placed before it ships.