Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95ad3a30cf | ||
|
|
8ce4831efa |
No files matched your search
@@ -33,7 +33,7 @@ jobs:
|
||||
# directly drops the docker-in-docker requirement entirely.
|
||||
# Full tag pinned; Renovate's github-actions manager keeps it bumped.
|
||||
container:
|
||||
image: ghcr.io/renovatebot/renovate:44.237.3
|
||||
image: ghcr.io/renovatebot/renovate:43.232.0
|
||||
steps:
|
||||
- name: Run Renovate
|
||||
run: renovate
|
||||
|
||||
@@ -7,16 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.24.2] — 2026-11-13
|
||||
|
||||
### Fixed
|
||||
- **Quick moves no longer get lost.** Dropping an event while an earlier move was still saving used to snap it back without a word. Drops now wait in line and are saved in order, and a moved event stays where you dropped it until the calendar shows it there. If an event changed before a drop could be saved, or the line is full, a message says so instead ([#494]).
|
||||
- **Undo after a run of moves** keeps the first move's Undo, and each failed move gets its own message rather than replacing the last one ([#494]).
|
||||
|
||||
### Changed
|
||||
- **The day view's title names the month**, like the week and 3-day views. Weekday and date already sit in the column header below it ([#501]).
|
||||
- Library updates (kotlinx-datetime, AppCompat).
|
||||
|
||||
## [2.24.1] — 2026-11-12
|
||||
|
||||
### Added
|
||||
@@ -1983,5 +1973,3 @@ automatically, with zero telemetry and no internet permission.
|
||||
[#489]: https://codeberg.org/jlmakiola/calendula/issues/489
|
||||
[#491]: https://codeberg.org/jlmakiola/calendula/issues/491
|
||||
[#493]: https://codeberg.org/jlmakiola/calendula/issues/493
|
||||
[#494]: https://codeberg.org/jlmakiola/calendula/issues/494
|
||||
[#501]: https://codeberg.org/jlmakiola/calendula/issues/501
|
||||
@@ -7,4 +7,4 @@ source "https://rubygems.org"
|
||||
# Pinned exactly; Renovate's bundler manager keeps it bumped. No Gemfile.lock is
|
||||
# committed on purpose: this resolves an uploader's transitive deps, not the
|
||||
# app's, and none of it affects the reproducible release build.
|
||||
gem "fastlane", "2.241.1"
|
||||
gem "fastlane", "2.237.0"
|
||||
@@ -32,8 +32,8 @@ android {
|
||||
// (up to 2.22.3: 2.22.3 -> 22203; from 2.22.4: 2.23.0-beta.1 -> 2230001,
|
||||
// 2.22.4 -> 2220499), and CI fails if the committed one doesn't match.
|
||||
// See docs/RELEASING.md.
|
||||
versionCode = 2240299
|
||||
versionName = "2.24.2"
|
||||
versionCode = 2240199
|
||||
versionName = "2.24.1"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@@ -177,7 +177,6 @@ dependencies {
|
||||
implementation("de.jeanlucmakiola.floret:core-crash")
|
||||
implementation("de.jeanlucmakiola.floret:core-di")
|
||||
implementation("de.jeanlucmakiola.floret:core-prefs")
|
||||
implementation("de.jeanlucmakiola.floret:core-backup")
|
||||
implementation("de.jeanlucmakiola.floret:core-reminders")
|
||||
implementation("de.jeanlucmakiola.floret:core-android")
|
||||
implementation("de.jeanlucmakiola.floret:identity")
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.data.backup
|
||||
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import de.jeanlucmakiola.calendula.data.appname.LauncherName
|
||||
import de.jeanlucmakiola.calendula.data.calendar.ManagedCalendarRow
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefKeys
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsKeys
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.floret.backup.BackupDocument
|
||||
import de.jeanlucmakiola.floret.backup.BackupResult
|
||||
import de.jeanlucmakiola.floret.backup.PrefsSection
|
||||
import de.jeanlucmakiola.floret.backup.invalidBackup
|
||||
import de.jeanlucmakiola.floret.backup.optBoolean
|
||||
import de.jeanlucmakiola.floret.backup.optIntList
|
||||
import de.jeanlucmakiola.floret.backup.optObject
|
||||
import de.jeanlucmakiola.floret.backup.optObjectList
|
||||
import de.jeanlucmakiola.floret.backup.optInt
|
||||
import de.jeanlucmakiola.floret.backup.optString
|
||||
import de.jeanlucmakiola.floret.backup.readBackup
|
||||
import de.jeanlucmakiola.floret.backup.requireString
|
||||
import de.jeanlucmakiola.floret.prefs.Pref
|
||||
import de.jeanlucmakiola.floret.prefs.get
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
|
||||
/**
|
||||
* A calendar as another device can find it again: provider ids are per
|
||||
* device, so a calendar is named by its account and display name, and a
|
||||
* special-dates mirror by the date type it mirrors.
|
||||
*/
|
||||
data class CalendarIdentity(
|
||||
val accountName: String,
|
||||
val accountType: String,
|
||||
val displayName: String,
|
||||
val specialDates: SpecialDateType? = null,
|
||||
)
|
||||
|
||||
/** The per-calendar settings of one calendar; null fields inherit the global default. */
|
||||
data class CalendarSettings(
|
||||
val calendar: CalendarIdentity,
|
||||
val hidden: Boolean = false,
|
||||
val lastUsed: Boolean = false,
|
||||
val eventDurationMinutes: Int? = null,
|
||||
/** Timed reminder override; an empty list is "no reminder". */
|
||||
val reminders: List<Int>? = null,
|
||||
val allDayReminders: List<Int>? = null,
|
||||
)
|
||||
|
||||
/** The app language a file carries; [tag] null follows the system. */
|
||||
data class LanguageChoice(val tag: String?)
|
||||
|
||||
/** Everything a settings backup holds. Null [language]/[launcherName]: the file didn't say. */
|
||||
data class SettingsBackup(
|
||||
val prefs: Map<Pref<*>, Any>,
|
||||
val calendars: List<CalendarSettings>,
|
||||
val language: LanguageChoice? = null,
|
||||
val launcherName: LauncherName? = null,
|
||||
)
|
||||
|
||||
internal val plainSection = PrefsSection(SettingsBackupSchema.plain)
|
||||
|
||||
/**
|
||||
* The backup of [snapshot]. Per-calendar entries are named through
|
||||
* [calendars] and [managed]; an id neither knows (a deleted calendar's
|
||||
* leftover) is dropped.
|
||||
*/
|
||||
fun buildSettingsBackup(
|
||||
snapshot: Preferences,
|
||||
calendars: List<CalendarSource>,
|
||||
managed: List<ManagedCalendarRow>,
|
||||
language: LanguageChoice?,
|
||||
launcherName: LauncherName?,
|
||||
): SettingsBackup {
|
||||
val hidden = snapshot[CalendarPrefKeys.hiddenCalendarIds]
|
||||
val lastUsed = snapshot[CalendarPrefKeys.lastUsedCalendarId]
|
||||
val durations = snapshot[SettingsKeys.perCalendarEventDuration]
|
||||
val reminders = snapshot[SettingsKeys.perCalendarReminderOverride]
|
||||
val allDay = snapshot[SettingsKeys.perCalendarAllDayReminderOverride]
|
||||
val ids = hidden + listOfNotNull(lastUsed) + durations.keys + reminders.keys + allDay.keys
|
||||
val byId = calendars.associateBy { it.id }
|
||||
val managedType = managed.associate { it.id to it.type }
|
||||
val entries = ids.sorted().mapNotNull { id ->
|
||||
val calendar = byId[id] ?: return@mapNotNull null
|
||||
CalendarSettings(
|
||||
calendar = CalendarIdentity(
|
||||
accountName = calendar.accountName,
|
||||
accountType = calendar.accountType,
|
||||
displayName = calendar.displayName,
|
||||
specialDates = managedType[id],
|
||||
),
|
||||
hidden = id in hidden,
|
||||
lastUsed = id == lastUsed,
|
||||
eventDurationMinutes = durations[id],
|
||||
reminders = reminders[id],
|
||||
allDayReminders = allDay[id],
|
||||
)
|
||||
}
|
||||
return SettingsBackup(
|
||||
prefs = plainSection.snapshot(snapshot),
|
||||
calendars = entries,
|
||||
language = language,
|
||||
launcherName = launcherName,
|
||||
)
|
||||
}
|
||||
|
||||
fun encodeSettingsBackup(backup: SettingsBackup, exportedAtMillis: Long, appVersion: String?): String {
|
||||
val body = buildJsonObject {
|
||||
put(KEY_SETTINGS, plainSection.encode(backup.prefs))
|
||||
put(KEY_CALENDARS, JsonArray(backup.calendars.map { it.toJson() }))
|
||||
put(
|
||||
KEY_APP,
|
||||
buildJsonObject {
|
||||
backup.language?.let { put(KEY_LANGUAGE, it.tag?.let(::JsonPrimitive) ?: JsonNull) }
|
||||
backup.launcherName?.let { put(KEY_LAUNCHER_NAME, JsonPrimitive(it.name)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
val document = BackupDocument(
|
||||
version = SettingsBackupSchema.format.version,
|
||||
exportedAtMillis = exportedAtMillis,
|
||||
appVersion = appVersion,
|
||||
body = body,
|
||||
)
|
||||
return SettingsBackupSchema.format.encode(document)
|
||||
}
|
||||
|
||||
/** Validates the whole file before anything is applied; see docs/SETTINGS_BACKUP.md. */
|
||||
fun decodeSettingsBackup(text: String): BackupResult<SettingsBackup> {
|
||||
val document = when (val decoded = SettingsBackupSchema.format.decode(text)) {
|
||||
is BackupResult.Failure -> return decoded
|
||||
is BackupResult.Success -> decoded.value
|
||||
}
|
||||
return readBackup {
|
||||
val body = document.body
|
||||
val app = body.optObject(KEY_APP)
|
||||
SettingsBackup(
|
||||
prefs = plainSection.read(body.optObject(KEY_SETTINGS) ?: invalidBackup("$KEY_SETTINGS: missing")),
|
||||
calendars = body.optObjectList(KEY_CALENDARS).orEmpty().map { it.toCalendarSettings() },
|
||||
language = app?.takeIf { KEY_LANGUAGE in it }?.let { LanguageChoice(it.optString(KEY_LANGUAGE)) },
|
||||
launcherName = app?.optString(KEY_LAUNCHER_NAME)
|
||||
?.let { name -> LauncherName.entries.firstOrNull { it.name == name } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun CalendarSettings.toJson(): JsonObject = buildJsonObject {
|
||||
put(KEY_ACCOUNT_NAME, JsonPrimitive(calendar.accountName))
|
||||
put(KEY_ACCOUNT_TYPE, JsonPrimitive(calendar.accountType))
|
||||
put(KEY_DISPLAY_NAME, JsonPrimitive(calendar.displayName))
|
||||
calendar.specialDates?.let { put(KEY_SPECIAL_DATES, JsonPrimitive(it.name)) }
|
||||
if (hidden) put(KEY_HIDDEN, JsonPrimitive(true))
|
||||
if (lastUsed) put(KEY_LAST_USED, JsonPrimitive(true))
|
||||
eventDurationMinutes?.let { put(KEY_EVENT_DURATION, JsonPrimitive(it)) }
|
||||
reminders?.let { put(KEY_REMINDERS, JsonArray(it.map(::JsonPrimitive))) }
|
||||
allDayReminders?.let { put(KEY_ALL_DAY_REMINDERS, JsonArray(it.map(::JsonPrimitive))) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict about what it reads. A special-dates type this build doesn't know
|
||||
* reads as an ordinary calendar, which then simply finds no match.
|
||||
*/
|
||||
private fun JsonObject.toCalendarSettings(): CalendarSettings {
|
||||
return CalendarSettings(
|
||||
calendar = CalendarIdentity(
|
||||
accountName = requireString(KEY_ACCOUNT_NAME),
|
||||
accountType = requireString(KEY_ACCOUNT_TYPE),
|
||||
displayName = requireString(KEY_DISPLAY_NAME),
|
||||
specialDates = optString(KEY_SPECIAL_DATES)?.let { name -> SpecialDateType.entries.firstOrNull { it.name == name } },
|
||||
),
|
||||
hidden = optBoolean(KEY_HIDDEN) ?: false,
|
||||
lastUsed = optBoolean(KEY_LAST_USED) ?: false,
|
||||
eventDurationMinutes = optInt(KEY_EVENT_DURATION),
|
||||
reminders = optIntList(KEY_REMINDERS)?.cleanReminders(),
|
||||
allDayReminders = optIntList(KEY_ALL_DAY_REMINDERS)?.cleanReminders(),
|
||||
)
|
||||
}
|
||||
|
||||
/** As the reminder setters store them: no negative leads, each once, ascending. */
|
||||
private fun List<Int>.cleanReminders(): List<Int> = filter { it >= 0 }.distinct().sorted()
|
||||
|
||||
private const val KEY_SETTINGS = "settings"
|
||||
private const val KEY_CALENDARS = "calendars"
|
||||
private const val KEY_APP = "app"
|
||||
private const val KEY_LANGUAGE = "language"
|
||||
private const val KEY_LAUNCHER_NAME = "launcher_name"
|
||||
private const val KEY_ACCOUNT_NAME = "account_name"
|
||||
private const val KEY_ACCOUNT_TYPE = "account_type"
|
||||
private const val KEY_DISPLAY_NAME = "display_name"
|
||||
private const val KEY_SPECIAL_DATES = "special_dates"
|
||||
private const val KEY_HIDDEN = "hidden"
|
||||
private const val KEY_LAST_USED = "last_used"
|
||||
private const val KEY_EVENT_DURATION = "event_duration_minutes"
|
||||
private const val KEY_REMINDERS = "reminders"
|
||||
private const val KEY_ALL_DAY_REMINDERS = "all_day_reminders"
|
||||
@@ -1,240 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.data.backup
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.BuildConfig
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.appname.LauncherNameManager
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||
import de.jeanlucmakiola.calendula.data.contacts.ContactSpecialDatesDataSource
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncEngine
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncResult
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SpecialDatesStalledReason
|
||||
import de.jeanlucmakiola.calendula.data.fonts.CustomFontStore
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsKeys
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner
|
||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||
import de.jeanlucmakiola.calendula.widget.requestWidgetRedraw
|
||||
import de.jeanlucmakiola.floret.backup.BackupProblem
|
||||
import de.jeanlucmakiola.floret.backup.BackupResult
|
||||
import de.jeanlucmakiola.floret.di.IoDispatcher
|
||||
import de.jeanlucmakiola.floret.locale.AppLanguage
|
||||
import de.jeanlucmakiola.floret.prefs.get
|
||||
import de.jeanlucmakiola.floret.prefs.rawValue
|
||||
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Exports and imports Calendula's settings (#481). The file format and what
|
||||
* it holds are [SettingsBackupSchema]'s; the import is planned purely by
|
||||
* [planSettingsImport], written in one DataStore edit, and then followed by
|
||||
* the side effects the settings screens would have run themselves.
|
||||
*/
|
||||
@Singleton
|
||||
class SettingsBackupEngine @Inject constructor(
|
||||
private val store: DataStore<Preferences>,
|
||||
private val calendars: CalendarDataSource,
|
||||
private val contacts: ContactSpecialDatesDataSource,
|
||||
private val specialDates: SpecialDatesSyncEngine,
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
private val reminderScanner: ReminderScanner,
|
||||
private val launcherNames: LauncherNameManager,
|
||||
@ApplicationContext private val context: Context,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) {
|
||||
|
||||
suspend fun export(): String = withContext(io) {
|
||||
val supported = supportedLanguages()
|
||||
val snapshot = store.data.first()
|
||||
// Without the list the per-calendar settings would silently drop out of the file.
|
||||
val list = if (snapshot.hasCalendarSettings()) calendars.calendars() else emptyList()
|
||||
val backup = buildSettingsBackup(
|
||||
snapshot = snapshot,
|
||||
calendars = list,
|
||||
managed = if (list.any { it.isManaged }) calendars.findManagedCalendars() else emptyList(),
|
||||
language = LanguageChoice(AppLanguage.currentTag(context, supported)),
|
||||
launcherName = launcherNames.current(),
|
||||
)
|
||||
encodeSettingsBackup(backup, System.currentTimeMillis(), BuildConfig.VERSION_NAME)
|
||||
}
|
||||
|
||||
/** Validates [text] in full, then replaces the settings; nothing is written for an invalid file. */
|
||||
suspend fun import(text: String): BackupResult<SettingsImportSummary> = withContext(io) {
|
||||
val backup = when (val decoded = decodeSettingsBackup(text)) {
|
||||
is BackupResult.Failure -> return@withContext decoded
|
||||
is BackupResult.Success -> decoded.value
|
||||
}
|
||||
val current = store.data.first()
|
||||
val supported = supportedLanguages()
|
||||
val environment = environment(current, backup, supported)
|
||||
val plan = planSettingsImport(backup, current, environment)
|
||||
val existingMirrors = environment.managed.map { it.id }.toSet()
|
||||
// Once the write starts, the side effects have to follow it, whoever cancels.
|
||||
withContext(NonCancellable) {
|
||||
try {
|
||||
store.edit { plan.applyTo(it) }
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Settings import could not be written", e)
|
||||
return@withContext BackupResult.Failure(BackupProblem.NOT_APPLIED, e.message.orEmpty())
|
||||
}
|
||||
val lateSkips = afterImport(plan, current, supported, existingMirrors)
|
||||
BackupResult.Success(plan.summary(plan.calendars.orEmpty().size + lateSkips.matched, lateSkips.skipped))
|
||||
}
|
||||
}
|
||||
|
||||
private class LateMatches(val matched: Int, val skipped: List<SkippedSetting>)
|
||||
|
||||
/**
|
||||
* Brings what reads the settings outside DataStore in line: the mirror,
|
||||
* reminder alarms, scheduled backups, the launcher alias and the language.
|
||||
* Each step stands alone, so one failing never undoes the import.
|
||||
*/
|
||||
private suspend fun afterImport(
|
||||
plan: SettingsImportPlan,
|
||||
before: Preferences,
|
||||
supported: List<String>,
|
||||
existingMirrors: Set<Long>,
|
||||
): LateMatches {
|
||||
val after = store.data.first()
|
||||
val wasMirroring = before[SettingsKeys.specialDatesEnabled]
|
||||
val mirroring = after[SettingsKeys.specialDatesEnabled]
|
||||
var late = LateMatches(0, plan.pendingSpecialDates.map { it.calendar.skipped() })
|
||||
step("special dates") {
|
||||
if (mirroring) {
|
||||
SpecialDatesScheduler.apply(context, enabled = true)
|
||||
val result = specialDates.sync()
|
||||
if (result != SpecialDatesSyncResult.Disabled) {
|
||||
val stalled = SpecialDatesStalledReason.PermissionRevoked
|
||||
.takeIf { result == SpecialDatesSyncResult.PermissionMissing }
|
||||
settingsPrefs.recordSpecialDatesRun(System.currentTimeMillis(), stalled)
|
||||
}
|
||||
late = matchSpecialDates(plan, before, existingMirrors, synced = result == SpecialDatesSyncResult.Success)
|
||||
} else if (wasMirroring) {
|
||||
SpecialDatesScheduler.apply(context, enabled = false)
|
||||
specialDates.teardown()
|
||||
}
|
||||
}
|
||||
step("reminders") { reminderScanner.scan() }
|
||||
step("auto backup") {
|
||||
BackupScheduler.apply(
|
||||
context = context,
|
||||
enabled = after[SettingsKeys.autoBackupEnabled],
|
||||
intervalMinutes = after[SettingsKeys.autoBackupIntervalMinutes],
|
||||
hasFolder = after[SettingsKeys.autoBackupFolderUri] != null,
|
||||
)
|
||||
}
|
||||
plan.launcherName?.let { name ->
|
||||
step("launcher name") { if (launcherNames.current() != name) launcherNames.set(name) }
|
||||
}
|
||||
plan.language?.let { choice ->
|
||||
step("language") {
|
||||
if (AppLanguage.currentTag(context, supported) != choice.tag) {
|
||||
withContext(Dispatchers.Main) { AppLanguage.apply(context, choice.tag) }
|
||||
requestWidgetRedraw(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
return late
|
||||
}
|
||||
|
||||
/**
|
||||
* Gives late-created mirror calendars their entries (only after a
|
||||
* successful [synced] run), then rewrites each mirror whose all-day
|
||||
* reminders differ from what its events carry: the pre-import override for
|
||||
* a calendar that already existed, the sync's seed for a new one. A mirror
|
||||
* the file doesn't override inherits again.
|
||||
*/
|
||||
private suspend fun matchSpecialDates(
|
||||
plan: SettingsImportPlan,
|
||||
before: Preferences,
|
||||
existingMirrors: Set<Long>,
|
||||
synced: Boolean,
|
||||
): LateMatches {
|
||||
val managed = calendars.findManagedCalendars()
|
||||
val skipped = mutableListOf<SkippedSetting>()
|
||||
var matched = 0
|
||||
val resolved = if (!synced) {
|
||||
skipped += plan.pendingSpecialDates.map { it.calendar.skipped() }
|
||||
emptyList()
|
||||
} else {
|
||||
plan.pendingSpecialDates.mapNotNull { entry ->
|
||||
val id = managed.firstOrNull { it.type == entry.calendar.specialDates }?.id
|
||||
if (id == null) skipped += entry.calendar.skipped() else matched++
|
||||
id?.let { it to entry }
|
||||
}
|
||||
}
|
||||
if (resolved.isNotEmpty()) {
|
||||
store.edit { prefs ->
|
||||
resolved.forEach { (id, entry) -> prefs.addCalendarSettings(id, entry.copy(allDayReminders = null)) }
|
||||
}
|
||||
}
|
||||
if (plan.calendars != null) {
|
||||
val wanted = (plan.calendars.values + resolved.map { it.second })
|
||||
.mapNotNull { entry -> entry.calendar.specialDates?.let { it to entry.allDayReminders } }
|
||||
.toMap()
|
||||
val carried = before[SettingsKeys.perCalendarAllDayReminderOverride]
|
||||
val seeded = store.data.first()[SettingsKeys.perCalendarAllDayReminderOverride]
|
||||
managed.distinctBy { it.type }.forEach { (id, type) ->
|
||||
val minutes = wanted[type]
|
||||
val onEvents = if (id in existingMirrors) carried[id] else seeded[id]
|
||||
if (onEvents == minutes) return@forEach
|
||||
val desired = minutes?.let { mapOf(id to it) }.orEmpty()
|
||||
specialDates.applyReminders(type, desired.reminderOverrideFor(id))
|
||||
}
|
||||
}
|
||||
return LateMatches(matched, skipped)
|
||||
}
|
||||
|
||||
/** The highest byte count [import] is worth reading. */
|
||||
val maxImportBytes: Int get() = SettingsBackupSchema.format.maxChars
|
||||
|
||||
private fun environment(
|
||||
current: Preferences,
|
||||
backup: SettingsBackup,
|
||||
supported: List<String>,
|
||||
): ImportEnvironment = ImportEnvironment(
|
||||
// An empty list here is a failed read as far as the import can tell, never "wipe them all".
|
||||
calendars = if (backup.calendars.isEmpty()) {
|
||||
emptyList()
|
||||
} else {
|
||||
runCatching { calendars.calendars() }.getOrNull()?.takeIf { it.isNotEmpty() }
|
||||
},
|
||||
managed = runCatching { calendars.findManagedCalendars() }.getOrDefault(emptyList()),
|
||||
contactsPermission = contacts.hasPermission(),
|
||||
customFonts = FontRole.entries.filter { CustomFontStore.exists(context, it) }.toSet(),
|
||||
hasBackupFolder = current[SettingsKeys.autoBackupFolderUri] != null,
|
||||
supportedLanguages = supported,
|
||||
)
|
||||
|
||||
private fun Preferences.hasCalendarSettings(): Boolean =
|
||||
SettingsBackupSchema.perCalendar.any { rawValue(it) != null }
|
||||
|
||||
private fun supportedLanguages(): List<String> = AppLanguage.supportedTags(context, R.xml.locales_config)
|
||||
|
||||
private suspend fun step(name: String, block: suspend () -> Unit) {
|
||||
try {
|
||||
block()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Settings import: $name could not be refreshed", e)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "SettingsBackup"
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.data.backup
|
||||
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefKeys
|
||||
import de.jeanlucmakiola.calendula.data.prefs.ReminderStateKeys
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsKeys
|
||||
import de.jeanlucmakiola.floret.backup.BackupFormat
|
||||
import de.jeanlucmakiola.floret.prefs.Pref
|
||||
|
||||
/**
|
||||
* Which stored keys a settings backup carries (#481), and how. Every key of
|
||||
* the settings store is in exactly one of the three lists; a test holds that,
|
||||
* so a new setting cannot ship without deciding. See docs/SETTINGS_BACKUP.md.
|
||||
*/
|
||||
internal object SettingsBackupSchema {
|
||||
|
||||
val format = BackupFormat(name = "calendula-settings", version = 1)
|
||||
|
||||
/** Carried as stored and restored over the current value; an absent key restores the default. */
|
||||
val plain: List<Pref<*>> = listOf(
|
||||
SettingsKeys.themeMode,
|
||||
SettingsKeys.dynamicColor,
|
||||
SettingsKeys.weekStart,
|
||||
SettingsKeys.softenCalendarColors,
|
||||
SettingsKeys.brandFont,
|
||||
SettingsKeys.plainFont,
|
||||
SettingsKeys.timeFormat,
|
||||
SettingsKeys.showHourGrid,
|
||||
SettingsKeys.pastEventDisplay,
|
||||
SettingsKeys.dimCompletedEvents,
|
||||
SettingsKeys.showWeekNumbers,
|
||||
SettingsKeys.monthShowStartTimes,
|
||||
SettingsKeys.monthViewStyle,
|
||||
SettingsKeys.timelineScale,
|
||||
SettingsKeys.multiDayCount,
|
||||
SettingsKeys.timelineHoursEnabled,
|
||||
SettingsKeys.timelineHoursStart,
|
||||
SettingsKeys.timelineHoursEnd,
|
||||
SettingsKeys.todayButtonInToolbar,
|
||||
SettingsKeys.dragToReschedule,
|
||||
SettingsKeys.tapOpensEditor,
|
||||
SettingsKeys.agendaScreenRange,
|
||||
SettingsKeys.agendaShowRangeBar,
|
||||
SettingsKeys.agendaShowToday,
|
||||
SettingsKeys.defaultView,
|
||||
SettingsKeys.quickSwitchConfig,
|
||||
SettingsKeys.drawerViewOrder,
|
||||
SettingsKeys.defaultFormFields,
|
||||
SettingsKeys.autofocusEventTitle,
|
||||
SettingsKeys.defaultEventDurationMinutes,
|
||||
SettingsKeys.remindersEnabled,
|
||||
SettingsKeys.allowColorOnUnsupportedCalendars,
|
||||
SettingsKeys.defaultReminderMinutes,
|
||||
SettingsKeys.defaultAllDayReminderMinutes,
|
||||
SettingsKeys.allDayReminderTimeMinutes,
|
||||
SettingsKeys.snoozeMinutes,
|
||||
SettingsKeys.autoBackupEnabled,
|
||||
SettingsKeys.autoBackupIntervalMinutes,
|
||||
SettingsKeys.specialDatesEnabled,
|
||||
SettingsKeys.specialDatesShowYear,
|
||||
) + SettingsKeys.specialDateTypeEnabled.values + SettingsKeys.specialDatesTitleTemplate.values
|
||||
|
||||
/**
|
||||
* Keyed by calendar id on disk, so carried per calendar under a portable
|
||||
* identity instead ([CalendarSettings]) and re-keyed to this device's ids.
|
||||
*/
|
||||
val perCalendar: List<Pref<*>> = listOf(
|
||||
CalendarPrefKeys.hiddenCalendarIds,
|
||||
CalendarPrefKeys.lastUsedCalendarId,
|
||||
SettingsKeys.perCalendarEventDuration,
|
||||
SettingsKeys.perCalendarReminderOverride,
|
||||
SettingsKeys.perCalendarAllDayReminderOverride,
|
||||
)
|
||||
|
||||
/** Never exported: this install's own state, bookkeeping, or meaningless on another device. */
|
||||
val excluded: List<Pref<*>> = (
|
||||
listOf(
|
||||
// Cache-busting counters for the locally imported font file.
|
||||
SettingsKeys.brandFontStamp,
|
||||
SettingsKeys.plainFontStamp,
|
||||
// Legacy defaults for unconfigured widgets; widget configuration stays out.
|
||||
SettingsKeys.agendaWidgetRange,
|
||||
SettingsKeys.widgetSize,
|
||||
// Usage history, not a setting.
|
||||
SettingsKeys.recentTimeZones,
|
||||
// First-run wizard progress of this install.
|
||||
SettingsKeys.onboardingWizardArmed,
|
||||
CalendarPrefKeys.visibilityNoticePending,
|
||||
// A SAF grant, which does not survive a reinstall or another device.
|
||||
SettingsKeys.autoBackupFolderUri,
|
||||
// Run status.
|
||||
SettingsKeys.autoBackupLastRun,
|
||||
SettingsKeys.autoBackupLastSuccess,
|
||||
SettingsKeys.autoBackupFailures,
|
||||
SettingsKeys.specialDatesLastRun,
|
||||
SettingsKeys.specialDatesStalled,
|
||||
SettingsKeys.specialDatesLastForegroundSync,
|
||||
// Switch-offs still owed to the provider on this device.
|
||||
CalendarPrefKeys.pendingDisabledCalendarIds,
|
||||
// The reminder delivery watermark.
|
||||
ReminderStateKeys.lastScanMillis,
|
||||
) +
|
||||
SettingsKeys.onboardingDone.values +
|
||||
// The mirror re-adopts its calendars by their provider marker.
|
||||
SettingsKeys.specialDatesCalendarId.values
|
||||
).distinct()
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.data.backup
|
||||
|
||||
import androidx.datastore.preferences.core.MutablePreferences
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import de.jeanlucmakiola.calendula.data.appname.LauncherName
|
||||
import de.jeanlucmakiola.calendula.data.calendar.ManagedCalendarRow
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefKeys
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsKeys
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
|
||||
import de.jeanlucmakiola.floret.locale.AppLanguage
|
||||
import de.jeanlucmakiola.floret.prefs.Pref
|
||||
import de.jeanlucmakiola.floret.prefs.get
|
||||
import de.jeanlucmakiola.floret.prefs.rawValue
|
||||
import de.jeanlucmakiola.floret.prefs.set
|
||||
import de.jeanlucmakiola.floret.prefs.setRawValue
|
||||
|
||||
/** What this device can take of a backup, read before planning. */
|
||||
data class ImportEnvironment(
|
||||
/** Null when the calendar list can't be read: the per-calendar settings are then left alone. */
|
||||
val calendars: List<CalendarSource>?,
|
||||
val managed: List<ManagedCalendarRow>,
|
||||
val contactsPermission: Boolean,
|
||||
val customFonts: Set<FontRole>,
|
||||
val hasBackupFolder: Boolean,
|
||||
val supportedLanguages: List<String>,
|
||||
)
|
||||
|
||||
/** Something in the file this device could not take, for the import summary. */
|
||||
sealed interface SkippedSetting {
|
||||
/** No calendar on this device matches the file's, or [ambiguous]ly more than one does. */
|
||||
data class Calendar(
|
||||
val displayName: String,
|
||||
val accountName: String,
|
||||
val ambiguous: Boolean = false,
|
||||
) : SkippedSetting
|
||||
|
||||
/** The calendars couldn't be read, so the per-calendar settings were left as they are. */
|
||||
data object CalendarsUnavailable : SkippedSetting
|
||||
|
||||
/** A custom font: the font file itself is not part of the backup. */
|
||||
data class CustomFont(val role: FontRole) : SkippedSetting
|
||||
|
||||
/** Contact special dates were on, but this device hasn't granted the contacts permission. */
|
||||
data object SpecialDates : SkippedSetting
|
||||
|
||||
/** Automatic backup was on, but its folder has to be chosen again here. */
|
||||
data object AutoBackupFolder : SkippedSetting
|
||||
|
||||
/** The file's app language isn't one this build ships. */
|
||||
data class Language(val tag: String) : SkippedSetting
|
||||
}
|
||||
|
||||
data class SettingsImportSummary(
|
||||
val calendarsMatched: Int,
|
||||
val skipped: List<SkippedSetting>,
|
||||
)
|
||||
|
||||
/**
|
||||
* A validated backup laid onto this device: the stored values to write in one
|
||||
* edit, the side effects the engine runs after it, and the summary.
|
||||
* [calendars] null leaves the per-calendar keys untouched.
|
||||
* [pendingSpecialDates] are mirror calendars that don't exist yet; the engine
|
||||
* matches them once the mirror's sync has created them.
|
||||
*/
|
||||
data class SettingsImportPlan(
|
||||
val prefs: Map<Pref<*>, Any?>,
|
||||
val calendars: Map<Long, CalendarSettings>?,
|
||||
val pendingSpecialDates: List<CalendarSettings>,
|
||||
val language: LanguageChoice?,
|
||||
val launcherName: LauncherName?,
|
||||
val skipped: List<SkippedSetting>,
|
||||
)
|
||||
|
||||
/** Plans an import that replaces every backed-up setting; see docs/SETTINGS_BACKUP.md. */
|
||||
fun planSettingsImport(
|
||||
backup: SettingsBackup,
|
||||
current: Preferences,
|
||||
environment: ImportEnvironment,
|
||||
): SettingsImportPlan {
|
||||
val skipped = mutableListOf<SkippedSetting>()
|
||||
val prefs = backup.prefs.toMutableMap<Pref<*>, Any?>()
|
||||
|
||||
fun keepCurrent(pref: Pref<*>) {
|
||||
prefs[pref] = current.rawValue(pref)
|
||||
}
|
||||
|
||||
FontRole.entries.forEach { role ->
|
||||
val pref = role.fontPref()
|
||||
if (prefs[pref] == FONT_CUSTOM_TOKEN && role !in environment.customFonts) {
|
||||
keepCurrent(pref)
|
||||
skipped += SkippedSetting.CustomFont(role)
|
||||
}
|
||||
}
|
||||
if (prefs[SettingsKeys.specialDatesEnabled] == true && !environment.contactsPermission) {
|
||||
keepCurrent(SettingsKeys.specialDatesEnabled)
|
||||
skipped += SkippedSetting.SpecialDates
|
||||
}
|
||||
if (prefs[SettingsKeys.autoBackupEnabled] == true && !environment.hasBackupFolder) {
|
||||
keepCurrent(SettingsKeys.autoBackupEnabled)
|
||||
skipped += SkippedSetting.AutoBackupFolder
|
||||
}
|
||||
|
||||
val language = backup.language?.let { choice ->
|
||||
val tag = choice.tag ?: return@let choice
|
||||
val resolved = AppLanguage.resolveTag(tag, environment.supportedLanguages)
|
||||
if (resolved in environment.supportedLanguages) {
|
||||
LanguageChoice(resolved)
|
||||
} else {
|
||||
skipped += SkippedSetting.Language(tag)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// The mirror's calendars are torn down when the imported settings leave it off.
|
||||
val mirroring = prefs[SettingsKeys.specialDatesEnabled] == true
|
||||
val available = environment.calendars
|
||||
val calendars = linkedMapOf<Long, CalendarSettings>()
|
||||
val pending = mutableListOf<CalendarSettings>()
|
||||
if (available == null) {
|
||||
if (backup.calendars.isNotEmpty()) skipped += SkippedSetting.CalendarsUnavailable
|
||||
} else {
|
||||
val repeated = backup.calendars.groupingBy { it.calendar }.eachCount().filterValues { it > 1 }.keys
|
||||
backup.calendars.forEach { entry ->
|
||||
val type = entry.calendar.specialDates
|
||||
if (type != null) {
|
||||
// The mirror only keeps calendars for the types the imported settings switch on.
|
||||
val typeOn = prefs[SettingsKeys.specialDateTypeEnabled.getValue(type)] != false
|
||||
if (!mirroring || !typeOn) return@forEach
|
||||
val id = environment.managed.firstOrNull { it.type == type }?.id
|
||||
if (id != null) calendars[id] = entry else pending += entry
|
||||
return@forEach
|
||||
}
|
||||
if (entry.calendar in repeated) {
|
||||
// Two calendars on the exporting phone that this one can't tell apart.
|
||||
val reported = SkippedSetting.Calendar(entry.calendar.displayName, entry.calendar.accountName, ambiguous = true)
|
||||
if (reported !in skipped) skipped += reported
|
||||
return@forEach
|
||||
}
|
||||
val candidates = available.filter { !it.isManaged && it.matches(entry.calendar) }
|
||||
when (candidates.size) {
|
||||
1 -> calendars[candidates.single().id] = entry
|
||||
else -> skipped += entry.calendar.skipped(ambiguous = candidates.size > 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return SettingsImportPlan(
|
||||
prefs = prefs,
|
||||
calendars = calendars.takeIf { available != null },
|
||||
pendingSpecialDates = pending,
|
||||
language = language,
|
||||
launcherName = backup.launcherName,
|
||||
skipped = skipped,
|
||||
)
|
||||
}
|
||||
|
||||
/** Writes the plan's stored values, replacing every backed-up setting and all per-calendar ones. */
|
||||
fun SettingsImportPlan.applyTo(target: MutablePreferences) {
|
||||
plainSection.apply(target, prefs)
|
||||
calendars?.let(target::writeCalendarSettings)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the per-calendar keys with [calendars], by this device's ids. A
|
||||
* last-used calendar only counts from the first entry claiming it.
|
||||
*/
|
||||
fun MutablePreferences.writeCalendarSettings(calendars: Map<Long, CalendarSettings>) {
|
||||
SettingsBackupSchema.perCalendar.forEach { setRawValue(it, null) }
|
||||
calendars.forEach { (id, entry) -> addCalendarSettings(id, entry) }
|
||||
}
|
||||
|
||||
/** Adds one late-matched calendar's settings to what [writeCalendarSettings] stored. */
|
||||
fun MutablePreferences.addCalendarSettings(id: Long, entry: CalendarSettings) {
|
||||
if (entry.hidden) this[CalendarPrefKeys.hiddenCalendarIds] = this[CalendarPrefKeys.hiddenCalendarIds] + id
|
||||
if (entry.lastUsed && this[CalendarPrefKeys.lastUsedCalendarId] == null) {
|
||||
this[CalendarPrefKeys.lastUsedCalendarId] = id
|
||||
}
|
||||
entry.eventDurationMinutes?.let {
|
||||
this[SettingsKeys.perCalendarEventDuration] =
|
||||
this[SettingsKeys.perCalendarEventDuration] + (id to it.clampDuration())
|
||||
}
|
||||
entry.reminders?.let {
|
||||
this[SettingsKeys.perCalendarReminderOverride] = this[SettingsKeys.perCalendarReminderOverride] + (id to it)
|
||||
}
|
||||
entry.allDayReminders?.let {
|
||||
this[SettingsKeys.perCalendarAllDayReminderOverride] =
|
||||
this[SettingsKeys.perCalendarAllDayReminderOverride] + (id to it)
|
||||
}
|
||||
}
|
||||
|
||||
fun CalendarIdentity.skipped(ambiguous: Boolean = false): SkippedSetting =
|
||||
SkippedSetting.Calendar(displayName, accountName, ambiguous)
|
||||
|
||||
fun SettingsImportPlan.summary(calendarsMatched: Int, more: List<SkippedSetting> = emptyList()) =
|
||||
SettingsImportSummary(
|
||||
calendarsMatched = calendarsMatched,
|
||||
skipped = skipped + more,
|
||||
)
|
||||
|
||||
private fun CalendarSource.matches(identity: CalendarIdentity): Boolean =
|
||||
accountType == identity.accountType &&
|
||||
accountName == identity.accountName &&
|
||||
displayName == identity.displayName
|
||||
|
||||
/** The same range the per-calendar duration setter clamps to. */
|
||||
private fun Int.clampDuration(): Int = coerceIn(SettingsPrefs.MIN_EVENT_DURATION, SettingsPrefs.MAX_EVENT_DURATION)
|
||||
|
||||
private fun FontRole.fontPref(): Pref<String> = when (this) {
|
||||
FontRole.BRAND -> SettingsKeys.brandFont
|
||||
FontRole.PLAIN -> SettingsKeys.plainFont
|
||||
}
|
||||
@@ -28,9 +28,9 @@ class CalendarPrefs @Inject constructor(
|
||||
) {
|
||||
private val prefs = PrefStore(store)
|
||||
|
||||
val hiddenCalendarIds: Flow<Set<Long>> = prefs.flow(CalendarPrefKeys.hiddenCalendarIds)
|
||||
val hiddenCalendarIds: Flow<Set<Long>> = prefs.flow(HIDDEN_IDS)
|
||||
|
||||
suspend fun setHiddenCalendarIds(ids: Set<Long>) = prefs.set(CalendarPrefKeys.hiddenCalendarIds, ids)
|
||||
suspend fun setHiddenCalendarIds(ids: Set<Long>) = prefs.set(HIDDEN_IDS, ids)
|
||||
|
||||
/**
|
||||
* Switch-offs the provider does not know about yet, because writing
|
||||
@@ -41,10 +41,10 @@ class CalendarPrefs @Inject constructor(
|
||||
* second visibility model: `CalendarVisibilityReconciler` drains it entry by
|
||||
* entry as soon as the app may write, and nothing adds to it while it may.
|
||||
*/
|
||||
val pendingDisabledCalendarIds: Flow<Set<Long>> = prefs.flow(CalendarPrefKeys.pendingDisabledCalendarIds)
|
||||
val pendingDisabledCalendarIds: Flow<Set<Long>> = prefs.flow(DISABLED_IDS)
|
||||
|
||||
suspend fun addPendingDisabledCalendarIds(ids: Collection<Long>) {
|
||||
prefs.update(CalendarPrefKeys.pendingDisabledCalendarIds) { it + ids }
|
||||
prefs.update(DISABLED_IDS) { it + ids }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,7 +52,7 @@ class CalendarPrefs @Inject constructor(
|
||||
* them, so a run that fails part-way never re-applies what already landed.
|
||||
*/
|
||||
suspend fun removePendingDisabledCalendarIds(ids: Collection<Long>) {
|
||||
prefs.update(CalendarPrefKeys.pendingDisabledCalendarIds) { it - ids.toSet() }
|
||||
prefs.update(DISABLED_IDS) { it - ids.toSet() }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,26 +60,24 @@ class CalendarPrefs @Inject constructor(
|
||||
* owed. Null until the reconciler has evaluated it (which needs the calendar
|
||||
* permission), false once it has been shown or was never needed.
|
||||
*/
|
||||
val visibilityNoticePending: Flow<Boolean?> = prefs.flow(CalendarPrefKeys.visibilityNoticePending)
|
||||
val visibilityNoticePending: Flow<Boolean?> = prefs.flow(VISIBILITY_NOTICE)
|
||||
|
||||
suspend fun setVisibilityNoticePending(pending: Boolean) = prefs.set(CalendarPrefKeys.visibilityNoticePending, pending)
|
||||
suspend fun setVisibilityNoticePending(pending: Boolean) = prefs.set(VISIBILITY_NOTICE, pending)
|
||||
|
||||
/**
|
||||
* The calendar the user last created an event in; preselected in the
|
||||
* event form. Null until the first event is created.
|
||||
*/
|
||||
val lastUsedCalendarId: Flow<Long?> = prefs.flow(CalendarPrefKeys.lastUsedCalendarId)
|
||||
val lastUsedCalendarId: Flow<Long?> = prefs.flow(LAST_USED_CALENDAR)
|
||||
|
||||
suspend fun setLastUsedCalendarId(id: Long) = prefs.set(CalendarPrefKeys.lastUsedCalendarId, id)
|
||||
suspend fun setLastUsedCalendarId(id: Long) = prefs.set(LAST_USED_CALENDAR, id)
|
||||
|
||||
}
|
||||
|
||||
/** Every key [CalendarPrefs] stores; the names are on-disk data. */
|
||||
internal object CalendarPrefKeys {
|
||||
val hiddenCalendarIds: Pref<Set<Long>> = idSetPref("hidden_calendar_ids")
|
||||
val pendingDisabledCalendarIds: Pref<Set<Long>> = idSetPref("disabled_calendar_ids")
|
||||
val visibilityNoticePending: Pref<Boolean?> = nullableBooleanPref("visibility_notice_pending")
|
||||
val lastUsedCalendarId: Pref<Long?> = nullableLongPref("last_used_calendar_id")
|
||||
private companion object {
|
||||
val HIDDEN_IDS = idSetPref("hidden_calendar_ids")
|
||||
val DISABLED_IDS = idSetPref("disabled_calendar_ids")
|
||||
val VISIBILITY_NOTICE = nullableBooleanPref("visibility_notice_pending")
|
||||
val LAST_USED_CALENDAR = nullableLongPref("last_used_calendar_id")
|
||||
}
|
||||
}
|
||||
|
||||
/** A comma-joined id set; non-numeric tokens are dropped, and an empty set removes the key. */
|
||||
|
||||
@@ -2,7 +2,6 @@ package de.jeanlucmakiola.calendula.data.prefs
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import de.jeanlucmakiola.floret.prefs.Pref
|
||||
import de.jeanlucmakiola.floret.prefs.PrefStore
|
||||
import de.jeanlucmakiola.floret.prefs.nullableLongPref
|
||||
import javax.inject.Inject
|
||||
@@ -24,13 +23,11 @@ class ReminderStatePrefs @Inject constructor(
|
||||
private val prefs = PrefStore(store)
|
||||
|
||||
/** The watermark, or `null` before the first scan has ever run. */
|
||||
suspend fun lastScanMillis(): Long? = prefs.get(ReminderStateKeys.lastScanMillis)
|
||||
suspend fun lastScanMillis(): Long? = prefs.get(LAST_SCAN)
|
||||
|
||||
suspend fun setLastScanMillis(millis: Long) = prefs.set(ReminderStateKeys.lastScanMillis, millis)
|
||||
suspend fun setLastScanMillis(millis: Long) = prefs.set(LAST_SCAN, millis)
|
||||
|
||||
}
|
||||
|
||||
/** The key [ReminderStatePrefs] stores. */
|
||||
internal object ReminderStateKeys {
|
||||
val lastScanMillis: Pref<Long?> = nullableLongPref("reminder_last_scan_millis")
|
||||
private companion object {
|
||||
val LAST_SCAN = nullableLongPref("reminder_last_scan_millis")
|
||||
}
|
||||
}
|
||||
@@ -485,8 +485,9 @@ fun CalendarHost(
|
||||
movableCalendarIds = movableCalendarIds,
|
||||
dragEnabled = dragToReschedule,
|
||||
move = { reschedule.move(it) },
|
||||
drops = reschedule.drops,
|
||||
inFlight = reschedule.inFlight,
|
||||
undoStarted = reschedule.undoStarted,
|
||||
abandoned = reschedule.abandoned,
|
||||
edit = onEditEvent,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,9 @@ import java.util.Locale
|
||||
* out in [locale]'s own order, with the year shown only when [date] falls outside
|
||||
* [currentYear].
|
||||
*
|
||||
* Pass [skeleton] *without* a year field, e.g. "LLLL" for a month. A skeleton
|
||||
* is a field list, so wanting the year is just asking for one more field; the
|
||||
* locale still decides where it lands ("July 2026" vs "2026年7月").
|
||||
* Pass [skeleton] *without* a year field — "LLLL" for a month, "EEEdMMM" for a
|
||||
* day. A skeleton is a field list, so wanting the year is just asking for one
|
||||
* more field; the locale still decides where it lands ("July 2026" vs "2026年7月").
|
||||
*
|
||||
* Dropping the year in the current year is the one bit of policy here: the title
|
||||
* sits directly above a grid that already says which year it is, and the year's
|
||||
|
||||
@@ -90,9 +90,6 @@ fun EventMoveHost(
|
||||
MoveOutcome.Undone -> stringResource(R.string.event_move_undone)
|
||||
MoveOutcome.WriteDenied -> stringResource(R.string.event_move_write_denied)
|
||||
MoveOutcome.Gone -> stringResource(R.string.event_move_gone)
|
||||
MoveOutcome.Stale -> stringResource(R.string.event_move_stale)
|
||||
MoveOutcome.Skipped -> stringResource(R.string.event_move_skipped)
|
||||
MoveOutcome.QueueFull -> stringResource(R.string.event_move_queue_full)
|
||||
MoveOutcome.BlockedSeriesEnd -> stringResource(R.string.event_move_blocked_series_end)
|
||||
MoveOutcome.BlockedCancelledSeries -> stringResource(R.string.event_move_blocked_cancelled_series)
|
||||
MoveOutcome.Failed -> stringResource(R.string.event_move_failed)
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import kotlinx.coroutines.delay
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -19,7 +16,6 @@ import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
/**
|
||||
* Drag-to-reschedule wiring (#68), provided once at `CalendarHost` and read by
|
||||
@@ -41,18 +37,24 @@ class EventMoveScope(
|
||||
val dragEnabled: Boolean,
|
||||
/**
|
||||
* Hand a drop to the writer. Returns nothing on purpose: a drop is answered
|
||||
* before its write resolves, so [drops] — not a return value — is what tells
|
||||
* a held copy whether anything landed.
|
||||
* before its write resolves, so [abandoned] — not a return value — is what
|
||||
* tells a held copy that nothing landed.
|
||||
*/
|
||||
val move: (MoveRequest) -> Unit,
|
||||
/**
|
||||
* Each drop's progress by its token — see `RescheduleViewModel.drops`. A flow
|
||||
* rather than a value so this scope stays the same object across a move —
|
||||
* every visible block reads it as a composition local.
|
||||
* True while a dropped event is being written, including the time its scope
|
||||
* dialog is up. A flow rather than a value so this scope stays the same
|
||||
* object across a move — every visible block reads it as a composition local.
|
||||
*/
|
||||
val drops: StateFlow<DropStates>,
|
||||
/** The undo write that just began — see `RescheduleViewModel.undoStarted`. */
|
||||
val undoStarted: StateFlow<UndoStart?>,
|
||||
val inFlight: StateFlow<Boolean>,
|
||||
/** Ticks when an undo write begins — see `RescheduleViewModel.undoStarted`. */
|
||||
val undoStarted: StateFlow<Int>,
|
||||
/**
|
||||
* Ticks when a drop ends with nothing landing — see
|
||||
* `RescheduleViewModel.abandoned`. [move] answers before the write is
|
||||
* resolved, so this is what tells a held copy to stop waiting.
|
||||
*/
|
||||
val abandoned: StateFlow<Int>,
|
||||
/** Open an event in the edit form — the pointer-free route to the same change. */
|
||||
val edit: (EventInstance) -> Unit,
|
||||
) {
|
||||
@@ -61,42 +63,30 @@ class EventMoveScope(
|
||||
|
||||
val LocalEventMove = compositionLocalOf<EventMoveScope?> { null }
|
||||
|
||||
private val moveTokens = AtomicLong()
|
||||
private val NEVER_IN_FLIGHT = MutableStateFlow(false)
|
||||
|
||||
/** A fresh identity for one drop or undo, unique for the process. */
|
||||
fun nextMoveToken(): Long = moveTokens.incrementAndGet()
|
||||
private val NEVER_UNDONE = MutableStateFlow(0)
|
||||
|
||||
/** How many abandoned drops [DropStates] remembers — far more than can be on screen at once. */
|
||||
private const val ABANDONED_KEPT = 32
|
||||
|
||||
/** Per-drop progress, by token — see `RescheduleViewModel.drops`. */
|
||||
@Immutable
|
||||
data class DropStates(
|
||||
val pending: Set<Long> = emptySet(),
|
||||
val abandoned: Set<Long> = emptySet(),
|
||||
) {
|
||||
fun started(token: Long): DropStates = copy(pending = pending + token)
|
||||
|
||||
fun ended(token: Long, landed: Boolean): DropStates = copy(
|
||||
pending = pending - token,
|
||||
// Insertion-ordered, so the oldest go first.
|
||||
abandoned = if (landed) abandoned else (abandoned + token).let { kept ->
|
||||
if (kept.size > ABANDONED_KEPT) kept.drop(kept.size - ABANDONED_KEPT).toSet() else kept
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private val NO_DROPS = MutableStateFlow(DropStates())
|
||||
|
||||
private val NEVER_UNDONE = MutableStateFlow<UndoStart?>(null)
|
||||
private val NEVER_ABANDONED = MutableStateFlow(0)
|
||||
|
||||
/**
|
||||
* Every drop's progress — empty wherever moving is off. A drag overlay keeps
|
||||
* the token of its own drop and holds its copy while that token is pending.
|
||||
* Whether a dropped event is still being written — false wherever moving is off.
|
||||
* The drag overlays hold a landed block on its target for this window.
|
||||
*/
|
||||
@Composable
|
||||
fun dropStates(): DropStates {
|
||||
val flow = LocalEventMove.current?.drops ?: NO_DROPS
|
||||
fun moveInFlight(): Boolean {
|
||||
val flow = LocalEventMove.current?.inFlight ?: NEVER_IN_FLIGHT
|
||||
return flow.collectAsStateWithLifecycle().value
|
||||
}
|
||||
|
||||
/**
|
||||
* How many drops have ended with nothing landing. Read as a plain count so a
|
||||
* drag overlay can capture it at the moment of the drop and tell, later, whether
|
||||
* *its own* drop was one of them.
|
||||
*/
|
||||
@Composable
|
||||
fun abandonedMoves(): Int {
|
||||
val flow = LocalEventMove.current?.abandoned ?: NEVER_ABANDONED
|
||||
return flow.collectAsStateWithLifecycle().value
|
||||
}
|
||||
|
||||
@@ -105,53 +95,17 @@ fun dropStates(): DropStates {
|
||||
* this composable came on screen.
|
||||
*/
|
||||
@Composable
|
||||
fun OnUndoStarted(onUndo: (UndoStart) -> Unit) {
|
||||
fun OnUndoStarted(onUndo: () -> Unit) {
|
||||
val flow = LocalEventMove.current?.undoStarted ?: NEVER_UNDONE
|
||||
val start by flow.collectAsStateWithLifecycle()
|
||||
var seen by remember { mutableStateOf(start) }
|
||||
LaunchedEffect(start) {
|
||||
val started = start
|
||||
if (started == null || started == seen) return@LaunchedEffect
|
||||
seen = started
|
||||
onUndo(started)
|
||||
val tick by flow.collectAsStateWithLifecycle()
|
||||
var seen by remember { mutableIntStateOf(tick) }
|
||||
LaunchedEffect(tick) {
|
||||
if (tick == seen) return@LaunchedEffect
|
||||
seen = tick
|
||||
onUndo()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The life of a drop parked by a newer pickup (#494): held while its [token]
|
||||
* is pending, then a beat after the grid [seated] it — or [timeoutMillis] if it
|
||||
* never does — handed over and faded, then released. Returns the copy's alpha.
|
||||
* An abandoned drop skips the wait; nothing is coming to seat it.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberHeldSettle(
|
||||
token: Long,
|
||||
seated: Boolean,
|
||||
timeoutMillis: Long,
|
||||
onHandOver: () -> Unit,
|
||||
onRelease: () -> Unit,
|
||||
): Float {
|
||||
val drops = dropStates()
|
||||
val pending = token in drops.pending
|
||||
val abandoned = token in drops.abandoned
|
||||
val handOver by rememberUpdatedState(onHandOver)
|
||||
val release by rememberUpdatedState(onRelease)
|
||||
var fading by remember(token) { mutableStateOf(false) }
|
||||
LaunchedEffect(token, pending, abandoned, seated) {
|
||||
if (pending) return@LaunchedEffect
|
||||
if (!abandoned) delay(if (seated) SETTLE_GRACE_MILLIS else timeoutMillis)
|
||||
fading = true
|
||||
handOver()
|
||||
delay(SETTLE_FADE_MILLIS.toLong())
|
||||
release()
|
||||
}
|
||||
return animateFloatAsState(
|
||||
targetValue = if (fading) 0f else 1f,
|
||||
animationSpec = tween(SETTLE_FADE_MILLIS),
|
||||
label = "held-settle",
|
||||
).value
|
||||
}
|
||||
|
||||
/** Opacity the source block keeps while its floating copy travels. */
|
||||
const val GHOST_ALPHA: Float = 0.3f
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
@@ -41,21 +40,12 @@ import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.Instant
|
||||
import kotlin.time.TimeMark
|
||||
import kotlin.time.TimeSource
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val MILLIS_PER_MINUTE = 60_000
|
||||
private const val MINUTES_PER_DAY = 24 * 60
|
||||
|
||||
/** How many drops may wait behind the one in flight before more are refused. */
|
||||
private const val MAX_QUEUED = 8
|
||||
|
||||
/** How long after a write its event's drawn times may still be the old ones. */
|
||||
private val RECENT_WRITE = 3.seconds
|
||||
|
||||
/** Where a dragged event should land. */
|
||||
sealed interface MoveTarget {
|
||||
/** A new start instant — a timeline drag, which moves time and day at once. */
|
||||
@@ -84,10 +74,6 @@ data class MoveRequest(
|
||||
val beginMillis: Long,
|
||||
val endMillis: Long,
|
||||
val target: MoveTarget,
|
||||
/** Whether the dragged occurrence is all-day, which decides the zone its day is read in. */
|
||||
val isAllDay: Boolean,
|
||||
/** The drop's own identity, from [nextMoveToken], which the view holding its copy waits on. */
|
||||
val token: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -106,13 +92,8 @@ data class MoveUndo(
|
||||
val moved: EventForm,
|
||||
/** The form as it stood before the move. */
|
||||
val restored: EventForm,
|
||||
/** The token of the drop this undoes, so the view that drew it carries the right chip back. */
|
||||
val dropToken: Long,
|
||||
)
|
||||
|
||||
/** An undo write that just began: its own [token], and the [dropToken] of the drop it undoes. */
|
||||
data class UndoStart(val token: Long, val dropToken: Long)
|
||||
|
||||
sealed interface MoveOutcome {
|
||||
/**
|
||||
* Written. [startMillis] is where the dragged occurrence now begins, for the
|
||||
@@ -134,19 +115,6 @@ sealed interface MoveOutcome {
|
||||
/** The event vanished (sync, another device) between the read and the write. */
|
||||
data object Gone : MoveOutcome
|
||||
|
||||
/** Drops that waited behind a scope dialog, cancelled rather than run after it (#494). */
|
||||
data object Skipped : MoveOutcome
|
||||
|
||||
/** More drops waiting than the queue holds; this one was not taken. */
|
||||
data object QueueFull : MoveOutcome
|
||||
|
||||
/**
|
||||
* The drop's recurring occurrence may no longer be where it was picked up —
|
||||
* its series was just written — and can't be followed there, so it is not
|
||||
* written (#494).
|
||||
*/
|
||||
data object Stale : MoveOutcome
|
||||
|
||||
/** The drop would push the series past its own `UNTIL`, generating nothing. */
|
||||
data object BlockedSeriesEnd : MoveOutcome
|
||||
|
||||
@@ -184,32 +152,36 @@ class RescheduleViewModel @Inject constructor(
|
||||
|
||||
private val _inFlight = MutableStateFlow(false)
|
||||
|
||||
/** True while any drop or undo runs or waits — what the confirmation chip holds for. */
|
||||
/**
|
||||
* True from the moment a drop is accepted until its write settles — the
|
||||
* window the dropped block holds its landing position for.
|
||||
*/
|
||||
val inFlight: StateFlow<Boolean> = _inFlight.asStateFlow()
|
||||
|
||||
private val _drops = MutableStateFlow(DropStates())
|
||||
private val _abandoned = MutableStateFlow(0)
|
||||
|
||||
/**
|
||||
* Each drop's own state, by its [MoveRequest.token]: pending from the moment
|
||||
* it is accepted until its write settles, and abandoned once it ends with
|
||||
* nothing on the target day — refused, failed, or its scope dialog dismissed.
|
||||
* Per drop, so a queued drop's held copy waits on its own write and is never
|
||||
* let go by another drop's failure (#253, #494).
|
||||
* Ticks when a drop ends with nothing on the target day: refused outright,
|
||||
* refused before the write, written and failed, or its scope dialog
|
||||
* dismissed. [move] answers before any of that is known, so without this
|
||||
* signal the view that drew the drop held its copy out for the full settle
|
||||
* timeout waiting for a grid that was never going to draw it (#253).
|
||||
*/
|
||||
val drops: StateFlow<DropStates> = _drops.asStateFlow()
|
||||
val abandoned: StateFlow<Int> = _abandoned.asStateFlow()
|
||||
|
||||
private val _undoStarted = MutableStateFlow<UndoStart?>(null)
|
||||
private val _undoStarted = MutableStateFlow(0)
|
||||
|
||||
/**
|
||||
* An undo write the moment it begins, before the provider has anything to
|
||||
* Ticks the moment an undo write begins, before the provider has anything to
|
||||
* re-read, so the view that drew the drop can carry its chip back rather than
|
||||
* let it reappear on the old day. Its fate then reads off [drops].
|
||||
* let it reappear on the old day.
|
||||
*/
|
||||
val undoStarted: StateFlow<UndoStart?> = _undoStarted.asStateFlow()
|
||||
val undoStarted: StateFlow<Int> = _undoStarted.asStateFlow()
|
||||
|
||||
/**
|
||||
* Set while a drop is being prepared, waits on its scope dialog, or is
|
||||
* written. Drops arriving in that window wait in [queue] and run in order.
|
||||
* Set from the moment a drop is accepted until its write settles. Two drops
|
||||
* of the same recurring event inside that window would compute their shift
|
||||
* from the same pre-move occurrence, and the shifts would compound.
|
||||
*/
|
||||
private var busy = false
|
||||
set(value) {
|
||||
@@ -217,22 +189,6 @@ class RescheduleViewModel @Inject constructor(
|
||||
_inFlight.value = value
|
||||
}
|
||||
|
||||
/** Whether a move has been announced since the pipeline was last idle — see [report]. */
|
||||
private var movedThisRun = false
|
||||
|
||||
/** Outcomes waiting for the chip, shown one after another as each is consumed. */
|
||||
private val notices = ArrayDeque<MoveOutcome>()
|
||||
|
||||
/**
|
||||
* When each event's series (by id) or single occurrence (by id and begin)
|
||||
* was last written. The grid draws the old times until it re-reads, so a
|
||||
* drop on a recurring event written moments ago may carry stale times (#494).
|
||||
*/
|
||||
private val written = mutableMapOf<Pair<Long, Long?>, TimeMark>()
|
||||
|
||||
/** Drops that arrived while another was in flight, oldest first (#494). */
|
||||
private val queue = ArrayDeque<MoveRequest>()
|
||||
|
||||
/**
|
||||
* The calendars whose events may be dragged. Nothing below the UI guards
|
||||
* this, so a block outside this set registers no drag gesture at all.
|
||||
@@ -263,50 +219,38 @@ class RescheduleViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a drop: run it now, or queue it behind the ones in flight. False means
|
||||
* nothing will be written — the queue is full — and the drop is marked
|
||||
* abandoned in [drops], so its held copy is let go by the one signal rather
|
||||
* than by every caller remembering to.
|
||||
* Take a drop, unless one is already being written. False means nothing will
|
||||
* be written; [abandoned] ticks for that refusal too, so a held copy is let
|
||||
* go by the one signal rather than by every caller remembering to.
|
||||
*
|
||||
* The Boolean is diagnostic — the tests read it, [EventMoveScope] does not
|
||||
* hand it on. It answers before the write is resolved anyway.
|
||||
* hand it on. A caller that gated on it would be back to releasing its copy
|
||||
* from two places, and it answers before the write is resolved anyway.
|
||||
*/
|
||||
fun move(request: MoveRequest): Boolean {
|
||||
if (queue.size >= MAX_QUEUED) {
|
||||
_drops.update { it.ended(request.token, landed = false) }
|
||||
report(MoveOutcome.QueueFull)
|
||||
if (busy || _scopePrompt.value != null) {
|
||||
_abandoned.value += 1
|
||||
return false
|
||||
}
|
||||
if (_scopePrompt.value != null) {
|
||||
_drops.update { it.ended(request.token, landed = false) }
|
||||
report(MoveOutcome.Skipped)
|
||||
return false
|
||||
}
|
||||
_drops.update { it.started(request.token) }
|
||||
if (busy) queue.addLast(request) else run(request)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun run(request: MoveRequest) {
|
||||
busy = true
|
||||
viewModelScope.launch {
|
||||
val prepared = prepare(request)
|
||||
if (prepared == null) {
|
||||
settle(request.token, landed = false)
|
||||
_abandoned.value += 1
|
||||
busy = false
|
||||
return@launch
|
||||
}
|
||||
// A cancelled series only moves as a whole (#388).
|
||||
val seriesOnly = !prepared.original.canSaveApartFromSeries()
|
||||
if (!prepared.isRecurring || (seriesOnly && prepared.canRealign)) {
|
||||
settle(request.token, write(prepared, RecurringWriteScope.AllEvents))
|
||||
write(prepared, RecurringWriteScope.AllEvents)
|
||||
busy = false
|
||||
} else if (seriesOnly) {
|
||||
report(MoveOutcome.BlockedCancelledSeries)
|
||||
settle(request.token, landed = false)
|
||||
_outcome.value = MoveOutcome.BlockedCancelledSeries
|
||||
_abandoned.value += 1
|
||||
busy = false
|
||||
} else {
|
||||
// Still busy: the scope dialog is now the thing in flight. Drops
|
||||
// waiting behind it are cancelled, not held behind a question
|
||||
// about another event.
|
||||
skipQueued()
|
||||
// Still busy: the scope dialog is now the thing in flight.
|
||||
pending = prepared
|
||||
_scopePrompt.value = MoveScopePrompt(
|
||||
occurrenceOnly = !prepared.canRealign,
|
||||
@@ -314,6 +258,7 @@ class RescheduleViewModel @Inject constructor(
|
||||
)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Answer the scope dialog. */
|
||||
@@ -324,92 +269,35 @@ class RescheduleViewModel @Inject constructor(
|
||||
pending = null
|
||||
_scopePrompt.value = null
|
||||
viewModelScope.launch {
|
||||
settle(prepared.request.token, write(prepared, scope))
|
||||
write(prepared, scope)
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Dismiss the scope dialog without writing. */
|
||||
fun cancelScope() {
|
||||
val prepared = pending ?: return
|
||||
pending = null
|
||||
busy = false
|
||||
_scopePrompt.value = null
|
||||
settle(prepared.request.token, landed = false)
|
||||
}
|
||||
|
||||
/** End the drop [token], then run the next queued one, if any. */
|
||||
private fun settle(token: Long, landed: Boolean) {
|
||||
_drops.update { it.ended(token, landed) }
|
||||
// Straight on to the next, so busy never reads false between two drops.
|
||||
val next = queue.removeFirstOrNull()
|
||||
if (next != null) {
|
||||
run(next)
|
||||
} else {
|
||||
busy = false
|
||||
movedThisRun = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun skipQueued() {
|
||||
if (queue.isEmpty()) return
|
||||
val skipped = queue.map { it.token }
|
||||
queue.clear()
|
||||
_drops.update { states -> skipped.fold(states) { acc, token -> acc.ended(token, landed = false) } }
|
||||
report(MoveOutcome.Skipped)
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue [outcome] for the chip. While drops run back to back only the first
|
||||
* move is announced, so its Undo stays on offer; every failure gets its own
|
||||
* turn after it, since the grid shows what moved but only the chip says what
|
||||
* didn't. A later write to the announced move's event takes that Undo away,
|
||||
* as putting the event back would undo the later move too.
|
||||
*/
|
||||
private fun report(outcome: MoveOutcome, eventId: Long? = null) {
|
||||
if (outcome is MoveOutcome.Moved) {
|
||||
if (movedThisRun) {
|
||||
dropUndoFor(eventId)
|
||||
return
|
||||
}
|
||||
movedThisRun = true
|
||||
} else if (outcome == (notices.lastOrNull() ?: _outcome.value)) {
|
||||
return
|
||||
}
|
||||
if (_outcome.value == null) _outcome.value = outcome else notices.addLast(outcome)
|
||||
}
|
||||
|
||||
private fun dropUndoFor(eventId: Long?) {
|
||||
fun MoveOutcome.withoutUndo(): MoveOutcome =
|
||||
if (this is MoveOutcome.Moved && undo?.eventId == eventId) copy(undo = null) else this
|
||||
_outcome.value = _outcome.value?.withoutUndo()
|
||||
val rest = notices.map { it.withoutUndo() }
|
||||
notices.clear()
|
||||
notices.addAll(rest)
|
||||
}
|
||||
|
||||
private fun markWritten(eventId: Long, beginMillis: Long?) {
|
||||
written[eventId to beginMillis] = TimeSource.Monotonic.markNow()
|
||||
}
|
||||
|
||||
private fun writtenRecently(eventId: Long, beginMillis: Long): Boolean {
|
||||
written.entries.removeAll { it.value.elapsedNow() > RECENT_WRITE }
|
||||
return (eventId to null) in written || (eventId to beginMillis) in written
|
||||
_abandoned.value += 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Put a completed move back where it came from. The outcome stands until the
|
||||
* inverse write reports back, so the one chip changes what it says rather
|
||||
* than closing and reopening. False when another write is already running —
|
||||
* the chip hides its action for that window, so this is the backstop. An
|
||||
* undo is a journey of its own: [undoStarted] names its token, and [drops]
|
||||
* reports whether it landed.
|
||||
* the chip hides its action for that window, so this is the backstop.
|
||||
*
|
||||
* [abandoned] ticks once an inverse that *started* fails to land: [undoStarted]
|
||||
* has sent the chip back on its journey by then and nothing else will seat it.
|
||||
* A refusal ticks nothing — it launched no journey of its own, and the counter
|
||||
* is global, so a tick here would release some other drop's held copy before
|
||||
* the grid has drawn it.
|
||||
*/
|
||||
fun undo(undo: MoveUndo): Boolean {
|
||||
if (busy) return false
|
||||
val token = nextMoveToken()
|
||||
_drops.update { it.started(token) }
|
||||
busy = true
|
||||
_undoStarted.value = UndoStart(token, undo.dropToken)
|
||||
markWritten(undo.eventId, null)
|
||||
_undoStarted.value += 1
|
||||
viewModelScope.launch {
|
||||
_outcome.value = try {
|
||||
repository.updateEvent(undo.eventId, undo.moved, undo.restored)
|
||||
@@ -423,40 +311,33 @@ class RescheduleViewModel @Inject constructor(
|
||||
} catch (e: Exception) {
|
||||
MoveOutcome.Failed
|
||||
}
|
||||
settle(token, landed = _outcome.value is MoveOutcome.Undone)
|
||||
// Read off the outcome, the way [write] does — no second copy of the
|
||||
// same fact to drift from it.
|
||||
if (_outcome.value !is MoveOutcome.Undone) _abandoned.value += 1
|
||||
busy = false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Clear the outcome once the screen has shown it, bringing on the next one waiting. */
|
||||
/** Clear the outcome once the screen has shown it. */
|
||||
fun consumeOutcome() {
|
||||
_outcome.value = notices.removeFirstOrNull()
|
||||
_outcome.value = null
|
||||
}
|
||||
|
||||
private suspend fun prepare(dropped: MoveRequest): PreparedMove? {
|
||||
val zone = zoneSource.current()
|
||||
private suspend fun prepare(request: MoveRequest): PreparedMove? {
|
||||
val detail = try {
|
||||
repository.eventDetail(dropped.eventId)
|
||||
repository.eventDetail(request.eventId)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: NoSuchEventException) {
|
||||
report(MoveOutcome.Gone)
|
||||
_outcome.value = MoveOutcome.Gone
|
||||
return null
|
||||
} catch (e: Exception) {
|
||||
report(MoveOutcome.Failed)
|
||||
_outcome.value = MoveOutcome.Failed
|
||||
return null
|
||||
}
|
||||
// The drop carries the times the grid drew, which an earlier write may
|
||||
// have moved on since (#494).
|
||||
val request = if (detail.rrule == null || detail.isException) {
|
||||
followed(dropped, detail, zone)
|
||||
} else if (writtenRecently(dropped.eventId, dropped.beginMillis)) {
|
||||
report(MoveOutcome.Stale)
|
||||
return null
|
||||
} else {
|
||||
dropped
|
||||
}
|
||||
|
||||
val zone = zoneSource.current()
|
||||
val original = detail.toEditForm(request.beginMillis, request.endMillis, zone)
|
||||
val shifted = when (val target = request.target) {
|
||||
is MoveTarget.Start -> original.shiftedTo(target.instant, zone)
|
||||
@@ -469,7 +350,7 @@ class RescheduleViewModel @Inject constructor(
|
||||
// The UNTIL check is deferred to the write, which knows how far the move
|
||||
// reaches and so which date has to clear it.
|
||||
if ((shifted.problems(zone) - EventFormProblem.RecurrenceEndsBeforeStart).isNotEmpty()) {
|
||||
report(MoveOutcome.Failed)
|
||||
_outcome.value = MoveOutcome.Failed
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -514,14 +395,14 @@ class RescheduleViewModel @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
/** Write [prepared] in [scope]; true when it landed. */
|
||||
private suspend fun write(prepared: PreparedMove, scope: RecurringWriteScope): Boolean {
|
||||
private suspend fun write(prepared: PreparedMove, scope: RecurringWriteScope) {
|
||||
val request = prepared.request
|
||||
if (endsBeforeItStarts(prepared, scope)) {
|
||||
report(MoveOutcome.BlockedSeriesEnd)
|
||||
return false
|
||||
_outcome.value = MoveOutcome.BlockedSeriesEnd
|
||||
_abandoned.value += 1
|
||||
return
|
||||
}
|
||||
val outcome = try {
|
||||
_outcome.value = try {
|
||||
when {
|
||||
!prepared.isRecurring || scope == RecurringWriteScope.AllEvents ->
|
||||
repository.updateEvent(request.eventId, prepared.original, prepared.updated)
|
||||
@@ -556,37 +437,9 @@ class RescheduleViewModel @Inject constructor(
|
||||
} catch (e: Exception) {
|
||||
MoveOutcome.Failed
|
||||
}
|
||||
if (outcome is MoveOutcome.Moved) {
|
||||
markWritten(request.eventId, request.beginMillis.takeIf { scope == RecurringWriteScope.ThisEvent && prepared.isRecurring })
|
||||
}
|
||||
report(outcome, request.eventId)
|
||||
return outcome is MoveOutcome.Moved
|
||||
}
|
||||
|
||||
/**
|
||||
* A one-off [dropped] rebased onto where its event now is, so the last drop
|
||||
* wins: absolute targets stand as they are, and a whole-day shift is re-aimed
|
||||
* at the day it was dropped on. Unchanged when the event hasn't moved.
|
||||
*/
|
||||
private fun followed(dropped: MoveRequest, detail: EventDetail, zone: TimeZone): MoveRequest {
|
||||
val now = detail.instance
|
||||
val target = when (val target = dropped.target) {
|
||||
is MoveTarget.ByDays -> {
|
||||
// Each day read the way its own kind anchors it: all-day at UTC.
|
||||
val aimed = Instant.fromEpochMilliseconds(dropped.beginMillis)
|
||||
.toLocalDateTime(if (dropped.isAllDay) TimeZone.UTC else zone).date
|
||||
.plus(target.days, DateTimeUnit.DAY)
|
||||
val from = now.start.toLocalDateTime(if (now.isAllDay) TimeZone.UTC else zone).date
|
||||
MoveTarget.ByDays((aimed.toEpochDays() - from.toEpochDays()).toInt())
|
||||
}
|
||||
else -> target
|
||||
}
|
||||
return dropped.copy(
|
||||
beginMillis = now.start.toEpochMilliseconds(),
|
||||
endMillis = now.end.toEpochMilliseconds(),
|
||||
target = target,
|
||||
isAllDay = now.isAllDay,
|
||||
)
|
||||
// Nothing landed, so the copy the view is holding has nothing to fade
|
||||
// into — let it go now rather than at the end of the settle timeout.
|
||||
if (_outcome.value !is MoveOutcome.Moved) _abandoned.value += 1
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -653,7 +506,6 @@ class RescheduleViewModel @Inject constructor(
|
||||
eventId = prepared.request.eventId,
|
||||
moved = prepared.updated,
|
||||
restored = prepared.original,
|
||||
dropToken = prepared.request.token,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
|
||||
@@ -20,7 +20,6 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -61,6 +60,7 @@ import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
@@ -139,17 +139,9 @@ data class StripDrag(val event: EventInstance, val dayShift: Int = 0, val overGr
|
||||
sealed interface StripDrop {
|
||||
val event: EventInstance
|
||||
|
||||
/** The drop's identity, which its held copy waits on. */
|
||||
val token: Long
|
||||
data class Days(override val event: EventInstance, val days: Int) : StripDrop
|
||||
|
||||
data class Days(override val event: EventInstance, val days: Int, override val token: Long) : StripDrop
|
||||
|
||||
data class Timed(
|
||||
override val event: EventInstance,
|
||||
val start: Instant,
|
||||
val end: Instant,
|
||||
override val token: Long,
|
||||
) : StripDrop
|
||||
data class Timed(override val event: EventInstance, val start: Instant, val end: Instant) : StripDrop
|
||||
}
|
||||
|
||||
/** The event a long press left selected (#141). */
|
||||
@@ -305,9 +297,7 @@ data class TimelineDrop(
|
||||
val date: LocalDate,
|
||||
val startMin: Int,
|
||||
/** Minutes from the event's start to the dragged block's top edge. */
|
||||
val clipOffsetMin: Int,
|
||||
/** The drop's identity, which its held copy waits on. */
|
||||
val token: Long,
|
||||
val clipOffsetMin: Int = 0,
|
||||
)
|
||||
|
||||
/** The id a drafted block's stand-in event carries, which no provider row has (#153). */
|
||||
@@ -350,37 +340,8 @@ internal fun draftBlock(draft: EventInstance, date: LocalDate, zone: TimeZone):
|
||||
/** Which end of an event a resize handle moves (#141). */
|
||||
enum class ResizeEdge { Top, Bottom }
|
||||
|
||||
/** A drop parked by a newer pickup while its write still runs — see [TimelineDragController.held]. */
|
||||
data class HeldDrop(
|
||||
val copy: TimelineDrag,
|
||||
val token: Long,
|
||||
val instanceId: Long,
|
||||
/** The event row and start the drop came from, as [TimelineDragController.ghosts] matches them. */
|
||||
val origin: Pair<Long, Instant>,
|
||||
/** A resize's new end, which has to have landed too before the drop counts as seated. */
|
||||
val end: Instant?,
|
||||
/** Whether the grid now draws the drop at its landing slot. */
|
||||
val seated: Boolean = false,
|
||||
val handedOver: Boolean = false,
|
||||
) {
|
||||
fun ghosts(block: TimedBlock): Boolean = block.event.instanceId == instanceId ||
|
||||
(block.event.eventId == origin.first && block.event.start == origin.second)
|
||||
}
|
||||
|
||||
/** A strip drop parked by a newer one while its write still runs — see [TimelineDragController.heldStrips]. */
|
||||
data class HeldStrip(val drag: StripDrag, val token: Long, val seated: Boolean = false)
|
||||
|
||||
/** An event's new start and end, as a resize leaves them. */
|
||||
data class ResizedSpan(val start: Instant, val end: Instant)
|
||||
|
||||
/** Where a finished resize asks its event to begin and end. */
|
||||
data class TimelineResizeDrop(
|
||||
val event: EventInstance,
|
||||
val start: Instant,
|
||||
val end: Instant,
|
||||
/** The drop's identity, which its held copy waits on. */
|
||||
val token: Long,
|
||||
)
|
||||
data class TimelineResizeDrop(val event: EventInstance, val start: Instant, val end: Instant)
|
||||
|
||||
/**
|
||||
* [event] resized by its [edge] to the span [startMin]..+[spanMin] from [date]'s
|
||||
@@ -391,16 +352,17 @@ data class TimelineResizeDrop(
|
||||
* it was drawn (see [repicked]). An end takes a pass the grid draws at the drop
|
||||
* point, so the block settles under the finger.
|
||||
*/
|
||||
internal fun resizedSpan(
|
||||
internal fun resizedDrop(
|
||||
event: EventInstance,
|
||||
edge: ResizeEdge?,
|
||||
date: LocalDate,
|
||||
startMin: Int,
|
||||
spanMin: Int,
|
||||
zone: TimeZone,
|
||||
): ResizedSpan {
|
||||
): TimelineResizeDrop {
|
||||
val drawn = wallClockSpan(event, zone)
|
||||
return ResizedSpan(
|
||||
return TimelineResizeDrop(
|
||||
event = event,
|
||||
start = if (edge == ResizeEdge.Top) {
|
||||
event.start.toFormTime(zone).repicked(
|
||||
wallClockTime(date, startMin),
|
||||
@@ -551,14 +513,6 @@ class TimelineDragController {
|
||||
*/
|
||||
private var settledOrigin: Pair<Long, Instant>? by mutableStateOf(null)
|
||||
|
||||
/**
|
||||
* Earlier drops still being written when another block was picked up. They
|
||||
* keep their copy and their ghost until their own token settles, rather than
|
||||
* vanishing and flashing back at the old slot (#494).
|
||||
*/
|
||||
var held: List<HeldDrop> by mutableStateOf(emptyList())
|
||||
private set
|
||||
|
||||
/** How long a block drafted on an empty slot starts out, in minutes (#153). */
|
||||
var draftMinutes: Int = SettingsPrefs.DEFAULT_EVENT_DURATION
|
||||
|
||||
@@ -657,8 +611,14 @@ class TimelineDragController {
|
||||
var settlingSelected: Boolean = false
|
||||
private set
|
||||
|
||||
/** The token of the drop [settling] waits on. */
|
||||
var settledToken: Long = 0L
|
||||
/**
|
||||
* The abandoned-move counter, read at the drop so the mark can't be a
|
||||
* composition behind the tick it is compared against.
|
||||
*/
|
||||
var abandonedTicks: StateFlow<Int>? = null
|
||||
|
||||
/** What [abandonedTicks] stood at when the settling drop was let go. */
|
||||
var settledAbandonedMark: Int = 0
|
||||
private set
|
||||
|
||||
private var source: TimedBlock? = null
|
||||
@@ -701,12 +661,8 @@ class TimelineDragController {
|
||||
var stripSeated: Boolean by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
/** Strip drops parked by a newer strip drop while their writes still run (#494). */
|
||||
var heldStrips: List<HeldStrip> by mutableStateOf(emptyList())
|
||||
private set
|
||||
|
||||
/** The token of the drop [stripSettling] waits on. */
|
||||
var stripToken: Long = 0L
|
||||
/** What [abandonedTicks] stood at when [stripSettling] was let go. */
|
||||
var stripAbandonedMark: Int = 0
|
||||
private set
|
||||
|
||||
/** The shown day the finger picked the strip bar up on. */
|
||||
@@ -731,7 +687,7 @@ class TimelineDragController {
|
||||
dragDuration = (block.event.end - block.event.start).coerceAtLeast(Duration.ZERO)
|
||||
dragKept = zone.repeatedHourOffsetAt(block.event.start)
|
||||
this.titleLines = titleLines
|
||||
parkSettling()
|
||||
settling = null
|
||||
isDragging = true
|
||||
liftedInstanceId = block.event.instanceId
|
||||
grab = pointerInRoot - blockInRoot
|
||||
@@ -770,7 +726,7 @@ class TimelineDragController {
|
||||
dragDuration = draftMinutes.minutes
|
||||
dragKept = null
|
||||
titleLines = 1
|
||||
parkSettling()
|
||||
settling = null
|
||||
isDragging = true
|
||||
liftedInstanceId = event.instanceId
|
||||
grab = Offset(0f, geometry.hourPx * STRIP_GRAB_MINUTES / 60f)
|
||||
@@ -793,18 +749,16 @@ class TimelineDragController {
|
||||
val zone = dragZone
|
||||
cancel()
|
||||
if (held.overGrid && landed != null && zone != null && !landed.edgeOnly) {
|
||||
val token = settle(landed)
|
||||
val start = dropStartInstant(landed.event, landed.date, landed.startMin, clipOffsetMin = 0, zone)
|
||||
return StripDrop.Timed(held.event, start, start + draftMinutes.minutes, token)
|
||||
settle(landed)
|
||||
val start = TimelineDrop(landed.event, landed.date, landed.startMin).startInstant(zone)
|
||||
return StripDrop.Timed(held.event, start, start + draftMinutes.minutes)
|
||||
}
|
||||
if (held.overGrid || held.dayShift == 0) return null
|
||||
// One still settling keeps its place on the strip until its own write lands (#494).
|
||||
stripSettling?.let { heldStrips = heldStrips + HeldStrip(it, stripToken, stripSeated) }
|
||||
stripSettling = held
|
||||
stripSeated = false
|
||||
stripToken = nextMoveToken()
|
||||
stripAbandonedMark = abandonedTicks?.value ?: 0
|
||||
drops++
|
||||
return StripDrop.Days(held.event, held.dayShift, stripToken)
|
||||
return StripDrop.Days(held.event, held.dayShift)
|
||||
}
|
||||
|
||||
/** Let the held strip drop go, once the strip shows the event at its new days or the move failed. */
|
||||
@@ -818,21 +772,6 @@ class TimelineDragController {
|
||||
if (stripSettling != null) stripSeated = true
|
||||
}
|
||||
|
||||
/** Note that the strip draws the parked strip drop [token] at its new days. */
|
||||
fun seatHeldStrip(token: Long) {
|
||||
if (heldStrips.any { it.token == token && !it.seated }) {
|
||||
heldStrips = heldStrips.map { if (it.token == token) it.copy(seated = true) else it }
|
||||
}
|
||||
}
|
||||
|
||||
/** Let the parked strip drop [token] go. */
|
||||
fun releaseHeldStrip(token: Long) {
|
||||
heldStrips = heldStrips.filterNot { it.token == token }
|
||||
}
|
||||
|
||||
/** Every strip drop still shown at its new days: the parked ones, then the latest. */
|
||||
fun stripPreviews(): List<StripDrag> = heldStrips.map { it.drag } + listOfNotNull(stripDrag ?: stripSettling)
|
||||
|
||||
fun move(pointerInRoot: Offset) {
|
||||
pointer = pointerInRoot
|
||||
if (!pointerMoved && (pointerInRoot - pickupPointer).getDistance() > slopPx) pointerMoved = true
|
||||
@@ -867,36 +806,12 @@ class TimelineDragController {
|
||||
* an event that crosses midnight (#253).
|
||||
*/
|
||||
fun ghosts(block: TimedBlock): Boolean {
|
||||
if (held.any { !it.handedOver && it.ghosts(block) }) return true
|
||||
if (liftedInstanceId == null) return false
|
||||
if (block.event.instanceId == liftedInstanceId) return true
|
||||
val (eventId, start) = settledOrigin ?: return false
|
||||
return block.event.eventId == eventId && block.event.start == start
|
||||
}
|
||||
|
||||
/** Move a still-settling drop to [held] before a new pickup takes the slot. */
|
||||
private fun parkSettling() {
|
||||
val landed = settling
|
||||
val origin = settledOrigin
|
||||
if (landed != null && origin != null && !landed.event.isDraft) {
|
||||
held = held + HeldDrop(landed, settledToken, landed.event.instanceId, origin, settledEnd, settledOnGrid)
|
||||
}
|
||||
settling = null
|
||||
settledOnGrid = false
|
||||
settledOrigin = null
|
||||
settledEnd = null
|
||||
}
|
||||
|
||||
/** Stop ghosting the held drop [token]'s source while its copy fades. */
|
||||
fun handOverHeld(token: Long) {
|
||||
held = held.map { if (it.token == token) it.copy(handedOver = true) else it }
|
||||
}
|
||||
|
||||
/** Drop the held copy [token] once it has faded. */
|
||||
fun releaseHeld(token: Long) {
|
||||
held = held.filterNot { it.token == token }
|
||||
}
|
||||
|
||||
/**
|
||||
* What a day column now holds, so a settled drop can tell when the grid has
|
||||
* caught up with it. Matched on the landing slot plus either the event row
|
||||
@@ -904,23 +819,16 @@ class TimelineDragController {
|
||||
* blank title matches nothing: every untitled event has one (#253).
|
||||
*/
|
||||
fun noteGrid(date: LocalDate, blocks: List<TimedBlock>) {
|
||||
if (held.any { !it.seated && drawsDrop(it.copy, it.end, date, blocks) }) {
|
||||
held = held.map { if (!it.seated && drawsDrop(it.copy, it.end, date, blocks)) it.copy(seated = true) else it }
|
||||
}
|
||||
val landed = settling ?: return
|
||||
if (settledOnGrid) return
|
||||
settledOnGrid = drawsDrop(landed, settledEnd, date, blocks)
|
||||
}
|
||||
|
||||
/** Whether [blocks], [date]'s column, draws the drop [landed] where it landed. */
|
||||
private fun drawsDrop(landed: TimelineDrag, end: Instant?, date: LocalDate, blocks: List<TimedBlock>): Boolean {
|
||||
// Where this day would draw it, which for a drop clipped at midnight is
|
||||
// the day's own start rather than the edge the finger held (#253) — and
|
||||
// which for a tail dragged clear of its day is the day before it.
|
||||
val offset = (date.toEpochDays() - landed.date.toEpochDays()).toInt()
|
||||
val slice = dragSlices(landed.eventStartMin, landed.eventSpanMin, offset..offset)
|
||||
.firstOrNull() ?: return false
|
||||
return blocks.any { block ->
|
||||
.firstOrNull() ?: return
|
||||
val end = settledEnd
|
||||
settledOnGrid = blocks.any { block ->
|
||||
block.startMin == slice.startMin &&
|
||||
// A resize may keep its start, so its end has to have landed too.
|
||||
(end == null || block.event.end == end) &&
|
||||
@@ -947,15 +855,12 @@ class TimelineDragController {
|
||||
if (landed == null || landed.slot == origin) return null
|
||||
// A draft dropped on a day off screen would vanish, so it stays where it was (#153).
|
||||
if (landed.edgeOnly && landed.event.isDraft) return null
|
||||
val token = settle(landed)
|
||||
return TimelineDrop(landed.event, landed.date, landed.startMin, clipOffset, token)
|
||||
settle(landed)
|
||||
return TimelineDrop(landed.event, landed.date, landed.startMin, clipOffset)
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep [landed]'s copy on the target as [settling] until [release]; [end] is a
|
||||
* resize's. Returns the drop's fresh token.
|
||||
*/
|
||||
private fun settle(landed: TimelineDrag, end: Instant? = null): Long {
|
||||
/** Keep [landed]'s copy on the target as [settling] until [release]; [end] is a resize's. */
|
||||
private fun settle(landed: TimelineDrag, end: Instant? = null) {
|
||||
// Landed on midnight to midnight, the event moves to the all-day strip (#296).
|
||||
val leavesGrid = !landed.event.isDraft && landed.eventSpanMin > 0 &&
|
||||
landed.eventStartMin.mod(MINUTES_PER_DAY) == 0 &&
|
||||
@@ -968,10 +873,9 @@ class TimelineDragController {
|
||||
settling = if (landed.edgeOnly || leavesGrid) landed.copy(pieces = emptyList()) else landed
|
||||
liftedInstanceId = landed.event.instanceId
|
||||
settledOrigin = landed.event.eventId to landed.event.start
|
||||
settledToken = nextMoveToken()
|
||||
settledAbandonedMark = abandonedTicks?.value ?: 0
|
||||
settledEnd = end
|
||||
drops++
|
||||
return settledToken
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -991,7 +895,7 @@ class TimelineDragController {
|
||||
resize = session
|
||||
clipOffsetMin = 0
|
||||
this.titleLines = titleLines
|
||||
parkSettling()
|
||||
settling = null
|
||||
isDragging = true
|
||||
pointerMoved = true
|
||||
liftedInstanceId = block.event.instanceId
|
||||
@@ -1022,10 +926,10 @@ class TimelineDragController {
|
||||
val event = source?.event ?: return null
|
||||
val edge = resize?.edge
|
||||
if (edge != null) {
|
||||
val drop = resizedSpan(event, edge, date, startMin, spanMin, zone)
|
||||
val drop = resizedDrop(event, edge, date, startMin, spanMin, zone)
|
||||
return event.copy(start = drop.start, end = drop.end)
|
||||
}
|
||||
val start = dropStartInstant(event, date, startMin, clipOffsetMin, zone)
|
||||
val start = TimelineDrop(event, date, startMin, clipOffsetMin).startInstant(zone)
|
||||
return event.copy(start = start, end = start + dragDuration)
|
||||
}
|
||||
|
||||
@@ -1039,8 +943,9 @@ class TimelineDragController {
|
||||
val edge = resize?.edge
|
||||
cancel()
|
||||
if (landed == null || landed.slot == origin) return null
|
||||
val span = resizedSpan(landed.event, edge, landed.date, landed.eventStartMin, landed.eventSpanMin, zone)
|
||||
return TimelineResizeDrop(landed.event, span.start, span.end, settle(landed, end = span.end))
|
||||
val drop = resizedDrop(landed.event, edge, landed.date, landed.eventStartMin, landed.eventSpanMin, zone)
|
||||
settle(landed, end = drop.end)
|
||||
return drop
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1294,6 +1199,7 @@ fun rememberTimelineDragController(): TimelineDragController {
|
||||
controller.geometry.edgePx = with(density) { AUTO_SCROLL_EDGE.toPx() }
|
||||
controller.geometry.stepPx = with(density) { AUTO_SCROLL_STEP.toPx() }
|
||||
controller.slopPx = LocalViewConfiguration.current.touchSlop
|
||||
controller.abandonedTicks = LocalEventMove.current?.abandoned
|
||||
return controller
|
||||
}
|
||||
|
||||
@@ -1323,16 +1229,7 @@ fun TimedBlock.continuesBefore(day: LocalDate, zone: TimeZone): Boolean =
|
||||
* visibly settles there.
|
||||
*/
|
||||
fun TimelineDrop.startInstant(zone: TimeZone): Instant =
|
||||
dropStartInstant(event, date, startMin, clipOffsetMin, zone)
|
||||
|
||||
/** Where [event] starts when its block's top edge lands [startMin] past [date]'s midnight — see [TimelineDrop]. */
|
||||
internal fun dropStartInstant(
|
||||
event: EventInstance,
|
||||
date: LocalDate,
|
||||
startMin: Int,
|
||||
clipOffsetMin: Int,
|
||||
zone: TimeZone,
|
||||
): Instant = wallClockInstant(date, startMin - clipOffsetMin, zone, kept = zone.repeatedHourOffsetAt(event.start))
|
||||
wallClockInstant(date, startMin - clipOffsetMin, zone, kept = zone.repeatedHourOffsetAt(event.start))
|
||||
|
||||
/**
|
||||
* The wall-clock minutes an event lasting [duration] covers when it starts
|
||||
@@ -1404,7 +1301,6 @@ private const val SETTLE_TIMEOUT_MILLIS = 900L
|
||||
*/
|
||||
const val SETTLE_FADE_MILLIS: Int = 250
|
||||
|
||||
|
||||
/**
|
||||
* The floating block, drawn over the whole calendar so it is free of the day
|
||||
* column's clip and of the scroll viewport's rounded corners.
|
||||
@@ -1412,10 +1308,12 @@ const val SETTLE_FADE_MILLIS: Int = 250
|
||||
@Composable
|
||||
fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = Modifier) {
|
||||
var origin by remember { mutableStateOf(Offset.Zero) }
|
||||
val density = LocalDensity.current
|
||||
val dark = LocalFloretDarkTheme.current
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
val locale = currentLocale()
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
val drops = dropStates()
|
||||
val moveInFlight = controller.settledToken in drops.pending
|
||||
val stripInFlight = controller.stripToken in drops.pending
|
||||
val moveInFlight = moveInFlight()
|
||||
|
||||
// Both live here rather than beside the controller: they read [drag], which
|
||||
// changes every frame, and this composable is the one that is meant to.
|
||||
@@ -1429,11 +1327,12 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
|
||||
// recurring drop's scope dialog is up — and then until the grid draws the
|
||||
// drop itself.
|
||||
var handingOver by remember(controller.settling) { mutableStateOf(false) }
|
||||
val abandoned = controller.settledToken in drops.abandoned
|
||||
val abandoned = abandonedMoves()
|
||||
LaunchedEffect(controller.settling, moveInFlight, controller.settledOnGrid, abandoned) {
|
||||
if (controller.settling == null || moveInFlight) return@LaunchedEffect
|
||||
// An abandoned drop: nothing will land and there is nothing to wait for.
|
||||
if (!abandoned) {
|
||||
// Past the mark this drop was let go on, it was abandoned: nothing will
|
||||
// land and there is nothing to wait for.
|
||||
if (abandoned == controller.settledAbandonedMark) {
|
||||
delay(if (controller.settledOnGrid) SETTLE_GRACE_MILLIS else SETTLE_TIMEOUT_MILLIS)
|
||||
}
|
||||
handingOver = true
|
||||
@@ -1443,10 +1342,9 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
|
||||
}
|
||||
|
||||
// The same hold for a bar moved along the all-day strip.
|
||||
val stripAbandoned = controller.stripToken in drops.abandoned
|
||||
LaunchedEffect(controller.stripSettling, stripInFlight, controller.stripSeated, stripAbandoned) {
|
||||
if (controller.stripSettling == null || stripInFlight) return@LaunchedEffect
|
||||
if (!stripAbandoned) {
|
||||
LaunchedEffect(controller.stripSettling, moveInFlight, controller.stripSeated, abandoned) {
|
||||
if (controller.stripSettling == null || moveInFlight) return@LaunchedEffect
|
||||
if (abandoned == controller.stripAbandonedMark) {
|
||||
delay(if (controller.stripSeated) SETTLE_GRACE_MILLIS else SETTLE_TIMEOUT_MILLIS)
|
||||
}
|
||||
controller.releaseStrip()
|
||||
@@ -1460,12 +1358,6 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
|
||||
.clearAndSetSemantics { }
|
||||
.onGloballyPositioned { origin = it.positionInRoot() },
|
||||
) {
|
||||
controller.held.forEach { parked ->
|
||||
key(parked.token) { HeldTimelineCopy(controller, parked, origin) }
|
||||
}
|
||||
controller.heldStrips.forEach { parked ->
|
||||
key(parked.token) { HeldStripSettle(controller, parked) }
|
||||
}
|
||||
val drag = controller.drag ?: controller.settling ?: return@Box
|
||||
// Landed: the copy sinks back to the grid's own plane while the write runs.
|
||||
val landed = controller.drag == null
|
||||
@@ -1479,58 +1371,6 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
|
||||
animationSpec = tween(SETTLE_FADE_MILLIS),
|
||||
label = "drag-handover",
|
||||
)
|
||||
TimelineCopy(drag, controller.geometry, origin, lift, copyAlpha)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [TimelineDragController.ghosts] for [block], derived so the block recomposes
|
||||
* only when its own answer changes rather than on every lift, park and release.
|
||||
*/
|
||||
@Composable
|
||||
fun TimelineDragController.ghostsDerived(block: TimedBlock): Boolean {
|
||||
val ghosted by remember(this, block) { derivedStateOf { ghosts(block) } }
|
||||
return ghosted
|
||||
}
|
||||
|
||||
/** A drop parked by a newer pickup: held at its landing slot until its own write settles. */
|
||||
@Composable
|
||||
private fun HeldTimelineCopy(controller: TimelineDragController, parked: HeldDrop, origin: Offset) {
|
||||
val alpha = rememberHeldSettle(
|
||||
token = parked.token,
|
||||
seated = parked.seated,
|
||||
timeoutMillis = SETTLE_TIMEOUT_MILLIS,
|
||||
onHandOver = { controller.handOverHeld(parked.token) },
|
||||
onRelease = { controller.releaseHeld(parked.token) },
|
||||
)
|
||||
TimelineCopy(parked.copy, controller.geometry, origin, lift = 0f, copyAlpha = alpha)
|
||||
}
|
||||
|
||||
/** A strip drop parked by a newer one: shown at its new days until its own write settles. */
|
||||
@Composable
|
||||
private fun HeldStripSettle(controller: TimelineDragController, parked: HeldStrip) {
|
||||
rememberHeldSettle(
|
||||
token = parked.token,
|
||||
seated = parked.seated,
|
||||
timeoutMillis = SETTLE_TIMEOUT_MILLIS,
|
||||
onHandOver = {},
|
||||
onRelease = { controller.releaseHeldStrip(parked.token) },
|
||||
)
|
||||
}
|
||||
|
||||
/** [drag]'s pieces drawn over the calendar, clipped to the timeline's viewport. */
|
||||
@Composable
|
||||
private fun TimelineCopy(
|
||||
drag: TimelineDrag,
|
||||
geometry: TimelineGeometry,
|
||||
origin: Offset,
|
||||
lift: Float,
|
||||
copyAlpha: Float,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val dark = LocalFloretDarkTheme.current
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
val locale = currentLocale()
|
||||
val paint = eventPaint(drag.event, dark)
|
||||
val title = drag.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
// Read off the event rather than the held block, so grabbing either half
|
||||
@@ -1546,7 +1386,7 @@ private fun TimelineCopy(
|
||||
// so no column clip or rounded corner cuts it, but it still belongs to
|
||||
// the timeline: a piece a whole day tall reaches far past both ends of
|
||||
// the viewport, and unclipped it paints over the headers above (#267).
|
||||
val port = geometry.viewport?.takeIf { it.isAttached }?.boundsInRoot()
|
||||
val port = controller.geometry.viewport?.takeIf { it.isAttached }?.boundsInRoot()
|
||||
// How much of each piece the viewport actually shows. A multi-day event
|
||||
// has a piece per day, and the widest one is a full 24 hours — taller
|
||||
// than the screen, its top at a midnight scrolled out of sight. Picking
|
||||
@@ -1598,6 +1438,7 @@ private fun TimelineCopy(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One piece of a dragged block, floating over the calendar. */
|
||||
|
||||
@@ -14,7 +14,8 @@ import androidx.compose.ui.layout.boundsInRoot
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.common.nextMoveToken
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlin.math.abs
|
||||
|
||||
@@ -106,22 +107,11 @@ data class MonthChipDrag(
|
||||
val time: String? = null,
|
||||
)
|
||||
|
||||
/** A drop parked by a newer pickup while its write still runs — see [MonthDragController.held]. */
|
||||
data class HeldChip(
|
||||
val copy: MonthChipDrag,
|
||||
val token: Long,
|
||||
val handedOver: Boolean = false,
|
||||
/** Where the grid now draws the drop, in root coordinates, once a row seats it. */
|
||||
val seat: Offset? = null,
|
||||
)
|
||||
|
||||
/** Where a finished chip drag asks its event to go, as a whole-day shift. */
|
||||
data class MonthChipDrop(
|
||||
val event: EventInstance,
|
||||
val grabDate: LocalDate,
|
||||
val targetDate: LocalDate,
|
||||
/** The drop's identity, which its held copy waits on — see [MonthDragController.settledToken]. */
|
||||
val token: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -177,22 +167,19 @@ class MonthDragController {
|
||||
private set
|
||||
|
||||
/**
|
||||
* This controller's recent drops by token, kept past [release] for
|
||||
* [beginUndo] — the confirmation chip carries Undo long after the copy is
|
||||
* gone, and it may be any of them the chip offers to undo.
|
||||
* The last drop this controller made, kept past [release] for [beginUndo] —
|
||||
* the confirmation chip carries Undo long after the copy is gone.
|
||||
*/
|
||||
private val undoable = LinkedHashMap<Long, MonthChipDrag>()
|
||||
private var undoable: MonthChipDrag? = null
|
||||
|
||||
/**
|
||||
* Earlier drops still being written when another chip was picked up. They
|
||||
* keep their copy and their ghost until their own token settles, rather than
|
||||
* vanishing and flashing back on the old day (#494).
|
||||
* The abandoned-move counter, read at the drop so the mark can't be a
|
||||
* composition behind the tick it is compared against.
|
||||
*/
|
||||
var held: List<HeldChip> by mutableStateOf(emptyList())
|
||||
private set
|
||||
var abandonedTicks: StateFlow<Int>? = null
|
||||
|
||||
/** The token of the drop (or undo) [settling] is waiting on. */
|
||||
var settledToken: Long = 0L
|
||||
/** What [abandonedTicks] stood at when the settling chip was let go. */
|
||||
var settledAbandonedMark: Int = 0
|
||||
private set
|
||||
|
||||
private var event: EventInstance? = null
|
||||
@@ -221,7 +208,7 @@ class MonthDragController {
|
||||
this.event = event
|
||||
this.grabDate = grabDate
|
||||
this.time = time
|
||||
parkSettling()
|
||||
settling = null
|
||||
isDragging = true
|
||||
liftedInstanceId = event.instanceId
|
||||
grab = pointerInRoot - chipInRoot
|
||||
@@ -247,39 +234,6 @@ class MonthDragController {
|
||||
settledGhost = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [event]'s chip belongs to a held drop and must stay a ghost: its
|
||||
* source, or once a row seats it, the chip the grid draws on its target day.
|
||||
*/
|
||||
fun isHeldChip(event: EventInstance, days: List<LocalDate>?): Boolean = held.any { parked ->
|
||||
!parked.handedOver && (
|
||||
parked.copy.event.instanceId == event.instanceId ||
|
||||
(
|
||||
parked.seat != null && days != null && parked.copy.targetDate in days &&
|
||||
isSameEvent(event, parked.copy.event)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/** Move a still-settling drop to [held] before a new pickup takes the slot. */
|
||||
private fun parkSettling() {
|
||||
val landed = settling
|
||||
if (landed != null) held = held + HeldChip(landed, settledToken)
|
||||
settling = null
|
||||
settledInRoot = null
|
||||
settledGhost = false
|
||||
}
|
||||
|
||||
/** Stop ghosting the held drop [token]'s source while its copy fades. */
|
||||
fun handOverHeld(token: Long) {
|
||||
held = held.map { if (it.token == token) it.copy(handedOver = true) else it }
|
||||
}
|
||||
|
||||
/** Drop the held copy [token] once it has faded. */
|
||||
fun releaseHeld(token: Long) {
|
||||
held = held.filterNot { it.token == token }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [days] holds the chip the grid has drawn for the settled drop —
|
||||
* the seat the copy is on its way to, which must ghost until it gets there.
|
||||
@@ -296,13 +250,6 @@ class MonthDragController {
|
||||
* nearest where the chip was let go is the one the finger was over.
|
||||
*/
|
||||
fun noteSettled(token: Any) {
|
||||
val row = rows[token]
|
||||
if (row != null && held.any { it.seat == null }) {
|
||||
held = held.map { parked ->
|
||||
val target = parked.copy.targetDate
|
||||
if (parked.seat != null || target == null) parked else parked.copy(seat = row.seatOf(parked.copy.event, target))
|
||||
}
|
||||
}
|
||||
val landed = settling ?: return
|
||||
val target = landed.targetDate ?: return
|
||||
val at = rows[token]?.seatOf(landed.event, target) ?: return
|
||||
@@ -318,16 +265,16 @@ class MonthDragController {
|
||||
* there is no drop of this controller's left to undo, or the grid doesn't
|
||||
* seat the moved event where the chip would have to start from.
|
||||
*/
|
||||
fun beginUndo(token: Long, dropToken: Long): Boolean {
|
||||
val last = undoable.remove(dropToken) ?: return false
|
||||
parkSettling()
|
||||
fun beginUndo(): Boolean {
|
||||
val last = undoable ?: return false
|
||||
undoable = null
|
||||
val from = last.targetDate ?: return false
|
||||
val at = rows.values.firstNotNullOfOrNull { it.seatOf(last.event, from) } ?: return false
|
||||
settling = last.copy(grabDate = from, targetDate = last.grabDate, topLeftInRoot = at)
|
||||
liftedInstanceId = last.event.instanceId
|
||||
settledGhost = true
|
||||
settledInRoot = null
|
||||
settledToken = token
|
||||
settledAbandonedMark = abandonedTicks?.value ?: 0
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -347,11 +294,10 @@ class MonthDragController {
|
||||
)
|
||||
liftedInstanceId = landed.event.instanceId
|
||||
settledGhost = true
|
||||
settledToken = nextMoveToken()
|
||||
undoable[settledToken] = requireNotNull(settling)
|
||||
if (undoable.size > UNDOABLE_KEPT) undoable.remove(undoable.keys.first())
|
||||
undoable = settling
|
||||
settledAbandonedMark = abandonedTicks?.value ?: 0
|
||||
drops++
|
||||
return MonthChipDrop(landed.event, landed.grabDate, target, settledToken)
|
||||
return MonthChipDrop(landed.event, landed.grabDate, target)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -409,10 +355,9 @@ class MonthDragController {
|
||||
|
||||
@Composable
|
||||
fun rememberMonthDragController(): MonthDragController {
|
||||
return remember { MonthDragController() }
|
||||
val controller = remember { MonthDragController() }
|
||||
controller.abandonedTicks = LocalEventMove.current?.abandoned
|
||||
return controller
|
||||
}
|
||||
|
||||
val LocalMonthDrag = compositionLocalOf<MonthDragController?> { null }
|
||||
|
||||
/** How many recent drops a month controller keeps for an undo to carry back. */
|
||||
private const val UNDOABLE_KEPT = 8
|
||||
@@ -125,11 +125,11 @@ import de.jeanlucmakiola.calendula.ui.common.eventTitleOverflow
|
||||
import de.jeanlucmakiola.calendula.ui.common.MoveTarget
|
||||
import de.jeanlucmakiola.calendula.ui.common.MoveRequest
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||
import de.jeanlucmakiola.calendula.ui.common.dropStates
|
||||
import de.jeanlucmakiola.calendula.ui.common.moveInFlight
|
||||
import de.jeanlucmakiola.calendula.ui.common.abandonedMoves
|
||||
import de.jeanlucmakiola.calendula.ui.common.OnUndoStarted
|
||||
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.DragSnapHaptics
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberHeldSettle
|
||||
import de.jeanlucmakiola.calendula.ui.common.SETTLE_FADE_MILLIS
|
||||
import de.jeanlucmakiola.calendula.ui.common.SETTLE_GRACE_MILLIS
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
|
||||
@@ -515,7 +515,7 @@ fun MonthScreen(
|
||||
val chipDrag = rememberMonthDragController()
|
||||
// Undo moves the event back, so the chip travels back too. The
|
||||
// signal comes from the write, not from the chip that offers it.
|
||||
OnUndoStarted { start -> chipDrag.beginUndo(start.token, start.dropToken) }
|
||||
OnUndoStarted { chipDrag.beginUndo() }
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
@@ -591,11 +591,11 @@ private const val MONTH_SETTLE_TIMEOUT_MILLIS = 450L
|
||||
@Composable
|
||||
private fun MonthDragOverlay(controller: MonthDragController, showStartTimes: Boolean) {
|
||||
var origin by remember { mutableStateOf(Offset.Zero) }
|
||||
val dark = LocalFloretDarkTheme.current
|
||||
val density = LocalDensity.current
|
||||
val timeFit = rememberMonthChipTimeFit(showStartTimes)
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
val drops = dropStates()
|
||||
val moveInFlight = controller.settledToken in drops.pending
|
||||
val abandoned = controller.settledToken in drops.abandoned
|
||||
val moveInFlight = moveInFlight()
|
||||
// Here rather than beside the controller: this reads [drag], which changes
|
||||
// every frame, and this composable is the one that is meant to.
|
||||
DragSnapHaptics(controller.drag?.targetDate, controller.drops)
|
||||
@@ -608,12 +608,14 @@ private fun MonthDragOverlay(controller: MonthDragController, showStartTimes: Bo
|
||||
val glide = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
|
||||
val glideSpec = MaterialTheme.motionScheme.fastSpatialSpec<Offset>()
|
||||
val seat = controller.settledInRoot
|
||||
val abandoned = abandonedMoves()
|
||||
LaunchedEffect(controller.settling, moveInFlight, seat, abandoned) {
|
||||
val landed = controller.settling
|
||||
if (landed == null || moveInFlight) return@LaunchedEffect
|
||||
if (seat == null) {
|
||||
// An abandoned drop has no seat coming.
|
||||
if (!abandoned) delay(MONTH_SETTLE_TIMEOUT_MILLIS)
|
||||
// Past the mark this drop was let go on, it was abandoned and no
|
||||
// seat is ever coming.
|
||||
if (abandoned == controller.settledAbandonedMark) delay(MONTH_SETTLE_TIMEOUT_MILLIS)
|
||||
} else {
|
||||
if (!gliding) glide.snapTo(landed.topLeftInRoot)
|
||||
gliding = true
|
||||
@@ -633,9 +635,6 @@ private fun MonthDragOverlay(controller: MonthDragController, showStartTimes: Bo
|
||||
.clearAndSetSemantics { }
|
||||
.onGloballyPositioned { origin = it.positionInRoot() },
|
||||
) {
|
||||
controller.held.forEach { parked ->
|
||||
key(parked.token) { HeldMonthChip(controller, parked, origin, timeFit) }
|
||||
}
|
||||
val drag = controller.drag ?: controller.settling ?: return@Box
|
||||
// Landed: the copy sinks back to the grid's own plane while the write runs.
|
||||
val lift by animateFloatAsState(
|
||||
@@ -647,93 +646,42 @@ private fun MonthDragOverlay(controller: MonthDragController, showStartTimes: Bo
|
||||
animationSpec = tween(SETTLE_FADE_MILLIS),
|
||||
label = "chip-handover",
|
||||
)
|
||||
MonthChipCopy(
|
||||
drag = drag,
|
||||
// Read at layout, so the glide doesn't recompose every frame.
|
||||
at = { if (gliding) glide.value else drag.topLeftInRoot },
|
||||
origin = origin,
|
||||
lift = lift,
|
||||
alpha = copyAlpha,
|
||||
MonthBar(
|
||||
event = drag.event,
|
||||
dark = dark,
|
||||
continuesLeft = false,
|
||||
continuesRight = false,
|
||||
// The time the source chip carried, drawn only if this one-column
|
||||
// cut-out of it has the room — which a slice of a multi-day bar has
|
||||
// not, however wide the bar was.
|
||||
time = drag.time,
|
||||
timeFit = timeFit,
|
||||
chipWidth = with(density) { drag.sizePx.width.toDp() },
|
||||
modifier = Modifier
|
||||
// Absolute: these are root coordinates, and the direction-aware
|
||||
// offset would mirror them across the screen in an RTL layout.
|
||||
.absoluteOffset {
|
||||
val at = if (gliding) glide.value else drag.topLeftInRoot
|
||||
IntOffset(
|
||||
(at.x - origin.x).roundToInt(),
|
||||
(at.y - origin.y).roundToInt(),
|
||||
)
|
||||
}
|
||||
.width(with(density) { drag.sizePx.width.toDp() })
|
||||
.height(with(density) { drag.sizePx.height.toDp() })
|
||||
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp)
|
||||
.graphicsLayer {
|
||||
scaleX = 1f + 0.04f * lift
|
||||
scaleY = 1f + 0.04f * lift
|
||||
shadowElevation = 8.dp.toPx() * lift
|
||||
alpha = copyAlpha
|
||||
shape = EventChipShape
|
||||
clip = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A drop parked by a newer pickup: held on its landing day until its own write settles. */
|
||||
@Composable
|
||||
private fun HeldMonthChip(
|
||||
controller: MonthDragController,
|
||||
parked: HeldChip,
|
||||
origin: Offset,
|
||||
timeFit: MonthChipTimeFit?,
|
||||
) {
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
val glide = remember { Animatable(parked.copy.topLeftInRoot, Offset.VectorConverter) }
|
||||
val glideSpec = MaterialTheme.motionScheme.fastSpatialSpec<Offset>()
|
||||
val seat = parked.seat
|
||||
val landed = parked.token !in dropStates().pending
|
||||
// Carried onto its seat once the write is in, as the settling chip is.
|
||||
LaunchedEffect(seat, landed) {
|
||||
if (seat == null || !landed) return@LaunchedEffect
|
||||
if (reduceMotion) glide.snapTo(seat) else glide.animateTo(seat, glideSpec)
|
||||
}
|
||||
val alpha = rememberHeldSettle(
|
||||
token = parked.token,
|
||||
seated = seat != null,
|
||||
timeoutMillis = MONTH_SETTLE_TIMEOUT_MILLIS,
|
||||
onHandOver = { controller.handOverHeld(parked.token) },
|
||||
onRelease = { controller.releaseHeld(parked.token) },
|
||||
)
|
||||
MonthChipCopy(parked.copy, { glide.value }, origin, lift = 0f, alpha = alpha, timeFit = timeFit)
|
||||
}
|
||||
|
||||
/** [drag]'s chip floating over the grid with its top left at [at], in root coordinates. */
|
||||
@Composable
|
||||
private fun MonthChipCopy(
|
||||
drag: MonthChipDrag,
|
||||
at: () -> Offset,
|
||||
origin: Offset,
|
||||
lift: Float,
|
||||
alpha: Float,
|
||||
timeFit: MonthChipTimeFit?,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val dark = LocalFloretDarkTheme.current
|
||||
MonthBar(
|
||||
event = drag.event,
|
||||
dark = dark,
|
||||
continuesLeft = false,
|
||||
continuesRight = false,
|
||||
// The time the source chip carried, drawn only if this one-column
|
||||
// cut-out of it has the room — which a slice of a multi-day bar has
|
||||
// not, however wide the bar was.
|
||||
time = drag.time,
|
||||
timeFit = timeFit,
|
||||
chipWidth = with(density) { drag.sizePx.width.toDp() },
|
||||
modifier = Modifier
|
||||
// Absolute: these are root coordinates, and the direction-aware
|
||||
// offset would mirror them across the screen in an RTL layout.
|
||||
.absoluteOffset {
|
||||
val topLeft = at()
|
||||
IntOffset(
|
||||
(topLeft.x - origin.x).roundToInt(),
|
||||
(topLeft.y - origin.y).roundToInt(),
|
||||
)
|
||||
}
|
||||
.width(with(density) { drag.sizePx.width.toDp() })
|
||||
.height(with(density) { drag.sizePx.height.toDp() })
|
||||
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp)
|
||||
.graphicsLayer {
|
||||
scaleX = 1f + 0.04f * lift
|
||||
scaleY = 1f + 0.04f * lift
|
||||
shadowElevation = 8.dp.toPx() * lift
|
||||
this.alpha = alpha
|
||||
shape = EventChipShape
|
||||
clip = false
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Content for both scrolling styles. No swipe detector and no [AnimatedContent]:
|
||||
* vertical scrolling owns the gesture, and the list never swaps wholesale — it
|
||||
@@ -2310,7 +2258,7 @@ private fun MonthWeekRow(
|
||||
// Once the grid holds the settled chip, tell the controller where this row
|
||||
// has seated it, so the copy in flight can glide onto it. Keyed on the week,
|
||||
// which is what changes when the re-read lands.
|
||||
LaunchedEffect(week, dragController?.settling, dragController?.held) {
|
||||
LaunchedEffect(week, dragController?.settling) {
|
||||
dragController?.noteSettled(rowToken)
|
||||
}
|
||||
|
||||
@@ -2743,8 +2691,6 @@ private fun monthChipDragModifier(
|
||||
beginMillis = drop.event.start.toEpochMilliseconds(),
|
||||
endMillis = drop.event.end.toEpochMilliseconds(),
|
||||
target = MoveTarget.ByDays(delta),
|
||||
isAllDay = drop.event.isAllDay,
|
||||
token = drop.token,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -2890,17 +2836,8 @@ private fun MonthBar(
|
||||
val moveAction = eventMoveAction(event)
|
||||
// The source stays put as a ghost while its floating copy travels.
|
||||
val monthDrag = LocalMonthDrag.current
|
||||
// Derived, so a chip recomposes only when its own answer changes rather than
|
||||
// on every lift, park and release anywhere in the grid.
|
||||
val lifted by remember(monthDrag, event, days) {
|
||||
derivedStateOf {
|
||||
monthDrag != null && (
|
||||
monthDrag.liftedInstanceId == event.instanceId ||
|
||||
monthDrag.isSettledChip(event, days) ||
|
||||
monthDrag.isHeldChip(event, days)
|
||||
)
|
||||
}
|
||||
}
|
||||
val lifted = monthDrag?.liftedInstanceId == event.instanceId ||
|
||||
monthDrag?.isSettledChip(event, days) == true
|
||||
val ghost = ghostAlpha(lifted)
|
||||
val shape = monthBarShape(continuesLeft, continuesRight)
|
||||
Box(
|
||||
|
||||
@@ -157,7 +157,6 @@ import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||
import de.jeanlucmakiola.calendula.ui.common.MoveRequest
|
||||
import de.jeanlucmakiola.calendula.ui.common.MoveTarget
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
|
||||
import de.jeanlucmakiola.calendula.ui.common.ghostsDerived
|
||||
import de.jeanlucmakiola.calendula.ui.common.StripDrag
|
||||
import de.jeanlucmakiola.calendula.ui.common.StripDrop
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay
|
||||
@@ -311,6 +310,14 @@ internal enum class TimelineKind(
|
||||
// A run of days often straddles two weeks, so no single number fits it.
|
||||
MultiDay(showWeekNumber = false, todayText = R.string.week_today_action, headerBias = 0f),
|
||||
Day(showWeekNumber = false, todayText = R.string.day_today_action, headerBias = -1f),
|
||||
;
|
||||
|
||||
fun title(start: LocalDate, end: LocalDate, locale: Locale, currentYear: Int, abbreviated: Boolean): String =
|
||||
if (this == Day) {
|
||||
formatDayTitle(start, locale, currentYear, abbreviated)
|
||||
} else {
|
||||
formatWeekTitle(start, end, locale, currentYear, abbreviated)
|
||||
}
|
||||
}
|
||||
|
||||
internal val CalendarView.timelineKind: TimelineKind
|
||||
@@ -485,10 +492,9 @@ private fun PagedTimelineScreen(
|
||||
val jumpToToday = { jumpToDate(today) }
|
||||
|
||||
val locale = currentLocale()
|
||||
// The day view names the month too: its header already shows weekday and date (#501).
|
||||
val (title, shortTitle) = remember(weekStart, weekEnd, locale, today.year) {
|
||||
formatTimelineTitle(weekStart, weekEnd, locale, today.year, abbreviated = false) to
|
||||
formatTimelineTitle(weekStart, weekEnd, locale, today.year, abbreviated = true)
|
||||
val (title, shortTitle) = remember(titleKind, weekStart, weekEnd, locale, today.year) {
|
||||
titleKind.title(weekStart, weekEnd, locale, today.year, abbreviated = false) to
|
||||
titleKind.title(weekStart, weekEnd, locale, today.year, abbreviated = true)
|
||||
}
|
||||
PublishChrome(
|
||||
ScreenChrome(
|
||||
@@ -585,9 +591,7 @@ private fun WeekContent(
|
||||
eventId = drop.event.eventId,
|
||||
beginMillis = drop.event.start.toEpochMilliseconds(),
|
||||
endMillis = drop.event.end.toEpochMilliseconds(),
|
||||
isAllDay = drop.event.isAllDay,
|
||||
target = MoveTarget.Start(start),
|
||||
token = drop.token,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -606,9 +610,7 @@ private fun WeekContent(
|
||||
eventId = drop.event.eventId,
|
||||
beginMillis = drop.event.start.toEpochMilliseconds(),
|
||||
endMillis = drop.event.end.toEpochMilliseconds(),
|
||||
isAllDay = drop.event.isAllDay,
|
||||
target = MoveTarget.Span(drop.start, drop.end),
|
||||
token = drop.token,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -620,12 +622,10 @@ private fun WeekContent(
|
||||
eventId = drop.event.eventId,
|
||||
beginMillis = drop.event.start.toEpochMilliseconds(),
|
||||
endMillis = drop.event.end.toEpochMilliseconds(),
|
||||
isAllDay = drop.event.isAllDay,
|
||||
target = when (drop) {
|
||||
is StripDrop.Days -> MoveTarget.ByDays(drop.days)
|
||||
is StripDrop.Timed -> MoveTarget.Span(drop.start, drop.end)
|
||||
},
|
||||
token = drop.token,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -767,7 +767,8 @@ private fun WeekPager(
|
||||
// Until its days arrive a page shows their frame without events.
|
||||
val empty = remember(pageSpan, today) { emptySpan(pageSpan, today) }
|
||||
// A bar held over other days in the strip makes room there before it is dropped.
|
||||
val held = dragController.stripPreviews().takeIf { page == pagerState.currentPage }.orEmpty()
|
||||
val held = (dragController.stripDrag ?: dragController.stripSettling)
|
||||
?.takeIf { page == pagerState.currentPage }
|
||||
val zone = LocalTimelineZone.current
|
||||
val shown = remember(loaded, held, zone) { loaded?.withStripPreview(held, zone) }
|
||||
val stripHeight = shown?.allDayStripHeight()
|
||||
@@ -792,24 +793,17 @@ private fun WeekPager(
|
||||
}
|
||||
|
||||
/**
|
||||
* [this] with the strip bars [held] sideways laid out at the days they are over,
|
||||
* so the bars there move aside live. Each bar keeps its own event, and with it
|
||||
* its key and the gesture on it; one moved off the page keeps a lane it is not
|
||||
* seen in.
|
||||
* [this] with the strip bar [held] sideways laid out at the days it is over, so
|
||||
* the bars there move aside live. The bar keeps its own event, and with it its
|
||||
* key and the gesture on it; one moved off the page keeps a lane it is not seen in.
|
||||
*/
|
||||
internal fun WeekUiState.Success.withStripPreview(held: List<StripDrag>, zone: TimeZone): WeekUiState.Success {
|
||||
val shifts = held.filter { !it.overGrid && it.dayShift != 0 }.associate { it.event.instanceId to it.dayShift }
|
||||
if (shifts.isEmpty()) return this
|
||||
val originals = allDaySpans.filter { it.event.instanceId in shifts }.associateBy { it.event.instanceId }
|
||||
if (originals.isEmpty()) return this
|
||||
val events = allDaySpans.map { span ->
|
||||
shifts[span.event.instanceId]?.let { span.event.shiftedDays(it, zone) } ?: span.event
|
||||
}
|
||||
val spans = layoutAllDay(events, days, zone).map { span ->
|
||||
originals[span.event.instanceId]?.let { span.copy(event = it.event) } ?: span
|
||||
}
|
||||
val laidOut = spans.mapTo(HashSet()) { it.event.instanceId }
|
||||
return copy(allDaySpans = spans + originals.values.filter { it.event.instanceId !in laidOut }.map { it.copy(lane = 0) })
|
||||
internal fun WeekUiState.Success.withStripPreview(held: StripDrag?, zone: TimeZone): WeekUiState.Success {
|
||||
if (held == null || held.overGrid || held.dayShift == 0) return this
|
||||
val id = held.event.instanceId
|
||||
val original = allDaySpans.firstOrNull { it.event.instanceId == id } ?: return this
|
||||
val events = allDaySpans.map { if (it.event.instanceId == id) it.event.shiftedDays(held.dayShift, zone) else it.event }
|
||||
val spans = layoutAllDay(events, days, zone).map { if (it.event.instanceId == id) it.copy(event = original.event) else it }
|
||||
return copy(allDaySpans = if (spans.any { it.event.instanceId == id }) spans else spans + original.copy(lane = 0))
|
||||
}
|
||||
|
||||
private fun EventInstance.shiftedDays(days: Int, zone: TimeZone): EventInstance {
|
||||
@@ -817,17 +811,6 @@ private fun EventInstance.shiftedDays(days: Int, zone: TimeZone): EventInstance
|
||||
return copy(start = start.plus(days, DateTimeUnit.DAY, dates), end = end.plus(days, DateTimeUnit.DAY, dates))
|
||||
}
|
||||
|
||||
/** Whether these bars draw the strip drop [drop] at the days it was dropped on. */
|
||||
private fun List<PlacedBar>.drawStripDrop(drop: StripDrag, zone: TimeZone): Boolean {
|
||||
val target = drop.event.spanFirstDay(zone).plus(drop.dayShift, DateTimeUnit.DAY)
|
||||
return any { bar ->
|
||||
bar.event.instanceId != drop.event.instanceId &&
|
||||
(bar.event.eventId == drop.event.eventId ||
|
||||
(drop.event.title.isNotBlank() && bar.event.title == drop.event.title)) &&
|
||||
bar.event.spanFirstDay(zone) == target
|
||||
}
|
||||
}
|
||||
|
||||
/** A page with no events yet, for one whose days are still loading. */
|
||||
private fun emptySpan(span: DaySpan, today: LocalDate): WeekUiState.Success {
|
||||
val days = (0 until span.count).map { span.start.plus(it, kotlinx.datetime.DateTimeUnit.DAY) }
|
||||
@@ -1210,11 +1193,17 @@ private fun AllDayStrip(
|
||||
val sideways = held?.takeIf { !it.overGrid }
|
||||
val lifted = dragController.liftedInstanceId?.takeIf { active && dragController.stripDrag?.overGrid != false }
|
||||
val settling = dragController.stripSettling?.takeIf { active }
|
||||
val heldStrips = dragController.heldStrips.takeIf { active }.orEmpty()
|
||||
// Seated once the re-read draws the event at the days it was dropped on.
|
||||
LaunchedEffect(settling, heldStrips, bars) {
|
||||
if (settling != null && bars.drawStripDrop(settling, zone)) dragController.seatStrip()
|
||||
heldStrips.forEach { if (!it.seated && bars.drawStripDrop(it.drag, zone)) dragController.seatHeldStrip(it.token) }
|
||||
LaunchedEffect(settling, bars) {
|
||||
if (settling == null) return@LaunchedEffect
|
||||
val target = settling.event.spanFirstDay(zone).plus(settling.dayShift, DateTimeUnit.DAY)
|
||||
val landed = bars.any { bar ->
|
||||
bar.event.instanceId != settling.event.instanceId &&
|
||||
(bar.event.eventId == settling.event.eventId ||
|
||||
(settling.event.title.isNotBlank() && bar.event.title == settling.event.title)) &&
|
||||
bar.event.spanFirstDay(zone) == target
|
||||
}
|
||||
if (landed) dragController.seatStrip()
|
||||
}
|
||||
|
||||
Row(
|
||||
@@ -1599,7 +1588,7 @@ private fun DayColumnCard(
|
||||
widthFraction = 1f / block.laneCount,
|
||||
y = top,
|
||||
height = height,
|
||||
landing = dragController.ghostsDerived(block),
|
||||
landing = dragController.ghosts(block),
|
||||
)
|
||||
val topInset = hourCellBlockInset(showHourGrid, cuts.top)
|
||||
val bottomInset = hourCellBlockInset(showHourGrid, cuts.bottom)
|
||||
@@ -1767,7 +1756,7 @@ private fun EventBlock(
|
||||
onCancel = dragController::cancel,
|
||||
onRelease = { dragController.select(block.event, fromLift = draggable) },
|
||||
)
|
||||
val lifted = draggable && dragController.ghostsDerived(block)
|
||||
val lifted = draggable && dragController.ghosts(block)
|
||||
// Released in place, the block takes over the copy's lift without a dip.
|
||||
val takesOverLift = selected && dragController.selection?.fromLift == true
|
||||
val ghostFade = ghostAlpha(lifted)
|
||||
@@ -2027,7 +2016,7 @@ private fun minToHm(min: Int, is24Hour: Boolean, locale: java.util.Locale): Stri
|
||||
formatMinuteOfDay(min, is24Hour, locale)
|
||||
|
||||
/**
|
||||
* Every timeline's title, the day view's included: just the month [weekStart] falls in.
|
||||
* The week's title: just the month [weekStart] falls in.
|
||||
*
|
||||
* The day numbers are already in the column headers directly below, so spelling
|
||||
* out "13.–19. Jul" restates them in the widest string in the bar. Naming the
|
||||
@@ -2042,7 +2031,7 @@ private fun minToHm(min: Int, is24Hour: Boolean, locale: java.util.Locale): Stri
|
||||
* "December" with no year would be actively misleading while the reader is
|
||||
* looking straight at January dates.
|
||||
*/
|
||||
private fun formatTimelineTitle(
|
||||
private fun formatWeekTitle(
|
||||
weekStart: LocalDate,
|
||||
weekEnd: LocalDate,
|
||||
locale: Locale,
|
||||
@@ -2057,3 +2046,17 @@ private fun formatTimelineTitle(
|
||||
forceYear = weekEnd.year != weekStart.year,
|
||||
)
|
||||
}
|
||||
|
||||
/** The day's title: its weekday and date, abbreviated to the date alone when short of room. */
|
||||
private fun formatDayTitle(
|
||||
date: LocalDate,
|
||||
locale: Locale,
|
||||
currentYear: Int,
|
||||
abbreviated: Boolean = false,
|
||||
): String =
|
||||
formatCalendarTitle(
|
||||
date = date.toJavaLocalDate(),
|
||||
locale = locale,
|
||||
currentYear = currentYear,
|
||||
skeleton = if (abbreviated) "dMMM" else "EEEdMMM",
|
||||
)
|
||||
@@ -110,9 +110,6 @@
|
||||
<string name="event_move_failed">Couldn\'t move the event</string>
|
||||
<string name="event_move_write_denied">Calendula needs write access to move events</string>
|
||||
<string name="event_move_gone">That event no longer exists</string>
|
||||
<string name="event_move_stale">That event moved before your drop could be saved</string>
|
||||
<string name="event_move_queue_full">Too many moves at once, so that one wasn\'t saved</string>
|
||||
<string name="event_move_skipped">Moves made while the question was open weren\'t saved</string>
|
||||
<string name="event_move_blocked_series_end">Can\'t move an event past the end of its series</string>
|
||||
<string name="event_move_blocked_cancelled_series">A cancelled series can only move as a whole, and this would change which days it falls on</string>
|
||||
|
||||
@@ -910,41 +907,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)
|
||||
}
|
||||
}
|
||||
+23
-278
@@ -13,12 +13,10 @@ import de.jeanlucmakiola.calendula.testEvent
|
||||
import de.jeanlucmakiola.calendula.testPrefs
|
||||
import de.jeanlucmakiola.calendula.testRepository
|
||||
import de.jeanlucmakiola.calendula.testSettings
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.datetime.LocalDate
|
||||
@@ -81,43 +79,15 @@ class RescheduleViewModelTest {
|
||||
status = status,
|
||||
)
|
||||
|
||||
/**
|
||||
* [present] are the events whose occurrence the provider still reports where
|
||||
* a drop picked it up; [io] a [StandardTestDispatcher] keeps a drop in flight.
|
||||
*/
|
||||
private fun viewModel(
|
||||
tempDir: Path,
|
||||
fake: FakeCalendarDataSource,
|
||||
present: List<Long> = listOf(42L, 7L),
|
||||
io: CoroutineDispatcher = dispatcher,
|
||||
): RescheduleViewModel {
|
||||
fake.occurrencesAt(present)
|
||||
private fun viewModel(tempDir: Path, fake: FakeCalendarDataSource): RescheduleViewModel {
|
||||
val repo = testRepository(
|
||||
fake, tempDir, io,
|
||||
fake, tempDir, dispatcher,
|
||||
prefs = testPrefs(tempDir, CoroutineScope(dispatcher)),
|
||||
settings = testSettings(tempDir, CoroutineScope(dispatcher)),
|
||||
)
|
||||
return RescheduleViewModel(repo, io, FakeZoneSource())
|
||||
return RescheduleViewModel(repo, dispatcher, FakeZoneSource())
|
||||
}
|
||||
|
||||
private fun slowViewModel(tempDir: Path, fake: FakeCalendarDataSource): RescheduleViewModel =
|
||||
viewModel(tempDir, fake, io = StandardTestDispatcher(dispatcher.scheduler))
|
||||
|
||||
/** Every query answers with an occurrence of each of [ids] exactly at the queried span. */
|
||||
private fun FakeCalendarDataSource.occurrencesAt(ids: List<Long>) {
|
||||
instancesResult = { begin, end ->
|
||||
ids.map { id ->
|
||||
testEvent(
|
||||
id = id,
|
||||
start = Instant.fromEpochMilliseconds(begin),
|
||||
end = Instant.fromEpochMilliseconds(end),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val RescheduleViewModel.abandonedCount: Int get() = drops.value.abandoned.size
|
||||
|
||||
private fun CoroutineScope.activate(vm: RescheduleViewModel): Job =
|
||||
launch { vm.movableCalendarIds.collect {} }
|
||||
|
||||
@@ -127,8 +97,6 @@ class RescheduleViewModelTest {
|
||||
beginMillis = beginMillis,
|
||||
endMillis = endMillis,
|
||||
target = MoveTarget.ByDays(2),
|
||||
isAllDay = false,
|
||||
token = nextMoveToken(),
|
||||
)
|
||||
|
||||
/** A drop an hour later, expressed the way a timeline drag expresses one. */
|
||||
@@ -137,8 +105,6 @@ class RescheduleViewModelTest {
|
||||
beginMillis = beginMillis,
|
||||
endMillis = endMillis,
|
||||
target = MoveTarget.Start(Instant.fromEpochMilliseconds(beginMillis + 3_600_000L)),
|
||||
isAllDay = false,
|
||||
token = nextMoveToken(),
|
||||
)
|
||||
|
||||
/** The end dragged an hour later, the start left where it is. */
|
||||
@@ -150,8 +116,6 @@ class RescheduleViewModelTest {
|
||||
Instant.fromEpochMilliseconds(beginMillis),
|
||||
Instant.fromEpochMilliseconds(endMillis + 3_600_000L),
|
||||
),
|
||||
isAllDay = false,
|
||||
token = nextMoveToken(),
|
||||
)
|
||||
|
||||
@Test
|
||||
@@ -295,228 +259,21 @@ class RescheduleViewModelTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a drop of another event waits for the one in flight, then lands`(
|
||||
fun `a second drop is refused while the first is still in flight`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
eventDetailResult = { detail() }
|
||||
eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO") }
|
||||
}
|
||||
val vm = slowViewModel(tempDir, fake)
|
||||
|
||||
val first = oneHourLater()
|
||||
val second = toWednesday().copy(eventId = 7L, token = nextMoveToken())
|
||||
assertThat(vm.move(first)).isTrue()
|
||||
assertThat(vm.move(second)).isTrue()
|
||||
assertThat(vm.drops.value.pending).containsExactly(first.token, second.token)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(fake.updatedEvents.map { it.first }).containsExactly(42L, 7L).inOrder()
|
||||
assertThat(vm.drops.value).isEqualTo(DropStates())
|
||||
assertThat(vm.inFlight.value).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed drop lets go of its own copy only, not the one queued behind it`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
eventDetailResult = { detail() }
|
||||
}
|
||||
val vm = slowViewModel(tempDir, fake)
|
||||
|
||||
// Zero distance: refused in prepare.
|
||||
val nowhere = oneHourLater().copy(
|
||||
target = MoveTarget.Start(Instant.fromEpochMilliseconds(beginMillis)),
|
||||
token = nextMoveToken(),
|
||||
)
|
||||
val queued = toWednesday().copy(eventId = 7L, token = nextMoveToken())
|
||||
vm.move(nowhere)
|
||||
vm.move(queued)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.drops.value.abandoned).containsExactly(nowhere.token)
|
||||
assertThat(fake.updatedEvents.map { it.first }).containsExactly(7L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same event dropped twice ends up where it was dropped last`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val hour = 3_600_000L
|
||||
val fake = FakeCalendarDataSource()
|
||||
val vm = slowViewModel(tempDir, fake)
|
||||
// Once the first drop is written, the event sits an hour later.
|
||||
fake.eventDetailResult = {
|
||||
if (fake.updatedEvents.isEmpty()) detail() else detail(anchorMillis = beginMillis + hour)
|
||||
}
|
||||
fake.instancesResult = { _, _ ->
|
||||
val shift = if (fake.updatedEvents.isEmpty()) 0L else hour
|
||||
listOf(
|
||||
testEvent(
|
||||
id = 42L,
|
||||
start = Instant.fromEpochMilliseconds(beginMillis + shift),
|
||||
end = Instant.fromEpochMilliseconds(endMillis + shift),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
vm.move(oneHourLater())
|
||||
// Picked up from where the grid still drew it, and dropped two days on.
|
||||
vm.move(toWednesday())
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(fake.updatedEvents).hasSize(2)
|
||||
val last = fake.updatedEvents.last().third
|
||||
assertThat(last.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 6, 10), LocalTime(13, 0)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a drop on a series written moments ago is not written`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
// A whole-series write shifts every occurrence; the grid still draws the
|
||||
// old ones, and another occurrence may now sit at the dragged time.
|
||||
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail(rrule = "FREQ=DAILY") } }
|
||||
val vm = viewModel(tempDir, fake)
|
||||
vm.move(oneHourLater())
|
||||
advanceUntilIdle()
|
||||
vm.moveWithScope(RecurringWriteScope.AllEvents)
|
||||
advanceUntilIdle()
|
||||
vm.consumeOutcome()
|
||||
|
||||
val again = toWednesday()
|
||||
vm.move(again)
|
||||
assertThat(vm.move(toWednesday())).isTrue()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.scopePrompt.value).isNull()
|
||||
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.Stale)
|
||||
assertThat(vm.drops.value.abandoned).containsExactly(again.token)
|
||||
assertThat(fake.updatedEvents).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a single occurrence written leaves the rest of its series movable`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail(rrule = "FREQ=WEEKLY") } }
|
||||
val vm = viewModel(tempDir, fake)
|
||||
vm.move(oneHourLater())
|
||||
// Parked on the scope dialog, so the first drop still owns the pipeline.
|
||||
assertThat(vm.move(oneHourLater())).isFalse()
|
||||
advanceUntilIdle()
|
||||
vm.moveWithScope(RecurringWriteScope.ThisEvent)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Another occurrence, a week on.
|
||||
val week = 7 * 86_400_000L
|
||||
vm.move(oneHourLater().copy(beginMillis = beginMillis + week, endMillis = endMillis + week, token = nextMoveToken()))
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.scopePrompt.value).isNotNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `chip messages wait their turn instead of replacing each other`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
eventDetailResult = { id ->
|
||||
if (id == 42L) detail(rrule = "FREQ=WEEKLY;BYDAY=MO,WE", status = EventStatus.Cancelled) else detail()
|
||||
}
|
||||
}
|
||||
val vm = slowViewModel(tempDir, fake)
|
||||
|
||||
vm.move(toWednesday())
|
||||
vm.move(toWednesday().copy(eventId = 7L, token = nextMoveToken()))
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.BlockedCancelledSeries)
|
||||
vm.consumeOutcome()
|
||||
assertThat((vm.outcome.value as MoveOutcome.Moved).undo?.eventId).isEqualTo(7L)
|
||||
vm.consumeOutcome()
|
||||
assertThat(vm.outcome.value).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a full queue says so`(@TempDir tempDir: Path) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
|
||||
val vm = slowViewModel(tempDir, fake)
|
||||
|
||||
repeat(9) { i -> vm.move(toWednesday().copy(eventId = 100L + i, token = nextMoveToken())) }
|
||||
val refused = toWednesday().copy(eventId = 200L, token = nextMoveToken())
|
||||
assertThat(vm.move(refused)).isFalse()
|
||||
|
||||
assertThat(vm.drops.value.abandoned).containsExactly(refused.token)
|
||||
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.QueueFull)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a deleted event reports itself as gone, not moved`(@TempDir tempDir: Path) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply { eventDetailResult = { null } }
|
||||
val vm = viewModel(tempDir, fake, present = emptyList())
|
||||
|
||||
vm.move(oneHourLater())
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.Gone)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `drops waiting when a scope dialog opens are cancelled, not held behind it`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
eventDetailResult = { id -> if (id == 42L) detail(rrule = "FREQ=WEEKLY") else detail() }
|
||||
}
|
||||
val vm = slowViewModel(tempDir, fake)
|
||||
|
||||
vm.move(oneHourLater())
|
||||
val queued = toWednesday().copy(eventId = 7L, token = nextMoveToken())
|
||||
vm.move(queued)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.scopePrompt.value).isNotNull()
|
||||
assertThat(vm.drops.value.abandoned).containsExactly(queued.token)
|
||||
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.Skipped)
|
||||
|
||||
// One made while the dialog is up is refused the same way.
|
||||
val during = toWednesday().copy(eventId = 7L, token = nextMoveToken())
|
||||
assertThat(vm.move(during)).isFalse()
|
||||
|
||||
vm.moveWithScope(RecurringWriteScope.AllEvents)
|
||||
advanceUntilIdle()
|
||||
assertThat(fake.updatedEvents.map { it.first }).containsExactly(42L)
|
||||
// The skip has its turn, then the move it waited on, with its Undo.
|
||||
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.Skipped)
|
||||
vm.consumeOutcome()
|
||||
assertThat((vm.outcome.value as MoveOutcome.Moved).undo?.eventId).isEqualTo(42L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the first move of a run keeps the chip and its undo`(@TempDir tempDir: Path) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
|
||||
val vm = slowViewModel(tempDir, fake)
|
||||
|
||||
vm.move(oneHourLater())
|
||||
vm.move(toWednesday().copy(eventId = 7L, token = nextMoveToken()))
|
||||
advanceUntilIdle()
|
||||
|
||||
val shown = vm.outcome.value as MoveOutcome.Moved
|
||||
assertThat(fake.updatedEvents).hasSize(2)
|
||||
assertThat(shown.undo?.eventId).isEqualTo(42L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a later move of the same event takes the first move's undo away`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
|
||||
val vm = slowViewModel(tempDir, fake)
|
||||
|
||||
vm.move(oneHourLater())
|
||||
vm.move(toWednesday())
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(fake.updatedEvents).hasSize(2)
|
||||
assertThat((vm.outcome.value as MoveOutcome.Moved).undo).isNull()
|
||||
assertThat(fake.updatedEvents).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -527,19 +284,19 @@ class RescheduleViewModelTest {
|
||||
advanceUntilIdle()
|
||||
val undo = (vm.outcome.value as MoveOutcome.Moved).undo!!
|
||||
|
||||
// A second drop, of another event, parks on its scope dialog, which holds the pipeline.
|
||||
// A second drop parks on its scope dialog, which holds the pipeline.
|
||||
fake.eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO") }
|
||||
vm.move(toWednesday().copy(eventId = 7L, token = nextMoveToken()))
|
||||
vm.move(toWednesday())
|
||||
advanceUntilIdle()
|
||||
|
||||
val abandoned = vm.abandonedCount
|
||||
val abandoned = vm.abandoned.value
|
||||
assertThat(vm.undo(undo)).isFalse()
|
||||
advanceUntilIdle()
|
||||
assertThat(fake.updatedEvents).hasSize(1)
|
||||
// Nothing ticks: the refusal started no journey of its own, and the
|
||||
// counter is global — a tick here would release the copy the drop that
|
||||
// *is* in flight is holding, before the grid has drawn it.
|
||||
assertThat(vm.abandonedCount).isEqualTo(abandoned)
|
||||
assertThat(vm.abandoned.value).isEqualTo(abandoned)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -554,13 +311,13 @@ class RescheduleViewModelTest {
|
||||
advanceUntilIdle()
|
||||
val undo = (vm.outcome.value as MoveOutcome.Moved).undo!!
|
||||
|
||||
val abandoned = vm.abandonedCount
|
||||
val abandoned = vm.abandoned.value
|
||||
fake.writeError = SecurityException("permission revoked")
|
||||
assertThat(vm.undo(undo)).isTrue()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.WriteDenied)
|
||||
assertThat(vm.abandonedCount).isEqualTo(abandoned + 1)
|
||||
assertThat(vm.abandoned.value).isEqualTo(abandoned + 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -573,12 +330,12 @@ class RescheduleViewModelTest {
|
||||
advanceUntilIdle()
|
||||
val undo = (vm.outcome.value as MoveOutcome.Moved).undo!!
|
||||
|
||||
val abandoned = vm.abandonedCount
|
||||
val abandoned = vm.abandoned.value
|
||||
vm.undo(undo)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.Undone)
|
||||
assertThat(vm.abandonedCount).isEqualTo(abandoned)
|
||||
assertThat(vm.abandoned.value).isEqualTo(abandoned)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -658,8 +415,6 @@ class RescheduleViewModelTest {
|
||||
target = MoveTarget.Start(
|
||||
Instant.fromEpochMilliseconds(beginMillis + 26 * 3_600_000L),
|
||||
),
|
||||
isAllDay = false,
|
||||
token = nextMoveToken(),
|
||||
),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
@@ -680,8 +435,6 @@ class RescheduleViewModelTest {
|
||||
beginMillis = beginMillis,
|
||||
endMillis = endMillis,
|
||||
target = MoveTarget.Start(Instant.fromEpochMilliseconds(beginMillis)),
|
||||
isAllDay = false,
|
||||
token = nextMoveToken(),
|
||||
),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
@@ -803,19 +556,17 @@ class RescheduleViewModelTest {
|
||||
beginMillis = beginMillis,
|
||||
endMillis = endMillis,
|
||||
target = MoveTarget.Start(Instant.fromEpochMilliseconds(beginMillis)),
|
||||
isAllDay = false,
|
||||
token = nextMoveToken(),
|
||||
),
|
||||
)).isTrue()
|
||||
advanceUntilIdle()
|
||||
assertThat(vm.abandonedCount).isEqualTo(1)
|
||||
assertThat(vm.abandoned.value).isEqualTo(1)
|
||||
|
||||
// Refused by the provider, after the write was attempted.
|
||||
fake.writeError = SecurityException("revoked")
|
||||
vm.move(oneHourLater())
|
||||
advanceUntilIdle()
|
||||
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.WriteDenied)
|
||||
assertThat(vm.abandonedCount).isEqualTo(2)
|
||||
assertThat(vm.abandoned.value).isEqualTo(2)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -829,11 +580,11 @@ class RescheduleViewModelTest {
|
||||
|
||||
vm.move(oneHourLater())
|
||||
advanceUntilIdle()
|
||||
assertThat(vm.abandonedCount).isEqualTo(0)
|
||||
assertThat(vm.abandoned.value).isEqualTo(0)
|
||||
|
||||
vm.cancelScope()
|
||||
advanceUntilIdle()
|
||||
assertThat(vm.abandonedCount).isEqualTo(1)
|
||||
assertThat(vm.abandoned.value).isEqualTo(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -845,7 +596,7 @@ class RescheduleViewModelTest {
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.outcome.value).isInstanceOf(MoveOutcome.Moved::class.java)
|
||||
assertThat(vm.abandonedCount).isEqualTo(0)
|
||||
assertThat(vm.abandoned.value).isEqualTo(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -917,8 +668,6 @@ class RescheduleViewModelTest {
|
||||
beginMillis = beginMillis,
|
||||
endMillis = endMillis,
|
||||
target = MoveTarget.Span(sundayEvening, Instant.fromEpochMilliseconds(endMillis)),
|
||||
isAllDay = false,
|
||||
token = nextMoveToken(),
|
||||
),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
@@ -960,7 +709,7 @@ class RescheduleViewModelTest {
|
||||
|
||||
assertThat(vm.scopePrompt.value).isNull()
|
||||
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.BlockedCancelledSeries)
|
||||
assertThat(vm.abandonedCount).isEqualTo(1)
|
||||
assertThat(vm.abandoned.value).isEqualTo(1)
|
||||
assertThat(fake.updatedEvents).isEmpty()
|
||||
assertThat(fake.updatedOccurrences).isEmpty()
|
||||
}
|
||||
@@ -990,8 +739,6 @@ class RescheduleViewModelTest {
|
||||
beginMillis = dayStart,
|
||||
endMillis = dayStart + 86_400_000L,
|
||||
target = MoveTarget.Span(start, Instant.fromEpochMilliseconds(endMillis)),
|
||||
isAllDay = true,
|
||||
token = nextMoveToken(),
|
||||
),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
@@ -1028,8 +775,6 @@ class RescheduleViewModelTest {
|
||||
beginMillis = dayStart,
|
||||
endMillis = dayStart + 86_400_000L,
|
||||
target = MoveTarget.Span(eight, eight + 1.hours),
|
||||
isAllDay = true,
|
||||
token = nextMoveToken(),
|
||||
),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
@@ -53,7 +53,7 @@ class TimelineDropTest {
|
||||
@Test
|
||||
fun `dropping the head asks for exactly where it landed`() {
|
||||
val target = LocalDate(2026, 9, 2)
|
||||
val drop = TimelineDrop(overnight, target, 20 * 60, clipOffsetMin = 0, token = 0L)
|
||||
val drop = TimelineDrop(overnight, target, 20 * 60, clipOffsetMin = 0)
|
||||
|
||||
assertThat(drop.startInstant(zone)).isEqualTo(target.atTime(20, 0).toInstant(zone))
|
||||
}
|
||||
@@ -63,7 +63,7 @@ class TimelineDropTest {
|
||||
val tail = blockOn(sun)
|
||||
val offset = tail.clipOffsetMinutes(sun, zone)
|
||||
// Dragged three days back, same height in the column: Sun 6 -> Thu 3.
|
||||
val drop = TimelineDrop(overnight, LocalDate(2026, 9, 3), 0, offset, token = 0L)
|
||||
val drop = TimelineDrop(overnight, LocalDate(2026, 9, 3), 0, offset)
|
||||
|
||||
// The event began Sat 5 Sep 20:00, so it now begins Wed 2 Sep 20:00 —
|
||||
// the whole event moved three days, and no time of day was invented.
|
||||
@@ -75,7 +75,7 @@ class TimelineDropTest {
|
||||
fun `dragging the tail down the column re-times the event across midnight`() {
|
||||
val offset = blockOn(sun).clipOffsetMinutes(sun, zone)
|
||||
// Tail top pulled from 00:00 to 03:00 on its own day.
|
||||
val drop = TimelineDrop(overnight, sun, 3 * 60, offset, token = 0L)
|
||||
val drop = TimelineDrop(overnight, sun, 3 * 60, offset)
|
||||
|
||||
assertThat(drop.startInstant(zone)).isEqualTo(sat.atTime(23, 0).toInstant(zone))
|
||||
}
|
||||
@@ -84,7 +84,7 @@ class TimelineDropTest {
|
||||
fun `pulling the tail above its own midnight moves the event earlier`() {
|
||||
val offset = blockOn(sun).clipOffsetMinutes(sun, zone)
|
||||
// The tail's top edge dragged an hour above the midnight it rests on.
|
||||
val drop = TimelineDrop(overnight, sun, -60, offset, token = 0L)
|
||||
val drop = TimelineDrop(overnight, sun, -60, offset)
|
||||
|
||||
// Event was Sat 20:00 to Sun 08:00; it now begins an hour earlier.
|
||||
assertThat(drop.startInstant(zone)).isEqualTo(sat.atTime(19, 0).toInstant(zone))
|
||||
@@ -92,7 +92,7 @@ class TimelineDropTest {
|
||||
|
||||
@Test
|
||||
fun `a top edge dragged past the next midnight resolves onto the day after`() {
|
||||
val drop = TimelineDrop(overnight, sat, 25 * 60, clipOffsetMin = 0, token = 0L)
|
||||
val drop = TimelineDrop(overnight, sat, 25 * 60, clipOffsetMin = 0)
|
||||
|
||||
assertThat(drop.startInstant(zone)).isEqualTo(sun.atTime(1, 0).toInstant(zone))
|
||||
}
|
||||
@@ -130,7 +130,7 @@ class TimelineDropTest {
|
||||
val tail = layoutDay(listOf(overnightIntoDst), sun, berlin).single()
|
||||
val offset = tail.clipOffsetMinutes(sun, berlin)
|
||||
// Dropped back onto its own slot a week earlier, clear of the boundary.
|
||||
val drop = TimelineDrop(overnightIntoDst, LocalDate(2026, 3, 22), 0, offset, token = 0L)
|
||||
val drop = TimelineDrop(overnightIntoDst, LocalDate(2026, 3, 22), 0, offset)
|
||||
|
||||
assertThat(drop.startInstant(berlin))
|
||||
.isEqualTo(LocalDate(2026, 3, 21).atTime(22, 0).toInstant(berlin))
|
||||
@@ -155,7 +155,7 @@ class TimelineDropTest {
|
||||
)
|
||||
val block = layoutDay(listOf(event), fallBack, brussels).single()
|
||||
assertThat(block.clipOffsetMinutes(fallBack, brussels)).isEqualTo(0)
|
||||
val drop = TimelineDrop(event, fallBack, block.startMin, clipOffsetMin = 0, token = 0L)
|
||||
val drop = TimelineDrop(event, fallBack, block.startMin, clipOffsetMin = 0)
|
||||
assertThat(drop.startInstant(brussels)).isEqualTo(event.start)
|
||||
}
|
||||
|
||||
@@ -168,7 +168,7 @@ class TimelineDropTest {
|
||||
val tail = layoutDay(listOf(event), fallBack, brussels).single()
|
||||
val offset = tail.clipOffsetMinutes(fallBack, brussels)
|
||||
assertThat(offset).isEqualTo(2 * 60)
|
||||
val drop = TimelineDrop(event, fallBack, tail.startMin, clipOffsetMin = offset, token = 0L)
|
||||
val drop = TimelineDrop(event, fallBack, tail.startMin, clipOffsetMin = offset)
|
||||
assertThat(drop.startInstant(brussels)).isEqualTo(event.start)
|
||||
}
|
||||
|
||||
@@ -189,7 +189,7 @@ class TimelineDropTest {
|
||||
end = Instant.parse("2026-10-25T02:30:00Z"),
|
||||
)
|
||||
val block = layoutDay(listOf(event), fallBack, brussels).single()
|
||||
val drop = TimelineDrop(event, fallBack, block.startMin + 15, clipOffsetMin = 0, token = 0L)
|
||||
val drop = TimelineDrop(event, fallBack, block.startMin + 15, clipOffsetMin = 0)
|
||||
assertThat(drop.startInstant(brussels)).isEqualTo(Instant.parse("2026-10-25T01:45:00Z"))
|
||||
}
|
||||
|
||||
@@ -234,7 +234,7 @@ class TimelineDropTest {
|
||||
start = Instant.parse("2026-10-25T01:30:00Z"),
|
||||
end = Instant.parse("2026-10-25T03:00:00Z"),
|
||||
)
|
||||
val drop = resizedSpan(event, ResizeEdge.Bottom, fallBack, 2 * 60 + 30, 15, brussels)
|
||||
val drop = resizedDrop(event, ResizeEdge.Bottom, fallBack, 2 * 60 + 30, 15, brussels)
|
||||
assertThat(drop.start).isEqualTo(event.start)
|
||||
assertThat(drop.end).isEqualTo(Instant.parse("2026-10-25T01:45:00Z"))
|
||||
}
|
||||
@@ -246,7 +246,7 @@ class TimelineDropTest {
|
||||
start = Instant.parse("2026-10-25T00:30:00Z"),
|
||||
end = Instant.parse("2026-10-25T01:15:00Z"),
|
||||
)
|
||||
val drop = resizedSpan(event, ResizeEdge.Top, fallBack, 2 * 60, 60, brussels)
|
||||
val drop = resizedDrop(event, ResizeEdge.Top, fallBack, 2 * 60, 60, brussels)
|
||||
assertThat(drop.start).isEqualTo(Instant.parse("2026-10-25T00:00:00Z"))
|
||||
assertThat(drop.end).isEqualTo(event.end)
|
||||
}
|
||||
@@ -258,7 +258,7 @@ class TimelineDropTest {
|
||||
start = Instant.parse("2026-10-25T00:20:00Z"),
|
||||
end = Instant.parse("2026-10-25T01:10:00Z"),
|
||||
)
|
||||
val drop = resizedSpan(event, ResizeEdge.Bottom, fallBack, 2 * 60 + 20, 30, brussels)
|
||||
val drop = resizedDrop(event, ResizeEdge.Bottom, fallBack, 2 * 60 + 20, 30, brussels)
|
||||
assertThat(drop.end).isEqualTo(Instant.parse("2026-10-25T00:50:00Z"))
|
||||
}
|
||||
|
||||
@@ -270,7 +270,7 @@ class TimelineDropTest {
|
||||
start = Instant.parse("2026-10-24T23:00:00Z"),
|
||||
end = Instant.parse("2026-10-25T03:00:00Z"),
|
||||
)
|
||||
val drop = resizedSpan(event, ResizeEdge.Bottom, fallBack, 60, 75, brussels)
|
||||
val drop = resizedDrop(event, ResizeEdge.Bottom, fallBack, 60, 75, brussels)
|
||||
assertThat(drop.end).isEqualTo(Instant.parse("2026-10-25T00:15:00Z"))
|
||||
}
|
||||
}
|
||||
@@ -356,12 +356,12 @@ class WeekLayoutTest {
|
||||
)
|
||||
assertThat(state.allDaySpans.map { it.lane }).containsExactly(0, 0)
|
||||
|
||||
val preview = state.withStripPreview(listOf(StripDrag(held, dayShift = 2)), zone).allDaySpans
|
||||
val preview = state.withStripPreview(StripDrag(held, dayShift = 2), zone).allDaySpans
|
||||
val moved = preview.single { it.event.instanceId == held.instanceId }
|
||||
|
||||
assertThat(moved.event).isEqualTo(held)
|
||||
assertThat(moved.startCol).isEqualTo(2)
|
||||
assertThat(preview.map { it.lane }).containsExactly(0, 1)
|
||||
assertThat(state.withStripPreview(listOf(StripDrag(held, dayShift = 2, overGrid = true)), zone)).isSameInstanceAs(state)
|
||||
assertThat(state.withStripPreview(StripDrag(held, dayShift = 2, overGrid = true), zone)).isSameInstanceAs(state)
|
||||
}
|
||||
}
|
||||
@@ -492,17 +492,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,6 +0,0 @@
|
||||
الإصلاحات
|
||||
• لم تعد الأحداث المنقولة بسرعة متتالية تضيع: تُحفظ عمليات النقل بالترتيب.
|
||||
• تظهر رسالة عند تعذّر حفظ عملية نقل.
|
||||
|
||||
التغييرات
|
||||
• يعرض عنوان عرض اليوم الشهر.
|
||||
@@ -1,6 +0,0 @@
|
||||
Выпраўлена
|
||||
• Падзеі, перанесеныя хутка адна за адной, больш не губляюцца: пераносы захоўваюцца па чарзе.
|
||||
• Паведамленне паказвае, калі перанос не ўдалося захаваць.
|
||||
|
||||
Зменена
|
||||
• Загаловак дзённага прагляду паказвае месяц.
|
||||
@@ -1,6 +0,0 @@
|
||||
Opraveno
|
||||
• Události rychle přesunuté za sebou se už neztrácí: přesuny se ukládají postupně.
|
||||
• Zpráva upozorní, když přesun nešel uložit.
|
||||
|
||||
Změněno
|
||||
• Název denního zobrazení ukazuje měsíc.
|
||||
@@ -1,6 +0,0 @@
|
||||
Behoben
|
||||
• Schnell hintereinander verschobene Termine gehen nicht mehr verloren: Verschiebungen werden der Reihe nach gespeichert.
|
||||
• Eine Meldung sagt, wenn eine Verschiebung nicht gespeichert werden konnte.
|
||||
|
||||
Geändert
|
||||
• Der Titel der Tagesansicht zeigt den Monat.
|
||||
@@ -1,6 +0,0 @@
|
||||
Fixed
|
||||
• Events moved in quick succession are no longer lost: moves are saved in order.
|
||||
• A message says when a move could not be saved.
|
||||
|
||||
Changed
|
||||
• The day view title shows the month.
|
||||
@@ -1,6 +0,0 @@
|
||||
Correcciones
|
||||
• Los eventos movidos rápidamente uno tras otro ya no se pierden: los cambios se guardan en orden.
|
||||
• Un mensaje avisa cuando no se pudo guardar un cambio.
|
||||
|
||||
Cambios
|
||||
• El título de la vista diaria muestra el mes.
|
||||
@@ -1,6 +0,0 @@
|
||||
Corrections
|
||||
• Les événements déplacés rapidement à la suite ne sont plus perdus : les déplacements sont enregistrés dans l’ordre.
|
||||
• Un message indique quand un déplacement n’a pas pu être enregistré.
|
||||
|
||||
Modifications
|
||||
• Le titre de la vue jour affiche le mois.
|
||||
@@ -1,6 +0,0 @@
|
||||
Javítva
|
||||
• A gyors egymásutánban áthelyezett események többé nem vesznek el: az áthelyezések sorban mentődnek.
|
||||
• Üzenet jelzi, ha egy áthelyezést nem sikerült menteni.
|
||||
|
||||
Változások
|
||||
• A napi nézet címe a hónapot mutatja.
|
||||
@@ -1,6 +0,0 @@
|
||||
Correzioni
|
||||
• Gli eventi spostati rapidamente uno dopo l’altro non vanno più persi: gli spostamenti vengono salvati in ordine.
|
||||
• Un messaggio avvisa quando uno spostamento non è stato salvato.
|
||||
|
||||
Modifiche
|
||||
• Il titolo della vista giornaliera mostra il mese.
|
||||
@@ -1,6 +0,0 @@
|
||||
修正
|
||||
• 続けてすばやく移動した予定が失われなくなりました。移動は順番に保存されます。
|
||||
• 移動を保存できなかったときはメッセージが表示されます。
|
||||
|
||||
変更
|
||||
• 日表示のタイトルに月が表示されるようになりました。
|
||||
@@ -1,6 +0,0 @@
|
||||
Opgelost
|
||||
• Snel na elkaar verplaatste afspraken gaan niet meer verloren: verplaatsingen worden op volgorde opgeslagen.
|
||||
• Een melding laat weten wanneer een verplaatsing niet kon worden opgeslagen.
|
||||
|
||||
Gewijzigd
|
||||
• De titel van de dagweergave toont de maand.
|
||||
@@ -1,6 +0,0 @@
|
||||
Poprawki
|
||||
• Wydarzenia szybko przenoszone jedno po drugim już nie giną: przeniesienia są zapisywane po kolei.
|
||||
• Komunikat informuje, gdy przeniesienia nie udało się zapisać.
|
||||
|
||||
Zmiany
|
||||
• Tytuł widoku dnia pokazuje miesiąc.
|
||||
@@ -1,6 +0,0 @@
|
||||
Correções
|
||||
• Eventos movidos rapidamente um após o outro não se perdem mais: as mudanças são salvas em ordem.
|
||||
• Uma mensagem avisa quando uma mudança não pôde ser salva.
|
||||
|
||||
Alterações
|
||||
• O título da visualização diária mostra o mês.
|
||||
@@ -1,6 +0,0 @@
|
||||
Correções
|
||||
• Os eventos movidos rapidamente uns após os outros já não se perdem: as alterações são guardadas por ordem.
|
||||
• Uma mensagem avisa quando uma alteração não pôde ser guardada.
|
||||
|
||||
Alterações
|
||||
• O título da vista diária mostra o mês.
|
||||
@@ -1,6 +0,0 @@
|
||||
Исправлено
|
||||
• События, быстро перенесённые одно за другим, больше не теряются: переносы сохраняются по очереди.
|
||||
• Сообщение показывает, если перенос не удалось сохранить.
|
||||
|
||||
Изменено
|
||||
• Заголовок дневного вида показывает месяц.
|
||||
@@ -1,6 +0,0 @@
|
||||
Opravené
|
||||
• Udalosti rýchlo presunuté za sebou sa už nestrácajú: presuny sa ukladajú postupne.
|
||||
• Správa upozorní, keď sa presun nepodarilo uložiť.
|
||||
|
||||
Zmenené
|
||||
• Názov denného zobrazenia ukazuje mesiac.
|
||||
@@ -1,6 +0,0 @@
|
||||
修复
|
||||
• 快速连续移动的日程不再丢失:移动会按顺序保存。
|
||||
• 移动无法保存时会显示提示。
|
||||
|
||||
变更
|
||||
• 日视图标题显示月份。
|
||||
@@ -1,6 +0,0 @@
|
||||
修正
|
||||
• 快速連續移動的活動不再遺失:移動會依序儲存。
|
||||
• 移動無法儲存時會顯示訊息。
|
||||
|
||||
變更
|
||||
• 日檢視標題會顯示月份。
|
||||
+1
-1
Submodule floret-kit updated: da7dbc3e04...a039ab5bd4.
@@ -4,7 +4,7 @@ kotlin = "2.4.20"
|
||||
ksp = "2.3.11"
|
||||
hilt = "2.60.1"
|
||||
coreKtx = "1.19.0"
|
||||
appcompat = "1.8.0"
|
||||
appcompat = "1.7.1"
|
||||
lifecycleRuntime = "2.11.0"
|
||||
activityCompose = "1.13.0"
|
||||
composeBom = "2026.09.00"
|
||||
@@ -18,7 +18,7 @@ junitPlatform = "6.1.3"
|
||||
truth = "1.4.5"
|
||||
androidxJunit = "1.3.0"
|
||||
espressoCore = "3.7.0"
|
||||
kotlinxDatetime = "0.8.0-0.6.x-compat"
|
||||
kotlinxDatetime = "0.7.0"
|
||||
kotlinxCoroutines = "1.11.0"
|
||||
turbine = "1.2.1"
|
||||
hiltNavigationCompose = "1.4.0"
|
||||
|
||||
Reference in new issue
Block a user