Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1880d04b2 | ||
|
|
05ac15f63c | ||
|
|
31ba82a295 | ||
|
|
99a584a82d | ||
|
|
2e71e4dc5f | ||
|
|
955dccff2b | ||
|
|
13e4908c1e | ||
|
|
c353ac8072 | ||
|
|
741e1e9a65 | ||
|
|
589f2c3862 | ||
|
|
c28582ae6f | ||
|
|
fa999f8cf0 | ||
|
|
515c7c3d26 | ||
|
|
9b5880b719 | ||
|
|
994fc1026f |
No files matched your search
@@ -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")
|
||||
|
||||
@@ -67,7 +67,10 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
@@ -620,6 +623,20 @@ class MainActivity : AppCompatActivity() {
|
||||
putExtra(EXTRA_SOURCE_VIEW, source.name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the day view on [date] over the default home view — the daily
|
||||
* summary's tap (#459). Spoken as the system's own "view time" intent,
|
||||
* at the day's local noon, so it lands on the path a clock tap takes.
|
||||
*/
|
||||
fun openDayIntent(context: Context, date: LocalDate): Intent {
|
||||
val noon = LocalDateTime(date, LocalTime(12, 0))
|
||||
.toInstant(TimeZone.currentSystemDefault())
|
||||
.toEpochMilliseconds()
|
||||
return Intent(Intent.ACTION_VIEW, "content://$CALENDAR_PROVIDER_HOST/time/$noon".toUri())
|
||||
.setClass(context, MainActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
|
||||
/** Open the create-event form prefilled for [date] (home-screen widgets). */
|
||||
fun openCreateIntent(context: Context, date: LocalDate): Intent =
|
||||
Intent(context, MainActivity::class.java).apply {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.semanticReminderMinutes
|
||||
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
|
||||
import de.jeanlucmakiola.calendula.domain.fixedReminderOffsets
|
||||
import de.jeanlucmakiola.calendula.domain.toEditForm
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
@@ -919,11 +920,15 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
minutes
|
||||
}
|
||||
|
||||
/** [form]'s reminders as the distinct raw provider offsets to store. */
|
||||
/**
|
||||
* [form]'s reminders as the distinct raw provider offsets to store, its
|
||||
* fixed-time ones (#459) measured from the start it is saved with.
|
||||
*/
|
||||
private fun encodedReminders(form: EventForm, allDayReminderTimeMinutes: Int): List<Int> =
|
||||
form.reminders
|
||||
.map { providerReminderMinutes(form, it, allDayReminderTimeMinutes) }
|
||||
.distinct()
|
||||
(
|
||||
form.reminders.map { providerReminderMinutes(form, it, allDayReminderTimeMinutes) } +
|
||||
form.fixedReminderOffsets(TimeZone.currentSystemDefault())
|
||||
).distinct()
|
||||
|
||||
override fun insertEvent(form: EventForm, allDayReminderTimeMinutes: Int): Long {
|
||||
// A globally-unique UID so a later .ics backup/restore can identify
|
||||
@@ -1026,7 +1031,11 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
// Untouched reminder sets are left alone so unrelated edits can't
|
||||
// disturb provider rows the form never knew about. The diff is on the
|
||||
// form's semantic minutes; reconcile works in encoded provider minutes.
|
||||
if (updated.reminders.toSet() != original.reminders.toSet()) {
|
||||
// A fixed-time reminder also changes when the start's day or time does.
|
||||
val zone = TimeZone.currentSystemDefault()
|
||||
if (updated.reminders.toSet() != original.reminders.toSet() ||
|
||||
updated.fixedReminderOffsets(zone).toSet() != original.fixedReminderOffsets(zone).toSet()
|
||||
) {
|
||||
reconcileReminders(eventId, encodedReminders(updated, allDayReminderTimeMinutes))
|
||||
}
|
||||
// Same untouched-set guard for guests: only reconcile when the form's
|
||||
@@ -1227,8 +1236,9 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
)
|
||||
}
|
||||
// Whether the provider copied the parent's reminder rows is its
|
||||
// business — reconciling against the actual rows handles both ways.
|
||||
reconcileReminders(exceptionId, encodedReminders(form, allDayReminderTimeMinutes))
|
||||
// business — reconciling against the actual rows handles both ways. The
|
||||
// exception is a one-off, so a fixed-time reminder takes real minutes.
|
||||
reconcileReminders(exceptionId, encodedReminders(form.toDetachedOccurrence(), allDayReminderTimeMinutes))
|
||||
reconcileAttendees(exceptionId, form.attendees)
|
||||
return exceptionId
|
||||
}
|
||||
|
||||
@@ -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,30 +60,28 @@ 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. */
|
||||
private fun idSetPref(name: String): Pref<Set<Long>> = nullableStringPref(name).map(
|
||||
internal fun idSetPref(name: String): Pref<Set<Long>> = nullableStringPref(name).map(
|
||||
{ stored -> stored.orEmpty().split(',').mapNotNull { it.trim().toLongOrNull() }.toSet() },
|
||||
{ ids -> if (ids.isEmpty()) null else ids.sorted().joinToString(",") },
|
||||
)
|
||||
@@ -2,9 +2,10 @@ 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 de.jeanlucmakiola.floret.prefs.nullableStringPref
|
||||
import java.time.LocalDate
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -24,13 +25,24 @@ 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 day the last daily summary listed (#459): recorded at once, so neither
|
||||
* a pass failing after posting nor a later time picked the same evening
|
||||
* lists it twice.
|
||||
*/
|
||||
suspend fun lastDigestDay(): LocalDate? =
|
||||
prefs.get(LAST_DIGEST_DAY)?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
|
||||
|
||||
/** The key [ReminderStatePrefs] stores. */
|
||||
internal object ReminderStateKeys {
|
||||
val lastScanMillis: Pref<Long?> = nullableLongPref("reminder_last_scan_millis")
|
||||
suspend fun setLastDigestDay(day: LocalDate) = prefs.set(LAST_DIGEST_DAY, day.toString())
|
||||
|
||||
suspend fun clearLastDigestDay() = prefs.set(LAST_DIGEST_DAY, null)
|
||||
|
||||
private companion object {
|
||||
val LAST_SCAN = nullableLongPref("reminder_last_scan_millis")
|
||||
val LAST_DIGEST_DAY = nullableStringPref("reminder_last_digest_day")
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
package de.jeanlucmakiola.calendula.data.prefs
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.MutablePreferences
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.DEFAULT_DIGEST_TIME_MINUTES
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.DailyDigestSettings
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.DigestDay
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.FixedTimeReminder
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
||||
@@ -166,6 +171,8 @@ class SettingsPrefs @Inject constructor(
|
||||
val defaultEventDurationMinutes: Flow<Int> = prefs.flow(SettingsKeys.defaultEventDurationMinutes)
|
||||
val perCalendarEventDuration: Flow<Map<Long, Int>> = prefs.flow(SettingsKeys.perCalendarEventDuration)
|
||||
val remindersEnabled: Flow<Boolean> = prefs.flow(SettingsKeys.remindersEnabled)
|
||||
val mutedReminderCalendarIds: Flow<Set<Long>> = prefs.flow(SettingsKeys.mutedReminderCalendarIds)
|
||||
val notificationCategoryHintDismissed: Flow<Boolean> = prefs.flow(SettingsKeys.notificationCategoryHintDismissed)
|
||||
val allowColorOnUnsupportedCalendars: Flow<Boolean> =
|
||||
prefs.flow(SettingsKeys.allowColorOnUnsupportedCalendars)
|
||||
val defaultReminderMinutes: Flow<List<Int>> = prefs.flow(SettingsKeys.defaultReminderMinutes)
|
||||
@@ -176,6 +183,20 @@ class SettingsPrefs @Inject constructor(
|
||||
prefs.flow(SettingsKeys.perCalendarReminderOverride)
|
||||
val perCalendarAllDayReminderOverride: Flow<Map<Long, List<Int>>> =
|
||||
prefs.flow(SettingsKeys.perCalendarAllDayReminderOverride)
|
||||
val defaultFixedTimeReminder: Flow<FixedTimeReminder?> = prefs.flow(SettingsKeys.defaultFixedTimeReminder)
|
||||
val perCalendarFixedTimeReminderOverride: Flow<Map<Long, FixedTimeReminder?>> =
|
||||
prefs.flow(SettingsKeys.perCalendarFixedTimeReminderOverride)
|
||||
|
||||
/**
|
||||
* Every "N days before at HH:MM" the defaults name, so an offset landing on
|
||||
* one reads back as that reminder rather than a lead time (#459).
|
||||
*/
|
||||
val knownFixedTimeReminders: Flow<Set<FixedTimeReminder>> = prefs.snapshot {
|
||||
setOfNotNull(it[SettingsKeys.defaultFixedTimeReminder]) +
|
||||
it[SettingsKeys.perCalendarFixedTimeReminderOverride].values.filterNotNull() +
|
||||
it[SettingsKeys.recentFixedTimeReminders]
|
||||
}
|
||||
val dailyDigest: Flow<DailyDigestSettings> = prefs.snapshot { it.dailyDigest }
|
||||
val autoBackupEnabled: Flow<Boolean> = prefs.flow(SettingsKeys.autoBackupEnabled)
|
||||
val autoBackupIntervalMinutes: Flow<Long> = prefs.flow(SettingsKeys.autoBackupIntervalMinutes)
|
||||
val autoBackupFolderUri: Flow<String?> = prefs.flow(SettingsKeys.autoBackupFolderUri)
|
||||
@@ -273,6 +294,12 @@ class SettingsPrefs @Inject constructor(
|
||||
|
||||
suspend fun setRemindersEnabled(enabled: Boolean) = prefs.set(SettingsKeys.remindersEnabled, enabled)
|
||||
|
||||
suspend fun dismissNotificationCategoryHint() = prefs.set(SettingsKeys.notificationCategoryHintDismissed, true)
|
||||
|
||||
suspend fun setCalendarRemindersEnabled(calendarId: Long, enabled: Boolean) {
|
||||
prefs.update(SettingsKeys.mutedReminderCalendarIds) { if (enabled) it - calendarId else it + calendarId }
|
||||
}
|
||||
|
||||
suspend fun setAllowColorOnUnsupportedCalendars(enabled: Boolean) =
|
||||
prefs.set(SettingsKeys.allowColorOnUnsupportedCalendars, enabled)
|
||||
|
||||
@@ -324,6 +351,77 @@ class SettingsPrefs @Inject constructor(
|
||||
suspend fun setCalendarAllDayReminderOverride(calendarId: Long, override: ReminderOverride) =
|
||||
prefs.applyOverride(SettingsKeys.perCalendarAllDayReminderOverride, calendarId, override)
|
||||
|
||||
/** The replaced default stays a known pattern, so events that got it keep reading as it. */
|
||||
suspend fun setDefaultFixedTimeReminder(reminder: FixedTimeReminder?) {
|
||||
prefs.edit {
|
||||
val old = it[SettingsKeys.defaultFixedTimeReminder]
|
||||
if (old != reminder) retireFixedTimeReminder(it, old)
|
||||
it[SettingsKeys.defaultFixedTimeReminder] = reminder
|
||||
}
|
||||
}
|
||||
|
||||
/** [override] [FixedTimeOverride.Inherit] drops the calendar's entry; a replaced value stays known. */
|
||||
suspend fun setCalendarFixedTimeReminderOverride(calendarId: Long, override: FixedTimeOverride) {
|
||||
prefs.edit {
|
||||
val current = it[SettingsKeys.perCalendarFixedTimeReminderOverride]
|
||||
val old = current[calendarId]
|
||||
if (old != (override as? FixedTimeOverride.At)?.reminder) retireFixedTimeReminder(it, old)
|
||||
it[SettingsKeys.perCalendarFixedTimeReminderOverride] = when (override) {
|
||||
FixedTimeOverride.Inherit -> current - calendarId
|
||||
FixedTimeOverride.Off -> current + (calendarId to null)
|
||||
is FixedTimeOverride.At -> current + (calendarId to override.reminder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember fixed-time reminders saved by hand (#459), so their rows read
|
||||
* back as them; the defaults are known anyway and take no slot.
|
||||
*/
|
||||
suspend fun rememberFixedTimeReminders(reminders: Collection<FixedTimeReminder>) {
|
||||
prefs.edit {
|
||||
val defaults = setOfNotNull(it[SettingsKeys.defaultFixedTimeReminder]) +
|
||||
it[SettingsKeys.perCalendarFixedTimeReminderOverride].values.filterNotNull()
|
||||
val fresh = reminders.filterNot { reminder -> reminder in defaults }
|
||||
if (fresh.isNotEmpty()) {
|
||||
it[SettingsKeys.recentFixedTimeReminders] = (fresh + it[SettingsKeys.recentFixedTimeReminders]).distinct()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep a default being replaced known, unless it still is a default elsewhere. */
|
||||
private fun retireFixedTimeReminder(preferences: MutablePreferences, reminder: FixedTimeReminder?) {
|
||||
if (reminder == null) return
|
||||
val defaults = listOfNotNull(preferences[SettingsKeys.defaultFixedTimeReminder]) +
|
||||
preferences[SettingsKeys.perCalendarFixedTimeReminderOverride].values.filterNotNull()
|
||||
if (defaults.count { it == reminder } > 1) return
|
||||
preferences[SettingsKeys.recentFixedTimeReminders] =
|
||||
(listOf(reminder) + preferences[SettingsKeys.recentFixedTimeReminders]).distinct()
|
||||
}
|
||||
|
||||
suspend fun setDailyDigestEnabled(enabled: Boolean) = editDigest { it[SettingsKeys.dailyDigestEnabled] = enabled }
|
||||
|
||||
suspend fun setDailyDigestTimeMinutes(minutesOfDay: Int) =
|
||||
editDigest { it[SettingsKeys.dailyDigestTimeMinutes] = minutesOfDay }
|
||||
|
||||
suspend fun setDailyDigestDay(day: DigestDay) = editDigest { it[SettingsKeys.dailyDigestDay] = day }
|
||||
|
||||
/**
|
||||
* Change when the summary arrives, stamping the moment: a summary whose time
|
||||
* had already passed today is not owed by the change itself.
|
||||
*/
|
||||
private suspend fun editDigest(change: (MutablePreferences) -> Unit) {
|
||||
prefs.edit {
|
||||
change(it)
|
||||
it[SettingsKeys.dailyDigestArmedAt] = System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
suspend fun setCalendarInDailyDigest(calendarId: Long, included: Boolean) {
|
||||
prefs.update(SettingsKeys.digestExcludedCalendarIds) { if (included) it - calendarId else it + calendarId }
|
||||
}
|
||||
|
||||
suspend fun setAutoBackupEnabled(enabled: Boolean) = prefs.set(SettingsKeys.autoBackupEnabled, enabled)
|
||||
|
||||
suspend fun setAutoBackupIntervalMinutes(minutes: Long) =
|
||||
@@ -373,6 +471,7 @@ class SettingsPrefs @Inject constructor(
|
||||
/** 09:00 as minutes from midnight; the default all-day reminder fire time. */
|
||||
internal const val DEFAULT_ALLDAY_REMINDER_TIME = 540
|
||||
internal const val MINUTES_PER_DAY = 1_440
|
||||
internal const val MAX_RECENT_FIXED_TIME_REMINDERS = 12
|
||||
/** Default snooze delay for the notification "Snooze" action. */
|
||||
const val DEFAULT_SNOOZE_MINUTES = 10
|
||||
const val DEFAULT_MULTI_DAY_COUNT = 3
|
||||
@@ -594,6 +693,18 @@ object SettingsKeys {
|
||||
*/
|
||||
val remindersEnabled: Pref<Boolean> = booleanPref("reminders_enabled", true)
|
||||
|
||||
/**
|
||||
* Calendars whose reminders Calendula doesn't post (#272), independent of
|
||||
* `Calendars.VISIBLE`: the events still show, only the notifications stop.
|
||||
*/
|
||||
val mutedReminderCalendarIds: Pref<Set<Long>> = idSetPref("muted_reminder_calendar_ids")
|
||||
|
||||
/**
|
||||
* Whether the one-time hint that some skins (Samsung) start new notification
|
||||
* categories switched off has been dismissed (#145).
|
||||
*/
|
||||
val notificationCategoryHintDismissed: Pref<Boolean> = booleanPref("notification_category_hint_dismissed", false)
|
||||
|
||||
/**
|
||||
* Whether to offer a custom event colour on calendars that publish no colour
|
||||
* palette; synced ones without a palette (some CalDAV) may drop or overwrite
|
||||
@@ -683,6 +794,48 @@ object SettingsKeys {
|
||||
val perCalendarAllDayReminderOverride: Pref<Map<Long, List<Int>>> =
|
||||
reminderOverridePref("per_calendar_allday_reminder_override")
|
||||
|
||||
/**
|
||||
* The "N days before at HH:MM" reminder added to new **timed** events
|
||||
* (#459), or null for none — the factory default. Stored as `days@minute`.
|
||||
*/
|
||||
val defaultFixedTimeReminder: Pref<FixedTimeReminder?> =
|
||||
nullableStringPref("default_fixed_time_reminder").map(FixedTimeReminder::parse) { it?.toToken() }
|
||||
|
||||
/**
|
||||
* Per-calendar overrides of [defaultFixedTimeReminder]: present with a
|
||||
* value replaces it, present as null turns it off, absent inherits.
|
||||
* Serialised as `id=days@minute` or `id=none`, joined by `;`.
|
||||
*/
|
||||
val perCalendarFixedTimeReminderOverride: Pref<Map<Long, FixedTimeReminder?>> =
|
||||
nullableStringPref("per_calendar_fixed_time_reminder").map(::parseFixedTimeOverrides) {
|
||||
it.toStoredFixedTimeOverrides()
|
||||
}
|
||||
|
||||
/** The daily summary notification (#459): opt-in, and which day it lists at what time. */
|
||||
val dailyDigestEnabled: Pref<Boolean> = booleanPref("daily_digest_enabled", false)
|
||||
val dailyDigestTimeMinutes: Pref<Int> =
|
||||
intPref("daily_digest_time_minutes", DEFAULT_DIGEST_TIME_MINUTES).coerced {
|
||||
it.coerceIn(0, SettingsPrefs.MINUTES_PER_DAY - 1)
|
||||
}
|
||||
val dailyDigestDay: Pref<DigestDay> = enumPref("daily_digest_day", DigestDay.Tomorrow)
|
||||
|
||||
/** Calendars left out of the daily summary; every other one is in. */
|
||||
val digestExcludedCalendarIds: Pref<Set<Long>> = idSetPref("digest_excluded_calendar_ids")
|
||||
|
||||
/** When the summary's switch, time or day last changed; a moment before it is never owed. */
|
||||
val dailyDigestArmedAt: Pref<Long> = longPref("daily_digest_armed_at", 0L)
|
||||
|
||||
/**
|
||||
* Fixed-time reminders last saved by hand, and defaults since replaced,
|
||||
* newest first — with the current defaults, the patterns a stored offset is
|
||||
* recognised as. Capped, so the oldest falls out of recognition.
|
||||
*/
|
||||
val recentFixedTimeReminders: Pref<List<FixedTimeReminder>> =
|
||||
nullableStringPref("recent_fixed_time_reminders").map(
|
||||
{ stored -> stored?.split(LIST_SEP).orEmpty().mapNotNull(FixedTimeReminder::parse).distinct() },
|
||||
{ reminders -> reminders.take(SettingsPrefs.MAX_RECENT_FIXED_TIME_REMINDERS).joinToString(LIST_SEP) { it.toToken() } },
|
||||
)
|
||||
|
||||
/** Whether periodic automatic `.ics` export of local calendars is on (issue #8). */
|
||||
val autoBackupEnabled: Pref<Boolean> = booleanPref("auto_backup_enabled", false)
|
||||
|
||||
@@ -879,6 +1032,57 @@ data class BackupStatus(
|
||||
val consecutiveFailures: Int,
|
||||
)
|
||||
|
||||
/** The daily summary's settings, read together. */
|
||||
internal val Preferences.dailyDigest: DailyDigestSettings
|
||||
get() = DailyDigestSettings(
|
||||
enabled = this[SettingsKeys.dailyDigestEnabled],
|
||||
timeMinutes = this[SettingsKeys.dailyDigestTimeMinutes],
|
||||
day = this[SettingsKeys.dailyDigestDay],
|
||||
excludedCalendarIds = this[SettingsKeys.digestExcludedCalendarIds],
|
||||
armedAtMillis = this[SettingsKeys.dailyDigestArmedAt],
|
||||
)
|
||||
|
||||
/** A calendar's choice for the fixed-time default, as a picker returns it. */
|
||||
sealed interface FixedTimeOverride {
|
||||
data object Inherit : FixedTimeOverride
|
||||
data object Off : FixedTimeOverride
|
||||
data class At(val reminder: FixedTimeReminder) : FixedTimeOverride
|
||||
}
|
||||
|
||||
/** [calendarId]'s entry read as a [FixedTimeOverride]. */
|
||||
fun Map<Long, FixedTimeReminder?>.fixedTimeOverrideFor(calendarId: Long): FixedTimeOverride = when {
|
||||
!containsKey(calendarId) -> FixedTimeOverride.Inherit
|
||||
else -> getValue(calendarId)?.let { FixedTimeOverride.At(it) } ?: FixedTimeOverride.Off
|
||||
}
|
||||
|
||||
/** The fixed-time reminder a new timed event on [calendarId] gets, or null for none. */
|
||||
fun resolveDefaultFixedTimeReminder(
|
||||
global: FixedTimeReminder?,
|
||||
overrides: Map<Long, FixedTimeReminder?>,
|
||||
calendarId: Long?,
|
||||
): FixedTimeReminder? =
|
||||
if (calendarId != null && overrides.containsKey(calendarId)) overrides[calendarId] else global
|
||||
|
||||
private fun parseFixedTimeOverrides(stored: String?): Map<Long, FixedTimeReminder?> =
|
||||
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 value = parts[1].trim()
|
||||
if (value == NONE) return@mapNotNull calendarId to null
|
||||
val reminder = FixedTimeReminder.parse(value) ?: return@mapNotNull null
|
||||
calendarId to reminder
|
||||
}.toMap()
|
||||
|
||||
private fun Map<Long, FixedTimeReminder?>.toStoredFixedTimeOverrides(): String? =
|
||||
if (isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
entries.sortedBy { it.key }.joinToString(ENTRY_SEP) { (id, reminder) ->
|
||||
"$id$KEY_VALUE_SEP${reminder?.toToken() ?: NONE}"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The lead times to prefill on a new event: the matching per-calendar override
|
||||
* if [calendarId] has one for this event kind, otherwise the global default for
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.MainActivity
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.ReminderEventInstance
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.datetime.isoDayNumber
|
||||
import kotlinx.datetime.toKotlinLocalDate
|
||||
import java.time.DayOfWeek
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Posts the daily summary (#459): one notification listing a day's events, on
|
||||
* its own channel, replacing the one before it. Tapping opens that day.
|
||||
*/
|
||||
@Singleton
|
||||
class DailyDigestNotifier @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
) {
|
||||
|
||||
/**
|
||||
* Post the summary of [day] listing [listed], or withdraw the last one when
|
||||
* there is nothing to list — yesterday's would otherwise stand in for it.
|
||||
*/
|
||||
suspend fun post(day: LocalDate, listed: List<ReminderEventInstance>) {
|
||||
val manager = NotificationManagerCompat.from(context)
|
||||
if (listed.isEmpty()) {
|
||||
manager.cancel(TAG_DIGEST, NOTIFICATION_ID)
|
||||
return
|
||||
}
|
||||
ensureChannel()
|
||||
val zone = ZoneId.systemDefault()
|
||||
val locale = Locale.getDefault()
|
||||
val is24Hour = settingsPrefs.timeFormat.first()
|
||||
.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
|
||||
val today = LocalDate.now(zone)
|
||||
val firstDayOfWeek = DayOfWeek.of(settingsPrefs.weekStart.first().resolveFirstDay(locale).isoDayNumber)
|
||||
// The date rides along: the summary stays up into the day it lists,
|
||||
// when "Tomorrow" alone would point at the wrong one.
|
||||
val date = DateTimeFormatter.ofPattern(
|
||||
android.text.format.DateFormat.getBestDateTimePattern(locale, "EEEdMMM"),
|
||||
locale,
|
||||
).format(day)
|
||||
val dayLabel = when (day) {
|
||||
today -> context.getString(R.string.digest_day_relative, context.getString(R.string.reminder_day_today), date)
|
||||
today.plusDays(1) ->
|
||||
context.getString(R.string.digest_day_relative, context.getString(R.string.reminder_day_tomorrow), date)
|
||||
else -> date
|
||||
}
|
||||
val allDay = context.getString(R.string.event_detail_all_day)
|
||||
val lines = listed.map { entry ->
|
||||
// Read against the listed day, so a timed event on it shows its bare
|
||||
// range and one crossing midnight its dates.
|
||||
val time = if (entry.isAllDay) allDay else reminderTimeText(
|
||||
beginMillis = entry.beginMillis,
|
||||
endMillis = entry.endMillis,
|
||||
isAllDay = entry.isAllDay,
|
||||
zone = zone,
|
||||
locale = locale,
|
||||
is24Hour = is24Hour,
|
||||
today = day,
|
||||
firstDayOfWeek = firstDayOfWeek,
|
||||
todayLabel = allDay,
|
||||
tomorrowLabel = context.getString(R.string.reminder_day_tomorrow),
|
||||
yesterdayLabel = context.getString(R.string.reminder_day_yesterday),
|
||||
)
|
||||
context.getString(R.string.digest_line, time, entry.title.ifBlank { context.getString(R.string.event_untitled) })
|
||||
}
|
||||
val title = context.resources.getQuantityString(R.plurals.digest_title, listed.size, dayLabel, listed.size)
|
||||
val style = NotificationCompat.InboxStyle().setBigContentTitle(title)
|
||||
lines.take(MAX_LINES).forEach(style::addLine)
|
||||
if (lines.size > MAX_LINES) {
|
||||
val more = lines.size - MAX_LINES
|
||||
style.setSummaryText(context.resources.getQuantityString(R.plurals.digest_more, more, more))
|
||||
}
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(title)
|
||||
.setContentText(listed.joinToString(", ") { it.title.ifBlank { context.getString(R.string.event_untitled) } })
|
||||
.setStyle(style)
|
||||
.setCategory(NotificationCompat.CATEGORY_REMINDER)
|
||||
.setAutoCancel(true)
|
||||
// Gone once its day is over; "Tomorrow" would read wrong by then anyway.
|
||||
.setTimeoutAfter(
|
||||
(day.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli() - System.currentTimeMillis())
|
||||
.coerceAtLeast(1L),
|
||||
)
|
||||
.setContentIntent(openDay(day))
|
||||
.build()
|
||||
try {
|
||||
manager.notify(TAG_DIGEST, NOTIFICATION_ID, notification)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "Could not post the daily summary", e)
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the summary's own channel is switched off in the system settings. */
|
||||
fun isChannelOff(): Boolean =
|
||||
context.getSystemService(NotificationManager::class.java)
|
||||
.getNotificationChannel(CHANNEL_ID)?.importance == NotificationManager.IMPORTANCE_NONE
|
||||
|
||||
/** Withdraw the summary showing, if any. */
|
||||
fun cancel() {
|
||||
NotificationManagerCompat.from(context).cancel(TAG_DIGEST, NOTIFICATION_ID)
|
||||
}
|
||||
|
||||
/** The summary's channel id, created if missing — for the settings deep-link. */
|
||||
fun channelId(): String {
|
||||
ensureChannel()
|
||||
return CHANNEL_ID
|
||||
}
|
||||
|
||||
/** Creates the channel, or renames it after a language change; the user's settings on it stay. */
|
||||
private fun ensureChannel() {
|
||||
val manager = context.getSystemService(NotificationManager::class.java)
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
context.getString(R.string.digest_channel_name),
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
).apply { description = context.getString(R.string.digest_channel_description) },
|
||||
)
|
||||
}
|
||||
|
||||
private fun openDay(day: LocalDate): PendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
REQUEST_CODE,
|
||||
MainActivity.openDayIntent(context, day.toKotlinLocalDate()),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val TAG = "DailyDigestNotifier"
|
||||
const val CHANNEL_ID = "daily_digest"
|
||||
const val TAG_DIGEST = "daily_digest"
|
||||
const val NOTIFICATION_ID = 2
|
||||
const val REQUEST_CODE = 0xD16E
|
||||
const val MAX_LINES = 6
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
|
||||
|
||||
/** The single channel every reminder posted on before they moved per calendar (#145). */
|
||||
const val LEGACY_REMINDER_CHANNEL_ID = "reminders"
|
||||
|
||||
private const val CHANNEL_PREFIX = "reminders.calendar."
|
||||
private const val GROUP_PREFIX = "reminders.account."
|
||||
private const val LOCAL_GROUP_ID = GROUP_PREFIX + "local"
|
||||
|
||||
/** The notification channel [calendarId]'s reminders post on. */
|
||||
fun reminderChannelId(calendarId: Long): String = CHANNEL_PREFIX + calendarId
|
||||
|
||||
/** Whether [channelId] is one of the per-calendar reminder channels. */
|
||||
fun isReminderChannelId(channelId: String): Boolean = channelId.startsWith(CHANNEL_PREFIX)
|
||||
|
||||
/** Whether [groupId] is one of the per-account reminder channel groups. */
|
||||
fun isReminderGroupId(groupId: String): Boolean = groupId.startsWith(GROUP_PREFIX)
|
||||
|
||||
/**
|
||||
* One account's channel group, labelled as the calendar manager labels it. The
|
||||
* app's own device calendars share a single group ([isLocal]); [ambiguous]
|
||||
* marks a [label] another group shows too, so it should name the source app.
|
||||
*/
|
||||
data class ReminderChannelGroupSpec(
|
||||
val id: String,
|
||||
val label: String,
|
||||
val accountType: String,
|
||||
val isLocal: Boolean,
|
||||
val ambiguous: Boolean,
|
||||
)
|
||||
|
||||
data class ReminderChannelSpec(
|
||||
val id: String,
|
||||
val calendarId: Long,
|
||||
val name: String,
|
||||
/** Null only for a channel created before its calendar could be looked up. */
|
||||
val groupId: String?,
|
||||
/**
|
||||
* Whether a sync creates it up front. A calendar that syncs no events never
|
||||
* fires, so its channel only appears once something posts on it.
|
||||
*/
|
||||
val upFront: Boolean = true,
|
||||
)
|
||||
|
||||
/** What a channel already looks like in the system, as far as the plan compares it. */
|
||||
data class ExistingReminderChannel(val id: String, val name: String, val description: String?, val groupId: String?)
|
||||
|
||||
data class ReminderChannelPlan(
|
||||
val groups: List<ReminderChannelGroupSpec>,
|
||||
val channels: List<ReminderChannelSpec>,
|
||||
/**
|
||||
* The [channels] a sync writes: missing up-front ones, and existing ones
|
||||
* whose name or (localized) description changed, or that still lack a group.
|
||||
*/
|
||||
val channelsToWrite: List<ReminderChannelSpec>,
|
||||
/** The [groups] holding a channel that exists or is about to be written. */
|
||||
val groupsInUse: List<ReminderChannelGroupSpec>,
|
||||
val staleChannelIds: List<String>,
|
||||
val staleGroupIds: List<String>,
|
||||
)
|
||||
|
||||
/**
|
||||
* What the reminder channels should look like for [calendars]: one channel per
|
||||
* calendar inside a group per account, which of them need writing, and the
|
||||
* reminder channels and groups in [existing] / [existingGroupIds] no calendar
|
||||
* claims any more. Ids outside the reminder namespace (the backup channel, the
|
||||
* legacy channel) are never touched.
|
||||
*/
|
||||
fun planReminderChannels(
|
||||
calendars: List<CalendarSource>,
|
||||
existing: Collection<ExistingReminderChannel>,
|
||||
existingGroupIds: Collection<String>,
|
||||
unnamed: String,
|
||||
description: String,
|
||||
): ReminderChannelPlan {
|
||||
val (local, synced) = calendars.partition { it.isLocal }
|
||||
val groups = buildList {
|
||||
if (local.isNotEmpty()) {
|
||||
add(ReminderChannelGroupSpec(LOCAL_GROUP_ID, "", local.first().accountType, isLocal = true, ambiguous = false))
|
||||
}
|
||||
synced.groupByAccount().forEach { group ->
|
||||
add(
|
||||
ReminderChannelGroupSpec(
|
||||
id = accountGroupId(group.key.type, group.key.name),
|
||||
label = group.label,
|
||||
accountType = group.accountType,
|
||||
isLocal = false,
|
||||
ambiguous = group.ambiguous,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
val channels = calendars.map { calendar ->
|
||||
ReminderChannelSpec(
|
||||
id = reminderChannelId(calendar.id),
|
||||
calendarId = calendar.id,
|
||||
name = calendar.displayName.ifBlank { unnamed },
|
||||
groupId = if (calendar.isLocal) LOCAL_GROUP_ID else accountGroupId(calendar.accountType, calendar.accountName),
|
||||
upFront = calendar.isLocal || calendar.syncsEvents,
|
||||
)
|
||||
}
|
||||
val existingById = existing.associateBy { it.id }
|
||||
val wantedChannels = channels.mapTo(HashSet()) { it.id }
|
||||
val wantedGroups = groups.mapTo(HashSet()) { it.id }
|
||||
val toWrite = channels.filter { spec ->
|
||||
val current = existingById[spec.id]
|
||||
if (current == null) {
|
||||
spec.upFront
|
||||
} else {
|
||||
current.name != spec.name || current.description != description ||
|
||||
(current.groupId == null && spec.groupId != null)
|
||||
}
|
||||
}
|
||||
val usedGroupIds = channels
|
||||
.filter { it.id in existingById || it in toWrite }
|
||||
.mapNotNullTo(HashSet()) { it.groupId }
|
||||
return ReminderChannelPlan(
|
||||
groups = groups,
|
||||
channels = channels,
|
||||
channelsToWrite = toWrite,
|
||||
groupsInUse = groups.filter { it.id in usedGroupIds },
|
||||
staleChannelIds = existingById.keys.filter { isReminderChannelId(it) && it !in wantedChannels },
|
||||
staleGroupIds = existingGroupIds.filter { isReminderGroupId(it) && it !in wantedGroups },
|
||||
)
|
||||
}
|
||||
|
||||
private fun accountGroupId(type: String, name: String): String = "$GROUP_PREFIX$type/$name"
|
||||
|
||||
/** A channel as far as its blocked state goes: switched off itself, and the group it sits in. */
|
||||
data class ReminderChannelState(val blocked: Boolean, val groupId: String?)
|
||||
|
||||
/** Reminder notifications the system blocks, while Calendula would post them. */
|
||||
data class ReminderChannelStatus(
|
||||
val appBlocked: Boolean = false,
|
||||
val blockedCalendarIds: Set<Long> = emptySet(),
|
||||
/** The blocked ones whose whole account group is off, which only the app's own screen can undo. */
|
||||
val groupBlockedCalendarIds: Set<Long> = emptySet(),
|
||||
)
|
||||
|
||||
/**
|
||||
* What the system blocks of [calendarIds]' reminders: all of them while the
|
||||
* app's notifications are off, otherwise those whose channel, or the group it
|
||||
* sits in, is switched off. A calendar without a channel yet isn't blocked.
|
||||
*/
|
||||
fun reminderChannelStatus(
|
||||
calendarIds: Collection<Long>,
|
||||
appEnabled: Boolean,
|
||||
channels: Map<String, ReminderChannelState>,
|
||||
blockedGroupIds: Set<String>,
|
||||
): ReminderChannelStatus {
|
||||
val groupBlocked = calendarIds.filterTo(HashSet()) { channels[reminderChannelId(it)]?.groupId in blockedGroupIds }
|
||||
val blocked = if (!appEnabled) {
|
||||
calendarIds.toSet()
|
||||
} else {
|
||||
calendarIds.filterTo(HashSet()) { channels[reminderChannelId(it)]?.blocked == true } + groupBlocked
|
||||
}
|
||||
return ReminderChannelStatus(appBlocked = !appEnabled, blockedCalendarIds = blocked, groupBlockedCalendarIds = groupBlocked)
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationChannelGroup
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.ui.common.sourceAppLabel
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The reminder notification channels: one per calendar, grouped by account
|
||||
* (#145), created up front so each calendar can be muted or given its own sound
|
||||
* in the system settings before it ever fires.
|
||||
*
|
||||
* A channel the user has touched keeps their settings; [sync] only creates the
|
||||
* missing ones, renames changed ones, and deletes those whose calendar is gone.
|
||||
*/
|
||||
@Singleton
|
||||
class ReminderChannels @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val calendarDataSource: CalendarDataSource,
|
||||
) {
|
||||
private val manager: NotificationManager
|
||||
get() = context.getSystemService(NotificationManager::class.java)
|
||||
|
||||
/** What the last [sync] saw, so an unchanged provider costs no channel writes. */
|
||||
@Volatile
|
||||
private var lastSync: Any? = null
|
||||
|
||||
/**
|
||||
* Bring the channels in line with the provider's calendars. An empty
|
||||
* snapshot changes nothing: that is a provider hiccup far more often than
|
||||
* every calendar gone at once.
|
||||
*/
|
||||
@Synchronized
|
||||
fun sync() {
|
||||
val calendars = calendarDataSource.calendars()
|
||||
if (calendars.isEmpty()) return
|
||||
val manager = manager
|
||||
val existing = manager.notificationChannels
|
||||
val existingIds = existing.mapTo(HashSet()) { it.id }
|
||||
if (syncKey(calendars, existingIds) == lastSync && LEGACY_REMINDER_CHANNEL_ID !in existingIds) return
|
||||
val groups = manager.notificationChannelGroups
|
||||
val plan = plan(calendars, existing, groups.map { it.id })
|
||||
plan.staleChannelIds.forEach(manager::deleteNotificationChannel)
|
||||
plan.staleGroupIds.forEach(manager::deleteNotificationChannelGroup)
|
||||
val groupNames = groups.associate { it.id to it.name?.toString() }
|
||||
write(
|
||||
groups = plan.groupsInUse.filter { groupNames[it.id] != groupLabel(it) },
|
||||
channels = plan.channelsToWrite,
|
||||
existing = existing,
|
||||
)
|
||||
val ids = existingIds - plan.staleChannelIds.toSet() + plan.channelsToWrite.map { it.id }
|
||||
lastSync = syncKey(calendars, if (retireLegacyChannel()) ids - LEGACY_REMINDER_CHANNEL_ID else ids)
|
||||
}
|
||||
|
||||
private fun syncKey(calendars: List<CalendarSource>, channelIds: Set<String>): Any = listOf(
|
||||
calendars.map { listOf(it.id, it.displayName, it.accountName, it.accountType, it.isLocal, it.syncsEvents) },
|
||||
Locale.getDefault(),
|
||||
channelIds,
|
||||
)
|
||||
|
||||
/**
|
||||
* The channel to post [calendarId]'s reminder on, created first if a sync
|
||||
* has not got to it yet. Null when the calendar no longer exists. Should the
|
||||
* calendar lookup fail or come back empty, the channel is still created,
|
||||
* plainly named and ungrouped, for the next sync to name and file — a
|
||||
* reminder is never lost to its channel.
|
||||
*/
|
||||
@Synchronized
|
||||
fun channelFor(calendarId: Long): String? {
|
||||
val id = reminderChannelId(calendarId)
|
||||
val manager = manager
|
||||
if (manager.getNotificationChannel(id) != null) return id
|
||||
val existing = manager.notificationChannels
|
||||
val calendars = try {
|
||||
calendarDataSource.calendars()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not look up calendar $calendarId for its channel", e)
|
||||
emptyList()
|
||||
}
|
||||
val fallback = ReminderChannelSpec(id, calendarId, context.getString(R.string.reminder_channel_name), null)
|
||||
if (calendars.isEmpty()) {
|
||||
write(emptyList(), listOf(fallback), existing)
|
||||
return id
|
||||
}
|
||||
val plan = plan(calendars, existing, emptyList())
|
||||
val spec = plan.channels.firstOrNull { it.calendarId == calendarId } ?: return null
|
||||
try {
|
||||
val knownGroups = manager.notificationChannelGroups.mapTo(HashSet()) { it.id }
|
||||
write(
|
||||
groups = plan.groups.filter { it.id == spec.groupId && it.id !in knownGroups },
|
||||
channels = listOf(spec),
|
||||
existing = existing,
|
||||
)
|
||||
} catch (e: RuntimeException) {
|
||||
Log.w(TAG, "Could not file the channel of calendar $calendarId", e)
|
||||
write(emptyList(), listOf(fallback), existing)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
/** What the system currently lets through for [calendarIds]' reminders (#145). */
|
||||
fun status(calendarIds: Collection<Long>): ReminderChannelStatus {
|
||||
val manager = manager
|
||||
val appEnabled = manager.areNotificationsEnabled()
|
||||
val channels = manager.notificationChannels.associate {
|
||||
it.id to ReminderChannelState(it.importance == NotificationManager.IMPORTANCE_NONE, it.group)
|
||||
}
|
||||
val blockedGroups = manager.notificationChannelGroups.filter { it.isBlocked }.mapTo(HashSet()) { it.id }
|
||||
return reminderChannelStatus(calendarIds, appEnabled, channels, blockedGroups)
|
||||
}
|
||||
|
||||
private fun plan(
|
||||
calendars: List<CalendarSource>,
|
||||
existing: List<NotificationChannel>,
|
||||
existingGroupIds: List<String>,
|
||||
): ReminderChannelPlan = planReminderChannels(
|
||||
calendars = calendars,
|
||||
existing = existing.map { ExistingReminderChannel(it.id, it.name.toString(), it.description, it.group) },
|
||||
existingGroupIds = existingGroupIds,
|
||||
unnamed = context.getString(R.string.calendar_unnamed),
|
||||
description = context.getString(R.string.reminder_channel_description),
|
||||
)
|
||||
|
||||
private fun write(
|
||||
groups: List<ReminderChannelGroupSpec>,
|
||||
channels: List<ReminderChannelSpec>,
|
||||
existing: List<NotificationChannel>,
|
||||
) {
|
||||
val manager = manager
|
||||
if (groups.isNotEmpty()) {
|
||||
manager.createNotificationChannelGroups(groups.map { NotificationChannelGroup(it.id, groupLabel(it)) })
|
||||
}
|
||||
if (channels.isEmpty()) return
|
||||
val byId = existing.associateBy { it.id }
|
||||
val description = context.getString(R.string.reminder_channel_description)
|
||||
val template = byId[LEGACY_REMINDER_CHANNEL_ID]
|
||||
// Importance only applies on creation; an existing channel keeps its own.
|
||||
manager.createNotificationChannels(
|
||||
channels.map { spec ->
|
||||
val current = byId[spec.id]
|
||||
NotificationChannel(
|
||||
spec.id,
|
||||
spec.name,
|
||||
current?.importance ?: template?.importance ?: NotificationManager.IMPORTANCE_HIGH,
|
||||
).apply {
|
||||
this.description = description
|
||||
spec.groupId?.let { group = it }
|
||||
if (current == null && template != null) inheritFrom(template)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Carry the old single channel's sound, vibration, lights and badge over to
|
||||
* a new per-calendar one (the system resets lock-screen visibility itself), so an upgrade keeps how reminders sounded.
|
||||
*/
|
||||
private fun NotificationChannel.inheritFrom(template: NotificationChannel) {
|
||||
setSound(template.sound, template.audioAttributes)
|
||||
// The pattern setter also flips vibration on or off, so it goes first.
|
||||
vibrationPattern = template.vibrationPattern
|
||||
enableVibration(template.shouldVibrate())
|
||||
enableLights(template.shouldShowLights())
|
||||
lightColor = template.lightColor
|
||||
setShowBadge(template.canShowBadge())
|
||||
// Only honoured with Do Not Disturb access, so usually left to the user.
|
||||
setBypassDnd(template.canBypassDnd())
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the pre-#145 channel once the per-calendar ones carry its settings,
|
||||
* but not while a reminder posted on it is still on screen — deleting the
|
||||
* channel would take that notification with it. Returns whether it is gone.
|
||||
*/
|
||||
private fun retireLegacyChannel(): Boolean {
|
||||
val manager = manager
|
||||
if (manager.getNotificationChannel(LEGACY_REMINDER_CHANNEL_ID) == null) return true
|
||||
val showing = runCatching {
|
||||
manager.activeNotifications.any { it.notification.channelId == LEGACY_REMINDER_CHANNEL_ID }
|
||||
}.getOrElse { e ->
|
||||
Log.w(TAG, "Could not read active notifications", e)
|
||||
true
|
||||
}
|
||||
if (!showing) manager.deleteNotificationChannel(LEGACY_REMINDER_CHANNEL_ID)
|
||||
return !showing
|
||||
}
|
||||
|
||||
private fun groupLabel(group: ReminderChannelGroupSpec): String {
|
||||
if (group.isLocal) return context.getString(R.string.calendars_local_header)
|
||||
val name = group.label.ifBlank { context.getString(R.string.calendar_unnamed) }
|
||||
if (!group.ambiguous) return name
|
||||
return context.getString(R.string.calendars_account_from_source, name, sourceAppLabel(context, group.accountType))
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ReminderChannels"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
@@ -29,8 +27,8 @@ import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Posts one notification per occurrence on a dedicated channel. Tapping opens
|
||||
* the event's detail screen.
|
||||
* Posts one notification per occurrence on its calendar's channel
|
||||
* ([ReminderChannels], #145). Tapping opens the event's detail screen.
|
||||
*
|
||||
* The tag is the [occurrenceKey], so each reminder for an occurrence replaces
|
||||
* the one before it in place (#463). A different reminder alerts afresh; the
|
||||
@@ -43,6 +41,7 @@ class ReminderNotifier @Inject constructor(
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
private val calendarPrefs: CalendarPrefs,
|
||||
private val calendarDataSource: CalendarDataSource,
|
||||
private val channels: ReminderChannels,
|
||||
) {
|
||||
|
||||
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
|
||||
@@ -53,19 +52,28 @@ class ReminderNotifier @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* The single choke point for "this calendar is switched off", covering the
|
||||
* two paths that reach [post] around the scan's own filter: a snooze armed
|
||||
* before the switch-off, and a read-only install whose switch lives in
|
||||
* [CalendarPrefs].
|
||||
* The single choke point for "this calendar posts no reminders": switched
|
||||
* off (also covering a snooze armed before the switch-off, and a read-only
|
||||
* install whose switch lives in [CalendarPrefs]), or its notifications
|
||||
* turned off in Calendula's own settings (#272).
|
||||
*/
|
||||
private suspend fun isSilenced(calendarId: Long): Boolean =
|
||||
calendarId in calendarPrefs.pendingDisabledCalendarIds.first() ||
|
||||
calendarId in settingsPrefs.mutedReminderCalendarIds.first() ||
|
||||
calendarId in calendarPrefs.pendingDisabledCalendarIds.first() ||
|
||||
calendarDataSource.isCalendarVisible(calendarId) == false
|
||||
|
||||
/** Post [alert], unless its calendar is switched off. */
|
||||
/** Post [alert], unless its calendar is silenced or gone. */
|
||||
suspend fun post(alert: ReminderAlert) {
|
||||
if (isSilenced(alert.calendarId)) return
|
||||
ensureChannel()
|
||||
val channelId = try {
|
||||
channels.channelFor(alert.calendarId) ?: run {
|
||||
Log.i(TAG, "Dropped the reminder of event ${alert.eventId}: calendar ${alert.calendarId} is gone")
|
||||
return
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "No channel for the reminder of event ${alert.eventId}", e)
|
||||
return
|
||||
}
|
||||
val title = displayTitle(alert)
|
||||
val is24Hour = settingsPrefs.timeFormat.first()
|
||||
.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
|
||||
@@ -88,7 +96,7 @@ class ReminderNotifier @Inject constructor(
|
||||
yesterdayLabel = context.getString(R.string.reminder_day_yesterday),
|
||||
)
|
||||
val text = listOfNotNull(time, alert.location).joinToString(" · ")
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
val notification = NotificationCompat.Builder(context, channelId)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
@@ -153,7 +161,7 @@ class ReminderNotifier @Inject constructor(
|
||||
private fun cancelLegacy(alert: ReminderAlert) {
|
||||
val manager = NotificationManagerCompat.from(context)
|
||||
manager.activeNotifications
|
||||
.filter { it.id == NOTIFICATION_ID && it.notification.channelId == CHANNEL_ID }
|
||||
.filter { it.id == NOTIFICATION_ID && it.notification.channelId == LEGACY_REMINDER_CHANNEL_ID }
|
||||
.filter { !it.notification.extras.containsKey(EXTRA_REMINDER_KEY) }
|
||||
.filter { it.notification.`when` == alert.beginMillis }
|
||||
.filter { it.notification.extras.getCharSequence(NotificationCompat.EXTRA_TITLE)?.toString() == displayTitle(alert) }
|
||||
@@ -181,21 +189,8 @@ class ReminderNotifier @Inject constructor(
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
/** Channel creation is idempotent; re-running refreshes the localized name. */
|
||||
private fun ensureChannel() {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
context.getString(R.string.reminder_channel_name),
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
).apply {
|
||||
description = context.getString(R.string.reminder_channel_description)
|
||||
}
|
||||
NotificationManagerCompat.from(context).createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ReminderNotifier"
|
||||
const val CHANNEL_ID = "reminders"
|
||||
|
||||
// One id, distinct tags: the tag (occurrence) already keys the
|
||||
// notification, so a fixed id keeps cancellation/replacement simple.
|
||||
|
||||
@@ -6,9 +6,13 @@ import android.util.Log
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||
import de.jeanlucmakiola.calendula.data.calendar.withoutProviderDefaults
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.ReminderStatePrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.DailyDigestSettings
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.digestEntries
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.planReminders
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.scheduleDigest
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.reminderQueryHorizon
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.reminderWatermark
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.scheduleReminders
|
||||
@@ -43,9 +47,12 @@ class ReminderScanner @Inject constructor(
|
||||
private val source: ReminderInstanceSource,
|
||||
private val calendarDataSource: CalendarDataSource,
|
||||
private val notifier: ReminderNotifier,
|
||||
private val digestNotifier: DailyDigestNotifier,
|
||||
private val channels: ReminderChannels,
|
||||
private val snoozes: ReminderSnoozeScheduler,
|
||||
private val alarms: ReminderAlarmScheduler,
|
||||
private val state: ReminderStatePrefs,
|
||||
private val calendarPrefs: CalendarPrefs,
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
@IoDispatcher private val io: kotlinx.coroutines.CoroutineDispatcher,
|
||||
) {
|
||||
@@ -77,18 +84,37 @@ class ReminderScanner @Inject constructor(
|
||||
val now = System.currentTimeMillis()
|
||||
if (!hasReadCalendar()) return
|
||||
|
||||
if (!settingsPrefs.remindersEnabled.first()) {
|
||||
// Reminders off: drop the wake-up, but keep the watermark moving
|
||||
// so switching them back on doesn't replay the backlog.
|
||||
alarms.cancelScan()
|
||||
state.setLastScanMillis(now)
|
||||
return
|
||||
val lastFired = reminderWatermark(state.lastScanMillis(), now)
|
||||
val horizon = now + MAX_ALARM_INTERVAL_MILLIS
|
||||
val digestSettings = settingsPrefs.dailyDigest.first()
|
||||
val nextDigest = if (digestSettings.enabled) postDigest(digestSettings, lastFired, now) else null
|
||||
val nextReminder = try {
|
||||
if (settingsPrefs.remindersEnabled.first()) reminders(lastFired, now, horizon) else null
|
||||
} catch (e: Exception) {
|
||||
// The watermark stays, so the next pass owes these reminders again;
|
||||
// keep that pass, and the summary's next moment, armed.
|
||||
alarms.scheduleScan(minOf(nextDigest ?: horizon, now + RETRY_AFTER_FAILURE_MILLIS))
|
||||
throw e
|
||||
}
|
||||
|
||||
// Advance even when nothing could be posted, so muting notifications
|
||||
// doesn't build a backlog — and while both are off, so switching one
|
||||
// back on doesn't replay it.
|
||||
state.setLastScanMillis(now)
|
||||
val next = listOfNotNull(nextReminder, nextDigest).minOrNull()
|
||||
if (next == null) alarms.cancelScan() else alarms.scheduleScan(minOf(next, horizon))
|
||||
}
|
||||
|
||||
/** Post the event reminders that are due; returns the next wake-up they need. */
|
||||
private suspend fun reminders(lastFired: Long, now: Long, horizon: Long): Long {
|
||||
syncChannels()
|
||||
val lookahead = reminderQueryHorizon(LOOKAHEAD_MILLIS, source.longestReminderMinutes())
|
||||
// Reach into the past too: an all-day "at time of event" encodes to a
|
||||
// negative offset, and a catch-up pass needs the occurrences it missed.
|
||||
// Calendars muted in-app (#272) plan nothing, so they arm no wake-ups.
|
||||
val muted = settingsPrefs.mutedReminderCalendarIds.first()
|
||||
val occurrences = source.occurrences(now - PAST_WINDOW_MILLIS, now + lookahead)
|
||||
.filter { it.calendarId !in muted }
|
||||
val planned = planReminders(
|
||||
instances = occurrences,
|
||||
minutesByEvent = source.reminderMinutes(occurrences.map { it.eventId })
|
||||
@@ -99,9 +125,9 @@ class ReminderScanner @Inject constructor(
|
||||
|
||||
val schedule = scheduleReminders(
|
||||
planned = planned,
|
||||
lastFiredMillis = reminderWatermark(state.lastScanMillis(), now),
|
||||
lastFiredMillis = lastFired,
|
||||
nowMillis = now,
|
||||
horizonMillis = now + MAX_ALARM_INTERVAL_MILLIS,
|
||||
horizonMillis = horizon,
|
||||
)
|
||||
|
||||
if (notifier.canPost()) {
|
||||
@@ -111,10 +137,61 @@ class ReminderScanner @Inject constructor(
|
||||
notifier.post(alert)
|
||||
}
|
||||
}
|
||||
// Advance even when nothing could be posted, so muting notifications
|
||||
// doesn't build a backlog.
|
||||
state.setLastScanMillis(now)
|
||||
alarms.scheduleScan(schedule.nextAlarmMillis)
|
||||
return schedule.nextAlarmMillis
|
||||
}
|
||||
|
||||
/**
|
||||
* Post the daily summary if it is owed (#459); returns its next moment. It
|
||||
* leaves out calendars excluded from it, not those muted for reminders —
|
||||
* a calendar too noisy to ping per event can still belong in the overview.
|
||||
*/
|
||||
private suspend fun postDigest(settings: DailyDigestSettings, lastFired: Long, now: Long): Long {
|
||||
val zone = ZoneId.systemDefault()
|
||||
val listed = try {
|
||||
state.lastDigestDay()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not read the last daily summary", e)
|
||||
null
|
||||
}
|
||||
val schedule = scheduleDigest(
|
||||
timeMinutes = settings.timeMinutes,
|
||||
day = settings.day,
|
||||
zone = zone,
|
||||
// A moment that passed before the summary was last changed is not owed.
|
||||
lastFiredMillis = maxOf(lastFired, settings.armedAtMillis),
|
||||
nowMillis = now,
|
||||
lastListedDay = listed,
|
||||
)
|
||||
val day = schedule.dueDay
|
||||
// A blocked channel would swallow it; the day stays owed until allowed.
|
||||
if (day != null && notifier.canPost() && !digestNotifier.isChannelOff()) {
|
||||
// A failed summary must not cost the event reminders this pass owes.
|
||||
try {
|
||||
// A day either side: all-day occurrences sit at UTC midnight, which
|
||||
// can fall outside the local day they stand for.
|
||||
val from = day.minusDays(1).atStartOfDay(zone).toInstant().toEpochMilli()
|
||||
val to = day.plusDays(2).atStartOfDay(zone).toInstant().toEpochMilli()
|
||||
val left = settings.excludedCalendarIds + calendarPrefs.pendingDisabledCalendarIds.first()
|
||||
digestNotifier.post(day, digestEntries(source.occurrences(from, to), day, zone, left))
|
||||
state.setLastDigestDay(day)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Daily summary failed", e)
|
||||
}
|
||||
}
|
||||
return schedule.nextAlarmMillis
|
||||
}
|
||||
|
||||
/**
|
||||
* A provider change re-scans, so a calendar added, renamed or removed keeps
|
||||
* its channel in step here. Skipped while reminders are off, when the
|
||||
* channels have nothing to do. A failure never costs the reminders.
|
||||
*/
|
||||
private fun syncChannels() {
|
||||
try {
|
||||
channels.sync()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Reminder channel sync failed", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasReadCalendar(): Boolean = context.hasPermission(Manifest.permission.READ_CALENDAR)
|
||||
@@ -158,5 +235,8 @@ class ReminderScanner @Inject constructor(
|
||||
const val MAX_ALARM_INTERVAL_MILLIS = 24L * 60 * 60 * 1000
|
||||
|
||||
const val PROVIDER_CHANGE_DEBOUNCE_MILLIS = 2_000L
|
||||
|
||||
/** How soon a pass whose reminders failed is tried again. */
|
||||
const val RETRY_AFTER_FAILURE_MILLIS = 15L * 60 * 1000
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.FixedTimeReminder
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.splitTimedReminders
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
@@ -40,6 +42,12 @@ data class EventForm(
|
||||
val description: String = "",
|
||||
/** Reminder lead times in minutes before the start, deduplicated. */
|
||||
val reminders: List<Int> = emptyList(),
|
||||
/**
|
||||
* "N days before at HH:MM" reminders of a timed event (#459), written as lead
|
||||
* times measured from [start] at save time. Ignored while [isAllDay], so a
|
||||
* round trip through all-day keeps them.
|
||||
*/
|
||||
val fixedReminders: List<FixedTimeReminder> = emptyList(),
|
||||
val availability: Availability = Availability.Busy,
|
||||
val accessLevel: AccessLevel = AccessLevel.Default,
|
||||
/** Confirmed / Tentative / Cancelled (#388). */
|
||||
@@ -286,7 +294,7 @@ fun EventForm.populatedFields(): Set<EventFormField> = buildSet {
|
||||
if (location.isNotBlank()) add(EventFormField.Location)
|
||||
if (description.isNotBlank()) add(EventFormField.Description)
|
||||
if (timezone != null) add(EventFormField.Timezone)
|
||||
if (reminders.isNotEmpty()) add(EventFormField.Reminders)
|
||||
if (reminders.isNotEmpty() || (!isAllDay && fixedReminders.isNotEmpty())) add(EventFormField.Reminders)
|
||||
if (rrule != null) add(EventFormField.Recurrence)
|
||||
if (availability != Availability.Busy) add(EventFormField.Availability)
|
||||
if (status != EventStatus.Confirmed) add(EventFormField.Status)
|
||||
@@ -324,13 +332,37 @@ fun EventForm.problems(deviceZone: TimeZone): Set<EventFormProblem> = buildSet {
|
||||
* cleared so the create resolves to a writable calendar, which lets a read-only
|
||||
* event be copied into an editable one. [instance]'s own times carry over.
|
||||
*/
|
||||
fun EventDetail.toDuplicateForm(canModify: Boolean, zone: TimeZone): EventForm =
|
||||
fun EventDetail.toDuplicateForm(
|
||||
canModify: Boolean,
|
||||
zone: TimeZone,
|
||||
knownFixed: Set<FixedTimeReminder> = emptySet(),
|
||||
): EventForm =
|
||||
toEditForm(
|
||||
beginMillis = instance.start.toEpochMilliseconds(),
|
||||
endMillis = instance.end.toEpochMilliseconds(),
|
||||
zone = zone,
|
||||
).copy(
|
||||
).withFixedTimeRemindersRead(knownFixed, zone).copy(
|
||||
rrule = null,
|
||||
calendarId = instance.calendarId.takeIf { canModify },
|
||||
status = status.forNewEvent(),
|
||||
)
|
||||
|
||||
/**
|
||||
* This form with its timed reminders read back into lead times and
|
||||
* [EventForm.fixedReminders] ([splitTimedReminders]), the wall clock taken in
|
||||
* [deviceZone]. All-day forms come back untouched.
|
||||
*/
|
||||
fun EventForm.withFixedTimeRemindersRead(known: Set<FixedTimeReminder>, deviceZone: TimeZone): EventForm {
|
||||
if (isAllDay || reminders.isEmpty()) return this
|
||||
val split = splitTimedReminders(
|
||||
reminders, startInstant(resolvedZone(deviceZone)), deviceZone, known, wallClock = rrule != null,
|
||||
)
|
||||
return copy(reminders = split.leadMinutes, fixedReminders = (fixedReminders + split.fixed).distinct())
|
||||
}
|
||||
|
||||
/** The offsets [EventForm.fixedReminders] store for this form's start; empty for an all-day form. */
|
||||
fun EventForm.fixedReminderOffsets(deviceZone: TimeZone): List<Int> {
|
||||
if (isAllDay || fixedReminders.isEmpty()) return emptyList()
|
||||
val start = startInstant(resolvedZone(deviceZone))
|
||||
return fixedReminders.map { it.offsetMinutes(start, deviceZone, wallClock = rrule != null) }
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package de.jeanlucmakiola.calendula.domain.reminders
|
||||
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
|
||||
/** Which day the daily summary (#459) lists, counted from the day it arrives. */
|
||||
enum class DigestDay(val daysAhead: Long) { Today(0), Tomorrow(1) }
|
||||
|
||||
/** 20:00, when the daily summary arrives until changed. */
|
||||
const val DEFAULT_DIGEST_TIME_MINUTES = 1_200
|
||||
|
||||
/**
|
||||
* The daily summary's settings. Calendars in [excludedCalendarIds] are left out
|
||||
* of it; [armedAtMillis] is when its switch, time or day last changed.
|
||||
*/
|
||||
data class DailyDigestSettings(
|
||||
val enabled: Boolean = false,
|
||||
val timeMinutes: Int = DEFAULT_DIGEST_TIME_MINUTES,
|
||||
val day: DigestDay = DigestDay.Tomorrow,
|
||||
val excludedCalendarIds: Set<Long> = emptySet(),
|
||||
val armedAtMillis: Long = 0L,
|
||||
)
|
||||
|
||||
/**
|
||||
* What one scan concluded for the summary: list [dueDay] now (null when nothing
|
||||
* is owed), and wake up again at [nextAlarmMillis].
|
||||
*/
|
||||
data class DigestSchedule(
|
||||
val dueDay: LocalDate?,
|
||||
val nextAlarmMillis: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
* When the summary is owed: a moment in `(lastFiredMillis, nowMillis]` is due.
|
||||
* Only the latest such moment counts — a phone off for three evenings gets one
|
||||
* summary, not three — and only while the day it lists has not passed and is
|
||||
* not [lastListedDay]: each day is listed once.
|
||||
*/
|
||||
fun scheduleDigest(
|
||||
timeMinutes: Int,
|
||||
day: DigestDay,
|
||||
zone: ZoneId,
|
||||
lastFiredMillis: Long,
|
||||
nowMillis: Long,
|
||||
lastListedDay: LocalDate? = null,
|
||||
): DigestSchedule {
|
||||
val time = LocalTime.of(timeMinutes / 60, timeMinutes % 60)
|
||||
val fireAt = { date: LocalDate -> date.atTime(time).atZone(zone).toInstant().toEpochMilli() }
|
||||
val today = Instant.ofEpochMilli(nowMillis).atZone(zone).toLocalDate()
|
||||
val latestDate = if (fireAt(today) <= nowMillis) today else today.minusDays(1)
|
||||
val listed = latestDate.plusDays(day.daysAhead)
|
||||
val dueAt = fireAt(latestDate)
|
||||
val dueDay = listed.takeIf { dueAt > lastFiredMillis && !it.isBefore(today) && it != lastListedDay }
|
||||
return DigestSchedule(dueDay = dueDay, nextAlarmMillis = fireAt(latestDate.plusDays(1)))
|
||||
}
|
||||
|
||||
/**
|
||||
* The occurrences the summary for [day] lists: all-day ones covering it (their
|
||||
* UTC dates), then timed ones overlapping it in [zone], each group in start
|
||||
* order. Calendars in [excludedCalendarIds] are left out.
|
||||
*/
|
||||
fun digestEntries(
|
||||
instances: List<ReminderEventInstance>,
|
||||
day: LocalDate,
|
||||
zone: ZoneId,
|
||||
excludedCalendarIds: Set<Long>,
|
||||
): List<ReminderEventInstance> {
|
||||
val dayStart = day.atStartOfDay(zone).toInstant().toEpochMilli()
|
||||
val dayEnd = day.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli()
|
||||
val utcDay = day.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||
return instances
|
||||
.asSequence()
|
||||
.filter { it.calendarId !in excludedCalendarIds }
|
||||
.filter {
|
||||
if (it.isAllDay) {
|
||||
it.beginMillis <= utcDay && maxOf(it.endMillis, it.beginMillis + 1) > utcDay
|
||||
} else {
|
||||
it.beginMillis < dayEnd && maxOf(it.endMillis, it.beginMillis + 1) > dayStart
|
||||
}
|
||||
}
|
||||
.distinctBy { occurrenceKey(it.eventId, it.beginMillis) }
|
||||
.sortedWith(compareBy({ !it.isAllDay }, { it.beginMillis }, { it.title }))
|
||||
.toList()
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package de.jeanlucmakiola.calendula.domain.reminders
|
||||
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.minus
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* A timed event's reminder "[daysBefore] days before, at [minuteOfDay]" (#459).
|
||||
*
|
||||
* The provider only knows lead times, so it is stored as an ordinary `Reminders`
|
||||
* row whose offset is the distance from that wall-clock moment to the start
|
||||
* ([offsetMinutes]) — it fires on every device and in every app. The pattern is
|
||||
* only known to Calendula while it can be read back ([classifyTimedReminder]); the
|
||||
* editor then re-derives the offset whenever the start moves.
|
||||
*/
|
||||
data class FixedTimeReminder(val daysBefore: Int, val minuteOfDay: Int) {
|
||||
|
||||
init {
|
||||
require(daysBefore in 1..MAX_DAYS_BEFORE) { "daysBefore out of range: $daysBefore" }
|
||||
require(minuteOfDay in 0 until MINUTES_PER_DAY) { "minuteOfDay out of range: $minuteOfDay" }
|
||||
}
|
||||
|
||||
/** The wall clock this reminder names for an event starting at [start], read in [zone]. */
|
||||
private fun alarmWallClock(start: Instant, zone: TimeZone): LocalDateTime {
|
||||
val day = start.toLocalDateTime(zone).date.minus(daysBefore, DateTimeUnit.DAY)
|
||||
return LocalDateTime(day, LocalTime(minuteOfDay / 60, minuteOfDay % 60))
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider offset to store for an event starting at [start]: the real
|
||||
* minutes to that moment, or with [wallClock] the minutes on the clock face.
|
||||
* A recurring series stores one offset for every occurrence, so it takes the
|
||||
* wall-clock reading — otherwise an occurrence opened next to a DST change
|
||||
* would bake that hour into all the others.
|
||||
*/
|
||||
fun offsetMinutes(start: Instant, zone: TimeZone, wallClock: Boolean = false): Int {
|
||||
val alarm = alarmWallClock(start, zone)
|
||||
return if (wallClock) {
|
||||
(start.toLocalDateTime(zone).toInstant(TimeZone.UTC) - alarm.toInstant(TimeZone.UTC)).inWholeMinutes.toInt()
|
||||
} else {
|
||||
(start - alarm.toInstant(zone)).inWholeMinutes.toInt()
|
||||
}
|
||||
}
|
||||
|
||||
/** The stored form, `days@minuteOfDay`. */
|
||||
fun toToken(): String = "$daysBefore$TOKEN_SEP$minuteOfDay"
|
||||
|
||||
companion object {
|
||||
/** Two weeks, well inside the four weeks Google Calendar takes for a reminder. */
|
||||
const val MAX_DAYS_BEFORE = 14
|
||||
private const val TOKEN_SEP = "@"
|
||||
|
||||
/** [toToken] read back; null for anything malformed or out of range. */
|
||||
fun parse(token: String?): FixedTimeReminder? {
|
||||
val parts = token?.trim()?.split(TOKEN_SEP) ?: return null
|
||||
if (parts.size != 2) return null
|
||||
val days = parts[0].toIntOrNull() ?: return null
|
||||
val minute = parts[1].toIntOrNull() ?: return null
|
||||
if (days !in 1..MAX_DAYS_BEFORE || minute !in 0 until MINUTES_PER_DAY) return null
|
||||
return FixedTimeReminder(days, minute)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The "N days before at HH:MM" a raw offset lands on for an event starting at
|
||||
* [start], or null when it fires on the start's own day (or after it), or
|
||||
* further back than [FixedTimeReminder.MAX_DAYS_BEFORE]. [wallClock] reads the
|
||||
* offset as [FixedTimeReminder.offsetMinutes] wrote it.
|
||||
*/
|
||||
fun fixedTimeReminderAt(
|
||||
offsetMinutes: Int,
|
||||
start: Instant,
|
||||
zone: TimeZone,
|
||||
wallClock: Boolean = false,
|
||||
): FixedTimeReminder? {
|
||||
if (offsetMinutes <= 0) return null
|
||||
val startLocal = start.toLocalDateTime(zone)
|
||||
val alarm = if (wallClock) {
|
||||
(startLocal.toInstant(TimeZone.UTC) - offsetMinutes.minutes).toLocalDateTime(TimeZone.UTC)
|
||||
} else {
|
||||
(start - offsetMinutes.minutes).toLocalDateTime(zone)
|
||||
}
|
||||
val startDate = startLocal.date
|
||||
val days = startDate.toEpochDays() - alarm.date.toEpochDays()
|
||||
if (days !in 1..FixedTimeReminder.MAX_DAYS_BEFORE) return null
|
||||
return FixedTimeReminder(days.toInt(), alarm.hour * 60 + alarm.minute)
|
||||
}
|
||||
|
||||
/** A timed event's reminders, read back: plain lead times and the fixed-time ones. */
|
||||
data class TimedReminders(
|
||||
val leadMinutes: List<Int>,
|
||||
val fixed: List<FixedTimeReminder>,
|
||||
)
|
||||
|
||||
/**
|
||||
* The fixed-time reminder [offsetMinutes] stands for on an event starting at
|
||||
* [start], or null when it reads as a plain lead time. The provider can't say
|
||||
* which an offset was meant as, so only the patterns in [known] — the configured
|
||||
* defaults, past and present, and the ones saved by hand on this device — are
|
||||
* recognised: guessing from the offset's shape alone would turn ordinary leads
|
||||
* ("12 hours before") into fixed-time ones. Where a lead and a known pattern
|
||||
* coincide ("1 day before" on an event at the default's own time) the pattern
|
||||
* wins. A wrong reading fires at the same moment; only a later change of the
|
||||
* start's time of day then treats the reminder the other way.
|
||||
*/
|
||||
fun classifyTimedReminder(
|
||||
offsetMinutes: Int,
|
||||
start: Instant,
|
||||
zone: TimeZone,
|
||||
known: Set<FixedTimeReminder>,
|
||||
wallClock: Boolean = false,
|
||||
): FixedTimeReminder? = fixedTimeReminderAt(offsetMinutes, start, zone, wallClock)?.takeIf { it in known }
|
||||
|
||||
/** Sort a timed event's raw offsets into lead times and fixed-time reminders ([classifyTimedReminder]). */
|
||||
fun splitTimedReminders(
|
||||
offsets: List<Int>,
|
||||
start: Instant,
|
||||
zone: TimeZone,
|
||||
known: Set<FixedTimeReminder>,
|
||||
wallClock: Boolean = false,
|
||||
): TimedReminders {
|
||||
val leads = mutableListOf<Int>()
|
||||
val fixed = mutableListOf<FixedTimeReminder>()
|
||||
offsets.distinct().forEach { offset ->
|
||||
val pattern = classifyTimedReminder(offset, start, zone, known, wallClock)
|
||||
if (pattern != null) fixed += pattern else leads += offset
|
||||
}
|
||||
return TimedReminders(leads.sorted(), fixed.distinct().sortedWith(FIXED_ORDER))
|
||||
}
|
||||
|
||||
/** Most days before first, then by time — the order they fire in. */
|
||||
val FIXED_ORDER: Comparator<FixedTimeReminder> =
|
||||
compareByDescending<FixedTimeReminder> { it.daysBefore }.thenBy { it.minuteOfDay }
|
||||
|
||||
private const val MINUTES_PER_DAY = 1_440
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
-108
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -72,11 +73,13 @@ fun accountGroupTitle(group: CalendarAccountGroup): String {
|
||||
@Composable
|
||||
fun sourceAppName(accountType: String): String {
|
||||
val context = LocalContext.current
|
||||
return remember(accountType) {
|
||||
val pm = context.packageManager
|
||||
val packages = sourceAppPackages(context, accountType)
|
||||
packages.firstNotNullOfOrNull { pkg ->
|
||||
runCatching { pm.getApplicationLabel(pm.getApplicationInfo(pkg, 0)).toString() }.getOrNull()
|
||||
} ?: accountType
|
||||
}
|
||||
return remember(accountType) { sourceAppLabel(context, accountType) }
|
||||
}
|
||||
|
||||
/** [sourceAppName] outside composition, e.g. for notification channel groups. */
|
||||
fun sourceAppLabel(context: Context, accountType: String): String {
|
||||
val pm = context.packageManager
|
||||
return sourceAppPackages(context, accountType).firstNotNullOfOrNull { pkg ->
|
||||
runCatching { pm.getApplicationLabel(pm.getApplicationInfo(pkg, 0)).toString() }.getOrNull()
|
||||
} ?: accountType
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import de.jeanlucmakiola.calendula.domain.atOccurrence
|
||||
import de.jeanlucmakiola.calendula.domain.toDuplicateForm
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
|
||||
import de.jeanlucmakiola.floret.di.IoDispatcher
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
@@ -43,6 +44,7 @@ class EventActionsViewModel @Inject constructor(
|
||||
private val icsExporter: IcsExporter,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
private val zoneSource: ZoneSource,
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
) : ViewModel() {
|
||||
|
||||
/** Every calendar by id, for what a selected event's bar may offer. */
|
||||
@@ -65,7 +67,7 @@ class EventActionsViewModel @Inject constructor(
|
||||
suspend fun duplicateForm(event: EventInstance): EventForm? {
|
||||
val detail = load(event) ?: return null
|
||||
val canModify = calendars.value[event.calendarId]?.canModifyContents == true
|
||||
return detail.toDuplicateForm(canModify, zoneSource.current())
|
||||
return detail.toDuplicateForm(canModify, zoneSource.current(), settingsPrefs.knownFixedTimeReminders.first())
|
||||
}
|
||||
|
||||
/** A staged `.ics` for [event], or null — reported — when it can't be built. */
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.FixedTimeOverride
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.FixedTimeReminder
|
||||
import de.jeanlucmakiola.floret.components.CustomExpandRow
|
||||
import de.jeanlucmakiola.floret.components.DialogAmountField
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedHint
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import kotlinx.datetime.LocalTime
|
||||
|
||||
/** "1 day before at 20:00", in the app's 12/24-hour convention. */
|
||||
@Composable
|
||||
fun fixedTimeReminderLabel(reminder: FixedTimeReminder): String = stringResource(
|
||||
R.string.reminder_fixed_time,
|
||||
pluralStringResource(R.plurals.reminder_days, reminder.daysBefore, reminder.daysBefore),
|
||||
formatMinuteOfDay(reminder.minuteOfDay, LocalUse24HourFormat.current, currentLocale()),
|
||||
)
|
||||
|
||||
/**
|
||||
* The "N days before at HH:MM" editor (#459): a day count and a time, read as
|
||||
* one sentence. Seeded from [initial]; [onValueChange] follows every edit (null
|
||||
* while the day count is out of range). With a [confirmLabel] it carries its own
|
||||
* confirm, reporting through [onConfirm]; without one the host picker commits
|
||||
* from its actions slot. [connected] gives it the shape that meets a row above it.
|
||||
*/
|
||||
@Composable
|
||||
fun FixedTimeReminderEditor(
|
||||
initial: FixedTimeReminder,
|
||||
modifier: Modifier = Modifier,
|
||||
confirmLabel: String? = null,
|
||||
onConfirm: (FixedTimeReminder) -> Unit = {},
|
||||
onValueChange: (FixedTimeReminder?) -> Unit = {},
|
||||
connected: Boolean = true,
|
||||
) {
|
||||
var daysText by rememberSaveable { mutableStateOf(initial.daysBefore.toString()) }
|
||||
var minuteOfDay by rememberSaveable { mutableStateOf(initial.minuteOfDay) }
|
||||
var pickingTime by rememberSaveable { mutableStateOf(false) }
|
||||
val days = daysText.toIntOrNull()?.takeIf { it in 1..FixedTimeReminder.MAX_DAYS_BEFORE }
|
||||
val value = days?.let { FixedTimeReminder(it, minuteOfDay) }
|
||||
LaunchedEffect(value) { onValueChange(value) }
|
||||
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
shape = if (connected) {
|
||||
RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp)
|
||||
} else {
|
||||
RoundedCornerShape(22.dp)
|
||||
},
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
DialogAmountField(value = daysText, onValueChange = { daysText = it }, placeholder = "1")
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.reminder_fixed_days_before_at),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
FilledTonalButton(onClick = { pickingTime = true }) {
|
||||
Text(formatMinuteOfDay(minuteOfDay, LocalUse24HourFormat.current, currentLocale()))
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = value?.let { fixedTimeReminderLabel(it) }
|
||||
?: stringResource(R.string.reminder_fixed_days_range, FixedTimeReminder.MAX_DAYS_BEFORE),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (confirmLabel != null) {
|
||||
Spacer(Modifier.width(16.dp))
|
||||
FilledTonalButton(
|
||||
enabled = value != null,
|
||||
onClick = { value?.let(onConfirm) },
|
||||
) { Text(confirmLabel) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (pickingTime) {
|
||||
TimePickerAlert(
|
||||
initial = LocalTime(minuteOfDay / 60, minuteOfDay % 60),
|
||||
onConfirm = {
|
||||
minuteOfDay = it.hour * 60 + it.minute
|
||||
pickingTime = false
|
||||
},
|
||||
onDismiss = { pickingTime = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Picker for a fixed-time default (#459), full-screen: off, or a reminder at a
|
||||
* set time edited in place — and, on a per-calendar picker ([inheritedLabel]
|
||||
* non-null), deferring to the global default, [inherited], which also seeds the
|
||||
* editor. Applies live; the user leaves via back.
|
||||
*/
|
||||
@Composable
|
||||
fun FixedTimeReminderPicker(
|
||||
title: String,
|
||||
selected: FixedTimeOverride,
|
||||
onSelect: (FixedTimeOverride) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
inheritedLabel: String? = null,
|
||||
inherited: FixedTimeReminder? = null,
|
||||
) {
|
||||
var current by rememberSaveable(stateSaver = FixedTimeOverrideSaver) { mutableStateOf(selected) }
|
||||
var editing by rememberSaveable { mutableStateOf(false) }
|
||||
// Mirrors [selected] until the first pick, as ReminderDefaultPicker does.
|
||||
var userEdited by rememberSaveable { mutableStateOf(false) }
|
||||
LaunchedEffect(selected) {
|
||||
if (!userEdited) current = selected
|
||||
}
|
||||
fun apply(choice: FixedTimeOverride) {
|
||||
userEdited = true
|
||||
current = choice
|
||||
onSelect(choice)
|
||||
}
|
||||
val chosen = (current as? FixedTimeOverride.At)?.reminder
|
||||
|
||||
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
||||
GroupedHint(text = stringResource(R.string.settings_fixed_reminder_hint))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
val rowCount = if (inheritedLabel != null) 3 else 2
|
||||
if (inheritedLabel != null) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.reminder_use_default),
|
||||
summary = inheritedLabel,
|
||||
position = Position.Top,
|
||||
checked = current is FixedTimeOverride.Inherit,
|
||||
onClick = { apply(FixedTimeOverride.Inherit) },
|
||||
)
|
||||
}
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.reminder_none),
|
||||
position = if (rowCount == 3) Position.Middle else Position.Top,
|
||||
checked = current is FixedTimeOverride.Off,
|
||||
onClick = { apply(FixedTimeOverride.Off) },
|
||||
)
|
||||
CustomExpandRow(
|
||||
title = stringResource(R.string.reminder_fixed_at_set_time),
|
||||
summary = chosen?.let { fixedTimeReminderLabel(it) },
|
||||
position = Position.Bottom,
|
||||
expanded = editing,
|
||||
onExpandedChange = { editing = it },
|
||||
checked = chosen != null,
|
||||
) {
|
||||
FixedTimeReminderEditor(
|
||||
initial = chosen ?: inherited ?: DEFAULT_FIXED_TIME_REMINDER,
|
||||
confirmLabel = stringResource(R.string.reminder_custom_set),
|
||||
onConfirm = {
|
||||
apply(FixedTimeOverride.At(it))
|
||||
editing = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** What the editor offers first: the evening before. */
|
||||
val DEFAULT_FIXED_TIME_REMINDER = FixedTimeReminder(daysBefore = 1, minuteOfDay = 20 * 60)
|
||||
|
||||
private val FixedTimeOverrideSaver = Saver<FixedTimeOverride, String>(
|
||||
save = {
|
||||
when (it) {
|
||||
FixedTimeOverride.Inherit -> "inherit"
|
||||
FixedTimeOverride.Off -> "off"
|
||||
is FixedTimeOverride.At -> it.reminder.toToken()
|
||||
}
|
||||
},
|
||||
restore = {
|
||||
when (it) {
|
||||
"inherit" -> FixedTimeOverride.Inherit
|
||||
"off" -> FixedTimeOverride.Off
|
||||
else -> FixedTimeReminder.parse(it)?.let(FixedTimeOverride::At) ?: FixedTimeOverride.Off
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -23,9 +23,12 @@ import de.jeanlucmakiola.calendula.domain.resolvedZone
|
||||
import de.jeanlucmakiola.calendula.domain.shiftedByDays
|
||||
import de.jeanlucmakiola.calendula.domain.shiftedTo
|
||||
import de.jeanlucmakiola.calendula.domain.toEditForm
|
||||
import de.jeanlucmakiola.calendula.domain.withFixedTimeRemindersRead
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
|
||||
import de.jeanlucmakiola.floret.di.IoDispatcher
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -172,6 +175,7 @@ class RescheduleViewModel @Inject constructor(
|
||||
private val repository: CalendarRepository,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
private val zoneSource: ZoneSource,
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _scopePrompt = MutableStateFlow<MoveScopePrompt?>(null)
|
||||
@@ -457,7 +461,9 @@ class RescheduleViewModel @Inject constructor(
|
||||
dropped
|
||||
}
|
||||
|
||||
// Read fixed-time reminders out, so the move keeps them on their wall clock (#459).
|
||||
val original = detail.toEditForm(request.beginMillis, request.endMillis, zone)
|
||||
.withFixedTimeRemindersRead(settingsPrefs.knownFixedTimeReminders.first(), zone)
|
||||
val shifted = when (val target = request.target) {
|
||||
is MoveTarget.Start -> original.shiftedTo(target.instant, zone)
|
||||
is MoveTarget.ByDays -> original.shiftedByDays(target.days, zone)
|
||||
|
||||
@@ -108,6 +108,7 @@ import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||
import de.jeanlucmakiola.calendula.ui.common.withRepeatedHourZone
|
||||
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
||||
import de.jeanlucmakiola.calendula.ui.common.fixedTimeReminderLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.TimeZone
|
||||
@@ -532,7 +533,8 @@ private fun EventDetailContent(
|
||||
.sortedBy { it.minutes }
|
||||
.forEach { reminder ->
|
||||
Text(
|
||||
text = reminderLeadTimeLabel(reminder.minutes),
|
||||
text = state.fixedReminders[reminder.minutes]?.let { fixedTimeReminderLabel(it) }
|
||||
?: reminderLeadTimeLabel(reminder.minutes),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.ui.detail
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.RichText
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.FixedTimeReminder
|
||||
|
||||
/**
|
||||
* UI state for the event-detail screen (spec S4).
|
||||
@@ -26,6 +27,10 @@ sealed interface EventDetailUiState {
|
||||
val phoneLinks: EventPhoneLinks = EventPhoneLinks(),
|
||||
/** The description as drawn — HTML rendered (#271); null when there is none. */
|
||||
val description: RichText? = null,
|
||||
/** Reminder offsets that read as "N days before at HH:MM" (#459), by raw minutes. */
|
||||
val fixedReminders: Map<Int, FixedTimeReminder> = emptyMap(),
|
||||
/** The fixed-time patterns the reminders were read against. */
|
||||
val knownFixed: Set<FixedTimeReminder> = emptySet(),
|
||||
) : EventDetailUiState
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package de.jeanlucmakiola.calendula.ui.detail
|
||||
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.classifyTimedReminder
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
@@ -46,6 +48,7 @@ class EventDetailViewModel @Inject constructor(
|
||||
private val icsExporter: IcsExporter,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
private val zoneSource: ZoneSource,
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _target = MutableStateFlow<Target?>(null)
|
||||
@@ -142,7 +145,7 @@ class EventDetailViewModel @Inject constructor(
|
||||
*/
|
||||
fun duplicateForm(): EventForm? {
|
||||
val loaded = state.value as? EventDetailUiState.Success ?: return null
|
||||
return loaded.detail.toDuplicateForm(loaded.canModify, zoneSource.current())
|
||||
return loaded.detail.toDuplicateForm(loaded.canModify, zoneSource.current(), loaded.knownFixed)
|
||||
}
|
||||
|
||||
private suspend fun loadDetail(target: Target): EventDetailUiState = try {
|
||||
@@ -157,6 +160,17 @@ class EventDetailViewModel @Inject constructor(
|
||||
}
|
||||
val calendar = repository.calendars().first()
|
||||
.firstOrNull { it.id == corrected.instance.calendarId }
|
||||
val knownFixed = settingsPrefs.knownFixedTimeReminders.first()
|
||||
val fixedReminders = if (corrected.instance.isAllDay) {
|
||||
emptyMap()
|
||||
} else {
|
||||
val zone = zoneSource.current()
|
||||
corrected.reminders.map { it.minutes }.distinct().mapNotNull { minutes ->
|
||||
classifyTimedReminder(
|
||||
minutes, corrected.instance.start, zone, knownFixed, wallClock = corrected.rrule != null,
|
||||
)?.let { minutes to it }
|
||||
}.toMap()
|
||||
}
|
||||
val description = corrected.description
|
||||
?.let(::descriptionRichText)
|
||||
?.takeIf { it.text.isNotBlank() }
|
||||
@@ -173,6 +187,8 @@ class EventDetailViewModel @Inject constructor(
|
||||
description = description?.text,
|
||||
),
|
||||
description = description,
|
||||
fixedReminders = fixedReminders,
|
||||
knownFixed = knownFixed,
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
|
||||
@@ -125,6 +125,10 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventAttendee
|
||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.FixedTimeReminder
|
||||
import de.jeanlucmakiola.calendula.ui.common.DEFAULT_FIXED_TIME_REMINDER
|
||||
import de.jeanlucmakiola.calendula.ui.common.FixedTimeReminderEditor
|
||||
import de.jeanlucmakiola.calendula.ui.common.fixedTimeReminderLabel
|
||||
import de.jeanlucmakiola.calendula.domain.startInstant
|
||||
import de.jeanlucmakiola.calendula.domain.endInstant
|
||||
import de.jeanlucmakiola.calendula.domain.resolvedZone
|
||||
@@ -153,6 +157,7 @@ import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.floret.components.DialogAmountField
|
||||
import de.jeanlucmakiola.floret.components.GroupedHint
|
||||
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
|
||||
@@ -875,7 +880,9 @@ private fun EventEditContent(
|
||||
Spacer(Modifier.height(gap))
|
||||
RemindersSection(
|
||||
reminders = form.reminders,
|
||||
fixedReminders = if (form.isAllDay) emptyList() else form.fixedReminders,
|
||||
onRemove = viewModel::removeReminder,
|
||||
onRemoveFixed = viewModel::removeFixedReminder,
|
||||
onAdd = { sheet = EditSheet.Reminder },
|
||||
)
|
||||
}
|
||||
@@ -1284,9 +1291,25 @@ private fun LocationCard(
|
||||
* connected corners, the last row being "Add reminder".
|
||||
*/
|
||||
@Composable
|
||||
private fun RemindersSection(reminders: List<Int>, onRemove: (Int) -> Unit, onAdd: () -> Unit) {
|
||||
val rows = reminders.size + 1
|
||||
reminders.forEachIndexed { index, minutes ->
|
||||
private fun RemindersSection(
|
||||
reminders: List<Int>,
|
||||
fixedReminders: List<FixedTimeReminder>,
|
||||
onRemove: (Int) -> Unit,
|
||||
onRemoveFixed: (FixedTimeReminder) -> Unit,
|
||||
onAdd: () -> Unit,
|
||||
) {
|
||||
val rows = fixedReminders.size + reminders.size + 1
|
||||
fixedReminders.forEachIndexed { index, reminder ->
|
||||
GroupedItemCard(
|
||||
position = positionOf(index, rows),
|
||||
icon = Icons.Default.Notifications,
|
||||
title = fixedTimeReminderLabel(reminder),
|
||||
removeContentDescription = stringResource(R.string.event_edit_remove_reminder),
|
||||
onRemove = { onRemoveFixed(reminder) },
|
||||
)
|
||||
}
|
||||
reminders.forEachIndexed { i, minutes ->
|
||||
val index = fixedReminders.size + i
|
||||
GroupedItemCard(
|
||||
position = positionOf(index, rows),
|
||||
icon = Icons.Default.Notifications,
|
||||
@@ -1297,7 +1320,7 @@ private fun RemindersSection(reminders: List<Int>, onRemove: (Int) -> Unit, onAd
|
||||
}
|
||||
AddActionCard(
|
||||
label = stringResource(R.string.event_edit_add_reminder),
|
||||
position = positionOf(reminders.size, rows),
|
||||
position = positionOf(rows - 1, rows),
|
||||
onClick = onAdd,
|
||||
)
|
||||
}
|
||||
@@ -1489,10 +1512,15 @@ private fun EditSheetHost(
|
||||
}
|
||||
EditSheet.Reminder -> ReminderPickerDialog(
|
||||
alreadyChosen = form.reminders,
|
||||
offerFixedTime = !form.isAllDay,
|
||||
onSelect = { minutes ->
|
||||
viewModel.addReminder(minutes)
|
||||
onClose()
|
||||
},
|
||||
onSelectFixed = { reminder ->
|
||||
viewModel.addFixedReminder(reminder)
|
||||
onClose()
|
||||
},
|
||||
onDismiss = onClose,
|
||||
)
|
||||
EditSheet.Recurrence -> RecurrencePickerDialog(
|
||||
@@ -1581,10 +1609,14 @@ private fun FieldPickerDialog(
|
||||
@Composable
|
||||
private fun ReminderPickerDialog(
|
||||
alreadyChosen: List<Int>,
|
||||
offerFixedTime: Boolean,
|
||||
onSelect: (Int) -> Unit,
|
||||
onSelectFixed: (FixedTimeReminder) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var customMode by rememberSaveable { mutableStateOf(false) }
|
||||
var fixedMode by rememberSaveable { mutableStateOf(false) }
|
||||
var pendingFixed by remember { mutableStateOf<FixedTimeReminder?>(null) }
|
||||
var amountText by rememberSaveable { mutableStateOf("") }
|
||||
var unit by rememberSaveable { mutableStateOf(ReminderUnit.Minutes) }
|
||||
val customMinutes = amountText.toIntOrNull()
|
||||
@@ -1595,18 +1627,31 @@ private fun ReminderPickerDialog(
|
||||
title = stringResource(R.string.event_edit_add_reminder),
|
||||
onDismiss = onDismiss,
|
||||
actions = {
|
||||
// The quick-pick list adds on tap; only the custom step needs Add.
|
||||
// The quick-pick list adds on tap; only the custom steps need Add.
|
||||
if (customMode) {
|
||||
TextButton(
|
||||
enabled = customMinutes != null && customMinutes !in alreadyChosen,
|
||||
onClick = { customMinutes?.let(onSelect) },
|
||||
) { Text(stringResource(R.string.event_edit_add)) }
|
||||
} else if (fixedMode) {
|
||||
TextButton(
|
||||
enabled = pendingFixed != null,
|
||||
onClick = { pendingFixed?.let(onSelectFixed) },
|
||||
) { Text(stringResource(R.string.event_edit_add)) }
|
||||
}
|
||||
},
|
||||
) {
|
||||
if (!customMode) {
|
||||
if (fixedMode) {
|
||||
GroupedHint(text = stringResource(R.string.event_edit_reminder_fixed_hint))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
FixedTimeReminderEditor(
|
||||
initial = DEFAULT_FIXED_TIME_REMINDER,
|
||||
onValueChange = { pendingFixed = it },
|
||||
connected = false,
|
||||
)
|
||||
} else if (!customMode) {
|
||||
val presets = REMINDER_PRESETS.filterNot { it in alreadyChosen }
|
||||
val rowCount = presets.size + 1
|
||||
val rowCount = presets.size + if (offerFixedTime) 2 else 1
|
||||
presets.forEachIndexed { index, minutes ->
|
||||
GroupedRow(
|
||||
title = reminderLeadTimeLabel(minutes),
|
||||
@@ -1614,6 +1659,14 @@ private fun ReminderPickerDialog(
|
||||
onClick = { onSelect(minutes) },
|
||||
)
|
||||
}
|
||||
if (offerFixedTime) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.reminder_fixed_at_set_time),
|
||||
summary = stringResource(R.string.event_edit_reminder_fixed_summary),
|
||||
position = positionOf(presets.size, rowCount),
|
||||
onClick = { fixedMode = true },
|
||||
)
|
||||
}
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.event_edit_reminder_custom),
|
||||
position = positionOf(rowCount - 1, rowCount),
|
||||
|
||||
@@ -13,7 +13,12 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.time.ZoneSource
|
||||
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultEventDuration
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultFixedTimeReminder
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.FIXED_ORDER
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.FixedTimeReminder
|
||||
import de.jeanlucmakiola.calendula.domain.fixedReminderOffsets
|
||||
import de.jeanlucmakiola.calendula.domain.withFixedTimeRemindersRead
|
||||
import de.jeanlucmakiola.calendula.domain.canSaveApartFromSeries
|
||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||
import de.jeanlucmakiola.calendula.domain.Availability
|
||||
@@ -139,6 +144,7 @@ enum class ImportSource(internal val ownsDuration: Boolean) {
|
||||
data class ImportReminderPrompt(
|
||||
val currentReminderCount: Int,
|
||||
val defaultReminders: List<Int>,
|
||||
val defaultFixedReminders: List<FixedTimeReminder> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -208,6 +214,8 @@ class EventEditViewModel @Inject constructor(
|
||||
val zone: TimeZone,
|
||||
/** The event as loaded, on the tapped occurrence's times — what Share exports. */
|
||||
val detail: EventDetail,
|
||||
/** The fixed-time patterns its reminders were read against, so a re-read splits alike. */
|
||||
val knownFixed: Set<FixedTimeReminder>,
|
||||
) {
|
||||
val original: EventForm get() = snapshot.form
|
||||
}
|
||||
@@ -225,7 +233,13 @@ class EventEditViewModel @Inject constructor(
|
||||
val allDay: List<Int>,
|
||||
val timedOverrides: Map<Long, List<Int>>,
|
||||
val allDayOverrides: Map<Long, List<Int>>,
|
||||
val fixed: FixedTimeReminder?,
|
||||
val fixedOverrides: Map<Long, FixedTimeReminder?>,
|
||||
) {
|
||||
/** The fixed-time reminders a new event on [calendarId] gets (#459); none on an all-day one. */
|
||||
fun resolveFixedFor(calendarId: Long?, isAllDay: Boolean): List<FixedTimeReminder> =
|
||||
if (isAllDay) emptyList() else listOfNotNull(resolveDefaultFixedTimeReminder(fixed, fixedOverrides, calendarId))
|
||||
|
||||
/** The default reminders for an event on [calendarId] of the given kind. */
|
||||
fun resolveFor(calendarId: Long?, isAllDay: Boolean): List<Int> = resolveDefaultReminder(
|
||||
timedGlobal = timed,
|
||||
@@ -435,7 +449,7 @@ class EventEditViewModel @Inject constructor(
|
||||
if (source.ownsDuration) _durationTouched.value = true else applyDefaultDuration()
|
||||
when (source) {
|
||||
ImportSource.Insert, ImportSource.InsertOpenEnded ->
|
||||
if (form.reminders.isNotEmpty()) _remindersTouched.value = true
|
||||
if (form.reminders.isNotEmpty() || form.fixedReminders.isNotEmpty()) _remindersTouched.value = true
|
||||
else applyDefaultReminder()
|
||||
|
||||
ImportSource.File -> {
|
||||
@@ -465,27 +479,35 @@ class EventEditViewModel @Inject constructor(
|
||||
val form = _form.value ?: return@launch
|
||||
if (_editTarget.value != null || _remindersTouched.value) return@launch
|
||||
val reminders = defaults.resolveFor(targetId, form.isAllDay)
|
||||
val fixed = defaults.resolveFixedFor(targetId, form.isAllDay)
|
||||
// The duration default resolves in parallel, so both must compose
|
||||
// onto the current form, not a pre-suspend copy.
|
||||
applyDefault { it.copy(reminders = reminders) }
|
||||
applyDefault { it.copy(reminders = reminders, fixedReminders = fixed) }
|
||||
// Surface the section so an auto-applied default is visible and
|
||||
// removable, even when Reminders isn't a default-shown field.
|
||||
if (reminders.isNotEmpty()) {
|
||||
if (reminders.isNotEmpty() || fixed.isNotEmpty()) {
|
||||
_revealed.value = _revealed.value + EventFormField.Reminders
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Snapshot the four settings-default reminder flows into one value. */
|
||||
/** Snapshot the settings-default reminder flows into one value. */
|
||||
private suspend fun reminderDefaults(): ReminderDefaults = combine(
|
||||
settingsPrefs.defaultReminderMinutes,
|
||||
settingsPrefs.defaultAllDayReminderMinutes,
|
||||
settingsPrefs.perCalendarReminderOverride,
|
||||
settingsPrefs.perCalendarAllDayReminderOverride,
|
||||
) { timed, allDay, timedOv, allDayOv ->
|
||||
ReminderDefaults(timed, allDay, timedOv, allDayOv)
|
||||
combine(
|
||||
settingsPrefs.defaultReminderMinutes,
|
||||
settingsPrefs.defaultAllDayReminderMinutes,
|
||||
settingsPrefs.perCalendarReminderOverride,
|
||||
settingsPrefs.perCalendarAllDayReminderOverride,
|
||||
::Quadruple,
|
||||
),
|
||||
settingsPrefs.defaultFixedTimeReminder,
|
||||
settingsPrefs.perCalendarFixedTimeReminderOverride,
|
||||
) { (timed, allDay, timedOv, allDayOv), fixed, fixedOv ->
|
||||
ReminderDefaults(timed, allDay, timedOv, allDayOv, fixed, fixedOv)
|
||||
}.first()
|
||||
|
||||
private data class Quadruple<A, B, C, D>(val a: A, val b: B, val c: C, val d: D)
|
||||
|
||||
/**
|
||||
* Stretch a new timed event to the configured default length (#54) — the
|
||||
* resolved calendar's per-calendar duration, otherwise the global default —
|
||||
@@ -531,15 +553,19 @@ class EventEditViewModel @Inject constructor(
|
||||
val opened = session
|
||||
viewModelScope.launch {
|
||||
val targetId = resolvedCalendarId.first()
|
||||
val default = reminderDefaults().resolveFor(targetId, form.isAllDay)
|
||||
val defaults = reminderDefaults()
|
||||
val default = defaults.resolveFor(targetId, form.isAllDay)
|
||||
val fixed = defaults.resolveFixedFor(targetId, form.isAllDay)
|
||||
// Bail if the form closed or became an edit while we resolved.
|
||||
if (opened != session) return@launch
|
||||
val current = _form.value ?: return@launch
|
||||
if (_editTarget.value != null) return@launch
|
||||
if (default.isEmpty() || default == current.reminders) return@launch
|
||||
if (default.isEmpty() && fixed.isEmpty()) return@launch
|
||||
if (default == current.reminders && fixed == current.fixedReminders) return@launch
|
||||
_importReminderPrompt.value = ImportReminderPrompt(
|
||||
currentReminderCount = current.reminders.size,
|
||||
currentReminderCount = current.reminders.size + current.fixedReminders.size,
|
||||
defaultReminders = default,
|
||||
defaultFixedReminders = fixed,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -549,7 +575,7 @@ class EventEditViewModel @Inject constructor(
|
||||
val prompt = _importReminderPrompt.value ?: return
|
||||
_importReminderPrompt.value = null
|
||||
// Already frozen as touched by openImported; this just swaps the values.
|
||||
edit { it.copy(reminders = prompt.defaultReminders) }
|
||||
edit { it.copy(reminders = prompt.defaultReminders, fixedReminders = prompt.defaultFixedReminders) }
|
||||
_revealed.value = _revealed.value + EventFormField.Reminders
|
||||
}
|
||||
|
||||
@@ -579,14 +605,15 @@ class EventEditViewModel @Inject constructor(
|
||||
if (opened == session) _loadFailed.value = true
|
||||
return@launch
|
||||
}
|
||||
val knownFixed = settingsPrefs.knownFixedTimeReminders.first()
|
||||
if (opened != session) return@launch
|
||||
val zone = zoneSource.current()
|
||||
val begin = beginMillis.takeUnless { it == NO_OCCURRENCE_TIME }
|
||||
?: detail.instance.start.toEpochMilliseconds()
|
||||
val end = endMillis.takeUnless { it == NO_OCCURRENCE_TIME }
|
||||
?: detail.instance.end.toEpochMilliseconds()
|
||||
val snapshot = detail.toEditSnapshot(begin, end, zone)
|
||||
_editTarget.value = EditTarget(eventId, snapshot, begin, end, zone, detail.atOccurrence(begin, end))
|
||||
val snapshot = detail.toEditSnapshot(begin, end, zone).withFixedTimeRemindersRead(knownFixed, zone)
|
||||
_editTarget.value = EditTarget(eventId, snapshot, begin, end, zone, detail.atOccurrence(begin, end), knownFixed)
|
||||
// Sections holding data must show even when not in the defaults.
|
||||
_revealed.value = snapshot.form.populatedFields()
|
||||
open(snapshot.form)
|
||||
@@ -691,7 +718,11 @@ class EventEditViewModel @Inject constructor(
|
||||
|
||||
fun addReminder(minutes: Int) {
|
||||
_remindersTouched.value = true
|
||||
edit { it.copy(reminders = (it.reminders + minutes).distinct().sorted()) }
|
||||
edit {
|
||||
// A lead firing with a set-time reminder would be one row once saved.
|
||||
if (minutes in it.fixedReminderOffsets(zoneSource.current())) it
|
||||
else it.copy(reminders = (it.reminders + minutes).distinct().sorted())
|
||||
}
|
||||
}
|
||||
|
||||
fun removeReminder(minutes: Int) {
|
||||
@@ -699,6 +730,26 @@ class EventEditViewModel @Inject constructor(
|
||||
edit { it.copy(reminders = it.reminders - minutes) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an "N days before at HH:MM" reminder (#459); timed events only. A lead
|
||||
* time landing on the same moment is replaced by it — the two would be one
|
||||
* row once saved.
|
||||
*/
|
||||
fun addFixedReminder(reminder: FixedTimeReminder) {
|
||||
_remindersTouched.value = true
|
||||
edit { form ->
|
||||
if (form.isAllDay) return@edit form
|
||||
val withFixed = form.copy(fixedReminders = (form.fixedReminders + reminder).distinct().sortedWith(FIXED_ORDER))
|
||||
val offsets = withFixed.fixedReminderOffsets(zoneSource.current()).toSet()
|
||||
withFixed.copy(reminders = form.reminders.filterNot { it in offsets })
|
||||
}
|
||||
}
|
||||
|
||||
fun removeFixedReminder(reminder: FixedTimeReminder) {
|
||||
_remindersTouched.value = true
|
||||
edit { it.copy(fixedReminders = it.fixedReminders - reminder) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a guest, keyed on a trimmed lower-cased email so re-adding the same
|
||||
* address just refreshes it (no duplicate row). A blank email is ignored —
|
||||
@@ -815,6 +866,7 @@ class EventEditViewModel @Inject constructor(
|
||||
val fresh = try {
|
||||
repository.eventDetail(target.eventId)
|
||||
.toEditSnapshot(target.beginMillis, target.endMillis, target.zone)
|
||||
.withFixedTimeRemindersRead(target.knownFixed, target.zone)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: NoSuchEventException) {
|
||||
@@ -829,6 +881,17 @@ class EventEditViewModel @Inject constructor(
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
// Remembered before the write, so the rows read back as these
|
||||
// reminders from the first re-read on (#459).
|
||||
if (!form.isAllDay && form.fixedReminders.isNotEmpty()) {
|
||||
try {
|
||||
settingsPrefs.rememberFixedTimeReminders(form.fixedReminders)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not remember the set-time reminders", e)
|
||||
}
|
||||
}
|
||||
val result = try {
|
||||
if (target == null) {
|
||||
repository.createEvent(form)
|
||||
@@ -950,3 +1013,7 @@ class EventEditViewModel @Inject constructor(
|
||||
_form.value = _form.value?.let(block)
|
||||
}
|
||||
}
|
||||
|
||||
/** [EditSnapshot.form] with its timed reminders read back into lead times and fixed-time ones. */
|
||||
private fun EditSnapshot.withFixedTimeRemindersRead(known: Set<FixedTimeReminder>, zone: TimeZone): EditSnapshot =
|
||||
copy(form = form.withFixedTimeRemindersRead(known, zone))
|
||||
+352
-42
@@ -9,19 +9,28 @@ import android.provider.Settings
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
||||
import androidx.compose.material.icons.outlined.Info
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -29,8 +38,18 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.FixedTimeOverride
|
||||
import de.jeanlucmakiola.calendula.data.prefs.fixedTimeOverrideFor
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.DailyDigestSettings
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.DigestDay
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.FixedTimeReminder
|
||||
import de.jeanlucmakiola.calendula.ui.common.FixedTimeReminderPicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.fixedTimeReminderLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.ui.common.DurationPicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
||||
@@ -39,10 +58,13 @@ import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
||||
import de.jeanlucmakiola.calendula.ui.common.durationLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
|
||||
import de.jeanlucmakiola.floret.android.hasPermission
|
||||
import de.jeanlucmakiola.floret.android.openNotificationSettings
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.ExpandIcon
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.GroupedSwitchRow
|
||||
import de.jeanlucmakiola.floret.components.IconInfoCard
|
||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||
@@ -50,6 +72,7 @@ import de.jeanlucmakiola.floret.identity.expandEnter
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
import de.jeanlucmakiola.floret.reminders.minutesOrEmpty
|
||||
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.LocalTime
|
||||
|
||||
/**
|
||||
@@ -69,8 +92,9 @@ internal fun NotificationsScreen(
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
) { /* The pref is already on; a denial just leaves the OS gate shut. */ }
|
||||
val toggleReminders: (Boolean) -> Unit = { enabled ->
|
||||
viewModel.setRemindersEnabled(enabled)
|
||||
// Turning either kind of notification on asks for the permission if missing.
|
||||
fun askingPermission(set: (Boolean) -> Unit): (Boolean) -> Unit = { enabled ->
|
||||
set(enabled)
|
||||
val needsPermission = enabled &&
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
!context.hasPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||
@@ -78,14 +102,31 @@ internal fun NotificationsScreen(
|
||||
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
}
|
||||
val toggleReminders = askingPermission(viewModel::setRemindersEnabled)
|
||||
val toggleDigest = askingPermission(viewModel::setDailyDigestEnabled)
|
||||
|
||||
var showDefaultReminder by remember { mutableStateOf(false) }
|
||||
var showAllDayReminder by remember { mutableStateOf(false) }
|
||||
var showAllDayReminderTime by remember { mutableStateOf(false) }
|
||||
var showSnooze by remember { mutableStateOf(false) }
|
||||
var showFixedDefault by remember { mutableStateOf(false) }
|
||||
var fixedOverrideFor by remember { mutableStateOf<Long?>(null) }
|
||||
var showDigestDay by remember { mutableStateOf(false) }
|
||||
var showDigestTime by remember { mutableStateOf(false) }
|
||||
var overrideDialog by remember { mutableStateOf<OverrideTarget?>(null) }
|
||||
var calendarSectionExpanded by remember { mutableStateOf(false) }
|
||||
var expandedCalendars by remember { mutableStateOf(emptySet<Long>()) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val channelStatus by viewModel.channelStatus.collectAsStateWithLifecycle()
|
||||
val digestBlocked by viewModel.digestBlocked.collectAsStateWithLifecycle()
|
||||
val calendarIds = state.reminderCalendars.map { it.id }
|
||||
LifecycleResumeEffect(calendarIds) {
|
||||
viewModel.refreshChannelStatus(calendarIds)
|
||||
onPauseOrDispose { }
|
||||
}
|
||||
val openCalendarSound: (Long) -> Unit = { calendarId ->
|
||||
scope.launch { context.openNotificationSettings(viewModel.soundSettingsChannel(calendarId)) }
|
||||
}
|
||||
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.settings_section_notifications),
|
||||
@@ -93,6 +134,21 @@ internal fun NotificationsScreen(
|
||||
predictiveBack = true,
|
||||
visible = visible,
|
||||
) {
|
||||
if (IS_SAMSUNG && !state.notificationCategoryHintDismissed) {
|
||||
NotificationCategoryHint(
|
||||
onOpen = {
|
||||
viewModel.dismissNotificationCategoryHint()
|
||||
scope.launch {
|
||||
viewModel.syncReminderChannels()
|
||||
// The summary's category too, so it is on the list to check.
|
||||
if (state.dailyDigest.enabled) viewModel.digestChannel()
|
||||
context.openNotificationSettings()
|
||||
}
|
||||
},
|
||||
onDismiss = viewModel::dismissNotificationCategoryHint,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
GroupedSwitchRow(
|
||||
title = stringResource(R.string.settings_reminders),
|
||||
checked = state.remindersEnabled,
|
||||
@@ -100,12 +156,34 @@ internal fun NotificationsScreen(
|
||||
summary = stringResource(R.string.settings_reminders_hint),
|
||||
position = Position.Top,
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_notification_sound),
|
||||
summary = if (channelStatus.appBlocked) {
|
||||
stringResource(R.string.settings_notifications_blocked_hint)
|
||||
} else {
|
||||
stringResource(R.string.settings_notification_sound_hint)
|
||||
},
|
||||
position = Position.Middle,
|
||||
trailing = { ExternalLinkIcon() },
|
||||
onClick = {
|
||||
scope.launch {
|
||||
viewModel.syncReminderChannels()
|
||||
context.openNotificationSettings()
|
||||
}
|
||||
},
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_default_reminder),
|
||||
summary = reminderChoiceLabel(state.defaultReminderMinutes),
|
||||
position = Position.Middle,
|
||||
onClick = { showDefaultReminder = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_fixed_reminder),
|
||||
summary = fixedReminderChoiceLabel(state.defaultFixedTimeReminder),
|
||||
position = Position.Middle,
|
||||
onClick = { showFixedDefault = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_default_reminder_allday),
|
||||
summary = reminderChoiceLabel(state.defaultAllDayReminderMinutes),
|
||||
@@ -122,6 +200,21 @@ internal fun NotificationsScreen(
|
||||
onClick = { showAllDayReminderTime = true },
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
DailyDigestRows(
|
||||
digest = state.dailyDigest,
|
||||
blocked = channelStatus.appBlocked || digestBlocked,
|
||||
onEnable = toggleDigest,
|
||||
onPickDay = { showDigestDay = true },
|
||||
onPickTime = { showDigestTime = true },
|
||||
onOpenSound = {
|
||||
// The app's own page while the app is blocked: the channel's would be greyed out.
|
||||
scope.launch {
|
||||
context.openNotificationSettings(viewModel.digestChannel().takeUnless { channelStatus.appBlocked })
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Reliability is a soft, optional battery-optimisation exemption via
|
||||
// a system-settings deep-link, shown as live status.
|
||||
Spacer(Modifier.height(24.dp))
|
||||
@@ -149,10 +242,8 @@ internal fun NotificationsScreen(
|
||||
onClick = { showSnooze = true },
|
||||
)
|
||||
|
||||
// Per-calendar overrides, folded behind one header. Each writable
|
||||
// calendar may keep, drop or replace the global default, separately for
|
||||
// timed and all-day events.
|
||||
if (state.writableCalendars.isNotEmpty()) {
|
||||
// Per-calendar notifications and reminder overrides (#145, #272).
|
||||
if (state.reminderCalendars.isNotEmpty()) {
|
||||
Spacer(Modifier.height(24.dp))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_calendar_reminders_title),
|
||||
@@ -167,30 +258,22 @@ internal fun NotificationsScreen(
|
||||
exit = collapseExit(),
|
||||
) {
|
||||
Column {
|
||||
state.writableCalendars.forEach { calendar ->
|
||||
state.reminderCalendars.forEach { calendar ->
|
||||
Spacer(Modifier.height(16.dp))
|
||||
// Special-dates calendars own their reminders in their
|
||||
// own section — link there instead.
|
||||
if (calendar.id in state.managedCalendarIds) {
|
||||
GroupedRow(
|
||||
title = calendar.displayName.orUnnamed(),
|
||||
summary = stringResource(R.string.settings_calendar_reminders_managed_hint),
|
||||
position = Position.Alone,
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
trailing = {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
onClick = onOpenSpecialDates,
|
||||
)
|
||||
return@forEach
|
||||
}
|
||||
val expanded = calendar.id in expandedCalendars
|
||||
val notifying = calendar.id !in state.mutedReminderCalendarIds
|
||||
val blocked = notifying && calendar.id in channelStatus.blockedCalendarIds
|
||||
GroupedRow(
|
||||
title = calendar.displayName.orUnnamed(),
|
||||
summary = when {
|
||||
// Still in the summary, so only its reminders are off.
|
||||
!notifying && state.dailyDigest.enabled &&
|
||||
calendar.id !in state.dailyDigest.excludedCalendarIds ->
|
||||
stringResource(R.string.settings_calendar_reminders_off)
|
||||
!notifying -> stringResource(R.string.settings_calendar_notifications_off)
|
||||
blocked -> stringResource(R.string.settings_notifications_blocked)
|
||||
else -> null
|
||||
},
|
||||
position = if (expanded) Position.Top else Position.Alone,
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
trailing = { ExpandIcon(expanded) },
|
||||
@@ -207,22 +290,18 @@ internal fun NotificationsScreen(
|
||||
enter = expandEnter(),
|
||||
exit = collapseExit(),
|
||||
) {
|
||||
Column {
|
||||
val timed = state.perCalendarReminderOverride.reminderOverrideFor(calendar.id)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_default_reminder),
|
||||
summary = calendarOverrideSummary(timed, state.defaultReminderMinutes),
|
||||
position = Position.Middle,
|
||||
onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = false) },
|
||||
)
|
||||
val allDay = state.perCalendarAllDayReminderOverride.reminderOverrideFor(calendar.id)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_default_reminder_allday),
|
||||
summary = calendarOverrideSummary(allDay, state.defaultAllDayReminderMinutes),
|
||||
position = Position.Bottom,
|
||||
onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = true) },
|
||||
)
|
||||
}
|
||||
CalendarNotificationRows(
|
||||
calendar = calendar,
|
||||
state = state,
|
||||
notifying = notifying,
|
||||
blocked = blocked,
|
||||
onSetNotifying = { viewModel.setCalendarRemindersEnabled(calendar.id, it) },
|
||||
onOpenSound = { openCalendarSound(calendar.id) },
|
||||
onOpenSpecialDates = onOpenSpecialDates,
|
||||
onEditOverride = { overrideDialog = it },
|
||||
onEditFixedOverride = { fixedOverrideFor = calendar.id },
|
||||
onSetInDigest = { viewModel.setCalendarInDailyDigest(calendar.id, it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -230,6 +309,44 @@ internal fun NotificationsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (showFixedDefault) {
|
||||
FixedTimeReminderPicker(
|
||||
title = stringResource(R.string.settings_fixed_reminder),
|
||||
selected = state.defaultFixedTimeReminder?.let { FixedTimeOverride.At(it) } ?: FixedTimeOverride.Off,
|
||||
onSelect = { viewModel.setDefaultFixedTimeReminder((it as? FixedTimeOverride.At)?.reminder) },
|
||||
onDismiss = { showFixedDefault = false },
|
||||
)
|
||||
}
|
||||
fixedOverrideFor?.let { calendarId ->
|
||||
FixedTimeReminderPicker(
|
||||
title = stringResource(R.string.settings_fixed_reminder),
|
||||
selected = state.perCalendarFixedTimeReminderOverride.fixedTimeOverrideFor(calendarId),
|
||||
onSelect = { viewModel.setCalendarFixedTimeReminderOverride(calendarId, it) },
|
||||
onDismiss = { fixedOverrideFor = null },
|
||||
inheritedLabel = fixedReminderChoiceLabel(state.defaultFixedTimeReminder),
|
||||
inherited = state.defaultFixedTimeReminder,
|
||||
)
|
||||
}
|
||||
if (showDigestDay) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_digest_day),
|
||||
options = DigestDay.entries,
|
||||
selected = state.dailyDigest.day,
|
||||
label = { stringResource(digestDayLabel(it)) },
|
||||
onSelect = viewModel::setDailyDigestDay,
|
||||
onDismiss = { showDigestDay = false },
|
||||
)
|
||||
}
|
||||
if (showDigestTime) {
|
||||
TimePickerAlert(
|
||||
initial = LocalTime(state.dailyDigest.timeMinutes / 60, state.dailyDigest.timeMinutes % 60),
|
||||
onConfirm = {
|
||||
viewModel.setDailyDigestTimeMinutes(it.hour * 60 + it.minute)
|
||||
showDigestTime = false
|
||||
},
|
||||
onDismiss = { showDigestTime = false },
|
||||
)
|
||||
}
|
||||
if (showSnooze) {
|
||||
DurationPicker(
|
||||
title = stringResource(R.string.settings_snooze_duration),
|
||||
@@ -309,6 +426,199 @@ internal fun NotificationsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One calendar's expanded rows: its notification switch and channel deep-link
|
||||
* for every calendar, then the default-reminder overrides where it takes them —
|
||||
* or, for a special-dates calendar, a link to the section that owns them.
|
||||
*/
|
||||
@Composable
|
||||
private fun CalendarNotificationRows(
|
||||
calendar: CalendarSource,
|
||||
state: SettingsUiState,
|
||||
notifying: Boolean,
|
||||
blocked: Boolean,
|
||||
onSetNotifying: (Boolean) -> Unit,
|
||||
onOpenSound: () -> Unit,
|
||||
onOpenSpecialDates: () -> Unit,
|
||||
onEditOverride: (OverrideTarget) -> Unit,
|
||||
onEditFixedOverride: () -> Unit,
|
||||
onSetInDigest: (Boolean) -> Unit,
|
||||
) {
|
||||
val managed = calendar.id in state.managedCalendarIds
|
||||
val takesOverrides = calendar.canModifyContents && !managed
|
||||
val digestOn = state.dailyDigest.enabled
|
||||
Column {
|
||||
GroupedSwitchRow(
|
||||
title = stringResource(R.string.settings_calendar_notifications),
|
||||
checked = notifying,
|
||||
onCheckedChange = onSetNotifying,
|
||||
summary = stringResource(R.string.settings_calendar_notifications_hint),
|
||||
position = Position.Middle,
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_notification_sound),
|
||||
summary = if (blocked) {
|
||||
stringResource(R.string.settings_notifications_blocked_hint)
|
||||
} else {
|
||||
stringResource(R.string.settings_calendar_notification_sound_hint)
|
||||
},
|
||||
position = if (managed || takesOverrides || digestOn) Position.Middle else Position.Bottom,
|
||||
trailing = { ExternalLinkIcon() },
|
||||
onClick = onOpenSound,
|
||||
)
|
||||
if (digestOn) {
|
||||
GroupedSwitchRow(
|
||||
title = stringResource(R.string.settings_calendar_in_digest),
|
||||
checked = calendar.id !in state.dailyDigest.excludedCalendarIds,
|
||||
onCheckedChange = onSetInDigest,
|
||||
summary = stringResource(R.string.settings_calendar_in_digest_hint),
|
||||
position = if (managed || takesOverrides) Position.Middle else Position.Bottom,
|
||||
)
|
||||
}
|
||||
if (managed) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_default_reminder_allday),
|
||||
summary = stringResource(R.string.settings_calendar_reminders_managed_hint),
|
||||
position = Position.Bottom,
|
||||
trailing = {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
onClick = onOpenSpecialDates,
|
||||
)
|
||||
} else if (takesOverrides) {
|
||||
val timed = state.perCalendarReminderOverride.reminderOverrideFor(calendar.id)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_default_reminder),
|
||||
summary = calendarOverrideSummary(timed, state.defaultReminderMinutes),
|
||||
position = Position.Middle,
|
||||
onClick = { onEditOverride(OverrideTarget(calendar.id, isAllDay = false)) },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_fixed_reminder),
|
||||
summary = when (val fixed = state.perCalendarFixedTimeReminderOverride.fixedTimeOverrideFor(calendar.id)) {
|
||||
FixedTimeOverride.Inherit -> stringResource(
|
||||
R.string.settings_calendar_reminder_inherits,
|
||||
fixedReminderChoiceLabel(state.defaultFixedTimeReminder),
|
||||
)
|
||||
FixedTimeOverride.Off -> stringResource(R.string.reminder_none)
|
||||
is FixedTimeOverride.At -> fixedTimeReminderLabel(fixed.reminder)
|
||||
},
|
||||
position = Position.Middle,
|
||||
onClick = onEditFixedOverride,
|
||||
)
|
||||
val allDay = state.perCalendarAllDayReminderOverride.reminderOverrideFor(calendar.id)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_default_reminder_allday),
|
||||
summary = calendarOverrideSummary(allDay, state.defaultAllDayReminderMinutes),
|
||||
position = Position.Bottom,
|
||||
onClick = { onEditOverride(OverrideTarget(calendar.id, isAllDay = true)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The daily summary (#459): its switch, and once on, which day it lists, when,
|
||||
* and its channel's system settings.
|
||||
*/
|
||||
@Composable
|
||||
private fun DailyDigestRows(
|
||||
digest: DailyDigestSettings,
|
||||
blocked: Boolean,
|
||||
onEnable: (Boolean) -> Unit,
|
||||
onPickDay: () -> Unit,
|
||||
onPickTime: () -> Unit,
|
||||
onOpenSound: () -> Unit,
|
||||
) {
|
||||
GroupedSwitchRow(
|
||||
title = stringResource(R.string.settings_digest),
|
||||
checked = digest.enabled,
|
||||
onCheckedChange = onEnable,
|
||||
summary = if (digest.enabled) {
|
||||
stringResource(
|
||||
R.string.settings_digest_summary,
|
||||
stringResource(digestDayLabel(digest.day)),
|
||||
settingsTimeOfDay(digest.timeMinutes),
|
||||
)
|
||||
} else {
|
||||
stringResource(R.string.settings_digest_hint)
|
||||
},
|
||||
position = if (digest.enabled) Position.Top else Position.Alone,
|
||||
)
|
||||
AnimatedVisibility(visible = digest.enabled, enter = expandEnter(), exit = collapseExit()) {
|
||||
Column {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_digest_day),
|
||||
summary = stringResource(digestDayLabel(digest.day)),
|
||||
position = Position.Middle,
|
||||
onClick = onPickDay,
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_digest_time),
|
||||
summary = settingsTimeOfDay(digest.timeMinutes),
|
||||
position = Position.Middle,
|
||||
onClick = onPickTime,
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_notification_sound),
|
||||
summary = if (blocked) {
|
||||
stringResource(R.string.settings_notifications_blocked_hint)
|
||||
} else {
|
||||
stringResource(R.string.settings_digest_sound_hint)
|
||||
},
|
||||
position = Position.Bottom,
|
||||
trailing = { ExternalLinkIcon() },
|
||||
onClick = onOpenSound,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Samsung's One UI can start a new notification category switched off (#145). */
|
||||
private val IS_SAMSUNG = Build.MANUFACTURER.equals("samsung", ignoreCase = true)
|
||||
|
||||
/** The one-time Samsung note, with a way into the app's notification settings. */
|
||||
@Composable
|
||||
private fun NotificationCategoryHint(onOpen: () -> Unit, onDismiss: () -> Unit) {
|
||||
IconInfoCard(
|
||||
icon = Icons.Outlined.Info,
|
||||
iconContentDescription = null,
|
||||
iconAlignment = Alignment.Top,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_notification_category_hint),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.reminder_action_dismiss)) }
|
||||
TextButton(onClick = onOpen) { Text(stringResource(R.string.settings_notification_category_hint_open)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun digestDayLabel(day: DigestDay): Int = when (day) {
|
||||
DigestDay.Today -> R.string.settings_digest_day_today
|
||||
DigestDay.Tomorrow -> R.string.settings_digest_day_tomorrow
|
||||
}
|
||||
|
||||
/** A fixed-time default's label, or "None". */
|
||||
@Composable
|
||||
private fun fixedReminderChoiceLabel(reminder: FixedTimeReminder?): String =
|
||||
reminder?.let { fixedTimeReminderLabel(it) } ?: stringResource(R.string.reminder_none)
|
||||
|
||||
@Composable
|
||||
private fun ExternalLinkIcon() {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.OpenInNew,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
/** Which calendar + event kind a per-calendar reminder-override dialog targets. */
|
||||
private data class OverrideTarget(val calendarId: Long, val isAllDay: Boolean)
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package de.jeanlucmakiola.calendula.ui.settings
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.DailyDigestSettings
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.FixedTimeReminder
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
@@ -96,8 +98,20 @@ data class SettingsUiState(
|
||||
val perCalendarReminderOverride: Map<Long, List<Int>> = emptyMap(),
|
||||
/** Per-calendar overrides of [defaultAllDayReminderMinutes] for all-day events. */
|
||||
val perCalendarAllDayReminderOverride: Map<Long, List<Int>> = emptyMap(),
|
||||
/** Writable calendars, shown as per-calendar reminder-override rows. */
|
||||
/** The "N days before at HH:MM" default for new timed events (#459); null = none. */
|
||||
val defaultFixedTimeReminder: FixedTimeReminder? = null,
|
||||
/** Per-calendar overrides of [defaultFixedTimeReminder]: absent inherits, null turns it off. */
|
||||
val perCalendarFixedTimeReminderOverride: Map<Long, FixedTimeReminder?> = emptyMap(),
|
||||
/** The daily summary notification (#459). */
|
||||
val dailyDigest: DailyDigestSettings = DailyDigestSettings(),
|
||||
/** Switched-on calendars you can edit: the per-calendar event-duration rows. */
|
||||
val writableCalendars: List<CalendarSource> = emptyList(),
|
||||
/** Switched-on calendars, each with its own notification switch and channel (#145, #272). */
|
||||
val reminderCalendars: List<CalendarSource> = emptyList(),
|
||||
/** Calendars whose reminders Calendula doesn't post (#272). */
|
||||
val mutedReminderCalendarIds: Set<Long> = emptySet(),
|
||||
/** Whether the Samsung notification-category hint has been dismissed (#145); hidden until prefs load. */
|
||||
val notificationCategoryHintDismissed: Boolean = true,
|
||||
/**
|
||||
* Whether the event-colour picker is offered on calendars that publish no
|
||||
* colour palette (the colour may then not survive their next sync).
|
||||
|
||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.ui.settings
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
@@ -18,8 +19,15 @@ import de.jeanlucmakiola.calendula.data.contacts.resolveTitleTemplate
|
||||
import de.jeanlucmakiola.calendula.data.fonts.CustomFontStore
|
||||
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
|
||||
import de.jeanlucmakiola.floret.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.prefs.FixedTimeOverride
|
||||
import de.jeanlucmakiola.calendula.data.prefs.dailyDigest
|
||||
import de.jeanlucmakiola.calendula.data.prefs.ReminderStatePrefs
|
||||
import de.jeanlucmakiola.calendula.data.reminders.DailyDigestNotifier
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.DigestDay
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.FixedTimeReminder
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsKeys
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
@@ -33,6 +41,8 @@ import de.jeanlucmakiola.calendula.data.prefs.SpecialDatesStalledReason
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderChannelStatus
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderChannels
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
@@ -45,7 +55,9 @@ import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||
import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
|
||||
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -62,11 +74,15 @@ import javax.inject.Inject
|
||||
@HiltViewModel
|
||||
class SettingsViewModel @Inject constructor(
|
||||
private val prefs: SettingsPrefs,
|
||||
calendarPrefs: CalendarPrefs,
|
||||
repository: CalendarRepository,
|
||||
private val specialDatesEngine: SpecialDatesSyncEngine,
|
||||
specialDatesSpec: SpecialDatesCalendarSpec,
|
||||
private val launcherNameManager: LauncherNameManager,
|
||||
private val reminderScanner: ReminderScanner,
|
||||
private val reminderChannels: ReminderChannels,
|
||||
private val digestNotifier: DailyDigestNotifier,
|
||||
private val reminderState: ReminderStatePrefs,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
@ApplicationContext private val appContext: Context,
|
||||
) : ViewModel() {
|
||||
@@ -74,16 +90,16 @@ class SettingsViewModel @Inject constructor(
|
||||
private val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
|
||||
/**
|
||||
* Writable calendars that are switched on — the only ones that take a
|
||||
* per-calendar reminder override, since a switched-off one plans no
|
||||
* reminders at all (#75).
|
||||
* Calendars that are switched on — the only ones that plan reminders at
|
||||
* all (#75), so the only ones with notification settings.
|
||||
*/
|
||||
private val writableCalendars: Flow<List<CalendarSource>> = repository.calendars()
|
||||
.map { calendars -> calendars.filter { it.canModifyContents && it.isVisibleInSystem } }
|
||||
.catch { emit(emptyList()) }
|
||||
private val visibleCalendars: Flow<List<CalendarSource>> =
|
||||
combine(repository.calendars(), calendarPrefs.pendingDisabledCalendarIds) { calendars, pending ->
|
||||
calendars.filter { it.isVisibleInSystem && it.id !in pending }
|
||||
}.catch { emit(emptyList()) }
|
||||
|
||||
val state: StateFlow<SettingsUiState> =
|
||||
combine(prefs.data, writableCalendars) { stored, calendars -> stored.toUiState(calendars) }
|
||||
combine(prefs.data, visibleCalendars) { stored, calendars -> stored.toUiState(calendars) }
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable),
|
||||
@@ -125,7 +141,13 @@ class SettingsViewModel @Inject constructor(
|
||||
snoozeMinutes = this[SettingsKeys.snoozeMinutes],
|
||||
perCalendarReminderOverride = this[SettingsKeys.perCalendarReminderOverride],
|
||||
perCalendarAllDayReminderOverride = this[SettingsKeys.perCalendarAllDayReminderOverride],
|
||||
writableCalendars = calendars,
|
||||
defaultFixedTimeReminder = this[SettingsKeys.defaultFixedTimeReminder],
|
||||
perCalendarFixedTimeReminderOverride = this[SettingsKeys.perCalendarFixedTimeReminderOverride],
|
||||
dailyDigest = dailyDigest,
|
||||
writableCalendars = calendars.filter { it.canModifyContents },
|
||||
reminderCalendars = calendars,
|
||||
mutedReminderCalendarIds = this[SettingsKeys.mutedReminderCalendarIds],
|
||||
notificationCategoryHintDismissed = this[SettingsKeys.notificationCategoryHintDismissed],
|
||||
allowColorOnUnsupportedCalendars = this[SettingsKeys.allowColorOnUnsupportedCalendars],
|
||||
managedCalendarIds = specialDatesCalendars.values.toSet(),
|
||||
)
|
||||
@@ -350,13 +372,73 @@ class SettingsViewModel @Inject constructor(
|
||||
* A scan follows the write: switching reminders off cancels the scan alarm,
|
||||
* so switching them back on has to arm a new one (#75).
|
||||
*/
|
||||
fun setRemindersEnabled(enabled: Boolean) {
|
||||
fun setRemindersEnabled(enabled: Boolean) = saveAndScan { setRemindersEnabled(enabled) }
|
||||
|
||||
/**
|
||||
* Re-plans, since a muted calendar armed no wake-up for its next reminder.
|
||||
* Unmuting scans once more before the write, so the watermark starts at the
|
||||
* unmute and nothing that came due while muted is replayed.
|
||||
*/
|
||||
fun setCalendarRemindersEnabled(calendarId: Long, enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
prefs.setRemindersEnabled(enabled)
|
||||
if (enabled) reminderScanner.scan()
|
||||
prefs.setCalendarRemindersEnabled(calendarId, enabled)
|
||||
reminderScanner.scan()
|
||||
}
|
||||
}
|
||||
|
||||
private val _channelStatus = MutableStateFlow(ReminderChannelStatus())
|
||||
|
||||
/** What the system blocks of the reminders; re-read with [refreshChannelStatus] (#145). */
|
||||
val channelStatus: StateFlow<ReminderChannelStatus> = _channelStatus.asStateFlow()
|
||||
|
||||
private var channelStatusJob: Job? = null
|
||||
|
||||
fun refreshChannelStatus(calendarIds: Collection<Long>) {
|
||||
channelStatusJob?.cancel()
|
||||
channelStatusJob = viewModelScope.launch {
|
||||
onChannels { status(calendarIds) }?.let { _channelStatus.value = it }
|
||||
_digestBlocked.value = withContext(io) { runCatching { digestNotifier.isChannelOff() }.getOrDefault(false) }
|
||||
}
|
||||
}
|
||||
|
||||
private val _digestBlocked = MutableStateFlow(false)
|
||||
|
||||
/**
|
||||
* Whether the daily summary's own channel is switched off (#459); the app-wide
|
||||
* block is [channelStatus]'s. Refreshed with [refreshChannelStatus].
|
||||
*/
|
||||
val digestBlocked: StateFlow<Boolean> = _digestBlocked.asStateFlow()
|
||||
|
||||
fun dismissNotificationCategoryHint() = save { dismissNotificationCategoryHint() }
|
||||
|
||||
/**
|
||||
* The channel whose system screen to open for [calendarId] (#340), created
|
||||
* if needed; null when only the app's own screen can help — the app or the
|
||||
* calendar's account group is blocked there, or the channel couldn't be made.
|
||||
*/
|
||||
suspend fun soundSettingsChannel(calendarId: Long): String? = onChannels {
|
||||
val channelId = channelFor(calendarId) ?: return@onChannels null
|
||||
val status = status(listOf(calendarId))
|
||||
channelId.takeUnless { status.appBlocked || calendarId in status.groupBlockedCalendarIds }
|
||||
}
|
||||
|
||||
/** Create the per-calendar channels before showing the app's channel list, even with reminders off. */
|
||||
suspend fun syncReminderChannels() {
|
||||
onChannels { sync() }
|
||||
}
|
||||
|
||||
private suspend fun <T> onChannels(block: ReminderChannels.() -> T): T? = withContext(io) {
|
||||
try {
|
||||
reminderChannels.block()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w("SettingsViewModel", "Reminder channel update failed", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun setAutofocusEventTitle(enabled: Boolean) = save { setAutofocusEventTitle(enabled) }
|
||||
|
||||
fun setDefaultEventDuration(minutes: Int) = save { setDefaultEventDurationMinutes(minutes) }
|
||||
@@ -369,12 +451,7 @@ class SettingsViewModel @Inject constructor(
|
||||
fun setDefaultAllDayReminderMinutes(minutes: List<Int>) = save { setDefaultAllDayReminderMinutes(minutes) }
|
||||
|
||||
/** The armed alarm was planned for the old hour, so re-plan against the new one. */
|
||||
fun setAllDayReminderTimeMinutes(minutesOfDay: Int) {
|
||||
viewModelScope.launch {
|
||||
prefs.setAllDayReminderTimeMinutes(minutesOfDay)
|
||||
reminderScanner.scan()
|
||||
}
|
||||
}
|
||||
fun setAllDayReminderTimeMinutes(minutesOfDay: Int) = saveAndScan { setAllDayReminderTimeMinutes(minutesOfDay) }
|
||||
|
||||
fun setSnoozeMinutes(minutes: Int) = save { setSnoozeMinutes(minutes) }
|
||||
|
||||
@@ -384,6 +461,53 @@ class SettingsViewModel @Inject constructor(
|
||||
|
||||
fun setAllowColorOnUnsupportedCalendars(enabled: Boolean) = save { setAllowColorOnUnsupportedCalendars(enabled) }
|
||||
|
||||
fun setDefaultFixedTimeReminder(reminder: FixedTimeReminder?) = save { setDefaultFixedTimeReminder(reminder) }
|
||||
|
||||
fun setCalendarFixedTimeReminderOverride(calendarId: Long, override: FixedTimeOverride) =
|
||||
save { setCalendarFixedTimeReminderOverride(calendarId, override) }
|
||||
|
||||
/** Every summary change re-plans, so the next one is armed for its new moment (#459). */
|
||||
fun setDailyDigestEnabled(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
prefs.setDailyDigestEnabled(enabled)
|
||||
// Created now, so a phone that starts new categories switched off
|
||||
// shows it blocked here rather than dropping the first summary.
|
||||
if (enabled) {
|
||||
digestChannel()
|
||||
_digestBlocked.value = withContext(io) { runCatching { digestNotifier.isChannelOff() }.getOrDefault(false) }
|
||||
}
|
||||
// After the scan, so one already under way can't post past the cancel;
|
||||
// a summary left showing would go stale with nothing to replace it.
|
||||
reminderScanner.scan()
|
||||
if (!enabled) digestNotifier.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
fun setDailyDigestTimeMinutes(minutesOfDay: Int) = saveAndScan { setDailyDigestTimeMinutes(minutesOfDay) }
|
||||
|
||||
/** A new mode lists afresh: from "tomorrow" to "today", the day just listed comes again. */
|
||||
fun setDailyDigestDay(day: DigestDay) {
|
||||
viewModelScope.launch {
|
||||
prefs.setDailyDigestDay(day)
|
||||
reminderState.clearLastDigestDay()
|
||||
reminderScanner.scan()
|
||||
}
|
||||
}
|
||||
|
||||
fun setCalendarInDailyDigest(calendarId: Long, included: Boolean) = save { setCalendarInDailyDigest(calendarId, included) }
|
||||
|
||||
/** The summary's channel, created if needed so its system settings can open. */
|
||||
suspend fun digestChannel(): String? = withContext(io) {
|
||||
runCatching { digestNotifier.channelId() }.getOrNull()
|
||||
}
|
||||
|
||||
private fun saveAndScan(write: suspend SettingsPrefs.() -> Unit) {
|
||||
viewModelScope.launch {
|
||||
prefs.write()
|
||||
reminderScanner.scan()
|
||||
}
|
||||
}
|
||||
|
||||
private fun save(write: suspend SettingsPrefs.() -> Unit) {
|
||||
viewModelScope.launch { prefs.write() }
|
||||
}
|
||||
|
||||
@@ -731,8 +731,56 @@
|
||||
<string name="reminder_custom_amount">Amount</string>
|
||||
<string name="reminder_custom_set">Set</string>
|
||||
<string name="settings_calendar_reminders_title">Per-calendar reminders</string>
|
||||
<string name="settings_calendar_reminders_hint">Override the default per calendar — separately for timed and all-day events. A calendar can keep the default, drop it, or set its own.</string>
|
||||
<string name="settings_calendar_reminders_hint">Turn notifications off or pick a sound per calendar, and override the default reminder for calendars you can edit.</string>
|
||||
<string name="settings_calendar_notifications">Notifications</string>
|
||||
<string name="settings_calendar_notifications_hint">Notify at this calendar\'s reminder times</string>
|
||||
<string name="settings_calendar_notifications_off">Notifications off</string>
|
||||
<string name="settings_calendar_reminders_off">Reminders off</string>
|
||||
<string name="settings_notification_sound">Notification sound</string>
|
||||
<string name="settings_notification_sound_hint">Sound, vibration and pop-ups for each calendar, in system settings</string>
|
||||
<string name="settings_notifications_blocked">Blocked in system settings</string>
|
||||
<string name="settings_notifications_blocked_hint">Blocked in system settings — tap to allow</string>
|
||||
<string name="settings_notification_category_hint">On Samsung phones, new notification categories can start switched off. If a calendar\'s reminders don\'t arrive, check that its category is on.</string>
|
||||
<string name="settings_notification_category_hint_open">Open settings</string>
|
||||
<string name="settings_calendar_notification_sound_hint">Sound, vibration and pop-ups for this calendar, in system settings</string>
|
||||
<string name="settings_calendar_reminder_inherits">Default (%1$s)</string>
|
||||
<!-- Reminders at a set time (#459). %1$s is a lead in days ("1 day before"), %2$s a clock time. -->
|
||||
<string name="reminder_fixed_time">%1$s at %2$s</string>
|
||||
<string name="reminder_fixed_at_set_time">At a set time</string>
|
||||
<!-- Read as one sentence with a number field before it and a time after it: "[2] days before, at [20:00]". The full wording shows below it. -->
|
||||
<string name="reminder_fixed_days_before_at">days before, at</string>
|
||||
<string name="reminder_fixed_days_range">Pick 1 to %1$d days</string>
|
||||
<string name="settings_fixed_reminder">Reminder at a set time</string>
|
||||
<string name="event_edit_reminder_fixed_summary">E.g. the evening before</string>
|
||||
<string name="event_edit_reminder_fixed_hint">Fires at this time however early or late the event starts, and moves along when you change the event\'s time.</string>
|
||||
<string name="settings_fixed_reminder_hint">Added to new timed events, e.g. the evening before. It follows the event when you move it to another time.</string>
|
||||
<!-- Daily summary notification (#459) -->
|
||||
<string name="settings_digest">Daily summary</string>
|
||||
<string name="settings_digest_hint">One notification listing a day\'s events</string>
|
||||
<!-- %1$s is settings_digest_day_today/tomorrow, %2$s a clock time. -->
|
||||
<string name="settings_digest_summary">%1$s at %2$s</string>
|
||||
<string name="settings_digest_day">Lists</string>
|
||||
<string name="settings_digest_day_today">Today\'s events</string>
|
||||
<string name="settings_digest_day_tomorrow">Tomorrow\'s events</string>
|
||||
<string name="settings_digest_time">Time</string>
|
||||
<string name="settings_digest_sound_hint">Sound and pop-up for the summary, in system settings</string>
|
||||
<string name="settings_calendar_in_digest">In daily summary</string>
|
||||
<string name="settings_calendar_in_digest_hint">List this calendar\'s events in the daily summary</string>
|
||||
<string name="digest_channel_name">Daily summary</string>
|
||||
<string name="digest_channel_description">One notification listing a day\'s events</string>
|
||||
<!-- %1$s is "Today", "Tomorrow" or a date; %2$d the number of events. -->
|
||||
<plurals name="digest_title">
|
||||
<item quantity="one">%1$s: %2$d event</item>
|
||||
<item quantity="other">%1$s: %2$d events</item>
|
||||
</plurals>
|
||||
<!-- %1$s is "Today" or "Tomorrow", %2$s a short date, e.g. "Fri, 14 Nov". -->
|
||||
<string name="digest_day_relative">%1$s, %2$s</string>
|
||||
<!-- One event in the summary: %1$s its time ("09:00 – 10:00" or "All day"), %2$s its title. -->
|
||||
<string name="digest_line">%1$s · %2$s</string>
|
||||
<plurals name="digest_more">
|
||||
<item quantity="one">+%d more</item>
|
||||
<item quantity="other">+%d more</item>
|
||||
</plurals>
|
||||
<string name="settings_reliable_delivery">Reliable delivery</string>
|
||||
<string name="settings_reliable_delivery_hint">Android may delay reminders to save battery. Exempt Calendula so they arrive on time.</string>
|
||||
<string name="settings_reliable_delivery_exempt">Exempt from battery optimisation — reminders arrive on time.</string>
|
||||
@@ -910,41 +958,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)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,9 @@ import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.DailyDigestSettings
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.DigestDay
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.FixedTimeReminder
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
@@ -601,6 +604,19 @@ class SettingsPrefsTest {
|
||||
assertThat(prefs.perCalendarEventDuration.first()).containsExactly(9L, 30)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `calendar notifications default on and toggle per calendar`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest {
|
||||
val prefs = testSettings(tempDir)
|
||||
assertThat(prefs.mutedReminderCalendarIds.first()).isEmpty()
|
||||
prefs.setCalendarRemindersEnabled(7L, false)
|
||||
prefs.setCalendarRemindersEnabled(9L, false)
|
||||
assertThat(prefs.mutedReminderCalendarIds.first()).containsExactly(7L, 9L)
|
||||
prefs.setCalendarRemindersEnabled(7L, true)
|
||||
assertThat(prefs.mutedReminderCalendarIds.first()).containsExactly(9L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `garbage per-calendar duration entries are dropped, the rest survive`(
|
||||
@TempDir tempDir: Path,
|
||||
@@ -705,4 +721,62 @@ class SettingsPrefsTest {
|
||||
prefs.recordSpecialDatesRun(2000L, null)
|
||||
assertThat(prefs.specialDatesStatus.first()).isEqualTo(SpecialDatesStatus(2000L, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fixed-time reminder defaults to off and round-trips`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = testSettings(tempDir)
|
||||
assertThat(prefs.defaultFixedTimeReminder.first()).isNull()
|
||||
val evening = FixedTimeReminder(1, 1_200)
|
||||
prefs.setDefaultFixedTimeReminder(evening)
|
||||
assertThat(prefs.defaultFixedTimeReminder.first()).isEqualTo(evening)
|
||||
prefs.setDefaultFixedTimeReminder(null)
|
||||
assertThat(prefs.defaultFixedTimeReminder.first()).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `per-calendar fixed-time overrides keep inherit, off and a value apart`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = testSettings(tempDir)
|
||||
val morning = FixedTimeReminder(2, 480)
|
||||
prefs.setDefaultFixedTimeReminder(FixedTimeReminder(1, 1_200))
|
||||
prefs.setCalendarFixedTimeReminderOverride(1L, FixedTimeOverride.Off)
|
||||
prefs.setCalendarFixedTimeReminderOverride(2L, FixedTimeOverride.At(morning))
|
||||
val overrides = prefs.perCalendarFixedTimeReminderOverride.first()
|
||||
assertThat(overrides.fixedTimeOverrideFor(1L)).isEqualTo(FixedTimeOverride.Off)
|
||||
assertThat(overrides.fixedTimeOverrideFor(2L)).isEqualTo(FixedTimeOverride.At(morning))
|
||||
assertThat(overrides.fixedTimeOverrideFor(3L)).isEqualTo(FixedTimeOverride.Inherit)
|
||||
assertThat(resolveDefaultFixedTimeReminder(FixedTimeReminder(1, 1_200), overrides, 1L)).isNull()
|
||||
assertThat(resolveDefaultFixedTimeReminder(FixedTimeReminder(1, 1_200), overrides, 3L))
|
||||
.isEqualTo(FixedTimeReminder(1, 1_200))
|
||||
assertThat(prefs.knownFixedTimeReminders.first()).containsExactly(FixedTimeReminder(1, 1_200), morning)
|
||||
val picked = FixedTimeReminder(3, 600)
|
||||
prefs.rememberFixedTimeReminders(listOf(picked, morning))
|
||||
assertThat(prefs.knownFixedTimeReminders.first()).contains(picked)
|
||||
// A default already known takes no recents slot.
|
||||
assertThat(prefs.knownFixedTimeReminders.first()).hasSize(3)
|
||||
|
||||
// A replaced default stays known.
|
||||
prefs.setDefaultFixedTimeReminder(FixedTimeReminder(1, 1_140))
|
||||
assertThat(prefs.knownFixedTimeReminders.first()).contains(FixedTimeReminder(1, 1_200))
|
||||
|
||||
prefs.setCalendarFixedTimeReminderOverride(2L, FixedTimeOverride.Inherit)
|
||||
assertThat(prefs.perCalendarFixedTimeReminderOverride.first().containsKey(2L)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `daily summary is off by default and its settings round-trip`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = testSettings(tempDir)
|
||||
assertThat(prefs.dailyDigest.first()).isEqualTo(DailyDigestSettings())
|
||||
prefs.setDailyDigestEnabled(true)
|
||||
prefs.setDailyDigestTimeMinutes(420)
|
||||
prefs.setDailyDigestDay(DigestDay.Today)
|
||||
prefs.setCalendarInDailyDigest(5L, included = false)
|
||||
val digest = prefs.dailyDigest.first()
|
||||
assertThat(digest.copy(armedAtMillis = 0L)).isEqualTo(
|
||||
DailyDigestSettings(enabled = true, timeMinutes = 420, day = DigestDay.Today, excludedCalendarIds = setOf(5L)),
|
||||
)
|
||||
assertThat(digest.armedAtMillis).isGreaterThan(0L)
|
||||
prefs.setCalendarInDailyDigest(5L, included = true)
|
||||
assertThat(prefs.dailyDigest.first().excludedCalendarIds).isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class ReminderChannelPlanTest {
|
||||
|
||||
private fun calendar(id: Long, account: String, type: String, local: Boolean = false) = CalendarSource(
|
||||
id = id,
|
||||
displayName = "Cal $id",
|
||||
accountName = account,
|
||||
accountType = type,
|
||||
color = 0,
|
||||
isVisibleInSystem = true,
|
||||
isLocal = local,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `one channel per calendar, grouped by account`() {
|
||||
val plan = planReminderChannels(
|
||||
calendars = listOf(
|
||||
calendar(1, "me@example.org", "com.google"),
|
||||
calendar(2, "me@example.org", "com.google"),
|
||||
calendar(3, "dav", "bitfire.at.davdroid"),
|
||||
),
|
||||
existing = emptyList(),
|
||||
existingGroupIds = emptyList(),
|
||||
unnamed = "Unnamed",
|
||||
description = DESC,
|
||||
)
|
||||
assertThat(plan.groups).hasSize(2)
|
||||
assertThat(plan.channels.map { it.id })
|
||||
.containsExactly(reminderChannelId(1), reminderChannelId(2), reminderChannelId(3))
|
||||
assertThat(plan.channels[0].groupId).isEqualTo(plan.channels[1].groupId)
|
||||
assertThat(plan.channels[2].groupId).isNotEqualTo(plan.channels[0].groupId)
|
||||
assertThat(plan.staleChannelIds).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `device calendars share one group`() {
|
||||
val plan = planReminderChannels(
|
||||
calendars = listOf(
|
||||
calendar(1, "Calendula", "LOCAL", local = true),
|
||||
calendar(2, "Other", "LOCAL", local = true),
|
||||
),
|
||||
existing = emptyList(),
|
||||
existingGroupIds = emptyList(),
|
||||
unnamed = "Unnamed",
|
||||
description = DESC,
|
||||
)
|
||||
assertThat(plan.groups).hasSize(1)
|
||||
assertThat(plan.groups.single().isLocal).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `same account name under two types is ambiguous`() {
|
||||
val plan = planReminderChannels(
|
||||
calendars = listOf(
|
||||
calendar(1, "me@example.org", "com.google"),
|
||||
calendar(2, "me@example.org", "bitfire.at.davdroid"),
|
||||
calendar(3, "work", "bitfire.at.davdroid"),
|
||||
),
|
||||
existing = emptyList(),
|
||||
existingGroupIds = emptyList(),
|
||||
unnamed = "Unnamed",
|
||||
description = DESC,
|
||||
)
|
||||
assertThat(plan.groups.filter { it.ambiguous }.map { it.accountType })
|
||||
.containsExactly("com.google", "bitfire.at.davdroid")
|
||||
assertThat(plan.groups.single { it.label == "work" }.ambiguous).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stale reminder channels and groups go, foreign ones stay`() {
|
||||
val current = calendar(1, "me@example.org", "com.google")
|
||||
fun plan(calendars: List<CalendarSource>, existing: List<String> = emptyList(), groups: List<String> = emptyList()) =
|
||||
planReminderChannels(calendars, existing.map { ExistingReminderChannel(it, "Cal ${it.last()}", DESC, null) }, groups, "Unnamed", DESC)
|
||||
val keptGroup = plan(listOf(current)).groups.single().id
|
||||
val goneGroup = plan(listOf(calendar(9, "old", "com.google"))).groups.single().id
|
||||
val result = plan(
|
||||
calendars = listOf(current),
|
||||
existing = listOf(reminderChannelId(1), reminderChannelId(9), LEGACY_REMINDER_CHANNEL_ID, "backup"),
|
||||
groups = listOf(keptGroup, goneGroup, "other"),
|
||||
)
|
||||
assertThat(result.staleChannelIds).containsExactly(reminderChannelId(9))
|
||||
assertThat(result.staleGroupIds).containsExactly(goneGroup)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only missing or renamed channels are written`() {
|
||||
val plan = planReminderChannels(
|
||||
calendars = listOf(
|
||||
calendar(1, "a", "com.google"),
|
||||
calendar(2, "a", "com.google"),
|
||||
calendar(3, "a", "com.google").copy(displayName = ""),
|
||||
calendar(4, "a", "com.google"),
|
||||
),
|
||||
existing = listOf(
|
||||
ExistingReminderChannel(reminderChannelId(1), "Cal 1", DESC, "g"),
|
||||
ExistingReminderChannel(reminderChannelId(4), "Cal 4", "Old description", "g"),
|
||||
ExistingReminderChannel(reminderChannelId(2), "Old name", DESC, "g"),
|
||||
),
|
||||
existingGroupIds = emptyList(),
|
||||
unnamed = "Unnamed",
|
||||
description = DESC,
|
||||
)
|
||||
assertThat(plan.channelsToWrite.map { it.calendarId }).containsExactly(2L, 3L, 4L)
|
||||
assertThat(plan.channelsToWrite.single { it.calendarId == 3L }.name).isEqualTo("Unnamed")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-syncing calendars keep a channel but get none up front`() {
|
||||
val quiet = calendar(2, "a", "com.google").copy(syncsEvents = false)
|
||||
val fresh = planReminderChannels(listOf(quiet), emptyList(), emptyList(), "Unnamed", DESC)
|
||||
assertThat(fresh.channelsToWrite).isEmpty()
|
||||
assertThat(fresh.groupsInUse).isEmpty()
|
||||
val posted = planReminderChannels(
|
||||
calendars = listOf(quiet),
|
||||
existing = listOf(ExistingReminderChannel(reminderChannelId(2), "Cal 2", DESC, null)),
|
||||
existingGroupIds = emptyList(),
|
||||
unnamed = "Unnamed",
|
||||
description = DESC,
|
||||
)
|
||||
assertThat(posted.staleChannelIds).isEmpty()
|
||||
assertThat(posted.channelsToWrite.map { it.calendarId }).containsExactly(2L)
|
||||
assertThat(posted.groupsInUse).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blocked calendars follow app, channel and group`() {
|
||||
val channels = mapOf(
|
||||
reminderChannelId(1) to ReminderChannelState(blocked = true, groupId = "g1"),
|
||||
reminderChannelId(2) to ReminderChannelState(blocked = false, groupId = "g2"),
|
||||
reminderChannelId(3) to ReminderChannelState(blocked = false, groupId = "g1"),
|
||||
)
|
||||
val ids = listOf(1L, 2L, 3L, 4L)
|
||||
val groupOff = reminderChannelStatus(ids, appEnabled = true, channels, blockedGroupIds = setOf("g2"))
|
||||
assertThat(groupOff.blockedCalendarIds).containsExactly(1L, 2L)
|
||||
assertThat(groupOff.groupBlockedCalendarIds).containsExactly(2L)
|
||||
assertThat(reminderChannelStatus(ids, appEnabled = true, channels, blockedGroupIds = emptySet()).blockedCalendarIds)
|
||||
.containsExactly(1L)
|
||||
val appOff = reminderChannelStatus(ids, appEnabled = false, channels, blockedGroupIds = emptySet())
|
||||
assertThat(appOff.appBlocked).isTrue()
|
||||
assertThat(appOff.blockedCalendarIds).containsExactly(1L, 2L, 3L, 4L)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DESC = "Reminders"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.FixedTimeReminder
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class FixedTimeReminderFormTest {
|
||||
|
||||
private val zone = TimeZone.of("Europe/Berlin")
|
||||
private val evening = FixedTimeReminder(1, 20 * 60)
|
||||
|
||||
private fun form(start: String, reminders: List<Int> = emptyList(), allDay: Boolean = false) = EventForm(
|
||||
calendarId = 1,
|
||||
isAllDay = allDay,
|
||||
start = FormTime(LocalDateTime.parse(start)),
|
||||
end = FormTime(LocalDateTime.parse(start)),
|
||||
reminders = reminders,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a moved start re-derives the offset so the wall clock stays`() {
|
||||
val loaded = form("2026-06-10T10:00", reminders = listOf(10, 840))
|
||||
.withFixedTimeRemindersRead(known = setOf(evening), deviceZone = zone)
|
||||
assertThat(loaded.reminders).containsExactly(10)
|
||||
assertThat(loaded.fixedReminders).containsExactly(evening)
|
||||
assertThat(loaded.fixedReminderOffsets(zone)).containsExactly(840)
|
||||
|
||||
val moved = loaded.copy(start = FormTime(LocalDateTime.parse("2026-06-10T15:30")))
|
||||
assertThat(moved.fixedReminderOffsets(zone)).containsExactly(19 * 60 + 30)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all-day forms keep their reminders and store no fixed ones`() {
|
||||
val allDay = form("2026-06-10T00:00", reminders = listOf(840), allDay = true)
|
||||
assertThat(allDay.withFixedTimeRemindersRead(setOf(evening), zone)).isEqualTo(allDay)
|
||||
assertThat(allDay.copy(fixedReminders = listOf(evening)).fixedReminderOffsets(zone)).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fixed reminders count as a populated reminders section on a timed event only`() {
|
||||
val withFixed = form("2026-06-10T10:00").copy(fixedReminders = listOf(evening))
|
||||
assertThat(withFixed.populatedFields()).contains(EventFormField.Reminders)
|
||||
assertThat(withFixed.copy(isAllDay = true).populatedFields()).doesNotContain(EventFormField.Reminders)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package de.jeanlucmakiola.calendula.domain.reminders
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
|
||||
class DailyDigestTest {
|
||||
|
||||
private val zone = ZoneId.of("Europe/Berlin")
|
||||
private val minute = 60_000L
|
||||
private val eight = 20 * 60
|
||||
|
||||
private fun millis(text: String): Long = LocalDateTime.parse(text).atZone(zone).toInstant().toEpochMilli()
|
||||
|
||||
@Test
|
||||
fun `due once the moment passes the watermark`() {
|
||||
val schedule = scheduleDigest(eight, DigestDay.Tomorrow, zone, millis("2026-06-10T19:59"), millis("2026-06-10T20:00"))
|
||||
assertThat(schedule.dueDay).isEqualTo(LocalDate.of(2026, 6, 11))
|
||||
assertThat(schedule.nextAlarmMillis).isEqualTo(millis("2026-06-11T20:00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a second scan does not post again`() {
|
||||
val schedule = scheduleDigest(eight, DigestDay.Tomorrow, zone, millis("2026-06-10T20:00"), millis("2026-06-10T20:05"))
|
||||
assertThat(schedule.dueDay).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a day already listed is not listed again`() {
|
||||
// Posted at 20:00, then the time moved to 21:00 the same evening.
|
||||
val schedule = scheduleDigest(
|
||||
21 * 60, DigestDay.Tomorrow, zone, millis("2026-06-10T20:30"), millis("2026-06-10T21:00"),
|
||||
lastListedDay = LocalDate.of(2026, 6, 11),
|
||||
)
|
||||
assertThat(schedule.dueDay).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `before the moment it only arms the alarm`() {
|
||||
val schedule = scheduleDigest(eight, DigestDay.Tomorrow, zone, millis("2026-06-10T08:00"), millis("2026-06-10T09:00"))
|
||||
assertThat(schedule.dueDay).isNull()
|
||||
assertThat(schedule.nextAlarmMillis).isEqualTo(millis("2026-06-10T20:00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a late catch-up still lists a day that has not passed`() {
|
||||
// Off overnight: last scan before 20:00, back on the next morning.
|
||||
val schedule = scheduleDigest(eight, DigestDay.Tomorrow, zone, millis("2026-06-10T18:00"), millis("2026-06-11T07:00"))
|
||||
assertThat(schedule.dueDay).isEqualTo(LocalDate.of(2026, 6, 11))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a today summary missed until the next day is dropped`() {
|
||||
val seven = 7 * 60
|
||||
val schedule = scheduleDigest(seven, DigestDay.Today, zone, millis("2026-06-10T06:00"), millis("2026-06-11T06:30"))
|
||||
assertThat(schedule.dueDay).isNull()
|
||||
assertThat(schedule.nextAlarmMillis).isEqualTo(millis("2026-06-11T07:00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `entries list all-day first, keep the day's own, and skip excluded calendars`() {
|
||||
val day = LocalDate.of(2026, 6, 11)
|
||||
val utcMidnight = day.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||
val allDay = instance(1, utcMidnight, utcMidnight + 24 * 60 * minute, isAllDay = true)
|
||||
val previousAllDay = instance(2, utcMidnight - 24 * 60 * minute, utcMidnight, isAllDay = true)
|
||||
val morning = instance(3, millis("2026-06-11T09:00"), millis("2026-06-11T10:00"))
|
||||
val early = instance(4, millis("2026-06-11T07:00"), millis("2026-06-11T08:00"))
|
||||
val nextDay = instance(5, millis("2026-06-12T00:00"), millis("2026-06-12T01:00"))
|
||||
val excluded = instance(6, millis("2026-06-11T12:00"), millis("2026-06-11T13:00"), calendarId = 9)
|
||||
val entries = digestEntries(
|
||||
listOf(morning, previousAllDay, nextDay, early, allDay, excluded),
|
||||
day,
|
||||
zone,
|
||||
excludedCalendarIds = setOf(9),
|
||||
)
|
||||
assertThat(entries.map { it.eventId }).containsExactly(1L, 4L, 3L).inOrder()
|
||||
}
|
||||
|
||||
private fun instance(
|
||||
eventId: Long,
|
||||
begin: Long,
|
||||
end: Long,
|
||||
isAllDay: Boolean = false,
|
||||
calendarId: Long = 1,
|
||||
) = ReminderEventInstance(eventId, calendarId, begin, end, "Event $eventId", null, isAllDay)
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
package de.jeanlucmakiola.calendula.domain.reminders
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toInstant
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.time.Instant
|
||||
|
||||
class FixedTimeReminderTest {
|
||||
|
||||
private val berlin = TimeZone.of("Europe/Berlin")
|
||||
private val eveningBefore = FixedTimeReminder(daysBefore = 1, minuteOfDay = 20 * 60)
|
||||
|
||||
private fun at(text: String, zone: TimeZone = berlin): Instant = LocalDateTime.parse(text).toInstant(zone)
|
||||
|
||||
@Test
|
||||
fun `offset is the distance from the named wall clock to the start`() {
|
||||
assertThat(eveningBefore.offsetMinutes(at("2026-06-10T10:00"), berlin)).isEqualTo(14 * 60)
|
||||
assertThat(eveningBefore.offsetMinutes(at("2026-06-10T21:30"), berlin)).isEqualTo(25 * 60 + 30)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `offset spans a DST change in real minutes`() {
|
||||
// Clocks go forward on 29 March 2026: 20:00 on the 28th to 10:00 on the 29th is 13 hours.
|
||||
assertThat(eveningBefore.offsetMinutes(at("2026-03-29T10:00"), berlin)).isEqualTo(13 * 60)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a series offset counts clock minutes, so a DST-side occurrence bakes in no hour`() {
|
||||
// Opened on the Sunday clocks go back: the real span is 15 hours, the clock face says 14.
|
||||
val start = at("2026-10-25T10:00")
|
||||
assertThat(eveningBefore.offsetMinutes(start, berlin)).isEqualTo(15 * 60)
|
||||
val offset = eveningBefore.offsetMinutes(start, berlin, wallClock = true)
|
||||
assertThat(offset).isEqualTo(14 * 60)
|
||||
// Read back the same way on any occurrence of the series.
|
||||
assertThat(fixedTimeReminderAt(offset, at("2026-11-01T10:00"), berlin, wallClock = true)).isEqualTo(eveningBefore)
|
||||
assertThat(fixedTimeReminderAt(offset, start, berlin, wallClock = true)).isEqualTo(eveningBefore)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an offset reads back as the pattern it was written from`() {
|
||||
val start = at("2026-03-29T10:00")
|
||||
val offset = eveningBefore.offsetMinutes(start, berlin)
|
||||
assertThat(fixedTimeReminderAt(offset, start, berlin)).isEqualTo(eveningBefore)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a reminder on the start's own day or after it is no pattern`() {
|
||||
val start = at("2026-06-10T10:00")
|
||||
assertThat(fixedTimeReminderAt(60, start, berlin)).isNull()
|
||||
assertThat(fixedTimeReminderAt(0, start, berlin)).isNull()
|
||||
assertThat(fixedTimeReminderAt(-30, start, berlin)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only known patterns read as fixed-time`() {
|
||||
val start = at("2026-06-10T10:00")
|
||||
// 840 is "1 day before at 20:00" here, but also a plain "14 hours before".
|
||||
val unknown = splitTimedReminders(listOf(10, 1_440, 840), start, berlin, known = emptySet())
|
||||
assertThat(unknown.leadMinutes).containsExactly(10, 840, 1_440).inOrder()
|
||||
assertThat(unknown.fixed).isEmpty()
|
||||
|
||||
val known = splitTimedReminders(listOf(10, 1_440, 840), start, berlin, known = setOf(eveningBefore))
|
||||
assertThat(known.leadMinutes).containsExactly(10, 1_440).inOrder()
|
||||
assertThat(known.fixed).containsExactly(eveningBefore)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a whole-day offset landing on a known pattern is fixed-time`() {
|
||||
// An event at 20:00 with "1 day before at 20:00" stores exactly a day.
|
||||
val start = at("2026-06-10T20:00")
|
||||
assertThat(splitTimedReminders(listOf(1_440), start, berlin, known = emptySet()).fixed).isEmpty()
|
||||
val split = splitTimedReminders(listOf(1_440), start, berlin, known = setOf(eveningBefore))
|
||||
assertThat(split.fixed).containsExactly(eveningBefore)
|
||||
assertThat(split.leadMinutes).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fixed reminders sort by when they fire`() {
|
||||
val start = at("2026-06-10T10:00")
|
||||
val twoDays = FixedTimeReminder(2, 9 * 60)
|
||||
val split = splitTimedReminders(
|
||||
listOf(eveningBefore.offsetMinutes(start, berlin), twoDays.offsetMinutes(start, berlin)),
|
||||
start,
|
||||
berlin,
|
||||
known = setOf(eveningBefore, twoDays),
|
||||
)
|
||||
assertThat(split.fixed).containsExactly(twoDays, eveningBefore).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `token round-trips and rejects garbage`() {
|
||||
assertThat(FixedTimeReminder.parse(eveningBefore.toToken())).isEqualTo(eveningBefore)
|
||||
assertThat(FixedTimeReminder.parse("0@600")).isNull()
|
||||
assertThat(FixedTimeReminder.parse("1@1440")).isNull()
|
||||
assertThat(FixedTimeReminder.parse("x")).isNull()
|
||||
assertThat(FixedTimeReminder.parse(null)).isNull()
|
||||
}
|
||||
}
|
||||
@@ -92,12 +92,13 @@ class RescheduleViewModelTest {
|
||||
io: CoroutineDispatcher = dispatcher,
|
||||
): RescheduleViewModel {
|
||||
fake.occurrencesAt(present)
|
||||
val settings = testSettings(tempDir, CoroutineScope(dispatcher))
|
||||
val repo = testRepository(
|
||||
fake, tempDir, io,
|
||||
prefs = testPrefs(tempDir, CoroutineScope(dispatcher)),
|
||||
settings = testSettings(tempDir, CoroutineScope(dispatcher)),
|
||||
settings = settings,
|
||||
)
|
||||
return RescheduleViewModel(repo, io, FakeZoneSource())
|
||||
return RescheduleViewModel(repo, io, FakeZoneSource(), settings)
|
||||
}
|
||||
|
||||
private fun slowViewModel(tempDir: Path, fake: FakeCalendarDataSource): RescheduleViewModel =
|
||||
|
||||
@@ -51,13 +51,14 @@ class EventDetailViewModelTest {
|
||||
)
|
||||
|
||||
private fun viewModel(tempDir: Path, fake: FakeCalendarDataSource): EventDetailViewModel {
|
||||
val settings = testSettings(tempDir, CoroutineScope(dispatcher))
|
||||
val repo = testRepository(
|
||||
fake, tempDir, dispatcher,
|
||||
prefs = testPrefs(tempDir, CoroutineScope(dispatcher)),
|
||||
settings = testSettings(tempDir, CoroutineScope(dispatcher)),
|
||||
settings = settings,
|
||||
)
|
||||
// Only `shareUri()` touches the exporter, and nothing here shares.
|
||||
return EventDetailViewModel(repo, IcsExporter(ContextWrapper(null)), dispatcher, FakeZoneSource())
|
||||
return EventDetailViewModel(repo, IcsExporter(ContextWrapper(null)), dispatcher, FakeZoneSource(), settings)
|
||||
}
|
||||
|
||||
private fun fakeSource(description: () -> String?) = FakeCalendarDataSource().apply {
|
||||
|
||||
@@ -9,7 +9,11 @@ import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.time.FakeZoneSource
|
||||
import de.jeanlucmakiola.calendula.data.prefs.FixedTimeOverride
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||
import de.jeanlucmakiola.calendula.domain.ReminderMethod
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.FixedTimeReminder
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.FormTime
|
||||
@@ -719,4 +723,121 @@ class EventEditViewModelTest {
|
||||
assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.Idle)
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a new timed event gets the fixed-time default, and a calendar can turn it off`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L), cal(2L)) }
|
||||
val s = settings(tempDir)
|
||||
val evening = FixedTimeReminder(1, 1_200)
|
||||
s.setDefaultFixedTimeReminder(evening)
|
||||
s.setCalendarFixedTimeReminderOverride(2L, FixedTimeOverride.Off)
|
||||
val vm = viewModel(tempDir, fake, s)
|
||||
val job = activate(vm)
|
||||
|
||||
vm.openNew(LocalDate(2030, 1, 15), startMinutes = 600)
|
||||
advanceUntilIdle()
|
||||
assertThat(vm.state.value!!.form!!.fixedReminders).containsExactly(evening)
|
||||
|
||||
vm.setCalendar(2L)
|
||||
advanceUntilIdle()
|
||||
assertThat(vm.state.value!!.form!!.fixedReminders).isEmpty()
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an event's set-time reminder opens as one and saves without a conflict`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val zone = FakeZoneSource().current()
|
||||
val start = Instant.fromEpochMilliseconds(beginMillis)
|
||||
val evening = FixedTimeReminder(1, 1_200)
|
||||
val offset = evening.offsetMinutes(start, zone)
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
calendarsResult = listOf(cal(1L))
|
||||
eventDetailResult = {
|
||||
detail(calendarId = 1L).copy(reminders = listOf(Reminder(offset, ReminderMethod.Alert)))
|
||||
}
|
||||
}
|
||||
val s = settings(tempDir)
|
||||
s.setDefaultFixedTimeReminder(evening)
|
||||
val vm = viewModel(tempDir, fake, s)
|
||||
val job = activate(vm)
|
||||
|
||||
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
|
||||
advanceUntilIdle()
|
||||
val form = vm.state.value!!.form!!
|
||||
assertThat(form.fixedReminders).containsExactly(evening)
|
||||
assertThat(form.reminders).isEmpty()
|
||||
assertThat(vm.hasUnsavedChanges.value).isFalse()
|
||||
|
||||
vm.setTitle("Renamed")
|
||||
vm.save()
|
||||
advanceUntilIdle()
|
||||
assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.Saved)
|
||||
assertThat(fake.updatedEvents.single().third.fixedReminders).containsExactly(evening)
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a round trip through all-day keeps set-time reminders`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L)) }
|
||||
val s = settings(tempDir)
|
||||
s.setDefaultFixedTimeReminder(FixedTimeReminder(1, 1_200))
|
||||
val vm = viewModel(tempDir, fake, s)
|
||||
val job = activate(vm)
|
||||
|
||||
vm.openNew(LocalDate(2030, 1, 15), startMinutes = 600)
|
||||
advanceUntilIdle()
|
||||
vm.addFixedReminder(FixedTimeReminder(2, 480))
|
||||
vm.setAllDay(true)
|
||||
vm.setAllDay(false)
|
||||
advanceUntilIdle()
|
||||
assertThat(vm.state.value!!.form!!.fixedReminders)
|
||||
.containsExactly(FixedTimeReminder(2, 480), FixedTimeReminder(1, 1_200))
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a set-time reminder replaces a lead firing at the same moment`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L)) }
|
||||
val s = settings(tempDir)
|
||||
s.setDefaultReminderMinutes(listOf(1_440, 10))
|
||||
val vm = viewModel(tempDir, fake, s, zones = FakeZoneSource(berlin))
|
||||
val job = activate(vm)
|
||||
|
||||
vm.openNew(LocalDate(2030, 1, 15), startMinutes = 20 * 60)
|
||||
advanceUntilIdle()
|
||||
vm.addFixedReminder(FixedTimeReminder(1, 20 * 60))
|
||||
val form = vm.state.value!!.form!!
|
||||
assertThat(form.reminders).containsExactly(10)
|
||||
assertThat(form.fixedReminders).containsExactly(FixedTimeReminder(1, 20 * 60))
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a copy carrying only set-time reminders keeps them`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L)) }
|
||||
val s = settings(tempDir)
|
||||
s.setDefaultReminderMinutes(listOf(15))
|
||||
val vm = viewModel(tempDir, fake, s)
|
||||
val job = activate(vm)
|
||||
|
||||
val copy = importedForm(end = LocalDateTime(2030, 1, 15, 10, 0))
|
||||
.copy(reminders = emptyList(), fixedReminders = listOf(FixedTimeReminder(1, 1_200)))
|
||||
vm.openImported(copy, ImportSource.Insert)
|
||||
advanceUntilIdle()
|
||||
val form = vm.state.value!!.form!!
|
||||
assertThat(form.fixedReminders).containsExactly(FixedTimeReminder(1, 1_200))
|
||||
assertThat(form.reminders).isEmpty()
|
||||
job.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
+65
-11
@@ -391,6 +391,71 @@ 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.
|
||||
|
||||
**A channel per calendar (#145).** `ReminderChannels` keeps one notification
|
||||
channel per calendar, grouped by account (one that syncs no events only gets
|
||||
its channel once something posts on it), so a calendar can be
|
||||
muted or given its own sound in the system settings before it ever fires. Every
|
||||
scan with reminders on syncs them (skipped when nothing changed): missing
|
||||
channels are created, renamed ones updated, and channels of deleted calendars
|
||||
removed — but never on an empty snapshot, which is a provider hiccup more often
|
||||
than every calendar gone. Switched-off calendars keep theirs, so toggling one
|
||||
never cancels a showing reminder or loses its settings. `post` creates a missing
|
||||
channel itself, so a reminder never waits on a sync. A new channel copies the sound, vibration and
|
||||
importance of the pre-#145 single `reminders` channel, which is deleted once
|
||||
nothing showing still sits on it. Settings → Notifications only deep-links into
|
||||
the system's channel screens (#340); the app never sets a sound itself. Separate
|
||||
from both, `SettingsPrefs.mutedReminderCalendarIds` is the in-app per-calendar
|
||||
off switch (#272): the scan plans nothing for those calendars (so they arm no
|
||||
wake-ups; toggling it re-scans), and `ReminderNotifier.post` checks it again
|
||||
for snoozes. The settings screen re-reads `ReminderChannels.status` on every
|
||||
resume and marks a calendar "Blocked in system settings" when the app, its
|
||||
channel or its account group is switched off there.
|
||||
|
||||
**Reminders at a set time (#459).** "1 day before at 20:00" on a timed event
|
||||
is an ordinary `Reminders` row whose offset is the distance from that wall-clock
|
||||
moment (device zone) to the start, so it syncs and fires everywhere — the scan
|
||||
plans it like any lead time. Only the editor knows it as a pattern
|
||||
(`EventForm.fixedReminders`), and it re-derives the offset from the start it
|
||||
saves with, so moving the event, in the form or by drag, keeps the wall clock.
|
||||
Reading a row back is a guess, made in one place (`classifyTimedReminder`): an
|
||||
offset is fixed-time only when it lands on a known pattern — a current or
|
||||
replaced default, or one saved by hand on this device
|
||||
(`recentFixedTimeReminders`, capped). Guessing from the offset's shape alone
|
||||
would turn ordinary leads ("12 hours before") into fixed ones. Limits: where a
|
||||
lead and a known pattern coincide ("1 day before" on an event at the default's
|
||||
own time) the pattern wins, and a pattern that falls out of the recents reads as
|
||||
a lead again — either way the alarm fires at the same moment until the start's
|
||||
time of day changes. A recurring series stores one offset for all occurrences,
|
||||
so it counts clock minutes rather than real ones (`wallClock`); the occurrence
|
||||
whose lead spans a DST change then fires an hour off, as in every other app
|
||||
reading that row. On another device or app the row is simply a lead time.
|
||||
New timed events get the default (`SettingsKeys.defaultFixedTimeReminder`, with
|
||||
a per-calendar inherit/off/value override) like the lead-time defaults.
|
||||
|
||||
**The daily summary (#459)** rides the same scan and watermark: its moment is
|
||||
due when it falls in `(lastScan, now]` (`scheduleDigest`), only the latest one
|
||||
counts after a long gap, and it is dropped once the day it lists has passed.
|
||||
It is independent of the event-reminder switch, posts on its own channel
|
||||
(`DailyDigestNotifier`), lists switched-on calendars minus its own exclusions,
|
||||
and replaces the previous summary — or withdraws it when the day is empty.
|
||||
Each day is listed once: the day is recorded as soon as it is posted
|
||||
(`ReminderStatePrefs.lastDigestDay`), so neither a pass failing later nor a later
|
||||
time picked the same evening lists it twice, and changing the switch, time or
|
||||
day stamps `dailyDigestArmedAt`, so a moment that passed before the change is
|
||||
not owed by it. A failing summary is logged and skipped; it never costs the
|
||||
event reminders of the same pass, and a failing reminder pass still arms a retry
|
||||
and the summary's next moment. Switching between "today's" and "tomorrow's"
|
||||
events clears the day last listed, so the first summary of the new mode comes.
|
||||
The notification names its date and times out when that day ends.
|
||||
Its channel's blocked state is read separately (`DailyDigestNotifier.isChannelOff`,
|
||||
with the app-wide block from `ReminderChannels.status`); while it is off the
|
||||
scan neither posts nor records the day, so allowing it brings the summary back.
|
||||
The channel is created when the summary is switched on, so a phone that starts
|
||||
new categories off shows it blocked in settings before the first summary.
|
||||
The per-calendar reminder mute does not apply: a calendar too noisy to ping per
|
||||
event can still belong in the overview, and the summary has its own
|
||||
per-calendar switch next to 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 —
|
||||
a latch that disables its own scheduling once a real broadcast arrives — cannot
|
||||
@@ -492,17 +557,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:
|
||||
|
||||
@@ -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.
|
||||
+1
-1
Submodule floret-kit updated: da7dbc3e04...20b136aaf7.
Reference in new issue
Block a user