Compare commits
90
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00a8040efe | ||
|
|
07a2bb632f | ||
|
|
281d511402 | ||
|
|
ad5f0f4662 | ||
|
|
a902f5f862 | ||
|
|
783916e3a7 | ||
|
|
09c20ee90f | ||
|
|
fb178a779e | ||
|
|
34cd75e9a3 | ||
|
|
6758b7fada | ||
|
|
70f72d03db | ||
|
|
dca98bbb2d | ||
|
|
93b795ed72 | ||
|
|
f18ae694a7 | ||
|
|
b2498d362b | ||
|
|
8e99c46f3e | ||
|
|
1e83000696 | ||
|
|
05887d2ed0 | ||
|
|
028d8d0e1e | ||
|
|
30af150197 | ||
|
|
041f8ec2f7 | ||
|
|
10208a332e | ||
|
|
0a36a6d287 | ||
|
|
d5079f0618 | ||
|
|
3ed56b8596 | ||
|
|
3f4bf65b1a | ||
|
|
026970501e | ||
|
|
46882d154e | ||
|
|
e5d9b4965b | ||
|
|
c6901c9c63 | ||
|
|
8754f046f2 | ||
|
|
bb075d052e | ||
|
|
5c05563785 | ||
|
|
88c64d5786 | ||
|
|
d86a09b744 | ||
|
|
f314761e45 | ||
|
|
46818cd0ae | ||
|
|
240ed5237a | ||
|
|
deb2fd4482 | ||
|
|
e9cf7d66ba | ||
|
|
e01d6efb84 | ||
|
|
b7c801c8db | ||
|
|
b15e90cc64 | ||
|
|
8d3be6aba3 | ||
|
|
464ae8952e | ||
|
|
2007541adf | ||
|
|
d6167617a9 | ||
|
|
38d7c1494d | ||
|
|
c0c0d56e86 | ||
|
|
745301d14b | ||
|
|
8d640b595e | ||
|
|
1925e1c2f4 | ||
|
|
0595124ecd | ||
|
|
8e3202d1ad | ||
|
|
5f8eb39206 | ||
|
|
e7bf700f36 | ||
|
|
c10342818a | ||
|
|
70a7bf1033 | ||
|
|
a92dd013bc | ||
|
|
6d6baf3f35 | ||
|
|
72f2b33754 | ||
|
|
89a456f88c | ||
|
|
d98d995326 | ||
|
|
20a76f1921 | ||
|
|
0c4a7992a7 | ||
|
|
5e6b88fa31 | ||
|
|
d8735cc738 | ||
|
|
185671adb5 | ||
|
|
ee268444b7 | ||
|
|
9387bdc1ee | ||
|
|
7cfdb43f37 | ||
|
|
06f658917f | ||
|
|
88d5614158 | ||
|
|
f896153fb7 | ||
|
|
1004356e75 | ||
|
|
77b0c6db09 | ||
|
|
dec2d1cb05 | ||
|
|
76314aebbd | ||
|
|
7d149d2ab1 | ||
|
|
ff4082d5df | ||
|
|
51f58e76b2 | ||
|
|
347ce74808 | ||
|
|
726dbbbaf3 | ||
|
|
fb434f3f15 | ||
|
|
aacd86cebe | ||
|
|
bd3b4605cb | ||
|
|
f267744c1c | ||
|
|
fffb033e39 | ||
|
|
f574f039f5 | ||
|
|
506ebdb092 |
No files matched your search
@@ -33,7 +33,7 @@ jobs:
|
||||
# directly drops the docker-in-docker requirement entirely.
|
||||
# Full tag pinned; Renovate's github-actions manager keeps it bumped.
|
||||
container:
|
||||
image: ghcr.io/renovatebot/renovate:43.232.0
|
||||
image: ghcr.io/renovatebot/renovate:44.237.3
|
||||
steps:
|
||||
- name: Run Renovate
|
||||
run: renovate
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -41,6 +41,11 @@
|
||||
-->
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
<!-- WorkManager declares these for network constraints and foreground workers, neither of which the app uses (#448). -->
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" tools:node="remove" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" tools:node="remove" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" tools:node="remove" />
|
||||
|
||||
<!-- Package visibility (Android 11+): without this, getLaunchIntentForPackage
|
||||
returns null and the calendar manager's per-account "manage" button can't
|
||||
open the source sync app (DAVx5, ICSx5, Google Calendar, …). The LAUNCHER
|
||||
@@ -291,6 +296,12 @@
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!-- The change watch behind "Also back up after changes" (#516). -->
|
||||
<service
|
||||
android:name=".data.backup.BackupChangeJob"
|
||||
android:exported="false"
|
||||
android:permission="android.permission.BIND_JOB_SERVICE" />
|
||||
|
||||
<!-- Reminder delivery is the app's own (#75): it plans the alarms from
|
||||
Instances + Reminders instead of waiting for the provider's
|
||||
EVENT_REMINDER broadcast, which OEM-modified providers demonstrably
|
||||
@@ -475,6 +486,18 @@
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- WorkManager initialises on demand from CalendulaApp's configuration. -->
|
||||
<provider
|
||||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
android:exported="false"
|
||||
tools:node="merge">
|
||||
<meta-data
|
||||
android:name="androidx.work.WorkManagerInitializer"
|
||||
android:value="androidx.startup"
|
||||
tools:node="remove" />
|
||||
</provider>
|
||||
|
||||
<!-- Hands .ics files we stage in the cache to other apps via a content
|
||||
Uri (single-event share). Authority tracks applicationId so the
|
||||
debug suffix doesn't break getUriForFile. -->
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package de.jeanlucmakiola.calendula
|
||||
|
||||
import android.app.Application
|
||||
import androidx.work.Configuration
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import de.jeanlucmakiola.calendula.data.backup.BackupChangeJob
|
||||
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarVisibilityReconciler
|
||||
import de.jeanlucmakiola.calendula.data.backup.BackupWorker
|
||||
@@ -27,7 +29,11 @@ import kotlinx.coroutines.launch
|
||||
* in AndroidManifest.xml. Hilt initializes its component graph here.
|
||||
*/
|
||||
@HiltAndroidApp
|
||||
class CalendulaApp : Application() {
|
||||
class CalendulaApp : Application(), Configuration.Provider {
|
||||
|
||||
/** Keeps WorkManager's JobScheduler ids clear of [BackupChangeJob]'s. */
|
||||
override val workManagerConfiguration: Configuration =
|
||||
Configuration.Builder().setJobSchedulerJobIdRange(0, BackupChangeJob.JOB_ID - 1).build()
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
@@ -110,12 +116,7 @@ class CalendulaApp : Application() {
|
||||
val deps = EntryPointAccessors.fromApplication(this, BackupWorker.Deps::class.java)
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
|
||||
val prefs = deps.settingsPrefs()
|
||||
BackupScheduler.apply(
|
||||
context = this@CalendulaApp,
|
||||
enabled = prefs.autoBackupEnabled.first(),
|
||||
intervalMinutes = prefs.autoBackupIntervalMinutes.first(),
|
||||
hasFolder = prefs.autoBackupFolderUri.first() != null,
|
||||
)
|
||||
BackupScheduler.reconcile(this@CalendulaApp, prefs)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,18 +5,23 @@ import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.await
|
||||
import androidx.work.workDataOf
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
@@ -25,42 +30,87 @@ import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.backup.BackupFilePlan
|
||||
import de.jeanlucmakiola.calendula.domain.backup.BackupTarget
|
||||
import de.jeanlucmakiola.calendula.domain.backup.backupFingerprint
|
||||
import de.jeanlucmakiola.calendula.domain.backup.backupTargets
|
||||
import de.jeanlucmakiola.calendula.domain.backup.inBackupOrder
|
||||
import de.jeanlucmakiola.calendula.domain.backup.latestHeldElsewhere
|
||||
import de.jeanlucmakiola.calendula.domain.backup.latestFileName
|
||||
import de.jeanlucmakiola.calendula.domain.backup.latestLedgerKey
|
||||
import de.jeanlucmakiola.calendula.domain.backup.needsRefresh
|
||||
import de.jeanlucmakiola.calendula.domain.backup.planBackupFiles
|
||||
import de.jeanlucmakiola.calendula.domain.backup.replacedLatestCopies
|
||||
import de.jeanlucmakiola.calendula.domain.backup.withFingerprint
|
||||
import de.jeanlucmakiola.calendula.domain.backup.withLedgerEntry
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||
import de.jeanlucmakiola.floret.android.hasPermission
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.time.LocalDateTime
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.time.Clock
|
||||
|
||||
/**
|
||||
* Schedules and runs the automatic periodic `.ics` backup of local calendars
|
||||
* (issue #8). One-way export only — it mirrors the manual "Back up" into a
|
||||
* user-chosen folder on an interval; it is not a sync. Stays INTERNET-free:
|
||||
* everything is local provider reads + a local file write.
|
||||
* Schedules and runs the automatic periodic `.ics` backup (issue #8) of the
|
||||
* calendars the user picked (#147). One-way export only — it mirrors the manual
|
||||
* "Back up" into a user-chosen folder on an interval; it is not a sync. Stays
|
||||
* INTERNET-free: everything is local provider reads + local file writes.
|
||||
*/
|
||||
object BackupScheduler {
|
||||
|
||||
private const val WORK_NAME = "auto-backup"
|
||||
private const val WORK_NAME_NOW = "auto-backup-now"
|
||||
private const val WORK_NAME_CHANGE = "auto-backup-change"
|
||||
internal const val KEY_CHANGE_RUN = "change_run"
|
||||
|
||||
private val reconcileLock = Mutex()
|
||||
|
||||
/** A watch that fired while the option was off: still pending until it finishes, but not re-armed. */
|
||||
private var watchDropped = false
|
||||
|
||||
/**
|
||||
* Reconcile the scheduled work with the current settings: enqueue a unique
|
||||
* Reconcile the scheduled work with the saved settings: enqueue a unique
|
||||
* periodic request when backup is on and a folder is set, otherwise cancel
|
||||
* it. WorkManager persists the request across reboots on its own.
|
||||
*
|
||||
* The first run is delayed by one interval so it never overlaps an immediate
|
||||
* [runNow] (two simultaneous writes would race and leave a "(1)" duplicate).
|
||||
* The first run is delayed by one interval so it doesn't follow an immediate
|
||||
* [runNow] with a near-identical copy. With backing up after changes on
|
||||
* (#516), the change watch ([BackupChangeJob]) is armed unless it already
|
||||
* waits; it doesn't survive a reboot, and this runs on every process start.
|
||||
* Serialised and not cancellable, so quick toggles settle on the last one.
|
||||
*/
|
||||
fun apply(context: Context, enabled: Boolean, intervalMinutes: Long, hasFolder: Boolean) {
|
||||
suspend fun reconcile(context: Context, prefs: SettingsPrefs) = withContext(NonCancellable + Dispatchers.IO) {
|
||||
reconcileLock.withLock {
|
||||
try {
|
||||
apply(context, prefs)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Scheduling the automatic backup failed", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun apply(context: Context, prefs: SettingsPrefs) {
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
if (!enabled || !hasFolder) {
|
||||
fun stopWatching() {
|
||||
BackupChangeJob.cancel(context)
|
||||
workManager.cancelUniqueWork(WORK_NAME_CHANGE)
|
||||
}
|
||||
if (!prefs.autoBackupEnabled.first() || prefs.autoBackupFolderUri.first() == null) {
|
||||
workManager.cancelUniqueWork(WORK_NAME)
|
||||
// Also cancel any pending immediate run — otherwise a failing run-now
|
||||
// keeps retrying after the user has turned backup off.
|
||||
workManager.cancelUniqueWork(WORK_NAME_NOW)
|
||||
stopWatching()
|
||||
return
|
||||
}
|
||||
// WorkManager's own floor is 15 min; the UI floors the user choice at 30.
|
||||
val interval = intervalMinutes.coerceAtLeast(15L)
|
||||
val interval = prefs.autoBackupIntervalMinutes.first().coerceAtLeast(15L)
|
||||
val request = PeriodicWorkRequestBuilder<BackupWorker>(interval, TimeUnit.MINUTES)
|
||||
.setInitialDelay(interval, TimeUnit.MINUTES)
|
||||
.build()
|
||||
@@ -69,6 +119,44 @@ object BackupScheduler {
|
||||
ExistingPeriodicWorkPolicy.UPDATE,
|
||||
request,
|
||||
)
|
||||
if (!prefs.autoBackupOnChange.first()) {
|
||||
stopWatching()
|
||||
} else if (watchDropped || !BackupChangeJob.isPending(context)) {
|
||||
// Replacing a waiting watch would drop the changes it has seen.
|
||||
BackupChangeJob.schedule(context)
|
||||
watchDropped = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The watch fired: queue a change run and arm the next watch, unless the
|
||||
* option went off meanwhile. Under [reconcileLock], so it can't re-arm
|
||||
* after a switch-off.
|
||||
*/
|
||||
internal suspend fun changed(context: Context, prefs: SettingsPrefs) = withContext(NonCancellable) {
|
||||
reconcileLock.withLock {
|
||||
// Re-armed even when queueing the run fails: a trigger fires only once.
|
||||
var wanted = true
|
||||
try {
|
||||
wanted = prefs.autoBackupEnabled.first() && prefs.autoBackupFolderUri.first() != null &&
|
||||
prefs.autoBackupOnChange.first()
|
||||
if (wanted) queueChangeRun(context)
|
||||
} finally {
|
||||
if (wanted) BackupChangeJob.schedule(context)
|
||||
watchDropped = !wanted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun queueChangeRun(context: Context) {
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
val states = workManager.getWorkInfosForUniqueWorkFlow(WORK_NAME_CHANGE).first().map { it.state }
|
||||
val policy = changeRunPolicy(states) ?: return
|
||||
val run = OneTimeWorkRequestBuilder<BackupWorker>()
|
||||
.setConstraints(Constraints.Builder().setRequiresBatteryNotLow(true).build())
|
||||
.setInputData(workDataOf(KEY_CHANGE_RUN to true))
|
||||
.build()
|
||||
workManager.enqueueUniqueWork(WORK_NAME_CHANGE, policy, run).await()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,11 +171,25 @@ object BackupScheduler {
|
||||
OneTimeWorkRequestBuilder<BackupWorker>().build(),
|
||||
)
|
||||
}
|
||||
|
||||
private const val TAG = "BackupScheduler"
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports the local calendars to `calendula-backup.ics` in the configured folder,
|
||||
* overwriting the previous file. Pulls its collaborators through a Hilt
|
||||
* How a fired change watch queues its run (#516), from the change runs' work
|
||||
* [states]: a run that hasn't started yet will see the change anyway (null);
|
||||
* one already going may have read too early, so the new one queues behind it.
|
||||
*/
|
||||
internal fun changeRunPolicy(states: List<WorkInfo.State>): ExistingWorkPolicy? = when {
|
||||
states.any { it == WorkInfo.State.ENQUEUED || it == WorkInfo.State.BLOCKED } -> null
|
||||
WorkInfo.State.RUNNING in states -> ExistingWorkPolicy.APPEND_OR_REPLACE
|
||||
else -> ExistingWorkPolicy.REPLACE
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports the selected calendars into the configured folder — one dated file
|
||||
* per calendar, or one combined file (#411) — then prunes each series down to
|
||||
* the retention count. Pulls its collaborators through a Hilt
|
||||
* [EntryPoint] so it works under WorkManager's default (no-arg) worker factory —
|
||||
* no custom factory / Application wiring needed. Records the outcome for the
|
||||
* settings status line, and notifies after repeated failures.
|
||||
@@ -107,27 +209,230 @@ class BackupWorker(
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val deps = EntryPointAccessors.fromApplication(applicationContext, Deps::class.java)
|
||||
val prefs = deps.settingsPrefs()
|
||||
// Respect the toggle even for already-queued work: if backup was turned
|
||||
// off, no-op (and don't retry) so a lingering run can't revive itself.
|
||||
if (!prefs.autoBackupEnabled.first()) return Result.success()
|
||||
val folder = prefs.autoBackupFolderUri.first()
|
||||
?: return Result.failure() // nothing to write to — leave scheduling to settings
|
||||
val now = System.currentTimeMillis()
|
||||
return try {
|
||||
val events = deps.repository().exportEvents()
|
||||
val content = IcsWriter().writeCalendar(events, Clock.System.now())
|
||||
deps.exporter().writeToFolder(folder.toUri(), BACKUP_FILE_NAME, content)
|
||||
prefs.recordAutoBackupRun(success = true, atMillis = now)
|
||||
Result.success()
|
||||
val changeRun = inputData.getBoolean(BackupScheduler.KEY_CHANGE_RUN, false)
|
||||
if (!changeRun) return backUp(deps, deps.settingsPrefs(), changeRun = false)
|
||||
// A failed change run would fail the one queued behind it too.
|
||||
try {
|
||||
backUp(deps, deps.settingsPrefs(), changeRun = true)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
prefs.recordAutoBackupRun(success = false, atMillis = now)
|
||||
if (prefs.autoBackupStatus.first().consecutiveFailures >= FAILURE_NOTIFY_THRESHOLD) {
|
||||
notifyFailure(applicationContext)
|
||||
}
|
||||
Log.w(TAG, "Automatic backup failed", e)
|
||||
Result.retry()
|
||||
Log.w(TAG, "Backup after a change failed", e)
|
||||
}
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
/**
|
||||
* One run under the shared lock, recorded for the status line. A change
|
||||
* run (#516) refreshes only the stable copies and never retries. The
|
||||
* failure notification comes once per failure streak, when it reaches
|
||||
* [FAILURE_NOTIFY_THRESHOLD], and goes with the next success of any run.
|
||||
*/
|
||||
private suspend fun backUp(deps: Deps, prefs: SettingsPrefs, changeRun: Boolean): Result {
|
||||
// One run at a time, or each would overwrite the other's ledger.
|
||||
val outcome = runLock.withLock {
|
||||
val now = System.currentTimeMillis()
|
||||
// Respect the toggles even for already-queued work, so a lingering
|
||||
// run can't revive itself.
|
||||
if (!prefs.autoBackupEnabled.first()) return Result.success()
|
||||
if (changeRun && !prefs.autoBackupOnChange.first()) return Result.success()
|
||||
val folder = prefs.autoBackupFolderUri.first() ?: return Result.failure()
|
||||
val outcome = try {
|
||||
runBackup(deps, prefs, folder.toUri(), latestOnly = changeRun)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Automatic backup failed", e)
|
||||
RunOutcome.Failed
|
||||
}
|
||||
if (outcome != RunOutcome.NothingToDo) {
|
||||
withContext(NonCancellable) {
|
||||
prefs.recordAutoBackupRun(success = outcome == RunOutcome.Written, atMillis = now)
|
||||
when {
|
||||
outcome == RunOutcome.Written -> clearFailureNotification(applicationContext)
|
||||
prefs.autoBackupStatus.first().consecutiveFailures == FAILURE_NOTIFY_THRESHOLD ->
|
||||
notifyFailure(applicationContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
outcome
|
||||
}
|
||||
// Only a run that wrote nothing retries: a retry rewrites every series.
|
||||
return if (outcome == RunOutcome.Failed && !changeRun) Result.retry() else Result.success()
|
||||
}
|
||||
|
||||
private enum class RunOutcome { Written, PartlyFailed, Failed, NothingToDo }
|
||||
|
||||
/** How a stable copy's write went: under its own name, under one the provider picked, or not at all. */
|
||||
private enum class LatestWrite { Landed, Renamed, Failed }
|
||||
|
||||
/**
|
||||
* Writes each series and prunes it only once its new file has landed. A
|
||||
* [latestOnly] run (#516) leaves the dated files and their retention to the
|
||||
* schedule and rewrites only the stable copies whose calendars changed.
|
||||
*/
|
||||
private suspend fun runBackup(deps: Deps, prefs: SettingsPrefs, folder: Uri, latestOnly: Boolean): RunOutcome {
|
||||
val repository = deps.repository()
|
||||
val exporter = deps.exporter()
|
||||
if (!applicationContext.hasPermission(Manifest.permission.READ_CALENDAR)) return RunOutcome.Failed
|
||||
val all = repository.calendars().first()
|
||||
val selection = prefs.autoBackupSelection.first()
|
||||
val targets = backupTargets(all.filter(selection::selects), combined = prefs.autoBackupCombined.first())
|
||||
if (targets.isEmpty()) return RunOutcome.NothingToDo
|
||||
val stamp = Clock.System.now()
|
||||
val fingerprints = prefs.autoBackupFingerprints.first()
|
||||
suspend fun export(target: BackupTarget): String? = try {
|
||||
val events = repository.exportEvents(target.calendarIds).inBackupOrder()
|
||||
IcsWriter().writeCalendar(events, stamp, target.calendarName)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Reading one series failed", e)
|
||||
null
|
||||
}
|
||||
fun fingerprintOf(target: BackupTarget, text: String) =
|
||||
backupFingerprint(text, latestFileName(target.baseName))
|
||||
// Hashes only serve change runs; without them a stale one could pass later.
|
||||
val trackHashes = prefs.autoBackupOnChange.first()
|
||||
if (!trackHashes && fingerprints.isNotEmpty()) prefs.updateAutoBackupFingerprints(folder.toString()) { emptyMap() }
|
||||
// A change run hashes first (null: unreadable) and leaves the folder alone when nothing moved.
|
||||
val preHashes: Map<String, String?> = if (latestOnly) {
|
||||
targets.associate { target -> target.key to export(target)?.let { fingerprintOf(target, it) } }
|
||||
} else {
|
||||
emptyMap()
|
||||
}
|
||||
if (latestOnly && targets.all { preHashes[it.key].let { hash -> hash != null && hash == fingerprints[it.key] } }) {
|
||||
return RunOutcome.NothingToDo
|
||||
}
|
||||
prefs.adoptLegacyBackupFile()
|
||||
val listing = exporter.listFolder(folder)
|
||||
val ledger = prefs.autoBackupLedger.first()
|
||||
// A listing still loading can't tell a gone file from a late one.
|
||||
val existing = if (listing.complete) listing.files.keys else listing.files.keys + ledger.values.flatten()
|
||||
val plans = planBackupFiles(
|
||||
targets = targets,
|
||||
ledger = ledger,
|
||||
existing = existing,
|
||||
at = LocalDateTime.now(),
|
||||
keep = prefs.autoBackupKeep.first(),
|
||||
)
|
||||
val liveKeys = all.map { it.id.toString() }.toSet()
|
||||
suspend fun record(key: String, files: List<String>) =
|
||||
prefs.updateAutoBackupLedger(folder.toString()) { withLedgerEntry(it, key, files, liveKeys) }
|
||||
|
||||
suspend fun writeDated(plan: BackupFilePlan, bytes: ByteArray): Boolean = try {
|
||||
val name = exporter.writeToFolder(folder, listing, plan.fileName, bytes)
|
||||
record(plan.target.key, plan.prune + plan.keep + name)
|
||||
val undeleted = exporter.deleteFromFolder(listing, plan.prune)
|
||||
record(plan.target.key, plan.prune.filter { it in undeleted } + plan.keep + name)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Backup of one series failed", e)
|
||||
false
|
||||
}
|
||||
|
||||
// An old copy goes only once the new one has landed.
|
||||
suspend fun writeLatest(plan: BackupFilePlan, bytes: ByteArray, fingerprint: String): LatestWrite {
|
||||
val target = plan.target
|
||||
val latest = checkNotNull(plan.latestName)
|
||||
var landed: String? = null
|
||||
try {
|
||||
val latestKey = latestLedgerKey(target.key)
|
||||
if (trackHashes && target.key in fingerprints) {
|
||||
prefs.updateAutoBackupFingerprints(folder.toString()) { it - target.key }
|
||||
}
|
||||
landed = exporter.writeToFolder(folder, listing, latest, bytes)
|
||||
// A provider that renamed it leaves a copy to clean up next
|
||||
// run; the intended name stays ours only if it already was.
|
||||
val ownsLatest = landed == latest || latest in ledger[latestKey].orEmpty()
|
||||
val current = listOfNotNull(landed, latest.takeIf { ownsLatest })
|
||||
// A hand-off stays on record only until its new owner claimed it.
|
||||
suspend fun recordLatest(stale: List<String>, landedFingerprint: String? = null) =
|
||||
prefs.updateAutoBackupLedger(
|
||||
folder.toString(),
|
||||
fingerprints = landedFingerprint?.let { hash ->
|
||||
{ old -> withFingerprint(old, target.key, hash, liveKeys) }
|
||||
},
|
||||
) {
|
||||
val pending = plan.handOff - it.latestHeldElsewhere(target.key)
|
||||
withLedgerEntry(it, latestKey, stale + pending + current, liveKeys)
|
||||
}
|
||||
recordLatest(plan.staleLatest, fingerprint.takeIf { trackHashes })
|
||||
val staleLeft = exporter.deleteFromFolder(listing, plan.staleLatest)
|
||||
recordLatest(plan.staleLatest.filter { it in staleLeft })
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Stable copy of one series failed", e)
|
||||
}
|
||||
return when (landed) {
|
||||
null -> LatestWrite.Failed
|
||||
latest -> LatestWrite.Landed
|
||||
else -> LatestWrite.Renamed
|
||||
}
|
||||
}
|
||||
|
||||
// A copy blocked by a file of the user's keeps its hash, so a later change run can stop early.
|
||||
suspend fun rememberBlocked(plan: BackupFilePlan, hash: String) {
|
||||
val key = plan.target.key
|
||||
if (plan.latestName == null && trackHashes && fingerprints[key] != hash) {
|
||||
prefs.updateAutoBackupFingerprints(folder.toString()) { withFingerprint(it, key, hash, liveKeys) }
|
||||
}
|
||||
}
|
||||
|
||||
var written = 0
|
||||
var failed = 0
|
||||
val landedLatest = mutableSetOf<String>()
|
||||
for (plan in plans) {
|
||||
val target = plan.target
|
||||
if (latestOnly) {
|
||||
val hash = preHashes[target.key] ?: run {
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
rememberBlocked(plan, hash)
|
||||
if (!plan.needsRefresh(hash, fingerprints, listing.files.keys, listing.complete)) {
|
||||
if (plan.latestName in listing.files) landedLatest += target.key
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Read again rather than held since the pre-pass: one series' text in memory at a time.
|
||||
val text = export(target) ?: run {
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
val fingerprint = fingerprintOf(target, text)
|
||||
if (!latestOnly) rememberBlocked(plan, fingerprint)
|
||||
val bytes = text.toByteArray(Charsets.UTF_8)
|
||||
// Not cancellable once a file exists: it must reach the ledger.
|
||||
withContext(NonCancellable) {
|
||||
val dated = if (latestOnly) null else writeDated(plan, bytes)
|
||||
val latest = plan.latestName?.let { writeLatest(plan, bytes, fingerprint) }
|
||||
if (latest == LatestWrite.Landed) landedLatest += target.key
|
||||
// A full run's status follows its dated file; the stable copy is best-effort there.
|
||||
val ok = dated ?: (latest != LatestWrite.Failed)
|
||||
if (ok) written++ else failed++
|
||||
}
|
||||
}
|
||||
// A layout switch retires the other layout's copies once every new one is there.
|
||||
if (landedLatest.size == plans.size) {
|
||||
replacedLatestCopies(targets, prefs.autoBackupLedger.first(), existing).forEach { (key, names) ->
|
||||
withContext(NonCancellable) {
|
||||
val left = exporter.deleteFromFolder(listing, names)
|
||||
record(latestLedgerKey(key), names.filter { it in left })
|
||||
// Its copy is gone, so its hash mustn't let a change run stop early.
|
||||
prefs.updateAutoBackupFingerprints(folder.toString()) { it - key }
|
||||
}
|
||||
}
|
||||
}
|
||||
return when {
|
||||
failed == 0 && written == 0 -> RunOutcome.NothingToDo
|
||||
failed == 0 -> RunOutcome.Written
|
||||
written == 0 -> RunOutcome.Failed
|
||||
else -> RunOutcome.PartlyFailed
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearFailureNotification(context: Context) {
|
||||
NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID)
|
||||
}
|
||||
|
||||
private fun notifyFailure(context: Context) {
|
||||
@@ -165,10 +470,10 @@ class BackupWorker(
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val BACKUP_FILE_NAME = "calendula-backup.ics"
|
||||
private const val FAILURE_NOTIFY_THRESHOLD = 2
|
||||
private const val CHANNEL_ID = "backup"
|
||||
private const val NOTIFICATION_ID = 2
|
||||
private const val TAG = "BackupWorker"
|
||||
private val runLock = Mutex()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package de.jeanlucmakiola.calendula.data.backup
|
||||
|
||||
import android.app.job.JobInfo
|
||||
import android.app.job.JobParameters
|
||||
import android.app.job.JobScheduler
|
||||
import android.app.job.JobService
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.provider.CalendarContract
|
||||
import android.util.Log
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* The watch behind "Also back up after changes" (#516): a JobScheduler job
|
||||
* with a content trigger on the provider's root URI, where it reports every
|
||||
* change, whoever made it (this app, a sync adapter or another app). It waits
|
||||
* for [SETTLE_MINUTES] of quiet, at most [MAX_WAIT_MINUTES], so a burst makes
|
||||
* one run. A trigger fires once; each firing queues a change run and schedules
|
||||
* the next watch under the same id before finishing, which hands over the
|
||||
* changes seen meanwhile, so exactly one watch exists and none are lost.
|
||||
*/
|
||||
class BackupChangeJob : JobService() {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
override fun onStartJob(params: JobParameters): Boolean {
|
||||
scope.launch {
|
||||
// Retried when it failed early, so the watch isn't lost; once re-armed, the retry is moot.
|
||||
val failed = try {
|
||||
val deps = EntryPointAccessors.fromApplication(applicationContext, BackupWorker.Deps::class.java)
|
||||
BackupScheduler.changed(applicationContext, deps.settingsPrefs())
|
||||
false
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Queueing a backup after a change failed", e)
|
||||
true
|
||||
}
|
||||
jobFinished(params, failed)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Stopped before it could re-arm: run again rather than leave the provider unwatched. */
|
||||
override fun onStopJob(params: JobParameters): Boolean = true
|
||||
|
||||
override fun onDestroy() {
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Above the range WorkManager is given (see CalendulaApp). */
|
||||
const val JOB_ID = 0x0516_0001
|
||||
private const val SETTLE_MINUTES = 2L
|
||||
private const val MAX_WAIT_MINUTES = 15L
|
||||
private const val TAG = "BackupChangeJob"
|
||||
|
||||
fun schedule(context: Context) {
|
||||
val job = JobInfo.Builder(JOB_ID, ComponentName(context, BackupChangeJob::class.java))
|
||||
.addTriggerContentUri(
|
||||
JobInfo.TriggerContentUri(
|
||||
CalendarContract.CONTENT_URI,
|
||||
JobInfo.TriggerContentUri.FLAG_NOTIFY_FOR_DESCENDANTS,
|
||||
),
|
||||
)
|
||||
.setTriggerContentUpdateDelay(TimeUnit.MINUTES.toMillis(SETTLE_MINUTES))
|
||||
.setTriggerContentMaxDelay(TimeUnit.MINUTES.toMillis(MAX_WAIT_MINUTES))
|
||||
.build()
|
||||
if (context.getSystemService(JobScheduler::class.java).schedule(job) != JobScheduler.RESULT_SUCCESS) {
|
||||
Log.w(TAG, "The change watch could not be scheduled")
|
||||
}
|
||||
}
|
||||
|
||||
fun isPending(context: Context): Boolean =
|
||||
context.getSystemService(JobScheduler::class.java).getPendingJob(JOB_ID) != null
|
||||
|
||||
fun cancel(context: Context) = context.getSystemService(JobScheduler::class.java).cancel(JOB_ID)
|
||||
}
|
||||
}
|
||||
+110
-53
@@ -30,6 +30,9 @@ import de.jeanlucmakiola.calendula.domain.Reminder
|
||||
import de.jeanlucmakiola.calendula.domain.SearchCandidate
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.MANAGED_UID_PREFIX
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.domain.ics.CalendarRows
|
||||
import de.jeanlucmakiola.calendula.domain.ics.OverrideRow
|
||||
import de.jeanlucmakiola.calendula.domain.ics.MasterRow
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.semanticReminderMinutes
|
||||
@@ -90,15 +93,15 @@ interface CalendarDataSource {
|
||||
fun publishedEventColors(calendarId: Long): List<EventColorOption>
|
||||
|
||||
/**
|
||||
* Every master/one-off event of the writable local calendars, mapped for a
|
||||
* whole-calendar `.ics` backup. Modified-occurrence and cancelled-exception
|
||||
* rows are excluded (see [EventExportProjection]). When [calendarIds] is
|
||||
* given, only those calendars are exported (still intersected with the
|
||||
* eligible set); `null` exports every eligible calendar.
|
||||
* [allDayReminderTimeMinutes]: needed to write all-day reminders as whole-day
|
||||
* lead times rather than raw provider offsets (see [toIcsEvent]).
|
||||
* Every event row of [calendarIds], whatever kind of calendar they are
|
||||
* (local, synced, read-only): the masters/one-offs and the modified and
|
||||
* cancelled exception rows, each with the keys that link it to its series
|
||||
* and tagged with its calendar's name. A plain read: which calendars may be
|
||||
* read for what is the caller's call. [allDayReminderTimeMinutes]: needed to
|
||||
* write all-day reminders as whole-day lead times rather than raw provider
|
||||
* offsets (see [toIcsEvent]).
|
||||
*/
|
||||
fun exportableEvents(calendarIds: Set<Long>?, allDayReminderTimeMinutes: Int): List<IcsEvent>
|
||||
fun calendarRows(calendarIds: Set<Long>, allDayReminderTimeMinutes: Int): CalendarRows
|
||||
|
||||
/**
|
||||
* The non-empty `Events.UID_2445` values present in [calendarId] — used to
|
||||
@@ -776,47 +779,94 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
?: emptyList()
|
||||
}
|
||||
|
||||
override fun exportableEvents(
|
||||
calendarIds: Set<Long>?,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
): List<IcsEvent> {
|
||||
// Only the local calendars the app owns and can write — synced calendars
|
||||
// already have a backup (their server). Exclude the managed special-dates
|
||||
// mirror calendars: their events are derived from contacts, not authored
|
||||
// here, and re-materialise from the contact sync — backing them up would
|
||||
// just duplicate them on restore. A non-null [calendarIds] narrows the
|
||||
// export to the user's chosen subset. Map id → display name for the
|
||||
// X-CALENDULA-CALENDAR tag a restore uses to fan back out.
|
||||
val names = calendars()
|
||||
.filter {
|
||||
it.isLocal && it.canModifyContents && !it.isManaged &&
|
||||
(calendarIds == null || it.id in calendarIds)
|
||||
}
|
||||
.associate { it.id to it.displayName }
|
||||
if (names.isEmpty()) return emptyList()
|
||||
override fun calendarRows(calendarIds: Set<Long>, allDayReminderTimeMinutes: Int): CalendarRows {
|
||||
if (calendarIds.isEmpty()) return CalendarRows(emptyList(), emptyList())
|
||||
// The name tags each event for a combined backup's restore to fan back
|
||||
// out; an id that no longer exists drops out here.
|
||||
val calendars = calendars().filter { it.id in calendarIds }.associateBy { it.id }
|
||||
if (calendars.isEmpty()) return CalendarRows(emptyList(), emptyList())
|
||||
|
||||
val idList = names.keys.joinToString(",")
|
||||
return resolver.query(
|
||||
val selection = "${CalendarContract.Events.CALENDAR_ID} IN (${calendars.keys.joinToString(",")}) AND " +
|
||||
"${CalendarContract.Events.DELETED} = 0"
|
||||
val series = mutableListOf<MasterRow>()
|
||||
val overrides = mutableListOf<OverrideRow>()
|
||||
forEachExportRow(selection, allDayReminderTimeMinutes) { event ->
|
||||
val calendarId = getLong(EventExportProjection.IDX_CALENDAR_ID)
|
||||
val calendar = calendars[calendarId]
|
||||
// An adapter may stamp the calendar colour on every row; a palette key means it was chosen.
|
||||
val color = getIntOrNull(EventExportProjection.IDX_EVENT_COLOR)?.takeIf {
|
||||
it != calendar?.color || !getString(EventExportProjection.IDX_EVENT_COLOR_KEY).isNullOrEmpty()
|
||||
}
|
||||
val mapped = event(calendar?.displayName).copy(color = color)
|
||||
val selfStatus = getIntOrNull(EventExportProjection.IDX_SELF_ATTENDEE_STATUS)
|
||||
val declined = selfStatus == CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED
|
||||
val originalId = getLongOrNull(EventExportProjection.IDX_ORIGINAL_ID)
|
||||
val originalSyncId = getString(EventExportProjection.IDX_ORIGINAL_SYNC_ID)?.takeIf { it.isNotEmpty() }
|
||||
// Without a link the provider shows the row as an event of its own.
|
||||
if (originalId == null && originalSyncId == null) {
|
||||
series += MasterRow(
|
||||
eventId = getLong(EventExportProjection.IDX_ID),
|
||||
calendarId = calendarId,
|
||||
syncId = getString(EventExportProjection.IDX_SYNC_ID)?.takeIf { it.isNotEmpty() },
|
||||
event = mapped,
|
||||
declined = declined,
|
||||
)
|
||||
} else {
|
||||
// An exception without its occurrence time can't name what it replaces.
|
||||
val originalInstance = getLongOrNull(EventExportProjection.IDX_ORIGINAL_INSTANCE_TIME)
|
||||
?: return@forEachExportRow
|
||||
overrides += OverrideRow(
|
||||
calendarId = calendarId,
|
||||
originalId = originalId,
|
||||
originalSyncId = originalSyncId,
|
||||
originalInstance = originalInstance.toKotlinInstantFromEpochMillis(),
|
||||
event = mapped,
|
||||
declined = declined,
|
||||
accepted = selfStatus == CalendarContract.Attendees.ATTENDEE_STATUS_ACCEPTED ||
|
||||
selfStatus == CalendarContract.Attendees.ATTENDEE_STATUS_TENTATIVE,
|
||||
)
|
||||
}
|
||||
}
|
||||
return CalendarRows(series, overrides)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand each Events row matching [selection], oldest first, to [row] with a
|
||||
* mapper to [IcsEvent] that takes the calendar name to tag it with.
|
||||
* Reminders are read in one batched pass ahead of the rows.
|
||||
*/
|
||||
private fun forEachExportRow(
|
||||
selection: String,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
row: ColumnReader.(event: (calendarName: String?) -> IcsEvent) -> Unit,
|
||||
) {
|
||||
val eventIds = resolver.query(
|
||||
CalendarContract.Events.CONTENT_URI,
|
||||
arrayOf(CalendarContract.Events._ID),
|
||||
selection,
|
||||
null,
|
||||
null,
|
||||
)?.use { c -> c.mapRows { getLong(0) } }.orEmpty()
|
||||
val reminders = resolver.reminderMinutesByEvent(eventIds, alertsOnly = false)
|
||||
resolver.query(
|
||||
CalendarContract.Events.CONTENT_URI,
|
||||
EventExportProjection.COLUMNS,
|
||||
// Skip soft-deleted rows and exception rows (modified occurrences /
|
||||
// cancellations) — v1 exports masters + one-offs only.
|
||||
"${CalendarContract.Events.CALENDAR_ID} IN ($idList) AND " +
|
||||
"${CalendarContract.Events.DELETED} = 0 AND " +
|
||||
"${CalendarContract.Events.ORIGINAL_ID} IS NULL",
|
||||
selection,
|
||||
null,
|
||||
CalendarContract.Events.DTSTART + " ASC",
|
||||
)?.use { c ->
|
||||
c.mapRows {
|
||||
val eventId = getLong(EventExportProjection.IDX_ID)
|
||||
val calendarId = getLong(EventExportProjection.IDX_CALENDAR_ID)
|
||||
toIcsEvent(
|
||||
reminderMinutes = queryReminders(eventId).map { it.minutes },
|
||||
calendarName = names[calendarId],
|
||||
allDayReminderTimeMinutes = allDayReminderTimeMinutes,
|
||||
)
|
||||
val reader = CursorColumnReader(c)
|
||||
while (c.moveToNext()) {
|
||||
val eventId = reader.getLong(EventExportProjection.IDX_ID)
|
||||
reader.row { calendarName ->
|
||||
reader.toIcsEvent(
|
||||
reminderMinutes = reminders[eventId].orEmpty(),
|
||||
calendarName = calendarName,
|
||||
allDayReminderTimeMinutes = allDayReminderTimeMinutes,
|
||||
)
|
||||
}
|
||||
}
|
||||
} ?: emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
override fun existingUids(calendarId: Long): Set<String> = resolver.query(
|
||||
@@ -865,18 +915,25 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
} else {
|
||||
null
|
||||
}
|
||||
event.semanticReminderMinutes().forEach { minutes ->
|
||||
val providerMinutes = if (reminderDate != null) {
|
||||
toProviderAllDayMinutes(
|
||||
semanticMinutes = minutes,
|
||||
startDate = reminderDate,
|
||||
zone = zone,
|
||||
timeOfDayMinutes = allDayReminderTimeMinutes,
|
||||
)
|
||||
} else {
|
||||
minutes
|
||||
// All or nothing: a row whose reminders failed would count as not added
|
||||
// and still be there.
|
||||
try {
|
||||
event.semanticReminderMinutes().forEach { minutes ->
|
||||
val providerMinutes = if (reminderDate != null) {
|
||||
toProviderAllDayMinutes(
|
||||
semanticMinutes = minutes,
|
||||
startDate = reminderDate,
|
||||
zone = zone,
|
||||
timeOfDayMinutes = allDayReminderTimeMinutes,
|
||||
)
|
||||
} else {
|
||||
minutes
|
||||
}
|
||||
insertReminderRow(eventId, providerMinutes)
|
||||
}
|
||||
insertReminderRow(eventId, providerMinutes)
|
||||
} catch (t: Throwable) {
|
||||
runCatching { resolver.delete(eventUri(eventId), null, null) }
|
||||
throw t
|
||||
}
|
||||
return eventId
|
||||
}
|
||||
|
||||
@@ -61,18 +61,41 @@ interface CalendarRepository {
|
||||
suspend fun setCalendarsVisible(ids: Collection<Long>, visible: Boolean)
|
||||
|
||||
/**
|
||||
* Every event of the writable local calendars, ready to serialise into a
|
||||
* whole-calendar `.ics` backup (see [CalendarDataSource.exportableEvents]).
|
||||
* [calendarIds] narrows the export to a chosen subset; `null` exports all.
|
||||
* Every event of [calendarIds], ready to serialise into an `.ics` backup
|
||||
* (see [CalendarDataSource.calendarRows]). Any calendar can be read,
|
||||
* synced and read-only ones included (#147). Exception rows come out
|
||||
* flattened ([de.jeanlucmakiola.calendula.domain.ics.flattenOverrides]),
|
||||
* so a restore keeps moved and deleted occurrences of a series.
|
||||
*/
|
||||
suspend fun exportEvents(calendarIds: Set<Long>? = null): List<IcsEvent>
|
||||
suspend fun exportEvents(calendarIds: Set<Long>): List<IcsEvent>
|
||||
|
||||
/**
|
||||
* Bulk-import parsed `.ics` [events] into [targetCalendarId]. Events whose
|
||||
* UID already exists in the target are skipped (idempotent restore); the
|
||||
* rest are inserted. See [CalendarDataSource.insertImportedEvent].
|
||||
* rest are inserted, as is a standalone occurrence whose series is already
|
||||
* there ([ParsedIcsEvent.overrideOf]). See [CalendarDataSource.insertImportedEvent].
|
||||
* [onProgress] gets the count of events handled so far before each event
|
||||
* after the first and once more when all were handled; it may throw to stop
|
||||
* the import between events.
|
||||
*/
|
||||
suspend fun importEvents(targetCalendarId: Long, events: List<ParsedIcsEvent>): IcsImportSummary
|
||||
suspend fun importEvents(
|
||||
targetCalendarId: Long,
|
||||
events: List<ParsedIcsEvent>,
|
||||
onProgress: (done: Int) -> Unit = {},
|
||||
): IcsImportSummary
|
||||
|
||||
/**
|
||||
* Copy every event of [sourceCalendarId] into [targetCalendarId] (#325),
|
||||
* exceptions included (see
|
||||
* [de.jeanlucmakiola.calendula.domain.ics.planCalendarCopy]). The source is only read. Runs
|
||||
* through [importEvents], so a re-run skips what an earlier one copied.
|
||||
* [onProgress] gets (done, total) once the source is read and after each event.
|
||||
*/
|
||||
suspend fun copyEvents(
|
||||
sourceCalendarId: Long,
|
||||
targetCalendarId: Long,
|
||||
onProgress: (done: Int, total: Int) -> Unit = { _, _ -> },
|
||||
): IcsImportSummary
|
||||
|
||||
/** Create a new event from a validated form; returns the new `Events._ID`. */
|
||||
suspend fun createEvent(form: EventForm): Long
|
||||
|
||||
+57
-7
@@ -14,6 +14,8 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.SearchCandidate
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.flattenOverrides
|
||||
import de.jeanlucmakiola.calendula.domain.ics.planCalendarCopy
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -30,6 +32,8 @@ import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlin.time.Instant
|
||||
@@ -247,12 +251,33 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun exportEvents(calendarIds: Set<Long>?) =
|
||||
withContext(io) { dataSource.exportableEvents(calendarIds, allDayReminderTimeMinutes()) }
|
||||
override suspend fun exportEvents(calendarIds: Set<Long>) = withContext(io) {
|
||||
flattenOverrides(dataSource.calendarRows(calendarIds, allDayReminderTimeMinutes()), ::formatExdateStamp)
|
||||
}
|
||||
|
||||
override suspend fun copyEvents(
|
||||
sourceCalendarId: Long,
|
||||
targetCalendarId: Long,
|
||||
onProgress: (done: Int, total: Int) -> Unit,
|
||||
): IcsImportSummary {
|
||||
val events = withContext(io) {
|
||||
planCalendarCopy(
|
||||
dataSource.calendarRows(setOf(sourceCalendarId), allDayReminderTimeMinutes()),
|
||||
::formatExdateStamp,
|
||||
)
|
||||
}
|
||||
onProgress(0, events.size)
|
||||
val context = currentCoroutineContext()
|
||||
return importEvents(targetCalendarId, events) { done ->
|
||||
if (done < events.size) context.ensureActive()
|
||||
onProgress(done, events.size)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun importEvents(
|
||||
targetCalendarId: Long,
|
||||
events: List<ParsedIcsEvent>,
|
||||
onProgress: (done: Int) -> Unit,
|
||||
): IcsImportSummary = write {
|
||||
val existing = dataSource.existingUids(targetCalendarId)
|
||||
// Both are per-calendar, not per-event: looking them up once keeps a
|
||||
@@ -265,15 +290,21 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
var skipped = 0
|
||||
var failed = 0
|
||||
var notAttempted = 0
|
||||
for ((index, event) in events.withIndex()) {
|
||||
// Occurrences whose standalone copy didn't land, by series UID: the series
|
||||
// keeps them instead of excluding them for good.
|
||||
val unexcluded = mutableMapOf<String, MutableList<Instant>>()
|
||||
// Standalone occurrences ahead of the series they were cut from, however the file is ordered.
|
||||
val ordered = events.sortedBy { it.overrideOf == null }
|
||||
for ((index, event) in ordered.withIndex()) {
|
||||
if (index > 0) onProgress(index)
|
||||
// A known UID means the event is already in this calendar — skip,
|
||||
// keeping a restore idempotent (no overwrite this pass).
|
||||
if (event.uid != null && event.uid in existing) {
|
||||
if (event.isAlreadyIn(existing)) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
try {
|
||||
dataSource.insertImportedEvent(event, targetCalendarId, allDayMinutes, palette)
|
||||
dataSource.insertImportedEvent(event.keeping(unexcluded), targetCalendarId, allDayMinutes, palette)
|
||||
imported++
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
@@ -283,8 +314,8 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
Log.w(TAG, "Import stopped: the calendar is no longer writable", e)
|
||||
// A known UID would have been skipped whatever happened, so it is
|
||||
// no part of what a retry still has to import.
|
||||
val remaining = events.drop(index)
|
||||
val duplicates = remaining.count { it.uid?.let { uid -> uid in existing } == true }
|
||||
val remaining = ordered.drop(index)
|
||||
val duplicates = remaining.count { it.isAlreadyIn(existing) }
|
||||
skipped += duplicates
|
||||
notAttempted = remaining.size - duplicates
|
||||
break
|
||||
@@ -293,9 +324,13 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
// rest of the file — foreign exports do carry rows it rejects
|
||||
// outright (a malformed RRULE throws straight out of insert).
|
||||
failed++
|
||||
if (event.overrideOf != null && event.overrideAt != null) {
|
||||
unexcluded.getOrPut(event.overrideOf) { mutableListOf() } += event.overrideAt
|
||||
}
|
||||
Log.w(TAG, "Skipped an unimportable event", e)
|
||||
}
|
||||
}
|
||||
if (notAttempted == 0) onProgress(events.size)
|
||||
IcsImportSummary(
|
||||
imported = imported,
|
||||
skippedDuplicate = skipped,
|
||||
@@ -376,3 +411,18 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
private fun <T> Flow<Unit>.reQuery(block: suspend () -> T): Flow<T> = flow {
|
||||
collect { emit(block()) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [existing] UIDs already hold this event: its own UID, or — for a
|
||||
* standalone occurrence of a backed-up or copied series — the series, which
|
||||
* there carries the override itself.
|
||||
*/
|
||||
private fun ParsedIcsEvent.isAlreadyIn(existing: Set<String>): Boolean =
|
||||
(uid != null && uid in existing) || (overrideOf != null && overrideOf in existing)
|
||||
|
||||
/** This series without the exclusions of [unexcluded] occurrences whose standalone copy failed. */
|
||||
private fun ParsedIcsEvent.keeping(unexcluded: Map<String, List<Instant>>): ParsedIcsEvent {
|
||||
val kept = uid?.let(unexcluded::get) ?: return this
|
||||
val stamps = kept.map { formatExdateStamp(it.toEpochMilliseconds(), isAllDay) }.toSet()
|
||||
return copy(exDates = exDates.filterNot { it in stamps })
|
||||
}
|
||||
@@ -684,7 +684,7 @@ private const val TZID_PREFIX = "TZID="
|
||||
* are UTC: the provider stores an all-day DTSTART at UTC midnight, so its date
|
||||
* reads off the UTC calendar day.
|
||||
*/
|
||||
private fun formatExdateStamp(occurrenceMillis: Long, isAllDay: Boolean): String =
|
||||
internal fun formatExdateStamp(occurrenceMillis: Long, isAllDay: Boolean): String =
|
||||
formatExdateStamp(
|
||||
local = Instant.ofEpochMilli(occurrenceMillis).atZone(ZoneOffset.UTC).toLocalDateTime(),
|
||||
isAllDay = isAllDay,
|
||||
@@ -851,10 +851,12 @@ internal fun buildImportedEventValues(
|
||||
put(CalendarContract.Events.TITLE, event.summary.trim())
|
||||
put(CalendarContract.Events.ALL_DAY, if (event.isAllDay) 1 else 0)
|
||||
put(CalendarContract.Events.DTSTART, startMillis)
|
||||
if (event.recurrenceRule == null) {
|
||||
if (event.recurrenceRule == null && event.recurrenceDates.isEmpty()) {
|
||||
put(CalendarContract.Events.DTEND, endMillis)
|
||||
} else {
|
||||
put(CalendarContract.Events.RRULE, event.recurrenceRule)
|
||||
event.recurrenceRule?.let { put(CalendarContract.Events.RRULE, it) }
|
||||
event.recurrenceDates.takeIf { it.isNotEmpty() }
|
||||
?.let { put(CalendarContract.Events.RDATE, it.joinToString(",")) }
|
||||
put(
|
||||
CalendarContract.Events.DURATION,
|
||||
importDuration(startMillis, endMillis, event.isAllDay),
|
||||
@@ -866,6 +868,9 @@ internal fun buildImportedEventValues(
|
||||
// timed rows keep the event's own zone.
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, if (event.isAllDay) "UTC" else event.zoneId)
|
||||
put(CalendarContract.Events.AVAILABILITY, event.availability.toProviderValue())
|
||||
if (event.accessLevel != AccessLevel.Default) {
|
||||
put(CalendarContract.Events.ACCESS_LEVEL, event.accessLevel.toProviderValue())
|
||||
}
|
||||
put(CalendarContract.Events.STATUS, event.status.forNewEvent().toProviderStatus())
|
||||
event.location?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.deriveIcsUid
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
/**
|
||||
* Map one Events row (read through [EventExportProjection]) into an [IcsEvent]
|
||||
@@ -28,6 +32,8 @@ internal fun ColumnReader.toIcsEvent(
|
||||
val dtStart = getLong(EventExportProjection.IDX_DTSTART)
|
||||
val rrule = getString(EventExportProjection.IDX_RRULE)?.takeIf { it.isNotBlank() }
|
||||
val isAllDay = getInt(EventExportProjection.IDX_ALL_DAY) != 0
|
||||
val timezone = getString(EventExportProjection.IDX_EVENT_TIMEZONE)
|
||||
val rdates = exportRecurrenceDates(getString(EventExportProjection.IDX_RDATE), isAllDay, timezone, dtStart)
|
||||
|
||||
// Unclamped: the writer renders DTEND as stored.
|
||||
val end = endMillis(dtStart, EventExportProjection.IDX_DTEND, EventExportProjection.IDX_DURATION)
|
||||
@@ -38,17 +44,12 @@ internal fun ColumnReader.toIcsEvent(
|
||||
start = dtStart.toKotlinInstantFromEpochMillis(),
|
||||
end = end.toKotlinInstantFromEpochMillis(),
|
||||
isAllDay = isAllDay,
|
||||
zoneId = getString(EventExportProjection.IDX_EVENT_TIMEZONE)?.takeIf { it.isNotBlank() }
|
||||
?: "UTC",
|
||||
zoneId = timezone?.takeIf { it.isNotBlank() } ?: "UTC",
|
||||
recurrenceRule = rrule,
|
||||
exDates = if (rrule == null) {
|
||||
exDates = if (rrule == null && rdates.isEmpty()) {
|
||||
emptyList()
|
||||
} else {
|
||||
exportExDates(
|
||||
getString(EventExportProjection.IDX_EXDATE),
|
||||
isAllDay,
|
||||
getString(EventExportProjection.IDX_EVENT_TIMEZONE),
|
||||
)
|
||||
exportExDates(getString(EventExportProjection.IDX_EXDATE), isAllDay, timezone, dtStart)
|
||||
},
|
||||
location = getString(EventExportProjection.IDX_LOCATION),
|
||||
description = getString(EventExportProjection.IDX_DESCRIPTION),
|
||||
@@ -63,6 +64,8 @@ internal fun ColumnReader.toIcsEvent(
|
||||
status = eventStatus(EventExportProjection.IDX_STATUS),
|
||||
availability = mapAvailability(getInt(EventExportProjection.IDX_AVAILABILITY)),
|
||||
calendarName = calendarName,
|
||||
recurrenceDates = rdates,
|
||||
accessLevel = getIntOrNull(EventExportProjection.IDX_ACCESS_LEVEL)?.let(::mapAccessLevel) ?: AccessLevel.Default,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -94,16 +97,53 @@ internal fun exportExDates(
|
||||
exdate: String?,
|
||||
isAllDay: Boolean,
|
||||
eventTimezone: String? = null,
|
||||
seriesStartMillis: Long? = null,
|
||||
): List<String> {
|
||||
if (exdate.isNullOrBlank()) return emptyList()
|
||||
val zone = exdateListZone(exdate, eventTimezone)
|
||||
return exdateStamps(exdate)
|
||||
.map { stamp ->
|
||||
if (isAllDay) {
|
||||
stamp.substringBefore('T')
|
||||
} else {
|
||||
canonicalExdateStamp(stamp, isAllDay = false, listZone = zone) ?: stamp
|
||||
// Several properties are stored one per line, each with its own zone prefix.
|
||||
return exdate.lines().filter { it.isNotBlank() }.flatMap { line ->
|
||||
val zone = exdateListZone(line, eventTimezone)
|
||||
exdateStamps(line).mapNotNull { stamp ->
|
||||
when {
|
||||
isAllDay -> stamp.substringBefore('T')
|
||||
// A bare day on a timed series means the occurrence at the series' time of day.
|
||||
seriesStartMillis != null && BARE_DAY.matches(stamp) -> atSeriesTime(stamp, seriesStartMillis, zone)
|
||||
else -> canonicalExdateStamp(stamp, isAllDay = false, listZone = zone) ?: stamp
|
||||
}
|
||||
}
|
||||
}
|
||||
.filter { (if (isAllDay) BARE_DAY else UTC_STAMP).matches(it) }
|
||||
.distinct()
|
||||
}
|
||||
|
||||
/**
|
||||
* The row's `RDATE` in the same shape [exportExDates] gives `EXDATE` — the list
|
||||
* shares its `<zone>;<stamps>` form. A `PERIOD` entry (`start/end`) keeps only its
|
||||
* start: the occurrence then runs the series' own length. Anything that still
|
||||
* isn't a date or a UTC stamp is dropped, since a file can't name it.
|
||||
*/
|
||||
internal fun exportRecurrenceDates(
|
||||
rdate: String?,
|
||||
isAllDay: Boolean,
|
||||
eventTimezone: String? = null,
|
||||
seriesStartMillis: Long? = null,
|
||||
): List<String> {
|
||||
if (rdate.isNullOrBlank()) return emptyList()
|
||||
// The zone prefix may hold a '/' of its own (Europe/Berlin), so only the stamps lose their period end.
|
||||
val starts = rdate.lines().joinToString("\n") { line ->
|
||||
val stamps = line.withoutExdateParameters()
|
||||
line.dropLast(stamps.length) + stamps.split(',').joinToString(",") { it.substringBefore('/') }
|
||||
}
|
||||
return exportExDates(starts, isAllDay, eventTimezone, seriesStartMillis)
|
||||
}
|
||||
|
||||
/** [day] (`yyyyMMdd`) at the wall-clock time the series starts at in [zone], as a UTC stamp. */
|
||||
private fun atSeriesTime(day: String, seriesStartMillis: Long, zone: ZoneId): String? {
|
||||
val date = runCatching { LocalDate.parse(day, DateTimeFormatter.BASIC_ISO_DATE) }.getOrNull() ?: return null
|
||||
val time = Instant.ofEpochMilli(seriesStartMillis).atZone(zone).toLocalTime()
|
||||
return formatExdateStamp(date.atTime(time).atZone(zone).toInstant().toEpochMilli(), isAllDay = false)
|
||||
}
|
||||
|
||||
private val BARE_DAY = Regex("""\d{8}""")
|
||||
private val UTC_STAMP = Regex("""\d{8}T\d{6}Z""")
|
||||
|
||||
@@ -152,14 +152,13 @@ internal object EventDetailProjection {
|
||||
}
|
||||
|
||||
/**
|
||||
* Master/one-off Events rows for a whole-calendar backup. Unlike
|
||||
* Every Events row of a whole-calendar backup or copy. Unlike
|
||||
* [EventDetailProjection] this reads `UID_2445` (to keep a row's identity across
|
||||
* backups) and `DURATION` (recurring rows carry it instead of DTEND), plus
|
||||
* `EXDATE` so a series exports the occurrences deleted from it. Modified-
|
||||
* occurrence and cancelled-exception rows are filtered out by the query
|
||||
* (`ORIGINAL_ID IS NULL`), so RECURRENCE-ID overrides aren't exported — a
|
||||
* documented v1 limit (import skips them too). EXDATE is unaffected by that
|
||||
* filter: it lives on the master row, not on an exception of its own.
|
||||
* `EXDATE` so a series exports the occurrences deleted from it. Exception rows
|
||||
* are read too and told apart by their series-link columns; see
|
||||
* [de.jeanlucmakiola.calendula.domain.ics.flattenOverrides] for how they are
|
||||
* written out.
|
||||
*/
|
||||
internal object EventExportProjection {
|
||||
val COLUMNS: Array<String> = arrayOf(
|
||||
@@ -178,6 +177,15 @@ internal object EventExportProjection {
|
||||
CalendarContract.Events.STATUS,
|
||||
CalendarContract.Events.AVAILABILITY,
|
||||
CalendarContract.Events.CALENDAR_ID,
|
||||
CalendarContract.Events.EVENT_COLOR,
|
||||
CalendarContract.Events._SYNC_ID,
|
||||
CalendarContract.Events.ORIGINAL_ID,
|
||||
CalendarContract.Events.ORIGINAL_SYNC_ID,
|
||||
CalendarContract.Events.ORIGINAL_INSTANCE_TIME,
|
||||
CalendarContract.Events.RDATE,
|
||||
CalendarContract.Events.ACCESS_LEVEL,
|
||||
CalendarContract.Events.EVENT_COLOR_KEY,
|
||||
CalendarContract.Events.SELF_ATTENDEE_STATUS,
|
||||
)
|
||||
|
||||
const val IDX_ID = 0
|
||||
@@ -195,6 +203,15 @@ internal object EventExportProjection {
|
||||
const val IDX_STATUS = 12
|
||||
const val IDX_AVAILABILITY = 13
|
||||
const val IDX_CALENDAR_ID = 14
|
||||
const val IDX_EVENT_COLOR = 15
|
||||
const val IDX_SYNC_ID = 16
|
||||
const val IDX_ORIGINAL_ID = 17
|
||||
const val IDX_ORIGINAL_SYNC_ID = 18
|
||||
const val IDX_ORIGINAL_INSTANCE_TIME = 19
|
||||
const val IDX_RDATE = 20
|
||||
const val IDX_ACCESS_LEVEL = 21
|
||||
const val IDX_EVENT_COLOR_KEY = 22
|
||||
const val IDX_SELF_ATTENDEE_STATUS = 23
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.provider.CalendarContract
|
||||
|
||||
/** Ids per `IN (…)` list: they go into the SQL literally, which SQLite bounds. */
|
||||
private const val EVENT_ID_BATCH = 50
|
||||
|
||||
/**
|
||||
* Reminder minutes of [eventIds], keyed by event, a batched query at a time
|
||||
* rather than one per event. [alertsOnly] keeps just the notification ones.
|
||||
*/
|
||||
internal fun ContentResolver.reminderMinutesByEvent(
|
||||
eventIds: Collection<Long>,
|
||||
alertsOnly: Boolean,
|
||||
): Map<Long, List<Int>> {
|
||||
if (eventIds.isEmpty()) return emptyMap()
|
||||
val out = mutableMapOf<Long, MutableList<Int>>()
|
||||
val method = if (alertsOnly) {
|
||||
"${CalendarContract.Reminders.METHOD} = ${CalendarContract.Reminders.METHOD_ALERT} AND "
|
||||
} else {
|
||||
""
|
||||
}
|
||||
eventIds.distinct().chunked(EVENT_ID_BATCH).forEach { batch ->
|
||||
query(
|
||||
CalendarContract.Reminders.CONTENT_URI,
|
||||
arrayOf(CalendarContract.Reminders.EVENT_ID, CalendarContract.Reminders.MINUTES),
|
||||
"$method${CalendarContract.Reminders.EVENT_ID} IN (${batch.joinToString(",")})",
|
||||
null,
|
||||
null,
|
||||
)?.use { c ->
|
||||
while (c.moveToNext()) out.getOrPut(c.getLong(0)) { mutableListOf() } += c.getInt(1)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -5,6 +5,10 @@ import android.net.Uri
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.floret.android.TreeListing
|
||||
import de.jeanlucmakiola.floret.android.deleteTreeFiles
|
||||
import de.jeanlucmakiola.floret.android.listTreeFiles
|
||||
import de.jeanlucmakiola.floret.android.writeTreeFile
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
@@ -29,34 +33,27 @@ class IcsExporter @Inject constructor(
|
||||
} ?: throw IOException("Could not open output stream for export (scheme=${uri.scheme})")
|
||||
}
|
||||
|
||||
/**
|
||||
* Write [content] to [fileName] inside the persisted SAF tree [folder],
|
||||
* overwriting it if it already exists (the automatic-backup destination).
|
||||
* Requires a persisted write grant on [folder]. Throws on failure.
|
||||
*/
|
||||
fun writeToFolder(folder: Uri, fileName: String, content: String) {
|
||||
/** The files in the persisted SAF tree [folder], the automatic-backup destination. Throws when unusable. */
|
||||
fun listFolder(folder: Uri): TreeListing {
|
||||
writableFolder(folder)
|
||||
return context.contentResolver.listTreeFiles(folder)
|
||||
}
|
||||
|
||||
/** Write the `.ics` [bytes] as [fileName] into [folder]; returns the name the file ended up with. */
|
||||
fun writeToFolder(folder: Uri, listing: TreeListing, fileName: String, bytes: ByteArray): String =
|
||||
context.contentResolver.writeTreeFile(folder, listing, fileName, MIME_CALENDAR, bytes)
|
||||
|
||||
/** Delete [names] from the listed folder; returns the ones that didn't go. */
|
||||
fun deleteFromFolder(listing: TreeListing, names: Collection<String>): Set<String> =
|
||||
context.contentResolver.deleteTreeFiles(listing, names)
|
||||
|
||||
private fun writableFolder(folder: Uri): DocumentFile {
|
||||
val dir = DocumentFile.fromTreeUri(context, folder)
|
||||
?: throw IOException("Backup folder is not accessible")
|
||||
if (!dir.exists() || !dir.canWrite()) {
|
||||
throw IOException("Backup folder is missing or not writable")
|
||||
}
|
||||
// Reuse the canonical file if present and clean up any "name (1).ics"
|
||||
// duplicates SAF may have created if two runs ever raced — so we always
|
||||
// converge on a single overwritten file.
|
||||
val base = fileName.substringBeforeLast('.')
|
||||
val ext = fileName.substringAfterLast('.', "")
|
||||
var target: DocumentFile? = null
|
||||
for (child in dir.listFiles()) {
|
||||
val name = child.name ?: continue
|
||||
when {
|
||||
name == fileName -> target = child
|
||||
name.startsWith("$base (") && name.endsWith(".$ext") -> child.delete()
|
||||
}
|
||||
}
|
||||
val file = target
|
||||
?: dir.createFile(MIME_CALENDAR, fileName)
|
||||
?: throw IOException("Could not create backup file in the chosen folder")
|
||||
writeDocument(file.uri, content)
|
||||
return dir
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -81,7 +81,7 @@ class CalendarPrefs @Inject constructor(
|
||||
}
|
||||
|
||||
/** A comma-joined id set; non-numeric tokens are dropped, and an empty set removes the key. */
|
||||
private fun idSetPref(name: String): Pref<Set<Long>> = nullableStringPref(name).map(
|
||||
internal fun idSetPref(name: String): Pref<Set<Long>> = nullableStringPref(name).map(
|
||||
{ stored -> stored.orEmpty().split(',').mapNotNull { it.trim().toLongOrNull() }.toSet() },
|
||||
{ ids -> if (ids.isEmpty()) null else ids.sorted().joinToString(",") },
|
||||
)
|
||||
@@ -1,9 +1,23 @@
|
||||
package de.jeanlucmakiola.calendula.data.prefs
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.MutablePreferences
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||
import de.jeanlucmakiola.calendula.domain.backup.BackupSelection
|
||||
import de.jeanlucmakiola.calendula.domain.backup.COMBINED_SERIES
|
||||
import de.jeanlucmakiola.calendula.domain.backup.DEFAULT_BACKUP_KEEP
|
||||
import de.jeanlucmakiola.calendula.domain.backup.LEGACY_BACKUP_FILE
|
||||
import de.jeanlucmakiola.calendula.domain.backup.latestLedgerKey
|
||||
import de.jeanlucmakiola.calendula.domain.backup.normalizeBackupKeep
|
||||
import de.jeanlucmakiola.calendula.domain.backup.BackupLedger
|
||||
import de.jeanlucmakiola.calendula.domain.backup.parseBackupLedger
|
||||
import de.jeanlucmakiola.calendula.domain.backup.persistBackupLedger
|
||||
import de.jeanlucmakiola.calendula.domain.backup.BackupFingerprints
|
||||
import de.jeanlucmakiola.calendula.domain.backup.parseBackupFingerprints
|
||||
import de.jeanlucmakiola.calendula.domain.backup.persistBackupFingerprints
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
||||
@@ -29,6 +43,7 @@ import de.jeanlucmakiola.floret.prefs.enumPref
|
||||
import de.jeanlucmakiola.floret.prefs.get
|
||||
import de.jeanlucmakiola.floret.prefs.intPref
|
||||
import de.jeanlucmakiola.floret.prefs.longPref
|
||||
import de.jeanlucmakiola.floret.prefs.nullableBooleanPref
|
||||
import de.jeanlucmakiola.floret.prefs.nullableLongPref
|
||||
import de.jeanlucmakiola.floret.prefs.nullableStringPref
|
||||
import de.jeanlucmakiola.floret.prefs.set
|
||||
@@ -180,6 +195,12 @@ class SettingsPrefs @Inject constructor(
|
||||
val autoBackupIntervalMinutes: Flow<Long> = prefs.flow(SettingsKeys.autoBackupIntervalMinutes)
|
||||
val autoBackupFolderUri: Flow<String?> = prefs.flow(SettingsKeys.autoBackupFolderUri)
|
||||
val autoBackupStatus: Flow<BackupStatus> = prefs.snapshot { it.autoBackupStatus }
|
||||
val autoBackupSelection: Flow<BackupSelection> = prefs.snapshot { it.autoBackupSelection }
|
||||
val autoBackupCombined: Flow<Boolean> = prefs.snapshot { it.autoBackupCombined }
|
||||
val autoBackupKeep: Flow<Int> = prefs.flow(SettingsKeys.autoBackupKeep)
|
||||
val autoBackupLedger: Flow<BackupLedger> = prefs.flow(SettingsKeys.autoBackupLedger)
|
||||
val autoBackupOnChange: Flow<Boolean> = prefs.flow(SettingsKeys.autoBackupOnChange)
|
||||
val autoBackupFingerprints: Flow<BackupFingerprints> = prefs.flow(SettingsKeys.autoBackupFingerprints)
|
||||
val specialDatesEnabled: Flow<Boolean> = prefs.flow(SettingsKeys.specialDatesEnabled)
|
||||
val specialDatesTypes: Flow<Set<SpecialDateType>> = prefs.snapshot { it.specialDatesTypes }
|
||||
val specialDatesCalendars: Flow<Map<SpecialDateType, Long>> = prefs.snapshot { it.specialDatesCalendars }
|
||||
@@ -329,7 +350,78 @@ class SettingsPrefs @Inject constructor(
|
||||
suspend fun setAutoBackupIntervalMinutes(minutes: Long) =
|
||||
prefs.set(SettingsKeys.autoBackupIntervalMinutes, minutes)
|
||||
|
||||
suspend fun setAutoBackupFolderUri(uri: String?) = prefs.set(SettingsKeys.autoBackupFolderUri, uri)
|
||||
/**
|
||||
* Also pins the file layout the first time it is written: an install that
|
||||
* already had a folder before #411 keeps its single combined file, a new
|
||||
* one starts on one file per calendar (see [Preferences.autoBackupCombined]).
|
||||
*/
|
||||
suspend fun setAutoBackupFolderUri(uri: String?) {
|
||||
prefs.edit {
|
||||
it.pinBackupLayout()
|
||||
// The ledger says which files in the old folder are ours; in a new
|
||||
// one a file of the same name may be the user's.
|
||||
if (it[SettingsKeys.autoBackupFolderUri] != uri) {
|
||||
it[SettingsKeys.autoBackupLedger] = emptyMap()
|
||||
it[SettingsKeys.autoBackupFingerprints] = emptyMap()
|
||||
}
|
||||
it[SettingsKeys.autoBackupFolderUri] = uri
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setAutoBackupCombined(combined: Boolean) {
|
||||
prefs.edit {
|
||||
it.pinBackupLayout()
|
||||
it[SettingsKeys.autoBackupCombinedStored] = combined
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setAutoBackupKeep(keep: Int) = prefs.set(SettingsKeys.autoBackupKeep, keep)
|
||||
|
||||
suspend fun setAutoBackupOnChange(enabled: Boolean) = prefs.set(SettingsKeys.autoBackupOnChange, enabled)
|
||||
|
||||
/**
|
||||
* Atomic, so a run's ledger writes never undo one made meanwhile (pinning
|
||||
* the layout). Dropped when the folder is no longer [folder]: a run still
|
||||
* finishing must not carry the old folder's names into the new one's ledger.
|
||||
* [fingerprints] updates the series' hashes (#516) in the same edit.
|
||||
*/
|
||||
suspend fun updateAutoBackupLedger(
|
||||
folder: String,
|
||||
fingerprints: ((BackupFingerprints) -> BackupFingerprints)? = null,
|
||||
transform: (BackupLedger) -> BackupLedger,
|
||||
) {
|
||||
prefs.edit {
|
||||
if (it[SettingsKeys.autoBackupFolderUri] == folder) {
|
||||
it[SettingsKeys.autoBackupLedger] = transform(it[SettingsKeys.autoBackupLedger])
|
||||
if (fingerprints != null) {
|
||||
it[SettingsKeys.autoBackupFingerprints] = fingerprints(it[SettingsKeys.autoBackupFingerprints])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Like [updateAutoBackupLedger], for the series' hashes (#516) alone. */
|
||||
suspend fun updateAutoBackupFingerprints(folder: String, transform: (BackupFingerprints) -> BackupFingerprints) {
|
||||
prefs.edit {
|
||||
if (it[SettingsKeys.autoBackupFolderUri] == folder) {
|
||||
it[SettingsKeys.autoBackupFingerprints] = transform(it[SettingsKeys.autoBackupFingerprints])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Record a pre-#411 install's old file as its combined series' stable copy (see [pinBackupLayout]). */
|
||||
suspend fun adoptLegacyBackupFile() {
|
||||
prefs.edit { if (it.autoBackupLegacyInstall) it.adoptLegacyBackupFile() }
|
||||
}
|
||||
|
||||
/** Switch [calendar] in or out of the automatic backup (#147). */
|
||||
suspend fun setAutoBackupCalendar(calendar: CalendarSource, included: Boolean) {
|
||||
prefs.edit {
|
||||
val next = it.autoBackupSelection.with(calendar, included)
|
||||
it[SettingsKeys.autoBackupIncluded] = next.included
|
||||
it[SettingsKeys.autoBackupExcluded] = next.excluded
|
||||
}
|
||||
}
|
||||
|
||||
/** Record an automatic run's outcome; resets the failure streak on success. */
|
||||
suspend fun recordAutoBackupRun(success: Boolean, atMillis: Long) {
|
||||
@@ -700,6 +792,34 @@ object SettingsKeys {
|
||||
val autoBackupLastSuccess: Pref<Boolean> = booleanPref("auto_backup_last_success", true)
|
||||
val autoBackupFailures: Pref<Int> = intPref("auto_backup_failures", 0)
|
||||
|
||||
/**
|
||||
* The automatic backup's calendar choice as deviations from the default
|
||||
* (#147), read together as [Preferences.autoBackupSelection].
|
||||
*/
|
||||
val autoBackupIncluded: Pref<Set<Long>> = idSetPref("auto_backup_included_calendars")
|
||||
val autoBackupExcluded: Pref<Set<Long>> = idSetPref("auto_backup_excluded_calendars")
|
||||
|
||||
/**
|
||||
* Whether the automatic backup writes one combined file instead of one per
|
||||
* calendar (#411). Unset until first pinned; read as [Preferences.autoBackupCombined].
|
||||
*/
|
||||
val autoBackupCombinedStored: Pref<Boolean?> = nullableBooleanPref("auto_backup_combined")
|
||||
|
||||
/** How many files of each backup series to keep (#411). */
|
||||
val autoBackupKeep: Pref<Int> =
|
||||
intPref("auto_backup_keep", DEFAULT_BACKUP_KEEP).coerced(::normalizeBackupKeep)
|
||||
|
||||
/** The files the automatic backup wrote, per series; the only ones it ever prunes (#411). */
|
||||
val autoBackupLedger: Pref<BackupLedger> =
|
||||
nullableStringPref("auto_backup_files").map(::parseBackupLedger, ::persistBackupLedger)
|
||||
|
||||
/** Whether a change to a backed-up calendar also refreshes the stable copies (#516). Opt-in. */
|
||||
val autoBackupOnChange: Pref<Boolean> = booleanPref("auto_backup_on_change", false)
|
||||
|
||||
/** Content hash of each series' stable copy, so a change run skips untouched ones (#516). */
|
||||
val autoBackupFingerprints: Pref<BackupFingerprints> =
|
||||
nullableStringPref("auto_backup_fingerprints").map(::parseBackupFingerprints, ::persistBackupFingerprints)
|
||||
|
||||
/** Master switch for the contact special-dates mirror (issue #15). Opt-in. */
|
||||
val specialDatesEnabled: Pref<Boolean> = booleanPref("special_dates_enabled", false)
|
||||
|
||||
@@ -749,6 +869,42 @@ val Preferences.autoBackupStatus: BackupStatus
|
||||
consecutiveFailures = this[SettingsKeys.autoBackupFailures],
|
||||
)
|
||||
|
||||
val Preferences.autoBackupSelection: BackupSelection
|
||||
get() = BackupSelection(
|
||||
included = this[SettingsKeys.autoBackupIncluded],
|
||||
excluded = this[SettingsKeys.autoBackupExcluded],
|
||||
)
|
||||
|
||||
/**
|
||||
* Unset means an install from before #411: one that already had a backup
|
||||
* folder keeps the single file it was writing, anything else gets per-calendar
|
||||
* files. Choosing a folder pins the answer (see [SettingsPrefs.setAutoBackupFolderUri]).
|
||||
*/
|
||||
val Preferences.autoBackupCombined: Boolean
|
||||
get() = this[SettingsKeys.autoBackupCombinedStored] ?: autoBackupLegacyInstall
|
||||
|
||||
/**
|
||||
* Store the layout the install is on, if it's still derived. Pinning forgets
|
||||
* it was a pre-#411 install, so its old file is recorded as the combined
|
||||
* series' own stable copy at the same time.
|
||||
*/
|
||||
private fun MutablePreferences.pinBackupLayout() {
|
||||
if (this[SettingsKeys.autoBackupCombinedStored] != null) return
|
||||
val legacy = autoBackupLegacyInstall
|
||||
if (legacy) adoptLegacyBackupFile()
|
||||
this[SettingsKeys.autoBackupCombinedStored] = legacy
|
||||
}
|
||||
|
||||
private fun MutablePreferences.adoptLegacyBackupFile() {
|
||||
val key = latestLedgerKey(COMBINED_SERIES)
|
||||
val ledger = this[SettingsKeys.autoBackupLedger]
|
||||
this[SettingsKeys.autoBackupLedger] = ledger + (key to (ledger[key].orEmpty() + LEGACY_BACKUP_FILE).distinct())
|
||||
}
|
||||
|
||||
/** An install that set up automatic backup before #411 and hasn't been pinned since. */
|
||||
val Preferences.autoBackupLegacyInstall: Boolean
|
||||
get() = this[SettingsKeys.autoBackupCombinedStored] == null && this[SettingsKeys.autoBackupFolderUri] != null
|
||||
|
||||
val Preferences.specialDatesTypes: Set<SpecialDateType>
|
||||
get() = SettingsKeys.specialDateTypeEnabled.filterValues { this[it] }.keys
|
||||
|
||||
|
||||
+3
-29
@@ -4,6 +4,7 @@ import android.content.Context
|
||||
import android.provider.CalendarContract
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.data.calendar.instancesUri
|
||||
import de.jeanlucmakiola.calendula.data.calendar.reminderMinutesByEvent
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.ReminderEventInstance
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -72,28 +73,8 @@ class ProviderReminderInstanceSource @Inject constructor(
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
override fun reminderMinutes(eventIds: Collection<Long>): Map<Long, List<Int>> {
|
||||
if (eventIds.isEmpty()) return emptyMap()
|
||||
val out = mutableMapOf<Long, MutableList<Int>>()
|
||||
// Batched: the ids go into the selection literally, and an unbounded
|
||||
// `IN (...)` would grow the SQL past what SQLite takes.
|
||||
eventIds.distinct().chunked(EVENT_ID_BATCH).forEach { batch ->
|
||||
context.contentResolver.query(
|
||||
CalendarContract.Reminders.CONTENT_URI,
|
||||
REMINDER_PROJECTION,
|
||||
"${CalendarContract.Reminders.METHOD} = " +
|
||||
"${CalendarContract.Reminders.METHOD_ALERT} AND " +
|
||||
"${CalendarContract.Reminders.EVENT_ID} IN (${batch.joinToString(",")})",
|
||||
null,
|
||||
null,
|
||||
)?.use { c ->
|
||||
while (c.moveToNext()) {
|
||||
out.getOrPut(c.getLong(0)) { mutableListOf() } += c.getInt(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
override fun reminderMinutes(eventIds: Collection<Long>): Map<Long, List<Int>> =
|
||||
context.contentResolver.reminderMinutesByEvent(eventIds, alertsOnly = true)
|
||||
|
||||
override fun longestReminderMinutes(): Int = context.contentResolver.query(
|
||||
CalendarContract.Reminders.CONTENT_URI,
|
||||
@@ -105,8 +86,6 @@ class ProviderReminderInstanceSource @Inject constructor(
|
||||
)?.use { c -> if (c.moveToFirst()) c.getInt(0) else 0 } ?: 0
|
||||
|
||||
private companion object {
|
||||
const val EVENT_ID_BATCH = 50
|
||||
|
||||
val OCCURRENCE_PROJECTION = arrayOf(
|
||||
CalendarContract.Instances.EVENT_ID,
|
||||
CalendarContract.Instances.CALENDAR_ID,
|
||||
@@ -116,10 +95,5 @@ class ProviderReminderInstanceSource @Inject constructor(
|
||||
CalendarContract.Instances.EVENT_LOCATION,
|
||||
CalendarContract.Instances.ALL_DAY,
|
||||
)
|
||||
|
||||
val REMINDER_PROJECTION = arrayOf(
|
||||
CalendarContract.Reminders.EVENT_ID,
|
||||
CalendarContract.Reminders.MINUTES,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
package de.jeanlucmakiola.calendula.domain.backup
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.flattenOverrides
|
||||
import de.jeanlucmakiola.calendula.domain.isNotSynced
|
||||
import de.jeanlucmakiola.floret.android.safeFileName
|
||||
import java.security.MessageDigest
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
/**
|
||||
* Whether this calendar can be backed up at all. Any calendar the provider
|
||||
* holds can be *read* (#147), whatever its visibility or access level. Left out:
|
||||
* the managed special-dates mirrors, rebuilt from contacts so a restore would
|
||||
* only duplicate them, and calendars whose events aren't synced to the device.
|
||||
*/
|
||||
val CalendarSource.isBackupCandidate: Boolean
|
||||
get() = !isManaged && !isNotSynced
|
||||
|
||||
/**
|
||||
* Whether a backup includes this calendar until the user says otherwise: the
|
||||
* app's own writable local calendars, which have no other copy anywhere. Synced
|
||||
* calendars already live on their server, so they are opt-in.
|
||||
*/
|
||||
val CalendarSource.isBackedUpByDefault: Boolean
|
||||
get() = isLocal && canModifyContents && !isManaged
|
||||
|
||||
/**
|
||||
* Which calendars the automatic backup covers, stored as deviations from
|
||||
* [isBackedUpByDefault] so a calendar created later still follows the default:
|
||||
* [included] opts a calendar in, [excluded] opts one out.
|
||||
*/
|
||||
data class BackupSelection(
|
||||
val included: Set<Long> = emptySet(),
|
||||
val excluded: Set<Long> = emptySet(),
|
||||
) {
|
||||
fun selects(calendar: CalendarSource): Boolean = when {
|
||||
!calendar.isBackupCandidate -> false
|
||||
calendar.id in included -> true
|
||||
calendar.id in excluded -> false
|
||||
else -> calendar.isBackedUpByDefault
|
||||
}
|
||||
|
||||
/** The selection with [calendar] switched [on]; a choice matching the default leaves no trace. */
|
||||
fun with(calendar: CalendarSource, on: Boolean): BackupSelection {
|
||||
val id = calendar.id
|
||||
val default = calendar.isBackedUpByDefault
|
||||
return BackupSelection(
|
||||
included = if (on && !default) included + id else included - id,
|
||||
excluded = if (!on && default) excluded + id else excluded - id,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** One backup file series: the calendars it holds and the name its files start with. */
|
||||
data class BackupTarget(
|
||||
/** Stable identity of the series in the [BackupLedger]: the calendar id, or [COMBINED_SERIES]. */
|
||||
val key: String,
|
||||
val calendarIds: Set<Long>,
|
||||
val baseName: String,
|
||||
/** The calendar name written into the file, or null for a combined file. */
|
||||
val calendarName: String?,
|
||||
)
|
||||
|
||||
/** What one run writes and which earlier files it removes afterwards. */
|
||||
data class BackupFilePlan(
|
||||
val target: BackupTarget,
|
||||
val fileName: String,
|
||||
/** Earlier files of this series that stay, oldest first. */
|
||||
val keep: List<String>,
|
||||
/** Earlier files of this series beyond the retention count, oldest first. */
|
||||
val prune: List<String>,
|
||||
/**
|
||||
* The stable, undated copy overwritten every run (`<base>.ics`), for tools
|
||||
* that follow one path. Null when a file of that name is in the folder but
|
||||
* isn't one this backup wrote: the user's own file is never overwritten.
|
||||
*/
|
||||
val latestName: String?,
|
||||
/** This series' earlier stable copies under a name it no longer uses (a rename), to delete. */
|
||||
val staleLatest: List<String> = emptyList(),
|
||||
/**
|
||||
* Stable copies this series held that another series of the run now takes
|
||||
* over: kept on record until that series has claimed them.
|
||||
*/
|
||||
val handOff: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* The files the automatic backup has written, per series key, oldest first.
|
||||
* Pruning only ever deletes names recorded here, so a series is its files, not
|
||||
* whatever currently matches a calendar's name: renames, same-named calendars
|
||||
* and the user's own files in the folder can't pull in the wrong ones. A
|
||||
* series' stable copy is recorded apart, under [latestLedgerKey].
|
||||
*/
|
||||
typealias BackupLedger = Map<String, List<String>>
|
||||
|
||||
const val COMBINED_SERIES = "combined"
|
||||
|
||||
/** The combined file's series name; also the fixed name used before #411. */
|
||||
const val COMBINED_BACKUP_BASE = "calendula-backup"
|
||||
|
||||
/**
|
||||
* The single file every automatic backup overwrote before #411: the combined
|
||||
* series' stable copy now, so an old setup keeps its path.
|
||||
*/
|
||||
val LEGACY_BACKUP_FILE: String = latestFileName(COMBINED_BACKUP_BASE)
|
||||
|
||||
private const val LATEST_PREFIX = "latest-"
|
||||
|
||||
/** Where the ledger records the stable copy of the series [key]. */
|
||||
fun latestLedgerKey(key: String): String = "$LATEST_PREFIX$key"
|
||||
|
||||
/** The retention choices the settings offer, newest kept first. */
|
||||
val BACKUP_KEEP_OPTIONS: List<Int> = listOf(1, 2, 3, 5, 7, 10)
|
||||
|
||||
const val DEFAULT_BACKUP_KEEP = 3
|
||||
|
||||
/** Room for the id suffix and the stamp within a file system's 255-byte name limit. */
|
||||
private const val MAX_BASE_BYTES = 120
|
||||
private const val ICS_EXTENSION = ".ics"
|
||||
private const val STAMP_PATTERN = "yyyy-MM-dd_HH-mm-ss"
|
||||
private val STAMP_FORMAT: DateTimeFormatter = DateTimeFormatter.ofPattern(STAMP_PATTERN)
|
||||
|
||||
/**
|
||||
* The file series the automatic backup writes: one combined file, or one per
|
||||
* calendar named after it. Calendars whose names clash case-insensitively (a
|
||||
* local and a synced "Personal") take their id as a suffix until every name in
|
||||
* the run is distinct, so no two series write the same file.
|
||||
*/
|
||||
fun backupTargets(calendars: List<CalendarSource>, combined: Boolean): List<BackupTarget> {
|
||||
if (calendars.isEmpty()) return emptyList()
|
||||
if (combined) {
|
||||
return listOf(
|
||||
BackupTarget(COMBINED_SERIES, calendars.map { it.id }.toSet(), COMBINED_BACKUP_BASE, calendarName = null),
|
||||
)
|
||||
}
|
||||
val bases = calendars.associateTo(LinkedHashMap()) {
|
||||
it.id to safeFileName(it.displayName, MAX_BASE_BYTES, fallback = "calendar")
|
||||
}
|
||||
while (true) {
|
||||
val counts = bases.values.groupingBy { it.lowercase() }.eachCount()
|
||||
val clashing = bases.filterValues {
|
||||
counts.getValue(it.lowercase()) > 1 || it.equals(COMBINED_BACKUP_BASE, ignoreCase = true)
|
||||
}
|
||||
if (clashing.isEmpty()) break
|
||||
clashing.forEach { (id, base) -> bases[id] = "$base-$id" }
|
||||
}
|
||||
return calendars.map { calendar ->
|
||||
BackupTarget(
|
||||
key = calendar.id.toString(),
|
||||
calendarIds = setOf(calendar.id),
|
||||
baseName = bases.getValue(calendar.id),
|
||||
calendarName = calendar.displayName,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** `Work_2026-11-13_14-30-05.ics`: the series name and the local time of the run. */
|
||||
fun backupFileName(baseName: String, at: LocalDateTime): String =
|
||||
"${baseName}_${at.format(STAMP_FORMAT)}$ICS_EXTENSION"
|
||||
|
||||
/**
|
||||
* Plan one run: a new dated file per target, and which of the series' recorded
|
||||
* files ([ledger]) fall out of the newest [keep], the new one included. Recorded
|
||||
* files no longer in the folder ([existing]) are forgotten. Each target also
|
||||
* gets its stable copy, which retention never touches. A stable name is only
|
||||
* written when the file is absent or this series owns it — or a series of this
|
||||
* run that is moving off it (a rename hand-off).
|
||||
*/
|
||||
fun planBackupFiles(
|
||||
targets: List<BackupTarget>,
|
||||
ledger: BackupLedger,
|
||||
existing: Set<String>,
|
||||
at: LocalDateTime,
|
||||
keep: Int,
|
||||
): List<BackupFilePlan> {
|
||||
val retain = keep.coerceAtLeast(1)
|
||||
val runKeys = targets.map { it.key }.toSet()
|
||||
val owners = ledger.filterKeys { it.startsWith(LATEST_PREFIX) }
|
||||
.flatMap { (key, names) -> names.map { it to key.removePrefix(LATEST_PREFIX) } }
|
||||
.toMap()
|
||||
val currentLatest = targets.map { latestFileName(it.baseName) }.toSet()
|
||||
return targets.map { target ->
|
||||
val fileName = backupFileName(target.baseName, at)
|
||||
val latest = latestFileName(target.baseName)
|
||||
val earlier = ledger[target.key].orEmpty()
|
||||
.filter { it in existing && it != fileName && it != latest }
|
||||
.distinct()
|
||||
val kept = earlier.takeLast(retain - 1)
|
||||
val owner = owners[latest]
|
||||
val writable = latest !in existing || (owner != null && owner in runKeys)
|
||||
BackupFilePlan(
|
||||
target = target,
|
||||
fileName = fileName,
|
||||
keep = kept,
|
||||
prune = earlier.dropLast(kept.size),
|
||||
latestName = latest.takeIf { writable },
|
||||
staleLatest = ledger[latestLedgerKey(target.key)].orEmpty()
|
||||
.filter { it in existing && it != latest && it !in currentLatest },
|
||||
handOff = ledger[latestLedgerKey(target.key)].orEmpty()
|
||||
.filter { it in existing && it != latest && it in currentLatest },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable copies the current layout replaced, per ledger key: a calendar's own
|
||||
* copy once the combined file holds it, or the combined copy once each calendar
|
||||
* has its own. Left in place they would keep serving old data under a path
|
||||
* something may follow. A calendar taken out of the backup keeps its copy.
|
||||
*/
|
||||
fun replacedLatestCopies(
|
||||
targets: List<BackupTarget>,
|
||||
ledger: BackupLedger,
|
||||
existing: Set<String>,
|
||||
): Map<String, List<String>> {
|
||||
val runKeys = targets.map { it.key }.toSet()
|
||||
val covered = targets.flatMap { it.calendarIds }.map { it.toString() }.toSet()
|
||||
val currentLatest = targets.map { latestFileName(it.baseName) }.toSet()
|
||||
val combinedRun = COMBINED_SERIES in runKeys
|
||||
return ledger.filterKeys { it.startsWith(LATEST_PREFIX) }
|
||||
.mapKeys { it.key.removePrefix(LATEST_PREFIX) }
|
||||
.filterKeys { key ->
|
||||
key !in runKeys && (if (key == COMBINED_SERIES) !combinedRun else combinedRun && key in covered)
|
||||
}
|
||||
.mapValues { (_, names) -> names.filter { it in existing && it !in currentLatest } }
|
||||
.filterValues { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
/**
|
||||
* The ledger with [key]'s files set to [files] (empty drops the entry). Entries
|
||||
* of deleted calendars go ([liveKeys]); their files stay, unrecorded. Others
|
||||
* stay, so a calendar taken out of the backup still has its files pruned if it
|
||||
* is put back.
|
||||
*/
|
||||
fun withLedgerEntry(
|
||||
previous: BackupLedger,
|
||||
key: String,
|
||||
files: List<String>,
|
||||
liveKeys: Set<String>,
|
||||
): BackupLedger {
|
||||
val next = previous.filterKeys { entry ->
|
||||
val series = entry.removePrefix(LATEST_PREFIX)
|
||||
series in liveKeys || series == COMBINED_SERIES
|
||||
}.toMutableMap()
|
||||
next[key] = files.distinct()
|
||||
// A stable copy has one owner: claiming it takes it off any other series.
|
||||
if (key.startsWith(LATEST_PREFIX)) {
|
||||
next.keys.filter { it != key && it.startsWith(LATEST_PREFIX) }.forEach { other ->
|
||||
next[other] = next.getValue(other) - files.toSet()
|
||||
}
|
||||
}
|
||||
return next.filterValues { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
/** Stable copies recorded under any series but [key] — a hand-off that has already been claimed. */
|
||||
fun BackupLedger.latestHeldElsewhere(key: String): Set<String> =
|
||||
filterKeys { it.startsWith(LATEST_PREFIX) && it != latestLedgerKey(key) }.values.flatten().toSet()
|
||||
|
||||
/** `Work.ics`: the series' stable copy. */
|
||||
fun latestFileName(baseName: String): String = "$baseName$ICS_EXTENSION"
|
||||
|
||||
private const val LEDGER_ENTRY_SEP = "|"
|
||||
private const val LEDGER_KEY_SEP = "/"
|
||||
|
||||
/** Stores the ledger; both separators are characters no name this app writes can hold. */
|
||||
fun persistBackupLedger(ledger: BackupLedger): String? =
|
||||
ledger.filterValues { it.isNotEmpty() }.takeIf { it.isNotEmpty() }
|
||||
?.entries?.sortedBy { it.key }
|
||||
?.joinToString(LEDGER_ENTRY_SEP) { (key, names) -> (listOf(key) + names).joinToString(LEDGER_KEY_SEP) }
|
||||
|
||||
fun parseBackupLedger(stored: String?): BackupLedger =
|
||||
stored?.split(LEDGER_ENTRY_SEP).orEmpty().mapNotNull { entry ->
|
||||
val parts = entry.split(LEDGER_KEY_SEP)
|
||||
val names = parts.drop(1).filter { it.isNotEmpty() }
|
||||
parts.first().takeIf { it.isNotEmpty() && names.isNotEmpty() }?.let { it to names }
|
||||
}.toMap()
|
||||
|
||||
/** Closest offered retention count to [stored], so a stray value still shows a row. */
|
||||
fun normalizeBackupKeep(stored: Int): Int =
|
||||
BACKUP_KEEP_OPTIONS.minBy { kotlin.math.abs(it - stored) }
|
||||
|
||||
/**
|
||||
* Content hash of a series' file, per series key (#516), so a run started by a
|
||||
* change can tell whether the calendars it covers changed at all.
|
||||
*/
|
||||
typealias BackupFingerprints = Map<String, String>
|
||||
|
||||
/**
|
||||
* Hash of a series' file, leaving out the run's `DTSTAMP`s so equal events give
|
||||
* an equal hash, and bound to the stable copy's [fileName], so a copy under
|
||||
* another name never passes as current.
|
||||
*/
|
||||
fun backupFingerprint(ics: String, fileName: String): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
digest.update(fileName.toByteArray(Charsets.UTF_8))
|
||||
digest.update(LINE_BREAK)
|
||||
ics.lineSequence().filterNot { it.startsWith(DTSTAMP_PREFIX) }
|
||||
.forEach {
|
||||
digest.update(it.toByteArray(Charsets.UTF_8))
|
||||
digest.update(LINE_BREAK)
|
||||
}
|
||||
return digest.digest().take(FINGERPRINT_BYTES).joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
/** [previous] with [key] set to [fingerprint]; entries of deleted calendars go, as in [withLedgerEntry]. */
|
||||
fun withFingerprint(
|
||||
previous: BackupFingerprints,
|
||||
key: String,
|
||||
fingerprint: String,
|
||||
liveKeys: Set<String>,
|
||||
): BackupFingerprints =
|
||||
previous.filterKeys { it in liveKeys || it == COMBINED_SERIES } + (key to fingerprint)
|
||||
|
||||
private const val FINGERPRINT_BYTES = 16
|
||||
private const val DTSTAMP_PREFIX = "DTSTAMP:"
|
||||
private val LINE_BREAK = byteArrayOf('\n'.code.toByte())
|
||||
private const val FINGERPRINT_SEP = "="
|
||||
|
||||
fun persistBackupFingerprints(fingerprints: BackupFingerprints): String? =
|
||||
fingerprints.takeIf { it.isNotEmpty() }
|
||||
?.entries?.sortedBy { it.key }
|
||||
?.joinToString(LEDGER_ENTRY_SEP) { (key, hash) -> "$key$FINGERPRINT_SEP$hash" }
|
||||
|
||||
fun parseBackupFingerprints(stored: String?): BackupFingerprints =
|
||||
stored?.split(LEDGER_ENTRY_SEP).orEmpty().mapNotNull { entry ->
|
||||
val key = entry.substringBefore(FINGERPRINT_SEP, "")
|
||||
val hash = entry.substringAfter(FINGERPRINT_SEP, "")
|
||||
if (key.isEmpty() || hash.isEmpty()) null else key to hash
|
||||
}.toMap()
|
||||
|
||||
/**
|
||||
* Whether a run started by a change (#516) rewrites this series' stable copy:
|
||||
* only when its content moved on from what the copy holds (a rename changes
|
||||
* the name written into it), or the copy is missing. Never when the name belongs to a file of the user's,
|
||||
* nor when a [complete]-less listing doesn't show the copy: writing then would
|
||||
* add a duplicate beside it, so that is left to the scheduled run.
|
||||
*/
|
||||
fun BackupFilePlan.needsRefresh(
|
||||
fingerprint: String,
|
||||
stored: BackupFingerprints,
|
||||
listed: Set<String>,
|
||||
complete: Boolean,
|
||||
): Boolean {
|
||||
val latest = latestName ?: return false
|
||||
if (latest !in listed) return complete
|
||||
return stored[target.key] != fingerprint
|
||||
}
|
||||
|
||||
/**
|
||||
* The events in a fixed order, their lists sorted, so the same content always
|
||||
* writes the same file and hashes the same, however the provider returned it.
|
||||
* Standalone occurrences stay first, as [flattenOverrides] puts them.
|
||||
*/
|
||||
fun List<IcsEvent>.inBackupOrder(): List<IcsEvent> =
|
||||
map {
|
||||
it.copy(
|
||||
reminderMinutes = it.reminderMinutes.sorted(),
|
||||
exDates = it.exDates.sorted(),
|
||||
recurrenceDates = it.recurrenceDates.sorted(),
|
||||
)
|
||||
}.sortedWith(
|
||||
compareBy<IcsEvent>(
|
||||
{ it.overrideOf == null }, { it.start }, { it.uid }, { it.overrideAt }, { it.calendarName },
|
||||
{ it.toString() },
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,74 @@
|
||||
package de.jeanlucmakiola.calendula.domain.ics
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import kotlin.time.Duration.Companion.days
|
||||
|
||||
/**
|
||||
* The events a calendar copy (#325) inserts into its target, in the shape
|
||||
* `importEvents` takes: the source's rows flattened as [flattenOverrides] does
|
||||
* for a backup, minus cancelled series and one-offs, which would arrive live
|
||||
* (an insert can't be cancelled), and declined invitations and occurrences. Attendees and organizer are never read.
|
||||
*
|
||||
* Idempotent per snapshot: `importEvents` skips a UID the target already holds,
|
||||
* and an [IcsEvent.overrideOf] occurrence whose series it holds, so a re-run
|
||||
* keeps the earlier copy of an occurrence changed since rather than adding a
|
||||
* second one beside it.
|
||||
*/
|
||||
fun planCalendarCopy(
|
||||
rows: CalendarRows,
|
||||
exdateStamp: (occurrenceMillis: Long, isAllDay: Boolean) -> String,
|
||||
): List<ParsedIcsEvent> {
|
||||
val declinedSeries = rows.series.filter { it.declined }
|
||||
val declinedIds = declinedSeries.map { it.eventId }.toSet()
|
||||
val declinedSyncIds = declinedSeries.filter { it.syncId != null }.map { it.calendarId to it.syncId }.toSet()
|
||||
fun OverrideRow.ofDeclinedSeries() =
|
||||
originalId in declinedIds || (originalSyncId != null && (calendarId to originalSyncId) in declinedSyncIds)
|
||||
val live = CalendarRows(
|
||||
series = rows.series.filterNot { it.event.status == EventStatus.Cancelled },
|
||||
// A declined occurrence is as good as deleted here, and so is one of a
|
||||
// declined series the user didn't accept on its own.
|
||||
overrides = rows.overrides.map {
|
||||
if (it.declined || (it.ofDeclinedSeries() && !it.accepted)) {
|
||||
it.copy(event = it.event.copy(status = EventStatus.Cancelled))
|
||||
} else {
|
||||
it
|
||||
}
|
||||
},
|
||||
)
|
||||
// A declined series still hands its accepted occurrences on before it goes.
|
||||
// The flattened series follow the standalone occurrences, one per row in order.
|
||||
val flat = flattenOverrides(live, exdateStamp)
|
||||
val standalone = flat.size - live.series.size
|
||||
return flat.filterIndexed { i, _ -> i < standalone || live.series[i - standalone].eventId !in declinedIds }
|
||||
.map { it.toParsedIcsEvent() }
|
||||
}
|
||||
|
||||
/**
|
||||
* [IcsEvent] read back as if [IcsWriter] had written it and [IcsParser] had read
|
||||
* it, without the text in between. Unlike that round trip it keeps a one-off's
|
||||
* own time zone (the writer pins it to UTC) and the event's colour (not part of
|
||||
* the file format).
|
||||
*/
|
||||
fun IcsEvent.toParsedIcsEvent(): ParsedIcsEvent = ParsedIcsEvent(
|
||||
uid = uid,
|
||||
summary = summary,
|
||||
start = start,
|
||||
// RFC 5545 §3.6.1: an all-day event lasts at least its one day; zero-length
|
||||
// it expands into no instance at all.
|
||||
end = if (isAllDay && end <= start) start + 1.days else end,
|
||||
isAllDay = isAllDay,
|
||||
zoneId = zoneId,
|
||||
recurrenceRule = recurrenceRule?.takeIf { it.isNotBlank() }?.removePrefix("RRULE:"),
|
||||
recurrenceDates = recurrenceDates.filter { it.isNotBlank() }.distinct(),
|
||||
location = location,
|
||||
description = description,
|
||||
// The writer emits only lead times; a negative one would be an alarm after the start.
|
||||
reminderMinutes = reminderMinutes.filter { it >= 0 }.distinct(),
|
||||
status = status,
|
||||
availability = availability,
|
||||
accessLevel = accessLevel,
|
||||
color = color,
|
||||
overrideOf = overrideOf,
|
||||
overrideAt = overrideAt,
|
||||
exDates = if (recurs) exDates.filter { it.isNotBlank() }.distinct() else emptyList(),
|
||||
)
|
||||
@@ -0,0 +1,99 @@
|
||||
package de.jeanlucmakiola.calendula.domain.ics
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* A master or one-off Events row of a calendar being backed up or copied, with
|
||||
* the provider keys its exception rows point back through.
|
||||
*/
|
||||
data class MasterRow(
|
||||
val eventId: Long,
|
||||
val calendarId: Long,
|
||||
val syncId: String?,
|
||||
val event: IcsEvent,
|
||||
/** The user declined this invitation. */
|
||||
val declined: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* An exception row: a modified or cancelled occurrence of the series named by
|
||||
* [originalId] / [originalSyncId] in [calendarId], standing in for the
|
||||
* occurrence that would have started at [originalInstance].
|
||||
*/
|
||||
data class OverrideRow(
|
||||
val calendarId: Long,
|
||||
val originalId: Long?,
|
||||
val originalSyncId: String?,
|
||||
val originalInstance: Instant,
|
||||
val event: IcsEvent,
|
||||
/** The user declined this occurrence. */
|
||||
val declined: Boolean = false,
|
||||
/** The user accepted (or tentatively accepted) this occurrence on its own. */
|
||||
val accepted: Boolean = false,
|
||||
) {
|
||||
val isCancelled: Boolean get() = event.status == EventStatus.Cancelled
|
||||
}
|
||||
|
||||
/** Every event row a backup or copy reads: series/one-offs and their exception rows. */
|
||||
data class CalendarRows(
|
||||
val series: List<MasterRow>,
|
||||
val overrides: List<OverrideRow>,
|
||||
)
|
||||
|
||||
/**
|
||||
* [rows] as plain events with no exception rows left, the shape both a backup
|
||||
* file and a copied calendar need: neither can hold an exception row, since one
|
||||
* only attaches to its series through a `_sync_id` (and an `.ics` restore skips
|
||||
* `RECURRENCE-ID` overrides).
|
||||
*
|
||||
* Every override excludes its occurrence from the series (`EXDATE`), and a
|
||||
* modified one comes along as a standalone event at its own times, marked
|
||||
* [IcsEvent.overrideOf] its series — what Calendula itself writes for "only this
|
||||
* event" on an unsynced series. Its UID is derived from the series' UID and the
|
||||
* occurrence, so it is the same on every run. Overrides whose series isn't
|
||||
* among the rows are left out; the provider doesn't show them either.
|
||||
*
|
||||
* Standalone occurrences come first; `importEvents` enforces that order for
|
||||
* any file, so an import cut short leaves at worst an occurrence missing its
|
||||
* series, never a series missing an occurrence.
|
||||
*
|
||||
* [exdateStamp] formats an occurrence as an `EXDATE` entry in the provider's
|
||||
* shape, the same one Calendula's own exclusions use. A cancelled series'
|
||||
* modified occurrences stay cancelled.
|
||||
*/
|
||||
fun flattenOverrides(
|
||||
rows: CalendarRows,
|
||||
exdateStamp: (occurrenceMillis: Long, isAllDay: Boolean) -> String,
|
||||
): List<IcsEvent> {
|
||||
val byId = rows.series.associateBy { it.eventId }
|
||||
// Sync ids are only unique within an account, so the link is per calendar.
|
||||
val bySyncId = rows.series.filter { it.syncId != null }.associateBy { it.calendarId to it.syncId }
|
||||
val excluded = mutableMapOf<Long, MutableList<String>>()
|
||||
val detached = mutableListOf<Pair<Long, IcsEvent>>()
|
||||
for (override in rows.overrides) {
|
||||
val parent = override.originalId?.let(byId::get)?.takeIf { it.calendarId == override.calendarId }
|
||||
?: override.originalSyncId?.let { bySyncId[override.calendarId to it] }
|
||||
?: continue
|
||||
// An exception to a one-off overrides nothing a plain event could carry.
|
||||
if (!parent.event.recurs) continue
|
||||
val occurrence = override.originalInstance.toEpochMilliseconds()
|
||||
excluded.getOrPut(parent.eventId) { mutableListOf() } += exdateStamp(occurrence, parent.event.isAllDay)
|
||||
if (override.isCancelled || parent.event.status == EventStatus.Cancelled) continue
|
||||
detached += override.calendarId to override.event.copy(
|
||||
uid = "$occurrence-${parent.event.uid}",
|
||||
recurrenceRule = null,
|
||||
recurrenceDates = emptyList(),
|
||||
exDates = emptyList(),
|
||||
overrideOf = parent.event.uid,
|
||||
overrideAt = override.originalInstance,
|
||||
)
|
||||
}
|
||||
// Two exception rows for one occurrence would derive the same UID; the same
|
||||
// series in two calendars legitimately does.
|
||||
return detached.distinctBy { (calendarId, event) -> calendarId to event.uid }.map { it.second } +
|
||||
rows.series.map { s ->
|
||||
val extra = excluded[s.eventId].orEmpty()
|
||||
if (extra.isEmpty()) s.event else s.event.copy(exDates = (s.event.exDates + extra).distinct())
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.domain.ics
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||
import de.jeanlucmakiola.calendula.domain.Availability
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import kotlin.time.Instant
|
||||
@@ -36,8 +37,25 @@ data class IcsEvent(
|
||||
val availability: Availability = Availability.Busy,
|
||||
/** Source calendar name, emitted as `X-CALENDULA-CALENDAR` so a combined backup can fan back out. */
|
||||
val calendarName: String? = null,
|
||||
/** Extra occurrences (`RDATE`), in the same shape as [exDates]. */
|
||||
val recurrenceDates: List<String> = emptyList(),
|
||||
/** Carried by a calendar copy, not written to a file. */
|
||||
val accessLevel: AccessLevel = AccessLevel.Default,
|
||||
/** The event's own colour, null when it shows its calendar's; carried by a calendar copy, not written to a file. */
|
||||
val color: Int? = null,
|
||||
/**
|
||||
* For a modified occurrence written out on its own ([flattenOverrides]): the
|
||||
* UID of the series it replaces an occurrence of, as `X-CALENDULA-OVERRIDE-OF`.
|
||||
*/
|
||||
val overrideOf: String? = null,
|
||||
/** The occurrence of [overrideOf] this event replaces, as `X-CALENDULA-OVERRIDE-AT`. */
|
||||
val overrideAt: Instant? = null,
|
||||
)
|
||||
|
||||
/** Whether this is a series: by rule, or by listed dates alone. */
|
||||
val IcsEvent.recurs: Boolean
|
||||
get() = !recurrenceRule.isNullOrBlank() || recurrenceDates.isNotEmpty()
|
||||
|
||||
/**
|
||||
* The UID to export for a provider event. A row that already carries a UID
|
||||
* (`Events.UID_2445`) keeps it; otherwise we synthesise a **stable** one from
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.domain.ics
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||
import de.jeanlucmakiola.calendula.domain.Availability
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.zoneOrNull
|
||||
@@ -45,6 +46,17 @@ data class ParsedIcsEvent(
|
||||
val exDates: List<String> = emptyList(),
|
||||
/** True when this came from a `VTODO` rather than a `VEVENT`. */
|
||||
val isTask: Boolean = false,
|
||||
/**
|
||||
* The UID of the series this standalone occurrence replaces one occurrence
|
||||
* of (`X-CALENDULA-OVERRIDE-OF`). An import skips it where that series
|
||||
* already is, since the series there carries its own override.
|
||||
*/
|
||||
val overrideOf: String? = null,
|
||||
/** The occurrence of [overrideOf] this event replaces (`X-CALENDULA-OVERRIDE-AT`). */
|
||||
val overrideAt: Instant? = null,
|
||||
/** Extra occurrences (`RDATE`), in the same shape as [exDates]. */
|
||||
val recurrenceDates: List<String> = emptyList(),
|
||||
val accessLevel: AccessLevel = AccessLevel.Default,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -212,7 +224,10 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
||||
var calendarColor: Int? = null
|
||||
val reminders = mutableListOf<Int>()
|
||||
val exDateLines = mutableListOf<IcsContentLine>()
|
||||
val rDateLines = mutableListOf<IcsContentLine>()
|
||||
var skipAsOverride = false
|
||||
var overrideOf: String? = null
|
||||
var overrideAt: Instant? = null
|
||||
|
||||
var i = 0
|
||||
while (i < body.size) {
|
||||
@@ -233,6 +248,7 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
||||
"DURATION" -> duration = line.value.trim()
|
||||
"RRULE" -> rrule = line.value.trim().ifEmpty { null }
|
||||
"EXDATE" -> exDateLines.add(line)
|
||||
"RDATE" -> rDateLines.add(line)
|
||||
"LOCATION" -> location = unescapeText(line.value).ifEmpty { null }
|
||||
"DESCRIPTION" -> description = unescapeText(line.value).ifEmpty { null }
|
||||
"STATUS" -> status = mapIcsStatus(line.value)
|
||||
@@ -241,6 +257,8 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
||||
else Availability.Busy
|
||||
"RECURRENCE-ID" -> skipAsOverride = true
|
||||
"ATTENDEE" -> warnings.add(IcsParseWarning.AttendeesIgnored)
|
||||
"X-CALENDULA-OVERRIDE-OF" -> overrideOf = unescapeText(line.value).trim().ifEmpty { null }
|
||||
"X-CALENDULA-OVERRIDE-AT" -> overrideAt = parseBasicDateTime(line.value.trim())?.toInstant(TimeZone.UTC)
|
||||
"X-CALENDULA-CALENDAR" ->
|
||||
calendarName = unescapeText(line.value).trim().ifEmpty { calendarName }
|
||||
// Fossify names the source calendar per event; it never writes
|
||||
@@ -279,6 +297,7 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
||||
val recurrence = sanitizeRrule(rrule)
|
||||
if (recurrence.repaired) warnings.add(IcsParseWarning.RecurrenceRuleRepaired)
|
||||
val end = resolveEnd(start, dtEnd, duration)
|
||||
val recurrenceDates = normalizeExDates(rDateLines, start)
|
||||
return ParsedIcsEvent(
|
||||
uid = uid,
|
||||
summary = summary,
|
||||
@@ -294,12 +313,15 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
||||
availability = availability,
|
||||
calendarName = calendarName ?: category,
|
||||
color = eventColor ?: calendarColor,
|
||||
exDates = if (recurrence.rule == null) {
|
||||
exDates = if (recurrence.rule == null && recurrenceDates.isEmpty()) {
|
||||
emptyList()
|
||||
} else {
|
||||
normalizeExDates(exDateLines, start)
|
||||
},
|
||||
recurrenceDates = recurrenceDates,
|
||||
isTask = isTask,
|
||||
overrideOf = overrideOf,
|
||||
overrideAt = overrideAt,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -334,13 +356,16 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
||||
* still means the day it spells out; resolving it to an instant first and
|
||||
* reading *that* back in UTC would roll a midnight east of UTC onto the day
|
||||
* before and exclude an occurrence that doesn't exist.
|
||||
*
|
||||
* `RDATE` shares the value form and is read here too.
|
||||
*/
|
||||
private fun normalizeExDates(lines: List<IcsContentLine>, start: IcsDateTime): List<String> {
|
||||
if (lines.isEmpty()) return emptyList()
|
||||
val startZone = zoneOrNull(start.zoneId) ?: deviceZone
|
||||
val startTime = start.instant.toLocalDateTime(startZone).time
|
||||
return lines
|
||||
.flatMap { line -> line.value.split(',').map { line to it.trim() } }
|
||||
// An RDATE PERIOD (`start/end`) counts by its start.
|
||||
.flatMap { line -> line.value.split(',').map { line to it.trim().substringBefore('/') } }
|
||||
.mapNotNull { (line, token) ->
|
||||
when {
|
||||
token.isEmpty() -> null
|
||||
|
||||
@@ -25,12 +25,14 @@ const val ICS_PROD_ID: String = "-//Jean-Luc Makiola//Calendula//EN"
|
||||
*/
|
||||
class IcsWriter(private val prodId: String = ICS_PROD_ID) {
|
||||
|
||||
fun writeCalendar(events: List<IcsEvent>, dtStamp: Instant): String {
|
||||
/** [calendarName], when given, names the whole file (`X-WR-CALNAME`), as a one-calendar backup does. */
|
||||
fun writeCalendar(events: List<IcsEvent>, dtStamp: Instant, calendarName: String? = null): String {
|
||||
val lines = buildList {
|
||||
add("BEGIN:VCALENDAR")
|
||||
add("VERSION:2.0")
|
||||
add("PRODID:$prodId")
|
||||
add("CALSCALE:GREGORIAN")
|
||||
calendarName?.takeIf { it.isNotBlank() }?.let { add("X-WR-CALNAME:${escapeText(it)}") }
|
||||
events.forEach { appendEvent(it, dtStamp) }
|
||||
add("END:VCALENDAR")
|
||||
}
|
||||
@@ -45,6 +47,7 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) {
|
||||
appendTimes(event)
|
||||
event.recurrenceRule?.takeIf { it.isNotBlank() }
|
||||
?.let { add("RRULE:${it.removePrefix("RRULE:")}") }
|
||||
appendRecurrenceDates(event)
|
||||
appendExDates(event)
|
||||
event.location?.takeIf { it.isNotBlank() }
|
||||
?.let { add("LOCATION:${escapeText(it)}") }
|
||||
@@ -54,6 +57,9 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) {
|
||||
add("TRANSP:${transpValue(event.availability)}")
|
||||
event.calendarName?.takeIf { it.isNotBlank() }
|
||||
?.let { add("X-CALENDULA-CALENDAR:${escapeText(it)}") }
|
||||
event.overrideOf?.takeIf { it.isNotBlank() }
|
||||
?.let { add("X-CALENDULA-OVERRIDE-OF:${escapeText(it)}") }
|
||||
event.overrideAt?.let { add("X-CALENDULA-OVERRIDE-AT:${it.toUtcBasic()}") }
|
||||
event.reminderMinutes.filter { it >= 0 }.distinct().forEach { minutes ->
|
||||
appendAlarm(minutes, event.summary)
|
||||
}
|
||||
@@ -66,7 +72,7 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) {
|
||||
add("DTEND;VALUE=DATE:${event.end.toLocalDateTime(TimeZone.UTC).date.toBasic()}")
|
||||
}
|
||||
// Recurring: anchor to wall-clock in the event's own zone.
|
||||
event.recurrenceRule?.isNotBlank() == true -> {
|
||||
event.recurs -> {
|
||||
val zone = zoneOrNull(event.zoneId)
|
||||
if (zone != null) {
|
||||
add("DTSTART;TZID=${event.zoneId}:${event.start.toLocalDateTime(zone).toBasic()}")
|
||||
@@ -83,6 +89,14 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Extra occurrences: the same UTC stamps / `VALUE=DATE` days as [appendExDates]. */
|
||||
private fun MutableList<String>.appendRecurrenceDates(event: IcsEvent) {
|
||||
val stamps = event.recurrenceDates.filter { it.isNotBlank() }.distinct()
|
||||
if (stamps.isEmpty()) return
|
||||
val prefix = if (event.isAllDay) "RDATE;VALUE=DATE:" else "RDATE:"
|
||||
add(prefix + stamps.joinToString(","))
|
||||
}
|
||||
|
||||
/**
|
||||
* The series' deleted occurrences. RFC 5545 ties `EXDATE`'s value type to
|
||||
* `DTSTART`'s, so an all-day series needs the explicit `VALUE=DATE` — without
|
||||
@@ -90,7 +104,7 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) {
|
||||
* recurring event: an exclusion names an occurrence, and a one-off has none.
|
||||
*/
|
||||
private fun MutableList<String>.appendExDates(event: IcsEvent) {
|
||||
if (event.recurrenceRule.isNullOrBlank()) return
|
||||
if (!event.recurs) return
|
||||
val stamps = event.exDates.filter { it.isNotBlank() }.distinct()
|
||||
if (stamps.isEmpty()) return
|
||||
val prefix = if (event.isAllDay) "EXDATE;VALUE=DATE:" else "EXDATE:"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,12 +13,12 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.FileDownload
|
||||
import androidx.compose.material.icons.filled.FileUpload
|
||||
import androidx.compose.material.icons.filled.Schedule
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
@@ -49,11 +49,13 @@ import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.domain.backup.BACKUP_KEEP_OPTIONS
|
||||
import de.jeanlucmakiola.calendula.domain.backup.BackupSelection
|
||||
import de.jeanlucmakiola.calendula.domain.backup.isBackedUpByDefault
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarCheckGroups
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
|
||||
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.DialogAmountField
|
||||
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
||||
@@ -61,8 +63,9 @@ import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedHint
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
|
||||
import de.jeanlucmakiola.floret.components.GroupedSwitchRow
|
||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.locale.relativeTimeAgo
|
||||
import java.time.LocalDate
|
||||
|
||||
@@ -75,7 +78,7 @@ private val RESTORE_MIME_TYPES = arrayOf(
|
||||
)
|
||||
|
||||
/**
|
||||
* Backup & restore (#69): `.ics` export and restore for local calendars, with
|
||||
* Backup & restore (#69): `.ics` export and restore, with
|
||||
* optional automatic backup. Shares [CalendarsViewModel] with the manager.
|
||||
*
|
||||
* A full-screen destination hoisted in `CalendarHost`; [onBack] pops it,
|
||||
@@ -87,19 +90,26 @@ fun BackupScreen(
|
||||
onImport: (Uri) -> Unit,
|
||||
visible: Boolean = true,
|
||||
viewModel: CalendarsViewModel = hiltViewModel(),
|
||||
copyViewModel: CopyEventsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.backupState.collectAsStateWithLifecycle()
|
||||
val copyRun by copyViewModel.run.collectAsStateWithLifecycle()
|
||||
var showCopy by rememberSaveable { mutableStateOf(false) }
|
||||
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
|
||||
val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle()
|
||||
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
// Exports everything eligible (null); the per-calendar selector owns its
|
||||
// own launcher.
|
||||
// The one-calendar shortcut; the per-calendar selector owns its own launcher.
|
||||
var shortcutExportId by rememberSaveable { mutableStateOf<Long?>(null) }
|
||||
val createBackup = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument("text/calendar"),
|
||||
) { uri -> uri?.let { viewModel.exportBackup(it, null) } }
|
||||
) { uri ->
|
||||
val id = shortcutExportId
|
||||
if (uri != null && id != null) viewModel.exportBackup(uri, setOf(id))
|
||||
shortcutExportId = null
|
||||
}
|
||||
var showExportPicker by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
// Restore runs the picked file through the normal .ics import flow.
|
||||
@@ -112,6 +122,9 @@ fun BackupScreen(
|
||||
contract = ActivityResultContracts.OpenDocumentTree(),
|
||||
) { uri -> uri?.let(viewModel::setAutoBackupFolder) }
|
||||
var showInterval by remember { mutableStateOf(false) }
|
||||
var showBackupCalendars by rememberSaveable { mutableStateOf(false) }
|
||||
var selectionBefore by remember { mutableStateOf<BackupSelection?>(null) }
|
||||
var showKeep by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val backupFailedText = stringResource(R.string.calendars_backup_failed)
|
||||
LaunchedEffect(backupResult) {
|
||||
@@ -149,19 +162,46 @@ fun BackupScreen(
|
||||
is BackupUiState.Ready -> BackupContent(
|
||||
exportable = s.exportable,
|
||||
canImport = s.canImport,
|
||||
canCopy = s.canCopy,
|
||||
copyRun = copyRun,
|
||||
onCopy = { showCopy = true },
|
||||
autoBackup = autoBackup,
|
||||
viewModel = viewModel,
|
||||
onRestore = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
|
||||
onPickFolder = { runCatching { pickFolder.launch(null) } },
|
||||
onExportAll = {
|
||||
onExportOnly = {
|
||||
shortcutExportId = s.exportable.singleOrNull()?.id
|
||||
runCatching { createBackup.launch(defaultBackupName()) }
|
||||
},
|
||||
onExportPick = { showExportPicker = true },
|
||||
onEditInterval = { showInterval = true },
|
||||
onEditCalendars = {
|
||||
selectionBefore = autoBackup.selection
|
||||
showBackupCalendars = true
|
||||
},
|
||||
onEditKeep = { showKeep = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Not gated on Ready: a running copy keeps its progress and Cancel whatever the list does.
|
||||
if (showCopy) {
|
||||
val calendars by copyViewModel.calendars.collectAsStateWithLifecycle()
|
||||
val selection by copyViewModel.selection.collectAsStateWithLifecycle()
|
||||
CopyEventsSheet(
|
||||
calendars = calendars,
|
||||
selection = selection,
|
||||
run = copyRun,
|
||||
onSelectSource = copyViewModel::setSource,
|
||||
onSelectTarget = copyViewModel::setTarget,
|
||||
onStart = copyViewModel::start,
|
||||
onCancel = copyViewModel::cancel,
|
||||
onDismiss = {
|
||||
showCopy = false
|
||||
copyViewModel.clearFinished()
|
||||
},
|
||||
)
|
||||
}
|
||||
// Gated on Ready rather than resetting the flag when the state leaves it:
|
||||
// flipping it here would be a write during composition.
|
||||
(state as? BackupUiState.Ready)?.let { ready ->
|
||||
@@ -172,6 +212,34 @@ fun BackupScreen(
|
||||
onDismiss = { showExportPicker = false },
|
||||
)
|
||||
}
|
||||
if (showBackupCalendars) {
|
||||
AutoBackupCalendarPicker(
|
||||
calendars = ready.exportable,
|
||||
selection = autoBackup.selection,
|
||||
onCheckedChange = viewModel::setAutoBackupCalendar,
|
||||
onDismiss = {
|
||||
showBackupCalendars = false
|
||||
viewModel.autoBackupCalendarsChosen(selectionBefore ?: autoBackup.selection)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
if (showKeep) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.calendars_auto_backup_keep),
|
||||
header = {
|
||||
GroupedHint(
|
||||
stringResource(R.string.calendars_auto_backup_keep_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
},
|
||||
predictiveBack = true,
|
||||
options = BACKUP_KEEP_OPTIONS,
|
||||
selected = autoBackup.keep,
|
||||
label = { backupKeepLabel(it) },
|
||||
onSelect = viewModel::setAutoBackupKeep,
|
||||
onDismiss = { showKeep = false },
|
||||
)
|
||||
}
|
||||
if (showInterval) {
|
||||
BackupIntervalDialog(
|
||||
@@ -197,7 +265,7 @@ private fun BackupLoading() {
|
||||
private fun defaultBackupName(): String = "calendula-backup-${LocalDate.now()}.ics"
|
||||
|
||||
/**
|
||||
* The working screen: export (when there are local calendars to export), restore,
|
||||
* The working screen: export (when any calendar can be backed up), restore,
|
||||
* and the automatic-backup block. Restore is always on screen — when nothing can
|
||||
* receive the events it says so and routes to the calendar manager rather than
|
||||
* disappearing, which is what #304 reported as an invisible button.
|
||||
@@ -206,13 +274,18 @@ private fun defaultBackupName(): String = "calendula-backup-${LocalDate.now()}.i
|
||||
private fun BackupContent(
|
||||
exportable: List<CalendarSource>,
|
||||
canImport: Boolean,
|
||||
canCopy: Boolean,
|
||||
copyRun: CopyRunState,
|
||||
onCopy: () -> Unit,
|
||||
autoBackup: AutoBackupUiState,
|
||||
viewModel: CalendarsViewModel,
|
||||
onRestore: () -> Unit,
|
||||
onPickFolder: () -> Unit,
|
||||
onExportAll: () -> Unit,
|
||||
onExportOnly: () -> Unit,
|
||||
onExportPick: () -> Unit,
|
||||
onEditInterval: () -> Unit,
|
||||
onEditCalendars: () -> Unit,
|
||||
onEditKeep: () -> Unit,
|
||||
) {
|
||||
val manageCalendars = LocalManageCalendars.current
|
||||
// Restore never depends on export eligibility, and never disappears: when no
|
||||
@@ -229,10 +302,11 @@ private fun BackupContent(
|
||||
GroupedHint(restoreSummary, style = MaterialTheme.typography.bodySmall)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_restore_action),
|
||||
position = Position.Alone,
|
||||
position = if (copyRun != CopyRunState.Idle) Position.Top else Position.Alone,
|
||||
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
||||
onClick = onRestoreClick,
|
||||
)
|
||||
if (copyRun != CopyRunState.Idle) CopyEventsRow(copyRun, Position.Bottom, onCopy)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -242,7 +316,7 @@ private fun BackupContent(
|
||||
position = Position.Top,
|
||||
leading = { LeadingAvatar(Icons.Default.FileDownload) },
|
||||
// A single exportable calendar skips the selector.
|
||||
onClick = if (exportable.size == 1) onExportAll else onExportPick,
|
||||
onClick = if (exportable.size == 1) onExportOnly else onExportPick,
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_restore_action),
|
||||
@@ -251,6 +325,8 @@ private fun BackupContent(
|
||||
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
||||
onClick = onRestoreClick,
|
||||
)
|
||||
// A running or finished copy keeps its row, the way back to its sheet.
|
||||
if (canCopy || copyRun != CopyRunState.Idle) CopyEventsRow(copyRun, Position.Middle, onCopy)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_auto_backup),
|
||||
summary = stringResource(R.string.calendars_auto_backup_hint),
|
||||
@@ -272,22 +348,83 @@ private fun BackupContent(
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_auto_backup_interval),
|
||||
summary = backupIntervalLabel(autoBackup.intervalMinutes),
|
||||
position = Position.Bottom,
|
||||
position = Position.Middle,
|
||||
onClick = onEditInterval,
|
||||
)
|
||||
GroupedHint(backupStatusText(autoBackup.status), style = MaterialTheme.typography.bodySmall)
|
||||
GroupedSwitchRow(
|
||||
title = stringResource(R.string.calendars_auto_backup_on_change),
|
||||
summary = stringResource(R.string.calendars_auto_backup_on_change_summary),
|
||||
checked = autoBackup.onChange,
|
||||
onCheckedChange = viewModel::setAutoBackupOnChange,
|
||||
position = Position.Middle,
|
||||
)
|
||||
val selectedCount = exportable.count(autoBackup.selection::selects)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_auto_backup_calendars),
|
||||
summary = if (selectedCount == 0) {
|
||||
stringResource(R.string.calendars_auto_backup_calendars_none)
|
||||
} else {
|
||||
pluralStringResource(R.plurals.calendars_auto_backup_calendars_count, selectedCount, selectedCount)
|
||||
},
|
||||
position = Position.Middle,
|
||||
onClick = onEditCalendars,
|
||||
)
|
||||
GroupedSwitchRow(
|
||||
title = stringResource(R.string.calendars_auto_backup_combined),
|
||||
summary = stringResource(
|
||||
if (autoBackup.combined) {
|
||||
R.string.calendars_auto_backup_combined_on
|
||||
} else {
|
||||
R.string.calendars_auto_backup_combined_off
|
||||
},
|
||||
),
|
||||
checked = autoBackup.combined,
|
||||
onCheckedChange = viewModel::setAutoBackupCombined,
|
||||
position = Position.Middle,
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_auto_backup_keep),
|
||||
summary = backupKeepLabel(autoBackup.keep),
|
||||
position = Position.Bottom,
|
||||
onClick = onEditKeep,
|
||||
)
|
||||
GroupedHint(
|
||||
if (selectedCount == 0) {
|
||||
stringResource(R.string.calendars_auto_backup_nothing_selected)
|
||||
} else {
|
||||
backupStatusText(autoBackup.status)
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CopyEventsRow(copyRun: CopyRunState, position: Position, onClick: () -> Unit) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_copy_action),
|
||||
summary = when (copyRun) {
|
||||
is CopyRunState.Running -> copyProgressText(copyRun)
|
||||
is CopyRunState.Done -> stringResource(copyRun.titleRes)
|
||||
CopyRunState.Cancelled -> stringResource(R.string.calendars_copy_cancelled)
|
||||
CopyRunState.Failed -> stringResource(R.string.calendars_copy_failed)
|
||||
CopyRunState.Idle -> stringResource(R.string.calendars_copy_summary)
|
||||
},
|
||||
position = position,
|
||||
leading = { LeadingAvatar(Icons.Default.ContentCopy) },
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose which local calendars to include in a one-time `.ics` export. Defaults
|
||||
* to all selected; the Export action opens the SAF save dialog and hands back
|
||||
* Choose which calendars to include in a one-time `.ics` export. Starts on the
|
||||
* calendars a backup covers by default, or on all of them when none is; the Export action opens the SAF save dialog and hands back
|
||||
* the picked file with the chosen calendar ids.
|
||||
*/
|
||||
@Composable
|
||||
private fun ExportCalendarPicker(
|
||||
calendars: List<CalendarSource>,
|
||||
onExport: (Uri, Set<Long>?) -> Unit,
|
||||
onExport: (Uri, Set<Long>) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
// Deliberately not keyed on [calendars]: that list is observer-driven, so
|
||||
@@ -298,7 +435,8 @@ private fun ExportCalendarPicker(
|
||||
restore = { it.toSet() },
|
||||
),
|
||||
) {
|
||||
mutableStateOf(calendars.map { it.id }.toSet())
|
||||
val defaults = calendars.filter { it.isBackedUpByDefault }.ifEmpty { calendars }
|
||||
mutableStateOf(defaults.map { it.id }.toSet())
|
||||
}
|
||||
val createBackup = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument("text/calendar"),
|
||||
@@ -315,29 +453,16 @@ private fun ExportCalendarPicker(
|
||||
predictiveBack = true,
|
||||
) {
|
||||
GroupedHint(stringResource(R.string.calendars_export_hint), style = MaterialTheme.typography.bodySmall)
|
||||
calendars.forEachIndexed { index, calendar ->
|
||||
val isSelected = calendar.id in selected
|
||||
GroupedRow(
|
||||
title = calendar.displayName.orUnnamed(),
|
||||
summary = calendar.description,
|
||||
position = positionOf(index, calendars.size),
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
trailing = {
|
||||
Checkbox(
|
||||
checked = isSelected,
|
||||
onCheckedChange = { checked ->
|
||||
selected = if (checked) selected + calendar.id else selected - calendar.id
|
||||
},
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
selected = if (isSelected) selected - calendar.id else selected + calendar.id
|
||||
},
|
||||
)
|
||||
}
|
||||
CalendarCheckGroups(
|
||||
calendars = calendars,
|
||||
isChecked = { it.id in selected },
|
||||
onCheckedChange = { calendar, checked ->
|
||||
selected = if (checked) selected + calendar.id else selected - calendar.id
|
||||
},
|
||||
)
|
||||
Button(
|
||||
onClick = {
|
||||
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
||||
runCatching { createBackup.launch(defaultBackupName()) }
|
||||
},
|
||||
enabled = selected.isNotEmpty(),
|
||||
modifier = Modifier
|
||||
@@ -349,6 +474,39 @@ private fun ExportCalendarPicker(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose which calendars the automatic backup covers (#147). Each tick is saved
|
||||
* at once, as a deviation from the default, so there is nothing to confirm.
|
||||
*/
|
||||
@Composable
|
||||
private fun AutoBackupCalendarPicker(
|
||||
calendars: List<CalendarSource>,
|
||||
selection: BackupSelection,
|
||||
onCheckedChange: (CalendarSource, Boolean) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.calendars_auto_backup_calendars),
|
||||
onDismiss = onDismiss,
|
||||
predictiveBack = true,
|
||||
) {
|
||||
GroupedHint(
|
||||
stringResource(R.string.calendars_auto_backup_calendars_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
CalendarCheckGroups(
|
||||
calendars = calendars,
|
||||
isChecked = selection::selects,
|
||||
onCheckedChange = onCheckedChange,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** "Only the latest" / "The latest 3". */
|
||||
@Composable
|
||||
private fun backupKeepLabel(keep: Int): String =
|
||||
pluralStringResource(R.plurals.calendars_auto_backup_keep_count, keep, keep)
|
||||
|
||||
/** Readable name of the persisted backup folder, resolved from its tree Uri. */
|
||||
@Composable
|
||||
private fun rememberFolderName(uriString: String?): String? {
|
||||
|
||||
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.ui.calendars
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.backup.isBackupCandidate
|
||||
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
||||
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
||||
|
||||
@@ -23,31 +24,52 @@ sealed interface BackupUiState {
|
||||
data class Ready(
|
||||
val exportable: List<CalendarSource>,
|
||||
val canImport: Boolean,
|
||||
/** Some calendar can be copied into a different one (#325). */
|
||||
val canCopy: Boolean = false,
|
||||
) : BackupUiState
|
||||
}
|
||||
|
||||
/**
|
||||
* What the screen can offer for this calendar list.
|
||||
*
|
||||
* Export covers the app's own local calendars; managed special-dates mirrors are
|
||||
* rebuilt from contacts, so they are excluded. Restore can target anything the
|
||||
* import picker offers ([isEventTarget]).
|
||||
* Export can read any calendar (#147) — the local ones are only its default;
|
||||
* managed special-dates mirrors are rebuilt from contacts, so they are excluded
|
||||
* ([isBackupCandidate]). Restore can target anything the import picker offers
|
||||
* ([isEventTarget]).
|
||||
*
|
||||
* A failure is raised only when *neither* is possible — deliberately not
|
||||
* [calendarListFailure] on its own, which would call an all-hidden device a
|
||||
* failure while its local calendars are still perfectly exportable. When both
|
||||
* halves are dead the list itself usually says why (no calendars, everything
|
||||
* switched off); [FailureReason.NoImportTarget] covers the remaining case, where
|
||||
* calendars exist and are visible but every one of them is read-only, managed or
|
||||
* not synced to the device.
|
||||
* failure while its calendars are still perfectly exportable. Since export
|
||||
* reads hidden and read-only calendars too, both halves are dead only when there
|
||||
* is no calendar at all, or every one is managed or not synced to the device
|
||||
* ([FailureReason.NoImportTarget] unless the list itself says why).
|
||||
*/
|
||||
fun backupUiState(calendars: List<CalendarSource>): BackupUiState {
|
||||
val exportable = calendars.filter { it.isLocal && it.canModifyContents && !it.isManaged }
|
||||
val exportable = calendars.filter { it.isBackupCandidate }
|
||||
val canImport = calendars.any { it.isEventTarget }
|
||||
if (exportable.isEmpty() && !canImport) {
|
||||
return BackupUiState.Failure(
|
||||
calendarListFailure(calendars) ?: FailureReason.NoImportTarget,
|
||||
)
|
||||
}
|
||||
return BackupUiState.Ready(exportable = exportable, canImport = canImport)
|
||||
return BackupUiState.Ready(
|
||||
exportable = exportable,
|
||||
canImport = canImport,
|
||||
canCopy = copySources(calendars).isNotEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calendars a copy can read from: any a backup can (#325), synced and read-only
|
||||
* ones included, as long as some other calendar can take their events.
|
||||
*/
|
||||
fun copySources(calendars: List<CalendarSource>): List<CalendarSource> {
|
||||
val targets = calendars.filter { it.isEventTarget }
|
||||
val onlyTarget = targets.singleOrNull()?.id
|
||||
if (targets.isEmpty()) return emptyList()
|
||||
return calendars.filter { it.isBackupCandidate && it.id != onlyTarget }
|
||||
}
|
||||
|
||||
/** Calendars a copy from [sourceId] can write into: the import targets, minus the source itself. */
|
||||
fun copyTargets(calendars: List<CalendarSource>, sourceId: Long?): List<CalendarSource> =
|
||||
calendars.filter { it.isEventTarget && it.id != sourceId }
|
||||
@@ -14,10 +14,14 @@ import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.backup.BackupSelection
|
||||
import de.jeanlucmakiola.calendula.domain.backup.DEFAULT_BACKUP_KEEP
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
|
||||
import de.jeanlucmakiola.floret.di.IoDispatcher
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.joinAll
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -73,12 +77,20 @@ class CalendarsViewModel @Inject constructor(
|
||||
|
||||
/** Automatic-backup settings + last-run status, for the Backup section UI. */
|
||||
val autoBackup: StateFlow<AutoBackupUiState> = combine(
|
||||
settingsPrefs.autoBackupEnabled,
|
||||
settingsPrefs.autoBackupIntervalMinutes,
|
||||
settingsPrefs.autoBackupFolderUri,
|
||||
settingsPrefs.autoBackupStatus,
|
||||
) { enabled, interval, folder, status ->
|
||||
AutoBackupUiState(enabled, interval, folder, status)
|
||||
combine(
|
||||
settingsPrefs.autoBackupEnabled,
|
||||
settingsPrefs.autoBackupIntervalMinutes,
|
||||
settingsPrefs.autoBackupFolderUri,
|
||||
settingsPrefs.autoBackupStatus,
|
||||
) { enabled, interval, folder, status ->
|
||||
AutoBackupUiState(enabled, interval, folder, status)
|
||||
},
|
||||
settingsPrefs.autoBackupSelection,
|
||||
settingsPrefs.autoBackupCombined,
|
||||
settingsPrefs.autoBackupKeep,
|
||||
settingsPrefs.autoBackupOnChange,
|
||||
) { base, selection, combined, keep, onChange ->
|
||||
base.copy(selection = selection, combined = combined, keep = keep, onChange = onChange)
|
||||
}
|
||||
.flowOn(io)
|
||||
.stateWhileSubscribed(
|
||||
@@ -115,18 +127,22 @@ class CalendarsViewModel @Inject constructor(
|
||||
fun consumeBackupResult() { _backupResult.value = null }
|
||||
|
||||
/**
|
||||
* Serialise every event of the writable local calendars into the chosen SAF
|
||||
* document [uri] as one `VCALENDAR`. Result (event count, or failure) lands
|
||||
* Serialise every event of [calendarIds] into the chosen SAF document [uri]
|
||||
* as one `VCALENDAR`. Result (event count, or failure) lands
|
||||
* in [backupResult] for a one-shot message.
|
||||
*/
|
||||
fun exportBackup(uri: Uri, calendarIds: Set<Long>? = null) {
|
||||
fun exportBackup(uri: Uri, calendarIds: Set<Long>) {
|
||||
viewModelScope.launch {
|
||||
_backupResult.value = try {
|
||||
val count = withContext(io) {
|
||||
val events = repository.exportEvents(calendarIds)
|
||||
// A one-calendar file is named after it, as the automatic backup's are.
|
||||
val name = calendarIds.singleOrNull()?.let { id ->
|
||||
repository.calendars().first().firstOrNull { it.id == id }?.displayName
|
||||
}
|
||||
icsExporter.writeDocument(
|
||||
uri = uri,
|
||||
content = IcsWriter().writeCalendar(events, Clock.System.now()),
|
||||
content = IcsWriter().writeCalendar(events, Clock.System.now(), name),
|
||||
)
|
||||
events.size
|
||||
}
|
||||
@@ -204,13 +220,58 @@ class CalendarsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** Selection writes since the calendar picker opened; the run after it waits for them. */
|
||||
private val selectionWrites = mutableListOf<Job>()
|
||||
|
||||
fun setAutoBackupCalendar(calendar: CalendarSource, included: Boolean) {
|
||||
selectionWrites += viewModelScope.launch { settingsPrefs.setAutoBackupCalendar(calendar, included) }
|
||||
}
|
||||
|
||||
fun setAutoBackupCombined(combined: Boolean) {
|
||||
viewModelScope.launch {
|
||||
if (settingsPrefs.autoBackupCombined.first() == combined) return@launch
|
||||
settingsPrefs.setAutoBackupCombined(combined)
|
||||
runBackupIfActive()
|
||||
}
|
||||
}
|
||||
|
||||
fun setAutoBackupOnChange(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
settingsPrefs.setAutoBackupOnChange(enabled)
|
||||
reschedule()
|
||||
}
|
||||
}
|
||||
|
||||
fun setAutoBackupKeep(keep: Int) {
|
||||
viewModelScope.launch {
|
||||
if (settingsPrefs.autoBackupKeep.first() == keep) return@launch
|
||||
settingsPrefs.setAutoBackupKeep(keep)
|
||||
runBackupIfActive()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* After the calendar choice is closed, back up what is now selected rather
|
||||
* than at the next interval — only if it changed, since every run rotates
|
||||
* an older backup out.
|
||||
*/
|
||||
fun autoBackupCalendarsChosen(before: BackupSelection) {
|
||||
val pending = selectionWrites.toList()
|
||||
selectionWrites.clear()
|
||||
viewModelScope.launch {
|
||||
pending.joinAll()
|
||||
if (settingsPrefs.autoBackupSelection.first() != before) runBackupIfActive()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runBackupIfActive() {
|
||||
if (settingsPrefs.autoBackupEnabled.first() && settingsPrefs.autoBackupFolderUri.first() != null) {
|
||||
BackupScheduler.runNow(context)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun reschedule() {
|
||||
BackupScheduler.apply(
|
||||
context = context,
|
||||
enabled = settingsPrefs.autoBackupEnabled.first(),
|
||||
intervalMinutes = settingsPrefs.autoBackupIntervalMinutes.first(),
|
||||
hasFolder = settingsPrefs.autoBackupFolderUri.first() != null,
|
||||
)
|
||||
BackupScheduler.reconcile(context, settingsPrefs)
|
||||
}
|
||||
|
||||
private inline fun write(crossinline block: suspend () -> Unit) {
|
||||
@@ -232,6 +293,10 @@ data class AutoBackupUiState(
|
||||
val intervalMinutes: Long = SettingsPrefs.DEFAULT_BACKUP_INTERVAL,
|
||||
val folderUri: String? = null,
|
||||
val status: BackupStatus = BackupStatus(lastRun = 0L, lastSuccess = true, consecutiveFailures = 0),
|
||||
val selection: BackupSelection = BackupSelection(),
|
||||
val combined: Boolean = false,
|
||||
val keep: Int = DEFAULT_BACKUP_KEEP,
|
||||
val onChange: Boolean = false,
|
||||
)
|
||||
|
||||
/** Outcome of a whole-calendar backup, surfaced once to the screen. */
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
package de.jeanlucmakiola.calendula.ui.calendars
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.LinearWavyProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups
|
||||
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedHint
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
|
||||
/**
|
||||
* Copy every event of one calendar into another (#325): pick the source and the
|
||||
* target, copy, watch it run. The run lives in [CopyEventsViewModel], so this
|
||||
* sheet can be closed and reopened mid-copy.
|
||||
*/
|
||||
@Composable
|
||||
fun CopyEventsSheet(
|
||||
calendars: CopyCalendars,
|
||||
selection: CopySelection,
|
||||
run: CopyRunState,
|
||||
onSelectSource: (Long) -> Unit,
|
||||
onSelectTarget: (Long) -> Unit,
|
||||
onStart: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val list = (calendars as? CopyCalendars.Ready)?.calendars.orEmpty()
|
||||
val running = run is CopyRunState.Running
|
||||
val sources = remember(list) { copySources(list) }
|
||||
// A running copy keeps showing its source even if it stopped qualifying meanwhile.
|
||||
val source = (if (running) list else sources).firstOrNull { it.id == selection.sourceId }
|
||||
val targets = remember(list, source?.id) { copyTargets(list, source?.id) }
|
||||
val target = targets.firstOrNull { it.id == selection.targetId }
|
||||
var picking by rememberSaveable { mutableStateOf<CopyEnd?>(null) }
|
||||
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.calendars_copy_action),
|
||||
onDismiss = onDismiss,
|
||||
predictiveBack = true,
|
||||
actions = {
|
||||
if (!running) {
|
||||
TextButton(onClick = onStart, enabled = source != null && target != null) {
|
||||
Text(stringResource(R.string.calendars_copy_start))
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
GroupedHint(stringResource(R.string.calendars_copy_hint), style = MaterialTheme.typography.bodySmall)
|
||||
// A copy under way keeps its progress and Cancel whatever the list does.
|
||||
if (run == CopyRunState.Idle) when (calendars) {
|
||||
CopyCalendars.Loading -> {
|
||||
Box(Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
return@FullScreenPicker
|
||||
}
|
||||
CopyCalendars.Failure -> {
|
||||
CalendarFailure(reason = FailureReason.ProviderUnavailable, onRetry = onDismiss)
|
||||
return@FullScreenPicker
|
||||
}
|
||||
is CopyCalendars.Ready -> Unit
|
||||
}
|
||||
if (sources.isEmpty() && run == CopyRunState.Idle) {
|
||||
GroupedHint(stringResource(R.string.calendars_copy_none))
|
||||
return@FullScreenPicker
|
||||
}
|
||||
CopyEndRow(
|
||||
label = stringResource(R.string.calendars_copy_from),
|
||||
calendar = source,
|
||||
position = Position.Top,
|
||||
onClick = if (running) null else ({ picking = CopyEnd.Source }),
|
||||
)
|
||||
CopyEndRow(
|
||||
label = stringResource(R.string.calendars_copy_to),
|
||||
calendar = target,
|
||||
position = Position.Bottom,
|
||||
onClick = if (running || source == null) null else ({ picking = CopyEnd.Target }),
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
when (run) {
|
||||
is CopyRunState.Running -> CopyProgress(run, onCancel)
|
||||
is CopyRunState.Done -> CopySummary(run)
|
||||
CopyRunState.Cancelled -> CopyNote(stringResource(R.string.calendars_copy_cancelled))
|
||||
CopyRunState.Failed -> CopyNote(
|
||||
stringResource(R.string.calendars_copy_failed),
|
||||
error = true,
|
||||
)
|
||||
CopyRunState.Idle -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (picking) {
|
||||
CopyEnd.Source -> FullScreenPicker(
|
||||
title = stringResource(R.string.calendars_copy_from_title),
|
||||
onDismiss = { picking = null },
|
||||
predictiveBack = true,
|
||||
) {
|
||||
CalendarPickerGroups(
|
||||
calendars = sources,
|
||||
selectedId = source?.id,
|
||||
onSelect = {
|
||||
onSelectSource(it)
|
||||
picking = null
|
||||
},
|
||||
)
|
||||
}
|
||||
CopyEnd.Target -> FullScreenPicker(
|
||||
title = stringResource(R.string.calendars_copy_to_title),
|
||||
onDismiss = { picking = null },
|
||||
predictiveBack = true,
|
||||
) {
|
||||
CalendarPickerGroups(
|
||||
calendars = targets,
|
||||
selectedId = target?.id,
|
||||
onSelect = {
|
||||
onSelectTarget(it)
|
||||
picking = null
|
||||
},
|
||||
)
|
||||
}
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private enum class CopyEnd { Source, Target }
|
||||
|
||||
@Composable
|
||||
private fun CopyEndRow(
|
||||
label: String,
|
||||
calendar: CalendarSource?,
|
||||
position: Position,
|
||||
onClick: (() -> Unit)?,
|
||||
) {
|
||||
GroupedRow(
|
||||
title = label,
|
||||
summary = calendar?.displayName?.orUnnamed() ?: stringResource(R.string.calendars_copy_choose),
|
||||
position = position,
|
||||
dimmed = onClick == null,
|
||||
leading = calendar?.let { { CalendarColorChip(it.color) } },
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
private fun CopyProgress(run: CopyRunState.Running, onCancel: () -> Unit) {
|
||||
val total = run.total
|
||||
if (total == null || total == 0) {
|
||||
LinearWavyProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
} else {
|
||||
LinearWavyProgressIndicator(
|
||||
progress = { run.done.toFloat() / total },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = copyProgressText(run),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
OutlinedButton(onClick = onCancel, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(stringResource(R.string.dialog_cancel))
|
||||
}
|
||||
}
|
||||
|
||||
/** "Reading events…" until the source is read, then "120 of 2,400 events". */
|
||||
@Composable
|
||||
fun copyProgressText(run: CopyRunState.Running): String {
|
||||
val total = run.total ?: return stringResource(R.string.calendars_copy_reading)
|
||||
return pluralStringResource(R.plurals.calendars_copy_progress, total, run.done, total)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CopySummary(run: CopyRunState.Done) {
|
||||
val summary = run.summary
|
||||
Text(
|
||||
stringResource(run.titleRes),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) {
|
||||
CopyCount(stringResource(R.string.import_done_added_label), summary.imported)
|
||||
if (summary.skippedDuplicate > 0) {
|
||||
CopyCount(stringResource(R.string.import_done_skipped_label), summary.skippedDuplicate)
|
||||
}
|
||||
if (summary.failed > 0) CopyCount(stringResource(R.string.import_done_failed_label), summary.failed)
|
||||
}
|
||||
if (summary.skippedDuplicate > 0) CopyNote(stringResource(R.string.import_done_dedup_note))
|
||||
if (summary.failed > 0) CopyNote(stringResource(R.string.import_done_failed_note))
|
||||
if (summary.notAttempted > 0) {
|
||||
CopyNote(
|
||||
pluralStringResource(R.plurals.calendars_copy_stopped, summary.notAttempted, summary.notAttempted),
|
||||
error = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CopyCount(label: String, count: Int) {
|
||||
Column {
|
||||
Text(count.toString(), style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onSurface)
|
||||
Text(label, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CopyNote(text: String, error: Boolean = false) {
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (error) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package de.jeanlucmakiola.calendula.ui.calendars
|
||||
|
||||
import android.util.Log
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Copy events from one calendar into another (#325). Scoped to the activity, so
|
||||
* a copy keeps running across rotation and while the Backup screen is closed;
|
||||
* finishing the activity or losing the process stops it like [cancel] does,
|
||||
* and a re-run picks up where it ended.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class CopyEventsViewModel @Inject constructor(
|
||||
private val repository: CalendarRepository,
|
||||
private val savedState: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
|
||||
val calendars: StateFlow<CopyCalendars> =
|
||||
repository.calendars()
|
||||
.map<List<CalendarSource>, CopyCalendars> { CopyCalendars.Ready(it) }
|
||||
.catch { emit(CopyCalendars.Failure) }
|
||||
.stateWhileSubscribed(scope = viewModelScope, initialValue = CopyCalendars.Loading)
|
||||
|
||||
val selection: StateFlow<CopySelection> = combine(
|
||||
savedState.getStateFlow<Long?>(KEY_SOURCE, null),
|
||||
savedState.getStateFlow<Long?>(KEY_TARGET, null),
|
||||
) { source, target -> CopySelection(source, target) }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, CopySelection())
|
||||
|
||||
private val _run = MutableStateFlow<CopyRunState>(CopyRunState.Idle)
|
||||
val run: StateFlow<CopyRunState> = _run.asStateFlow()
|
||||
|
||||
private var job: Job? = null
|
||||
|
||||
private val busy: Boolean get() = job?.isCompleted == false
|
||||
|
||||
private fun current() = CopySelection(savedState[KEY_SOURCE], savedState[KEY_TARGET])
|
||||
|
||||
private fun select(selection: CopySelection) {
|
||||
savedState[KEY_SOURCE] = selection.sourceId
|
||||
savedState[KEY_TARGET] = selection.targetId
|
||||
}
|
||||
|
||||
fun setSource(id: Long) {
|
||||
if (busy) return
|
||||
select(current().withSource(id))
|
||||
clearFinished()
|
||||
}
|
||||
|
||||
fun setTarget(id: Long) {
|
||||
if (busy) return
|
||||
select(current().copy(targetId = id))
|
||||
clearFinished()
|
||||
}
|
||||
|
||||
fun start() {
|
||||
val source = current().sourceId ?: return
|
||||
val target = current().targetId ?: return
|
||||
if (busy) return
|
||||
_run.value = CopyRunState.Running(done = 0, total = null)
|
||||
job = viewModelScope.launch {
|
||||
try {
|
||||
val summary = repository.copyEvents(source, target) { done, total ->
|
||||
if (done == 0 || done == total || done % (total / 200).coerceAtLeast(1) == 0) {
|
||||
_run.value = CopyRunState.Running(done, total)
|
||||
}
|
||||
}
|
||||
_run.value = CopyRunState.Done(summary)
|
||||
} catch (e: CancellationException) {
|
||||
_run.value = CopyRunState.Cancelled
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Copy events failed", e)
|
||||
_run.value = CopyRunState.Failed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
job?.cancel()
|
||||
}
|
||||
|
||||
/** Back to idle once a finished run's outcome has been seen. */
|
||||
fun clearFinished() {
|
||||
if (!busy) _run.value = CopyRunState.Idle
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val KEY_SOURCE = "copy_source"
|
||||
const val KEY_TARGET = "copy_target"
|
||||
const val TAG = "CopyEvents"
|
||||
}
|
||||
}
|
||||
|
||||
/** The calendars a copy runs between. */
|
||||
data class CopySelection(val sourceId: Long? = null, val targetId: Long? = null) {
|
||||
/** A target equal to the new source is dropped: a calendar is never copied into itself. */
|
||||
fun withSource(id: Long): CopySelection =
|
||||
CopySelection(sourceId = id, targetId = targetId?.takeIf { it != id })
|
||||
}
|
||||
|
||||
/** The calendar list the copy sheet picks from. */
|
||||
sealed interface CopyCalendars {
|
||||
data object Loading : CopyCalendars
|
||||
data object Failure : CopyCalendars
|
||||
data class Ready(val calendars: List<CalendarSource>) : CopyCalendars
|
||||
}
|
||||
|
||||
sealed interface CopyRunState {
|
||||
data object Idle : CopyRunState
|
||||
|
||||
/** [total] is null while the source calendar is still being read. */
|
||||
data class Running(val done: Int, val total: Int?) : CopyRunState
|
||||
|
||||
data class Done(val summary: IcsImportSummary) : CopyRunState {
|
||||
/** The target stopped taking events part way, so some were never tried. */
|
||||
val stopped: Boolean get() = summary.notAttempted > 0
|
||||
|
||||
/** How the run ended, the same words on the row and in the sheet. */
|
||||
@get:StringRes
|
||||
val titleRes: Int get() = when {
|
||||
stopped -> R.string.calendars_copy_stopped_title
|
||||
summary.failed > 0 -> R.string.calendars_copy_partial
|
||||
else -> R.string.calendars_copy_done
|
||||
}
|
||||
}
|
||||
data object Cancelled : CopyRunState
|
||||
data object Failed : CopyRunState
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Cloud
|
||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -55,28 +56,13 @@ fun ColumnScope.CalendarPickerGroups(
|
||||
onSelect: (Long) -> Unit,
|
||||
onManageCalendars: (() -> Unit)? = null,
|
||||
) {
|
||||
val local = remember(calendars) { calendars.filter { it.isLocal } }
|
||||
val syncedGroups = remember(calendars) {
|
||||
calendars.filterNot { it.isLocal }.groupByAccount()
|
||||
}
|
||||
|
||||
if (local.isNotEmpty()) {
|
||||
CalendarPickerGroup(
|
||||
title = stringResource(R.string.calendars_local_header),
|
||||
leading = { LeadingAvatar(Icons.Default.PhoneAndroid) },
|
||||
calendars = local,
|
||||
selectedId = selectedId,
|
||||
onSelect = onSelect,
|
||||
)
|
||||
}
|
||||
syncedGroups.forEachIndexed { index, group ->
|
||||
if (local.isNotEmpty() || index > 0) Spacer(Modifier.height(16.dp))
|
||||
CalendarPickerGroup(
|
||||
title = accountGroupTitle(group),
|
||||
leading = { SourceLogo(group.accountType) },
|
||||
calendars = group.calendars,
|
||||
selectedId = selectedId,
|
||||
onSelect = onSelect,
|
||||
CalendarAccountSections(calendars) { calendar, position ->
|
||||
GroupedRow(
|
||||
title = calendar.displayName.orUnnamed(),
|
||||
position = position,
|
||||
checked = calendar.id == selectedId,
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
onClick = { onSelect(calendar.id) },
|
||||
)
|
||||
}
|
||||
if (onManageCalendars != null) {
|
||||
@@ -98,14 +84,68 @@ fun ColumnScope.CalendarPickerGroups(
|
||||
}
|
||||
}
|
||||
|
||||
/** One account's category header (avatar + name) atop its selectable calendars. */
|
||||
/**
|
||||
* A multi-select take on [CalendarPickerGroups]: the same sections, with a
|
||||
* checkbox per calendar instead of a single check.
|
||||
*/
|
||||
@Composable
|
||||
private fun CalendarPickerGroup(
|
||||
fun ColumnScope.CalendarCheckGroups(
|
||||
calendars: List<CalendarSource>,
|
||||
isChecked: (CalendarSource) -> Boolean,
|
||||
onCheckedChange: (CalendarSource, Boolean) -> Unit,
|
||||
) {
|
||||
CalendarAccountSections(calendars) { calendar, position ->
|
||||
val checked = isChecked(calendar)
|
||||
GroupedRow(
|
||||
title = calendar.displayName.orUnnamed(),
|
||||
summary = calendar.description,
|
||||
position = position,
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
trailing = {
|
||||
Checkbox(checked = checked, onCheckedChange = { onCheckedChange(calendar, it) })
|
||||
},
|
||||
onClick = { onCheckedChange(calendar, !checked) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The app's own calendars, then one section per synced account, each under its category header. */
|
||||
@Composable
|
||||
private fun CalendarAccountSections(
|
||||
calendars: List<CalendarSource>,
|
||||
row: @Composable (CalendarSource, Position) -> Unit,
|
||||
) {
|
||||
val local = remember(calendars) { calendars.filter { it.isLocal } }
|
||||
val syncedGroups = remember(calendars) {
|
||||
calendars.filterNot { it.isLocal }.groupByAccount()
|
||||
}
|
||||
|
||||
if (local.isNotEmpty()) {
|
||||
CalendarAccountSection(
|
||||
title = stringResource(R.string.calendars_local_header),
|
||||
leading = { LeadingAvatar(Icons.Default.PhoneAndroid) },
|
||||
calendars = local,
|
||||
row = row,
|
||||
)
|
||||
}
|
||||
syncedGroups.forEachIndexed { index, group ->
|
||||
if (local.isNotEmpty() || index > 0) Spacer(Modifier.height(16.dp))
|
||||
CalendarAccountSection(
|
||||
title = accountGroupTitle(group),
|
||||
leading = { SourceLogo(group.accountType) },
|
||||
calendars = group.calendars,
|
||||
row = row,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** One account's category header (avatar + name) atop its calendars. */
|
||||
@Composable
|
||||
private fun CalendarAccountSection(
|
||||
title: String,
|
||||
leading: @Composable () -> Unit,
|
||||
calendars: List<CalendarSource>,
|
||||
selectedId: Long?,
|
||||
onSelect: (Long) -> Unit,
|
||||
row: @Composable (CalendarSource, Position) -> Unit,
|
||||
) {
|
||||
GroupedRow(
|
||||
title = title,
|
||||
@@ -113,13 +153,7 @@ private fun CalendarPickerGroup(
|
||||
leading = leading,
|
||||
)
|
||||
calendars.forEachIndexed { index, calendar ->
|
||||
GroupedRow(
|
||||
title = calendar.displayName.orUnnamed(),
|
||||
position = if (index == calendars.lastIndex) Position.Bottom else Position.Middle,
|
||||
checked = calendar.id == selectedId,
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
onClick = { onSelect(calendar.id) },
|
||||
)
|
||||
row(calendar, if (index == calendars.lastIndex) Position.Bottom else Position.Middle)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import android.content.res.Resources
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import de.jeanlucmakiola.floret.time.rowWeekNumber
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import java.time.temporal.WeekFields
|
||||
|
||||
/**
|
||||
* The week number of the grid row starting on [rowStart], numbered by the
|
||||
* device region's rules (#493). The region, not the app language: an English
|
||||
* UI in the Netherlands still numbers weeks the ISO way. Shared by the Month
|
||||
* gutter and the Week badge so the two always agree.
|
||||
*/
|
||||
@Composable
|
||||
internal fun rememberRowWeekNumber(rowStart: LocalDate): Int {
|
||||
val fields = remember { WeekFields.of(Resources.getSystem().configuration.locales[0]) }
|
||||
return remember(rowStart, fields) { rowStart.toJavaLocalDate().rowWeekNumber(fields) }
|
||||
}
|
||||
@@ -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
|
||||
@@ -187,7 +187,6 @@ import de.jeanlucmakiola.floret.identity.itemEnter
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
import de.jeanlucmakiola.floret.time.isoWeekNumber
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
@@ -205,6 +204,7 @@ import java.time.format.TextStyle as JavaTextStyle
|
||||
import java.util.Locale
|
||||
import kotlinx.datetime.toJavaMonth
|
||||
import kotlinx.datetime.yearMonth
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberRowWeekNumber
|
||||
|
||||
private val LocalTodayPulse = staticCompositionLocalOf { 0 }
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -2701,13 +2755,13 @@ private fun monthChipDragModifier(
|
||||
/**
|
||||
* Left-gutter calendar-week cell (#25): a full-height tonal pill mirroring the
|
||||
* day cells' geometry, set apart by the secondaryContainer tint (matching the
|
||||
* Week view's badge), with the ISO week number centred like a day number. The
|
||||
* week is computed on the row's first day — the same basis as the Week view — so
|
||||
* the two agree.
|
||||
* Week view's badge), with the week number centred like a day number. It is
|
||||
* numbered by the device region from the row's mid-week day, as in the Week
|
||||
* view, so it is right for any first weekday (#493).
|
||||
*/
|
||||
@Composable
|
||||
private fun WeekNumberGutter(weekStart: LocalDate, modifier: Modifier = Modifier) {
|
||||
val weekNumber = remember(weekStart) { weekStart.toJavaLocalDate().isoWeekNumber() }
|
||||
val weekNumber = rememberRowWeekNumber(weekStart)
|
||||
val label = stringResource(R.string.week_number_label)
|
||||
val openLabel = stringResource(R.string.month_open_week, weekNumber)
|
||||
val onOpenWeek = LocalOpenWeek.current
|
||||
@@ -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(
|
||||
|
||||
@@ -15,6 +15,7 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.backup.isBackedUpByDefault
|
||||
import de.jeanlucmakiola.calendula.domain.hasVisibilitySwitch
|
||||
import de.jeanlucmakiola.calendula.domain.orderedForManager
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
@@ -339,12 +340,7 @@ class OnboardingViewModel @Inject constructor(
|
||||
}
|
||||
prefs.setAutoBackupFolderUri(folder.toString())
|
||||
prefs.setAutoBackupEnabled(true)
|
||||
BackupScheduler.apply(
|
||||
context = context,
|
||||
enabled = true,
|
||||
intervalMinutes = prefs.autoBackupIntervalMinutes.first(),
|
||||
hasFolder = true,
|
||||
)
|
||||
BackupScheduler.reconcile(context, prefs)
|
||||
// Immediate feedback: the first backup lands while onboarding runs.
|
||||
BackupScheduler.runNow(context)
|
||||
prefs.setOnboardingDone(OnboardingStep.Backup)
|
||||
@@ -483,7 +479,7 @@ private fun List<CalendarSource>.allHidden(): Boolean =
|
||||
* A read-only subscription is not a backup, so it does not count.
|
||||
*/
|
||||
private fun List<CalendarSource>.backupApplies(): Boolean {
|
||||
val exportable = any { it.isLocal && it.canModifyContents && !it.isManaged }
|
||||
val exportable = any { it.isBackedUpByDefault }
|
||||
val syncedTarget = any { !it.isLocal && it.canModifyContents }
|
||||
return exportable && !syncedTarget
|
||||
}
|
||||
@@ -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
|
||||
@@ -209,7 +210,6 @@ import de.jeanlucmakiola.calendula.ui.common.TIMELINE_CONTENT_END_INSET
|
||||
import de.jeanlucmakiola.calendula.ui.common.hourCellBlockInset
|
||||
import de.jeanlucmakiola.calendula.ui.common.hourGridCells
|
||||
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
|
||||
import de.jeanlucmakiola.floret.time.isoWeekNumber
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
@@ -221,6 +221,7 @@ import kotlinx.datetime.toLocalDateTime
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
import java.util.Locale
|
||||
import kotlinx.datetime.yearMonth
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberRowWeekNumber
|
||||
|
||||
/** One lane of the all-day strip, sized to a trimmed bar line (#190). */
|
||||
private val ALL_DAY_ROW_HEIGHT = 20.dp
|
||||
@@ -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) }
|
||||
@@ -1137,7 +1154,7 @@ private fun WeekDayHeader(
|
||||
*/
|
||||
@Composable
|
||||
private fun WeekNumberCell(weekStart: LocalDate, showBadge: Boolean, modifier: Modifier = Modifier) {
|
||||
val weekNumber = remember(weekStart) { weekStart.toJavaLocalDate().isoWeekNumber() }
|
||||
val weekNumber = rememberRowWeekNumber(weekStart)
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
@@ -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",
|
||||
)
|
||||
@@ -4,6 +4,7 @@ import android.content.Context
|
||||
import androidx.datastore.preferences.core.MutablePreferences
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.floatPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import androidx.glance.GlanceId
|
||||
@@ -26,14 +27,22 @@ enum class WidgetTheme { FOLLOW_APP, LIGHT, DARK }
|
||||
data class WidgetAppearance(
|
||||
val background: WidgetBackground = WidgetBackground.TINTED,
|
||||
val theme: WidgetTheme = WidgetTheme.FOLLOW_APP,
|
||||
/** How opaque a Solid or Tinted fill is (#489). */
|
||||
val opacity: Float = 1f,
|
||||
)
|
||||
|
||||
/** The agenda widget's look: the 2.24 day cards, or v2.23's plain day headers and stripes (#489). */
|
||||
enum class AgendaWidgetStyle { CARDS, CLASSIC }
|
||||
|
||||
/** What the agenda widget lists, per placed widget. */
|
||||
data class AgendaWidgetOptions(
|
||||
val range: AgendaRange,
|
||||
val textSize: WidgetSize,
|
||||
val showToday: Boolean,
|
||||
val pastDisplay: PastEventDisplay,
|
||||
val style: AgendaWidgetStyle = AgendaWidgetStyle.CARDS,
|
||||
/** Classic style only: draw the text in the opposite theme's colours, for a faded fill over the wallpaper. */
|
||||
val invertText: Boolean = false,
|
||||
)
|
||||
|
||||
internal fun WidgetTheme.resolve(app: ThemeMode): WidgetThemeMode = when (this) {
|
||||
@@ -48,6 +57,9 @@ internal fun WidgetTheme.resolve(app: ThemeMode): WidgetThemeMode = when (this)
|
||||
|
||||
private val BackgroundKey = stringPreferencesKey("cfg_background")
|
||||
private val ThemeKey = stringPreferencesKey("cfg_theme")
|
||||
private val OpacityKey = floatPreferencesKey("cfg_opacity")
|
||||
private val AgendaStyleKey = stringPreferencesKey("cfg_agenda_style")
|
||||
private val InvertTextKey = booleanPreferencesKey("cfg_invert_text")
|
||||
private val AgendaRangeKey = stringPreferencesKey("cfg_agenda_range")
|
||||
private val TextSizeKey = stringPreferencesKey("cfg_text_size")
|
||||
private val ShowTodayKey = booleanPreferencesKey("cfg_show_today")
|
||||
@@ -58,6 +70,7 @@ internal val Preferences.widgetAppearance: WidgetAppearance
|
||||
get() = WidgetAppearance(
|
||||
background = this[BackgroundKey].toEnum(WidgetBackground.TINTED),
|
||||
theme = this[ThemeKey].toEnum(WidgetTheme.FOLLOW_APP),
|
||||
opacity = this[OpacityKey] ?: 1f,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -70,6 +83,8 @@ internal fun Preferences.agendaWidgetOptions(defaults: AgendaWidgetOptions) = Ag
|
||||
textSize = this[TextSizeKey].toEnum(defaults.textSize),
|
||||
showToday = this[ShowTodayKey] ?: defaults.showToday,
|
||||
pastDisplay = this[PastDisplayKey].toEnum(defaults.pastDisplay),
|
||||
style = this[AgendaStyleKey].toEnum(defaults.style),
|
||||
invertText = this[InvertTextKey] ?: defaults.invertText,
|
||||
)
|
||||
|
||||
/** Whether this widget has agenda options of its own, rather than following the app's. */
|
||||
@@ -78,6 +93,7 @@ internal val Preferences.hasAgendaOptions: Boolean get() = this[AgendaRangeKey]
|
||||
internal fun MutablePreferences.putAppearance(appearance: WidgetAppearance) {
|
||||
this[BackgroundKey] = appearance.background.name
|
||||
this[ThemeKey] = appearance.theme.name
|
||||
this[OpacityKey] = appearance.opacity
|
||||
}
|
||||
|
||||
internal fun MutablePreferences.putAgendaOptions(options: AgendaWidgetOptions) {
|
||||
@@ -85,6 +101,8 @@ internal fun MutablePreferences.putAgendaOptions(options: AgendaWidgetOptions) {
|
||||
this[TextSizeKey] = options.textSize.name
|
||||
this[ShowTodayKey] = options.showToday
|
||||
this[PastDisplayKey] = options.pastDisplay.name
|
||||
this[AgendaStyleKey] = options.style.name
|
||||
this[InvertTextKey] = options.invertText
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -126,6 +126,8 @@ sealed interface AgendaWidgetData {
|
||||
/** "Always show today" (#35): anchor today at the top even with no events left. */
|
||||
val showToday: Boolean,
|
||||
val textSize: WidgetSize,
|
||||
val style: AgendaWidgetStyle = AgendaWidgetStyle.CARDS,
|
||||
val invertText: Boolean = false,
|
||||
/** Snapshot instant the data was read at, for "has this event ended?" tests. */
|
||||
val now: Instant,
|
||||
) : AgendaWidgetData
|
||||
@@ -184,6 +186,8 @@ internal suspend fun Context.loadAgendaWidgetData(
|
||||
pastDisplay = options.pastDisplay,
|
||||
showToday = options.showToday,
|
||||
textSize = options.textSize,
|
||||
style = options.style,
|
||||
invertText = options.invertText,
|
||||
now = Clock.System.now(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.widget
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -18,7 +19,6 @@ import androidx.glance.layout.padding
|
||||
import androidx.glance.text.FontWeight
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextStyle
|
||||
import de.jeanlucmakiola.calendula.MainActivity
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.isCrossedOut
|
||||
@@ -26,20 +26,33 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaDarkFallback
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.metricsFor
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.glance.WidgetGroupedRow
|
||||
import de.jeanlucmakiola.floret.glance.themedColor
|
||||
|
||||
/** A past event's fill when dimmed: fainter than the app's whole-row dim, so its text can stay readable (#90). */
|
||||
internal const val WidgetDimFillAlpha = 0.3f
|
||||
/** How much of a past event's tone stays in its dimmed fill (#90). */
|
||||
private const val WidgetDimFillAlpha = 0.3f
|
||||
|
||||
/** A dimmed event's ink, held above its fill. */
|
||||
private const val WidgetDimInkAlpha = 0.65f
|
||||
|
||||
/** Ink for an event on [fill], picked against the opaque tone and faded when the fill is dimmed. */
|
||||
internal fun widgetEventInk(fill: Color, alpha: Float): Color =
|
||||
eventInk(fill.copy(alpha = 1f), if (fill.alpha < 1f) alpha * WidgetDimInkAlpha else alpha)
|
||||
/**
|
||||
* An event's fill in the light or [dark] theme. A dimmed one is its tone faded
|
||||
* into that theme's surface, kept opaque: a translucent tint lands on the row
|
||||
* shape's white, not on the widget, and its ink would be picked for the wrong tone (#475).
|
||||
*/
|
||||
internal fun widgetEventFill(rawArgb: Int, dark: Boolean, soften: Boolean, dimmed: Boolean): Color {
|
||||
val tone = eventFill(rawArgb, dark, soften)
|
||||
if (!dimmed) return tone
|
||||
val surface = if (dark) CalendulaDarkFallback.surface else Color.White
|
||||
return tone.copy(alpha = WidgetDimFillAlpha).compositeOver(surface)
|
||||
}
|
||||
|
||||
/** Ink for an event on [fill], faded when the row is [dimmed]. */
|
||||
internal fun widgetEventInk(fill: Color, alpha: Float, dimmed: Boolean): Color =
|
||||
eventInk(fill, if (dimmed) alpha * WidgetDimInkAlpha else alpha)
|
||||
|
||||
/** Text and row sizes of a compact event list, from a widget's [WidgetSize]. */
|
||||
internal data class EventRowMetrics(val title: TextUnit, val time: TextUnit, val rowHeight: Dp, val message: TextUnit)
|
||||
@@ -72,9 +85,8 @@ internal fun WidgetEventRow(
|
||||
quiet: Boolean = false,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
fun tone(dark: Boolean): Color = eventFill(event.color, dark, soften).let { if (dimmed) it.copy(alpha = WidgetDimFillAlpha) else it }
|
||||
val day = tone(dark = false)
|
||||
val night = tone(dark = true)
|
||||
val day = widgetEventFill(event.color, dark = false, soften, dimmed)
|
||||
val night = widgetEventFill(event.color, dark = true, soften, dimmed)
|
||||
val card = widgetCardColor()
|
||||
WidgetGroupedRow(position = position, color = if (quiet) card else themedColor(day = day, night = night)) {
|
||||
Column(
|
||||
@@ -84,13 +96,7 @@ internal fun WidgetEventRow(
|
||||
.padding(horizontal = 12.dp)
|
||||
.clickable(
|
||||
actionStartActivity(
|
||||
MainActivity.openEventIntent(
|
||||
context = context,
|
||||
eventId = event.eventId,
|
||||
beginMillis = event.start.toEpochMilliseconds(),
|
||||
endMillis = event.end.toEpochMilliseconds(),
|
||||
source = source,
|
||||
),
|
||||
event.openIntent(context, source),
|
||||
),
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -98,12 +104,12 @@ internal fun WidgetEventRow(
|
||||
val titleInk = if (quiet) {
|
||||
GlanceTheme.colors.onSurface
|
||||
} else {
|
||||
themedColor(day = widgetEventInk(day, 1f), night = widgetEventInk(night, 1f))
|
||||
themedColor(day = widgetEventInk(day, 1f, dimmed), night = widgetEventInk(night, 1f, dimmed))
|
||||
}
|
||||
val timeInk = if (quiet) {
|
||||
GlanceTheme.colors.onSurfaceVariant
|
||||
} else {
|
||||
themedColor(day = widgetEventInk(day, SECONDARY_INK_ALPHA), night = widgetEventInk(night, SECONDARY_INK_ALPHA))
|
||||
themedColor(day = widgetEventInk(day, SECONDARY_INK_ALPHA, dimmed), night = widgetEventInk(night, SECONDARY_INK_ALPHA, dimmed))
|
||||
}
|
||||
Text(
|
||||
text = event.title.ifBlank { context.getString(R.string.event_untitled) },
|
||||
|
||||
@@ -26,7 +26,7 @@ import de.jeanlucmakiola.calendula.widget.split.MonthDayWidget
|
||||
import de.jeanlucmakiola.calendula.widget.split.MonthDayWidgetReceiver
|
||||
|
||||
/** A content option a widget's config screen offers, besides background and theme. */
|
||||
enum class WidgetOption { RANGE, TEXT_SIZE, PAST_DISPLAY, SHOW_TODAY, CALENDARS }
|
||||
enum class WidgetOption { STYLE, RANGE, TEXT_SIZE, PAST_DISPLAY, SHOW_TODAY, CALENDARS }
|
||||
|
||||
/**
|
||||
* Every widget the app ships, told apart by their receiver: what redraws,
|
||||
@@ -42,7 +42,14 @@ enum class WidgetKind(
|
||||
AGENDA(
|
||||
AgendaWidgetReceiver::class.java,
|
||||
R.string.widget_agenda_label,
|
||||
setOf(WidgetOption.RANGE, WidgetOption.TEXT_SIZE, WidgetOption.PAST_DISPLAY, WidgetOption.SHOW_TODAY, WidgetOption.CALENDARS),
|
||||
setOf(
|
||||
WidgetOption.STYLE,
|
||||
WidgetOption.RANGE,
|
||||
WidgetOption.TEXT_SIZE,
|
||||
WidgetOption.PAST_DISPLAY,
|
||||
WidgetOption.SHOW_TODAY,
|
||||
WidgetOption.CALENDARS,
|
||||
),
|
||||
DpSize(260.dp, 280.dp),
|
||||
),
|
||||
MONTH(
|
||||
|
||||
@@ -19,6 +19,9 @@ import kotlinx.datetime.toJavaDayOfWeek
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
import de.jeanlucmakiola.calendula.MainActivity
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import androidx.glance.appwidget.action.actionStartActivity
|
||||
|
||||
/** Cards sit one tone off the root fill, and stay opaque over a wallpaper (#292). */
|
||||
@Composable
|
||||
@@ -27,6 +30,19 @@ internal fun widgetCardColor(): ColorProvider = when (LocalWidgetBackground.curr
|
||||
WidgetBackground.TINTED, WidgetBackground.TRANSPARENT -> GlanceTheme.colors.widgetBackground
|
||||
}
|
||||
|
||||
/** Opens this occurrence in the app, backing out to [source]. */
|
||||
internal fun EventInstance.openIntent(context: Context, source: CalendarView) = MainActivity.openEventIntent(
|
||||
context = context,
|
||||
eventId = eventId,
|
||||
beginMillis = start.toEpochMilliseconds(),
|
||||
endMillis = end.toEpochMilliseconds(),
|
||||
source = source,
|
||||
)
|
||||
|
||||
/** Opens [date] in the app's agenda. */
|
||||
internal fun openDayAction(context: Context, date: LocalDate) =
|
||||
actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Agenda))
|
||||
|
||||
/** An event's time on [day]: "All day", "Starts 14:00", "Ends 9:00" or "14:00 – 15:30". */
|
||||
internal fun widgetTimeLabel(context: Context, event: EventInstance, day: LocalDate, is24Hour: Boolean): String =
|
||||
when (val label = agendaTimeLabel(event, day, systemZone())) {
|
||||
|
||||
@@ -34,7 +34,7 @@ fun CalendulaGlanceTheme(mode: WidgetThemeMode, content: @Composable () -> Unit)
|
||||
@Composable
|
||||
internal fun CalendulaWidgetRoot(appearance: WidgetAppearance, appTheme: ThemeMode, content: @Composable () -> Unit) {
|
||||
CalendulaGlanceTheme(appearance.theme.resolve(appTheme)) {
|
||||
WidgetSurface(background = appearance.background, content = content)
|
||||
WidgetSurface(background = appearance.background, opacity = appearance.opacity, content = content)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,15 @@ internal val ROW_H_PAD = 12.dp
|
||||
/** Space between the date-pill gutter and a day's card. */
|
||||
internal val GUTTER_GAP = 6.dp
|
||||
|
||||
/** The classic style's row inset, colour stripe and the gap after it (#489). */
|
||||
internal val CLASSIC_ROW_H_PAD = 4.dp
|
||||
internal val STRIPE_WIDTH = 5.dp
|
||||
internal val STRIPE_GAP = 10.dp
|
||||
|
||||
/** The stripe and its gap, and where the classic style's event text starts, so its placeholder lines up with the titles. */
|
||||
internal val STRIPE_INSET = STRIPE_WIDTH + STRIPE_GAP
|
||||
internal val TEXT_INDENT = CLASSIC_ROW_H_PAD + STRIPE_INSET
|
||||
|
||||
/**
|
||||
* Every size the agenda widget varies by [WidgetSize]. Values that genuinely
|
||||
* shouldn't grow (the horizontal row constants above, corner radii) stay
|
||||
@@ -29,6 +38,8 @@ internal data class AgendaMetrics(
|
||||
val rowVPad: Dp, // event row vertical padding
|
||||
val dayGap: Dp, // space between two days' cards
|
||||
val monthLabel: TextUnit, // month name above a new month's first day
|
||||
val dayHeader: TextUnit, // classic style's day label ("Today · …")
|
||||
val dayHeaderTopPad: Dp, // classic style's space above a day label
|
||||
) {
|
||||
/** One event row's height, shared by the date pill beside it and the placeholder row. */
|
||||
val rowHeight: Dp get() = contentH + rowVPad * 2
|
||||
@@ -61,6 +72,8 @@ private val SMALL_METRICS = AgendaMetrics(
|
||||
rowVPad = 4.dp,
|
||||
dayGap = 8.dp,
|
||||
monthLabel = 12.sp,
|
||||
dayHeader = 13.sp,
|
||||
dayHeaderTopPad = 10.dp,
|
||||
)
|
||||
|
||||
private val MEDIUM_METRICS = AgendaMetrics(
|
||||
@@ -75,6 +88,8 @@ private val MEDIUM_METRICS = AgendaMetrics(
|
||||
rowVPad = 5.dp,
|
||||
dayGap = 9.dp,
|
||||
monthLabel = 14.sp,
|
||||
dayHeader = 14.sp, // M3 Title Small
|
||||
dayHeaderTopPad = 11.dp,
|
||||
)
|
||||
|
||||
private val LARGE_METRICS = AgendaMetrics(
|
||||
@@ -89,6 +104,8 @@ private val LARGE_METRICS = AgendaMetrics(
|
||||
rowVPad = 6.dp,
|
||||
dayGap = 10.dp,
|
||||
monthLabel = 14.sp,
|
||||
dayHeader = 16.sp, // M3 Title Medium
|
||||
dayHeaderTopPad = 12.dp,
|
||||
)
|
||||
|
||||
private val EXTRA_LARGE_METRICS = AgendaMetrics(
|
||||
@@ -103,6 +120,8 @@ private val EXTRA_LARGE_METRICS = AgendaMetrics(
|
||||
rowVPad = 8.dp,
|
||||
dayGap = 12.dp,
|
||||
monthLabel = 16.sp,
|
||||
dayHeader = 18.sp, // †
|
||||
dayHeaderTopPad = 14.dp,
|
||||
)
|
||||
|
||||
/** Indexed by [WidgetSize.ordinal] so lookup allocates nothing per recomposition. */
|
||||
|
||||
@@ -11,6 +11,7 @@ import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
@@ -55,8 +56,15 @@ import de.jeanlucmakiola.calendula.ui.agenda.anchorTodayIfMissing
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetDimFillAlpha
|
||||
import de.jeanlucmakiola.calendula.widget.widgetEventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetStyle
|
||||
import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
|
||||
import de.jeanlucmakiola.floret.glance.LocalWidgetSeeThrough
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
||||
import de.jeanlucmakiola.floret.glance.withAlpha
|
||||
import de.jeanlucmakiola.calendula.widget.widgetEventInk
|
||||
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
|
||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
||||
@@ -65,6 +73,8 @@ import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
|
||||
import de.jeanlucmakiola.calendula.widget.widgetCardColor
|
||||
import de.jeanlucmakiola.calendula.widget.widgetTimeLabel
|
||||
import de.jeanlucmakiola.calendula.widget.openIntent
|
||||
import de.jeanlucmakiola.calendula.widget.openDayAction
|
||||
import de.jeanlucmakiola.calendula.widget.loadAgendaWidget
|
||||
import de.jeanlucmakiola.calendula.widget.widgetAppearance
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
@@ -87,7 +97,8 @@ import kotlinx.datetime.toJavaDayOfWeek
|
||||
|
||||
/**
|
||||
* "Upcoming" agenda widget — a continuously scrolling list of the next ~30 days
|
||||
* of events, each day one card of grouped rows with its date pill beside it.
|
||||
* of events: each day one card of grouped rows with its date pill beside it, or
|
||||
* in the classic style a label line over plain colour-stripe rows (#489).
|
||||
* Reuses the app's [groupAgendaDays] grouping so it matches the in-app agenda.
|
||||
*/
|
||||
class AgendaWidget : GlanceAppWidget() {
|
||||
@@ -150,10 +161,10 @@ private const val PREVIEW_ROWS = 10
|
||||
* user scrolling a home-screen widget past a hundred rows is not a case worth
|
||||
* risking a failed update for.
|
||||
*/
|
||||
private const val MAX_AGENDA_ROWS = 100
|
||||
internal const val MAX_AGENDA_ROWS = 100
|
||||
|
||||
/** Flat row model: one [LazyColumn] item per event or placeholder, a day's first one carrying its date pill. */
|
||||
private sealed interface AgendaRow {
|
||||
internal sealed interface AgendaRow {
|
||||
val date: LocalDate
|
||||
|
||||
data class Event(override val date: LocalDate, val event: EventInstance) : AgendaRow
|
||||
@@ -168,16 +179,21 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, preview: Boolean) {
|
||||
val size = (data as? AgendaWidgetData.Ready)?.textSize ?: WidgetSize.SMALL
|
||||
val fontScale = androidx.glance.LocalContext.current.resources.configuration.fontScale
|
||||
val metrics = metricsFor(size).scaledForFont(fontScale)
|
||||
Column(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
) {
|
||||
AgendaHeader()
|
||||
Spacer(GlanceModifier.height(6.dp))
|
||||
when (data) {
|
||||
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission, metrics)
|
||||
is AgendaWidgetData.Ready -> AgendaRows(data = data, metrics = metrics, preview = preview)
|
||||
// The classic style adds no backings for a faded fill, only on a transparent one (#489).
|
||||
val classic = (data as? AgendaWidgetData.Ready)?.style == AgendaWidgetStyle.CLASSIC
|
||||
val seeThrough = if (classic) LocalWidgetBackground.current == WidgetBackground.TRANSPARENT else LocalWidgetSeeThrough.current
|
||||
CompositionLocalProvider(LocalWidgetSeeThrough provides seeThrough) {
|
||||
Column(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
) {
|
||||
AgendaHeader()
|
||||
Spacer(GlanceModifier.height(6.dp))
|
||||
when (data) {
|
||||
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission, metrics)
|
||||
is AgendaWidgetData.Ready -> AgendaRows(data = data, metrics = metrics, preview = preview)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -219,60 +235,248 @@ private fun AgendaRows(
|
||||
day.events.forEach { add(AgendaRow.Event(day.date, it)) }
|
||||
}
|
||||
}
|
||||
}.take(MAX_AGENDA_ROWS)
|
||||
// Each day is one card of grouped rows (#71), with its date pill
|
||||
// beside the first row.
|
||||
val positions = dayPositions(rows)
|
||||
val cardColor = widgetCardColor()
|
||||
val item: @Composable (Int) -> Unit = { index ->
|
||||
val row = rows[index]
|
||||
val position = positions[index]
|
||||
val first = position == Position.Top || position == Position.Alone
|
||||
val dayRow: @Composable (GlanceModifier) -> Unit = { modifier ->
|
||||
Row(modifier = modifier.fillMaxWidth()) {
|
||||
Box(modifier = GlanceModifier.width(metrics.pillWidth)) {
|
||||
if (first) DatePill(row.date, data.today, cardColor, metrics)
|
||||
}
|
||||
Spacer(GlanceModifier.width(GUTTER_GAP))
|
||||
Box(modifier = GlanceModifier.defaultWeight()) {
|
||||
val rowColor = when (row) {
|
||||
is AgendaRow.Event -> row.fill(data).let { themedColor(day = it.day, night = it.night) }
|
||||
is AgendaRow.Placeholder -> cardColor
|
||||
}
|
||||
WidgetGroupedRow(position = position, color = rowColor) {
|
||||
AgendaRowContent(row, data, metrics)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val gap = GlanceModifier.padding(top = if (first && index > 0) metrics.dayGap else 0.dp)
|
||||
// The pills carry no month, so a new month gets its name above its first day.
|
||||
if (first && index > 0 && row.date.month != rows[index - 1].date.month) {
|
||||
Column(modifier = gap.fillMaxWidth()) {
|
||||
MonthLabel(row.date, data.today, metrics)
|
||||
dayRow(GlanceModifier)
|
||||
}
|
||||
} else {
|
||||
dayRow(gap)
|
||||
}
|
||||
}
|
||||
if (preview) {
|
||||
Column(modifier = GlanceModifier.fillMaxSize()) {
|
||||
repeat(minOf(rows.size, PREVIEW_ROWS)) { item(it) }
|
||||
}
|
||||
} else {
|
||||
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
|
||||
items(rows.size) { item(it) }
|
||||
}
|
||||
when (data.style) {
|
||||
AgendaWidgetStyle.CARDS -> CardRows(rows, data, metrics, preview)
|
||||
AgendaWidgetStyle.CLASSIC -> ClassicRows(rows, data, metrics, preview)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Where each row sits among the rows of its day, for the grouped-card shapes. */
|
||||
private fun dayPositions(rows: List<AgendaRow>): List<Position> = buildList {
|
||||
/** Each day is one card of grouped rows (#71), with its date pill beside the first row. */
|
||||
@Composable
|
||||
private fun CardRows(allRows: List<AgendaRow>, data: AgendaWidgetData.Ready, metrics: AgendaMetrics, preview: Boolean) {
|
||||
val rows = allRows.take(MAX_AGENDA_ROWS)
|
||||
val positions = dayPositions(rows.map { it.date })
|
||||
val cardColor = widgetCardColor()
|
||||
val item: @Composable (Int) -> Unit = { index ->
|
||||
val row = rows[index]
|
||||
val position = positions[index]
|
||||
val first = position == Position.Top || position == Position.Alone
|
||||
val dayRow: @Composable (GlanceModifier) -> Unit = { modifier ->
|
||||
Row(modifier = modifier.fillMaxWidth()) {
|
||||
Box(modifier = GlanceModifier.width(metrics.pillWidth)) {
|
||||
if (first) DatePill(row.date, data.today, cardColor, metrics)
|
||||
}
|
||||
Spacer(GlanceModifier.width(GUTTER_GAP))
|
||||
Box(modifier = GlanceModifier.defaultWeight()) {
|
||||
val rowColor = when (row) {
|
||||
is AgendaRow.Event -> row.fill(data).let { themedColor(day = it.day, night = it.night) }
|
||||
is AgendaRow.Placeholder -> cardColor
|
||||
}
|
||||
WidgetGroupedRow(position = position, color = rowColor) {
|
||||
AgendaRowContent(row, data, metrics)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val gap = GlanceModifier.padding(top = if (first && index > 0) metrics.dayGap else 0.dp)
|
||||
// The pills carry no month, so a new month gets its name above its first day.
|
||||
if (first && index > 0 && row.date.month != rows[index - 1].date.month) {
|
||||
Column(modifier = gap.fillMaxWidth()) {
|
||||
MonthLabel(row.date, data.today, metrics)
|
||||
dayRow(GlanceModifier)
|
||||
}
|
||||
} else {
|
||||
dayRow(gap)
|
||||
}
|
||||
}
|
||||
RowList(rows.size, preview, item)
|
||||
}
|
||||
|
||||
/** [count] rows of [item]: a plain column in a [preview], else a [LazyColumn]. */
|
||||
@Composable
|
||||
private fun RowList(count: Int, preview: Boolean, item: @Composable (Int) -> Unit) {
|
||||
if (preview) {
|
||||
Column(modifier = GlanceModifier.fillMaxSize()) {
|
||||
repeat(minOf(count, PREVIEW_ROWS)) { item(it) }
|
||||
}
|
||||
} else {
|
||||
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
|
||||
items(count) { item(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A classic list entry: a day's label line, or one of its rows. */
|
||||
internal sealed interface ClassicItem {
|
||||
val date: LocalDate
|
||||
|
||||
data class Header(override val date: LocalDate) : ClassicItem
|
||||
data class Entry(val row: AgendaRow) : ClassicItem {
|
||||
override val date: LocalDate get() = row.date
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [rows] with a label before each day's first, held to [limit] items in all,
|
||||
* headers included; a label the cut leaves with nothing under it is dropped.
|
||||
*/
|
||||
internal fun classicItems(rows: List<AgendaRow>, limit: Int = MAX_AGENDA_ROWS): List<ClassicItem> = buildList {
|
||||
rows.forEachIndexed { index, row ->
|
||||
if (index == 0 || row.date != rows[index - 1].date) add(ClassicItem.Header(row.date))
|
||||
add(ClassicItem.Entry(row))
|
||||
}
|
||||
}.take(limit).dropLastWhile { it is ClassicItem.Header }
|
||||
|
||||
/**
|
||||
* v2.23's look (#489): a label line per day over plain rows with a colour
|
||||
* stripe. On a transparent root each day sits on its own backing card, so the
|
||||
* text stays readable over the wallpaper; a faded fill is left to the opacity
|
||||
* and "Invert text colour" options.
|
||||
*/
|
||||
@Composable
|
||||
private fun ClassicRows(rows: List<AgendaRow>, data: AgendaWidgetData.Ready, metrics: AgendaMetrics, preview: Boolean) {
|
||||
val items = classicItems(rows, if (preview) PREVIEW_ROWS else MAX_AGENDA_ROWS)
|
||||
val backed = LocalWidgetBackground.current == WidgetBackground.TRANSPARENT
|
||||
val ink = classicInk(inverted = data.invertText && !backed)
|
||||
val positions = if (backed) dayPositions(items.map { it.date }) else emptyList()
|
||||
val cardColor = widgetCardColor()
|
||||
val item: @Composable (Int) -> Unit = { index ->
|
||||
val entry = items[index]
|
||||
val content: @Composable () -> Unit = {
|
||||
when (entry) {
|
||||
is ClassicItem.Header -> DayHeaderRow(
|
||||
date = entry.date,
|
||||
today = data.today,
|
||||
startPad = if (backed) CLASSIC_CARD_H_PAD else 8.dp,
|
||||
topPad = when {
|
||||
backed -> CLASSIC_CARD_TOP_PAD
|
||||
index == 0 -> 0.dp
|
||||
else -> metrics.dayHeaderTopPad
|
||||
},
|
||||
ink = ink,
|
||||
metrics = metrics,
|
||||
)
|
||||
is ClassicItem.Entry -> when (val row = entry.row) {
|
||||
is AgendaRow.Placeholder -> ClassicPlaceholderRow(row.date, backed, ink, metrics)
|
||||
is AgendaRow.Event -> ClassicEventRow(row, data, backed, ink, metrics)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (backed) {
|
||||
val gap = if (entry is ClassicItem.Header && index > 0) metrics.dayGap else 0.dp
|
||||
WidgetGroupedRow(
|
||||
position = positions[index],
|
||||
modifier = GlanceModifier.padding(top = gap),
|
||||
color = cardColor,
|
||||
gapBelow = false,
|
||||
content = content,
|
||||
)
|
||||
} else {
|
||||
content()
|
||||
}
|
||||
}
|
||||
RowList(items.size, preview, item)
|
||||
}
|
||||
|
||||
/** The classic style's text colours: the theme's, or its inverse for the "Invert text colour" option. */
|
||||
private data class ClassicInk(val title: ColorProvider, val secondary: ColorProvider, val today: ColorProvider)
|
||||
|
||||
@Composable
|
||||
private fun classicInk(inverted: Boolean): ClassicInk = with(GlanceTheme.colors) {
|
||||
if (inverted) {
|
||||
ClassicInk(inverseOnSurface, inverseOnSurface.withAlpha(INVERTED_SECONDARY_INK), inversePrimary)
|
||||
} else {
|
||||
ClassicInk(onSurface, onSurfaceVariant, primary)
|
||||
}
|
||||
}
|
||||
|
||||
/** A classic day label, "Today · Wed, 17 Jun"; opens that day in the app. */
|
||||
@Composable
|
||||
private fun DayHeaderRow(date: LocalDate, today: LocalDate, startPad: Dp, topPad: Dp, ink: ClassicInk, metrics: AgendaMetrics) {
|
||||
val context = androidx.glance.LocalContext.current
|
||||
Text(
|
||||
text = agendaDayLabel(context, date, today),
|
||||
maxLines = 1,
|
||||
style = TextStyle(
|
||||
color = if (date == today) ink.today else ink.secondary,
|
||||
fontSize = metrics.dayHeader,
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = startPad, end = 8.dp, top = topPad, bottom = metrics.rowVPad)
|
||||
.clickable(openDayAction(context, date)),
|
||||
)
|
||||
}
|
||||
|
||||
/** "No more events today" as a muted line under an anchored, event-less today (#35), indented to the titles. */
|
||||
@Composable
|
||||
private fun ClassicPlaceholderRow(date: LocalDate, backed: Boolean, ink: ClassicInk, metrics: AgendaMetrics) {
|
||||
val context = androidx.glance.LocalContext.current
|
||||
Text(
|
||||
text = context.getString(R.string.agenda_no_more_today),
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = ink.secondary, fontSize = metrics.placeholder),
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = if (backed) CLASSIC_CARD_H_PAD + STRIPE_INSET else TEXT_INDENT, end = 8.dp, top = 2.dp, bottom = metrics.rowVPad + 2.dp)
|
||||
.clickable(openDayAction(context, date)),
|
||||
)
|
||||
}
|
||||
|
||||
/** A classic event row: the calendar's colour stripe beside the title and time; a dimmed one fades both. */
|
||||
@Composable
|
||||
private fun ClassicEventRow(row: AgendaRow.Event, data: AgendaWidgetData.Ready, backed: Boolean, ink: ClassicInk, metrics: AgendaMetrics) {
|
||||
val context = androidx.glance.LocalContext.current
|
||||
val event = row.event
|
||||
val dimmed = row.dimmed(data)
|
||||
fun accent(dark: Boolean) = eventAccent(event.color, dark, data.soften).let {
|
||||
if (dimmed) it.copy(alpha = EventDimAlpha) else it
|
||||
}
|
||||
Row(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = if (backed) CLASSIC_CARD_H_PAD else CLASSIC_ROW_H_PAD, vertical = metrics.rowVPad)
|
||||
.clickable(actionStartActivity(event.openIntent(context, CalendarView.Agenda))),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.width(STRIPE_WIDTH)
|
||||
.height(metrics.contentH)
|
||||
.cornerRadius(3.dp)
|
||||
.background(themedColor(day = accent(dark = false), night = accent(dark = true))),
|
||||
) {}
|
||||
Spacer(GlanceModifier.width(STRIPE_GAP))
|
||||
Column(modifier = GlanceModifier.defaultWeight()) {
|
||||
Text(
|
||||
text = event.title.ifBlank { context.getString(R.string.event_untitled) },
|
||||
maxLines = 1,
|
||||
style = TextStyle(
|
||||
color = if (dimmed) ink.title.withAlpha(CLASSIC_DIM_INK) else ink.title,
|
||||
fontSize = metrics.eventTitle,
|
||||
textDecoration = glanceDeclinedDecoration(event.isCrossedOut),
|
||||
),
|
||||
)
|
||||
Text(
|
||||
text = eventTimeSummary(context, event, row.date, data.is24Hour),
|
||||
maxLines = 1,
|
||||
style = TextStyle(
|
||||
color = if (dimmed) ink.secondary.withAlpha(CLASSIC_DIM_INK) else ink.secondary,
|
||||
fontSize = metrics.eventTime,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Space above a day's label on its own backing card, and its rows' inset, clear of the card's corners. */
|
||||
private val CLASSIC_CARD_TOP_PAD = 8.dp
|
||||
private val CLASSIC_CARD_H_PAD = 10.dp
|
||||
|
||||
/** How strongly a finished event's text shows in the classic style, on any fill. */
|
||||
private const val CLASSIC_DIM_INK = 0.5f
|
||||
|
||||
/** The inverted secondary text's strength, as `onSurfaceVariant` sits below `onSurface`. */
|
||||
private const val INVERTED_SECONDARY_INK = 0.75f
|
||||
|
||||
/** Where each row sits among the rows of its day, from each row's date, for the grouped-card shapes. */
|
||||
private fun dayPositions(dates: List<LocalDate>): List<Position> = buildList {
|
||||
var start = 0
|
||||
for (end in 1..rows.size) {
|
||||
if (end == rows.size || rows[end].date != rows[start].date) {
|
||||
for (end in 1..dates.size) {
|
||||
if (end == dates.size || dates[end] != dates[start]) {
|
||||
for (i in start until end) add(positionOf(i - start, end - start))
|
||||
start = end
|
||||
}
|
||||
@@ -343,7 +547,7 @@ private fun DatePill(date: LocalDate, today: LocalDate, cardColor: ColorProvider
|
||||
.height(metrics.rowHeight)
|
||||
.cornerRadius(metrics.pillWidth / 2)
|
||||
.background(container)
|
||||
.clickable(actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Agenda)))
|
||||
.clickable(openDayAction(context, date))
|
||||
.semantics { contentDescription = label },
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -375,11 +579,7 @@ private fun PlaceholderRow(date: LocalDate, metrics: AgendaMetrics) {
|
||||
.fillMaxWidth()
|
||||
.height(metrics.rowHeight)
|
||||
.padding(horizontal = ROW_H_PAD)
|
||||
.clickable(
|
||||
actionStartActivity(
|
||||
MainActivity.openDateIntent(context, date, CalendarView.Agenda),
|
||||
),
|
||||
),
|
||||
.clickable(openDayAction(context, date)),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
Text(
|
||||
@@ -391,14 +591,19 @@ private fun PlaceholderRow(date: LocalDate, metrics: AgendaMetrics) {
|
||||
}
|
||||
|
||||
/** An event row's light and dark fill: its calendar's container tone, faded when the row is dimmed. */
|
||||
private data class EventFill(val day: Color, val night: Color)
|
||||
private data class EventFill(val day: Color, val night: Color, val dimmed: Boolean)
|
||||
|
||||
/** Whether this row is drawn faded as a finished event. */
|
||||
private fun AgendaRow.Event.dimmed(data: AgendaWidgetData.Ready): Boolean =
|
||||
data.pastDisplay == PastEventDisplay.DIM && event.hasEnded(data.now)
|
||||
|
||||
private fun AgendaRow.Event.fill(data: AgendaWidgetData.Ready): EventFill {
|
||||
val dimmed = data.pastDisplay == PastEventDisplay.DIM && event.hasEnded(data.now)
|
||||
fun tone(dark: Boolean) = eventFill(event.color, dark, data.soften).let {
|
||||
if (dimmed) it.copy(alpha = WidgetDimFillAlpha) else it
|
||||
}
|
||||
return EventFill(day = tone(dark = false), night = tone(dark = true))
|
||||
val dimmed = dimmed(data)
|
||||
return EventFill(
|
||||
day = widgetEventFill(event.color, dark = false, data.soften, dimmed),
|
||||
night = widgetEventFill(event.color, dark = true, data.soften, dimmed),
|
||||
dimmed = dimmed,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -411,27 +616,17 @@ private fun EventRow(
|
||||
) {
|
||||
val context = androidx.glance.LocalContext.current
|
||||
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||
val titleInk = themedColor(day = widgetEventInk(fill.day, 1f), night = widgetEventInk(fill.night, 1f))
|
||||
val titleInk = themedColor(day = widgetEventInk(fill.day, 1f, fill.dimmed), night = widgetEventInk(fill.night, 1f, fill.dimmed))
|
||||
val timeInk = themedColor(
|
||||
day = widgetEventInk(fill.day, SECONDARY_INK_ALPHA),
|
||||
night = widgetEventInk(fill.night, SECONDARY_INK_ALPHA),
|
||||
day = widgetEventInk(fill.day, SECONDARY_INK_ALPHA, fill.dimmed),
|
||||
night = widgetEventInk(fill.night, SECONDARY_INK_ALPHA, fill.dimmed),
|
||||
)
|
||||
Column(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
.height(metrics.rowHeight)
|
||||
.padding(horizontal = ROW_H_PAD)
|
||||
.clickable(
|
||||
actionStartActivity(
|
||||
MainActivity.openEventIntent(
|
||||
context = context,
|
||||
eventId = event.eventId,
|
||||
beginMillis = event.start.toEpochMilliseconds(),
|
||||
endMillis = event.end.toEpochMilliseconds(),
|
||||
source = CalendarView.Agenda,
|
||||
),
|
||||
),
|
||||
),
|
||||
.clickable(actionStartActivity(event.openIntent(context, CalendarView.Agenda))),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
@@ -474,7 +669,7 @@ private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate):
|
||||
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
|
||||
// Weekday + date in the locale's own field order (compact: no year), rather
|
||||
// than a hardcoded day-month layout.
|
||||
val formatted = localizedDateFormatter(locale, "EEEdMMM").format(java)
|
||||
val formatted = localizedDateFormatter(locale, if (date.year == today.year) "EEEdMMM" else "EEEdMMMy").format(java)
|
||||
return if (relative != null) "$relative · $formatted" else formatted
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,16 @@ import de.jeanlucmakiola.floret.glance.GlanceWidgetPreview
|
||||
import de.jeanlucmakiola.floret.glance.WallpaperWindow
|
||||
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
||||
import kotlin.math.roundToInt
|
||||
import de.jeanlucmakiola.floret.components.GroupedSliderRow
|
||||
import de.jeanlucmakiola.floret.components.InfoPopover
|
||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetStyle
|
||||
import java.text.NumberFormat
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.material.icons.outlined.Info
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.foundation.layout.Row
|
||||
|
||||
private val PreviewMaxHeight = 280.dp
|
||||
|
||||
@@ -236,6 +246,42 @@ private fun ConfigOptions(
|
||||
onSelect = { onAppearance(state.appearance.copy(background = it)) },
|
||||
position = Position.Top,
|
||||
)
|
||||
val faded = state.appearance.background != WidgetBackground.TRANSPARENT
|
||||
val content = state.draft.content
|
||||
val classic = state.kind == WidgetKind.AGENDA && content.style == AgendaWidgetStyle.CLASSIC
|
||||
if (faded) {
|
||||
val opacity = state.appearance.opacity
|
||||
GroupedSliderRow(
|
||||
title = stringResource(R.string.widget_config_opacity),
|
||||
value = opacity,
|
||||
onValueChange = { onAppearance(state.appearance.copy(opacity = it)) },
|
||||
position = Position.Middle,
|
||||
valueLabel = currentLocale().let { locale ->
|
||||
remember(opacity, locale) { NumberFormat.getPercentInstance(locale).format(opacity) }
|
||||
},
|
||||
valueRange = MIN_OPACITY..1f,
|
||||
steps = OPACITY_STEPS,
|
||||
titleAccessory = {
|
||||
InfoPopover(
|
||||
text = stringResource(
|
||||
if (classic) R.string.widget_config_readability_hint_classic else R.string.widget_config_readability_hint,
|
||||
),
|
||||
contentDescription = stringResource(R.string.widget_config_readability_info),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
if (faded && classic) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.widget_config_invert_text),
|
||||
summary = stringResource(R.string.widget_config_invert_text_summary),
|
||||
position = Position.Middle,
|
||||
trailing = {
|
||||
Switch(checked = content.invertText, onCheckedChange = { onContent(content.copy(invertText = it)) })
|
||||
},
|
||||
onClick = { onContent(content.copy(invertText = !content.invertText)) },
|
||||
)
|
||||
}
|
||||
GroupedChoiceRow(
|
||||
title = stringResource(R.string.settings_theme),
|
||||
options = WidgetTheme.entries,
|
||||
@@ -265,6 +311,7 @@ private fun ContentOptions(
|
||||
onCalendarShown: (Long, Boolean) -> Unit,
|
||||
) {
|
||||
val options = state.draft.content
|
||||
var showStyle by rememberSaveable { mutableStateOf(false) }
|
||||
var showRange by rememberSaveable { mutableStateOf(false) }
|
||||
var showSize by rememberSaveable { mutableStateOf(false) }
|
||||
var showPast by rememberSaveable { mutableStateOf(false) }
|
||||
@@ -277,6 +324,12 @@ private fun ContentOptions(
|
||||
GroupedSectionHeader(stringResource(R.string.widget_config_content))
|
||||
rows.forEach { option ->
|
||||
when (option) {
|
||||
WidgetOption.STYLE -> GroupedRow(
|
||||
title = stringResource(R.string.widget_config_style),
|
||||
summary = agendaStyleLabel(options.style),
|
||||
position = position(option),
|
||||
onClick = { showStyle = true },
|
||||
)
|
||||
WidgetOption.RANGE -> GroupedRow(
|
||||
title = stringResource(R.string.widget_config_range),
|
||||
summary = agendaRangeLabel(options.range),
|
||||
@@ -319,6 +372,17 @@ private fun ContentOptions(
|
||||
)
|
||||
}
|
||||
|
||||
if (showStyle) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.widget_config_style),
|
||||
predictiveBack = true,
|
||||
options = AgendaWidgetStyle.entries,
|
||||
selected = options.style,
|
||||
label = { agendaStyleLabel(it) },
|
||||
onSelect = { onChange(options.copy(style = it)) },
|
||||
onDismiss = { showStyle = false },
|
||||
)
|
||||
}
|
||||
if (showRange) {
|
||||
AgendaRangePicker(
|
||||
title = stringResource(R.string.widget_config_range),
|
||||
@@ -356,6 +420,18 @@ private fun ContentOptions(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun agendaStyleLabel(style: AgendaWidgetStyle): String = stringResource(
|
||||
when (style) {
|
||||
AgendaWidgetStyle.CARDS -> R.string.widget_config_style_cards
|
||||
AgendaWidgetStyle.CLASSIC -> R.string.widget_config_style_classic
|
||||
},
|
||||
)
|
||||
|
||||
/** The faintest fill the opacity slider allows, and its 10% steps between that and opaque. */
|
||||
private const val MIN_OPACITY = 0.2f
|
||||
private const val OPACITY_STEPS = 7
|
||||
|
||||
@Composable
|
||||
internal fun backgroundLabel(background: WidgetBackground): String = stringResource(
|
||||
when (background) {
|
||||
|
||||
@@ -42,6 +42,7 @@ import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.coversWholeDays
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.widget.openIntent
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
|
||||
@@ -132,13 +133,7 @@ private fun EventCountdown(event: EventInstance, data: CountdownData.Ready) {
|
||||
val context = LocalContext.current
|
||||
val label = countdownLabel(event.counted(systemZone()), data.now, systemZone())
|
||||
val open = actionStartActivity(
|
||||
MainActivity.openEventIntent(
|
||||
context = context,
|
||||
eventId = event.eventId,
|
||||
beginMillis = event.start.toEpochMilliseconds(),
|
||||
endMillis = event.end.toEpochMilliseconds(),
|
||||
source = CalendarView.Agenda,
|
||||
),
|
||||
event.openIntent(context, CalendarView.Agenda),
|
||||
)
|
||||
// Over a wallpaper the text sits on its own card, as every other widget's rows do.
|
||||
val card = GlanceModifier.wallpaperBacking(radius = 16.dp)
|
||||
|
||||
@@ -36,11 +36,25 @@ internal fun <T : Any> mergeGaps(columns: Int, cellAt: (Int) -> GridCell<T>?): L
|
||||
return cells
|
||||
}
|
||||
|
||||
/** What a cell of the single mixed lane shows: an event's bar, or a day's event count. */
|
||||
internal sealed interface LaneItem {
|
||||
data class Bar(val event: EventInstance) : LaneItem
|
||||
data class Count(val count: Int) : LaneItem
|
||||
}
|
||||
|
||||
/**
|
||||
* One week of the grid seated with [laneCap] lanes. [showsOverflow] is off when
|
||||
* the week has no room for even the "+N" row.
|
||||
* One week of the grid seated with [laneCap] lanes, drawn [compact] when the
|
||||
* row is too short for a full one. [showsOverflow] is off when the week has no
|
||||
* room for the "+N" row; a [mixed] single lane then shows each day's count in
|
||||
* place of its bar where the day has more than fits (#491).
|
||||
*/
|
||||
internal class WeekLayout(val week: MonthWeek, val laneCap: Int, val showsOverflow: Boolean = true) {
|
||||
internal class WeekLayout(
|
||||
val week: MonthWeek,
|
||||
val laneCap: Int,
|
||||
val showsOverflow: Boolean = true,
|
||||
val compact: Boolean = false,
|
||||
val mixed: Boolean = false,
|
||||
) {
|
||||
private val seating: List<DaySeating> = week.days.indices.map { week.seating(it, laneCap) }
|
||||
|
||||
/** Lanes filled from the top; empty lanes below are not drawn. */
|
||||
@@ -55,6 +69,16 @@ internal class WeekLayout(val week: MonthWeek, val laneCap: Int, val showsOverfl
|
||||
}
|
||||
}
|
||||
|
||||
/** The single lane of a [mixed] week, bars cut short where a day shows its count instead; null otherwise. */
|
||||
val mixedLane: List<GridCell<LaneItem>>? = if (!mixed) null else mergeGaps(week.days.size) { col ->
|
||||
val day = seating[col]
|
||||
if (day.hidden.isNotEmpty()) return@mergeGaps GridCell(LaneItem.Count(week.countByDay[week.days[col]] ?: day.hidden.size), 1)
|
||||
val seat = day.seated.firstOrNull() ?: return@mergeGaps null
|
||||
val spanEnd = if (seat.bar) week.spanAt(col, seat.lane)?.endCol ?: col else col
|
||||
val end = (col + 1..spanEnd).firstOrNull { seating[it].hidden.isNotEmpty() }?.minus(1) ?: spanEnd
|
||||
GridCell(LaneItem.Bar(seat.event), end - col + 1)
|
||||
}
|
||||
|
||||
/** The "+N" row as counts and merged gaps; empty when everything fits. */
|
||||
val overflow: List<GridCell<Int>> = seating.map { it.hidden.size }.let { hidden ->
|
||||
if (hidden.none { it > 0 }) emptyList()
|
||||
@@ -62,19 +86,31 @@ internal class WeekLayout(val week: MonthWeek, val laneCap: Int, val showsOverfl
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Event lanes a week row [weekHeight] tall holds under its day number, held to
|
||||
* [MAX_LANES] so a tall widget's payload stays bounded (#214).
|
||||
*/
|
||||
internal fun lanesFitting(weekHeight: Dp): Int =
|
||||
((weekHeight.value - DAY_ROW_DP - LANES_TOP_DP) / LANE_ROW_DP).toInt().coerceIn(0, MAX_LANES)
|
||||
/** How many lanes a week row holds, and whether they're the [compact] height. */
|
||||
internal data class LaneFit(val lanes: Int, val compact: Boolean = false)
|
||||
|
||||
/**
|
||||
* Seats each week in [lanes] lanes. A week whose events need more gives its
|
||||
* last lane to the "+N" row instead.
|
||||
* Event lanes a week row [weekHeight] tall holds under its day number, held to
|
||||
* [MAX_LANES] so a tall widget's payload stays bounded (#214). A row too short
|
||||
* for one full lane still gets a compact one when its text fits at [fontScale] (#491).
|
||||
*/
|
||||
internal fun fitWeeks(weeks: List<MonthWeek>, lanes: Int): List<WeekLayout> = weeks.map { week ->
|
||||
if (lanes == 0) return@map WeekLayout(week, laneCap = 0, showsOverflow = false)
|
||||
internal fun lanesFitting(weekHeight: Dp, fontScale: Float): LaneFit {
|
||||
val room = weekHeight.value - DAY_ROW_DP - LANES_TOP_DP
|
||||
val full = (room / LANE_ROW_DP).toInt().coerceIn(0, MAX_LANES)
|
||||
val compactFits = room >= COMPACT_LANE_ROW_DP && fontScale <= COMPACT_MAX_FONT_SCALE
|
||||
return if (full == 0 && compactFits) LaneFit(1, compact = true) else LaneFit(full)
|
||||
}
|
||||
|
||||
/**
|
||||
* Seats each week in [fit]'s lanes. A week whose events need more gives its
|
||||
* last lane to the "+N" row instead. A single lane has no room for that row,
|
||||
* so it mixes bars and per-day counts (#491).
|
||||
*/
|
||||
internal fun fitWeeks(weeks: List<MonthWeek>, fit: LaneFit): List<WeekLayout> = weeks.map { week ->
|
||||
val lanes = fit.lanes
|
||||
if (lanes <= 1) {
|
||||
return@map WeekLayout(week, laneCap = lanes, showsOverflow = false, compact = fit.compact, mixed = lanes == 1)
|
||||
}
|
||||
val needed = WeekLayout(week, UNCAPPED_LANES).usedLanes
|
||||
WeekLayout(week, if (needed <= lanes) lanes else lanes - 1)
|
||||
}
|
||||
@@ -85,4 +121,7 @@ internal const val MAX_LANES = 8
|
||||
private const val DAY_ROW_DP = 18f
|
||||
private const val LANES_TOP_DP = 2f
|
||||
private const val LANE_ROW_DP = 15f
|
||||
/** Matches `widget_month_lane_compact`'s height; its 9sp text clips past [COMPACT_MAX_FONT_SCALE]. */
|
||||
private const val COMPACT_LANE_ROW_DP = 12f
|
||||
private const val COMPACT_MAX_FONT_SCALE = 1f
|
||||
private const val UNCAPPED_LANES = 64
|
||||
@@ -91,6 +91,21 @@ private fun weekViews(
|
||||
week.setViewVisibility(TODAY_IDS[col], if (isToday) View.VISIBLE else View.GONE)
|
||||
if (isToday) week.setImageTint(context, TODAY_IDS[col], colors.primary)
|
||||
}
|
||||
layout.mixedLane?.let { cells ->
|
||||
val lane = laneViews(context, layout.compact)
|
||||
cells.forEach { cell ->
|
||||
lane.addView(
|
||||
R.id.lane,
|
||||
when (val item = cell.item) {
|
||||
null -> gap(context, cell.cols)
|
||||
is LaneItem.Bar -> bar(context, item.event, cell.cols, soften, mode)
|
||||
is LaneItem.Count -> more(context, item.count, colors)
|
||||
},
|
||||
)
|
||||
}
|
||||
week.addView(R.id.week_lanes, lane)
|
||||
return week
|
||||
}
|
||||
layout.lanes.forEach { cells ->
|
||||
val lane = laneViews(context)
|
||||
cells.forEach { cell ->
|
||||
@@ -106,10 +121,7 @@ private fun weekViews(
|
||||
if (count == null) {
|
||||
lane.addView(R.id.lane, gap(context, cell.cols))
|
||||
} else {
|
||||
val more = RemoteViews(context.packageName, R.layout.widget_month_more)
|
||||
more.setTextViewText(R.id.more, "+$count")
|
||||
more.setTextColor(context, R.id.more, colors.onSurfaceVariant)
|
||||
lane.addView(R.id.lane, more)
|
||||
lane.addView(R.id.lane, more(context, count, colors))
|
||||
}
|
||||
}
|
||||
week.addView(R.id.week_lanes, lane)
|
||||
@@ -117,8 +129,15 @@ private fun weekViews(
|
||||
return week
|
||||
}
|
||||
|
||||
private fun laneViews(context: Context) =
|
||||
RemoteViews(context.packageName, R.layout.widget_month_lane).apply { removeAllViews(R.id.lane) }
|
||||
private fun more(context: Context, count: Int, colors: GridColors) =
|
||||
RemoteViews(context.packageName, R.layout.widget_month_more).apply {
|
||||
setTextViewText(R.id.more, "+$count")
|
||||
setTextColor(context, R.id.more, colors.onSurfaceVariant)
|
||||
}
|
||||
|
||||
private fun laneViews(context: Context, compact: Boolean = false) =
|
||||
RemoteViews(context.packageName, if (compact) R.layout.widget_month_lane_compact else R.layout.widget_month_lane)
|
||||
.apply { removeAllViews(R.id.lane) }
|
||||
|
||||
private fun gap(context: Context, cols: Int) = RemoteViews(context.packageName, GAP_LAYOUTS[cols - 1])
|
||||
|
||||
|
||||
@@ -254,7 +254,7 @@ private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) {
|
||||
val context = LocalContext.current
|
||||
val fontScale = context.resources.configuration.fontScale
|
||||
val weekHeight = (LocalSize.current.height - gridChromeHeight(fontScale)) / weeks.size
|
||||
val layouts = fitWeeks(weeks, lanesFitting(weekHeight))
|
||||
val layouts = fitWeeks(weeks, lanesFitting(weekHeight, fontScale))
|
||||
val mode = LocalWidgetThemeMode.current
|
||||
val colors = GridColors(
|
||||
onSurface = remoteColor(WidgetColorRole.ON_SURFACE),
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/lane"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="12dp"
|
||||
android:orientation="horizontal"
|
||||
android:paddingBottom="1dp"
|
||||
android:weightSum="7" />
|
||||
@@ -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>
|
||||
|
||||
@@ -517,6 +520,15 @@
|
||||
<string name="widget_config_background_tinted">Tinted</string>
|
||||
<string name="widget_config_background_transparent">Transparent</string>
|
||||
<string name="widget_config_theme_follow_app">Follow app</string>
|
||||
<string name="widget_config_opacity">Opacity</string>
|
||||
<string name="widget_config_invert_text">Invert text colour</string>
|
||||
<string name="widget_config_invert_text_summary">Light text on a dark theme turns dark, and the other way round</string>
|
||||
<string name="widget_config_readability_info">About readability</string>
|
||||
<string name="widget_config_readability_hint">At low opacity the wallpaper shows through, so some text may be harder to read. Raise the opacity if it is.</string>
|
||||
<string name="widget_config_readability_hint_classic">At low opacity the wallpaper shows through, so some text may be hard to read. If it is, raise the opacity or invert the text colour.</string>
|
||||
<string name="widget_config_style">Style</string>
|
||||
<string name="widget_config_style_cards">Cards</string>
|
||||
<string name="widget_config_style_classic">Classic</string>
|
||||
<string name="widget_config_content">Content</string>
|
||||
<string name="widget_config_range">Time range</string>
|
||||
<string name="widget_config_text_size">Text size</string>
|
||||
@@ -868,7 +880,7 @@
|
||||
<string name="calendars_write_error">Couldn\'t save the change.</string>
|
||||
<!-- Backup (whole-calendar .ics export) -->
|
||||
<string name="calendars_backup_header">Backup</string>
|
||||
<string name="calendars_backup_hint">Local calendars aren\'t synced anywhere, so export them to an .ics file to keep a copy.</string>
|
||||
<string name="calendars_backup_hint">Export calendars to an .ics file to keep a copy. Local calendars aren\'t synced anywhere, so they need it most.</string>
|
||||
<string name="calendars_backup_action">Export as .ics file</string>
|
||||
<string name="calendars_export_title">Export calendars</string>
|
||||
<string name="calendars_export_hint">Choose which calendars to include in the .ics file.</string>
|
||||
@@ -877,13 +889,56 @@
|
||||
<string name="calendars_restore_action">Restore from .ics file</string>
|
||||
<string name="calendars_restore_hint">Import events from a backup or another calendar app.</string>
|
||||
<string name="calendars_restore_unavailable">No calendar can take the imported events yet.</string>
|
||||
<string name="calendars_copy_action">Copy events</string>
|
||||
<string name="calendars_copy_summary">Copy one calendar\'s events into another, for example to move off Google.</string>
|
||||
<string name="calendars_copy_hint">Copies your events, including repeating ones with the occurrences you changed or deleted. Cancelled events and invitations you declined are left out, guests stay behind, and the original calendar is left as it is.</string>
|
||||
<string name="calendars_copy_from">From</string>
|
||||
<string name="calendars_copy_to">To</string>
|
||||
<string name="calendars_copy_choose">Choose a calendar</string>
|
||||
<string name="calendars_copy_from_title">Copy from</string>
|
||||
<string name="calendars_copy_to_title">Copy into</string>
|
||||
<string name="calendars_copy_start">Copy</string>
|
||||
<string name="calendars_copy_reading">Reading events…</string>
|
||||
<plurals name="calendars_copy_progress">
|
||||
<item quantity="one">%1$d of %2$d event</item>
|
||||
<item quantity="other">%1$d of %2$d events</item>
|
||||
</plurals>
|
||||
<string name="calendars_copy_done">Copy complete</string>
|
||||
<string name="calendars_copy_stopped_title">Copy stopped</string>
|
||||
<string name="calendars_copy_partial">Copy finished, some events couldn\'t be added</string>
|
||||
<string name="calendars_copy_none">No calendar can be copied into another one right now.</string>
|
||||
<string name="calendars_copy_cancelled">Copy cancelled. Run it again to finish: events that were already copied are skipped.</string>
|
||||
<string name="calendars_copy_failed">Couldn\'t copy the events.</string>
|
||||
<plurals name="calendars_copy_stopped">
|
||||
<item quantity="one">The target calendar stopped accepting events part way through. %d event was never copied.</item>
|
||||
<item quantity="other">The target calendar stopped accepting events part way through. %d events were never copied.</item>
|
||||
</plurals>
|
||||
<string name="calendars_auto_backup">Automatic backup</string>
|
||||
<string name="calendars_auto_backup_hint">Periodically export your local calendars to a folder as an .ics file.</string>
|
||||
<string name="calendars_auto_backup_hint">Periodically export your calendars to a folder as .ics files.</string>
|
||||
<string name="calendars_auto_backup_folder">Backup folder</string>
|
||||
<string name="calendars_auto_backup_folder_unset">Tap to choose a folder</string>
|
||||
<string name="calendars_auto_backup_interval">Interval</string>
|
||||
<string name="calendars_auto_backup_every">Every %1$s</string>
|
||||
<string name="calendars_auto_backup_interval_min">Minimum 30 minutes.</string>
|
||||
<string name="calendars_auto_backup_on_change">Also back up after changes</string>
|
||||
<string name="calendars_auto_backup_on_change_summary">Updates the undated copy a few minutes after events change. Dated backups stay on the interval.</string>
|
||||
<string name="calendars_auto_backup_calendars">Calendars</string>
|
||||
<plurals name="calendars_auto_backup_calendars_count">
|
||||
<item quantity="one">%d calendar</item>
|
||||
<item quantity="other">%d calendars</item>
|
||||
</plurals>
|
||||
<string name="calendars_auto_backup_calendars_none">No calendar selected</string>
|
||||
<string name="calendars_auto_backup_nothing_selected">No calendar is selected, so nothing is backed up.</string>
|
||||
<string name="calendars_auto_backup_calendars_hint">Local calendars are backed up unless you turn them off. Synced calendars already live on their server, so add them here only if you want a copy in the backup folder too.</string>
|
||||
<string name="calendars_auto_backup_combined">Single file</string>
|
||||
<string name="calendars_auto_backup_combined_on">All calendars in one file</string>
|
||||
<string name="calendars_auto_backup_combined_off">One file per calendar, named after it</string>
|
||||
<string name="calendars_auto_backup_keep">Backups to keep</string>
|
||||
<plurals name="calendars_auto_backup_keep_count">
|
||||
<item quantity="one">Only the latest</item>
|
||||
<item quantity="other">The latest %d</item>
|
||||
</plurals>
|
||||
<string name="calendars_auto_backup_keep_hint">Counted per file: with one file per calendar, each calendar keeps this many dated copies, plus one undated copy of the latest backup unless a file of yours already has that name. Older backups Calendula wrote are deleted after each run; a calendar you stop backing up keeps its last files, and nothing else in the folder is touched.</string>
|
||||
<string name="calendars_auto_backup_status_never">No automatic backup yet</string>
|
||||
<string name="calendars_auto_backup_status_ok">Last backup: %1$s</string>
|
||||
<string name="calendars_auto_backup_status_failed">Last backup failed: %1$s</string>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package de.jeanlucmakiola.calendula.data.backup
|
||||
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.WorkInfo.State.BLOCKED
|
||||
import androidx.work.WorkInfo.State.CANCELLED
|
||||
import androidx.work.WorkInfo.State.ENQUEUED
|
||||
import androidx.work.WorkInfo.State.RUNNING
|
||||
import androidx.work.WorkInfo.State.SUCCEEDED
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class ChangeRunPolicyTest {
|
||||
|
||||
@Test
|
||||
fun `a run that hasn't started yet covers the change`() {
|
||||
assertThat(changeRunPolicy(listOf(ENQUEUED))).isNull()
|
||||
assertThat(changeRunPolicy(listOf(RUNNING, BLOCKED))).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a run already going gets the next one queued behind it`() {
|
||||
assertThat(changeRunPolicy(listOf(RUNNING))).isEqualTo(ExistingWorkPolicy.APPEND_OR_REPLACE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `with nothing pending a fresh run starts`() {
|
||||
assertThat(changeRunPolicy(emptyList())).isEqualTo(ExistingWorkPolicy.REPLACE)
|
||||
assertThat(changeRunPolicy(listOf(SUCCEEDED, CANCELLED))).isEqualTo(ExistingWorkPolicy.REPLACE)
|
||||
}
|
||||
}
|
||||
+145
-4
@@ -8,6 +8,13 @@ import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.FormTime
|
||||
import de.jeanlucmakiola.calendula.domain.SearchCandidate
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.ics.CalendarRows
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsParser
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||
import de.jeanlucmakiola.calendula.domain.ics.OverrideRow
|
||||
import de.jeanlucmakiola.calendula.domain.ics.MasterRow
|
||||
import de.jeanlucmakiola.calendula.testEvent
|
||||
import de.jeanlucmakiola.calendula.testPrefs
|
||||
import de.jeanlucmakiola.calendula.testRepository
|
||||
@@ -946,19 +953,153 @@ class CalendarRepositoryImplTest {
|
||||
|
||||
repo.exportEvents(calendarIds = setOf(1L, 4L))
|
||||
|
||||
assertThat(fake.lastExportableEventsCalendarIds).containsExactly(1L, 4L)
|
||||
assertThat(fake.lastCalendarRowsIds).containsExactly(1L, 4L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exportEvents defaults to null so every eligible calendar is exported`(
|
||||
fun `exportEvents reads synced calendars when asked for them`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest {
|
||||
val fake = FakeCalendarDataSource()
|
||||
val repo = testRepository(fake, tempDir, Dispatchers.Unconfined)
|
||||
|
||||
repo.exportEvents()
|
||||
repo.exportEvents(calendarIds = setOf(2L))
|
||||
|
||||
assertThat(fake.lastExportableEventsCalendarIds).isNull()
|
||||
assertThat(fake.lastCalendarRowsIds).containsExactly(2L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `copyEvents reports progress and skips what an earlier run copied`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest {
|
||||
fun icsEvent(uid: String) = IcsEvent(
|
||||
uid = uid,
|
||||
summary = uid,
|
||||
start = Instant.fromEpochMilliseconds(1_000_000_000L),
|
||||
end = Instant.fromEpochMilliseconds(1_000_003_600L),
|
||||
isAllDay = false,
|
||||
zoneId = "UTC",
|
||||
)
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
existingUidsResult = setOf("a")
|
||||
calendarRowsResult = CalendarRows(
|
||||
series = listOf(MasterRow(1, 1, null, icsEvent("a")), MasterRow(2, 1, null, icsEvent("b"))),
|
||||
overrides = emptyList(),
|
||||
)
|
||||
}
|
||||
val repo = testRepository(fake, tempDir, Dispatchers.Unconfined)
|
||||
val progress = mutableListOf<Pair<Int, Int>>()
|
||||
|
||||
val summary = repo.copyEvents(sourceCalendarId = 1L, targetCalendarId = 3L) { done, total ->
|
||||
progress += done to total
|
||||
}
|
||||
|
||||
assertThat(summary.imported).isEqualTo(1)
|
||||
assertThat(summary.skippedDuplicate).isEqualTo(1)
|
||||
assertThat(fake.importedEvents.map { it.first.uid to it.second }).containsExactly("b" to 3L)
|
||||
assertThat(progress.first()).isEqualTo(0 to 2)
|
||||
assertThat(progress.last()).isEqualTo(2 to 2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a backup restores a series' moved and deleted occurrences, and skips them where the series is`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest {
|
||||
val start = Instant.parse("2026-01-05T09:00:00Z")
|
||||
fun at(week: Int) = Instant.fromEpochMilliseconds(start.toEpochMilliseconds() + week * 7 * 86_400_000L)
|
||||
fun event(at: Instant, status: EventStatus = EventStatus.Confirmed, rrule: String? = null) = IcsEvent(
|
||||
uid = "s@x",
|
||||
summary = "Standup",
|
||||
start = at,
|
||||
end = Instant.fromEpochMilliseconds(at.toEpochMilliseconds() + 3_600_000L),
|
||||
isAllDay = false,
|
||||
zoneId = "Europe/Berlin",
|
||||
recurrenceRule = rrule,
|
||||
status = status,
|
||||
)
|
||||
val moved = Instant.fromEpochMilliseconds(at(1).toEpochMilliseconds() + 3_600_000L)
|
||||
val source = FakeCalendarDataSource().apply {
|
||||
calendarRowsResult = CalendarRows(
|
||||
series = listOf(MasterRow(1, 1, "sync-1", event(start, rrule = "FREQ=WEEKLY"))),
|
||||
overrides = listOf(
|
||||
OverrideRow(1, null, "sync-1", at(1), event(moved)),
|
||||
OverrideRow(1, null, "sync-1", at(2), event(at(2), EventStatus.Cancelled)),
|
||||
),
|
||||
)
|
||||
}
|
||||
val file = IcsWriter().writeCalendar(
|
||||
testRepository(source, tempDir.resolve("a"), Dispatchers.Unconfined).exportEvents(setOf(1L)),
|
||||
start,
|
||||
)
|
||||
val parsed = IcsParser().parse(file).events
|
||||
|
||||
val empty = FakeCalendarDataSource()
|
||||
testRepository(empty, tempDir.resolve("b"), Dispatchers.Unconfined).importEvents(3L, parsed)
|
||||
val (detached, series) = empty.importedEvents.map { it.first }
|
||||
assertThat(detached.start).isEqualTo(moved)
|
||||
assertThat(detached.recurrenceRule).isNull()
|
||||
assertThat(series.exDates).containsExactly("20260112T090000Z", "20260119T090000Z")
|
||||
|
||||
val original = FakeCalendarDataSource().apply { existingUidsResult = setOf("s@x") }
|
||||
val summary = testRepository(original, tempDir.resolve("c"), Dispatchers.Unconfined).importEvents(3L, parsed)
|
||||
assertThat(original.importedEvents).isEmpty()
|
||||
assertThat(summary.skippedDuplicate).isEqualTo(2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a series keeps an occurrence whose standalone copy didn't land`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest {
|
||||
val fake = FakeCalendarDataSource().apply { failingImportSummaries += "moved" }
|
||||
val repo = testRepository(fake, tempDir, Dispatchers.Unconfined)
|
||||
val at = Instant.parse("2026-01-12T09:00:00Z")
|
||||
|
||||
val summary = repo.importEvents(
|
||||
3L,
|
||||
listOf(
|
||||
parsedEvent("1-s", summary = "moved").copy(overrideOf = "s", overrideAt = at),
|
||||
parsedEvent("s").copy(recurrenceRule = "FREQ=WEEKLY", exDates = listOf("20260112T090000Z", "20260119T090000Z")),
|
||||
),
|
||||
)
|
||||
|
||||
assertThat(summary.failed).isEqualTo(1)
|
||||
assertThat(fake.importedEvents.single().first.exDates).containsExactly("20260119T090000Z")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a standalone occurrence listed after its series still goes first`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest {
|
||||
val fake = FakeCalendarDataSource()
|
||||
val repo = testRepository(fake, tempDir, Dispatchers.Unconfined)
|
||||
|
||||
repo.importEvents(3L, listOf(parsedEvent("s"), parsedEvent("1-s").copy(overrideOf = "s")))
|
||||
|
||||
assertThat(fake.importedEvents.map { it.first.uid }).containsExactly("1-s", "s").inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a backup keeps an RDATE-only series and its exceptions`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest {
|
||||
val start = Instant.parse("2026-01-05T09:00:00Z")
|
||||
val other = Instant.parse("2026-01-12T09:00:00Z")
|
||||
val event = IcsEvent(
|
||||
"s", "S", start, Instant.parse("2026-01-05T10:00:00Z"), false, "UTC",
|
||||
recurrenceDates = listOf("20260112T090000Z", "20260119T090000Z"),
|
||||
)
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
calendarRowsResult = CalendarRows(
|
||||
listOf(MasterRow(1, 1, null, event)),
|
||||
listOf(OverrideRow(1, 1, null, other, event.copy(recurrenceDates = emptyList(), status = EventStatus.Cancelled))),
|
||||
)
|
||||
}
|
||||
|
||||
val exported = testRepository(fake, tempDir, Dispatchers.Unconfined).exportEvents(setOf(1L))
|
||||
val restored = IcsParser().parse(IcsWriter().writeCalendar(exported, start)).events.single()
|
||||
|
||||
assertThat(restored.recurrenceDates).containsExactly("20260112T090000Z", "20260119T090000Z")
|
||||
assertThat(restored.exDates).containsExactly("20260112T090000Z")
|
||||
}
|
||||
|
||||
private fun parsedEvent(uid: String?, summary: String = "E") =
|
||||
|
||||
+7
-9
@@ -8,6 +8,7 @@ import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.SearchCandidate
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.domain.ics.CalendarRows
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
import java.time.ZoneId
|
||||
@@ -23,9 +24,9 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
var searchResult: (String) -> List<SearchCandidate> = { _ -> emptyList() }
|
||||
var eventDetailResult: (Long) -> EventDetail? = { null }
|
||||
var publishedEventColorsResult: (Long) -> List<EventColorOption> = { emptyList() }
|
||||
var exportableEventsResult: List<IcsEvent> = emptyList()
|
||||
/** The [calendarIds] the last [exportableEvents] call received (null = all). */
|
||||
var lastExportableEventsCalendarIds: Set<Long>? = null
|
||||
var calendarRowsResult: CalendarRows = CalendarRows(emptyList(), emptyList())
|
||||
/** The [calendarIds] the last [calendarRows] call received. */
|
||||
var lastCalendarRowsIds: Set<Long>? = null
|
||||
/** UIDs the target calendar already holds, for import dedup. */
|
||||
var existingUidsResult: Set<String> = emptySet()
|
||||
/** Set to make the next write call throw. */
|
||||
@@ -81,12 +82,9 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
eventDetailResult(eventId)
|
||||
override fun publishedEventColors(calendarId: Long): List<EventColorOption> =
|
||||
publishedEventColorsResult(calendarId)
|
||||
override fun exportableEvents(
|
||||
calendarIds: Set<Long>?,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
): List<IcsEvent> {
|
||||
lastExportableEventsCalendarIds = calendarIds
|
||||
return exportableEventsResult
|
||||
override fun calendarRows(calendarIds: Set<Long>, allDayReminderTimeMinutes: Int): CalendarRows {
|
||||
lastCalendarRowsIds = calendarIds
|
||||
return calendarRowsResult
|
||||
}
|
||||
|
||||
override fun existingUids(calendarId: Long): Set<String> = existingUidsResult
|
||||
|
||||
@@ -294,4 +294,39 @@ class IcsExportMapperTest {
|
||||
|
||||
assertThat(event.exDates).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a zoned RDATE list is written as UTC stamps, a period by its start`() {
|
||||
assertThat(exportRecurrenceDates("Europe/Berlin;20260110T100000,20260111T100000/PT1H", isAllDay = false))
|
||||
.containsExactly("20260110T090000Z", "20260111T090000Z").inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an all-day RDATE keeps its days, junk is dropped`() {
|
||||
assertThat(exportRecurrenceDates("20260110T000000Z,20260111,garbage", isAllDay = true))
|
||||
.containsExactly("20260110", "20260111").inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an RDATE stored over several lines keeps every line's dates`() {
|
||||
assertThat(exportRecurrenceDates("Europe/Berlin;20260110T100000\nAmerica/New_York;20260112T090000", isAllDay = false))
|
||||
.containsExactly("20260110T090000Z", "20260112T140000Z").inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an EXDATE stored over several lines keeps every line, unreadable stamps go`() {
|
||||
assertThat(exportExDates("Europe/Berlin;20260110T100000\nEurope/Berlin;20260117T100000,junk", isAllDay = false))
|
||||
.containsExactly("20260110T090000Z", "20260117T090000Z").inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bare day on a timed series is read at the series' time of day`() {
|
||||
val tenBerlin = java.time.ZonedDateTime.of(2026, 1, 5, 10, 0, 0, 0, java.time.ZoneId.of("Europe/Berlin"))
|
||||
.toInstant().toEpochMilli()
|
||||
|
||||
assertThat(exportExDates("20260112", isAllDay = false, "Europe/Berlin", tenBerlin))
|
||||
.containsExactly("20260112T090000Z")
|
||||
assertThat(exportRecurrenceDates("20260712", isAllDay = false, "Europe/Berlin", tenBerlin))
|
||||
.containsExactly("20260712T080000Z")
|
||||
}
|
||||
}
|
||||
+18
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import android.provider.CalendarContract
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
@@ -123,4 +124,21 @@ class ImportedEventValuesTest {
|
||||
assertThat(v[CalendarContract.Events.DURATION]).isEqualTo("P900S")
|
||||
assertThat(v[CalendarContract.Events.ALL_DAY]).isEqualTo(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a copied RDATE-only series recurs by its dates`() {
|
||||
val v = values(allDay().copy(recurrenceDates = listOf("20260820"), exDates = listOf("20260820")))
|
||||
assertThat(v).doesNotContainKey(CalendarContract.Events.DTEND)
|
||||
assertThat(v).doesNotContainKey(CalendarContract.Events.RRULE)
|
||||
assertThat(v[CalendarContract.Events.RDATE]).isEqualTo("20260820")
|
||||
assertThat(v[CalendarContract.Events.EXDATE]).isEqualTo("20260820")
|
||||
assertThat(v[CalendarContract.Events.DURATION]).isEqualTo("P1D")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a private event stays private, a default one leaves the calendar's say`() {
|
||||
assertThat(values(allDay().copy(accessLevel = AccessLevel.Private))[CalendarContract.Events.ACCESS_LEVEL])
|
||||
.isEqualTo(CalendarContract.Events.ACCESS_PRIVATE)
|
||||
assertThat(values(allDay())).doesNotContainKey(CalendarContract.Events.ACCESS_LEVEL)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
@@ -705,4 +706,85 @@ class SettingsPrefsTest {
|
||||
prefs.recordSpecialDatesRun(2000L, null)
|
||||
assertThat(prefs.specialDatesStatus.first()).isEqualTo(SpecialDatesStatus(2000L, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a fresh install backs up one file per calendar, keeping three`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = testSettings(tempDir)
|
||||
prefs.setAutoBackupFolderUri("content://tree/a")
|
||||
assertThat(prefs.autoBackupCombined.first()).isFalse()
|
||||
assertThat(prefs.autoBackupKeep.first()).isEqualTo(3)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an install that had a backup folder before 411 keeps its single file`(@TempDir tempDir: Path) = runTest {
|
||||
val store = testDataStore(tempDir, "test_settings")
|
||||
val prefs = SettingsPrefs(store)
|
||||
store.updateData { p ->
|
||||
val m = p.toMutablePreferences()
|
||||
m[stringPreferencesKey("auto_backup_folder_uri")] = "content://tree/old"
|
||||
m
|
||||
}
|
||||
assertThat(prefs.autoBackupCombined.first()).isTrue()
|
||||
|
||||
|
||||
// Picking another folder pins the layout instead of flipping it, and
|
||||
// keeps the old file recognised as this install's own.
|
||||
prefs.setAutoBackupFolderUri("content://tree/new")
|
||||
assertThat(prefs.autoBackupCombined.first()).isTrue()
|
||||
// A new folder starts a new ledger: the old file there isn't known to be ours.
|
||||
assertThat(prefs.autoBackupLedger.first()).isEmpty()
|
||||
|
||||
prefs.setAutoBackupCombined(false)
|
||||
assertThat(prefs.autoBackupCombined.first()).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `backup calendar choices are stored as deviations from the default`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = testSettings(tempDir)
|
||||
val local = backupCalendar(1L, local = true)
|
||||
val synced = backupCalendar(2L, local = false)
|
||||
|
||||
prefs.setAutoBackupCalendar(synced, true)
|
||||
prefs.setAutoBackupCalendar(local, false)
|
||||
val selection = prefs.autoBackupSelection.first()
|
||||
assertThat(selection.included).containsExactly(2L)
|
||||
assertThat(selection.excluded).containsExactly(1L)
|
||||
|
||||
prefs.setAutoBackupCalendar(local, true)
|
||||
assertThat(prefs.autoBackupSelection.first().excluded).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stray retention count snaps to an offered one`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = testSettings(tempDir)
|
||||
prefs.setAutoBackupKeep(4)
|
||||
assertThat(prefs.autoBackupKeep.first()).isIn(listOf(3, 5))
|
||||
prefs.setAutoBackupKeep(99)
|
||||
assertThat(prefs.autoBackupKeep.first()).isEqualTo(10)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a pre-411 install adopts its old file when the layout is pinned in place`(@TempDir tempDir: Path) = runTest {
|
||||
val store = testDataStore(tempDir, "test_settings")
|
||||
val prefs = SettingsPrefs(store)
|
||||
store.updateData { p ->
|
||||
val m = p.toMutablePreferences()
|
||||
m[stringPreferencesKey("auto_backup_folder_uri")] = "content://tree/old"
|
||||
m
|
||||
}
|
||||
prefs.setAutoBackupCombined(false)
|
||||
assertThat(prefs.autoBackupLedger.first())
|
||||
.containsExactly("latest-combined", listOf("calendula-backup.ics"))
|
||||
}
|
||||
|
||||
private fun backupCalendar(id: Long, local: Boolean) = CalendarSource(
|
||||
id = id,
|
||||
displayName = "C$id",
|
||||
accountName = "a",
|
||||
accountType = if (local) "LOCAL" else "com.google",
|
||||
color = 0,
|
||||
isVisibleInSystem = true,
|
||||
canModifyContents = true,
|
||||
isLocal = local,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
package de.jeanlucmakiola.calendula.domain.backup
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.LocalDateTime
|
||||
import kotlin.time.Instant
|
||||
|
||||
class BackupPlanTest {
|
||||
|
||||
private fun cal(
|
||||
id: Long,
|
||||
name: String = "Calendar $id",
|
||||
local: Boolean = false,
|
||||
writable: Boolean = true,
|
||||
managed: Boolean = false,
|
||||
syncs: Boolean = true,
|
||||
) = CalendarSource(
|
||||
id = id,
|
||||
displayName = name,
|
||||
accountName = "acc",
|
||||
accountType = if (local) "LOCAL" else "bitfire.at.davdroid",
|
||||
color = 0,
|
||||
isVisibleInSystem = true,
|
||||
canModifyContents = writable,
|
||||
isLocal = local,
|
||||
isManaged = managed,
|
||||
syncsEvents = syncs,
|
||||
)
|
||||
|
||||
private val at = LocalDateTime.of(2026, 11, 13, 14, 30, 5)
|
||||
|
||||
@Test
|
||||
fun `local calendars are selected by default, synced ones are opt-in`() {
|
||||
val selection = BackupSelection()
|
||||
assertThat(selection.selects(cal(1L, local = true))).isTrue()
|
||||
assertThat(selection.selects(cal(2L))).isFalse()
|
||||
assertThat(selection.selects(cal(3L, writable = false))).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `managed and unsynced calendars are never backed up`() {
|
||||
val selection = BackupSelection(included = setOf(1L, 2L))
|
||||
assertThat(selection.selects(cal(1L, local = true, managed = true))).isFalse()
|
||||
assertThat(selection.selects(cal(2L, syncs = false))).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toggling back to the default leaves no trace`() {
|
||||
val synced = cal(2L)
|
||||
val local = cal(1L, local = true)
|
||||
val chosen = BackupSelection().with(synced, true).with(local, false)
|
||||
assertThat(chosen).isEqualTo(BackupSelection(included = setOf(2L), excluded = setOf(1L)))
|
||||
assertThat(chosen.with(synced, false).with(local, true)).isEqualTo(BackupSelection())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one target per calendar, clashing names told apart by id`() {
|
||||
val targets = backupTargets(
|
||||
listOf(cal(1L, "Personal", local = true), cal(2L, "personal"), cal(3L, "Work")),
|
||||
combined = false,
|
||||
)
|
||||
assertThat(targets.map { it.baseName }).containsExactly("Personal-1", "personal-2", "Work").inOrder()
|
||||
assertThat(targets.map { it.calendarName }).containsExactly("Personal", "personal", "Work").inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a calendar named like the combined file doesn't share its series`() {
|
||||
val target = backupTargets(listOf(cal(4L, COMBINED_BACKUP_BASE)), combined = false).single()
|
||||
assertThat(target.baseName).isEqualTo("$COMBINED_BACKUP_BASE-4")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `combined mode is one target holding every calendar`() {
|
||||
val target = backupTargets(listOf(cal(1L), cal(2L)), combined = true).single()
|
||||
assertThat(target.key).isEqualTo(COMBINED_SERIES)
|
||||
assertThat(target.calendarIds).containsExactly(1L, 2L)
|
||||
assertThat(target.baseName).isEqualTo(COMBINED_BACKUP_BASE)
|
||||
assertThat(target.calendarName).isNull()
|
||||
assertThat(backupTargets(emptyList(), combined = true)).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the new file carries the date and time of the run`() {
|
||||
assertThat(backupFileName("Work", at)).isEqualTo("Work_2026-11-13_14-30-05.ics")
|
||||
}
|
||||
|
||||
private val work = BackupTarget("1", setOf(1L), "Work", "Work")
|
||||
|
||||
@Test
|
||||
fun `pruning keeps the newest recorded files and nothing else`() {
|
||||
val ledger = mapOf("1" to listOf("Work_a.ics", "Work_b.ics", "Work_c.ics"))
|
||||
val existing = setOf("Work_a.ics", "Work_b.ics", "Work_c.ics", "Work_2026-11-01_09-00-00.ics", "Work notes.ics")
|
||||
val plan = planBackupFiles(listOf(work), ledger, existing, at, keep = 3).single()
|
||||
assertThat(plan.fileName).isEqualTo("Work_2026-11-13_14-30-05.ics")
|
||||
assertThat(plan.keep).containsExactly("Work_b.ics", "Work_c.ics").inOrder()
|
||||
assertThat(plan.prune).containsExactly("Work_a.ics")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keeping one replaces every earlier file`() {
|
||||
val ledger = mapOf("1" to listOf("Work_a.ics", "Work_b.ics"))
|
||||
val plan = planBackupFiles(listOf(work), ledger, setOf("Work_a.ics", "Work_b.ics"), at, keep = 1).single()
|
||||
assertThat(plan.keep).isEmpty()
|
||||
assertThat(plan.prune).containsExactly("Work_a.ics", "Work_b.ics").inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `files gone from the folder are forgotten, not counted`() {
|
||||
val ledger = mapOf("1" to listOf("Work_a.ics", "Work_b.ics"))
|
||||
val plan = planBackupFiles(listOf(work), ledger, setOf("Work_b.ics"), at, keep = 2).single()
|
||||
assertThat(plan.keep).containsExactly("Work_b.ics")
|
||||
assertThat(plan.prune).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a same-named calendar never prunes another calendar's files`() {
|
||||
// Local "Work" (id 1) was backed up; the user now backs up a synced "Work" (id 9) instead.
|
||||
val ledger = mapOf("1" to listOf("Work_a.ics"))
|
||||
val synced = backupTargets(listOf(cal(9L, "Work")), combined = false).single()
|
||||
val plan = planBackupFiles(listOf(synced), ledger, setOf("Work_a.ics"), at, keep = 1).single()
|
||||
assertThat(plan.prune).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a rerun in the same second never prunes the file it writes`() {
|
||||
val name = "Work_2026-11-13_14-30-05.ics"
|
||||
val plan = planBackupFiles(listOf(work), mapOf("1" to listOf(name)), setOf(name), at, keep = 1).single()
|
||||
assertThat(plan.prune).isEmpty()
|
||||
}
|
||||
|
||||
private val combined = BackupTarget(COMBINED_SERIES, setOf(1L, 2L), COMBINED_BACKUP_BASE, null)
|
||||
|
||||
@Test
|
||||
fun `the pre-411 file is the combined stable copy only for the install that wrote it`() {
|
||||
val adopted = mapOf(latestLedgerKey(COMBINED_SERIES) to listOf(LEGACY_BACKUP_FILE))
|
||||
val owned = planBackupFiles(listOf(combined), adopted, setOf(LEGACY_BACKUP_FILE), at, 1)
|
||||
assertThat(owned.single().latestName).isEqualTo(LEGACY_BACKUP_FILE)
|
||||
assertThat(owned.single().prune).isEmpty()
|
||||
|
||||
val foreign = planBackupFiles(listOf(combined), emptyMap(), setOf(LEGACY_BACKUP_FILE), at, 1)
|
||||
assertThat(foreign.single().latestName).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stable name recorded as a dated file by an earlier build is never pruned`() {
|
||||
val ledger = mapOf(COMBINED_SERIES to listOf(LEGACY_BACKUP_FILE, "calendula-backup_a.ics"))
|
||||
val plan = planBackupFiles(
|
||||
listOf(combined), ledger, setOf(LEGACY_BACKUP_FILE, "calendula-backup_a.ics"), at, 1,
|
||||
).single()
|
||||
assertThat(plan.prune).containsExactly("calendula-backup_a.ics")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `per-calendar series leave the legacy combined file alone`() {
|
||||
val plan = planBackupFiles(listOf(work), emptyMap(), setOf(LEGACY_BACKUP_FILE), at, keep = 1).single()
|
||||
assertThat(plan.prune).isEmpty()
|
||||
assertThat(plan.staleLatest).isEmpty()
|
||||
assertThat(plan.latestName).isEqualTo("Work.ics")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the stable copy is never pruned and never overwrites the user's own file`() {
|
||||
val ours = planBackupFiles(
|
||||
listOf(work),
|
||||
mapOf("1" to listOf("Work_a.ics"), latestLedgerKey("1") to listOf("Work.ics")),
|
||||
setOf("Work_a.ics", "Work.ics"),
|
||||
at,
|
||||
keep = 1,
|
||||
).single()
|
||||
assertThat(ours.latestName).isEqualTo("Work.ics")
|
||||
assertThat(ours.prune).containsExactly("Work_a.ics")
|
||||
|
||||
val foreign = planBackupFiles(listOf(work), emptyMap(), setOf("Work.ics"), at, keep = 1).single()
|
||||
assertThat(foreign.latestName).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a renamed calendar replaces its old stable copy`() {
|
||||
val job = BackupTarget("1", setOf(1L), "Job", "Job")
|
||||
val plan = planBackupFiles(
|
||||
listOf(job),
|
||||
mapOf(latestLedgerKey("1") to listOf("Work.ics")),
|
||||
setOf("Work.ics"),
|
||||
at,
|
||||
keep = 3,
|
||||
).single()
|
||||
assertThat(plan.latestName).isEqualTo("Job.ics")
|
||||
assertThat(plan.staleLatest).containsExactly("Work.ics")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stable copy is handed over to the calendar that takes its name in the same run`() {
|
||||
// Calendar 1 was "Work", is now "Job"; calendar 2 is now called "Work".
|
||||
val ledger = mapOf(latestLedgerKey("1") to listOf("Work.ics"))
|
||||
val plans = planBackupFiles(
|
||||
listOf(BackupTarget("1", setOf(1L), "Job", "Job"), BackupTarget("2", setOf(2L), "Work", "Work")),
|
||||
ledger,
|
||||
setOf("Work.ics"),
|
||||
at,
|
||||
keep = 1,
|
||||
)
|
||||
assertThat(plans[0].staleLatest).isEmpty()
|
||||
assertThat(plans[0].handOff).containsExactly("Work.ics")
|
||||
assertThat(plans[1].latestName).isEqualTo("Work.ics")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a deselected calendar's stable copy is not taken over by a same-named one`() {
|
||||
// Calendar 3 "Personal" is out of the backup; calendar 9, also "Personal", is in.
|
||||
val ledger = mapOf(latestLedgerKey("3") to listOf("Personal.ics"))
|
||||
val plan = planBackupFiles(
|
||||
listOf(BackupTarget("9", setOf(9L), "Personal", "Personal")), ledger, setOf("Personal.ics"), at, 1,
|
||||
).single()
|
||||
assertThat(plan.latestName).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a layout switch replaces the other layout's stable copies, a deselected calendar keeps its own`() {
|
||||
val ledger = mapOf(
|
||||
latestLedgerKey("1") to listOf("Work.ics"),
|
||||
latestLedgerKey("5") to listOf("Old.ics"),
|
||||
latestLedgerKey(COMBINED_SERIES) to listOf(LEGACY_BACKUP_FILE),
|
||||
)
|
||||
val existing = setOf("Work.ics", "Old.ics", LEGACY_BACKUP_FILE)
|
||||
assertThat(replacedLatestCopies(listOf(combined), ledger, existing))
|
||||
.containsExactly("1", listOf("Work.ics"))
|
||||
assertThat(replacedLatestCopies(listOf(work), ledger, existing))
|
||||
.containsExactly(COMBINED_SERIES, listOf(LEGACY_BACKUP_FILE))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a ledger entry is set, emptied away, and dropped with its calendar`() {
|
||||
val previous = mapOf(
|
||||
"1" to listOf("Work_a.ics"),
|
||||
latestLedgerKey("7") to listOf("Gone.ics"),
|
||||
latestLedgerKey(COMBINED_SERIES) to listOf(LEGACY_BACKUP_FILE),
|
||||
)
|
||||
val next = withLedgerEntry(previous, "1", listOf("Work_a.ics", "Work_b.ics", "Work_b.ics"), setOf("1"))
|
||||
assertThat(next).containsExactly(
|
||||
"1", listOf("Work_a.ics", "Work_b.ics"),
|
||||
latestLedgerKey(COMBINED_SERIES), listOf(LEGACY_BACKUP_FILE),
|
||||
)
|
||||
assertThat(withLedgerEntry(next, "1", emptyList(), setOf("1")).keys)
|
||||
.containsExactly(latestLedgerKey(COMBINED_SERIES))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the ledger round-trips through storage`() {
|
||||
val ledger = mapOf("3" to listOf("Work_b.ics", "Work_a (1).ics"), COMBINED_SERIES to listOf("calendula-backup_x.ics"))
|
||||
assertThat(parseBackupLedger(persistBackupLedger(ledger))).isEqualTo(ledger)
|
||||
assertThat(persistBackupLedger(emptyMap())).isNull()
|
||||
assertThat(parseBackupLedger("garbage|/x|4/")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retention snaps to the offered choices`() {
|
||||
assertThat(normalizeBackupKeep(0)).isEqualTo(1)
|
||||
assertThat(normalizeBackupKeep(3)).isEqualTo(3)
|
||||
assertThat(normalizeBackupKeep(50)).isEqualTo(10)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a suffixed name that meets a real one is told apart again`() {
|
||||
val targets = backupTargets(
|
||||
listOf(cal(5L, "Work"), cal(6L, "Work"), cal(9L, "Work-5")),
|
||||
combined = false,
|
||||
)
|
||||
assertThat(targets.map { it.baseName }).containsExactly("Work-5-5", "Work-6", "Work-5-9").inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `claiming a stable copy takes it off its previous owner`() {
|
||||
val previous = mapOf(latestLedgerKey("1") to listOf("Work.ics", "Job.ics"))
|
||||
val next = withLedgerEntry(previous, latestLedgerKey("2"), listOf("Work.ics"), setOf("1", "2"))
|
||||
assertThat(next).containsExactly(
|
||||
latestLedgerKey("1"), listOf("Job.ics"),
|
||||
latestLedgerKey("2"), listOf("Work.ics"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a claimed hand-off shows as held elsewhere`() {
|
||||
val ledger = mapOf(latestLedgerKey("1") to listOf("Job.ics"), latestLedgerKey("2") to listOf("Work.ics"), "2" to listOf("Work_a.ics"))
|
||||
assertThat(ledger.latestHeldElsewhere("1")).containsExactly("Work.ics")
|
||||
assertThat(ledger.latestHeldElsewhere("2")).containsExactly("Job.ics")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fingerprints survive storage and drop deleted calendars`() {
|
||||
val stored = persistBackupFingerprints(mapOf("1" to "ab", COMBINED_SERIES to "cd"))
|
||||
assertThat(parseBackupFingerprints(stored)).containsExactly("1", "ab", COMBINED_SERIES, "cd")
|
||||
assertThat(persistBackupFingerprints(emptyMap())).isNull()
|
||||
assertThat(parseBackupFingerprints(null)).isEmpty()
|
||||
val next = withFingerprint(mapOf("1" to "ab", "9" to "ef", COMBINED_SERIES to "cd"), "2", "gh", setOf("1", "2"))
|
||||
assertThat(next).containsExactly("1", "ab", COMBINED_SERIES, "cd", "2", "gh")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a fingerprint follows the content`() {
|
||||
val first = "BEGIN:VEVENT\r\nDTSTAMP:20261113T143005Z\r\nSUMMARY:A\r\nEND:VEVENT\r\n"
|
||||
val later = first.replace("20261113T143005Z", "20261114T090000Z")
|
||||
assertThat(backupFingerprint(later, "Work.ics")).isEqualTo(backupFingerprint(first, "Work.ics"))
|
||||
assertThat(backupFingerprint(first.replace("SUMMARY:A", "SUMMARY:B"), "Work.ics")).isNotEqualTo(backupFingerprint(first, "Work.ics"))
|
||||
assertThat(backupFingerprint(first, "Work.ics")).hasLength(32)
|
||||
assertThat(backupFingerprint(first, "Job.ics")).isNotEqualTo(backupFingerprint(first, "Work.ics"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the writer's output hashes the same at any stamp`() {
|
||||
val event = IcsEvent(
|
||||
uid = "u1", summary = "A", start = Instant.fromEpochSeconds(0), end = Instant.fromEpochSeconds(3600),
|
||||
isAllDay = false, zoneId = "UTC",
|
||||
)
|
||||
val early = IcsWriter().writeCalendar(listOf(event), Instant.fromEpochSeconds(0), "Work")
|
||||
val late = IcsWriter().writeCalendar(listOf(event), Instant.fromEpochSeconds(1_800_000_000), "Work")
|
||||
assertThat(early).isNotEqualTo(late)
|
||||
assertThat(backupFingerprint(late, "Work.ics")).isEqualTo(backupFingerprint(early, "Work.ics"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a change run refreshes only a changed or missing stable copy`() {
|
||||
val target = backupTargets(listOf(cal(1L, "Work")), combined = false).single()
|
||||
val ledger = mapOf(latestLedgerKey("1") to listOf("Work.ics"))
|
||||
val plan = planBackupFiles(listOf(target), ledger, setOf("Work.ics"), at, keep = 3).single()
|
||||
val stored = mapOf("1" to "h1")
|
||||
val listed = setOf("Work.ics")
|
||||
assertThat(plan.needsRefresh("h1", stored, listed, complete = true)).isFalse()
|
||||
assertThat(plan.needsRefresh("h2", stored, listed, complete = true)).isTrue()
|
||||
assertThat(plan.needsRefresh("h1", emptyMap(), listed, complete = true)).isTrue()
|
||||
assertThat(plan.needsRefresh("h1", stored, emptySet(), complete = true)).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a change run leaves a copy a loading listing doesn't show to the schedule`() {
|
||||
val target = backupTargets(listOf(cal(1L, "Work")), combined = false).single()
|
||||
val ledger = mapOf(latestLedgerKey("1") to listOf("Work.ics"))
|
||||
val plan = planBackupFiles(listOf(target), ledger, setOf("Work.ics"), at, keep = 3).single()
|
||||
assertThat(plan.needsRefresh("h2", mapOf("1" to "h1"), emptySet(), complete = false)).isFalse()
|
||||
assertThat(plan.needsRefresh("h2", mapOf("1" to "h1"), setOf("Work.ics"), complete = false)).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a change run never writes over a file of the user's`() {
|
||||
val target = backupTargets(listOf(cal(1L, "Work")), combined = false).single()
|
||||
val plan = planBackupFiles(listOf(target), emptyMap(), setOf("Work.ics"), at, keep = 3).single()
|
||||
assertThat(plan.latestName).isNull()
|
||||
assertThat(plan.needsRefresh("h1", emptyMap(), setOf("Work.ics"), complete = true)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `backup order doesn't depend on the provider's`() {
|
||||
fun event(uid: String, reminders: List<Int>) = IcsEvent(
|
||||
uid = uid, summary = uid, start = Instant.fromEpochSeconds(0), end = Instant.fromEpochSeconds(3600),
|
||||
isAllDay = false, zoneId = "UTC", reminderMinutes = reminders,
|
||||
)
|
||||
val one = listOf(event("b", listOf(15, 10)), event("a", listOf(5)))
|
||||
val other = listOf(event("a", listOf(5)), event("b", listOf(10, 15)))
|
||||
assertThat(one.inBackupOrder()).isEqualTo(other.inBackupOrder())
|
||||
assertThat(one.inBackupOrder().map { it.uid }).containsExactly("a", "b").inOrder()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
package de.jeanlucmakiola.calendula.domain.ics
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.data.calendar.formatExdateStamp
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** How a calendar's exception rows are written out for a backup (#147) or a copy (#325). */
|
||||
class CalendarRowsTest {
|
||||
|
||||
private val start = Instant.parse("2026-01-05T09:00:00Z")
|
||||
private val week = 7 * 24 * 3_600_000L
|
||||
|
||||
private fun event(
|
||||
uid: String,
|
||||
rrule: String? = null,
|
||||
allDay: Boolean = false,
|
||||
at: Instant = start,
|
||||
status: EventStatus = EventStatus.Confirmed,
|
||||
exDates: List<String> = emptyList(),
|
||||
reminders: List<Int> = emptyList(),
|
||||
color: Int? = null,
|
||||
) = IcsEvent(
|
||||
uid = uid,
|
||||
summary = uid,
|
||||
start = at,
|
||||
end = Instant.fromEpochMilliseconds(at.toEpochMilliseconds() + 3_600_000L),
|
||||
isAllDay = allDay,
|
||||
zoneId = "Europe/Berlin",
|
||||
recurrenceRule = rrule,
|
||||
exDates = exDates,
|
||||
reminderMinutes = reminders,
|
||||
status = status,
|
||||
color = color,
|
||||
calendarName = "Work",
|
||||
)
|
||||
|
||||
private fun occurrence(n: Int) = Instant.fromEpochMilliseconds(start.toEpochMilliseconds() + n * week)
|
||||
|
||||
private fun series(uid: String, rrule: String? = "FREQ=WEEKLY", id: Long = 1, calendar: Long = 1, syncId: String? = null, status: EventStatus = EventStatus.Confirmed) =
|
||||
MasterRow(id, calendar, syncId, event(uid, rrule = rrule, status = status))
|
||||
|
||||
private fun flatten(series: List<MasterRow>, overrides: List<OverrideRow>) =
|
||||
flattenOverrides(CalendarRows(series, overrides), ::formatExdateStamp)
|
||||
|
||||
@Test
|
||||
fun `a cancelled occurrence becomes an EXDATE on the series`() {
|
||||
val flat = flatten(
|
||||
listOf(MasterRow(1, 1, "sync-1", event("s", rrule = "FREQ=WEEKLY", exDates = listOf("20260112T080000Z")))),
|
||||
listOf(OverrideRow(1, null, "sync-1", occurrence(2), event("s", at = occurrence(2), status = EventStatus.Cancelled))),
|
||||
)
|
||||
|
||||
assertThat(flat.single().exDates).containsExactly("20260112T080000Z", "20260119T090000Z").inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a modified occurrence is excluded from the series and written on its own, first`() {
|
||||
val moved = Instant.fromEpochMilliseconds(occurrence(1).toEpochMilliseconds() + 3_600_000L)
|
||||
val flat = flatten(
|
||||
listOf(series("s")),
|
||||
listOf(OverrideRow(1, 1, null, occurrence(1), event("s", rrule = "FREQ=WEEKLY", at = moved))),
|
||||
)
|
||||
|
||||
assertThat(flat).hasSize(2)
|
||||
val detached = flat[0]
|
||||
assertThat(detached.uid).isEqualTo("${occurrence(1).toEpochMilliseconds()}-s")
|
||||
assertThat(detached.overrideOf).isEqualTo("s")
|
||||
assertThat(detached.recurrenceRule).isNull()
|
||||
assertThat(detached.start).isEqualTo(moved)
|
||||
assertThat(detached.calendarName).isEqualTo("Work")
|
||||
assertThat(flat[1].exDates).containsExactly("20260112T090000Z")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an all-day series excludes the occurrence by its day`() {
|
||||
val day = Instant.parse("2026-01-05T00:00:00Z")
|
||||
val flat = flatten(
|
||||
listOf(MasterRow(1, 1, null, event("s", rrule = "FREQ=DAILY", allDay = true, at = day))),
|
||||
listOf(OverrideRow(1, 1, null, Instant.parse("2026-01-07T00:00:00Z"), event("s", allDay = true, status = EventStatus.Cancelled))),
|
||||
)
|
||||
|
||||
assertThat(flat.single().exDates).containsExactly("20260107")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exceptions without their series are left out`() {
|
||||
val flat = flatten(
|
||||
emptyList(),
|
||||
listOf(
|
||||
OverrideRow(1, 5, null, occurrence(1), event("x")),
|
||||
OverrideRow(1, 5, null, occurrence(2), event("y", status = EventStatus.Cancelled)),
|
||||
),
|
||||
)
|
||||
|
||||
assertThat(flat).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a sync id links an exception only within its own calendar`() {
|
||||
val flat = flatten(
|
||||
listOf(series("a", calendar = 1, syncId = "same"), series("b", id = 2, calendar = 2, syncId = "same")),
|
||||
listOf(OverrideRow(2, null, "same", occurrence(1), event("x", status = EventStatus.Cancelled))),
|
||||
)
|
||||
|
||||
assertThat(flat.first { it.uid == "a" }.exDates).isEmpty()
|
||||
assertThat(flat.first { it.uid == "b" }.exDates).containsExactly("20260112T090000Z")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an exception to a one-off is ignored`() {
|
||||
val flat = flatten(
|
||||
listOf(series("o", rrule = null)),
|
||||
listOf(OverrideRow(1, 1, null, occurrence(0), event("x"))),
|
||||
)
|
||||
|
||||
assertThat(flat.map { it.uid }).containsExactly("o")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an RDATE-only series keeps its dates and takes its exceptions`() {
|
||||
val flat = flatten(
|
||||
listOf(MasterRow(1, 1, null, event("s").copy(recurrenceDates = listOf("20260112T090000Z")))),
|
||||
listOf(OverrideRow(1, 1, null, occurrence(1), event("s", status = EventStatus.Cancelled))),
|
||||
)
|
||||
|
||||
assertThat(flat.single().recurrenceDates).containsExactly("20260112T090000Z")
|
||||
assertThat(flat.single().exDates).containsExactly("20260112T090000Z")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two exception rows for one occurrence are written once`() {
|
||||
val flat = flatten(
|
||||
listOf(series("s")),
|
||||
listOf(OverrideRow(1, 1, null, occurrence(1), event("x")), OverrideRow(1, 1, null, occurrence(1), event("y"))),
|
||||
)
|
||||
|
||||
assertThat(flat.map { it.summary }).containsExactly("x", "s").inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the output is stable, so a re-run dedups every event`() {
|
||||
val rows = CalendarRows(listOf(series("s")), listOf(OverrideRow(1, 1, null, occurrence(1), event("x"))))
|
||||
|
||||
assertThat(flattenOverrides(rows, ::formatExdateStamp).map { it.uid })
|
||||
.isEqualTo(flattenOverrides(rows, ::formatExdateStamp).map { it.uid })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a backup keeps a cancelled series, a copy leaves it out with its exceptions`() {
|
||||
val rows = CalendarRows(
|
||||
listOf(series("s", status = EventStatus.Cancelled)),
|
||||
listOf(OverrideRow(1, 1, null, occurrence(1), event("x"))),
|
||||
)
|
||||
|
||||
assertThat(flattenOverrides(rows, ::formatExdateStamp).map { it.uid }).contains("s")
|
||||
assertThat(planCalendarCopy(rows, ::formatExdateStamp)).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a copy keeps the zone, colour and override link`() {
|
||||
val rows = CalendarRows(
|
||||
listOf(MasterRow(1, 1, null, event("s", rrule = "FREQ=WEEKLY", reminders = listOf(10, -5, 10), color = 0xFF112233.toInt()))),
|
||||
listOf(OverrideRow(1, 1, null, occurrence(1), event("x"))),
|
||||
)
|
||||
|
||||
val (detached, copied) = planCalendarCopy(rows, ::formatExdateStamp)
|
||||
assertThat(detached.overrideOf).isEqualTo("s")
|
||||
assertThat(copied.zoneId).isEqualTo("Europe/Berlin")
|
||||
assertThat(copied.color).isEqualTo(0xFF112233.toInt())
|
||||
assertThat(copied.reminderMinutes).containsExactly(10)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a zero-length all-day event lasts its day`() {
|
||||
val day = Instant.parse("2026-01-05T00:00:00Z")
|
||||
val parsed = event("a", allDay = true, at = day).copy(end = day).toParsedIcsEvent()
|
||||
|
||||
assertThat(parsed.end).isEqualTo(Instant.parse("2026-01-06T00:00:00Z"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the override link survives the file`() {
|
||||
val text = IcsWriter().writeCalendar(listOf(event("d").copy(overrideOf = "s;1", overrideAt = occurrence(1))), start)
|
||||
|
||||
val parsed = IcsParser().parse(text).events.single()
|
||||
assertThat(parsed.overrideOf).isEqualTo("s;1")
|
||||
assertThat(parsed.overrideAt).isEqualTo(occurrence(1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cancelled series' moved occurrence isn't brought back`() {
|
||||
val flat = flatten(
|
||||
listOf(series("s", status = EventStatus.Cancelled)),
|
||||
listOf(OverrideRow(1, 1, null, occurrence(1), event("x"))),
|
||||
)
|
||||
|
||||
assertThat(flat.map { it.uid }).containsExactly("s")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same series in two calendars keeps each calendar's moved occurrence`() {
|
||||
val flat = flatten(
|
||||
listOf(series("s", id = 1, calendar = 1), series("s", id = 2, calendar = 2)),
|
||||
listOf(
|
||||
OverrideRow(1, 1, null, occurrence(1), event("x")),
|
||||
OverrideRow(2, 2, null, occurrence(1), event("x")),
|
||||
),
|
||||
)
|
||||
|
||||
assertThat(flat.count { it.overrideOf == "s" }).isEqualTo(2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a copy leaves declined invitations out but keeps an occurrence accepted on its own`() {
|
||||
val rows = CalendarRows(
|
||||
listOf(series("d").copy(declined = true), series("k", id = 2)),
|
||||
listOf(
|
||||
OverrideRow(1, 1, null, occurrence(1), event("accepted"), accepted = true),
|
||||
OverrideRow(1, 1, null, occurrence(2), event("moved by the organizer")),
|
||||
),
|
||||
)
|
||||
|
||||
val plan = planCalendarCopy(rows, ::formatExdateStamp)
|
||||
assertThat(plan.map { it.summary }).containsExactly("accepted", "k")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a copy treats a declined occurrence as deleted`() {
|
||||
val rows = CalendarRows(
|
||||
listOf(series("s")),
|
||||
listOf(OverrideRow(1, 1, null, occurrence(1), event("x"), declined = true)),
|
||||
)
|
||||
|
||||
val plan = planCalendarCopy(rows, ::formatExdateStamp)
|
||||
assertThat(plan.map { it.uid }).containsExactly("s")
|
||||
assertThat(plan.single().exDates).containsExactly("20260112T090000Z")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `RDATE and its exclusions survive the file`() {
|
||||
val timed = event("t").copy(
|
||||
recurrenceDates = listOf("20260112T090000Z", "20260120T090000Z"),
|
||||
exDates = listOf("20260112T090000Z"),
|
||||
)
|
||||
val day = Instant.parse("2026-01-05T00:00:00Z")
|
||||
val allDay = event("a", allDay = true, at = day).copy(
|
||||
end = Instant.parse("2026-01-06T00:00:00Z"),
|
||||
recurrenceDates = listOf("20260110"),
|
||||
exDates = listOf("20260110"),
|
||||
)
|
||||
val text = IcsWriter().writeCalendar(listOf(timed, allDay), start)
|
||||
|
||||
assertThat(text).contains("RDATE;VALUE=DATE:20260110")
|
||||
assertThat(text).contains("DTSTART;TZID=Europe/Berlin:20260105T100000")
|
||||
val (t, a) = IcsParser().parse(text).events
|
||||
assertThat(t.recurrenceDates).containsExactly("20260112T090000Z", "20260120T090000Z")
|
||||
assertThat(t.exDates).containsExactly("20260112T090000Z")
|
||||
assertThat(a.recurrenceDates).containsExactly("20260110")
|
||||
assertThat(a.exDates).containsExactly("20260110")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a foreign RDATE is read in its own zone, a period by its start`() {
|
||||
val text = """
|
||||
BEGIN:VCALENDAR
|
||||
BEGIN:VEVENT
|
||||
UID:f
|
||||
DTSTART;TZID=Europe/Berlin:20260105T100000
|
||||
DTEND;TZID=Europe/Berlin:20260105T110000
|
||||
RDATE;TZID=America/New_York:20260110T090000
|
||||
RDATE;VALUE=PERIOD:20260111T090000Z/PT1H
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
""".trimIndent().replace("\n", "\r\n")
|
||||
|
||||
assertThat(IcsParser().parse(text).events.single().recurrenceDates)
|
||||
.containsExactly("20260110T140000Z", "20260111T090000Z")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a declined row takes no other row with its UID along`() {
|
||||
val rows = CalendarRows(listOf(series("u").copy(declined = true), series("u", id = 2)), emptyList())
|
||||
|
||||
assertThat(planCalendarCopy(rows, ::formatExdateStamp)).hasSize(1)
|
||||
}
|
||||
}
|
||||
@@ -45,10 +45,10 @@ class BackupUiStateTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a synced writable calendar can receive a restore`() {
|
||||
fun `a synced writable calendar can receive a restore and be backed up`() {
|
||||
val state = ready(backupUiState(listOf(cal(1L))))
|
||||
assertThat(state.canImport).isTrue()
|
||||
assertThat(state.exportable).isEmpty()
|
||||
assertThat(state.exportable.map { it.id }).containsExactly(1L)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -62,16 +62,18 @@ class BackupUiStateTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a read-only subscription is no import target either`() {
|
||||
assertThat(failure(backupUiState(listOf(cal(1L, writable = false)))))
|
||||
.isEqualTo(FailureReason.NoImportTarget)
|
||||
fun `a read-only subscription is no import target but can be backed up`() {
|
||||
// #147: export only reads, so a subscription is fair game.
|
||||
val state = ready(backupUiState(listOf(cal(1L, writable = false))))
|
||||
assertThat(state.canImport).isFalse()
|
||||
assertThat(state.exportable).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `everything switched off is reported as hidden, not as a missing target`() {
|
||||
// The remedy differs: a visibility switch fixes this one.
|
||||
assertThat(failure(backupUiState(listOf(cal(1L, visible = false)))))
|
||||
.isEqualTo(FailureReason.AllCalendarsHidden)
|
||||
fun `a switched-off synced calendar still backs up`() {
|
||||
val state = ready(backupUiState(listOf(cal(1L, visible = false))))
|
||||
assertThat(state.canImport).isFalse()
|
||||
assertThat(state.exportable).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -92,7 +94,51 @@ class BackupUiStateTest {
|
||||
@Test
|
||||
fun `a local calendar alongside a synced one offers both halves`() {
|
||||
val state = ready(backupUiState(listOf(cal(1L, local = true), cal(2L))))
|
||||
assertThat(state.exportable.map { it.id }).containsExactly(1L)
|
||||
assertThat(state.exportable.map { it.id }).containsExactly(1L, 2L)
|
||||
assertThat(state.canImport).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a read-only calendar can be copied into a writable one`() {
|
||||
val readOnly = cal(1, writable = false)
|
||||
val local = cal(2, local = true)
|
||||
|
||||
val state = ready(backupUiState(listOf(readOnly, local)))
|
||||
|
||||
assertThat(state.canCopy).isTrue()
|
||||
assertThat(copySources(listOf(readOnly, local)).map { it.id }).containsExactly(1L)
|
||||
assertThat(copyTargets(listOf(readOnly, local), 1L).map { it.id }).containsExactly(2L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a single writable calendar has nothing to copy into`() {
|
||||
val only = cal(1, local = true)
|
||||
|
||||
assertThat(ready(backupUiState(listOf(only))).canCopy).isFalse()
|
||||
assertThat(copyTargets(listOf(only), 1L)).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the only writable calendar is not offered as a source`() {
|
||||
val local = cal(1, local = true)
|
||||
val holidays = cal(2, writable = false)
|
||||
|
||||
assertThat(copySources(listOf(local, holidays)).map { it.id }).containsExactly(2L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a copy never targets a managed or switched-off calendar`() {
|
||||
val source = cal(1)
|
||||
val calendars = listOf(source, cal(2, managed = true), cal(3, visible = false))
|
||||
|
||||
assertThat(copyTargets(calendars, source.id)).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `choosing the target as source drops it`() {
|
||||
assertThat(CopySelection(sourceId = 1, targetId = 2).withSource(2))
|
||||
.isEqualTo(CopySelection(sourceId = 2, targetId = null))
|
||||
assertThat(CopySelection(sourceId = 1, targetId = 2).withSource(3))
|
||||
.isEqualTo(CopySelection(sourceId = 3, targetId = 2))
|
||||
}
|
||||
}
|
||||
+278
-23
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package de.jeanlucmakiola.calendula.widget
|
||||
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import androidx.datastore.preferences.core.mutablePreferencesOf
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/** Per-widget options survive a save and read back (#489). */
|
||||
class WidgetConfigTest {
|
||||
|
||||
private val defaults = AgendaWidgetOptions(AgendaRange.Week, WidgetSize.SMALL, true, PastEventDisplay.SHOW)
|
||||
|
||||
@Test
|
||||
fun `appearance and agenda style round-trip`() {
|
||||
val appearance = WidgetAppearance(WidgetBackground.SOLID, WidgetTheme.DARK, opacity = 0.6f)
|
||||
val options = defaults.copy(style = AgendaWidgetStyle.CLASSIC)
|
||||
val prefs = mutablePreferencesOf().apply {
|
||||
putAppearance(appearance)
|
||||
putAgendaOptions(options)
|
||||
}
|
||||
assertThat(prefs.widgetAppearance).isEqualTo(appearance)
|
||||
assertThat(prefs.agendaWidgetOptions(defaults)).isEqualTo(options)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a widget saved before 2_24_1 stays opaque cards`() {
|
||||
assertThat(emptyPreferences().widgetAppearance.opacity).isEqualTo(1f)
|
||||
assertThat(emptyPreferences().agendaWidgetOptions(defaults).style).isEqualTo(AgendaWidgetStyle.CARDS)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package de.jeanlucmakiola.calendula.widget.agenda
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.datetime.LocalDate
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/** The classic agenda's day labels and row cap (#489). */
|
||||
class ClassicItemsTest {
|
||||
|
||||
private val mon = LocalDate(2026, 11, 9)
|
||||
private val tue = LocalDate(2026, 11, 10)
|
||||
|
||||
@Test
|
||||
fun `each day gets one label before its rows`() {
|
||||
val rows = listOf(AgendaRow.Placeholder(mon), AgendaRow.Placeholder(tue), AgendaRow.Placeholder(tue))
|
||||
assertThat(classicItems(rows).map { it::class }).containsExactly(
|
||||
ClassicItem.Header::class, ClassicItem.Entry::class,
|
||||
ClassicItem.Header::class, ClassicItem.Entry::class, ClassicItem.Entry::class,
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `labels count towards the cap and a stranded one is dropped`() {
|
||||
val rows = listOf(AgendaRow.Placeholder(mon), AgendaRow.Placeholder(tue))
|
||||
val items = classicItems(rows, limit = 3)
|
||||
assertThat(items).containsExactly(ClassicItem.Header(mon), ClassicItem.Entry(rows[0])).inOrder()
|
||||
}
|
||||
}
|
||||
@@ -42,33 +42,80 @@ class MonthGridTest {
|
||||
|
||||
@Test
|
||||
fun `a week that fits keeps every lane`() {
|
||||
val layouts = fitWeeks(busyWeeks(perDay = 2), lanes = 3)
|
||||
val layouts = fitWeeks(busyWeeks(perDay = 2), LaneFit(3))
|
||||
assertThat(layouts.map { it.laneCap }.distinct()).containsExactly(3)
|
||||
assertThat(layouts.all { it.overflow.isEmpty() }).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a week that doesn't fit gives its last lane to the overflow`() {
|
||||
val layouts = fitWeeks(busyWeeks(perDay = 5), lanes = 3)
|
||||
val layouts = fitWeeks(busyWeeks(perDay = 5), LaneFit(3))
|
||||
assertThat(layouts.map { it.laneCap }.distinct()).containsExactly(2)
|
||||
assertThat(layouts.all { it.overflow.isNotEmpty() }).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a taller week fits more lanes`() {
|
||||
assertThat(lanesFitting(19.dp)).isEqualTo(0)
|
||||
assertThat(lanesFitting(50.dp)).isEqualTo(2)
|
||||
assertThat(lanesFitting(100.dp)).isEqualTo(5)
|
||||
assertThat(lanesFitting(19.dp, fontScale = 1f)).isEqualTo(LaneFit(0))
|
||||
assertThat(lanesFitting(50.dp, fontScale = 1f)).isEqualTo(LaneFit(2))
|
||||
assertThat(lanesFitting(100.dp, fontScale = 1f)).isEqualTo(LaneFit(5))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a row too short for a full lane gets a compact one`() {
|
||||
assertThat(lanesFitting(34.dp, fontScale = 1f)).isEqualTo(LaneFit(1, compact = true))
|
||||
assertThat(lanesFitting(32.dp, fontScale = 1f)).isEqualTo(LaneFit(1, compact = true))
|
||||
assertThat(lanesFitting(31.dp, fontScale = 1f)).isEqualTo(LaneFit(0))
|
||||
assertThat(lanesFitting(35.dp, fontScale = 1f)).isEqualTo(LaneFit(1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no compact lane at a large font scale`() {
|
||||
assertThat(lanesFitting(34.dp, fontScale = 1.1f)).isEqualTo(LaneFit(0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a single lane shows counts on busy days`() {
|
||||
val layouts = fitWeeks(busyWeeks(perDay = 5), LaneFit(1))
|
||||
assertThat(layouts.all { it.mixedLane == List(7) { GridCell<LaneItem>(LaneItem.Count(5), 1) } }).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a single lane keeps bars on days that fit`() {
|
||||
val lone = timedEvent(monday.plus(1, DateTimeUnit.DAY), 9, zone = zone, id = 1, title = "Lone")
|
||||
val busy = (2L..4L).map { timedEvent(monday.plus(3, DateTimeUnit.DAY), it.toInt() + 8, zone = zone, id = it, title = "B$it") }
|
||||
val week = fitWeeks(weeks(listOf(lone) + busy), LaneFit(1, compact = true))[1]
|
||||
assertThat(week.compact).isTrue()
|
||||
assertThat(week.mixedLane).containsExactly(
|
||||
GridCell<LaneItem>(null, 1),
|
||||
GridCell<LaneItem>(LaneItem.Bar(lone), 1),
|
||||
GridCell<LaneItem>(null, 1),
|
||||
GridCell<LaneItem>(LaneItem.Count(3), 1),
|
||||
GridCell<LaneItem>(null, 3),
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a single lane cuts a multi-day bar around a busy day`() {
|
||||
val trip = allDayEvent(monday, monday.plus(3, DateTimeUnit.DAY), id = 1, title = "Trip")
|
||||
val busy = timedEvent(monday.plus(1, DateTimeUnit.DAY), 9, zone = zone, id = 2, title = "Busy")
|
||||
val week = fitWeeks(weeks(listOf(trip, busy)), LaneFit(1))[1]
|
||||
assertThat(week.mixedLane).containsExactly(
|
||||
GridCell<LaneItem>(LaneItem.Bar(trip), 1),
|
||||
GridCell<LaneItem>(LaneItem.Count(2), 1),
|
||||
GridCell<LaneItem>(LaneItem.Bar(trip), 2),
|
||||
GridCell<LaneItem>(null, 3),
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `lanes stop at the cap however tall the week`() {
|
||||
assertThat(lanesFitting(1000.dp)).isEqualTo(MAX_LANES)
|
||||
assertThat(lanesFitting(1000.dp, fontScale = 1f)).isEqualTo(LaneFit(MAX_LANES))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a week with no room for a lane draws no overflow row either`() {
|
||||
val layouts = fitWeeks(busyWeeks(perDay = 5), lanes = 0)
|
||||
val layouts = fitWeeks(busyWeeks(perDay = 5), LaneFit(0))
|
||||
assertThat(layouts.none { it.showsOverflow }).isTrue()
|
||||
assertThat(layouts.all { it.lanes.isEmpty() }).isTrue()
|
||||
}
|
||||
|
||||
+103
-9
@@ -462,15 +462,109 @@ after the event — so `toIcsEvent` decodes it back through
|
||||
`fromProviderAllDayMinutes` first, and the importing device re-encodes it against
|
||||
its own setting.
|
||||
|
||||
**A backup carries the series' deleted occurrences.** `EXDATE` lives on the
|
||||
master row, so the export query's `ORIGINAL_ID IS NULL` filter — which does drop
|
||||
`RECURRENCE-ID` overrides — never hid it; it simply wasn't read. It is now part
|
||||
of `EventExportProjection`, and `IcsWriter` marks an all-day series' exclusions
|
||||
`VALUE=DATE`, because RFC 5545 ties `EXDATE`'s value type to `DTSTART`'s and a
|
||||
bare day code without it reads as a malformed DATE-TIME. Sync adapters disagree
|
||||
on whether an all-day exclusion is `yyyyMMdd` or a padded midnight stamp, so
|
||||
`exportExDates` drops the time part on the way out. Without this, an in-app
|
||||
backup→restore resurrected every occurrence the user had deleted.
|
||||
**Backups read any calendar; restores still write only into targets.**
|
||||
`CalendarDataSource.calendarEvents` reads the events of whatever calendar ids it
|
||||
is given — local, synced or read-only — and leaves the choice to its callers:
|
||||
export and the automatic backup offer every calendar but the managed mirrors
|
||||
(#147), defaulting to the writable local ones (`domain/backup/BackupPlan.kt`). A
|
||||
restore runs through the normal import, whose targets stay `isEventTarget`. The
|
||||
automatic backup writes one `<calendar name>_<date>_<time>.ics` series per
|
||||
calendar, or one `calendula-backup_…` series in combined mode (#411), and prunes
|
||||
each series to the retention count after its new file has landed. Pruning only
|
||||
deletes names recorded in a per-series ledger of files the backup wrote
|
||||
(`auto_backup_files`, keyed by calendar id, reset when the folder changes),
|
||||
never whatever happens to match a
|
||||
name, so renames, same-named calendars and the user's own files are safe.
|
||||
Each series also keeps a stable undated copy (`<calendar name>.ics`, or
|
||||
`calendula-backup.ics` combined) overwritten every run, so sync tools and
|
||||
subscriptions can follow one path. Retention never touches it, and it is only
|
||||
written over a file the series itself (or a series renaming away from that name
|
||||
in the same run) recorded. A renamed calendar's old copy is deleted once the new
|
||||
one has landed, and so is a copy the layout switch replaced (per-calendar ↔ combined); a calendar taken out
|
||||
of the backup keeps its last one. The pre-#411 fixed `calendula-backup.ics` is
|
||||
the combined series' stable copy for the install that wrote it, and an
|
||||
install that already had a folder stays on the combined layout.
|
||||
|
||||
**Backing up after a change (#516)** is opt-in. `BackupChangeJob` is a
|
||||
JobScheduler job with a content trigger on the provider's root URI (the provider
|
||||
notifies every change there, whoever made it: this app, a sync adapter or
|
||||
another app), a 2-minute settle delay capped at 15 minutes. A trigger fires
|
||||
once, so each firing queues a change run (a unique `BackupWorker`, battery not
|
||||
low, queued behind one already running) and schedules the next watch under the
|
||||
same id before it finishes, which carries over the changes seen meanwhile. Such
|
||||
a job can't be persisted; `BackupScheduler.reconcile` re-arms it on every
|
||||
process start (the boot receivers start one), without replacing a waiting watch.
|
||||
Runs share the scheduled backup's lock. A change run only rewrites the stable
|
||||
copies and never retries: the dated files and their retention stay on the
|
||||
interval, so a day of edits can't rotate the history out. Every run that writes
|
||||
something is recorded on the status line; the failure notification comes once
|
||||
per failure streak, as it reaches the threshold, and the next success of any run
|
||||
clears it. Since the trigger fires for any provider change (reminders, other
|
||||
calendars, sync bookkeeping), each series' file (events in a fixed order) is
|
||||
hashed without its `DTSTAMP`s (`auto_backup_fingerprints`, reset with the folder
|
||||
and kept only while the option is on) and a change run skips the series whose
|
||||
hash (bound to the copy's name) and stable copy are unchanged, without even
|
||||
listing the folder when no hash moved; the hash is dropped before a copy is
|
||||
rewritten. A copy that went missing is restored by the next run that lists the
|
||||
folder; one a still-loading listing doesn't show, or one edited in place outside
|
||||
the app, is left to the next scheduled run.
|
||||
|
||||
**A backup carries the series' deleted and moved occurrences.** `EXDATE` lives on
|
||||
the master row and is part of `EventExportProjection`; `IcsWriter` marks an
|
||||
all-day series' exclusions `VALUE=DATE`, because RFC 5545 ties `EXDATE`'s value
|
||||
type to `DTSTART`'s and a bare day code without it reads as a malformed
|
||||
DATE-TIME. Sync adapters disagree on whether an all-day exclusion is `yyyyMMdd`
|
||||
or a padded midnight stamp, so `exportExDates` drops the time part on the way
|
||||
out.
|
||||
|
||||
A synced calendar records a deleted or moved occurrence as an *exception row*
|
||||
(`ORIGINAL_ID` / `ORIGINAL_SYNC_ID` = the series, `ORIGINAL_INSTANCE_TIME` = the
|
||||
occurrence) instead, and neither a backup file nor a copied calendar can hold
|
||||
one: an `.ics` restore skips `RECURRENCE-ID` overrides, and an inserted row has
|
||||
no `_sync_id` for an exception to attach through. `CalendarDataSource.calendarRows`
|
||||
therefore reads every row, exceptions included, and `flattenOverrides` — the one
|
||||
place this mapping lives, shared by the backup (`exportEvents`) and Copy events
|
||||
(#325, `planCalendarCopy`) — writes them the way Calendula writes "only this
|
||||
event" on an unsynced series: every exception adds its occurrence to the
|
||||
series' `EXDATE`, and a modified one becomes a standalone event at its own
|
||||
times. Its UID is derived from the series UID and the occurrence, so it is the
|
||||
same in every backup, and it carries the series UID as `X-CALENDULA-OVERRIDE-OF`.
|
||||
`importEvents` skips such an occurrence wherever its series already is — that
|
||||
series there has the override itself — so restoring into the calendar a backup
|
||||
came from adds nothing twice, while a restore into an empty one gets both. The
|
||||
standalone event also names its occurrence (`X-CALENDULA-OVERRIDE-AT`); if it
|
||||
fails to insert, the series is inserted without that exclusion, so the
|
||||
occurrence stays at its original time rather than vanishing for good. The
|
||||
import inserts every such event ahead of the series, whatever order the file
|
||||
lists them in.
|
||||
Exceptions whose series isn't among the rows are left out, a cancelled
|
||||
series' moved occurrences stay cancelled, and sync ids link only within their
|
||||
own calendar. Backups made before this carry no exceptions and restore exactly
|
||||
as before.
|
||||
|
||||
**`RDATE` travels like `EXDATE` (#515).** A series can repeat by listed dates
|
||||
alone. The provider stores `RDATE` in the same `<zone>;<stamps>` form as
|
||||
`EXDATE`, so `exportRecurrenceDates` resolves it the same way to UTC stamps (or
|
||||
bare days for an all-day series), line by line where several `RDATE`s were
|
||||
stored; a `PERIOD` entry keeps its start, and a bare day on a timed series
|
||||
becomes the occurrence at the series' time of day, so the copy (which never
|
||||
goes through the parser) and a file get the same stamps. `IcsWriter`
|
||||
writes it as `RDATE:` / `RDATE;VALUE=DATE:`, anchors such a series to its zone
|
||||
like an RRULE series, and writes its `EXDATE` too; `IcsParser` reads `RDATE`
|
||||
through the same normalisation as `EXDATE`, so a foreign `TZID` or floating
|
||||
value lands on the right instant. Older backups wrote such a series as a single
|
||||
event and still restore as one.
|
||||
|
||||
**Copying a calendar.** Backup & restore → Copy events (#325) runs the source's
|
||||
flattened rows straight into `importEvents`, so the UID dedup makes a re-run skip
|
||||
what landed before. It skips the `.ics` text: the writer pins a timed one-off to
|
||||
UTC and carries no colour or visibility; the direct mapping keeps them.
|
||||
Attendees and organizer are never read, and cancelled events and declined
|
||||
invitations are left out (an insert can't be cancelled; it would arrive live, and
|
||||
a declined occurrence counts as deleted, as does one of a declined series the
|
||||
user didn't accept on its own). A re-run is idempotent per
|
||||
snapshot: a series already in the target keeps its overrides, so an occurrence
|
||||
changed since keeps its earlier copy rather than gaining a second one.
|
||||
|
||||
That re-encode is sampled where the reminder will actually *fire*, not at
|
||||
`DTSTART` (`importedAllDayReminderDate`). An imported series' `DTSTART` is only an
|
||||
|
||||
@@ -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.
|
||||
Loaded 100 of 111 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user