Compare commits

..
Author SHA1 Message Date
makiolaj 739c5f2a50 fix(#481): mirror reminder rewrite against what events carry, duplicate identities, clean reminder lists 2026-11-13 21:25:52 +01:00
makiolaj fe7a4b4719 fix(#481): rewrite changed mirror reminders, no empty keys, require settings section 2026-11-13 21:17:59 +01:00
makiolaj 47f3844d75 fix(#481): snackbar feedback, uncancellable import, mirror per-type matching, progress 2026-11-13 21:09:06 +01:00
makiolaj 5ee25c2115 fix(#481): keep per-calendar settings when calendars can't be read, re-apply mirror reminders 2026-11-13 19:46:32 +01:00
makiolaj 90b640054a feat(#481): export and import settings in Backup & restore 2026-11-13 19:39:47 +01:00
makiolaj e9cf7d66ba Release v2.24.2
Beta — Codeberg pre-release / detect (push) Successful in 4s
Beta — Codeberg pre-release / beta (push) Skipped
2026-11-13 15:51:08 +01:00
Jean-Luc Makiola e01d6efb84 Merge pull request 'chore(deps): update ghcr.io/renovatebot/renovate docker tag to v44' (#510) from deps/renovate-44 into release/2.24.2 2026-11-13 15:43:14 +01:00
Jean-Luc Makiola b7c801c8db Merge pull request 'chore(deps): update dependency fastlane to v2.241.1' (#509) from deps/fastlane into release/2.24.2 2026-11-13 15:42:26 +01:00
Jean-Luc Makiola b15e90cc64 Merge pull request 'fix(deps): update appcompat to v1.8.0' (#508) from deps/appcompat into release/2.24.2 2026-11-13 15:17:14 +01:00
Jean-Luc Makiola 8d3be6aba3 Merge pull request 'fix(deps): update kotlinxdatetime to v0.8.0-0.6.x-compat' (#507) from deps/kotlinxdatetime into release/2.24.2 2026-11-13 15:14:40 +01:00
renovate-bot 464ae8952e chore(deps): update ghcr.io/renovatebot/renovate docker tag to v44 2026-11-13 14:13:53 +01:00
renovate-bot 2007541adf chore(deps): update dependency fastlane to v2.241.1 2026-11-13 14:13:53 +01:00
renovate-bot d6167617a9 fix(deps): update appcompat to v1.8.0 2026-11-13 14:13:53 +01:00
renovate-bot 38d7c1494d fix(deps): update kotlinxdatetime to v0.8.0-0.6.x-compat 2026-11-13 14:13:53 +01:00
Jean-Luc Makiola c0c0d56e86 Merge pull request 'Queue drops instead of silently dropping them (#494)' (#506) from fix/494-queued-drop into release/2.24.2 2026-11-13 14:09:14 +01:00
Jean-Luc Makiolaandmakiolaj 745301d14b Day view: title names the month (#501) (#505)
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Skipped
The day view's top bar now shows the month name (e.g. "November", abbreviated when space is short, with the year outside the current year), the same as the week and 3-day views. Weekday and date already sit in the column header right below, so the old title repeated them.

Closes #501

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/505
2026-11-13 13:37:38 +01:00
makiolaj 8d640b595e Third review round: undo by drop, queued chip messages, recent-write stale check, held strip and seat (#494) 2026-11-13 13:30:49 +01:00
makiolaj 1925e1c2f4 Hold every dropped copy until its own write lands, cancel drops behind a dialog, keep the first Undo (#494) 2026-11-13 13:18:11 +01:00
makiolaj 0595124ecd Drops follow their event if it moved, failures stay on the chip (#494) 2026-11-13 12:45:48 +01:00
makiolaj 8e3202d1ad Give every drop its own token and queue drops in order (#494) 2026-11-13 12:00:37 +01:00
makiolaj 5f8eb39206 Queue a drop made while another move is still saving (#494) 2026-11-13 11:27:46 +01:00
makiolaj e7bf700f36 Release v2.24.1
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Skipped
2026-11-12 15:57:47 +01:00
makiolaj c10342818a Merge Weblate translations (#434) 2026-11-12 15:56:26 +01:00
Weblate 0f29dc4db6 Update translation files
Updated by "Remove blank strings" add-on in Weblate.

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/
2026-11-12 14:52:53 +00:00
Anonymous 80fae240da Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 3.9% (27 of 678 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/zh_Hant/
2026-11-12 14:52:48 +00:00
Anonymous ab2e11a2e4 Translated using Weblate (Dutch)
Currently translated at 100.0% (678 of 678 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/nl/
2026-11-12 14:52:38 +00:00
Anonymous fb4fd17603 Translated using Weblate (Hungarian)
Currently translated at 9.2% (63 of 678 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/hu/
2026-11-12 14:52:28 +00:00
Anonymous 86f7637213 Translated using Weblate (Slovak)
Currently translated at 36.8% (250 of 678 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/sk/
2026-11-12 14:52:17 +00:00
Anonymous c5084b86af Translated using Weblate (Belarusian)
Currently translated at 93.2% (632 of 678 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/be/
2026-11-12 14:52:11 +00:00
Dmitri Gabinski 919b95c378 Translated using Weblate (Belarusian)
Currently translated at 93.2% (632 of 678 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/be/
2026-11-12 14:52:10 +00:00
Anonymous ec7db10601 Translated using Weblate (Czech)
Currently translated at 100.0% (678 of 678 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/cs/
2026-11-12 14:52:01 +00:00
Anonymous 73403ee799 Translated using Weblate (Japanese)
Currently translated at 17.1% (116 of 678 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ja/
2026-11-12 14:51:50 +00:00
Anonymous a294542eda Translated using Weblate (Portuguese (Brazil))
Currently translated at 84.0% (570 of 678 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/pt_BR/
2026-11-12 14:51:35 +00:00
Anonymous 786bde2965 Translated using Weblate (Arabic)
Currently translated at 100.0% (678 of 678 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ar/
2026-11-12 14:51:33 +00:00
Sup99999 e2ac8bbdc7 Translated using Weblate (Arabic)
Currently translated at 100.0% (678 of 678 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ar/
2026-11-12 14:51:33 +00:00
Anonymous 673cfc2f99 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 83.6% (567 of 678 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/zh_Hans/
2026-11-12 14:51:23 +00:00
Anonymous c533cc0439 Translated using Weblate (Russian)
Currently translated at 37.7% (256 of 678 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ru/
2026-11-12 14:51:12 +00:00
Anonymous 7a49dffe0d Translated using Weblate (Portuguese)
Currently translated at 67.1% (455 of 678 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/pt/
2026-11-12 14:51:05 +00:00
Anonymous e6ab85d382 Translated using Weblate (Polish)
Currently translated at 83.6% (567 of 678 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/pl/
2026-11-12 14:50:55 +00:00
Anonymous 011356e103 Translated using Weblate (Italian)
Currently translated at 100.0% (678 of 678 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/it/
2026-11-12 14:50:45 +00:00
Anonymous c8595ccedd Translated using Weblate (French)
Currently translated at 87.0% (590 of 678 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/fr/
2026-11-12 14:50:33 +00:00
Louis Leblanc 9e1fd2956d Translated using Weblate (French)
Currently translated at 87.0% (590 of 678 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/fr/
2026-11-12 14:50:32 +00:00
Anonymous 6a1697dc5a Translated using Weblate (Spanish)
Currently translated at 61.5% (417 of 678 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/es/
2026-11-12 14:50:20 +00:00
Anonymous 2641145b60 Translated using Weblate (German)
Currently translated at 100.0% (678 of 678 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/de/
2026-11-12 14:50:15 +00:00
Sup99999 70a7bf1033 Translated using Weblate (Arabic)
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
Currently translated at 100.0% (678 of 678 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ar/
2026-11-12 15:47:40 +01:00
Jean-Luc Makiola a92dd013bc Merge pull request 'Widgets: readable dimmed events, pinned theme matches system (#475)' (#500) from fix/475-widget-feedback into release/2.24.1
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
2026-11-09 21:06:55 +01:00
71 changed files with 3213 additions and 373 deletions

No files matched your search

+1 -1
View File
@@ -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:43.232.0
image: ghcr.io/renovatebot/renovate:44.237.3
steps:
- name: Run Renovate
run: renovate
+33
View File
@@ -7,6 +7,32 @@ 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
- **A classic style for the agenda widget.** Its settings now have a Style option: Cards keeps the new look, Classic brings back the 2.23 layout with a line per day and a colour stripe beside each event, under the current header. Dates in another year show the year. Classic has an Invert text colour switch, so its text stays readable on light and dark wallpapers ([#489]).
- **Widget opacity.** Every widget with a Solid or Tinted background has an Opacity slider from 20 to 100%. Below 50%, the card-style widgets back their content like a Transparent widget so it stays readable ([#489]).
### Fixed
- **Week numbers match your region.** With weeks starting on Sunday, the month and week views showed every week number one too low. Rows are now numbered by your region's rules, so ISO regions such as Germany are unchanged ([#493]).
- **The month widget shows events at every height.** At some sizes a week row had room for a little more than the date but not a full line of events, and drew none. It now fits a slimmer line there, and a row with a single line shows "+N" on days with more events than fit ([#491]).
- **Dimmed past events in dark widgets are readable again.** They used to turn pastel with white text on top ([#475]).
- **A widget set to Light or Dark matches the system colours.** With Material You, the + button and today's date could look different from a widget that follows the app ([#475]).
### Changed
- **Fewer permissions.** Calendula no longer asks for network state, wake lock or foreground service access, which a library added without the app needing them ([#448]).
- Translation updates from [Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/).
## [2.24.0] — 2026-11-06
### Added
@@ -1952,3 +1978,10 @@ automatically, with zero telemetry and no internet permission.
[#473]: https://codeberg.org/jlmakiola/calendula/issues/473
[#476]: https://codeberg.org/jlmakiola/calendula/issues/476
[#483]: https://codeberg.org/jlmakiola/calendula/issues/483
[#448]: https://codeberg.org/jlmakiola/calendula/issues/448
[#475]: https://codeberg.org/jlmakiola/calendula/issues/475
[#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
+1 -1
View File
@@ -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.237.0"
gem "fastlane", "2.241.1"
+3 -2
View File
@@ -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 = 2240099
versionName = "2.24.0"
versionCode = 2240299
versionName = "2.24.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -177,6 +177,7 @@ 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")
@@ -0,0 +1,198 @@
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"
@@ -0,0 +1,240 @@
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"
}
}
@@ -0,0 +1,106 @@
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()
}
@@ -0,0 +1,214 @@
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(HIDDEN_IDS)
val hiddenCalendarIds: Flow<Set<Long>> = prefs.flow(CalendarPrefKeys.hiddenCalendarIds)
suspend fun setHiddenCalendarIds(ids: Set<Long>) = prefs.set(HIDDEN_IDS, ids)
suspend fun setHiddenCalendarIds(ids: Set<Long>) = prefs.set(CalendarPrefKeys.hiddenCalendarIds, 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(DISABLED_IDS)
val pendingDisabledCalendarIds: Flow<Set<Long>> = prefs.flow(CalendarPrefKeys.pendingDisabledCalendarIds)
suspend fun addPendingDisabledCalendarIds(ids: Collection<Long>) {
prefs.update(DISABLED_IDS) { it + ids }
prefs.update(CalendarPrefKeys.pendingDisabledCalendarIds) { 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(DISABLED_IDS) { it - ids.toSet() }
prefs.update(CalendarPrefKeys.pendingDisabledCalendarIds) { it - ids.toSet() }
}
/**
@@ -60,24 +60,26 @@ 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(VISIBILITY_NOTICE)
val visibilityNoticePending: Flow<Boolean?> = prefs.flow(CalendarPrefKeys.visibilityNoticePending)
suspend fun setVisibilityNoticePending(pending: Boolean) = prefs.set(VISIBILITY_NOTICE, pending)
suspend fun setVisibilityNoticePending(pending: Boolean) = prefs.set(CalendarPrefKeys.visibilityNoticePending, 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(LAST_USED_CALENDAR)
val lastUsedCalendarId: Flow<Long?> = prefs.flow(CalendarPrefKeys.lastUsedCalendarId)
suspend fun setLastUsedCalendarId(id: Long) = prefs.set(LAST_USED_CALENDAR, id)
suspend fun setLastUsedCalendarId(id: Long) = prefs.set(CalendarPrefKeys.lastUsedCalendarId, 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")
}
}
/** 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")
}
/** A comma-joined id set; non-numeric tokens are dropped, and an empty set removes the key. */
@@ -2,6 +2,7 @@ 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
@@ -23,11 +24,13 @@ 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(LAST_SCAN)
suspend fun lastScanMillis(): Long? = prefs.get(ReminderStateKeys.lastScanMillis)
suspend fun setLastScanMillis(millis: Long) = prefs.set(LAST_SCAN, millis)
suspend fun setLastScanMillis(millis: Long) = prefs.set(ReminderStateKeys.lastScanMillis, millis)
private companion object {
val LAST_SCAN = nullableLongPref("reminder_last_scan_millis")
}
}
/** The key [ReminderStatePrefs] stores. */
internal object ReminderStateKeys {
val lastScanMillis: Pref<Long?> = nullableLongPref("reminder_last_scan_millis")
}
@@ -485,9 +485,8 @@ fun CalendarHost(
movableCalendarIds = movableCalendarIds,
dragEnabled = dragToReschedule,
move = { reschedule.move(it) },
inFlight = reschedule.inFlight,
drops = reschedule.drops,
undoStarted = reschedule.undoStarted,
abandoned = reschedule.abandoned,
edit = onEditEvent,
)
}
@@ -10,6 +10,7 @@ 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
@@ -76,7 +77,9 @@ private val RESTORE_MIME_TYPES = arrayOf(
/**
* Backup & restore (#69): `.ics` export and restore for local calendars, with
* optional automatic backup. Shares [CalendarsViewModel] with the manager.
* 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.
*
* A full-screen destination hoisted in `CalendarHost`; [onBack] pops it,
* [onImport] hands a picked file to the app's normal .ics import flow.
@@ -87,6 +90,7 @@ 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()
@@ -132,20 +136,27 @@ 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 -> BackupLoading()
is BackupUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onBack)
BackupUiState.Loading -> {
StateSlot { BackupLoading() }
SettingsBackupSection(settings)
}
is BackupUiState.Failure -> {
StateSlot { CalendarFailure(reason = s.reason, onRetry = onBack) }
SettingsBackupSection(settings)
}
is BackupUiState.Ready -> BackupContent(
exportable = s.exportable,
canImport = s.canImport,
@@ -158,6 +169,7 @@ fun BackupScreen(
},
onExportPick = { showExportPicker = true },
onEditInterval = { showInterval = true },
settings = settings,
)
}
}
@@ -173,6 +185,7 @@ fun BackupScreen(
)
}
}
SettingsBackupDialogs(settings, settingsBackup, snackbarHostState)
if (showInterval) {
BackupIntervalDialog(
currentMinutes = autoBackup.intervalMinutes,
@@ -182,6 +195,14 @@ 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() {
@@ -213,6 +234,7 @@ 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
@@ -233,6 +255,7 @@ private fun BackupContent(
leading = { LeadingAvatar(Icons.Default.FileUpload) },
onClick = onRestoreClick,
)
SettingsBackupSection(settings)
return
}
@@ -277,6 +300,7 @@ private fun BackupContent(
)
GroupedHint(backupStatusText(autoBackup.status), style = MaterialTheme.typography.bodySmall)
}
SettingsBackupSection(settings)
}
/**
@@ -433,5 +457,6 @@ 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
@@ -0,0 +1,224 @@
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
}
@@ -0,0 +1,108 @@
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 — "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月").
* 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月").
*
* 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,6 +90,9 @@ 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,12 +1,15 @@
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.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.res.stringResource
@@ -16,6 +19,7 @@ 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
@@ -37,24 +41,18 @@ class EventMoveScope(
val dragEnabled: Boolean,
/**
* Hand a drop to the writer. Returns nothing on purpose: a drop is answered
* before its write resolves, so [abandoned] — not a return value — is what
* tells a held copy that nothing landed.
* before its write resolves, so [drops] — not a return value — is what tells
* a held copy whether anything landed.
*/
val move: (MoveRequest) -> Unit,
/**
* 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.
* 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.
*/
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>,
val drops: StateFlow<DropStates>,
/** The undo write that just began — see `RescheduleViewModel.undoStarted`. */
val undoStarted: StateFlow<UndoStart?>,
/** Open an event in the edit form — the pointer-free route to the same change. */
val edit: (EventInstance) -> Unit,
) {
@@ -63,30 +61,42 @@ class EventMoveScope(
val LocalEventMove = compositionLocalOf<EventMoveScope?> { null }
private val NEVER_IN_FLIGHT = MutableStateFlow(false)
private val moveTokens = AtomicLong()
private val NEVER_UNDONE = MutableStateFlow(0)
/** A fresh identity for one drop or undo, unique for the process. */
fun nextMoveToken(): Long = moveTokens.incrementAndGet()
private val NEVER_ABANDONED = MutableStateFlow(0)
/** How many abandoned drops [DropStates] remembers — far more than can be on screen at once. */
private const val ABANDONED_KEPT = 32
/**
* 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 moveInFlight(): Boolean {
val flow = LocalEventMove.current?.inFlight ?: NEVER_IN_FLIGHT
return flow.collectAsStateWithLifecycle().value
/** 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)
/**
* 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.
* 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.
*/
@Composable
fun abandonedMoves(): Int {
val flow = LocalEventMove.current?.abandoned ?: NEVER_ABANDONED
fun dropStates(): DropStates {
val flow = LocalEventMove.current?.drops ?: NO_DROPS
return flow.collectAsStateWithLifecycle().value
}
@@ -95,17 +105,53 @@ fun abandonedMoves(): Int {
* this composable came on screen.
*/
@Composable
fun OnUndoStarted(onUndo: () -> Unit) {
fun OnUndoStarted(onUndo: (UndoStart) -> Unit) {
val flow = LocalEventMove.current?.undoStarted ?: NEVER_UNDONE
val tick by flow.collectAsStateWithLifecycle()
var seen by remember { mutableIntStateOf(tick) }
LaunchedEffect(tick) {
if (tick == seen) return@LaunchedEffect
seen = tick
onUndo()
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)
}
}
/**
* 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,6 +32,7 @@ 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
@@ -40,12 +41,21 @@ 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. */
@@ -74,6 +84,10 @@ 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,
)
/**
@@ -92,8 +106,13 @@ 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
@@ -115,6 +134,19 @@ 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
@@ -152,36 +184,32 @@ class RescheduleViewModel @Inject constructor(
private val _inFlight = MutableStateFlow(false)
/**
* True from the moment a drop is accepted until its write settles — the
* window the dropped block holds its landing position for.
*/
/** True while any drop or undo runs or waits — what the confirmation chip holds for. */
val inFlight: StateFlow<Boolean> = _inFlight.asStateFlow()
private val _abandoned = MutableStateFlow(0)
private val _drops = MutableStateFlow(DropStates())
/**
* 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).
* 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).
*/
val abandoned: StateFlow<Int> = _abandoned.asStateFlow()
val drops: StateFlow<DropStates> = _drops.asStateFlow()
private val _undoStarted = MutableStateFlow(0)
private val _undoStarted = MutableStateFlow<UndoStart?>(null)
/**
* Ticks the moment an undo write begins, before the provider has anything to
* An undo write the moment it 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.
* let it reappear on the old day. Its fate then reads off [drops].
*/
val undoStarted: StateFlow<Int> = _undoStarted.asStateFlow()
val undoStarted: StateFlow<UndoStart?> = _undoStarted.asStateFlow()
/**
* 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.
* 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.
*/
private var busy = false
set(value) {
@@ -189,6 +217,22 @@ 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.
@@ -219,38 +263,50 @@ class RescheduleViewModel @Inject constructor(
}
/**
* 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.
* 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.
*
* The Boolean is diagnostic — the tests read it, [EventMoveScope] does not
* 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.
* hand it on. It answers before the write is resolved anyway.
*/
fun move(request: MoveRequest): Boolean {
if (busy || _scopePrompt.value != null) {
_abandoned.value += 1
if (queue.size >= MAX_QUEUED) {
_drops.update { it.ended(request.token, landed = false) }
report(MoveOutcome.QueueFull)
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) {
_abandoned.value += 1
busy = false
settle(request.token, landed = false)
return@launch
}
// A cancelled series only moves as a whole (#388).
val seriesOnly = !prepared.original.canSaveApartFromSeries()
if (!prepared.isRecurring || (seriesOnly && prepared.canRealign)) {
write(prepared, RecurringWriteScope.AllEvents)
busy = false
settle(request.token, write(prepared, RecurringWriteScope.AllEvents))
} else if (seriesOnly) {
_outcome.value = MoveOutcome.BlockedCancelledSeries
_abandoned.value += 1
busy = false
report(MoveOutcome.BlockedCancelledSeries)
settle(request.token, landed = false)
} else {
// Still busy: the scope dialog is now the thing in flight.
// 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()
pending = prepared
_scopePrompt.value = MoveScopePrompt(
occurrenceOnly = !prepared.canRealign,
@@ -258,7 +314,6 @@ class RescheduleViewModel @Inject constructor(
)
}
}
return true
}
/** Answer the scope dialog. */
@@ -269,35 +324,92 @@ class RescheduleViewModel @Inject constructor(
pending = null
_scopePrompt.value = null
viewModelScope.launch {
write(prepared, scope)
busy = false
settle(prepared.request.token, write(prepared, scope))
}
}
/** Dismiss the scope dialog without writing. */
fun cancelScope() {
val prepared = pending ?: return
pending = null
busy = false
_scopePrompt.value = null
_abandoned.value += 1
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
}
/**
* 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.
*
* [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.
* 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.
*/
fun undo(undo: MoveUndo): Boolean {
if (busy) return false
val token = nextMoveToken()
_drops.update { it.started(token) }
busy = true
_undoStarted.value += 1
_undoStarted.value = UndoStart(token, undo.dropToken)
markWritten(undo.eventId, null)
viewModelScope.launch {
_outcome.value = try {
repository.updateEvent(undo.eventId, undo.moved, undo.restored)
@@ -311,33 +423,40 @@ class RescheduleViewModel @Inject constructor(
} catch (e: Exception) {
MoveOutcome.Failed
}
// 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
settle(token, landed = _outcome.value is MoveOutcome.Undone)
}
return true
}
/** Clear the outcome once the screen has shown it. */
/** Clear the outcome once the screen has shown it, bringing on the next one waiting. */
fun consumeOutcome() {
_outcome.value = null
_outcome.value = notices.removeFirstOrNull()
}
private suspend fun prepare(request: MoveRequest): PreparedMove? {
private suspend fun prepare(dropped: MoveRequest): PreparedMove? {
val zone = zoneSource.current()
val detail = try {
repository.eventDetail(request.eventId)
repository.eventDetail(dropped.eventId)
} catch (e: CancellationException) {
throw e
} catch (e: NoSuchEventException) {
_outcome.value = MoveOutcome.Gone
report(MoveOutcome.Gone)
return null
} catch (e: Exception) {
_outcome.value = MoveOutcome.Failed
report(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)
@@ -350,7 +469,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()) {
_outcome.value = MoveOutcome.Failed
report(MoveOutcome.Failed)
return null
}
@@ -395,14 +514,14 @@ class RescheduleViewModel @Inject constructor(
)
}
private suspend fun write(prepared: PreparedMove, scope: RecurringWriteScope) {
/** Write [prepared] in [scope]; true when it landed. */
private suspend fun write(prepared: PreparedMove, scope: RecurringWriteScope): Boolean {
val request = prepared.request
if (endsBeforeItStarts(prepared, scope)) {
_outcome.value = MoveOutcome.BlockedSeriesEnd
_abandoned.value += 1
return
report(MoveOutcome.BlockedSeriesEnd)
return false
}
_outcome.value = try {
val outcome = try {
when {
!prepared.isRecurring || scope == RecurringWriteScope.AllEvents ->
repository.updateEvent(request.eventId, prepared.original, prepared.updated)
@@ -437,9 +556,37 @@ class RescheduleViewModel @Inject constructor(
} catch (e: Exception) {
MoveOutcome.Failed
}
// 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
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,
)
}
/**
@@ -506,6 +653,7 @@ class RescheduleViewModel @Inject constructor(
eventId = prepared.request.eventId,
moved = prepared.updated,
restored = prepared.original,
dropToken = prepared.request.token,
)
} else {
null
@@ -20,6 +20,7 @@ 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
@@ -60,7 +61,6 @@ 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,9 +139,17 @@ data class StripDrag(val event: EventInstance, val dayShift: Int = 0, val overGr
sealed interface StripDrop {
val event: EventInstance
data class Days(override val event: EventInstance, val days: Int) : StripDrop
/** The drop's identity, which its held copy waits on. */
val token: Long
data class Timed(override val event: EventInstance, val start: Instant, val end: Instant) : 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
}
/** The event a long press left selected (#141). */
@@ -297,7 +305,9 @@ 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 = 0,
val clipOffsetMin: Int,
/** The drop's identity, which its held copy waits on. */
val token: Long,
)
/** The id a drafted block's stand-in event carries, which no provider row has (#153). */
@@ -340,8 +350,37 @@ 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)
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,
)
/**
* [event] resized by its [edge] to the span [startMin]..+[spanMin] from [date]'s
@@ -352,17 +391,16 @@ data class TimelineResizeDrop(val event: EventInstance, val start: Instant, val
* 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 resizedDrop(
internal fun resizedSpan(
event: EventInstance,
edge: ResizeEdge?,
date: LocalDate,
startMin: Int,
spanMin: Int,
zone: TimeZone,
): TimelineResizeDrop {
): ResizedSpan {
val drawn = wallClockSpan(event, zone)
return TimelineResizeDrop(
event = event,
return ResizedSpan(
start = if (edge == ResizeEdge.Top) {
event.start.toFormTime(zone).repicked(
wallClockTime(date, startMin),
@@ -513,6 +551,14 @@ 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
@@ -611,14 +657,8 @@ class TimelineDragController {
var settlingSelected: Boolean = false
private set
/**
* 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
/** The token of the drop [settling] waits on. */
var settledToken: Long = 0L
private set
private var source: TimedBlock? = null
@@ -661,8 +701,12 @@ class TimelineDragController {
var stripSeated: Boolean by mutableStateOf(false)
private set
/** What [abandonedTicks] stood at when [stripSettling] was let go. */
var stripAbandonedMark: Int = 0
/** 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
private set
/** The shown day the finger picked the strip bar up on. */
@@ -687,7 +731,7 @@ class TimelineDragController {
dragDuration = (block.event.end - block.event.start).coerceAtLeast(Duration.ZERO)
dragKept = zone.repeatedHourOffsetAt(block.event.start)
this.titleLines = titleLines
settling = null
parkSettling()
isDragging = true
liftedInstanceId = block.event.instanceId
grab = pointerInRoot - blockInRoot
@@ -726,7 +770,7 @@ class TimelineDragController {
dragDuration = draftMinutes.minutes
dragKept = null
titleLines = 1
settling = null
parkSettling()
isDragging = true
liftedInstanceId = event.instanceId
grab = Offset(0f, geometry.hourPx * STRIP_GRAB_MINUTES / 60f)
@@ -749,16 +793,18 @@ class TimelineDragController {
val zone = dragZone
cancel()
if (held.overGrid && landed != null && zone != null && !landed.edgeOnly) {
settle(landed)
val start = TimelineDrop(landed.event, landed.date, landed.startMin).startInstant(zone)
return StripDrop.Timed(held.event, start, start + draftMinutes.minutes)
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)
}
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
stripAbandonedMark = abandonedTicks?.value ?: 0
stripToken = nextMoveToken()
drops++
return StripDrop.Days(held.event, held.dayShift)
return StripDrop.Days(held.event, held.dayShift, stripToken)
}
/** Let the held strip drop go, once the strip shows the event at its new days or the move failed. */
@@ -772,6 +818,21 @@ 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
@@ -806,12 +867,36 @@ 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
@@ -819,16 +904,23 @@ 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
val end = settledEnd
settledOnGrid = blocks.any { block ->
.firstOrNull() ?: return false
return 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) &&
@@ -855,12 +947,15 @@ 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
settle(landed)
return TimelineDrop(landed.event, landed.date, landed.startMin, clipOffset)
val token = settle(landed)
return TimelineDrop(landed.event, landed.date, landed.startMin, clipOffset, token)
}
/** Keep [landed]'s copy on the target as [settling] until [release]; [end] is a resize's. */
private fun settle(landed: TimelineDrag, end: Instant? = null) {
/**
* 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 {
// 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 &&
@@ -873,9 +968,10 @@ 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
settledAbandonedMark = abandonedTicks?.value ?: 0
settledToken = nextMoveToken()
settledEnd = end
drops++
return settledToken
}
/**
@@ -895,7 +991,7 @@ class TimelineDragController {
resize = session
clipOffsetMin = 0
this.titleLines = titleLines
settling = null
parkSettling()
isDragging = true
pointerMoved = true
liftedInstanceId = block.event.instanceId
@@ -926,10 +1022,10 @@ class TimelineDragController {
val event = source?.event ?: return null
val edge = resize?.edge
if (edge != null) {
val drop = resizedDrop(event, edge, date, startMin, spanMin, zone)
val drop = resizedSpan(event, edge, date, startMin, spanMin, zone)
return event.copy(start = drop.start, end = drop.end)
}
val start = TimelineDrop(event, date, startMin, clipOffsetMin).startInstant(zone)
val start = dropStartInstant(event, date, startMin, clipOffsetMin, zone)
return event.copy(start = start, end = start + dragDuration)
}
@@ -943,9 +1039,8 @@ class TimelineDragController {
val edge = resize?.edge
cancel()
if (landed == null || landed.slot == origin) return null
val drop = resizedDrop(landed.event, edge, landed.date, landed.eventStartMin, landed.eventSpanMin, zone)
settle(landed, end = drop.end)
return drop
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))
}
/**
@@ -1199,7 +1294,6 @@ 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
}
@@ -1229,7 +1323,16 @@ fun TimedBlock.continuesBefore(day: LocalDate, zone: TimeZone): Boolean =
* visibly settles there.
*/
fun TimelineDrop.startInstant(zone: TimeZone): Instant =
wallClockInstant(date, startMin - clipOffsetMin, zone, kept = zone.repeatedHourOffsetAt(event.start))
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))
/**
* The wall-clock minutes an event lasting [duration] covers when it starts
@@ -1301,6 +1404,7 @@ 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.
@@ -1308,12 +1412,10 @@ 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 moveInFlight = moveInFlight()
val drops = dropStates()
val moveInFlight = controller.settledToken in drops.pending
val stripInFlight = controller.stripToken in drops.pending
// 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.
@@ -1327,12 +1429,11 @@ 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 = abandonedMoves()
val abandoned = controller.settledToken in drops.abandoned
LaunchedEffect(controller.settling, moveInFlight, controller.settledOnGrid, abandoned) {
if (controller.settling == null || moveInFlight) return@LaunchedEffect
// 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) {
// An abandoned drop: nothing will land and there is nothing to wait for.
if (!abandoned) {
delay(if (controller.settledOnGrid) SETTLE_GRACE_MILLIS else SETTLE_TIMEOUT_MILLIS)
}
handingOver = true
@@ -1342,9 +1443,10 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
}
// The same hold for a bar moved along the all-day strip.
LaunchedEffect(controller.stripSettling, moveInFlight, controller.stripSeated, abandoned) {
if (controller.stripSettling == null || moveInFlight) return@LaunchedEffect
if (abandoned == controller.stripAbandonedMark) {
val stripAbandoned = controller.stripToken in drops.abandoned
LaunchedEffect(controller.stripSettling, stripInFlight, controller.stripSeated, stripAbandoned) {
if (controller.stripSettling == null || stripInFlight) return@LaunchedEffect
if (!stripAbandoned) {
delay(if (controller.stripSeated) SETTLE_GRACE_MILLIS else SETTLE_TIMEOUT_MILLIS)
}
controller.releaseStrip()
@@ -1358,6 +1460,12 @@ 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
@@ -1371,6 +1479,58 @@ 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
@@ -1386,7 +1546,7 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
// 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 = controller.geometry.viewport?.takeIf { it.isAttached }?.boundsInRoot()
val port = 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
@@ -1438,7 +1598,6 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
)
}
}
}
}
/** One piece of a dragged block, floating over the calendar. */
@@ -14,8 +14,7 @@ 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.LocalEventMove
import kotlinx.coroutines.flow.StateFlow
import de.jeanlucmakiola.calendula.ui.common.nextMoveToken
import kotlinx.datetime.LocalDate
import kotlin.math.abs
@@ -107,11 +106,22 @@ 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,
)
/**
@@ -167,19 +177,22 @@ class MonthDragController {
private set
/**
* The last drop this controller made, kept past [release] for [beginUndo] —
* the confirmation chip carries Undo long after the copy is gone.
* 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.
*/
private var undoable: MonthChipDrag? = null
private val undoable = LinkedHashMap<Long, MonthChipDrag>()
/**
* The abandoned-move counter, read at the drop so the mark can't be a
* composition behind the tick it is compared against.
* 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).
*/
var abandonedTicks: StateFlow<Int>? = null
var held: List<HeldChip> by mutableStateOf(emptyList())
private set
/** What [abandonedTicks] stood at when the settling chip was let go. */
var settledAbandonedMark: Int = 0
/** The token of the drop (or undo) [settling] is waiting on. */
var settledToken: Long = 0L
private set
private var event: EventInstance? = null
@@ -208,7 +221,7 @@ class MonthDragController {
this.event = event
this.grabDate = grabDate
this.time = time
settling = null
parkSettling()
isDragging = true
liftedInstanceId = event.instanceId
grab = pointerInRoot - chipInRoot
@@ -234,6 +247,39 @@ 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.
@@ -250,6 +296,13 @@ 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
@@ -265,16 +318,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(): Boolean {
val last = undoable ?: return false
undoable = null
fun beginUndo(token: Long, dropToken: Long): Boolean {
val last = undoable.remove(dropToken) ?: return false
parkSettling()
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
settledAbandonedMark = abandonedTicks?.value ?: 0
settledToken = token
return true
}
@@ -294,10 +347,11 @@ class MonthDragController {
)
liftedInstanceId = landed.event.instanceId
settledGhost = true
undoable = settling
settledAbandonedMark = abandonedTicks?.value ?: 0
settledToken = nextMoveToken()
undoable[settledToken] = requireNotNull(settling)
if (undoable.size > UNDOABLE_KEPT) undoable.remove(undoable.keys.first())
drops++
return MonthChipDrop(landed.event, landed.grabDate, target)
return MonthChipDrop(landed.event, landed.grabDate, target, settledToken)
}
/**
@@ -355,9 +409,10 @@ class MonthDragController {
@Composable
fun rememberMonthDragController(): MonthDragController {
val controller = remember { MonthDragController() }
controller.abandonedTicks = LocalEventMove.current?.abandoned
return controller
return remember { MonthDragController() }
}
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.moveInFlight
import de.jeanlucmakiola.calendula.ui.common.abandonedMoves
import de.jeanlucmakiola.calendula.ui.common.dropStates
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 { chipDrag.beginUndo() }
OnUndoStarted { start -> chipDrag.beginUndo(start.token, start.dropToken) }
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 moveInFlight = moveInFlight()
val drops = dropStates()
val moveInFlight = controller.settledToken in drops.pending
val abandoned = controller.settledToken in drops.abandoned
// 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,14 +608,12 @@ 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) {
// 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)
// An abandoned drop has no seat coming.
if (!abandoned) delay(MONTH_SETTLE_TIMEOUT_MILLIS)
} else {
if (!gliding) glide.snapTo(landed.topLeftInRoot)
gliding = true
@@ -635,6 +633,9 @@ 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(
@@ -646,42 +647,93 @@ private fun MonthDragOverlay(controller: MonthDragController, showStartTimes: Bo
animationSpec = tween(SETTLE_FADE_MILLIS),
label = "chip-handover",
)
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,
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,
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
@@ -2258,7 +2310,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) {
LaunchedEffect(week, dragController?.settling, dragController?.held) {
dragController?.noteSettled(rowToken)
}
@@ -2691,6 +2743,8 @@ private fun monthChipDragModifier(
beginMillis = drop.event.start.toEpochMilliseconds(),
endMillis = drop.event.end.toEpochMilliseconds(),
target = MoveTarget.ByDays(delta),
isAllDay = drop.event.isAllDay,
token = drop.token,
),
)
}
@@ -2836,8 +2890,17 @@ private fun MonthBar(
val moveAction = eventMoveAction(event)
// The source stays put as a ghost while its floating copy travels.
val monthDrag = LocalMonthDrag.current
val lifted = monthDrag?.liftedInstanceId == event.instanceId ||
monthDrag?.isSettledChip(event, days) == true
// 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 ghost = ghostAlpha(lifted)
val shape = monthBarShape(continuesLeft, continuesRight)
Box(
@@ -157,6 +157,7 @@ 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
@@ -310,14 +311,6 @@ 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
@@ -492,9 +485,10 @@ private fun PagedTimelineScreen(
val jumpToToday = { jumpToDate(today) }
val locale = currentLocale()
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)
// 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)
}
PublishChrome(
ScreenChrome(
@@ -591,7 +585,9 @@ 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,
),
)
}
@@ -610,7 +606,9 @@ 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,
),
)
}
@@ -622,10 +620,12 @@ 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,8 +767,7 @@ 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.stripDrag ?: dragController.stripSettling)
?.takeIf { page == pagerState.currentPage }
val held = dragController.stripPreviews().takeIf { page == pagerState.currentPage }.orEmpty()
val zone = LocalTimelineZone.current
val shown = remember(loaded, held, zone) { loaded?.withStripPreview(held, zone) }
val stripHeight = shown?.allDayStripHeight()
@@ -793,17 +792,24 @@ private fun WeekPager(
}
/**
* [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.
* [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.
*/
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))
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) })
}
private fun EventInstance.shiftedDays(days: Int, zone: TimeZone): EventInstance {
@@ -811,6 +817,17 @@ 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) }
@@ -1193,17 +1210,11 @@ 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, 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()
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) }
}
Row(
@@ -1588,7 +1599,7 @@ private fun DayColumnCard(
widthFraction = 1f / block.laneCount,
y = top,
height = height,
landing = dragController.ghosts(block),
landing = dragController.ghostsDerived(block),
)
val topInset = hourCellBlockInset(showHourGrid, cuts.top)
val bottomInset = hourCellBlockInset(showHourGrid, cuts.bottom)
@@ -1756,7 +1767,7 @@ private fun EventBlock(
onCancel = dragController::cancel,
onRelease = { dragController.select(block.event, fromLift = draggable) },
)
val lifted = draggable && dragController.ghosts(block)
val lifted = draggable && dragController.ghostsDerived(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)
@@ -2016,7 +2027,7 @@ private fun minToHm(min: Int, is24Hour: Boolean, locale: java.util.Locale): Stri
formatMinuteOfDay(min, is24Hour, locale)
/**
* The week's title: just the month [weekStart] falls in.
* Every timeline's title, the day view's included: 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
@@ -2031,7 +2042,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 formatWeekTitle(
private fun formatTimelineTitle(
weekStart: LocalDate,
weekEnd: LocalDate,
locale: Locale,
@@ -2046,17 +2057,3 @@ private fun formatWeekTitle(
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",
)
+1 -1
View File
@@ -292,7 +292,7 @@
<string name="settings_dim_completed_summary">تعتيم الأحداث التي انتهت بالفعل في عرض الشهر والأسبوع</string>
<string name="settings_today_toolbar">زر اليوم في شريط الأدوات</string>
<string name="settings_today_toolbar_summary">إظهار زر الانتقال إلى اليوم في شريط الأدوات بدلاً من زر عائم</string>
<string name="settings_app_name_summary">اعرض Calendula كـ ”Calendar“ في مشغّل التطبيقات الخاص بك. فقط الاسم في المشغّل يتغير؛ وقد ينتقل الرمز إلى مكان جديد بعد التبديل.</string>
<string name="settings_app_name_summary">اعرض Calendula كـ ”التقويم“ في مشغّل التطبيقات الخاص بك. فقط الاسم في المشغّل يتغير؛ وقد ينتقل الرمز إلى مكان جديد بعد التبديل.</string>
<string name="settings_past_events_dim">تعتيم</string>
<string name="settings_past_events">الأحداث السابقة</string>
<string name="settings_agenda_range">مدى الجدول</string>
+1 -1
View File
@@ -697,7 +697,7 @@
<string name="onboarding_calendars_benefit_sync_title">Або наладзьце сынхранізацыю з уліковым запісам</string>
<string name="onboarding_calendars_created_more_title">Прастора для іншых</string>
<string name="onboarding_calendars_created_title">«%1$s» гатовы</string>
<string name="onboarding_calendars_benefit_sync_body">Calendula паказвае ўліковыя запісы, якія ўжо сынхранізуюцца. Для выкананьня сынхранізацыі сэрвэры CalDAV патрабуець DAVx\\u2075.</string>
<string name="onboarding_calendars_benefit_sync_body">Calendula паказвае ўліковыя запісы, якія ўжо сынхранізуюцца. Для выкананьня сынхранізацыі сэрвэры CalDAV патрабуець DAVx.</string>
<string name="onboarding_calendars_created_body">Ён застаецца на гэтай прыладзе. У ім будуць запісвацца новыя падзеі, пакуль ня будзе выбраны іншы каляндар.</string>
<string name="onboarding_calendars_created_more_body">Праз мэню Налады → Календары можна задаць колькасьць, назвы і колеры наяўных календароў.</string>
<string name="onboarding_calendars_created_continue">Далей</string>
+18 -18
View File
@@ -374,7 +374,7 @@
<string name="settings_special_dates_type_anniversary">Anniversaires</string>
<string name="settings_special_dates_type_custom">Autres dates</string>
<string name="settings_special_dates_template">Format du titre</string>
<string name="settings_special_dates_template_hint">Utilisez {name} pour le nom et {year} pour l’année (celle de naissance ou celle du début d’un anniversaire ; masqué si inconnu).</string>
<string name="settings_special_dates_template_hint">Utilisez {name} pour le nom et {year} pour l’année (celle de naissance ou celle du début d’un anniversaire ; masqué si inconnu).</string>
<string name="settings_special_dates_show_year">Montrer l\'année</string>
<string name="settings_special_dates_show_year_hint">Inclure {year} dans les titres lorsque l\'année est connue</string>
<string name="settings_special_dates_sync_now">Synchroniser maintenant</string>
@@ -412,7 +412,7 @@
<string name="calendars_edit_title">Editer un agenda</string>
<string name="calendars_name_label">Nom</string>
<string name="calendars_description_hint">Ajouter une description</string>
<string name="calendars_delete_confirm_title">Supprimer ce calendrier ?</string>
<string name="calendars_delete_confirm_title">Supprimer ce calendrier ?</string>
<string name="calendars_delete_confirm_message">\"%1$s\" et tous ses événements seront définitivement retirés de cet appareil.</string>
<string name="calendars_write_error">Impossible de sauvegarder les changements.</string>
<string name="calendars_backup_hint">Les calendriers locaux ne sont synchronisés nulle part, alors exportez-les vers un fichier .ics pour en garder une copie.</string>
@@ -515,16 +515,16 @@
<string name="event_edit_timezone_all">Toutes les zones horaire</string>
<string name="event_edit_timezone_none">Aucune zone horaire ne correspond à “%1$s”</string>
<string name="event_edit_recurrence_incomplete">Entrez un nombre entre 1 et 999</string>
<string name="event_edit_recurrence_next">Prochain : %1$s</string>
<string name="event_edit_recurrence_next">Prochain : %1$s</string>
<string name="event_detail_title">Titre</string>
<string name="event_access_private_summary">Les autres ne voient seulement que vous êtes occupé</string>
<string name="event_access_confidential_summary">Marqué confidentiel ; ce que cela veut dire est au bon vouloir de votre compte de calendrier</string>
<string name="event_access_confidential_summary">Marqué confidentiel ; ce que cela veut dire est au bon vouloir de votre compte de calendrier</string>
<string name="reminder_after">%1$s après</string>
<string name="duration_custom_max">Au plus %1$s</string>
<string name="onboarding_step_counter">Étape %1$d sur %2$d</string>
<string name="settings_today_toolbar_summary">Afficher un bouton pour revenir au jour présent dans la barre d\'outils au lieu d\'un bouton flottant</string>
<string name="settings_app_name">Nom de l\'application</string>
<string name="settings_app_name_summary">Afficher Calendula comme \"Calendrier\" dans votre lanceur d\'applications. Le nom change seulement dans le lanceur ; l\'icône pourrait changer d\'emplacement après le renommage.</string>
<string name="settings_app_name_summary">Afficher Calendula comme \"Calendrier\" dans votre lanceur d\'applications. Le nom change seulement dans le lanceur ; l\'icône pourrait changer d\'emplacement après le renommage.</string>
<string name="settings_timeline_scale">Hauteur d\'une heure</string>
<string name="timeline_scale_fit_day">Remplir complètement</string>
<string name="timeline_scale_fit_day_summary">Affiche les 24 heures à l\'écran, pas de défilement</string>
@@ -551,15 +551,15 @@
<string name="event_access_public_summary">Toute personne ayant accès voit tous les détails</string>
<string name="duration_hours_minutes">%1$s:%2$s</string>
<string name="onboarding_backup_title">Vos événements ne sont accessibles qu\'ici</string>
<string name="onboarding_backup_body">Aucune synchronisation n\'existe sur ce téléphone : le perdre signifierait perdre votre calendrier. Calendula peut faire des sauvegardes pour vous.</string>
<string name="onboarding_backup_body">Aucune synchronisation n\'existe sur ce téléphone : le perdre signifierait perdre votre calendrier. Calendula peut faire des sauvegardes pour vous.</string>
<string name="onboarding_backup_benefit_folder_title">Un dossier que vous choisissez</string>
<string name="onboarding_backup_benefit_folder_body">Les sauvegardes sont juste des fichiers .ics — enregistrez-les dans un dossier synchronisé, ou bien sur une carte SD.</string>
<string name="onboarding_backup_benefit_daily_title">Une fois par jour, automatiquement</string>
<string name="onboarding_backup_benefit_daily_body">Calendula exporte vos calendriers locaux en arrière-plan. Changez la fréquence dans les réglages.</string>
<string name="onboarding_backup_enable_button">Choisissez un dossier et sauvegarder</string>
<string name="onboarding_backup_skip_button">Pas pour l\'instant</string>
<string name="onboarding_view_title">Que doit s\'afficher en premier ?</string>
<string name="onboarding_month_style_title">À quoi doivent ressembler les mois ?</string>
<string name="onboarding_view_title">Que doit s\'afficher en premier ?</string>
<string name="onboarding_month_style_title">À quoi doivent ressembler les mois ?</string>
<string name="onboarding_view_continue_button">Continuer</string>
<string name="onboarding_back">Retour</string>
<string name="onboarding_visibility_button">Compris</string>
@@ -579,9 +579,9 @@
<string name="search_select_all">Tout sélectionner</string>
<string name="search_delete_selected">Supprimer la sélection</string>
<plurals name="search_delete_title">
<item quantity="one">Supprimer %d événement ?</item>
<item quantity="many">Supprimer %d événements ?</item>
<item quantity="other">Supprimer %d événements ?</item>
<item quantity="one">Supprimer %d événement ?</item>
<item quantity="many">Supprimer %d événements ?</item>
<item quantity="other">Supprimer %d événements ?</item>
</plurals>
<plurals name="search_delete_done">
<item quantity="one">%d événement supprimé</item>
@@ -595,9 +595,9 @@
<string name="settings_font_specimen_heading">Jeudi 14 mai</string>
<string name="settings_font_specimen_body">Revue d\'équipe à 10h00, puis déjeuner avec Robin au café de la Place du Marché.</string>
<string name="settings_week_start_auto_summary">Actuellement %1$s</string>
<string name="settings_today_toolbar">Bouton « aujourd\'hui » dans la barre d\'outils</string>
<string name="settings_time_format_auto_summary">Suivant le système : %1$s</string>
<string name="settings_timeline_scale_hint">Espace vertical occupé par une heure dans les vues « Semaine » et « Jour ». Ce paramètre s\'applique aux deux vues. Vous pouvez également étirer l\'affichage avec deux doigts pour utiliser n\'importe quelle hauteur.</string>
<string name="settings_today_toolbar">Bouton « aujourd\'hui » dans la barre d\'outils</string>
<string name="settings_time_format_auto_summary">Suivant le système : %1$s</string>
<string name="settings_timeline_scale_hint">Espace vertical occupé par une heure dans les vues « Semaine » et « Jour ». Ce paramètre s\'applique aux deux vues. Vous pouvez également étirer l\'affichage avec deux doigts pour utiliser n\'importe quelle hauteur.</string>
<string name="settings_widget_size_hint">Taille du texte affiché par le widget d\'agenda sur l\'écran d\'accueil. Le widget mensuel n\'a pas ce réglage — sa grille s\'adapte toujours à l\'espace que vous lui allouez.</string>
<string name="settings_widget_size_small">Petit</string>
<string name="settings_widget_size_medium">Moyen</string>
@@ -637,7 +637,7 @@
<string name="settings_week_day_header">Semaine &amp; jour</string>
<string name="settings_default_view_hint">La vue ouverte par Calendula au démarrage.</string>
<string name="settings_week_start_hint">Le jour par lequel chaque semaine commence, dans toutes les vues et widgets.</string>
<string name="settings_time_format_hint">Format d\'affichage de l\'heure dans l\'application. Le format « Automatique » suit les paramètres de votre système.</string>
<string name="settings_time_format_hint">Format d\'affichage de l\'heure dans l\'application. Le format « Automatique » suit les paramètres de votre système.</string>
<string name="settings_past_events_hint">Comment l\'agenda gère les événements déjà terminés.</string>
<string name="settings_dynamic_color_summary">Les couleurs de l\'application proviennent de votre fond d\'écran.</string>
<string name="settings_licences">Licences open source</string>
@@ -646,16 +646,16 @@
<string name="licences_title">Licences open source</string>
<string name="licences_intro">Calendula comprend les projets ci-dessous. Appuyez sur n\'importe quelle entrée pour afficher son code source.</string>
<string name="licences_footer">Chaque projet présenté ici est inclus tel quel, sans modifications, sous la licence indiquée.</string>
<string name="calendars_visibility_hint">Désactivez un calendrier pour le masquer sur cet appareil : ses événements disparaîtront de l\'application et vous ne recevrez plus de rappels. Il s\'agit du même bouton que celui utilisé par vos autres applications de calendrier, ce qui signifie qu\'il sera aussi masqué sur ces dernières. Rien n\'est supprimé, aucun autre appareil n\'est affecté et vous pouvez le réactiver ici à tout moment.</string>
<string name="calendars_visibility_hint">Désactivez un calendrier pour le masquer sur cet appareil : ses événements disparaîtront de l\'application et vous ne recevrez plus de rappels. Il s\'agit du même bouton que celui utilisé par vos autres applications de calendrier, ce qui signifie qu\'il sera aussi masqué sur ces dernières. Rien n\'est supprimé, aucun autre appareil n\'est affecté et vous pouvez le réactiver ici à tout moment.</string>
<string name="calendars_visibility_a11y">Montrer \"%1$s\"</string>
<string name="calendars_visibility_notice_title">Certains calendriers sont masqués</string>
<string name="calendars_visibility_notice_message">Calendula affiche les calendriers activés pour cet appareil, et certains des vôtres sont désactivés. Pour voir leurs événements, activez-les dans Réglages → Calendriers.</string>
<string name="calendar_picker_missing_title">Il vous manque un calendrier ?</string>
<string name="calendar_picker_missing_title">Il vous manque un calendrier ?</string>
<string name="calendar_picker_missing_summary">Il est peut-être désactivé, en lecture seule ou rempli à partir de vos contacts — gérez vos calendriers ici.</string>
<string name="calendars_state_read_only">Lecture seule</string>
<string name="calendars_state_not_synced">Non synchronisé avec cet appareil</string>
<string name="calendars_state_managed">Rempli à partir de vos contacts</string>
<string name="calendars_managed_delete_locked">Ce calendrier est rempli à partir de vos contacts ; Calendula le recréera donc lors de la prochaine synchronisation. Pour le supprimer, désactivez les dates spéciales dans Paramètres → Dates spéciales.</string>
<string name="calendars_managed_delete_locked">Ce calendrier est rempli à partir de vos contacts ; Calendula le recréera donc lors de la prochaine synchronisation. Pour le supprimer, désactivez les dates spéciales dans Paramètres → Dates spéciales.</string>
<string name="calendars_account_from_source">%1$s (%2$s)</string>
<string name="import_done_failed_label">Non ajouté</string>
<string name="import_done_failed_note">Certains événements n\'ont pas pu être ajoutés et ont donc été omis.</string>
+38
View File
@@ -110,6 +110,9 @@
<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>
@@ -907,6 +910,41 @@
<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>
@@ -0,0 +1,45 @@
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()
}
}
@@ -0,0 +1,408 @@
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)
}
}
@@ -13,10 +13,12 @@ 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
@@ -79,15 +81,43 @@ class RescheduleViewModelTest {
status = status,
)
private fun viewModel(tempDir: Path, fake: FakeCalendarDataSource): RescheduleViewModel {
/**
* [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)
val repo = testRepository(
fake, tempDir, dispatcher,
fake, tempDir, io,
prefs = testPrefs(tempDir, CoroutineScope(dispatcher)),
settings = testSettings(tempDir, CoroutineScope(dispatcher)),
)
return RescheduleViewModel(repo, dispatcher, FakeZoneSource())
return RescheduleViewModel(repo, io, 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 {} }
@@ -97,6 +127,8 @@ 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. */
@@ -105,6 +137,8 @@ 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. */
@@ -116,6 +150,8 @@ class RescheduleViewModelTest {
Instant.fromEpochMilliseconds(beginMillis),
Instant.fromEpochMilliseconds(endMillis + 3_600_000L),
),
isAllDay = false,
token = nextMoveToken(),
)
@Test
@@ -259,21 +295,228 @@ class RescheduleViewModelTest {
}
@Test
fun `a second drop is refused while the first is still in flight`(
fun `a drop of another event waits for the one in flight, then lands`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO") }
eventDetailResult = { detail() }
}
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()
assertThat(vm.move(toWednesday())).isTrue()
val again = toWednesday()
vm.move(again)
advanceUntilIdle()
// Parked on the scope dialog, so the first drop still owns the pipeline.
assertThat(vm.move(oneHourLater())).isFalse()
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())
advanceUntilIdle()
assertThat(fake.updatedEvents).isEmpty()
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()
}
@Test
@@ -284,19 +527,19 @@ class RescheduleViewModelTest {
advanceUntilIdle()
val undo = (vm.outcome.value as MoveOutcome.Moved).undo!!
// A second drop parks on its scope dialog, which holds the pipeline.
// A second drop, of another event, parks on its scope dialog, which holds the pipeline.
fake.eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO") }
vm.move(toWednesday())
vm.move(toWednesday().copy(eventId = 7L, token = nextMoveToken()))
advanceUntilIdle()
val abandoned = vm.abandoned.value
val abandoned = vm.abandonedCount
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.abandoned.value).isEqualTo(abandoned)
assertThat(vm.abandonedCount).isEqualTo(abandoned)
}
@Test
@@ -311,13 +554,13 @@ class RescheduleViewModelTest {
advanceUntilIdle()
val undo = (vm.outcome.value as MoveOutcome.Moved).undo!!
val abandoned = vm.abandoned.value
val abandoned = vm.abandonedCount
fake.writeError = SecurityException("permission revoked")
assertThat(vm.undo(undo)).isTrue()
advanceUntilIdle()
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.WriteDenied)
assertThat(vm.abandoned.value).isEqualTo(abandoned + 1)
assertThat(vm.abandonedCount).isEqualTo(abandoned + 1)
}
@Test
@@ -330,12 +573,12 @@ class RescheduleViewModelTest {
advanceUntilIdle()
val undo = (vm.outcome.value as MoveOutcome.Moved).undo!!
val abandoned = vm.abandoned.value
val abandoned = vm.abandonedCount
vm.undo(undo)
advanceUntilIdle()
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.Undone)
assertThat(vm.abandoned.value).isEqualTo(abandoned)
assertThat(vm.abandonedCount).isEqualTo(abandoned)
}
@Test
@@ -415,6 +658,8 @@ class RescheduleViewModelTest {
target = MoveTarget.Start(
Instant.fromEpochMilliseconds(beginMillis + 26 * 3_600_000L),
),
isAllDay = false,
token = nextMoveToken(),
),
)
advanceUntilIdle()
@@ -435,6 +680,8 @@ class RescheduleViewModelTest {
beginMillis = beginMillis,
endMillis = endMillis,
target = MoveTarget.Start(Instant.fromEpochMilliseconds(beginMillis)),
isAllDay = false,
token = nextMoveToken(),
),
)
advanceUntilIdle()
@@ -556,17 +803,19 @@ class RescheduleViewModelTest {
beginMillis = beginMillis,
endMillis = endMillis,
target = MoveTarget.Start(Instant.fromEpochMilliseconds(beginMillis)),
isAllDay = false,
token = nextMoveToken(),
),
)).isTrue()
advanceUntilIdle()
assertThat(vm.abandoned.value).isEqualTo(1)
assertThat(vm.abandonedCount).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.abandoned.value).isEqualTo(2)
assertThat(vm.abandonedCount).isEqualTo(2)
}
@Test
@@ -580,11 +829,11 @@ class RescheduleViewModelTest {
vm.move(oneHourLater())
advanceUntilIdle()
assertThat(vm.abandoned.value).isEqualTo(0)
assertThat(vm.abandonedCount).isEqualTo(0)
vm.cancelScope()
advanceUntilIdle()
assertThat(vm.abandoned.value).isEqualTo(1)
assertThat(vm.abandonedCount).isEqualTo(1)
}
@Test
@@ -596,7 +845,7 @@ class RescheduleViewModelTest {
advanceUntilIdle()
assertThat(vm.outcome.value).isInstanceOf(MoveOutcome.Moved::class.java)
assertThat(vm.abandoned.value).isEqualTo(0)
assertThat(vm.abandonedCount).isEqualTo(0)
}
@Test
@@ -668,6 +917,8 @@ class RescheduleViewModelTest {
beginMillis = beginMillis,
endMillis = endMillis,
target = MoveTarget.Span(sundayEvening, Instant.fromEpochMilliseconds(endMillis)),
isAllDay = false,
token = nextMoveToken(),
),
)
advanceUntilIdle()
@@ -709,7 +960,7 @@ class RescheduleViewModelTest {
assertThat(vm.scopePrompt.value).isNull()
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.BlockedCancelledSeries)
assertThat(vm.abandoned.value).isEqualTo(1)
assertThat(vm.abandonedCount).isEqualTo(1)
assertThat(fake.updatedEvents).isEmpty()
assertThat(fake.updatedOccurrences).isEmpty()
}
@@ -739,6 +990,8 @@ class RescheduleViewModelTest {
beginMillis = dayStart,
endMillis = dayStart + 86_400_000L,
target = MoveTarget.Span(start, Instant.fromEpochMilliseconds(endMillis)),
isAllDay = true,
token = nextMoveToken(),
),
)
advanceUntilIdle()
@@ -775,6 +1028,8 @@ 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)
val drop = TimelineDrop(overnight, target, 20 * 60, clipOffsetMin = 0, token = 0L)
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)
val drop = TimelineDrop(overnight, LocalDate(2026, 9, 3), 0, offset, token = 0L)
// 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)
val drop = TimelineDrop(overnight, sun, 3 * 60, offset, token = 0L)
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)
val drop = TimelineDrop(overnight, sun, -60, offset, token = 0L)
// 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)
val drop = TimelineDrop(overnight, sat, 25 * 60, clipOffsetMin = 0, token = 0L)
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)
val drop = TimelineDrop(overnightIntoDst, LocalDate(2026, 3, 22), 0, offset, token = 0L)
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)
val drop = TimelineDrop(event, fallBack, block.startMin, clipOffsetMin = 0, token = 0L)
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)
val drop = TimelineDrop(event, fallBack, tail.startMin, clipOffsetMin = offset, token = 0L)
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)
val drop = TimelineDrop(event, fallBack, block.startMin + 15, clipOffsetMin = 0, token = 0L)
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 = resizedDrop(event, ResizeEdge.Bottom, fallBack, 2 * 60 + 30, 15, brussels)
val drop = resizedSpan(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 = resizedDrop(event, ResizeEdge.Top, fallBack, 2 * 60, 60, brussels)
val drop = resizedSpan(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 = resizedDrop(event, ResizeEdge.Bottom, fallBack, 2 * 60 + 20, 30, brussels)
val drop = resizedSpan(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 = resizedDrop(event, ResizeEdge.Bottom, fallBack, 60, 75, brussels)
val drop = resizedSpan(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(StripDrag(held, dayShift = 2), zone).allDaySpans
val preview = state.withStripPreview(listOf(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(StripDrag(held, dayShift = 2, overGrid = true), zone)).isSameInstanceAs(state)
assertThat(state.withStripPreview(listOf(StripDrag(held, dayShift = 2, overGrid = true)), zone)).isSameInstanceAs(state)
}
}
+11
View File
@@ -492,6 +492,17 @@ one the calendar accepts. The single-event review form deliberately keeps no col
first question is which calendar to use, and answering it clears the colour
anyway.
## Settings backup
Backup & restore also exports the app's settings as versioned JSON (#481),
with the envelope and the generic preference section from floret-kit's
`core-backup`. Every stored key is either carried or deliberately left out
(`SettingsBackupSchema`, enforced by a test); per-calendar settings travel
under the calendar's account and name, since provider ids are per device. An
import replaces rather than merges, in one DataStore edit, then re-runs what
reads settings outside DataStore. Format and per-key decisions:
docs/SETTINGS_BACKUP.md.
## Testing
JUnit 5 + Truth + Turbine on the JVM. The seams that make it work:
+135
View File
@@ -0,0 +1,135 @@
# 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.
@@ -0,0 +1,12 @@
الجديد
• نمط كلاسيكي لأداة جدول الأعمال.
• شريط تمرير لشفافية خلفية الأدوات.
الإصلاحات
• أرقام الأسابيع كانت أقل بواحد عندما يبدأ الأسبوع يوم الأحد.
• أداة الشهر تعرض الأحداث بأي ارتفاع.
• الأحداث الباهتة في الأدوات الداكنة مقروءة من جديد.
• الأدوات المضبوطة على فاتح أو داكن تطابق ألوان النظام.
التغييرات
• أذونات أقل.
@@ -0,0 +1,6 @@
الإصلاحات
• لم تعد الأحداث المنقولة بسرعة متتالية تضيع: تُحفظ عمليات النقل بالترتيب.
• تظهر رسالة عند تعذّر حفظ عملية نقل.
التغييرات
• يعرض عنوان عرض اليوم الشهر.
@@ -0,0 +1,12 @@
Новае
• Класічны стыль для віджэта спісу падзей.
• Паўзунок непразрыстасці для фону віджэтаў.
Выпраўлена
• Нумары тыдняў былі на адзін меншыя, калі тыдзень пачынаецца ў нядзелю.
• Віджэт месяца паказвае падзеі пры любой вышыні.
• Прыглушаныя падзеі ў цёмных віджэтах зноў чытэльныя.
• Віджэты са светлай ці цёмнай тэмай адпавядаюць колерам сістэмы.
Зменена
• Менш дазволаў.
@@ -0,0 +1,6 @@
Выпраўлена
• Падзеі, перанесеныя хутка адна за адной, больш не губляюцца: пераносы захоўваюцца па чарзе.
• Паведамленне паказвае, калі перанос не ўдалося захаваць.
Зменена
• Загаловак дзённага прагляду паказвае месяц.
@@ -0,0 +1,12 @@
Novinky
• Klasický styl widgetu agendy.
• Posuvník průhlednosti pozadí widgetů.
Opraveno
• Čísla týdnů byla o jedno nižší, když týden začíná nedělí.
• Widget měsíce zobrazí události v jakékoli výšce.
• Ztlumené události v tmavých widgetech jsou opět čitelné.
• Widgety se světlým nebo tmavým motivem odpovídají barvám systému.
Změněno
• Méně oprávnění.
@@ -0,0 +1,6 @@
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.
@@ -0,0 +1,12 @@
Neu
• Klassischer Stil für das Terminliste-Widget.
• Deckkraft-Regler für Widget-Hintergründe.
Behoben
• Kalenderwochen waren bei Wochenstart Sonntag eine zu niedrig.
• Monats-Widget zeigt Termine in jeder Höhe.
• Abgedunkelte Termine in dunklen Widgets wieder lesbar.
• Widgets mit Hell oder Dunkel passen zu den Systemfarben.
Geändert
• Weniger Berechtigungen.
@@ -0,0 +1,6 @@
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.
@@ -0,0 +1,12 @@
New
• Classic style for the agenda widget.
• Opacity slider for widget backgrounds.
Fixed
• Week numbers were one too low when weeks start on Sunday.
• The month widget shows events at every height.
• Dimmed events in dark widgets are readable again.
• Widgets set to Light or Dark match the system colours.
Changed
• Fewer permissions.
@@ -0,0 +1,6 @@
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.
@@ -0,0 +1,12 @@
Novedades
• Estilo clásico para el widget de agenda.
• Control de opacidad para el fondo de los widgets.
Correcciones
• Los números de semana salían uno menos con semanas que empiezan en domingo.
• El widget de mes muestra eventos con cualquier altura.
• Los eventos atenuados en widgets oscuros vuelven a leerse bien.
• Los widgets en claro u oscuro coinciden con los colores del sistema.
Cambios
• Menos permisos.
@@ -0,0 +1,6 @@
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.
@@ -0,0 +1,12 @@
Nouveautés
• Style classique pour le widget agenda.
• Curseur d’opacité pour le fond des widgets.
Corrections
• Les numéros de semaine avaient un de moins avec des semaines commençant le dimanche.
• Le widget mois affiche les événements à toutes les hauteurs.
• Les événements estompés sont de nouveau lisibles dans les widgets sombres.
• Les widgets en clair ou sombre suivent les couleurs du système.
Modifications
• Moins d’autorisations.
@@ -0,0 +1,6 @@
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.
@@ -0,0 +1,12 @@
Újdonságok
• Klasszikus stílus a napirend widgethez.
• Átlátszóság-csúszka a widgetek hátteréhez.
Javítva
• A hetek száma eggyel kevesebb volt, ha a hét vasárnap kezdődik.
• A havi widget bármilyen magasságban mutatja az eseményeket.
• A halványított események ismét olvashatók a sötét widgeteken.
• A világos vagy sötét widgetek a rendszer színeit követik.
Változások
• Kevesebb engedély.
@@ -0,0 +1,6 @@
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.
@@ -0,0 +1,12 @@
Novità
• Stile classico per il widget agenda.
• Cursore di opacità per lo sfondo dei widget.
Correzioni
• I numeri delle settimane erano uno in meno con settimane che iniziano di domenica.
• Il widget mese mostra gli eventi a ogni altezza.
• Gli eventi attenuati nei widget scuri sono di nuovo leggibili.
• I widget in chiaro o scuro seguono i colori di sistema.
Modifiche
• Meno autorizzazioni.
@@ -0,0 +1,6 @@
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.
@@ -0,0 +1,12 @@
新機能
• 予定リストウィジェットにクラシックスタイルを追加。
• ウィジェット背景の不透明度スライダーを追加。
修正
• 週の始まりが日曜日のとき、週番号が1つ少なく表示されていた問題。
• 月のウィジェットがどの高さでも予定を表示するように。
• 暗いウィジェットで薄く表示された予定が再び読みやすく。
• ライト/ダーク固定のウィジェットがシステムの色に合うように。
変更
• 必要な権限を削減。
@@ -0,0 +1,6 @@
修正
• 続けてすばやく移動した予定が失われなくなりました。移動は順番に保存されます。
• 移動を保存できなかったときはメッセージが表示されます。
変更
• 日表示のタイトルに月が表示されるようになりました。
@@ -0,0 +1,12 @@
Nieuw
• Klassieke stijl voor de agendawidget.
• Schuifregelaar voor de dekking van widgetachtergronden.
Opgelost
• Weeknummers waren één te laag als de week op zondag begint.
• De maandwidget toont afspraken op elke hoogte.
• Gedimde afspraken in donkere widgets zijn weer leesbaar.
• Widgets op Licht of Donker passen bij de systeemkleuren.
Gewijzigd
• Minder machtigingen.
@@ -0,0 +1,6 @@
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.
@@ -0,0 +1,12 @@
Nowości
• Klasyczny styl widżetu agendy.
• Suwak przezroczystości tła widżetów.
Poprawki
• Numery tygodni były o jeden za niskie, gdy tydzień zaczyna się w niedzielę.
• Widżet miesiąca pokazuje wydarzenia przy każdej wysokości.
• Przyciemnione wydarzenia w ciemnych widżetach znów są czytelne.
• Widżety z jasnym lub ciemnym motywem pasują do kolorów systemu.
Zmiany
• Mniej uprawnień.
@@ -0,0 +1,6 @@
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.
@@ -0,0 +1,12 @@
Novidades
• Estilo clássico para o widget de agenda.
• Controle de opacidade para o fundo dos widgets.
Correções
• Os números das semanas ficavam um a menos com semanas começando no domingo.
• O widget de mês mostra eventos em qualquer altura.
• Eventos esmaecidos em widgets escuros voltam a ser legíveis.
• Widgets em claro ou escuro seguem as cores do sistema.
Alterações
• Menos permissões.
@@ -0,0 +1,6 @@
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.
@@ -0,0 +1,12 @@
Novidades
• Estilo clássico para o widget de agenda.
• Controlo de opacidade para o fundo dos widgets.
Correções
• Os números das semanas ficavam um abaixo com semanas a começar ao domingo.
• O widget de mês mostra eventos em qualquer altura.
• Eventos esbatidos em widgets escuros voltam a ser legíveis.
• Widgets em claro ou escuro seguem as cores do sistema.
Alterações
• Menos permissões.
@@ -0,0 +1,6 @@
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.
@@ -0,0 +1,12 @@
Новое
• Классический стиль для виджета повестки.
• Ползунок непрозрачности для фона виджетов.
Исправлено
• Номера недель были на единицу меньше, если неделя начинается с воскресенья.
• Виджет месяца показывает события при любой высоте.
• Приглушённые события в тёмных виджетах снова читаемы.
• Виджеты со светлой или тёмной темой соответствуют цветам системы.
Изменено
• Меньше разрешений.
@@ -0,0 +1,6 @@
Исправлено
• События, быстро перенесённые одно за другим, больше не теряются: переносы сохраняются по очереди.
• Сообщение показывает, если перенос не удалось сохранить.
Изменено
• Заголовок дневного вида показывает месяц.
@@ -0,0 +1,12 @@
Novinky
• Klasický štýl widgetu agendy.
• Posuvník priehľadnosti pozadia widgetov.
Opravené
• Čísla týždňov boli o jedno nižšie, keď týždeň začína nedeľou.
• Widget mesiaca zobrazí udalosti v akejkoľvek výške.
• Stlmené udalosti v tmavých widgetoch sú opäť čitateľné.
• Widgety so svetlým alebo tmavým motívom zodpovedajú farbám systému.
Zmenené
• Menej povolení.
@@ -0,0 +1,6 @@
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.
@@ -0,0 +1,12 @@
新功能
• 日程列表小部件新增经典样式。
• 小部件背景新增不透明度滑块。
修复
• 每周从星期日开始时,周数少了一。
• 月份小部件在任何高度都会显示日程。
• 深色小部件中变暗的日程重新清晰可读。
• 设为浅色或深色的小部件与系统颜色一致。
变更
• 减少了所需权限。
@@ -0,0 +1,6 @@
修复
• 快速连续移动的日程不再丢失:移动会按顺序保存。
• 移动无法保存时会显示提示。
变更
• 日视图标题显示月份。
@@ -0,0 +1,12 @@
新功能
• 行程列表小工具新增經典樣式。
• 小工具背景新增不透明度滑桿。
修正
• 每週從星期日開始時,週數少了一。
• 月份小工具在任何高度都會顯示行程。
• 深色小工具中變暗的行程重新清晰可讀。
• 設為淺色或深色的小工具與系統顏色一致。
變更
• 減少了所需權限。
@@ -0,0 +1,6 @@
修正
• 快速連續移動的活動不再遺失:移動會依序儲存。
• 移動無法儲存時會顯示訊息。
變更
• 日檢視標題會顯示月份。
+2 -2
View File
@@ -4,7 +4,7 @@ kotlin = "2.4.20"
ksp = "2.3.11"
hilt = "2.60.1"
coreKtx = "1.19.0"
appcompat = "1.7.1"
appcompat = "1.8.0"
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.7.0"
kotlinxDatetime = "0.8.0-0.6.x-compat"
kotlinxCoroutines = "1.11.0"
turbine = "1.2.1"
hiltNavigationCompose = "1.4.0"