Compare commits
25 Commits
renovate/g
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3fe98907e | ||
|
|
b19493e925 | ||
|
|
19b4cf06b2 | ||
|
|
f9a5618b84 | ||
|
|
822a9799db | ||
|
|
eb4fd3108e | ||
|
|
4c86c261ba | ||
|
|
dc5c9db68a | ||
|
|
befcad586e | ||
|
|
9f950cba9f | ||
|
|
51a1d1e8ef | ||
| 631c9da0a4 | |||
|
|
5fa214b9ba | ||
|
|
eb406c3132 | ||
|
|
3a90e94958 | ||
|
|
248842871b | ||
|
|
1cab023abc | ||
| 9ca29bb797 | |||
|
|
13640751d2 | ||
|
|
0c7d1a97e2 | ||
|
|
d0202783d4 | ||
|
|
f72f810e4f | ||
|
|
13f4c86113 | ||
|
|
65d9f95717 | ||
|
|
19fa8203f1 |
1
.gitattributes
vendored
1
.gitattributes
vendored
@@ -5,3 +5,4 @@
|
|||||||
*.jpg binary
|
*.jpg binary
|
||||||
*.gif binary
|
*.gif binary
|
||||||
*.webp binary
|
*.webp binary
|
||||||
|
app/src/test/resources/ics/*.ics -text
|
||||||
|
|||||||
100
CHANGELOG.md
100
CHANGELOG.md
@@ -7,6 +7,101 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2.19.4] — 2026-09-01
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **A deleted occurrence no longer comes back when the series is re-timed.**
|
||||||
|
Delete a single occurrence of a repeating event, then change the time of the
|
||||||
|
whole series, and the occurrence you had removed reappeared. Doing it the
|
||||||
|
other way round — "This and all following events" — lost every removal in the
|
||||||
|
part of the series being changed. Removals now travel with the event: they
|
||||||
|
keep their place in the series across a time change, a move to a different
|
||||||
|
day, a timezone change and a switch to or from an all-day event, and they
|
||||||
|
carry over when a series is split. Repeating events on your device's own
|
||||||
|
calendars are affected, including the birthday and anniversary calendars
|
||||||
|
Calendula creates from your contacts ([#248]).
|
||||||
|
- **Home-screen widgets now turn the page at midnight.** Both the month and the
|
||||||
|
agenda widget kept highlighting yesterday as "today" — and the agenda kept
|
||||||
|
greying out the wrong events as already past — until you paged the month back
|
||||||
|
and forth or removed and re-added the widget. Calendula now wakes itself at the
|
||||||
|
day boundary and redraws, and re-arms after a reboot, a clock change or a
|
||||||
|
flight into another timezone. Paging the month widget forward and back also
|
||||||
|
stops quietly pinning it to that month, so it follows the date again instead of
|
||||||
|
being stranded on the month you happened to be looking at ([#228]).
|
||||||
|
- **"Only this event" now actually saves your edit.** On some calendars —
|
||||||
|
including Google ones that still show as on-device, and any local calendar —
|
||||||
|
editing a single occurrence of a repeating event did nothing at all: the scope
|
||||||
|
dialog closed, the edit screen stayed put, and saving again just repeated it.
|
||||||
|
Android can only attach a single-occurrence change to its series once the
|
||||||
|
calendar has been synced at least once, so on those calendars the change had
|
||||||
|
nowhere to go. Calendula now removes that one occurrence from the series and
|
||||||
|
saves the edit as its own event instead, which is what you see either way. A
|
||||||
|
save that does fail also says so for longer, rather than flashing past
|
||||||
|
([#234]).
|
||||||
|
- **A failed import no longer costs you the whole file.** One event the calendar
|
||||||
|
refuses used to abort the entire import with nothing on screen but "couldn't
|
||||||
|
read this file". Events are now added one at a time: anything rejected is
|
||||||
|
counted and shown, and the rest still arrive. A faulty repeat rule in the file
|
||||||
|
is repaired instead of being handed on ([#225]).
|
||||||
|
- **All-day reminders survive a backup and an import.** They fire at the hour you
|
||||||
|
chose, not at midnight UTC — the same rule the app already applied to all-day
|
||||||
|
events you create yourself — and an export no longer leaves them out of the
|
||||||
|
file entirely. An imported yearly birthday fires at the right hour too, even
|
||||||
|
though the file dates it back to the year of birth ([#225]).
|
||||||
|
- **Birthdays and anniversaries imported from Fossify Calendar now show up.**
|
||||||
|
Fossify writes the ones it mirrors from your contacts as events that start and
|
||||||
|
end on the same day, which the calendar read as lasting no time at all: they
|
||||||
|
imported without complaint and then appeared nowhere. Any all-day event that
|
||||||
|
ends where it starts, or carries no end at all, is now a one-day event
|
||||||
|
([#225]).
|
||||||
|
- **A backup no longer brings back occurrences you had deleted.** Remove a single
|
||||||
|
occurrence of a repeating event, back up, restore — and it was there again,
|
||||||
|
because the removals never made it into the file. They travel with the series
|
||||||
|
now, so a restored calendar looks the way you left it ([#225]).
|
||||||
|
- **A reminder left on your account's default no longer went off as the event
|
||||||
|
started.** An event whose reminder says "Default" is the account's to time, not
|
||||||
|
Calendula's — it was reading that setting as a lead time of its own and firing a
|
||||||
|
minute after the event began. It now leaves such a reminder alone, and says so
|
||||||
|
on the event.
|
||||||
|
- **"Only this event" on an event that doesn't repeat now edits or deletes it.**
|
||||||
|
Deleting used to write an exclusion onto a one-off event instead — which left
|
||||||
|
the event there and lost its end time — and editing filed the change away as an
|
||||||
|
exception to nothing, so it vanished.
|
||||||
|
- **An import that the calendar cuts short now tells you how far it got.** Losing
|
||||||
|
the calendar or the permission part way through a big file reported the whole
|
||||||
|
import as failed, so importing again added everything twice.
|
||||||
|
- **An event that runs past midnight can be dragged by either half.** Its second
|
||||||
|
half couldn't be picked up at all, and the first could only be moved later. Both
|
||||||
|
halves now move, and both are previewed while you hold either one, so the event
|
||||||
|
reads as moving rather than growing. The blocks are cut square at midnight
|
||||||
|
instead of rounded off, a month row draws an event crossing into the next week
|
||||||
|
as one bar, and a move across a daylight-saving change no longer lands an hour
|
||||||
|
out ([#253]).
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **More of an imported `.ics` survives the trip**: tasks come across as events
|
||||||
|
rather than being dropped in silence, deleted occurrences of a repeating event
|
||||||
|
stay deleted, a calendar named like the one the file came from is preselected,
|
||||||
|
and event colours are carried over — matched to the closest colour your
|
||||||
|
calendar's account offers, so a migrated calendar still reads at a glance
|
||||||
|
([#225]).
|
||||||
|
- **Belarusian, Hungarian and Slovak**, all three contributed as community
|
||||||
|
translations through
|
||||||
|
[Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/).
|
||||||
|
Belarusian covers about nine tenths of the app, Slovak about a third and
|
||||||
|
Hungarian is just under way, so untranslated parts still show in English until
|
||||||
|
they fill out. Pick any of them under Settings → Language or in Android's
|
||||||
|
per-app language settings. Thanks to Dmitri Gabinski (Belarusian), Ábel Nagy
|
||||||
|
(Hungarian) and Julie (Slovak); help finishing them is very welcome.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Arabic, Polish and Simplified Chinese are now complete**, all three carried
|
||||||
|
the rest of the way this cycle — Simplified Chinese from a fifth of the app to
|
||||||
|
all of it. Czech and Russian grew too — Russian to about half — and still have
|
||||||
|
room. Thanks to Sup99999 (Arabic), Bazyli Cyran (Polish), JeanWenzel
|
||||||
|
(Simplified Chinese), James Trivette (Czech), and SpiritOTHawk and Artur
|
||||||
|
(Russian).
|
||||||
|
|
||||||
## [2.19.3] — 2026-08-22
|
## [2.19.3] — 2026-08-22
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -1471,3 +1566,8 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#192]: https://codeberg.org/jlmakiola/calendula/issues/192
|
[#192]: https://codeberg.org/jlmakiola/calendula/issues/192
|
||||||
[#196]: https://codeberg.org/jlmakiola/calendula/issues/196
|
[#196]: https://codeberg.org/jlmakiola/calendula/issues/196
|
||||||
[#214]: https://codeberg.org/jlmakiola/calendula/issues/214
|
[#214]: https://codeberg.org/jlmakiola/calendula/issues/214
|
||||||
|
[#225]: https://codeberg.org/jlmakiola/calendula/issues/225
|
||||||
|
[#228]: https://codeberg.org/jlmakiola/calendula/issues/228
|
||||||
|
[#234]: https://codeberg.org/jlmakiola/calendula/issues/234
|
||||||
|
[#248]: https://codeberg.org/jlmakiola/calendula/issues/248
|
||||||
|
[#253]: https://codeberg.org/jlmakiola/calendula/issues/253
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ android {
|
|||||||
// which builds this version and then creates the matching vX.Y.Z tag +
|
// which builds this version and then creates the matching vX.Y.Z tag +
|
||||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||||
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
||||||
versionCode = 21903
|
versionCode = 21904
|
||||||
versionName = "2.19.3"
|
versionName = "2.19.4"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
6
app/proguard-rules.pro
vendored
6
app/proguard-rules.pro
vendored
@@ -50,3 +50,9 @@
|
|||||||
# the real names also survives app updates, which would otherwise renumber the
|
# the real names also survives app updates, which would otherwise renumber the
|
||||||
# obfuscated name and orphan the stored mapping.
|
# obfuscated name and orphan the stored mapping.
|
||||||
-keep class * extends androidx.glance.appwidget.GlanceAppWidget
|
-keep class * extends androidx.glance.appwidget.GlanceAppWidget
|
||||||
|
|
||||||
|
# Belt and braces one level up: the two receivers are nearly as alike, and the
|
||||||
|
# provider map is keyed off the receiver component too. Redundant today (AGP's
|
||||||
|
# manifest-derived rules cover them), but #89 cost a release to diagnose and the
|
||||||
|
# guarantee should not rest on a component staying in the manifest.
|
||||||
|
-keep class * extends androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||||
|
|||||||
@@ -330,9 +330,13 @@
|
|||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
<!-- Keeps both widgets fresh: the calendar provider broadcasts
|
<!-- Keeps both widgets fresh: the calendar provider broadcasts
|
||||||
PROVIDER_CHANGED on any data change (our writes and external sync),
|
PROVIDER_CHANGED on any data change (our writes and external sync).
|
||||||
and the system broadcasts the date/time ones at midnight / clock
|
The day boundary arrives as the app's own ROLLOVER alarm (#228), by
|
||||||
changes so "today" highlighting rolls over. -->
|
explicit PendingIntent, so it needs no filter here; DATE_CHANGED is
|
||||||
|
a free extra only, since Android 8+ withholds it from manifest
|
||||||
|
receivers. The four below re-arm that alarm: TIME_SET /
|
||||||
|
TIMEZONE_CHANGED move the boundary, boot / package-replace wipe it.
|
||||||
|
Exported: the system broadcasts arrive from outside the app. -->
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".widget.WidgetUpdateReceiver"
|
android:name=".widget.WidgetUpdateReceiver"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
@@ -346,6 +350,8 @@
|
|||||||
<action android:name="android.intent.action.DATE_CHANGED" />
|
<action android:name="android.intent.action.DATE_CHANGED" />
|
||||||
<action android:name="android.intent.action.TIME_SET" />
|
<action android:name="android.intent.action.TIME_SET" />
|
||||||
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
|
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
|
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
|||||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker
|
||||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceScheduler
|
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceScheduler
|
||||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceWorker
|
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceWorker
|
||||||
|
import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler
|
||||||
import de.jeanlucmakiola.floret.crash.CrashConfig
|
import de.jeanlucmakiola.floret.crash.CrashConfig
|
||||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -44,6 +45,19 @@ class CalendulaApp : Application() {
|
|||||||
reconcileSpecialDates()
|
reconcileSpecialDates()
|
||||||
reconcileCalendarVisibility()
|
reconcileCalendarVisibility()
|
||||||
startReminderDelivery()
|
startReminderDelivery()
|
||||||
|
reconcileWidgetRollover()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-arm the widgets' midnight rollover from whatever is actually placed
|
||||||
|
* (#228). Idempotent, and it covers what no broadcast reaches — an alarm
|
||||||
|
* dropped by a force-stop is armed again the next time the app is opened.
|
||||||
|
* Off the main thread: a handful of binder calls on every process start.
|
||||||
|
*/
|
||||||
|
private fun reconcileWidgetRollover() {
|
||||||
|
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
|
||||||
|
WidgetRolloverScheduler.sync(this@CalendulaApp)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -31,9 +31,13 @@ private const val MILLIS_PER_MINUTE = 60_000L
|
|||||||
* event (a whole-day multiple; sub-day remainders are dropped), so it fires at
|
* event (a whole-day multiple; sub-day remainders are dropped), so it fires at
|
||||||
* [timeOfDayMinutes] (minutes from local midnight) in [zone]. The result may be
|
* [timeOfDayMinutes] (minutes from local midnight) in [zone]. The result may be
|
||||||
* **negative** — e.g. "at time of event" at 09:00 CEST encodes to −420, meaning
|
* **negative** — e.g. "at time of event" at 09:00 CEST encodes to −420, meaning
|
||||||
* the provider fires *after* DTSTART; this is valid and must not be clamped.
|
* the provider fires *after* DTSTART; this is valid and must not be clamped. The
|
||||||
* A negative [semanticMinutes] is the "provider default" sentinel and passes
|
* one negative it never is is [MINUTES_DEFAULT]: the sentinel and the encoding
|
||||||
* through unchanged.
|
* share a value space, so an encoding that lands on it is nudged one minute past
|
||||||
|
* it rather than being read back as "use the account default". That sentinel is
|
||||||
|
* also the one [semanticMinutes] that passes through unencoded — a lead time may
|
||||||
|
* itself be negative (a foreign row firing the day *after* the event), and
|
||||||
|
* waving those through would change when they fire.
|
||||||
*/
|
*/
|
||||||
internal fun toProviderAllDayMinutes(
|
internal fun toProviderAllDayMinutes(
|
||||||
semanticMinutes: Int,
|
semanticMinutes: Int,
|
||||||
@@ -41,12 +45,13 @@ internal fun toProviderAllDayMinutes(
|
|||||||
zone: ZoneId,
|
zone: ZoneId,
|
||||||
timeOfDayMinutes: Int,
|
timeOfDayMinutes: Int,
|
||||||
): Int {
|
): Int {
|
||||||
if (semanticMinutes < 0) return semanticMinutes
|
if (semanticMinutes == MINUTES_DEFAULT) return semanticMinutes
|
||||||
val utcMidnight = startDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
val utcMidnight = startDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||||
val fire = startDate.minusDays((semanticMinutes / MINUTES_PER_DAY).toLong())
|
val fire = startDate.minusDays((semanticMinutes / MINUTES_PER_DAY).toLong())
|
||||||
.atTime(LocalTime.of(timeOfDayMinutes / 60, timeOfDayMinutes % 60))
|
.atTime(LocalTime.of(timeOfDayMinutes / 60, timeOfDayMinutes % 60))
|
||||||
.atZone(zone).toInstant().toEpochMilli()
|
.atZone(zone).toInstant().toEpochMilli()
|
||||||
return ((utcMidnight - fire) / MILLIS_PER_MINUTE).toInt()
|
val raw = ((utcMidnight - fire) / MILLIS_PER_MINUTE).toInt()
|
||||||
|
return if (raw == MINUTES_DEFAULT) MINUTES_DEFAULT - 1 else raw
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -70,15 +75,52 @@ internal fun nextYearlyOccurrence(month: Int, day: Int, today: LocalDate): Local
|
|||||||
return LocalDate.of(today.year, month, day)
|
return LocalDate.of(today.year, month, day)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The date an **imported** all-day event's reminder offset is sampled at.
|
||||||
|
*
|
||||||
|
* A one-off fires once, on its own date. A series' `DTSTART` is only an anchor
|
||||||
|
* and may be ancient — Fossify writes a year-less contact birthday at 1970, a
|
||||||
|
* year whose rules predate DST across most of Europe, and a real birth year is
|
||||||
|
* usually older still — so sampling there skews every modern occurrence by the
|
||||||
|
* offset delta (the same trap [nextYearlyOccurrence] exists for). A yearly
|
||||||
|
* series is therefore sampled at its next occurrence of the anchor's month/day,
|
||||||
|
* which shares the season; any other series at [today], which its upcoming
|
||||||
|
* occurrences are near.
|
||||||
|
*
|
||||||
|
* The decode side ([fromProviderAllDayMinutes]) only asks which local *day* the
|
||||||
|
* encoded instant falls on, so it still reads the lead time back correctly from
|
||||||
|
* the anchor's own date.
|
||||||
|
*/
|
||||||
|
internal fun importedAllDayReminderDate(
|
||||||
|
startDate: LocalDate,
|
||||||
|
recurrenceRule: String?,
|
||||||
|
today: LocalDate,
|
||||||
|
): LocalDate = when {
|
||||||
|
recurrenceRule.isNullOrBlank() -> startDate
|
||||||
|
recurrenceRule.contains("FREQ=YEARLY", ignoreCase = true) ->
|
||||||
|
nextYearlyOccurrence(startDate.monthValue, startDate.dayOfMonth, today)
|
||||||
|
startDate.isBefore(today) -> today
|
||||||
|
else -> startDate
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recover the semantic whole-day lead time from a raw all-day reminder
|
* Recover the semantic whole-day lead time from a raw all-day reminder
|
||||||
* [rawMinutes] — the inverse of [toProviderAllDayMinutes], for the form and the
|
* [rawMinutes] — the inverse of [toProviderAllDayMinutes], for the form and the
|
||||||
* detail screen. Delegates to [allDayLeadDays], so the day displayed is the day
|
* detail screen. Delegates to [allDayLeadDays], so the day displayed is the day
|
||||||
* the reminder actually fires on.
|
* the reminder actually fires on.
|
||||||
|
*
|
||||||
|
* [MINUTES_DEFAULT] passes through unchanged, the mirror of the encode side. A
|
||||||
|
* real encoding is often negative, so only that exact value may be treated as the
|
||||||
|
* sentinel — decoding it would land it on the start date and turn "use the
|
||||||
|
* account default" into a concrete at-start alarm.
|
||||||
*/
|
*/
|
||||||
internal fun fromProviderAllDayMinutes(
|
internal fun fromProviderAllDayMinutes(
|
||||||
rawMinutes: Int,
|
rawMinutes: Int,
|
||||||
startDate: LocalDate,
|
startDate: LocalDate,
|
||||||
zone: ZoneId,
|
zone: ZoneId,
|
||||||
timeOfDayMinutes: Int,
|
timeOfDayMinutes: Int,
|
||||||
): Int = allDayLeadDays(rawMinutes, startDate, zone, timeOfDayMinutes).toInt() * MINUTES_PER_DAY
|
): Int = if (rawMinutes == MINUTES_DEFAULT) {
|
||||||
|
rawMinutes
|
||||||
|
} else {
|
||||||
|
allDayLeadDays(rawMinutes, startDate, zone, timeOfDayMinutes).toInt() * MINUTES_PER_DAY
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import de.jeanlucmakiola.calendula.domain.contacts.MANAGED_UID_PREFIX
|
|||||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.semanticReminderMinutes
|
||||||
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
|
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
|
||||||
import kotlinx.datetime.toJavaLocalDate
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
@@ -84,14 +85,25 @@ interface CalendarDataSource {
|
|||||||
*/
|
*/
|
||||||
fun eventColorPalette(calendarId: Long): List<EventColorOption>
|
fun eventColorPalette(calendarId: Long): List<EventColorOption>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same palette **uncurated** — every key the account publishes, in
|
||||||
|
* provider order. Curation is a display concession (it folds look-alikes and
|
||||||
|
* drops the neutrals outright from an oversized palette), so matching a
|
||||||
|
* colour that came from outside the account has to run against the full set:
|
||||||
|
* those keys are all the calendar accepts. See [eventColorPalette].
|
||||||
|
*/
|
||||||
|
fun publishedEventColors(calendarId: Long): List<EventColorOption>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every master/one-off event of the writable local calendars, mapped for a
|
* Every master/one-off event of the writable local calendars, mapped for a
|
||||||
* whole-calendar `.ics` backup. Modified-occurrence and cancelled-exception
|
* whole-calendar `.ics` backup. Modified-occurrence and cancelled-exception
|
||||||
* rows are excluded (see [EventExportProjection]). When [calendarIds] is
|
* rows are excluded (see [EventExportProjection]). When [calendarIds] is
|
||||||
* given, only those calendars are exported (still intersected with the
|
* given, only those calendars are exported (still intersected with the
|
||||||
* eligible set); `null` exports every eligible calendar.
|
* 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]).
|
||||||
*/
|
*/
|
||||||
fun exportableEvents(calendarIds: Set<Long>? = null): List<IcsEvent>
|
fun exportableEvents(calendarIds: Set<Long>?, allDayReminderTimeMinutes: Int): List<IcsEvent>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The non-empty `Events.UID_2445` values present in [calendarId] — used to
|
* The non-empty `Events.UID_2445` values present in [calendarId] — used to
|
||||||
@@ -101,10 +113,19 @@ interface CalendarDataSource {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Insert a parsed `.ics` event into [calendarId], preserving its UID (or
|
* Insert a parsed `.ics` event into [calendarId], preserving its UID (or
|
||||||
* minting one when absent); returns the new `Events._ID`. Reminders are
|
* minting one when absent); returns the new `Events._ID`.
|
||||||
* written as the file's raw lead minutes (METHOD_ALERT).
|
*
|
||||||
|
* [colorPalette] is the target account's published event colours
|
||||||
|
* ([publishedEventColors]), looked up once per import;
|
||||||
|
* [allDayReminderTimeMinutes] is the user's preferred all-day firing time,
|
||||||
|
* applied exactly as a hand-created event's is.
|
||||||
*/
|
*/
|
||||||
fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long
|
fun insertImportedEvent(
|
||||||
|
event: ParsedIcsEvent,
|
||||||
|
calendarId: Long,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
colorPalette: List<EventColorOption>,
|
||||||
|
): Long
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new device-only (`ACCOUNT_TYPE_LOCAL`) calendar the app owns;
|
* Create a new device-only (`ACCOUNT_TYPE_LOCAL`) calendar the app owns;
|
||||||
@@ -232,14 +253,21 @@ interface CalendarDataSource {
|
|||||||
): Long
|
): Long
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Change a single occurrence of a recurring event by inserting a
|
* Change a single occurrence of a recurring event at [beginMillis] (the
|
||||||
* modified-occurrence exception at [beginMillis] (the occurrence's
|
* occurrence's `Instances.BEGIN`) to [form]'s values; returns the
|
||||||
* `Instances.BEGIN`) carrying [form]'s values; returns the exception
|
* `Events._ID` of the row now holding them.
|
||||||
* row's `Events._ID`. [allDayReminderTimeMinutes]: see [insertEvent].
|
*
|
||||||
|
* A series with a `_sync_id` gets a modified-occurrence exception. One
|
||||||
|
* without gets the occurrence excluded from the parent via EXDATE plus a
|
||||||
|
* standalone event carrying the edits — an exception cannot link to its
|
||||||
|
* parent there (Codeberg #234, the same constraint as [deleteOccurrence]).
|
||||||
|
* A row that turns out not to recur at all is edited whole, which is what
|
||||||
|
* [original] is for. [allDayReminderTimeMinutes]: see [insertEvent].
|
||||||
*/
|
*/
|
||||||
fun updateOccurrence(
|
fun updateOccurrence(
|
||||||
eventId: Long,
|
eventId: Long,
|
||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
|
original: EventForm,
|
||||||
form: EventForm,
|
form: EventForm,
|
||||||
allDayReminderTimeMinutes: Int,
|
allDayReminderTimeMinutes: Int,
|
||||||
): Long
|
): Long
|
||||||
@@ -721,7 +749,10 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun eventColorPalette(calendarId: Long): List<EventColorOption> {
|
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
||||||
|
publishedEventColors(calendarId).curatedForPicker()
|
||||||
|
|
||||||
|
override fun publishedEventColors(calendarId: Long): List<EventColorOption> {
|
||||||
val account = calendarAccount(calendarId) ?: return emptyList()
|
val account = calendarAccount(calendarId) ?: return emptyList()
|
||||||
return resolver.query(
|
return resolver.query(
|
||||||
CalendarContract.Colors.CONTENT_URI,
|
CalendarContract.Colors.CONTENT_URI,
|
||||||
@@ -739,11 +770,13 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
c.mapAll { EventColorOption(key = it.getString(0).orEmpty(), argb = it.getInt(1)) }
|
c.mapAll { EventColorOption(key = it.getString(0).orEmpty(), argb = it.getInt(1)) }
|
||||||
}
|
}
|
||||||
?.filter { it.key.isNotEmpty() }
|
?.filter { it.key.isNotEmpty() }
|
||||||
?.curatedForPicker()
|
|
||||||
?: emptyList()
|
?: emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun exportableEvents(calendarIds: Set<Long>?): List<IcsEvent> {
|
override fun exportableEvents(
|
||||||
|
calendarIds: Set<Long>?,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
): List<IcsEvent> {
|
||||||
// Only the local calendars the app owns and can write — synced calendars
|
// Only the local calendars the app owns and can write — synced calendars
|
||||||
// already have a backup (their server). Exclude the managed special-dates
|
// already have a backup (their server). Exclude the managed special-dates
|
||||||
// mirror calendars: their events are derived from contacts, not authored
|
// mirror calendars: their events are derived from contacts, not authored
|
||||||
@@ -778,6 +811,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
reader.toIcsEvent(
|
reader.toIcsEvent(
|
||||||
reminderMinutes = queryReminders(eventId).map { it.minutes },
|
reminderMinutes = queryReminders(eventId).map { it.minutes },
|
||||||
calendarName = names[calendarId],
|
calendarName = names[calendarId],
|
||||||
|
allDayReminderTimeMinutes = allDayReminderTimeMinutes,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} ?: emptyList()
|
} ?: emptyList()
|
||||||
@@ -786,76 +820,83 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
override fun existingUids(calendarId: Long): Set<String> = resolver.query(
|
override fun existingUids(calendarId: Long): Set<String> = resolver.query(
|
||||||
CalendarContract.Events.CONTENT_URI,
|
CalendarContract.Events.CONTENT_URI,
|
||||||
arrayOf(CalendarContract.Events.UID_2445),
|
arrayOf(CalendarContract.Events.UID_2445),
|
||||||
|
// DELETED rows linger until a sync adapter purges them; counting those
|
||||||
|
// as present would make a re-import skip everything the user has since
|
||||||
|
// deleted, reporting "all duplicates" and importing nothing.
|
||||||
"${CalendarContract.Events.CALENDAR_ID} = ? AND " +
|
"${CalendarContract.Events.CALENDAR_ID} = ? AND " +
|
||||||
"${CalendarContract.Events.UID_2445} IS NOT NULL",
|
"${CalendarContract.Events.UID_2445} IS NOT NULL AND " +
|
||||||
|
"${CalendarContract.Events.DELETED} = 0",
|
||||||
arrayOf(calendarId.toString()),
|
arrayOf(calendarId.toString()),
|
||||||
null,
|
null,
|
||||||
)?.use { c ->
|
)?.use { c ->
|
||||||
buildSet { while (c.moveToNext()) c.getString(0)?.takeIf { it.isNotEmpty() }?.let(::add) }
|
buildSet { while (c.moveToNext()) c.getString(0)?.takeIf { it.isNotEmpty() }?.let(::add) }
|
||||||
} ?: emptySet()
|
} ?: emptySet()
|
||||||
|
|
||||||
override fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long {
|
override fun insertImportedEvent(
|
||||||
val startMillis = event.start.toEpochMillis()
|
event: ParsedIcsEvent,
|
||||||
val endMillis = event.end.toEpochMillis()
|
calendarId: Long,
|
||||||
val values = ContentValues().apply {
|
allDayReminderTimeMinutes: Int,
|
||||||
put(CalendarContract.Events.CALENDAR_ID, calendarId)
|
colorPalette: List<EventColorOption>,
|
||||||
|
): Long {
|
||||||
|
val values = buildImportedEventValues(
|
||||||
|
event = event,
|
||||||
|
calendarId = calendarId,
|
||||||
// Preserve the file's UID so a re-import dedups against it; mint one
|
// Preserve the file's UID so a re-import dedups against it; mint one
|
||||||
// only when the source event carried none.
|
// only when the source event carried none.
|
||||||
put(
|
uid = event.uid?.takeIf { it.isNotBlank() } ?: "${UUID.randomUUID()}@calendula",
|
||||||
CalendarContract.Events.UID_2445,
|
palette = colorPalette,
|
||||||
event.uid?.takeIf { it.isNotBlank() } ?: "${UUID.randomUUID()}@calendula",
|
)
|
||||||
)
|
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values.toContentValues())
|
||||||
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) {
|
|
||||||
put(CalendarContract.Events.DTEND, endMillis)
|
|
||||||
} else {
|
|
||||||
put(CalendarContract.Events.RRULE, event.recurrenceRule)
|
|
||||||
put(
|
|
||||||
CalendarContract.Events.DURATION,
|
|
||||||
importDuration(startMillis, endMillis, event.isAllDay),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// All-day rows live at UTC midnights (the file already encodes them so);
|
|
||||||
// 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())
|
|
||||||
put(CalendarContract.Events.STATUS, event.status.toProviderStatus())
|
|
||||||
event.location?.trim()?.takeIf { it.isNotEmpty() }
|
|
||||||
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
|
|
||||||
event.description?.trim()?.takeIf { it.isNotEmpty() }
|
|
||||||
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
|
|
||||||
}
|
|
||||||
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
|
|
||||||
?: throw WriteFailedException("import event into calendar id=$calendarId")
|
?: throw WriteFailedException("import event into calendar id=$calendarId")
|
||||||
val eventId = ContentUris.parseId(uri)
|
val eventId = ContentUris.parseId(uri)
|
||||||
// Raw lead minutes straight from the file's VALARMs (best-effort, like insertEvent).
|
val zone = ZoneId.systemDefault()
|
||||||
event.reminderMinutes.distinct().filter { it >= 0 }.forEach { minutes ->
|
// An all-day reminder is stored the same way a hand-created one is, so it
|
||||||
|
// fires at the time the user picked rather than at UTC midnight — sampled
|
||||||
|
// where it will actually fire, not at an ancient recurrence anchor
|
||||||
|
// (see [importedAllDayReminderDate]).
|
||||||
|
val reminderDate = if (event.isAllDay) {
|
||||||
|
importedAllDayReminderDate(
|
||||||
|
startDate = Instant.ofEpochMilli(event.start.toEpochMilliseconds())
|
||||||
|
.atZone(ZoneOffset.UTC).toLocalDate(),
|
||||||
|
recurrenceRule = event.recurrenceRule,
|
||||||
|
today = LocalDate.now(zone),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
event.semanticReminderMinutes().forEach { minutes ->
|
||||||
|
val providerMinutes = if (reminderDate != null) {
|
||||||
|
toProviderAllDayMinutes(
|
||||||
|
semanticMinutes = minutes,
|
||||||
|
startDate = reminderDate,
|
||||||
|
zone = zone,
|
||||||
|
timeOfDayMinutes = allDayReminderTimeMinutes,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
minutes
|
||||||
|
}
|
||||||
val reminder = ContentValues().apply {
|
val reminder = ContentValues().apply {
|
||||||
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
||||||
put(CalendarContract.Reminders.MINUTES, minutes)
|
put(CalendarContract.Reminders.MINUTES, providerMinutes)
|
||||||
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
|
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
|
||||||
}
|
}
|
||||||
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
|
// The event row is already in. A reminder that won't attach costs
|
||||||
|
// that one alarm, not the event: throwing here would have the import
|
||||||
|
// count an event it did create as failed, and the retry would then
|
||||||
|
// skip it as a duplicate.
|
||||||
|
val attached = try {
|
||||||
|
resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Reminder insert threw for imported event $eventId", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
if (attached == null) {
|
||||||
Log.w(TAG, "Failed to attach reminder ($minutes min) to imported event $eventId")
|
Log.w(TAG, "Failed to attach reminder ($minutes min) to imported event $eventId")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return eventId
|
return eventId
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Provider DURATION for an imported recurring row: whole days / seconds. */
|
|
||||||
private fun importDuration(startMillis: Long, endMillis: Long, isAllDay: Boolean): String {
|
|
||||||
val span = (endMillis - startMillis).coerceAtLeast(0)
|
|
||||||
return if (isAllDay) "P${span / 86_400_000L}D" else "P${span / 1_000L}S"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun EventStatus.toProviderStatus(): Int = when (this) {
|
|
||||||
EventStatus.Confirmed -> CalendarContract.Events.STATUS_CONFIRMED
|
|
||||||
EventStatus.Tentative -> CalendarContract.Events.STATUS_TENTATIVE
|
|
||||||
EventStatus.Cancelled -> CalendarContract.Events.STATUS_CANCELED
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The account a calendar belongs to, for scoping a `Colors` lookup. */
|
/** The account a calendar belongs to, for scoping a `Colors` lookup. */
|
||||||
private fun calendarAccount(calendarId: Long): CalendarAccount? = resolver.query(
|
private fun calendarAccount(calendarId: Long): CalendarAccount? = resolver.query(
|
||||||
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarId),
|
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarId),
|
||||||
@@ -976,10 +1017,12 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
updated: EventForm,
|
updated: EventForm,
|
||||||
allDayReminderTimeMinutes: Int,
|
allDayReminderTimeMinutes: Int,
|
||||||
) {
|
) {
|
||||||
|
val row = querySeriesRow(eventId)
|
||||||
val values = buildEventUpdateValues(
|
val values = buildEventUpdateValues(
|
||||||
original = original,
|
original = original,
|
||||||
updated = updated,
|
updated = updated,
|
||||||
seriesDtStartMillis = querySeriesRow(eventId).dtStartMillis,
|
seriesDtStartMillis = row.dtStartMillis,
|
||||||
|
seriesExdate = row.exdate,
|
||||||
zone = ZoneId.systemDefault(),
|
zone = ZoneId.systemDefault(),
|
||||||
)
|
)
|
||||||
if (values.isNotEmpty()) {
|
if (values.isNotEmpty()) {
|
||||||
@@ -1191,9 +1234,22 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
override fun updateOccurrence(
|
override fun updateOccurrence(
|
||||||
eventId: Long,
|
eventId: Long,
|
||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
|
original: EventForm,
|
||||||
form: EventForm,
|
form: EventForm,
|
||||||
allDayReminderTimeMinutes: Int,
|
allDayReminderTimeMinutes: Int,
|
||||||
): Long {
|
): Long {
|
||||||
|
val row = querySeriesRow(eventId)
|
||||||
|
// Not a series, so there is no occurrence to single out: the row *is* the
|
||||||
|
// event, and an exception with nothing to be an exception to would leave
|
||||||
|
// an orphan holding the edit (the same test deleteOccurrence makes).
|
||||||
|
if (!row.recurs) {
|
||||||
|
updateEvent(eventId, original, form, allDayReminderTimeMinutes)
|
||||||
|
return eventId
|
||||||
|
}
|
||||||
|
// EXDATE needs no _sync_id; a modified exception does.
|
||||||
|
if (row.syncId == null) {
|
||||||
|
return detachOccurrence(eventId, beginMillis, row, form, allDayReminderTimeMinutes)
|
||||||
|
}
|
||||||
// The provider clones the series row and applies these values on top.
|
// The provider clones the series row and applies these values on top.
|
||||||
val values = buildOccurrenceExceptionValues(
|
val values = buildOccurrenceExceptionValues(
|
||||||
form = form,
|
form = form,
|
||||||
@@ -1212,6 +1268,90 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
return exceptionId
|
return exceptionId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Edit only this event" on a series with **no `_sync_id`**: drop the
|
||||||
|
* occurrence from the parent with EXDATE and insert the edited values as a
|
||||||
|
* standalone event on the same calendar.
|
||||||
|
*
|
||||||
|
* A modified exception attaches to its parent only through `ORIGINAL_SYNC_ID`,
|
||||||
|
* exactly like the cancelled one [deleteOccurrence] documents; with no
|
||||||
|
* `_sync_id` the link never forms and the edit is lost (Codeberg #234).
|
||||||
|
* EXDATE plus a standalone row needs no link — what a detached instance
|
||||||
|
* degrades to without a `RECURRENCE-ID` to carry it.
|
||||||
|
*
|
||||||
|
* The detached row keeps no stored link back to its series, so: it no longer
|
||||||
|
* travels with it ([moveEvent] copies the master and its `ORIGINAL_ID`
|
||||||
|
* children, and this is neither); its EXDATE hole is an absolute instant, so
|
||||||
|
* re-timing the whole series brings the occurrence back beside the copy (a
|
||||||
|
* #47 delete resurrects the same way); and it is built from the form, not
|
||||||
|
* cloned, so `ORGANIZER`, `STATUS` and the attendee rows [reconcileAttendees]
|
||||||
|
* preserves are dropped — the same limitation as [moveEvent].
|
||||||
|
*
|
||||||
|
* Insert first, so a failure leaves the series untouched
|
||||||
|
* ([updateEventFromOccurrence]'s discipline); roll the new row back if the
|
||||||
|
* EXDATE update then fails, since it would be a visible duplicate. The
|
||||||
|
* reverse order risks the worse outcome — an excluded occurrence with no
|
||||||
|
* replacement, i.e. an edit that quietly deletes.
|
||||||
|
*/
|
||||||
|
private fun detachOccurrence(
|
||||||
|
eventId: Long,
|
||||||
|
beginMillis: Long,
|
||||||
|
row: SeriesRow,
|
||||||
|
form: EventForm,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
): Long {
|
||||||
|
// Already detached (or deleted) from a stale screen still pointing at the
|
||||||
|
// parent: the EXDATE merge would fold the repeat away and still report a
|
||||||
|
// changed row, quietly leaving a *second* standalone copy.
|
||||||
|
if (exdateContains(row.exdate, beginMillis, row.allDay != 0, row.timezone)) {
|
||||||
|
throw NoSuchEventException(eventId)
|
||||||
|
}
|
||||||
|
// Reminders, guests and colour come along like any new event, and so does
|
||||||
|
// a fresh UID — the detached row is a separate event now, and sharing the
|
||||||
|
// parent's would collide with it in .ics restore dedup.
|
||||||
|
val detachedId = insertEvent(form.toDetachedOccurrence(), allDayReminderTimeMinutes)
|
||||||
|
val values = buildOccurrenceExdateValues(
|
||||||
|
existingExdate = row.exdate,
|
||||||
|
occurrenceMillis = beginMillis,
|
||||||
|
dtStartMillis = row.dtStartMillis,
|
||||||
|
rrule = row.rrule,
|
||||||
|
rdate = row.rdate,
|
||||||
|
duration = row.duration,
|
||||||
|
timezone = row.timezone,
|
||||||
|
allDay = row.allDay,
|
||||||
|
)
|
||||||
|
// Rows touched, not occurrences excluded — 1 whenever the series row still
|
||||||
|
// exists. It catches the row disappearing under us, not an EXDATE the
|
||||||
|
// provider's expansion fails to match.
|
||||||
|
val updatedRows = try {
|
||||||
|
resolver.update(
|
||||||
|
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||||
|
values.toContentValues(), null, null,
|
||||||
|
)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
rollBackDetached(detachedId)
|
||||||
|
throw t
|
||||||
|
}
|
||||||
|
if (updatedRows == 0) {
|
||||||
|
rollBackDetached(detachedId)
|
||||||
|
throw WriteFailedException(
|
||||||
|
"exdate occurrence for edit, event id=$eventId begin=$beginMillis",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return detachedId
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Undo the standalone row [detachOccurrence] inserted before its EXDATE
|
||||||
|
* update failed. Best effort: the caller is already throwing, and the worst
|
||||||
|
* case is the duplicate we were avoiding — never a lost occurrence.
|
||||||
|
*/
|
||||||
|
private fun rollBackDetached(detachedId: Long) {
|
||||||
|
runCatching { deleteEvent(detachedId) }.onFailure {
|
||||||
|
Log.w(TAG, "Failed to roll back detached occurrence $detachedId", it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun updateEventFromOccurrence(
|
override fun updateEventFromOccurrence(
|
||||||
eventId: Long,
|
eventId: Long,
|
||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
@@ -1228,10 +1368,71 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
}
|
}
|
||||||
// Insert the new series first: if it fails, the original is untouched.
|
// Insert the new series first: if it fails, the original is untouched.
|
||||||
val newEventId = insertEvent(updated, allDayReminderTimeMinutes)
|
val newEventId = insertEvent(updated, allDayReminderTimeMinutes)
|
||||||
|
carrySplitExdate(newEventId, row, beginMillis, original, updated)
|
||||||
truncateSeries(eventId, row, beginMillis)
|
truncateSeries(eventId, row, beginMillis)
|
||||||
return newEventId
|
return newEventId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carry the [parent]'s exclusions for occurrences past [beginMillis] onto the
|
||||||
|
* series [newEventId] that now owns them, re-timed by the shift the split
|
||||||
|
* applied ([shiftedExdate]/[exdateAfter]). [insertEvent] builds the new row
|
||||||
|
* from the form, which knows nothing about them, so without this every
|
||||||
|
* occurrence the user had deleted from the tail of the series comes back.
|
||||||
|
*
|
||||||
|
* The whole time/recurrence set rides along with EXDATE for the reason
|
||||||
|
* [buildOccurrenceExdateValues] documents: on its own the provider does not
|
||||||
|
* read an EXDATE write as a recurrence change, and leaves the instances it
|
||||||
|
* expanded on insert standing.
|
||||||
|
*
|
||||||
|
* A failure rolls the new series back and throws, so the split fails whole
|
||||||
|
* rather than landing with the exclusions quietly dropped — the parent is
|
||||||
|
* still untruncated at this point, so the event is left exactly as it was.
|
||||||
|
*/
|
||||||
|
private fun carrySplitExdate(
|
||||||
|
newEventId: Long,
|
||||||
|
parent: SeriesRow,
|
||||||
|
beginMillis: Long,
|
||||||
|
original: EventForm,
|
||||||
|
updated: EventForm,
|
||||||
|
) {
|
||||||
|
// Dropping the recurrence in the same save leaves a one-off tail, and an
|
||||||
|
// exclusion means nothing on a row that doesn't recur.
|
||||||
|
if (updated.rrule.isNullOrBlank()) return
|
||||||
|
val carried = shiftedExdate(
|
||||||
|
existingExdate = exdateAfter(
|
||||||
|
parent.exdate,
|
||||||
|
beginMillis,
|
||||||
|
original.isAllDay,
|
||||||
|
parent.timezone,
|
||||||
|
),
|
||||||
|
original = original,
|
||||||
|
updated = updated,
|
||||||
|
zone = ZoneId.systemDefault(),
|
||||||
|
) ?: return
|
||||||
|
val row = querySeriesRow(newEventId)
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put(CalendarContract.Events.EXDATE, carried)
|
||||||
|
put(CalendarContract.Events.DTSTART, row.dtStartMillis)
|
||||||
|
put(CalendarContract.Events.RRULE, row.rrule)
|
||||||
|
put(CalendarContract.Events.DURATION, row.duration)
|
||||||
|
put(CalendarContract.Events.EVENT_TIMEZONE, row.timezone)
|
||||||
|
put(CalendarContract.Events.ALL_DAY, row.allDay)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val rows = resolver.update(
|
||||||
|
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, newEventId),
|
||||||
|
values, null, null,
|
||||||
|
)
|
||||||
|
if (rows == 0) {
|
||||||
|
throw WriteFailedException("carry exdate onto split series id=$newEventId")
|
||||||
|
}
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
runCatching { deleteEvent(newEventId) }
|
||||||
|
throw t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun deleteEventFromOccurrence(eventId: Long, beginMillis: Long) {
|
override fun deleteEventFromOccurrence(eventId: Long, beginMillis: Long) {
|
||||||
val row = querySeriesRow(eventId)
|
val row = querySeriesRow(eventId)
|
||||||
// From the first occurrence on = the whole series; also the fallback
|
// From the first occurrence on = the whole series; also the fallback
|
||||||
@@ -1284,6 +1485,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
CalendarContract.Events.ALL_DAY,
|
CalendarContract.Events.ALL_DAY,
|
||||||
CalendarContract.Events._SYNC_ID,
|
CalendarContract.Events._SYNC_ID,
|
||||||
CalendarContract.Events.EXDATE,
|
CalendarContract.Events.EXDATE,
|
||||||
|
CalendarContract.Events.RDATE,
|
||||||
),
|
),
|
||||||
null, null, null,
|
null, null, null,
|
||||||
)?.use { c ->
|
)?.use { c ->
|
||||||
@@ -1296,6 +1498,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
allDay = c.getInt(4),
|
allDay = c.getInt(4),
|
||||||
syncId = c.getString(5),
|
syncId = c.getString(5),
|
||||||
exdate = c.getString(6),
|
exdate = c.getString(6),
|
||||||
|
rdate = c.getString(7),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
@@ -1311,7 +1514,16 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
/** Null on a local calendar (and before a synced event's first push). */
|
/** Null on a local calendar (and before a synced event's first push). */
|
||||||
val syncId: String? = null,
|
val syncId: String? = null,
|
||||||
val exdate: String? = null,
|
val exdate: String? = null,
|
||||||
|
val rdate: String? = null,
|
||||||
) {
|
) {
|
||||||
|
/**
|
||||||
|
* Whether this row expands into more than itself. RDATE counts as much as
|
||||||
|
* RRULE: a sync adapter may list the occurrences instead of naming a rule,
|
||||||
|
* and the provider expands either — as does the read side, which marks
|
||||||
|
* such a row recurring too.
|
||||||
|
*/
|
||||||
|
val recurs: Boolean get() = !rrule.isNullOrBlank() || !rdate.isNullOrBlank()
|
||||||
|
|
||||||
/** UNTIL cutoff for ending the series before the occurrence at [beginMillis]. */
|
/** UNTIL cutoff for ending the series before the occurrence at [beginMillis]. */
|
||||||
fun truncationCutoff(beginMillis: Long): Long = previousLocalDayEndUtcMillis(
|
fun truncationCutoff(beginMillis: Long): Long = previousLocalDayEndUtcMillis(
|
||||||
beginMillis = beginMillis,
|
beginMillis = beginMillis,
|
||||||
@@ -1502,6 +1714,12 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
|
|
||||||
override fun deleteOccurrence(eventId: Long, beginMillis: Long) {
|
override fun deleteOccurrence(eventId: Long, beginMillis: Long) {
|
||||||
val row = querySeriesRow(eventId)
|
val row = querySeriesRow(eventId)
|
||||||
|
// Not a series, so the row *is* the event: an exclusion would put a
|
||||||
|
// recurrence set on a one-off, an exception would leave an orphan.
|
||||||
|
if (!row.recurs) {
|
||||||
|
deleteEvent(eventId)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (row.syncId == null) {
|
if (row.syncId == null) {
|
||||||
// No _sync_id — a local calendar, or a synced event not pushed yet.
|
// No _sync_id — a local calendar, or a synced event not pushed yet.
|
||||||
// A cancelled exception can only attach to its parent through
|
// A cancelled exception can only attach to its parent through
|
||||||
@@ -1515,6 +1733,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
occurrenceMillis = beginMillis,
|
occurrenceMillis = beginMillis,
|
||||||
dtStartMillis = row.dtStartMillis,
|
dtStartMillis = row.dtStartMillis,
|
||||||
rrule = row.rrule,
|
rrule = row.rrule,
|
||||||
|
rdate = row.rdate,
|
||||||
duration = row.duration,
|
duration = row.duration,
|
||||||
timezone = row.timezone,
|
timezone = row.timezone,
|
||||||
allDay = row.allDay,
|
allDay = row.allDay,
|
||||||
|
|||||||
@@ -88,9 +88,15 @@ interface CalendarRepository {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Change a single occurrence of a recurring event (exception row with the
|
* Change a single occurrence of a recurring event (exception row with the
|
||||||
* form's values); returns the exception's `Events._ID`.
|
* form's values); returns the exception's `Events._ID`. [original] is what
|
||||||
|
* the row holds now, for the case where it turns out not to recur.
|
||||||
*/
|
*/
|
||||||
suspend fun updateOccurrence(eventId: Long, beginMillis: Long, form: EventForm): Long
|
suspend fun updateOccurrence(
|
||||||
|
eventId: Long,
|
||||||
|
beginMillis: Long,
|
||||||
|
original: EventForm,
|
||||||
|
form: EventForm,
|
||||||
|
): Long
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Change a recurring event from [beginMillis] onwards (series split);
|
* Change a recurring event from [beginMillis] onwards (series split);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import de.jeanlucmakiola.floret.time.toEpochMillis
|
import de.jeanlucmakiola.floret.time.toEpochMillis
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
@@ -26,6 +27,7 @@ import kotlinx.coroutines.flow.merge
|
|||||||
import kotlinx.coroutines.flow.onStart
|
import kotlinx.coroutines.flow.onStart
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.util.concurrent.atomic.AtomicLong
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
@@ -210,26 +212,60 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun exportEvents(calendarIds: Set<Long>?) =
|
override suspend fun exportEvents(calendarIds: Set<Long>?) =
|
||||||
withContext(io) { dataSource.exportableEvents(calendarIds) }
|
withContext(io) { dataSource.exportableEvents(calendarIds, allDayReminderTimeMinutes()) }
|
||||||
|
|
||||||
override suspend fun importEvents(
|
override suspend fun importEvents(
|
||||||
targetCalendarId: Long,
|
targetCalendarId: Long,
|
||||||
events: List<ParsedIcsEvent>,
|
events: List<ParsedIcsEvent>,
|
||||||
): IcsImportSummary = withContext(io) {
|
): IcsImportSummary = withContext(io) {
|
||||||
val existing = dataSource.existingUids(targetCalendarId)
|
val existing = dataSource.existingUids(targetCalendarId)
|
||||||
|
// Both are per-calendar, not per-event: looking them up once keeps a
|
||||||
|
// thousand-event restore to two extra queries. The palette is the
|
||||||
|
// uncurated one — an imported colour is matched against every key the
|
||||||
|
// account accepts, not the subset the picker shows.
|
||||||
|
val palette = dataSource.publishedEventColors(targetCalendarId)
|
||||||
|
val allDayMinutes = allDayReminderTimeMinutes()
|
||||||
var imported = 0
|
var imported = 0
|
||||||
var skipped = 0
|
var skipped = 0
|
||||||
for (event in events) {
|
var failed = 0
|
||||||
|
var notAttempted = 0
|
||||||
|
for ((index, event) in events.withIndex()) {
|
||||||
// A known UID means the event is already in this calendar — skip,
|
// A known UID means the event is already in this calendar — skip,
|
||||||
// keeping a restore idempotent (no overwrite this pass).
|
// keeping a restore idempotent (no overwrite this pass).
|
||||||
if (event.uid != null && event.uid in existing) {
|
if (event.uid != null && event.uid in existing) {
|
||||||
skipped++
|
skipped++
|
||||||
} else {
|
continue
|
||||||
dataSource.insertImportedEvent(event, targetCalendarId)
|
}
|
||||||
|
try {
|
||||||
|
dataSource.insertImportedEvent(event, targetCalendarId, allDayMinutes, palette)
|
||||||
imported++
|
imported++
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
// Not this event's fault: the permission or the target calendar is
|
||||||
|
// gone, so every remaining insert would fail the same way.
|
||||||
|
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 }
|
||||||
|
skipped += duplicates
|
||||||
|
notAttempted = remaining.size - duplicates
|
||||||
|
break
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// One event the provider won't take must not cost the user the
|
||||||
|
// rest of the file — foreign exports do carry rows it rejects
|
||||||
|
// outright (a malformed RRULE throws straight out of insert).
|
||||||
|
failed++
|
||||||
|
Log.w(TAG, "Skipped an unimportable event", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
IcsImportSummary(imported = imported, skippedDuplicate = skipped)
|
IcsImportSummary(
|
||||||
|
imported = imported,
|
||||||
|
skippedDuplicate = skipped,
|
||||||
|
failed = failed,
|
||||||
|
notAttempted = notAttempted,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun createEvent(form: EventForm): Long = withContext(io) {
|
override suspend fun createEvent(form: EventForm): Long = withContext(io) {
|
||||||
@@ -262,9 +298,12 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
override suspend fun updateOccurrence(
|
override suspend fun updateOccurrence(
|
||||||
eventId: Long,
|
eventId: Long,
|
||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
|
original: EventForm,
|
||||||
form: EventForm,
|
form: EventForm,
|
||||||
): Long = withContext(io) {
|
): Long = withContext(io) {
|
||||||
dataSource.updateOccurrence(eventId, beginMillis, form, allDayReminderTimeMinutes())
|
dataSource.updateOccurrence(
|
||||||
|
eventId, beginMillis, original, form, allDayReminderTimeMinutes(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun updateEventFromOccurrence(
|
override suspend fun updateEventFromOccurrence(
|
||||||
@@ -288,6 +327,10 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
) = withContext(io) {
|
) = withContext(io) {
|
||||||
dataSource.deleteEventFromOccurrence(eventId, beginMillis)
|
dataSource.deleteEventFromOccurrence(eventId, beginMillis)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "CalendarRepository"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun <T> Flow<Unit>.reQuery(block: suspend () -> T): Flow<T> = flow {
|
private fun <T> Flow<Unit>.reQuery(block: suspend () -> T): Flow<T> = flow {
|
||||||
|
|||||||
@@ -3,14 +3,23 @@ package de.jeanlucmakiola.calendula.data.calendar
|
|||||||
import android.provider.CalendarContract
|
import android.provider.CalendarContract
|
||||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||||
import de.jeanlucmakiola.calendula.domain.Availability
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
|
import de.jeanlucmakiola.calendula.domain.nearestTo
|
||||||
import kotlinx.datetime.toJavaLocalDate
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
import kotlinx.datetime.toJavaLocalDateTime
|
import kotlinx.datetime.toJavaLocalDateTime
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
|
import java.time.ZonedDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.ResolverStyle
|
||||||
|
import java.time.LocalDate as JavaLocalDate
|
||||||
import java.time.LocalDateTime as JavaLocalDateTime
|
import java.time.LocalDateTime as JavaLocalDateTime
|
||||||
|
import java.time.LocalTime as JavaLocalTime
|
||||||
|
|
||||||
/** Provider-ready DTSTART / DTEND / EVENT_TIMEZONE for an event write. */
|
/** Provider-ready DTSTART / DTEND / EVENT_TIMEZONE for an event write. */
|
||||||
internal data class EventWriteTimes(
|
internal data class EventWriteTimes(
|
||||||
@@ -134,18 +143,21 @@ internal fun buildEventInsertValues(
|
|||||||
*
|
*
|
||||||
* Time fields travel together (the provider validates them as a unit):
|
* Time fields travel together (the provider validates them as a unit):
|
||||||
* - unchanged times, all-day flag and rrule → no time columns at all;
|
* - unchanged times, all-day flag and rrule → no time columns at all;
|
||||||
* - non-recurring result → DTSTART/DTEND, DURATION and RRULE cleared;
|
* - non-recurring result → DTSTART/DTEND, DURATION, RRULE and EXDATE cleared;
|
||||||
* - recurring result → the *series* DTSTART moves by the same **wall-clock**
|
* - recurring result → the *series* DTSTART moves by the same **wall-clock**
|
||||||
* shift the user applied to the displayed occurrence and is re-resolved in the
|
* shift the user applied to the displayed occurrence and is re-resolved in the
|
||||||
* event's zone ([seriesDtStartMillis] is the row's current DTSTART), DURATION
|
* event's zone ([seriesDtStartMillis] is the row's current DTSTART), DURATION
|
||||||
* replaces DTEND, RRULE is written. This keeps past occurrences intact when
|
* replaces DTEND, RRULE is written, and the row's exclusions
|
||||||
* someone edits a later occurrence's time, and keeps the anchor's time-of-day
|
* ([seriesExdate], the current EXDATE) travel with the anchor. This keeps past
|
||||||
* stable across a DST boundary or a zone change between the two.
|
* occurrences intact when someone edits a later occurrence's time, and keeps
|
||||||
|
* the anchor's time-of-day stable across a DST boundary or a zone change
|
||||||
|
* between the two.
|
||||||
*/
|
*/
|
||||||
internal fun buildEventUpdateValues(
|
internal fun buildEventUpdateValues(
|
||||||
original: EventForm,
|
original: EventForm,
|
||||||
updated: EventForm,
|
updated: EventForm,
|
||||||
seriesDtStartMillis: Long,
|
seriesDtStartMillis: Long,
|
||||||
|
seriesExdate: String?,
|
||||||
zone: ZoneId,
|
zone: ZoneId,
|
||||||
): Map<String, Any?> = buildMap {
|
): Map<String, Any?> = buildMap {
|
||||||
if (updated.title.trim() != original.title.trim()) {
|
if (updated.title.trim() != original.title.trim()) {
|
||||||
@@ -185,6 +197,10 @@ internal fun buildEventUpdateValues(
|
|||||||
put(CalendarContract.Events.DTEND, newTimes.dtEndMillis)
|
put(CalendarContract.Events.DTEND, newTimes.dtEndMillis)
|
||||||
put(CalendarContract.Events.RRULE, null)
|
put(CalendarContract.Events.RRULE, null)
|
||||||
put(CalendarContract.Events.DURATION, null)
|
put(CalendarContract.Events.DURATION, null)
|
||||||
|
// The exclusions named occurrences of a series that no longer exists.
|
||||||
|
// Left behind they are dormant rather than harmless: adding a recurrence
|
||||||
|
// back later would punch the old holes into the new one.
|
||||||
|
put(CalendarContract.Events.EXDATE, null)
|
||||||
} else {
|
} else {
|
||||||
// Move the series anchor by the *wall-clock* shift the user applied to the
|
// Move the series anchor by the *wall-clock* shift the user applied to the
|
||||||
// displayed occurrence, then re-resolve it in the event's (possibly new)
|
// displayed occurrence, then re-resolve it in the event's (possibly new)
|
||||||
@@ -206,6 +222,14 @@ internal fun buildEventUpdateValues(
|
|||||||
put(CalendarContract.Events.DTEND, null)
|
put(CalendarContract.Events.DTEND, null)
|
||||||
put(CalendarContract.Events.RRULE, updated.rrule)
|
put(CalendarContract.Events.RRULE, updated.rrule)
|
||||||
put(CalendarContract.Events.DURATION, newTimes.toRfc2445Duration(updated.isAllDay))
|
put(CalendarContract.Events.DURATION, newTimes.toRfc2445Duration(updated.isAllDay))
|
||||||
|
// An EXDATE stamp is an absolute instant, so it excludes an occurrence
|
||||||
|
// only while the series keeps generating one at exactly that instant. The
|
||||||
|
// anchor has just moved, so every occurrence regenerates elsewhere and a
|
||||||
|
// stamp left behind matches none of them — the occurrence the user deleted
|
||||||
|
// comes back. Move the stamps the same way the anchor moved.
|
||||||
|
shiftedExdate(seriesExdate, original, updated, zone)
|
||||||
|
?.takeIf { it != seriesExdate }
|
||||||
|
?.let { put(CalendarContract.Events.EXDATE, it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,6 +267,21 @@ internal fun buildOccurrenceExceptionValues(
|
|||||||
putAll(eventColorColumns(form.colorKey, form.color))
|
putAll(eventColorColumns(form.colorKey, form.color))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The form as a **detached occurrence**: the same edited values, with the
|
||||||
|
* series rule dropped so [buildEventInsertValues] writes a standalone one-off
|
||||||
|
* row (DTSTART + DTEND, no RRULE/DURATION) at the occurrence's own times.
|
||||||
|
*
|
||||||
|
* The "edit only this event" shape for a series with **no `_sync_id`**, where an
|
||||||
|
* exception row can't attach to its parent at all (Codeberg #234).
|
||||||
|
*
|
||||||
|
* The exception path gets the rule dropped for free — the provider clears the
|
||||||
|
* RRULE it cloned when an exception carries DTSTART + DURATION
|
||||||
|
* ([buildOccurrenceExceptionValues]). Here nothing is cloned, so it is stripped
|
||||||
|
* by hand; leaving it on would insert a second *series* overlapping the first.
|
||||||
|
*/
|
||||||
|
internal fun EventForm.toDetachedOccurrence(): EventForm = copy(rrule = null)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Raw provider snapshot of a master/one-off Events row, enough to re-insert it
|
* Raw provider snapshot of a master/one-off Events row, enough to re-insert it
|
||||||
* verbatim on another calendar (a calendar move is copy+delete — `CALENDAR_ID`
|
* verbatim on another calendar (a calendar move is copy+delete — `CALENDAR_ID`
|
||||||
@@ -406,52 +445,336 @@ internal fun buildOccurrenceCancelValues(
|
|||||||
* first occurrence. Passing DTSTART + DURATION + RRULE + zone together is what
|
* first occurrence. Passing DTSTART + DURATION + RRULE + zone together is what
|
||||||
* re-expands it correctly. All observed on a Pixel; see the #47 notes.
|
* re-expands it correctly. All observed on a Pixel; see the #47 notes.
|
||||||
*
|
*
|
||||||
|
* [rdate] travels with [rrule]: a sync adapter may list a series' occurrences
|
||||||
|
* instead of naming a rule, and rewriting the recurrence set without it would
|
||||||
|
* leave the provider nothing but DTSTART to re-expand from — the collapse this
|
||||||
|
* values map exists to avoid.
|
||||||
|
*
|
||||||
* EXDATE is a comma-separated list, so an existing one is appended to (a repeat
|
* EXDATE is a comma-separated list, so an existing one is appended to (a repeat
|
||||||
* of the same occurrence is folded away). All-day series take the `VALUE=DATE`
|
* of the same occurrence is folded away — [mergedExdate]).
|
||||||
* form (`yyyyMMdd`), timed ones the UTC date-time form (`yyyyMMddTHHmmssZ`).
|
|
||||||
*/
|
*/
|
||||||
internal fun buildOccurrenceExdateValues(
|
internal fun buildOccurrenceExdateValues(
|
||||||
existingExdate: String?,
|
existingExdate: String?,
|
||||||
occurrenceMillis: Long,
|
occurrenceMillis: Long,
|
||||||
dtStartMillis: Long,
|
dtStartMillis: Long,
|
||||||
rrule: String?,
|
rrule: String?,
|
||||||
|
rdate: String?,
|
||||||
duration: String?,
|
duration: String?,
|
||||||
timezone: String?,
|
timezone: String?,
|
||||||
allDay: Int,
|
allDay: Int,
|
||||||
): Map<String, Any?> {
|
): Map<String, Any?> = mapOf(
|
||||||
val stamp = formatExdateStamp(occurrenceMillis, isAllDay = allDay != 0)
|
CalendarContract.Events.EXDATE to
|
||||||
val existing = existingExdate?.split(',')
|
mergedExdate(existingExdate, occurrenceMillis, allDay != 0, timezone),
|
||||||
?.map { it.trim() }
|
CalendarContract.Events.DTSTART to dtStartMillis,
|
||||||
?.filter { it.isNotEmpty() }
|
CalendarContract.Events.RRULE to rrule,
|
||||||
.orEmpty()
|
CalendarContract.Events.RDATE to rdate,
|
||||||
val merged = (existing + stamp).distinct().joinToString(",")
|
CalendarContract.Events.DURATION to duration,
|
||||||
return mapOf(
|
CalendarContract.Events.EVENT_TIMEZONE to timezone,
|
||||||
CalendarContract.Events.EXDATE to merged,
|
CalendarContract.Events.ALL_DAY to allDay,
|
||||||
CalendarContract.Events.DTSTART to dtStartMillis,
|
)
|
||||||
CalendarContract.Events.RRULE to rrule,
|
|
||||||
CalendarContract.Events.DURATION to duration,
|
/**
|
||||||
CalendarContract.Events.EVENT_TIMEZONE to timezone,
|
* [existingExdate] with the occurrence at [occurrenceMillis] excluded as well.
|
||||||
CalendarContract.Events.ALL_DAY to allDay,
|
*
|
||||||
)
|
* Existing stamps are canonicalised first — all-day series take the `VALUE=DATE`
|
||||||
|
* form (`yyyyMMdd`), timed ones the UTC date-time form (`yyyyMMddTHHmmssZ`) — so
|
||||||
|
* appending to a list a sync adapter wrote in the other accepted form folds the
|
||||||
|
* repeat away instead of leaving the same day excluded twice in two spellings.
|
||||||
|
*
|
||||||
|
* A stamp in no form Calendula reads makes the whole list untouchable: it is kept
|
||||||
|
* exactly as it is, behind the parameter prefix that tells a reader how to
|
||||||
|
* resolve it, and the new exclusion joins it in the list's own form.
|
||||||
|
* Canonicalising the readable stamps around it would leave absolute stamps under
|
||||||
|
* a prefix claiming a zone for all of them — which is how the unreadable one
|
||||||
|
* quietly changes instant.
|
||||||
|
*/
|
||||||
|
private fun mergedExdate(
|
||||||
|
existingExdate: String?,
|
||||||
|
occurrenceMillis: Long,
|
||||||
|
isAllDay: Boolean,
|
||||||
|
timezone: String?,
|
||||||
|
): String {
|
||||||
|
val listZone = exdateListZone(existingExdate, timezone)
|
||||||
|
val stamps = exdateStamps(existingExdate)
|
||||||
|
val canonical = stamps.map { canonicalExdateStamp(it, isAllDay, listZone) }
|
||||||
|
if (canonical.all { it != null }) {
|
||||||
|
val stamp = formatExdateStamp(occurrenceMillis, isAllDay)
|
||||||
|
return (canonical.filterNotNull() + stamp).distinct().joinToString(",")
|
||||||
|
}
|
||||||
|
val prefix = existingExdate?.exdateParameterPrefix().orEmpty()
|
||||||
|
val stamp = if (isAllDay || prefix.isEmpty()) {
|
||||||
|
formatExdateStamp(occurrenceMillis, isAllDay)
|
||||||
|
} else {
|
||||||
|
Instant.ofEpochMilli(occurrenceMillis).atZone(listZone).format(FLOATING_EXDATE)
|
||||||
|
}
|
||||||
|
return prefix + (stamps + stamp).distinct().joinToString(",")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether [existingExdate] already excludes the occurrence at [occurrenceMillis]
|
||||||
|
* — i.e. it has already been dropped from the series, deleted or detached.
|
||||||
|
*
|
||||||
|
* Guards the detach path against running twice from a stale screen: the EXDATE
|
||||||
|
* merge folds the repeat away silently and the update still reports one row
|
||||||
|
* changed, so a second save would leave a second standalone copy.
|
||||||
|
*/
|
||||||
|
internal fun exdateContains(
|
||||||
|
existingExdate: String?,
|
||||||
|
occurrenceMillis: Long,
|
||||||
|
isAllDay: Boolean,
|
||||||
|
timezone: String?,
|
||||||
|
): Boolean {
|
||||||
|
val stamp = formatExdateStamp(occurrenceMillis, isAllDay)
|
||||||
|
val listZone = exdateListZone(existingExdate, timezone)
|
||||||
|
// Canonicalised rather than compared verbatim: a sync adapter may spell the
|
||||||
|
// same exclusion differently to Calendula, and a guard that misses it lets
|
||||||
|
// the detach run a second time and leave a second standalone copy.
|
||||||
|
return exdateStamps(existingExdate)
|
||||||
|
.any { canonicalExdateStamp(it, isAllDay, listZone) == stamp }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [existingExdate] with every stamp re-timed by the same **wall-clock** shift the
|
||||||
|
* series anchor takes from [original] to [updated] — the exclusions' half of the
|
||||||
|
* anchor move [buildEventUpdateValues] performs, and what carries them onto the
|
||||||
|
* new series when "this and following" splits one.
|
||||||
|
*
|
||||||
|
* A stamp is an absolute instant, so it keeps naming its occurrence only if it
|
||||||
|
* moves exactly as the occurrence does: shifted in wall clock and re-resolved in
|
||||||
|
* the event's (possibly new) zone. A millisecond delta would instead bake in the
|
||||||
|
* offset that happened to apply at the edited occurrence — an hour off for any
|
||||||
|
* exclusion on the far side of a DST boundary. All-day-ness is read from
|
||||||
|
* [original] and written from [updated], so the `VALUE=DATE` and date-time forms
|
||||||
|
* convert into each other when the event switches.
|
||||||
|
*
|
||||||
|
* A zoned list is read in the zone its prefix names ([exdateListZone]). Null
|
||||||
|
* when there is nothing to carry: no stamps, or a stamp in a form Calendula
|
||||||
|
* cannot read at all. Those are left exactly as they are rather than guessed at —
|
||||||
|
* a stale stamp excludes nothing, but a mangled one could exclude the wrong
|
||||||
|
* occurrence.
|
||||||
|
*/
|
||||||
|
internal fun shiftedExdate(
|
||||||
|
existingExdate: String?,
|
||||||
|
original: EventForm,
|
||||||
|
updated: EventForm,
|
||||||
|
zone: ZoneId,
|
||||||
|
): String? {
|
||||||
|
val stamps = exdateStamps(existingExdate)
|
||||||
|
if (stamps.isEmpty()) return null
|
||||||
|
val fromZone = original.writeZone(zone)
|
||||||
|
val toZone = updated.writeZone(zone)
|
||||||
|
val listZone = exdateListZone(existingExdate, fromZone.id)
|
||||||
|
val wallClockShift = Duration.between(original.anchorLocal(), updated.anchorLocal())
|
||||||
|
return stamps
|
||||||
|
.map { stamp ->
|
||||||
|
val local = parseExdateStamp(stamp, original.isAllDay, fromZone, listZone)
|
||||||
|
?: return null
|
||||||
|
formatExdateStamp(local.plus(wallClockShift), updated.isAllDay, toZone)
|
||||||
|
}
|
||||||
|
.distinct()
|
||||||
|
.joinToString(",")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stamps of [existingExdate] naming occurrences after [beginMillis] — the ones
|
||||||
|
* that belong to the *new* series once "this and following" splits a recurring
|
||||||
|
* event there. The parent keeps the full list; its stamps past the split point are
|
||||||
|
* simply inert once it stops generating those occurrences.
|
||||||
|
*
|
||||||
|
* The occurrence at [beginMillis] itself is never carried. It is the one the user
|
||||||
|
* is editing, so it exists by definition, and honouring a stale exclusion for it
|
||||||
|
* would swallow the edit whole.
|
||||||
|
*
|
||||||
|
* The stamps that qualify come back canonicalised, since the zone prefix that
|
||||||
|
* told the reader how to resolve them does not survive the filter.
|
||||||
|
*
|
||||||
|
* Null when nothing qualifies, or when a stamp can't be read (see [shiftedExdate]).
|
||||||
|
*/
|
||||||
|
internal fun exdateAfter(
|
||||||
|
existingExdate: String?,
|
||||||
|
beginMillis: Long,
|
||||||
|
isAllDay: Boolean,
|
||||||
|
timezone: String?,
|
||||||
|
): String? {
|
||||||
|
val stamps = exdateStamps(existingExdate)
|
||||||
|
if (stamps.isEmpty()) return null
|
||||||
|
val listZone = exdateListZone(existingExdate, timezone)
|
||||||
|
return stamps
|
||||||
|
.mapNotNull { stamp ->
|
||||||
|
val utc = parseExdateStamp(stamp, isAllDay, ZoneOffset.UTC, listZone) ?: return null
|
||||||
|
utc.takeIf { it.toInstant(ZoneOffset.UTC).toEpochMilli() > beginMillis }
|
||||||
|
}
|
||||||
|
.map { formatExdateStamp(it, isAllDay, ZoneOffset.UTC) }
|
||||||
|
.takeIf { it.isNotEmpty() }
|
||||||
|
?.joinToString(",")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The individual stamps of an EXDATE column value; it is a comma-separated list,
|
||||||
|
* optionally behind the parameter prefix AOSP's `RecurrenceSet` puts in front of
|
||||||
|
* a zoned one ([exdateListZone] reads that half).
|
||||||
|
*/
|
||||||
|
private fun exdateStamps(exdate: String?): List<String> = exdate
|
||||||
|
?.withoutExdateParameters()
|
||||||
|
?.split(',')
|
||||||
|
?.map { it.trim() }
|
||||||
|
?.filter { it.isNotEmpty() }
|
||||||
|
.orEmpty()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An EXDATE column value with the parameter section its stamps sit behind taken
|
||||||
|
* off.
|
||||||
|
*/
|
||||||
|
internal fun String.withoutExdateParameters(): String = substring(exdateParameterPrefix().length)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The parameter section an EXDATE list's stamps sit behind, its `;` included, or
|
||||||
|
* empty when it has none. Only a `;` before the first stamp separator is one —
|
||||||
|
* anything later belongs to a stamp, and cutting there would drop every stamp in
|
||||||
|
* front of it.
|
||||||
|
*/
|
||||||
|
private fun String.exdateParameterPrefix(): String {
|
||||||
|
val semicolon = indexOf(';')
|
||||||
|
val comma = indexOf(',')
|
||||||
|
return if (semicolon >= 0 && (comma < 0 || semicolon < comma)) {
|
||||||
|
substring(0, semicolon + 1)
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The zone the floating stamps of [exdate] are wall clock in: the one its
|
||||||
|
* parameter prefix names, or [fallback] — the event's own `EVENT_TIMEZONE` —
|
||||||
|
* when there is no prefix, or the device's tz database doesn't know the name it
|
||||||
|
* uses (Exchange spells zones its own way). The series' own zone is the right
|
||||||
|
* guess, since a zoned exclusion list is written in it. UTC last, for a row that
|
||||||
|
* carries no zone either. Stamps already written as UTC instants ignore all this.
|
||||||
|
*
|
||||||
|
* Both spellings of the prefix are read. AOSP's `RecurrenceSet` stores the TZID
|
||||||
|
* parameter's *value* alone (`Europe/Berlin;<stamps>`), and so does DAVx5; the
|
||||||
|
* iCalendar `TZID=<zone>;` some writers keep is the other one. Only matching the
|
||||||
|
* second would miss every list the provider itself wrote.
|
||||||
|
*/
|
||||||
|
internal fun exdateListZone(exdate: String?, fallback: String?): ZoneId =
|
||||||
|
exdate?.exdateZoneName()?.toZoneIdOrNull()
|
||||||
|
?: fallback?.toZoneIdOrNull()
|
||||||
|
?: ZoneOffset.UTC
|
||||||
|
|
||||||
|
/** The zone id an EXDATE list's parameter prefix names, with or without `TZID=`. */
|
||||||
|
private fun String.exdateZoneName(): String {
|
||||||
|
val prefix = exdateParameterPrefix().dropLast(1)
|
||||||
|
return if (prefix.startsWith(TZID_PREFIX, ignoreCase = true)) {
|
||||||
|
prefix.drop(TZID_PREFIX.length)
|
||||||
|
} else {
|
||||||
|
prefix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.toZoneIdOrNull(): ZoneId? = runCatching { ZoneId.of(this) }.getOrNull()
|
||||||
|
|
||||||
|
/** The optional `TZID=` a zoned EXDATE list's zone id may sit behind. */
|
||||||
|
private const val TZID_PREFIX = "TZID="
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One EXDATE entry for the occurrence starting at [occurrenceMillis]. Both forms
|
* One EXDATE entry for the occurrence starting at [occurrenceMillis]. Both forms
|
||||||
* are UTC: the provider stores an all-day DTSTART at UTC midnight, so its date
|
* are UTC: the provider stores an all-day DTSTART at UTC midnight, so its date
|
||||||
* reads off the UTC calendar day.
|
* reads off the UTC calendar day.
|
||||||
*/
|
*/
|
||||||
private fun formatExdateStamp(occurrenceMillis: Long, isAllDay: Boolean): String {
|
private fun formatExdateStamp(occurrenceMillis: Long, isAllDay: Boolean): String =
|
||||||
val utc = Instant.ofEpochMilli(occurrenceMillis).atZone(ZoneOffset.UTC)
|
formatExdateStamp(
|
||||||
return if (isAllDay) {
|
local = Instant.ofEpochMilli(occurrenceMillis).atZone(ZoneOffset.UTC).toLocalDateTime(),
|
||||||
"%04d%02d%02d".format(utc.year, utc.monthValue, utc.dayOfMonth)
|
isAllDay = isAllDay,
|
||||||
|
zone = ZoneOffset.UTC,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One EXDATE entry for the occurrence whose wall clock in [zone] is [local]. An
|
||||||
|
* all-day entry keeps only the date (its time-of-day is the anchor's, not the
|
||||||
|
* occurrence's); a timed one is resolved in [zone] and written as a UTC instant.
|
||||||
|
*/
|
||||||
|
private fun formatExdateStamp(local: JavaLocalDateTime, isAllDay: Boolean, zone: ZoneId): String =
|
||||||
|
if (isAllDay) {
|
||||||
|
local.toLocalDate().format(ALL_DAY_EXDATE)
|
||||||
} else {
|
} else {
|
||||||
"%04d%02d%02dT%02d%02d%02dZ".format(
|
local.atZone(zone).withZoneSameInstant(ZoneOffset.UTC).format(TIMED_EXDATE)
|
||||||
utc.year, utc.monthValue, utc.dayOfMonth,
|
|
||||||
utc.hour, utc.minute, utc.second,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [stamp] as the wall clock it names in [zone] — the inverse of
|
||||||
|
* [formatExdateStamp]. Null for anything Calendula cannot read, so a caller can
|
||||||
|
* tell "not ours, leave it alone" from a value it may safely re-time.
|
||||||
|
*
|
||||||
|
* A timed exclusion is read both as the UTC instant Calendula writes and as a
|
||||||
|
* floating stamp, which [listZone] then resolves. An all-day one is read both
|
||||||
|
* bare (`yyyyMMdd`, what Calendula writes) and padded to a midnight stamp, in
|
||||||
|
* either spelling: sync adapters are inconsistent about which they store — the
|
||||||
|
* same forms [exportExDates] reshapes on the way out — and refusing one would
|
||||||
|
* drop every exclusion of a foreign all-day series the moment it is re-timed.
|
||||||
|
*/
|
||||||
|
private fun parseExdateStamp(
|
||||||
|
stamp: String,
|
||||||
|
isAllDay: Boolean,
|
||||||
|
zone: ZoneId,
|
||||||
|
listZone: ZoneId,
|
||||||
|
): JavaLocalDateTime? =
|
||||||
|
if (isAllDay) {
|
||||||
|
parseAllDayExdate(stamp)?.atStartOfDay()
|
||||||
|
} else {
|
||||||
|
parseTimedExdate(stamp, listZone)?.withZoneSameInstant(zone)?.toLocalDateTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A timed EXDATE stamp as the instant it names: the trailing `Z` form resolved in
|
||||||
|
* UTC, or the floating one resolved in [listZone].
|
||||||
|
*/
|
||||||
|
private fun parseTimedExdate(stamp: String, listZone: ZoneId): ZonedDateTime? =
|
||||||
|
runCatching { JavaLocalDateTime.parse(stamp, TIMED_EXDATE).atZone(ZoneOffset.UTC) }.getOrNull()
|
||||||
|
?: runCatching {
|
||||||
|
JavaLocalDateTime.parse(stamp, FLOATING_EXDATE).atZone(listZone)
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The calendar day an all-day EXDATE stamp names, bare or padded to a midnight
|
||||||
|
* stamp. A padded stamp naming any other time of day is refused rather than
|
||||||
|
* rounded: it is not a form an all-day series is written in, so it could be
|
||||||
|
* naming something else entirely. Midnight is midnight in any zone, so the
|
||||||
|
* padding's own spelling — trailing `Z` or floating — makes no difference.
|
||||||
|
*/
|
||||||
|
private fun parseAllDayExdate(stamp: String): JavaLocalDate? {
|
||||||
|
runCatching { JavaLocalDate.parse(stamp, ALL_DAY_EXDATE) }.getOrNull()?.let { return it }
|
||||||
|
val padded = parsePaddedExdate(stamp) ?: return null
|
||||||
|
return padded.toLocalDate().takeIf { padded.toLocalTime() == JavaLocalTime.MIDNIGHT }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** An `uuuuMMddTHHmmss` stamp, with or without the trailing `Z`, as its own wall clock. */
|
||||||
|
private fun parsePaddedExdate(stamp: String): JavaLocalDateTime? =
|
||||||
|
runCatching { JavaLocalDateTime.parse(stamp, TIMED_EXDATE) }.getOrNull()
|
||||||
|
?: runCatching { JavaLocalDateTime.parse(stamp, FLOATING_EXDATE) }.getOrNull()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [stamp] in the form Calendula writes, so two spellings of the same exclusion
|
||||||
|
* compare equal. Null for a stamp in no form it reads.
|
||||||
|
*/
|
||||||
|
internal fun canonicalExdateStamp(
|
||||||
|
stamp: String,
|
||||||
|
isAllDay: Boolean,
|
||||||
|
listZone: ZoneId,
|
||||||
|
): String? = parseExdateStamp(stamp, isAllDay, ZoneOffset.UTC, listZone)
|
||||||
|
?.let { formatExdateStamp(it, isAllDay, ZoneOffset.UTC) }
|
||||||
|
|
||||||
|
/** `VALUE=DATE` EXDATE form, for an all-day series. */
|
||||||
|
private val ALL_DAY_EXDATE: DateTimeFormatter =
|
||||||
|
DateTimeFormatter.ofPattern("uuuuMMdd").withResolverStyle(ResolverStyle.STRICT)
|
||||||
|
|
||||||
|
/** UTC date-time EXDATE form, for a timed series. */
|
||||||
|
private val TIMED_EXDATE: DateTimeFormatter =
|
||||||
|
DateTimeFormatter.ofPattern("uuuuMMdd'T'HHmmss'Z'").withResolverStyle(ResolverStyle.STRICT)
|
||||||
|
|
||||||
|
/** The same form without the `Z`, wall clock in the list's zone rather than UTC. */
|
||||||
|
private val FLOATING_EXDATE: DateTimeFormatter =
|
||||||
|
DateTimeFormatter.ofPattern("uuuuMMdd'T'HHmmss").withResolverStyle(ResolverStyle.STRICT)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `EVENT_COLOR` / `EVENT_COLOR_KEY` columns for a colour selection. A
|
* The `EVENT_COLOR` / `EVENT_COLOR_KEY` columns for a colour selection. A
|
||||||
* [colorKey] writes the key alone (the provider derives `EVENT_COLOR` from the
|
* [colorKey] writes the key alone (the provider derives `EVENT_COLOR` from the
|
||||||
@@ -499,3 +822,73 @@ internal fun AccessLevel.toProviderValue(): Int = when (this) {
|
|||||||
AccessLevel.Private -> CalendarContract.Events.ACCESS_PRIVATE
|
AccessLevel.Private -> CalendarContract.Events.ACCESS_PRIVATE
|
||||||
AccessLevel.Public -> CalendarContract.Events.ACCESS_PUBLIC
|
AccessLevel.Public -> CalendarContract.Events.ACCESS_PUBLIC
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `Events` row for a `.ics` event being imported into [calendarId].
|
||||||
|
*
|
||||||
|
* A recurring row carries RRULE + DURATION (and any EXDATE) with no DTEND; a
|
||||||
|
* one-off carries DTEND. All-day rows live at UTC midnights, exactly as the
|
||||||
|
* parser hands them over.
|
||||||
|
*
|
||||||
|
* [palette] is the target account's published event colours. A calendar that
|
||||||
|
* publishes one rejects a raw `EVENT_COLOR`, so an imported colour — which is
|
||||||
|
* an arbitrary ARGB from a foreign app — is snapped to the nearest key it does
|
||||||
|
* accept; accounts with no palette take the raw value (see [eventColorColumns]).
|
||||||
|
*/
|
||||||
|
internal fun buildImportedEventValues(
|
||||||
|
event: ParsedIcsEvent,
|
||||||
|
calendarId: Long,
|
||||||
|
uid: String,
|
||||||
|
palette: List<EventColorOption>,
|
||||||
|
): Map<String, Any?> = buildMap {
|
||||||
|
val startMillis = event.start.toEpochMilliseconds()
|
||||||
|
val endMillis = event.end.toEpochMilliseconds()
|
||||||
|
put(CalendarContract.Events.CALENDAR_ID, calendarId)
|
||||||
|
put(CalendarContract.Events.UID_2445, uid)
|
||||||
|
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) {
|
||||||
|
put(CalendarContract.Events.DTEND, endMillis)
|
||||||
|
} else {
|
||||||
|
put(CalendarContract.Events.RRULE, event.recurrenceRule)
|
||||||
|
put(
|
||||||
|
CalendarContract.Events.DURATION,
|
||||||
|
importDuration(startMillis, endMillis, event.isAllDay),
|
||||||
|
)
|
||||||
|
event.exDates.takeIf { it.isNotEmpty() }
|
||||||
|
?.let { put(CalendarContract.Events.EXDATE, it.joinToString(",")) }
|
||||||
|
}
|
||||||
|
// All-day rows live at UTC midnights (the file already encodes them so);
|
||||||
|
// 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())
|
||||||
|
put(CalendarContract.Events.STATUS, event.status.toProviderStatus())
|
||||||
|
event.location?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
|
||||||
|
event.description?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
|
||||||
|
event.color?.let { color ->
|
||||||
|
val key = palette.nearestTo(color)?.key
|
||||||
|
putAll(eventColorColumns(colorKey = key, color = if (key == null) color else null))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider `DURATION` for an imported recurring row: whole days for an all-day
|
||||||
|
* series, seconds otherwise. An all-day series is never shorter than a day —
|
||||||
|
* the provider expands a zero-length one into no instances at all, so the
|
||||||
|
* series would vanish (which is what a literal read of the Fossify family's
|
||||||
|
* inclusive DTEND used to produce; see "Importing foreign .ics" in
|
||||||
|
* docs/ARCHITECTURE.md).
|
||||||
|
*/
|
||||||
|
private fun importDuration(startMillis: Long, endMillis: Long, isAllDay: Boolean): String {
|
||||||
|
val span = (endMillis - startMillis).coerceAtLeast(0)
|
||||||
|
return if (isAllDay) "P${(span / 86_400_000L).coerceAtLeast(1)}D" else "P${span / 1_000L}S"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun EventStatus.toProviderStatus(): Int = when (this) {
|
||||||
|
EventStatus.Confirmed -> CalendarContract.Events.STATUS_CONFIRMED
|
||||||
|
EventStatus.Tentative -> CalendarContract.Events.STATUS_TENTATIVE
|
||||||
|
EventStatus.Cancelled -> CalendarContract.Events.STATUS_CANCELED
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import de.jeanlucmakiola.calendula.domain.EventStatus
|
|||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.deriveIcsUid
|
import de.jeanlucmakiola.calendula.domain.ics.deriveIcsUid
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map one Events row (read through [EventExportProjection]) into an [IcsEvent]
|
* Map one Events row (read through [EventExportProjection]) into an [IcsEvent]
|
||||||
@@ -12,14 +15,23 @@ import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
|||||||
* [calendarName] the display name of its calendar (emitted as
|
* [calendarName] the display name of its calendar (emitted as
|
||||||
* `X-CALENDULA-CALENDAR`). Pure given a [ColumnReader] — JVM-tested with
|
* `X-CALENDULA-CALENDAR`). Pure given a [ColumnReader] — JVM-tested with
|
||||||
* MapColumnReader.
|
* MapColumnReader.
|
||||||
|
*
|
||||||
|
* An all-day row's raw offset has the firing time of day encoded into it and is
|
||||||
|
* normally *negative* (see [AllDayReminderEncoding]); written out literally it
|
||||||
|
* would be dropped as a trigger that fires after the event, losing the reminder.
|
||||||
|
* [allDayReminderTimeMinutes] and [zone] decode it back to the whole-day lead
|
||||||
|
* time the file should carry, which an import re-encodes for its own device.
|
||||||
*/
|
*/
|
||||||
internal fun ColumnReader.toIcsEvent(
|
internal fun ColumnReader.toIcsEvent(
|
||||||
reminderMinutes: List<Int>,
|
reminderMinutes: List<Int>,
|
||||||
calendarName: String?,
|
calendarName: String?,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
zone: ZoneId = ZoneId.systemDefault(),
|
||||||
): IcsEvent {
|
): IcsEvent {
|
||||||
val eventId = getLong(EventExportProjection.IDX_ID)
|
val eventId = getLong(EventExportProjection.IDX_ID)
|
||||||
val dtStart = getLong(EventExportProjection.IDX_DTSTART)
|
val dtStart = getLong(EventExportProjection.IDX_DTSTART)
|
||||||
val rrule = getString(EventExportProjection.IDX_RRULE)?.takeIf { it.isNotBlank() }
|
val rrule = getString(EventExportProjection.IDX_RRULE)?.takeIf { it.isNotBlank() }
|
||||||
|
val isAllDay = getInt(EventExportProjection.IDX_ALL_DAY) != 0
|
||||||
|
|
||||||
// Recurring rows store DURATION instead of DTEND; reconstruct the end from it
|
// Recurring rows store DURATION instead of DTEND; reconstruct the end from it
|
||||||
// so the writer can render DTEND. A missing/blank both means a zero-length event.
|
// so the writer can render DTEND. A missing/blank both means a zero-length event.
|
||||||
@@ -40,16 +52,77 @@ internal fun ColumnReader.toIcsEvent(
|
|||||||
summary = getString(EventExportProjection.IDX_TITLE).orEmpty(),
|
summary = getString(EventExportProjection.IDX_TITLE).orEmpty(),
|
||||||
start = dtStart.toKotlinInstantFromEpochMillis(),
|
start = dtStart.toKotlinInstantFromEpochMillis(),
|
||||||
end = end.toKotlinInstantFromEpochMillis(),
|
end = end.toKotlinInstantFromEpochMillis(),
|
||||||
isAllDay = getInt(EventExportProjection.IDX_ALL_DAY) != 0,
|
isAllDay = isAllDay,
|
||||||
zoneId = getString(EventExportProjection.IDX_EVENT_TIMEZONE)?.takeIf { it.isNotBlank() }
|
zoneId = getString(EventExportProjection.IDX_EVENT_TIMEZONE)?.takeIf { it.isNotBlank() }
|
||||||
?: "UTC",
|
?: "UTC",
|
||||||
recurrenceRule = rrule,
|
recurrenceRule = rrule,
|
||||||
|
exDates = if (rrule == null) {
|
||||||
|
emptyList()
|
||||||
|
} else {
|
||||||
|
exportExDates(
|
||||||
|
getString(EventExportProjection.IDX_EXDATE),
|
||||||
|
isAllDay,
|
||||||
|
getString(EventExportProjection.IDX_EVENT_TIMEZONE),
|
||||||
|
)
|
||||||
|
},
|
||||||
location = getString(EventExportProjection.IDX_LOCATION),
|
location = getString(EventExportProjection.IDX_LOCATION),
|
||||||
description = getString(EventExportProjection.IDX_DESCRIPTION),
|
description = getString(EventExportProjection.IDX_DESCRIPTION),
|
||||||
reminderMinutes = reminderMinutes,
|
reminderMinutes = if (isAllDay) {
|
||||||
|
val startDate = Instant.ofEpochMilli(dtStart).atZone(ZoneOffset.UTC).toLocalDate()
|
||||||
|
reminderMinutes.map {
|
||||||
|
fromProviderAllDayMinutes(it, startDate, zone, allDayReminderTimeMinutes)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
reminderMinutes
|
||||||
|
},
|
||||||
status = status,
|
status = status,
|
||||||
availability = mapAvailability(getInt(EventExportProjection.IDX_AVAILABILITY)),
|
availability = mapAvailability(getInt(EventExportProjection.IDX_AVAILABILITY)),
|
||||||
calendarName = calendarName,
|
calendarName = calendarName,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The row's `EXDATE` as the writer wants it: one stamp per entry, empties
|
||||||
|
* dropped.
|
||||||
|
*
|
||||||
|
* An all-day exclusion names a calendar day, and sync adapters are inconsistent
|
||||||
|
* about whether they write it as a bare `yyyyMMdd` or pad it to a midnight
|
||||||
|
* stamp; the time part is dropped so the exported value is the day either way.
|
||||||
|
* Calendula's own writes ([buildOccurrenceExdateValues]) are already bare.
|
||||||
|
*
|
||||||
|
* A zoned exclusion arrives in AOSP's `RecurrenceSet` form, `<zone>;<stamps>`,
|
||||||
|
* where the prefix names the zone the whole list is wall clock in. [IcsWriter]
|
||||||
|
* emits one bare `EXDATE:` line, and a parameter belongs before that colon, not
|
||||||
|
* inside the value — passed on as-is it would make the line unparseable. The
|
||||||
|
* prefix is therefore taken off and its stamps resolved to UTC instants
|
||||||
|
* ([canonicalExdateStamp], the same reading the write path does), the form every
|
||||||
|
* reader accepts.
|
||||||
|
*
|
||||||
|
* Which zone that resolution uses is [exdateListZone]'s call, the same one the
|
||||||
|
* write path makes: the `TZID=` when this device's tz database knows the name,
|
||||||
|
* else the row's own [eventTimezone]. A stamp is never emitted floating just
|
||||||
|
* because the prefix named a zone we couldn't place — against a `DTSTART;TZID=…`
|
||||||
|
* line every reader would resolve it in its own zone and the exclusion would stop
|
||||||
|
* matching its occurrence.
|
||||||
|
*/
|
||||||
|
internal fun exportExDates(
|
||||||
|
exdate: String?,
|
||||||
|
isAllDay: Boolean,
|
||||||
|
eventTimezone: String? = null,
|
||||||
|
): List<String> {
|
||||||
|
if (exdate.isNullOrBlank()) return emptyList()
|
||||||
|
val zone = exdateListZone(exdate, eventTimezone)
|
||||||
|
return exdate
|
||||||
|
.withoutExdateParameters()
|
||||||
|
.split(',')
|
||||||
|
.map { it.trim() }
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
.map { stamp ->
|
||||||
|
if (isAllDay) {
|
||||||
|
stamp.substringBefore('T')
|
||||||
|
} else {
|
||||||
|
canonicalExdateStamp(stamp, isAllDay = false, listZone = zone) ?: stamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.distinct()
|
||||||
|
}
|
||||||
|
|||||||
@@ -121,10 +121,12 @@ internal object EventDetailProjection {
|
|||||||
/**
|
/**
|
||||||
* Master/one-off Events rows for a whole-calendar backup. Unlike
|
* Master/one-off Events rows for a whole-calendar backup. Unlike
|
||||||
* [EventDetailProjection] this reads `UID_2445` (to keep a row's identity across
|
* [EventDetailProjection] this reads `UID_2445` (to keep a row's identity across
|
||||||
* backups) and `DURATION` (recurring rows carry it instead of DTEND). Modified-
|
* 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
|
* occurrence and cancelled-exception rows are filtered out by the query
|
||||||
* (`ORIGINAL_ID IS NULL`), so RECURRENCE-ID overrides and EXDATEs aren't
|
* (`ORIGINAL_ID IS NULL`), so RECURRENCE-ID overrides aren't exported — a
|
||||||
* exported yet — a documented v1 limit (import skips them too).
|
* 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.
|
||||||
*/
|
*/
|
||||||
internal object EventExportProjection {
|
internal object EventExportProjection {
|
||||||
val COLUMNS: Array<String> = arrayOf(
|
val COLUMNS: Array<String> = arrayOf(
|
||||||
@@ -137,6 +139,7 @@ internal object EventExportProjection {
|
|||||||
CalendarContract.Events.ALL_DAY,
|
CalendarContract.Events.ALL_DAY,
|
||||||
CalendarContract.Events.EVENT_TIMEZONE,
|
CalendarContract.Events.EVENT_TIMEZONE,
|
||||||
CalendarContract.Events.RRULE,
|
CalendarContract.Events.RRULE,
|
||||||
|
CalendarContract.Events.EXDATE,
|
||||||
CalendarContract.Events.EVENT_LOCATION,
|
CalendarContract.Events.EVENT_LOCATION,
|
||||||
CalendarContract.Events.DESCRIPTION,
|
CalendarContract.Events.DESCRIPTION,
|
||||||
CalendarContract.Events.STATUS,
|
CalendarContract.Events.STATUS,
|
||||||
@@ -153,11 +156,12 @@ internal object EventExportProjection {
|
|||||||
const val IDX_ALL_DAY = 6
|
const val IDX_ALL_DAY = 6
|
||||||
const val IDX_EVENT_TIMEZONE = 7
|
const val IDX_EVENT_TIMEZONE = 7
|
||||||
const val IDX_RRULE = 8
|
const val IDX_RRULE = 8
|
||||||
const val IDX_LOCATION = 9
|
const val IDX_EXDATE = 9
|
||||||
const val IDX_DESCRIPTION = 10
|
const val IDX_LOCATION = 10
|
||||||
const val IDX_STATUS = 11
|
const val IDX_DESCRIPTION = 11
|
||||||
const val IDX_AVAILABILITY = 12
|
const val IDX_STATUS = 12
|
||||||
const val IDX_CALENDAR_ID = 13
|
const val IDX_AVAILABILITY = 13
|
||||||
|
const val IDX_CALENDAR_ID = 14
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import android.provider.CalendarContract
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Fire at whatever lead time this account defaults to" — a sentinel in the
|
||||||
|
* `Reminders.MINUTES` column, not an offset, so nothing may do arithmetic on it.
|
||||||
|
* `CalendarContract` never exposes what an account resolves it to, so Calendula
|
||||||
|
* neither fires such a row ([withoutProviderDefaults]) nor decodes it
|
||||||
|
* ([fromProviderAllDayMinutes]); the detail screen names it instead.
|
||||||
|
*
|
||||||
|
* It is the **only** negative that means this. A real all-day offset is often
|
||||||
|
* negative too — the firing time of day is encoded into it — so a `< 0` test
|
||||||
|
* would silence reminders that do fire.
|
||||||
|
*/
|
||||||
|
internal const val MINUTES_DEFAULT = CalendarContract.Reminders.MINUTES_DEFAULT
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw provider offsets with the account-default rows dropped, so the pure
|
||||||
|
* planner ([de.jeanlucmakiola.calendula.domain.reminders.planReminders]) only
|
||||||
|
* ever sees offsets it can resolve.
|
||||||
|
*/
|
||||||
|
internal fun Map<Long, List<Int>>.withoutProviderDefaults(): Map<Long, List<Int>> =
|
||||||
|
mapValues { (_, minutes) -> minutes.filter { it != MINUTES_DEFAULT } }
|
||||||
@@ -7,6 +7,7 @@ import android.util.Log
|
|||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.withoutProviderDefaults
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.ReminderStatePrefs
|
import de.jeanlucmakiola.calendula.data.prefs.ReminderStatePrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
@@ -90,7 +91,8 @@ class ReminderScanner @Inject constructor(
|
|||||||
val occurrences = source.occurrences(now - PAST_WINDOW_MILLIS, now + lookahead)
|
val occurrences = source.occurrences(now - PAST_WINDOW_MILLIS, now + lookahead)
|
||||||
val planned = planReminders(
|
val planned = planReminders(
|
||||||
instances = occurrences,
|
instances = occurrences,
|
||||||
minutesByEvent = source.reminderMinutes(occurrences.map { it.eventId }),
|
minutesByEvent = source.reminderMinutes(occurrences.map { it.eventId })
|
||||||
|
.withoutProviderDefaults(),
|
||||||
zone = ZoneId.systemDefault(),
|
zone = ZoneId.systemDefault(),
|
||||||
allDayTimeMinutes = settingsPrefs.allDayReminderTimeMinutes.first(),
|
allDayTimeMinutes = settingsPrefs.allDayReminderTimeMinutes.first(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -110,3 +110,17 @@ private const val CURATION_TRIGGER_SIZE = 36
|
|||||||
* apart that two swatches never read as the same colour in the grid.
|
* apart that two swatches never read as the same colour in the grid.
|
||||||
*/
|
*/
|
||||||
private const val MIN_DISTANCE = 0.025f
|
private const val MIN_DISTANCE = 0.025f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The palette entry closest to [argb], or null when the account publishes no
|
||||||
|
* palette (its calendars then take a raw `EVENT_COLOR` instead).
|
||||||
|
*
|
||||||
|
* Used to land a colour that came from outside the account — an imported `.ics`
|
||||||
|
* carries an arbitrary ARGB, while a palette calendar only accepts one of its
|
||||||
|
* own keys. Distance is measured in Oklab, so the match is the one that looks
|
||||||
|
* closest rather than the one that is closest in RGB coordinates.
|
||||||
|
*/
|
||||||
|
fun List<EventColorOption>.nearestTo(argb: Int): EventColorOption? {
|
||||||
|
val target = oklchOf(argb)
|
||||||
|
return minByOrNull { oklchOf(it.argb).distanceTo(target) }
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.ics
|
||||||
|
|
||||||
|
/** Opaque alpha, forced onto any colour that arrives without one. */
|
||||||
|
private const val OPAQUE_ALPHA = 0xFF000000.toInt()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a colour value out of a foreign `.ics` to an opaque ARGB int.
|
||||||
|
*
|
||||||
|
* Three encodings are in the wild and all appear in the files Calendula is
|
||||||
|
* asked to import:
|
||||||
|
* - a CSS3 colour name, which is what RFC 7986 `COLOR` is defined as
|
||||||
|
* (`COLOR:tomato`);
|
||||||
|
* - a raw signed Android colour int, which the Simple Calendar / Fossify
|
||||||
|
* family writes in its `X-…-COLOR` extensions (`X-FOSSIFY-EVENT-COLOR:-2818048`);
|
||||||
|
* - `#rrggbb` / `#aarrggbb` hex, used by assorted other producers.
|
||||||
|
*
|
||||||
|
* Anything else — including the literal `null` Fossify emits when its calendar
|
||||||
|
* lookup misses — resolves to null.
|
||||||
|
*/
|
||||||
|
fun parseIcsColorValue(raw: String?): Int? {
|
||||||
|
val value = raw?.trim().orEmpty()
|
||||||
|
if (value.isEmpty()) return null
|
||||||
|
|
||||||
|
if (value.startsWith("#")) {
|
||||||
|
val hex = value.substring(1)
|
||||||
|
val parsed = hex.toLongOrNull(16)?.toInt() ?: return null
|
||||||
|
return when (hex.length) {
|
||||||
|
6 -> parsed or OPAQUE_ALPHA
|
||||||
|
8 -> parsed
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
value.toIntOrNull()?.let { return if (it ushr 24 == 0) it or OPAQUE_ALPHA else it }
|
||||||
|
return cssColors[value.lowercase()]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The CSS3 extended colour keywords, the value space of RFC 7986 `COLOR`. */
|
||||||
|
private val cssColors: Map<String, Int> by lazy {
|
||||||
|
CSS3_TABLE.split(' ').associate { entry ->
|
||||||
|
val name = entry.substringBefore('=')
|
||||||
|
name to (entry.substringAfter('=').toInt(16) or OPAQUE_ALPHA)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val CSS3_TABLE =
|
||||||
|
"aliceblue=f0f8ff antiquewhite=faebd7 aqua=00ffff aquamarine=7fffd4 " +
|
||||||
|
"azure=f0ffff beige=f5f5dc bisque=ffe4c4 black=000000 blanchedalmond=ffebcd " +
|
||||||
|
"blue=0000ff blueviolet=8a2be2 brown=a52a2a burlywood=deb887 cadetblue=5f9ea0 " +
|
||||||
|
"chartreuse=7fff00 chocolate=d2691e coral=ff7f50 cornflowerblue=6495ed " +
|
||||||
|
"cornsilk=fff8dc crimson=dc143c cyan=00ffff darkblue=00008b darkcyan=008b8b " +
|
||||||
|
"darkgoldenrod=b8860b darkgray=a9a9a9 darkgreen=006400 darkgrey=a9a9a9 " +
|
||||||
|
"darkkhaki=bdb76b darkmagenta=8b008b darkolivegreen=556b2f darkorange=ff8c00 " +
|
||||||
|
"darkorchid=9932cc darkred=8b0000 darksalmon=e9967a darkseagreen=8fbc8f " +
|
||||||
|
"darkslateblue=483d8b darkslategray=2f4f4f darkslategrey=2f4f4f darkturquoise=00ced1 " +
|
||||||
|
"darkviolet=9400d3 deeppink=ff1493 deepskyblue=00bfff dimgray=696969 " +
|
||||||
|
"dimgrey=696969 dodgerblue=1e90ff firebrick=b22222 floralwhite=fffaf0 " +
|
||||||
|
"forestgreen=228b22 fuchsia=ff00ff gainsboro=dcdcdc ghostwhite=f8f8ff " +
|
||||||
|
"gold=ffd700 goldenrod=daa520 gray=808080 green=008000 greenyellow=adff2f " +
|
||||||
|
"grey=808080 honeydew=f0fff0 hotpink=ff69b4 indianred=cd5c5c indigo=4b0082 " +
|
||||||
|
"ivory=fffff0 khaki=f0e68c lavender=e6e6fa lavenderblush=fff0f5 lawngreen=7cfc00 " +
|
||||||
|
"lemonchiffon=fffacd lightblue=add8e6 lightcoral=f08080 lightcyan=e0ffff " +
|
||||||
|
"lightgoldenrodyellow=fafad2 lightgray=d3d3d3 lightgreen=90ee90 lightgrey=d3d3d3 " +
|
||||||
|
"lightpink=ffb6c1 lightsalmon=ffa07a lightseagreen=20b2aa lightskyblue=87cefa " +
|
||||||
|
"lightslategray=778899 lightslategrey=778899 lightsteelblue=b0c4de " +
|
||||||
|
"lightyellow=ffffe0 lime=00ff00 limegreen=32cd32 linen=faf0e6 magenta=ff00ff " +
|
||||||
|
"maroon=800000 mediumaquamarine=66cdaa mediumblue=0000cd mediumorchid=ba55d3 " +
|
||||||
|
"mediumpurple=9370db mediumseagreen=3cb371 mediumslateblue=7b68ee " +
|
||||||
|
"mediumspringgreen=00fa9a mediumturquoise=48d1cc mediumvioletred=c71585 " +
|
||||||
|
"midnightblue=191970 mintcream=f5fffa mistyrose=ffe4e1 moccasin=ffe4b5 " +
|
||||||
|
"navajowhite=ffdead navy=000080 oldlace=fdf5e6 olive=808000 olivedrab=6b8e23 " +
|
||||||
|
"orange=ffa500 orangered=ff4500 orchid=da70d6 palegoldenrod=eee8aa " +
|
||||||
|
"palegreen=98fb98 paleturquoise=afeeee palevioletred=db7093 papayawhip=ffefd5 " +
|
||||||
|
"peachpuff=ffdab9 peru=cd853f pink=ffc0cb plum=dda0dd powderblue=b0e0e6 " +
|
||||||
|
"purple=800080 rebeccapurple=663399 red=ff0000 rosybrown=bc8f8f royalblue=4169e1 " +
|
||||||
|
"saddlebrown=8b4513 salmon=fa8072 sandybrown=f4a460 seagreen=2e8b57 " +
|
||||||
|
"seashell=fff5ee sienna=a0522d silver=c0c0c0 skyblue=87ceeb slateblue=6a5acd " +
|
||||||
|
"slategray=708090 slategrey=708090 snow=fffafa springgreen=00ff7f " +
|
||||||
|
"steelblue=4682b4 tan=d2b48c teal=008080 thistle=d8bfd8 tomato=ff6347 " +
|
||||||
|
"turquoise=40e0d0 violet=ee82ee wheat=f5deb3 white=ffffff whitesmoke=f5f5f5 " +
|
||||||
|
"yellow=ffff00 yellowgreen=9acd32"
|
||||||
@@ -22,6 +22,12 @@ data class IcsEvent(
|
|||||||
val zoneId: String,
|
val zoneId: String,
|
||||||
/** Bare RRULE value (no `RRULE:` prefix), or null for a one-off event. */
|
/** Bare RRULE value (no `RRULE:` prefix), or null for a one-off event. */
|
||||||
val recurrenceRule: String? = null,
|
val recurrenceRule: String? = null,
|
||||||
|
/**
|
||||||
|
* Occurrences deleted from the series, in the provider's `EXDATE` shape —
|
||||||
|
* `yyyyMMdd` for an all-day series, a UTC `yyyyMMddTHHmmssZ` stamp
|
||||||
|
* otherwise. Empty for a one-off event.
|
||||||
|
*/
|
||||||
|
val exDates: List<String> = emptyList(),
|
||||||
val location: String? = null,
|
val location: String? = null,
|
||||||
val description: String? = null,
|
val description: String? = null,
|
||||||
/** Reminder lead times in minutes before start (raw provider offsets). */
|
/** Reminder lead times in minutes before start (raw provider offsets). */
|
||||||
|
|||||||
@@ -7,14 +7,25 @@ import kotlinx.datetime.LocalDateTime
|
|||||||
import kotlinx.datetime.LocalTime
|
import kotlinx.datetime.LocalTime
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.atStartOfDayIn
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
|
import kotlinx.datetime.number
|
||||||
import kotlinx.datetime.toInstant
|
import kotlinx.datetime.toInstant
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.math.ceil
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/** Milliseconds in a calendar day; all-day times are UTC midnights, so this is exact. */
|
||||||
|
private const val DAY_MILLIS = 86_400_000L
|
||||||
|
private const val MINUTES_PER_DAY = 1_440
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A `VEVENT` parsed from an `.ics` file — the read-side mirror of [IcsEvent],
|
* A `VEVENT` parsed from an `.ics` file — the read-side mirror of [IcsEvent],
|
||||||
* but [uid] is nullable (an incoming event may carry none; the insert layer
|
* but [uid] is nullable (an incoming event may carry none; the insert layer
|
||||||
* then assigns one). Times are absolute instants; [isAllDay]/[zoneId] mirror
|
* then assigns one). Times are absolute instants; [isAllDay]/[zoneId] mirror
|
||||||
* how the writer encoded them.
|
* how the writer encoded them.
|
||||||
|
*
|
||||||
|
* [reminderMinutes] holds raw lead times as the file's `VALARM`s expressed
|
||||||
|
* them — minutes *before* start, so a negative entry means "after start".
|
||||||
|
* [semanticReminderMinutes] turns those into the lead times Calendula models.
|
||||||
*/
|
*/
|
||||||
data class ParsedIcsEvent(
|
data class ParsedIcsEvent(
|
||||||
val uid: String?,
|
val uid: String?,
|
||||||
@@ -30,8 +41,45 @@ data class ParsedIcsEvent(
|
|||||||
val status: EventStatus = EventStatus.Confirmed,
|
val status: EventStatus = EventStatus.Confirmed,
|
||||||
val availability: Availability = Availability.Busy,
|
val availability: Availability = Availability.Busy,
|
||||||
val calendarName: String? = null,
|
val calendarName: String? = null,
|
||||||
|
/** Opaque ARGB the source file gave this event, if any (see [parseIcsColorValue]). */
|
||||||
|
val color: Int? = null,
|
||||||
|
/** Excluded occurrences, already in the provider's `EXDATE` shape. */
|
||||||
|
val exDates: List<String> = emptyList(),
|
||||||
|
/** True when this came from a `VTODO` rather than a `VEVENT`. */
|
||||||
|
val isTask: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [reminderMinutes] mapped onto Calendula's model: a lead time in minutes before
|
||||||
|
* the event, never negative.
|
||||||
|
*
|
||||||
|
* For a timed event the raw lead time already is that, and a trigger that fires
|
||||||
|
* *after* the start models no lead time at all — it is dropped rather than
|
||||||
|
* clamped, which would invent a reminder at the start the file never asked for
|
||||||
|
* (a legal `TRIGGER:PT30M` follow-up alarm is the case). All-day reminders are
|
||||||
|
* whole days before the event, fired at the user's configured time of day (see
|
||||||
|
* `AllDayReminderEncoding`), so a raw offset counts the whole days its trigger
|
||||||
|
* lands *earlier than* the event's UTC midnight — i.e. it rounds **up**.
|
||||||
|
*
|
||||||
|
* Rounding to the nearest day instead would lose a day for every producer whose
|
||||||
|
* all-day notifications fire after noon: a file's raw offset is
|
||||||
|
* `days × 1440 − timeOfDay`, so "1 day before at 18:00" arrives as
|
||||||
|
* `TRIGGER:-PT6H` (raw 360) and would read as "on the day". Rounding up also
|
||||||
|
* lands Fossify's encoding correctly — it writes "on the day at 09:00" as a
|
||||||
|
* *positive* `TRIGGER:P0DT9H0M0S`, nine hours after the UTC midnight, which
|
||||||
|
* ceils to zero days before.
|
||||||
|
*/
|
||||||
|
fun ParsedIcsEvent.semanticReminderMinutes(): List<Int> = reminderMinutes
|
||||||
|
.mapNotNull { raw ->
|
||||||
|
if (isAllDay) {
|
||||||
|
ceil(raw.toDouble() / MINUTES_PER_DAY).toInt().coerceAtLeast(0) * MINUTES_PER_DAY
|
||||||
|
} else {
|
||||||
|
raw.takeIf { it >= 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.distinct()
|
||||||
|
.sorted()
|
||||||
|
|
||||||
/** Things the parser dropped rather than failing — surfaced in the import report. */
|
/** Things the parser dropped rather than failing — surfaced in the import report. */
|
||||||
enum class IcsParseWarning {
|
enum class IcsParseWarning {
|
||||||
/** A `RECURRENCE-ID` override occurrence (not modelled; only masters import). */
|
/** A `RECURRENCE-ID` override occurrence (not modelled; only masters import). */
|
||||||
@@ -45,6 +93,12 @@ enum class IcsParseWarning {
|
|||||||
|
|
||||||
/** A `TZID` couldn't be resolved against the device tz database (used local zone). */
|
/** A `TZID` couldn't be resolved against the device tz database (used local zone). */
|
||||||
UnknownTimezone,
|
UnknownTimezone,
|
||||||
|
|
||||||
|
/** `VTODO` components were imported as events — Calendula has no task model. */
|
||||||
|
TasksImportedAsEvents,
|
||||||
|
|
||||||
|
/** A malformed `RRULE` was repaired or dropped (see [sanitizeRrule]). */
|
||||||
|
RecurrenceRuleRepaired,
|
||||||
}
|
}
|
||||||
|
|
||||||
data class IcsParseResult(
|
data class IcsParseResult(
|
||||||
@@ -52,8 +106,28 @@ data class IcsParseResult(
|
|||||||
val warnings: Set<IcsParseWarning>,
|
val warnings: Set<IcsParseWarning>,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Outcome of a bulk `.ics` import into one calendar. */
|
/**
|
||||||
data class IcsImportSummary(val imported: Int, val skippedDuplicate: Int)
|
* Outcome of a bulk `.ics` import into one calendar. [failed] counts events the
|
||||||
|
* provider rejected — the import continues past them, so a single unusable
|
||||||
|
* event can't cost the user the rest of the file.
|
||||||
|
*
|
||||||
|
* [notAttempted] counts what a retry still has to import, because the permission
|
||||||
|
* or the target calendar went away mid-file and every remaining insert would have
|
||||||
|
* failed the same way. Events past that point whose UID is already in the
|
||||||
|
* calendar count under [skippedDuplicate] instead — they were never going to be
|
||||||
|
* added either way. It travels in the summary rather than as an exception so the
|
||||||
|
* counts of what *did* land survive: an import is only idempotent for events that
|
||||||
|
* carry a UID, so a retry told "nothing happened" would duplicate the rest.
|
||||||
|
*/
|
||||||
|
data class IcsImportSummary(
|
||||||
|
val imported: Int,
|
||||||
|
val skippedDuplicate: Int,
|
||||||
|
val failed: Int = 0,
|
||||||
|
val notAttempted: Int = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The non-standard property most exporters name the whole calendar with. */
|
||||||
|
private const val CALENDAR_NAME_PROPERTY = "X-WR-CALNAME"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hand-rolled RFC 5545 reader, the inverse of [IcsWriter]. Pure and
|
* Hand-rolled RFC 5545 reader, the inverse of [IcsWriter]. Pure and
|
||||||
@@ -62,6 +136,9 @@ data class IcsImportSummary(val imported: Int, val skippedDuplicate: Int)
|
|||||||
* (`RECURRENCE-ID`, attendees, unresolved `TZID`) are reported as [warnings]
|
* (`RECURRENCE-ID`, attendees, unresolved `TZID`) are reported as [warnings]
|
||||||
* rather than silently dropped. `VTIMEZONE` blocks are skipped — a `TZID` is
|
* rather than silently dropped. `VTIMEZONE` blocks are skipped — a `TZID` is
|
||||||
* resolved against the OS tz database instead ([deviceZone] is the fallback).
|
* resolved against the OS tz database instead ([deviceZone] is the fallback).
|
||||||
|
*
|
||||||
|
* `VTODO` components are read as all-day/timed events: Calendula models no
|
||||||
|
* tasks, and dropping them would silently lose half of some exports.
|
||||||
*/
|
*/
|
||||||
class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault()) {
|
class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault()) {
|
||||||
|
|
||||||
@@ -69,42 +146,57 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
|||||||
val lines = unfoldLines(text)
|
val lines = unfoldLines(text)
|
||||||
val events = mutableListOf<ParsedIcsEvent>()
|
val events = mutableListOf<ParsedIcsEvent>()
|
||||||
val warnings = mutableSetOf<IcsParseWarning>()
|
val warnings = mutableSetOf<IcsParseWarning>()
|
||||||
var calendarName: String? = null
|
// Scanned up front, not in document order: an X-WR-CALNAME after the
|
||||||
|
// first VEVENT still names the calendar. The cheap prefix test first,
|
||||||
|
// so a file without one doesn't pay a full parse to find nothing.
|
||||||
|
val calendarName = lines.asSequence()
|
||||||
|
.filter { it.startsWith(CALENDAR_NAME_PROPERTY, ignoreCase = true) }
|
||||||
|
.mapNotNull(::parseContentLine)
|
||||||
|
.firstOrNull { it.name == CALENDAR_NAME_PROPERTY }
|
||||||
|
?.let { unescapeText(it.value).trim().ifEmpty { null } }
|
||||||
|
|
||||||
var i = 0
|
var i = 0
|
||||||
while (i < lines.size) {
|
while (i < lines.size) {
|
||||||
val line = parseContentLine(lines[i])
|
val line = parseContentLine(lines[i])
|
||||||
if (line == null) { i++; continue }
|
if (line == null) { i++; continue }
|
||||||
|
val component = when {
|
||||||
|
line.isBegin("VEVENT") -> "VEVENT"
|
||||||
|
line.isBegin("VTODO") -> "VTODO"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
when {
|
when {
|
||||||
line.isBegin("VEVENT") -> {
|
component != null -> {
|
||||||
val end = indexOfEnd(lines, i + 1, "VEVENT")
|
val end = indexOfEnd(lines, i + 1, component)
|
||||||
parseVevent(lines.subList(i + 1, end), calendarName, warnings)
|
parseComponent(
|
||||||
?.let(events::add)
|
body = lines.subList(i + 1, end),
|
||||||
|
fileCalendarName = calendarName,
|
||||||
|
warnings = warnings,
|
||||||
|
isTask = component == "VTODO",
|
||||||
|
)?.let(events::add)
|
||||||
i = end + 1
|
i = end + 1
|
||||||
}
|
}
|
||||||
line.isBegin("VTIMEZONE") -> {
|
line.isBegin("VTIMEZONE") -> {
|
||||||
// Skipped wholesale; TZIDs resolve against the OS tz database.
|
// Skipped wholesale; TZIDs resolve against the OS tz database.
|
||||||
i = indexOfEnd(lines, i + 1, "VTIMEZONE") + 1
|
i = indexOfEnd(lines, i + 1, "VTIMEZONE") + 1
|
||||||
}
|
}
|
||||||
line.name == "X-WR-CALNAME" -> {
|
|
||||||
calendarName = unescapeText(line.value).trim().ifEmpty { null }
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
else -> i++
|
else -> i++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (events.any { it.isTask }) warnings.add(IcsParseWarning.TasksImportedAsEvents)
|
||||||
return IcsParseResult(events, warnings)
|
return IcsParseResult(events, warnings)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseVevent(
|
private fun parseComponent(
|
||||||
body: List<String>,
|
body: List<String>,
|
||||||
fileCalendarName: String?,
|
fileCalendarName: String?,
|
||||||
warnings: MutableSet<IcsParseWarning>,
|
warnings: MutableSet<IcsParseWarning>,
|
||||||
|
isTask: Boolean,
|
||||||
): ParsedIcsEvent? {
|
): ParsedIcsEvent? {
|
||||||
var uid: String? = null
|
var uid: String? = null
|
||||||
var summary = ""
|
var summary = ""
|
||||||
var dtStart: IcsDateTime? = null
|
var dtStart: IcsDateTime? = null
|
||||||
var dtEnd: IcsDateTime? = null
|
var dtEnd: IcsDateTime? = null
|
||||||
|
var due: IcsDateTime? = null
|
||||||
var duration: String? = null
|
var duration: String? = null
|
||||||
var rrule: String? = null
|
var rrule: String? = null
|
||||||
var location: String? = null
|
var location: String? = null
|
||||||
@@ -112,7 +204,11 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
|||||||
var status = EventStatus.Confirmed
|
var status = EventStatus.Confirmed
|
||||||
var availability = Availability.Busy
|
var availability = Availability.Busy
|
||||||
var calendarName = fileCalendarName
|
var calendarName = fileCalendarName
|
||||||
|
var category: String? = null
|
||||||
|
var eventColor: Int? = null
|
||||||
|
var calendarColor: Int? = null
|
||||||
val reminders = mutableListOf<Int>()
|
val reminders = mutableListOf<Int>()
|
||||||
|
val exDateLines = mutableListOf<IcsContentLine>()
|
||||||
var skipAsOverride = false
|
var skipAsOverride = false
|
||||||
|
|
||||||
var i = 0
|
var i = 0
|
||||||
@@ -129,9 +225,11 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
|||||||
"UID" -> uid = line.value.trim().ifEmpty { null }
|
"UID" -> uid = line.value.trim().ifEmpty { null }
|
||||||
"SUMMARY" -> summary = unescapeText(line.value)
|
"SUMMARY" -> summary = unescapeText(line.value)
|
||||||
"DTSTART" -> dtStart = parseIcsDateTime(line, warnings)
|
"DTSTART" -> dtStart = parseIcsDateTime(line, warnings)
|
||||||
|
"DUE" -> if (isTask) due = parseIcsDateTime(line, warnings)
|
||||||
"DTEND" -> dtEnd = parseIcsDateTime(line, warnings)
|
"DTEND" -> dtEnd = parseIcsDateTime(line, warnings)
|
||||||
"DURATION" -> duration = line.value.trim()
|
"DURATION" -> duration = line.value.trim()
|
||||||
"RRULE" -> rrule = line.value.trim().ifEmpty { null }
|
"RRULE" -> rrule = line.value.trim().ifEmpty { null }
|
||||||
|
"EXDATE" -> exDateLines.add(line)
|
||||||
"LOCATION" -> location = unescapeText(line.value).ifEmpty { null }
|
"LOCATION" -> location = unescapeText(line.value).ifEmpty { null }
|
||||||
"DESCRIPTION" -> description = unescapeText(line.value).ifEmpty { null }
|
"DESCRIPTION" -> description = unescapeText(line.value).ifEmpty { null }
|
||||||
"STATUS" -> status = mapIcsStatus(line.value)
|
"STATUS" -> status = mapIcsStatus(line.value)
|
||||||
@@ -142,6 +240,19 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
|||||||
"ATTENDEE" -> warnings.add(IcsParseWarning.AttendeesIgnored)
|
"ATTENDEE" -> warnings.add(IcsParseWarning.AttendeesIgnored)
|
||||||
"X-CALENDULA-CALENDAR" ->
|
"X-CALENDULA-CALENDAR" ->
|
||||||
calendarName = unescapeText(line.value).trim().ifEmpty { calendarName }
|
calendarName = unescapeText(line.value).trim().ifEmpty { calendarName }
|
||||||
|
// Fossify names the source calendar per event; it never writes
|
||||||
|
// an X-WR-CALNAME, so this is the only calendar label its files
|
||||||
|
// carry. Elsewhere CATEGORIES is a plain tag list, so it only
|
||||||
|
// stands in where nothing named the calendar outright.
|
||||||
|
"CATEGORIES" -> category = singleCategory(line.value) ?: category
|
||||||
|
// Per-event colour, most specific encoding first (see parseIcsColorValue).
|
||||||
|
"X-FOSSIFY-EVENT-COLOR", "X-SMT-EVENT-COLOR" ->
|
||||||
|
eventColor = parseIcsColorValue(line.value) ?: eventColor
|
||||||
|
"COLOR" -> eventColor = eventColor ?: parseIcsColorValue(line.value)
|
||||||
|
// The *calendar's* colour. Kept as a fallback so a migration that
|
||||||
|
// folds several source calendars into one keeps them apart visually.
|
||||||
|
"X-FOSSIFY-CATEGORY-COLOR", "X-SMT-CATEGORY-COLOR", "CATEGORY_COLOR" ->
|
||||||
|
calendarColor = parseIcsColorValue(line.value) ?: calendarColor
|
||||||
}
|
}
|
||||||
i++
|
i++
|
||||||
}
|
}
|
||||||
@@ -150,37 +261,125 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
|||||||
warnings.add(IcsParseWarning.ModifiedOccurrenceSkipped)
|
warnings.add(IcsParseWarning.ModifiedOccurrenceSkipped)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
// Most producers give a VTODO only a DUE, which is then the moment the
|
||||||
|
// task sits at. One that carries a DTSTART too means the span between
|
||||||
|
// them, so DUE stands in for the end it has no DTEND for. Resolved after
|
||||||
|
// the sweep rather than inside it: property order isn't guaranteed, and
|
||||||
|
// a DUE ahead of the DTSTART would otherwise be taken for the start.
|
||||||
|
if (isTask && due != null) {
|
||||||
|
if (dtStart == null) dtStart = due else if (dtEnd == null) dtEnd = due
|
||||||
|
}
|
||||||
val start = dtStart ?: run {
|
val start = dtStart ?: run {
|
||||||
warnings.add(IcsParseWarning.EventWithoutStartSkipped)
|
warnings.add(IcsParseWarning.EventWithoutStartSkipped)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
val end = dtEnd
|
val recurrence = sanitizeRrule(rrule)
|
||||||
?: duration?.let {
|
if (recurrence.repaired) warnings.add(IcsParseWarning.RecurrenceRuleRepaired)
|
||||||
start.copy(
|
val end = resolveEnd(start, dtEnd, duration)
|
||||||
instant = Instant.fromEpochMilliseconds(
|
|
||||||
start.instant.toEpochMilliseconds() + parseRfc2445DurationMillis(it),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
?: start
|
|
||||||
return ParsedIcsEvent(
|
return ParsedIcsEvent(
|
||||||
uid = uid,
|
uid = uid,
|
||||||
summary = summary,
|
summary = summary,
|
||||||
start = start.instant,
|
start = start.instant,
|
||||||
end = end.instant,
|
end = end,
|
||||||
isAllDay = start.isAllDay,
|
isAllDay = start.isAllDay,
|
||||||
zoneId = start.zoneId,
|
zoneId = start.zoneId,
|
||||||
recurrenceRule = rrule,
|
recurrenceRule = recurrence.rule,
|
||||||
location = location,
|
location = location,
|
||||||
description = description,
|
description = description,
|
||||||
reminderMinutes = reminders.distinct(),
|
reminderMinutes = reminders.distinct(),
|
||||||
status = status,
|
status = status,
|
||||||
availability = availability,
|
availability = availability,
|
||||||
calendarName = calendarName,
|
calendarName = calendarName ?: category,
|
||||||
|
color = eventColor ?: calendarColor,
|
||||||
|
exDates = if (recurrence.rule == null) {
|
||||||
|
emptyList()
|
||||||
|
} else {
|
||||||
|
normalizeExDates(exDateLines, start)
|
||||||
|
},
|
||||||
|
isTask = isTask,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A VALARM's lead time in minutes before start, or null if not a usable relative trigger. */
|
/**
|
||||||
|
* The event's end instant.
|
||||||
|
*
|
||||||
|
* All-day events get two RFC 5545 §3.6.1 rules applied that a literal read
|
||||||
|
* would miss: a `DATE`-valued `DTSTART` with no `DTEND` lasts one day, and —
|
||||||
|
* since the provider expands a zero-length all-day series into no instances
|
||||||
|
* at all — an all-day event may never end at or before it starts.
|
||||||
|
*/
|
||||||
|
private fun resolveEnd(
|
||||||
|
start: IcsDateTime,
|
||||||
|
dtEnd: IcsDateTime?,
|
||||||
|
duration: String?,
|
||||||
|
): Instant {
|
||||||
|
val explicit = dtEnd?.instant ?: duration?.let {
|
||||||
|
Instant.fromEpochMilliseconds(
|
||||||
|
start.instant.toEpochMilliseconds() + parseRfc2445DurationMillis(it),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!start.isAllDay) return explicit ?: start.instant
|
||||||
|
return explicit?.takeIf { it > start.instant } ?: start.instant.plusDays(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `EXDATE` values in the shape `Events.EXDATE` wants: `yyyyMMdd` for an
|
||||||
|
* all-day series, a UTC `yyyyMMddTHHmmssZ` stamp otherwise.
|
||||||
|
*
|
||||||
|
* Fossify stores its excluded occurrences as bare day codes and writes them
|
||||||
|
* out that way even for timed series, where the property is then ambiguous.
|
||||||
|
* Such a value is resolved against the series' own time of day, which is
|
||||||
|
* what it meant — its repetitions never move within the day.
|
||||||
|
*
|
||||||
|
* An all-day exclusion names a calendar day, so a zone-qualified DATE-TIME
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
private fun normalizeExDates(lines: List<IcsContentLine>, start: IcsDateTime): List<String> {
|
||||||
|
if (lines.isEmpty()) return emptyList()
|
||||||
|
val startZone = runCatching { TimeZone.of(start.zoneId) }.getOrNull() ?: deviceZone
|
||||||
|
val startTime = start.instant.toLocalDateTime(startZone).time
|
||||||
|
return lines
|
||||||
|
.flatMap { line -> line.value.split(',').map { line to it.trim() } }
|
||||||
|
.mapNotNull { (line, token) ->
|
||||||
|
when {
|
||||||
|
token.isEmpty() -> null
|
||||||
|
start.isAllDay -> parseBasicDate(token.substringBefore('T'))?.let(::dayCode)
|
||||||
|
token.contains('T') -> parseExDateTime(token, line, startZone)?.let(::utcStamp)
|
||||||
|
else -> parseBasicDate(token)
|
||||||
|
?.let { utcStamp(LocalDateTime(it, startTime).toInstant(startZone)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.distinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [startZone] is the series' own zone, which a floating value (no `Z`, no
|
||||||
|
* `TZID`) has to be read in — RFC 5545 ties `EXDATE` to `DTSTART`'s form,
|
||||||
|
* and reading it in the device's zone instead would put the exclusion on an
|
||||||
|
* instant no occurrence has.
|
||||||
|
*/
|
||||||
|
private fun parseExDateTime(
|
||||||
|
token: String,
|
||||||
|
line: IcsContentLine,
|
||||||
|
startZone: TimeZone,
|
||||||
|
): Instant? {
|
||||||
|
val ldt = parseBasicDateTime(token.removeSuffix("Z")) ?: return null
|
||||||
|
val zone = when {
|
||||||
|
token.endsWith("Z") -> TimeZone.UTC
|
||||||
|
else -> line.params["TZID"]?.let { runCatching { TimeZone.of(it) }.getOrNull() }
|
||||||
|
?: startZone
|
||||||
|
}
|
||||||
|
return ldt.toInstant(zone)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A `VALARM`'s offset from the event start, in minutes *before* it — so a
|
||||||
|
* trigger that fires after the start (which is how the Fossify family
|
||||||
|
* encodes "on the day at 09:00") comes back negative. Null when the trigger
|
||||||
|
* isn't a usable relative offset.
|
||||||
|
*/
|
||||||
private fun parseAlarmMinutes(body: List<String>): Int? {
|
private fun parseAlarmMinutes(body: List<String>): Int? {
|
||||||
val trigger = body.asSequence()
|
val trigger = body.asSequence()
|
||||||
.mapNotNull { parseContentLine(it) }
|
.mapNotNull { parseContentLine(it) }
|
||||||
@@ -189,8 +388,7 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
|||||||
// Absolute (DATE-TIME) triggers can't be expressed as a lead time.
|
// Absolute (DATE-TIME) triggers can't be expressed as a lead time.
|
||||||
if (trigger.params["VALUE"].equals("DATE-TIME", true)) return null
|
if (trigger.params["VALUE"].equals("DATE-TIME", true)) return null
|
||||||
val millis = parseRfc2445DurationMillis(trigger.value)
|
val millis = parseRfc2445DurationMillis(trigger.value)
|
||||||
// Negative = before start (the normal case) → positive lead minutes.
|
return (-millis / 60_000L).toInt()
|
||||||
return (-millis / 60_000L).toInt().coerceAtLeast(0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseIcsDateTime(line: IcsContentLine, warnings: MutableSet<IcsParseWarning>): IcsDateTime? {
|
private fun parseIcsDateTime(line: IcsContentLine, warnings: MutableSet<IcsParseWarning>): IcsDateTime? {
|
||||||
@@ -218,6 +416,34 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
|||||||
fun IcsContentLine.isBegin(component: String) =
|
fun IcsContentLine.isBegin(component: String) =
|
||||||
name == "BEGIN" && value.trim().equals(component, true)
|
name == "BEGIN" && value.trim().equals(component, true)
|
||||||
|
|
||||||
|
fun Instant.plusDays(days: Int): Instant =
|
||||||
|
Instant.fromEpochMilliseconds(toEpochMilliseconds() + days * DAY_MILLIS)
|
||||||
|
|
||||||
|
fun dayCode(date: LocalDate): String =
|
||||||
|
"%04d%02d%02d".format(date.year, date.month.number, date.day)
|
||||||
|
|
||||||
|
fun utcStamp(instant: Instant): String = with(instant.toLocalDateTime(TimeZone.UTC)) {
|
||||||
|
"%04d%02d%02dT%02d%02d%02dZ".format(year, month.number, day, hour, minute, second)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single-valued `CATEGORIES`, which is what the Fossify family writes
|
||||||
|
* for the source calendar's name. A multi-item list is a tag list and
|
||||||
|
* names no calendar, so it is ignored.
|
||||||
|
*/
|
||||||
|
fun singleCategory(raw: String): String? {
|
||||||
|
var escaped = false
|
||||||
|
for (c in raw) {
|
||||||
|
when {
|
||||||
|
escaped -> escaped = false
|
||||||
|
c == '\\' -> escaped = true
|
||||||
|
c == ',' -> return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return unescapeText(raw).trim()
|
||||||
|
.takeIf { it.isNotEmpty() && !it.equals("null", true) }
|
||||||
|
}
|
||||||
|
|
||||||
/** Index of the matching `END:<component>` at/after [from], or list end. */
|
/** Index of the matching `END:<component>` at/after [from], or list end. */
|
||||||
fun indexOfEnd(lines: List<String>, from: Int, component: String): Int {
|
fun indexOfEnd(lines: List<String>, from: Int, component: String): Int {
|
||||||
var i = from
|
var i = from
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.ics
|
||||||
|
|
||||||
|
private val VALID_FREQ = setOf(
|
||||||
|
"SECONDLY", "MINUTELY", "HOURLY", "DAILY", "WEEKLY", "MONTHLY", "YEARLY",
|
||||||
|
)
|
||||||
|
|
||||||
|
private val VALID_WEEKDAYS = setOf("SU", "MO", "TU", "WE", "TH", "FR", "SA")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What `EventRecurrence` accepts in each numeric list part: a value range, plus
|
||||||
|
* whether `0` is legal. Its `parseNumberList` throws on a non-number, on an item
|
||||||
|
* outside the range *and* on a zero in the by-position parts, so checking only
|
||||||
|
* that an item is a number would still hand the provider a rule it rejects.
|
||||||
|
*
|
||||||
|
* `BYSETPOS` is the one part it leaves unbounded; `0` is dropped from it anyway
|
||||||
|
* because RFC 5545 §3.3.10 gives it no meaning.
|
||||||
|
*/
|
||||||
|
private val NUMERIC_LIST_PARTS = mapOf(
|
||||||
|
"BYSECOND" to NumericPart(0..59, zeroAllowed = true),
|
||||||
|
"BYMINUTE" to NumericPart(0..59, zeroAllowed = true),
|
||||||
|
"BYHOUR" to NumericPart(0..23, zeroAllowed = true),
|
||||||
|
"BYMONTHDAY" to NumericPart(-31..31, zeroAllowed = false),
|
||||||
|
"BYYEARDAY" to NumericPart(-366..366, zeroAllowed = false),
|
||||||
|
"BYWEEKNO" to NumericPart(-53..53, zeroAllowed = false),
|
||||||
|
"BYMONTH" to NumericPart(1..12, zeroAllowed = false),
|
||||||
|
"BYSETPOS" to NumericPart(Int.MIN_VALUE..Int.MAX_VALUE, zeroAllowed = false),
|
||||||
|
)
|
||||||
|
|
||||||
|
private class NumericPart(private val range: IntRange, private val zeroAllowed: Boolean) {
|
||||||
|
fun accepts(item: String): Boolean {
|
||||||
|
val n = item.toIntOrNull() ?: return false
|
||||||
|
return n in range && (zeroAllowed || n != 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `UNTIL` in RFC 5545 basic format; anything else fails at expansion time. */
|
||||||
|
private val UNTIL_SHAPE = Regex("""\d{8}(T\d{6}Z?)?""")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A foreign RRULE made safe to hand `CalendarContract`.
|
||||||
|
*
|
||||||
|
* [rule] is what survived, or null when there is nothing left to salvage.
|
||||||
|
* [repaired] is true only when a part was actually dropped — pure normalisation
|
||||||
|
* (case, a stray separator) doesn't count, so a conformant file is never
|
||||||
|
* reported to the user as faulty.
|
||||||
|
*/
|
||||||
|
data class SanitizedRrule(val rule: String?, val repaired: Boolean)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Salvage a foreign RRULE.
|
||||||
|
*
|
||||||
|
* The provider validates every RRULE through `EventRecurrence.parse`, which
|
||||||
|
* **throws** on a malformed rule — and the throw surfaces from `insert`, not
|
||||||
|
* from a later read. One bad rule in an imported file would therefore abort the
|
||||||
|
* write it rides on. Producers do emit such rules: Fossify's exporter writes a
|
||||||
|
* bare `;BYDAY=` whenever a weekly event carries no weekday mask
|
||||||
|
* (`Parser.getByDay` interpolates an empty day string unconditionally), which is
|
||||||
|
* the state every weekly event it imported from elsewhere is left in.
|
||||||
|
*
|
||||||
|
* That parser has a fixed table of parts and throws on any name outside it, so
|
||||||
|
* this is a whitelist: an unrecognised part (RFC 7529 `RSCALE`, a vendor `X-`
|
||||||
|
* extension) and an unusable value are dropped rather than passed on to fail the
|
||||||
|
* whole event. Only a missing or unrecognised `FREQ` — without which there is no
|
||||||
|
* rule at all — gives up entirely.
|
||||||
|
*/
|
||||||
|
fun sanitizeRrule(raw: String?): SanitizedRrule {
|
||||||
|
val rule = raw?.trim()?.removePrefix("RRULE:")?.trim().orEmpty()
|
||||||
|
if (rule.isEmpty()) return SanitizedRrule(rule = null, repaired = false)
|
||||||
|
|
||||||
|
var freqSeen = false
|
||||||
|
var repaired = false
|
||||||
|
val parts = mutableListOf<String>()
|
||||||
|
|
||||||
|
/** The items [valid] accepts, uppercased and comma-joined; null if none survive. */
|
||||||
|
fun keepItems(value: String, valid: (String) -> Boolean): String? {
|
||||||
|
val items = value.split(',').map { it.trim().uppercase() }
|
||||||
|
val kept = items.filter(valid)
|
||||||
|
if (kept.size != items.size) repaired = true
|
||||||
|
return kept.takeIf { it.isNotEmpty() }?.joinToString(",")
|
||||||
|
}
|
||||||
|
|
||||||
|
for (part in rule.split(';')) {
|
||||||
|
// A stray separator ("FREQ=DAILY;") normalises away; nothing was lost.
|
||||||
|
// A part that carries text but no '=' ("FREQ=WEEKLY;BYDAY") is a part
|
||||||
|
// being dropped, so it falls through and marks the rule repaired.
|
||||||
|
if (part.isBlank()) continue
|
||||||
|
val key = part.substringBefore('=', "").trim().uppercase()
|
||||||
|
val value = if ('=' in part) part.substringAfter('=').trim() else ""
|
||||||
|
val kept = when {
|
||||||
|
key.isEmpty() || value.isEmpty() -> null
|
||||||
|
key == "FREQ" ->
|
||||||
|
value.uppercase().takeIf { it in VALID_FREQ }
|
||||||
|
?.also { freqSeen = true }
|
||||||
|
?.let { "FREQ=$it" }
|
||||||
|
// A non-positive or non-numeric INTERVAL/COUNT is rejected by the
|
||||||
|
// provider, and a COUNT of zero would expand to no occurrences.
|
||||||
|
key == "INTERVAL" -> value.toIntOrNull()?.takeIf { it > 0 }?.let { "INTERVAL=$it" }
|
||||||
|
key == "COUNT" -> value.toIntOrNull()?.takeIf { it > 0 }?.let { "COUNT=$it" }
|
||||||
|
key == "UNTIL" ->
|
||||||
|
value.uppercase().takeIf { UNTIL_SHAPE.matches(it) }?.let { "UNTIL=$it" }
|
||||||
|
key == "WKST" ->
|
||||||
|
value.uppercase().takeIf { it in VALID_WEEKDAYS }?.let { "WKST=$it" }
|
||||||
|
key == "BYDAY" -> keepItems(value, ::isWeekdayItem)?.let { "BYDAY=$it" }
|
||||||
|
key in NUMERIC_LIST_PARTS ->
|
||||||
|
keepItems(value, NUMERIC_LIST_PARTS.getValue(key)::accepts)?.let { "$key=$it" }
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
if (kept == null) repaired = true else parts += kept
|
||||||
|
}
|
||||||
|
// `EventRecurrence.parse` ends with *two* global checks, not one: a missing
|
||||||
|
// FREQ throws, and so does an UNTIL that arrives together with a COUNT. A
|
||||||
|
// rule carrying both is malformed per RFC 5545 §3.3.10 but real producers
|
||||||
|
// emit it, and left alone it would throw straight out of `insert` — the very
|
||||||
|
// failure this function exists to prevent. UNTIL is kept: it bounds the
|
||||||
|
// series at an absolute date, so a stale COUNT can't over-generate past it.
|
||||||
|
if (parts.any { it.startsWith("UNTIL=") }) {
|
||||||
|
if (parts.removeAll { it.startsWith("COUNT=") }) repaired = true
|
||||||
|
}
|
||||||
|
return if (freqSeen) {
|
||||||
|
SanitizedRrule(rule = parts.joinToString(";"), repaired = repaired)
|
||||||
|
} else {
|
||||||
|
SanitizedRrule(rule = null, repaired = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A `BYDAY` item: a two-letter weekday, optionally preceded by an ordinal. */
|
||||||
|
private fun isWeekdayItem(item: String): Boolean {
|
||||||
|
if (item.length < 2) return false
|
||||||
|
val ordinal = item.dropLast(2)
|
||||||
|
return item.takeLast(2) in VALID_WEEKDAYS &&
|
||||||
|
(ordinal.isEmpty() || ordinal.toIntOrNull() != null)
|
||||||
|
}
|
||||||
@@ -46,6 +46,7 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) {
|
|||||||
appendTimes(event)
|
appendTimes(event)
|
||||||
event.recurrenceRule?.takeIf { it.isNotBlank() }
|
event.recurrenceRule?.takeIf { it.isNotBlank() }
|
||||||
?.let { add("RRULE:${it.removePrefix("RRULE:")}") }
|
?.let { add("RRULE:${it.removePrefix("RRULE:")}") }
|
||||||
|
appendExDates(event)
|
||||||
event.location?.takeIf { it.isNotBlank() }
|
event.location?.takeIf { it.isNotBlank() }
|
||||||
?.let { add("LOCATION:${escapeText(it)}") }
|
?.let { add("LOCATION:${escapeText(it)}") }
|
||||||
event.description?.takeIf { it.isNotBlank() }
|
event.description?.takeIf { it.isNotBlank() }
|
||||||
@@ -83,6 +84,20 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* it the bare day codes read as an invalid DATE-TIME. Only written for a
|
||||||
|
* 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
|
||||||
|
val stamps = event.exDates.filter { it.isNotBlank() }.distinct()
|
||||||
|
if (stamps.isEmpty()) return
|
||||||
|
val prefix = if (event.isAllDay) "EXDATE;VALUE=DATE:" else "EXDATE:"
|
||||||
|
add(prefix + stamps.joinToString(","))
|
||||||
|
}
|
||||||
|
|
||||||
private fun MutableList<String>.appendAlarm(minutes: Int, summary: String) {
|
private fun MutableList<String>.appendAlarm(minutes: Int, summary: String) {
|
||||||
add("BEGIN:VALARM")
|
add("BEGIN:VALARM")
|
||||||
add("ACTION:DISPLAY")
|
add("ACTION:DISPLAY")
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ import kotlinx.datetime.toLocalDateTime
|
|||||||
* calendar, exactly like a fresh create — the user confirms the target and
|
* calendar, exactly like a fresh create — the user confirms the target and
|
||||||
* reviews everything before saving. Mirrors `EventDetail.toEditForm`'s all-day
|
* reviews everything before saving. Mirrors `EventDetail.toEditForm`'s all-day
|
||||||
* handling (provider all-day times are UTC midnights with an exclusive end).
|
* handling (provider all-day times are UTC midnights with an exclusive end).
|
||||||
|
*
|
||||||
|
* [ParsedIcsEvent.color] is deliberately not carried over: picking the calendar
|
||||||
|
* is the first thing this form asks for, and that clears any colour (a raw ARGB
|
||||||
|
* is invalid on a calendar whose account publishes a palette). Bulk import,
|
||||||
|
* where the target is known up front, does keep it.
|
||||||
*/
|
*/
|
||||||
fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
|
fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
|
||||||
val (start, end) = if (isAllDay) {
|
val (start, end) = if (isAllDay) {
|
||||||
@@ -31,7 +36,7 @@ fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
|
|||||||
end = end,
|
end = end,
|
||||||
location = location.orEmpty(),
|
location = location.orEmpty(),
|
||||||
description = description.orEmpty(),
|
description = description.orEmpty(),
|
||||||
reminders = reminderMinutes.distinct().sorted(),
|
reminders = semanticReminderMinutes(),
|
||||||
availability = availability,
|
availability = availability,
|
||||||
rrule = recurrenceRule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
|
rrule = recurrenceRule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -63,6 +63,12 @@ private const val MINUTES_PER_DAY = 1_440
|
|||||||
* for *which day* it means ([allDayLeadDays]) and take the hour from
|
* for *which day* it means ([allDayLeadDays]) and take the hour from
|
||||||
* [allDayTimeMinutes], recomposed against each occurrence's own date in [zone].
|
* [allDayTimeMinutes], recomposed against each occurrence's own date in [zone].
|
||||||
* Duplicate offsets in [minutesByEvent] collapse.
|
* Duplicate offsets in [minutesByEvent] collapse.
|
||||||
|
*
|
||||||
|
* Every offset given is planned. The provider's "use the account default"
|
||||||
|
* sentinel names a lead time no reader can resolve, and is translated out on the
|
||||||
|
* way in
|
||||||
|
* ([de.jeanlucmakiola.calendula.data.calendar.withoutProviderDefaults]) — taken
|
||||||
|
* as an offset it would arm an alarm a minute after the event began.
|
||||||
*/
|
*/
|
||||||
fun planReminders(
|
fun planReminders(
|
||||||
instances: List<ReminderEventInstance>,
|
instances: List<ReminderEventInstance>,
|
||||||
@@ -70,17 +76,19 @@ fun planReminders(
|
|||||||
zone: ZoneId,
|
zone: ZoneId,
|
||||||
allDayTimeMinutes: Int,
|
allDayTimeMinutes: Int,
|
||||||
): List<PlannedReminder> = instances.flatMap { instance ->
|
): List<PlannedReminder> = instances.flatMap { instance ->
|
||||||
minutesByEvent[instance.eventId].orEmpty().distinct().map { minutes ->
|
minutesByEvent[instance.eventId].orEmpty()
|
||||||
PlannedReminder(
|
.distinct()
|
||||||
instance = instance,
|
.map { minutes ->
|
||||||
minutes = minutes,
|
PlannedReminder(
|
||||||
alarmMillis = if (instance.isAllDay) {
|
instance = instance,
|
||||||
allDayAlarmMillis(instance.beginMillis, minutes, zone, allDayTimeMinutes)
|
minutes = minutes,
|
||||||
} else {
|
alarmMillis = if (instance.isAllDay) {
|
||||||
instance.beginMillis - minutes * MILLIS_PER_MINUTE
|
allDayAlarmMillis(instance.beginMillis, minutes, zone, allDayTimeMinutes)
|
||||||
},
|
} else {
|
||||||
)
|
instance.beginMillis - minutes * MILLIS_PER_MINUTE
|
||||||
}
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** UTC midnight of an all-day occurrence, as the calendar date it stands for. */
|
/** UTC midnight of an all-day occurrence, as the calendar date it stands for. */
|
||||||
|
|||||||
@@ -319,9 +319,10 @@ fun CalendarHost(
|
|||||||
EventMoveScope(
|
EventMoveScope(
|
||||||
movableCalendarIds = movableCalendarIds,
|
movableCalendarIds = movableCalendarIds,
|
||||||
dragEnabled = dragToReschedule,
|
dragEnabled = dragToReschedule,
|
||||||
move = reschedule::move,
|
move = { reschedule.move(it) },
|
||||||
inFlight = reschedule.inFlight,
|
inFlight = reschedule.inFlight,
|
||||||
undoStarted = reschedule.undoStarted,
|
undoStarted = reschedule.undoStarted,
|
||||||
|
abandoned = reschedule.abandoned,
|
||||||
edit = onEditEvent,
|
edit = onEditEvent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/** The corner radius every event chip keeps on each edge the event doesn't cross. */
|
||||||
|
val EVENT_CHIP_CORNER = 4.dp
|
||||||
|
|
||||||
|
/** An event chip that is cut on no edge — an all-day bar, a floating copy. */
|
||||||
|
val EventChipShape = RoundedCornerShape(EVENT_CHIP_CORNER)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A timed block's corners: square on whichever edge the event runs past, so a
|
||||||
|
* cut edge reads as "this carries on" rather than as the event's own end. The
|
||||||
|
* vertical counterpart of [monthBarShape].
|
||||||
|
*/
|
||||||
|
fun timedBlockShape(continuesBefore: Boolean, continuesAfter: Boolean): RoundedCornerShape =
|
||||||
|
RoundedCornerShape(
|
||||||
|
topStart = if (continuesBefore) 0.dp else EVENT_CHIP_CORNER,
|
||||||
|
topEnd = if (continuesBefore) 0.dp else EVENT_CHIP_CORNER,
|
||||||
|
bottomStart = if (continuesAfter) 0.dp else EVENT_CHIP_CORNER,
|
||||||
|
bottomEnd = if (continuesAfter) 0.dp else EVENT_CHIP_CORNER,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A month/all-day bar's corners: square on whichever side the event runs past
|
||||||
|
* the row it is drawn in.
|
||||||
|
*/
|
||||||
|
fun monthBarShape(continuesLeft: Boolean, continuesRight: Boolean): RoundedCornerShape =
|
||||||
|
RoundedCornerShape(
|
||||||
|
topStart = if (continuesLeft) 0.dp else EVENT_CHIP_CORNER,
|
||||||
|
bottomStart = if (continuesLeft) 0.dp else EVENT_CHIP_CORNER,
|
||||||
|
topEnd = if (continuesRight) 0.dp else EVENT_CHIP_CORNER,
|
||||||
|
bottomEnd = if (continuesRight) 0.dp else EVENT_CHIP_CORNER,
|
||||||
|
)
|
||||||
@@ -35,8 +35,12 @@ class EventMoveScope(
|
|||||||
* accidental drags the setting exists to stop.
|
* accidental drags the setting exists to stop.
|
||||||
*/
|
*/
|
||||||
val dragEnabled: Boolean,
|
val dragEnabled: Boolean,
|
||||||
/** False when the drop was refused outright, so nothing will be written. */
|
/**
|
||||||
val move: (MoveRequest) -> 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.
|
||||||
|
*/
|
||||||
|
val move: (MoveRequest) -> Unit,
|
||||||
/**
|
/**
|
||||||
* True while a dropped event is being written, including the time its scope
|
* 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
|
* dialog is up. A flow rather than a value so this scope stays the same
|
||||||
@@ -45,6 +49,12 @@ class EventMoveScope(
|
|||||||
val inFlight: StateFlow<Boolean>,
|
val inFlight: StateFlow<Boolean>,
|
||||||
/** Ticks when an undo write begins — see `RescheduleViewModel.undoStarted`. */
|
/** Ticks when an undo write begins — see `RescheduleViewModel.undoStarted`. */
|
||||||
val undoStarted: StateFlow<Int>,
|
val undoStarted: StateFlow<Int>,
|
||||||
|
/**
|
||||||
|
* Ticks when a drop ends with nothing landing — see
|
||||||
|
* `RescheduleViewModel.abandoned`. [move] answers before the write is
|
||||||
|
* resolved, so this is what tells a held copy to stop waiting.
|
||||||
|
*/
|
||||||
|
val abandoned: StateFlow<Int>,
|
||||||
/** Open an event in the edit form — the pointer-free route to the same change. */
|
/** Open an event in the edit form — the pointer-free route to the same change. */
|
||||||
val edit: (EventInstance) -> Unit,
|
val edit: (EventInstance) -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -57,6 +67,8 @@ private val NEVER_IN_FLIGHT = MutableStateFlow(false)
|
|||||||
|
|
||||||
private val NEVER_UNDONE = MutableStateFlow(0)
|
private val NEVER_UNDONE = MutableStateFlow(0)
|
||||||
|
|
||||||
|
private val NEVER_ABANDONED = MutableStateFlow(0)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether a dropped event is still being written — false wherever moving is off.
|
* 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.
|
* The drag overlays hold a landed block on its target for this window.
|
||||||
@@ -67,6 +79,17 @@ fun moveInFlight(): Boolean {
|
|||||||
return flow.collectAsStateWithLifecycle().value
|
return flow.collectAsStateWithLifecycle().value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many drops have ended with nothing landing. Read as a plain count so a
|
||||||
|
* drag overlay can capture it at the moment of the drop and tell, later, whether
|
||||||
|
* *its own* drop was one of them.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun abandonedMoves(): Int {
|
||||||
|
val flow = LocalEventMove.current?.abandoned ?: NEVER_ABANDONED
|
||||||
|
return flow.collectAsStateWithLifecycle().value
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs [onUndo] when an undo write begins, and never for one that began before
|
* Runs [onUndo] when an undo write begins, and never for one that began before
|
||||||
* this composable came on screen.
|
* this composable came on screen.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.MINUTES_DEFAULT
|
||||||
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
||||||
|
|
||||||
/** Common reminder lead times offered as quick picks in the form and settings. */
|
/** Common reminder lead times offered as quick picks in the form and settings. */
|
||||||
@@ -20,15 +21,20 @@ fun reminderUnitLabel(unit: ReminderUnit): Int = when (unit) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Humanise a reminder lead time (minutes before the event start) into one
|
* Humanise a reminder lead time (minutes before the event start) into one
|
||||||
* line: "Default reminder" (negative = the provider default), "At time of
|
* line: "Default reminder" ([MINUTES_DEFAULT]), "At time of event" (0),
|
||||||
* event" (0), "10 minutes before", "1 hour before", … Shared by the detail
|
* "10 minutes before", "1 hour before", … Shared by the detail screen, the event
|
||||||
* screen, the event form and the default-reminder settings so the wording
|
* form and the default-reminder settings so the wording never drifts.
|
||||||
* never drifts.
|
*
|
||||||
|
* Only the sentinel itself is the account default. Any other negative is a real
|
||||||
|
* lead time firing *after* the start — rare, but a foreign all-day row decodes to
|
||||||
|
* one — and naming it "Default reminder" would tell the user an alarm that does
|
||||||
|
* go off is not ours.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun reminderLeadTimeLabel(minutes: Int): String = when {
|
fun reminderLeadTimeLabel(minutes: Int): String = when {
|
||||||
minutes < 0 -> stringResource(R.string.reminder_default)
|
minutes == MINUTES_DEFAULT -> stringResource(R.string.reminder_default)
|
||||||
minutes == 0 -> stringResource(R.string.reminder_at_time)
|
minutes == 0 -> stringResource(R.string.reminder_at_time)
|
||||||
|
minutes < 0 -> stringResource(R.string.reminder_after, durationLabel(-minutes))
|
||||||
minutes % 10_080 == 0 ->
|
minutes % 10_080 == 0 ->
|
||||||
pluralStringResource(R.plurals.reminder_weeks, minutes / 10_080, minutes / 10_080)
|
pluralStringResource(R.plurals.reminder_weeks, minutes / 10_080, minutes / 10_080)
|
||||||
minutes % 1_440 == 0 ->
|
minutes % 1_440 == 0 ->
|
||||||
|
|||||||
@@ -140,6 +140,17 @@ class RescheduleViewModel @Inject constructor(
|
|||||||
*/
|
*/
|
||||||
val inFlight: StateFlow<Boolean> = _inFlight.asStateFlow()
|
val inFlight: StateFlow<Boolean> = _inFlight.asStateFlow()
|
||||||
|
|
||||||
|
private val _abandoned = MutableStateFlow(0)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ticks when a drop ends with nothing on the target day: refused outright,
|
||||||
|
* refused before the write, written and failed, or its scope dialog
|
||||||
|
* dismissed. [move] answers before any of that is known, so without this
|
||||||
|
* signal the view that drew the drop held its copy out for the full settle
|
||||||
|
* timeout waiting for a grid that was never going to draw it (#253).
|
||||||
|
*/
|
||||||
|
val abandoned: StateFlow<Int> = _abandoned.asStateFlow()
|
||||||
|
|
||||||
private val _undoStarted = MutableStateFlow(0)
|
private val _undoStarted = MutableStateFlow(0)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -189,15 +200,23 @@ class RescheduleViewModel @Inject constructor(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Take a drop, unless one is already being written. False means nothing will
|
* Take a drop, unless one is already being written. False means nothing will
|
||||||
* be written and the caller must let its held copy go now — waiting on
|
* be written; [abandoned] ticks for that refusal too, so a held copy is let
|
||||||
* [inFlight] would strand it until the settle timeout instead.
|
* 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.
|
||||||
*/
|
*/
|
||||||
fun move(request: MoveRequest): Boolean {
|
fun move(request: MoveRequest): Boolean {
|
||||||
if (busy || _scopePrompt.value != null) return false
|
if (busy || _scopePrompt.value != null) {
|
||||||
|
_abandoned.value += 1
|
||||||
|
return false
|
||||||
|
}
|
||||||
busy = true
|
busy = true
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val prepared = prepare(request)
|
val prepared = prepare(request)
|
||||||
if (prepared == null) {
|
if (prepared == null) {
|
||||||
|
_abandoned.value += 1
|
||||||
busy = false
|
busy = false
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
@@ -231,6 +250,7 @@ class RescheduleViewModel @Inject constructor(
|
|||||||
pending = null
|
pending = null
|
||||||
busy = false
|
busy = false
|
||||||
_scopePrompt.value = null
|
_scopePrompt.value = null
|
||||||
|
_abandoned.value += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -238,6 +258,12 @@ class RescheduleViewModel @Inject constructor(
|
|||||||
* inverse write reports back, so the one chip changes what it says rather
|
* inverse write reports back, so the one chip changes what it says rather
|
||||||
* than closing and reopening. False when another write is already running —
|
* than closing and reopening. False when another write is already running —
|
||||||
* the chip hides its action for that window, so this is the backstop.
|
* the chip hides its action for that window, so this is the backstop.
|
||||||
|
*
|
||||||
|
* [abandoned] ticks once an inverse that *started* fails to land: [undoStarted]
|
||||||
|
* has sent the chip back on its journey by then and nothing else will seat it.
|
||||||
|
* A refusal ticks nothing — it launched no journey of its own, and the counter
|
||||||
|
* is global, so a tick here would release some other drop's held copy before
|
||||||
|
* the grid has drawn it.
|
||||||
*/
|
*/
|
||||||
fun undo(undo: MoveUndo): Boolean {
|
fun undo(undo: MoveUndo): Boolean {
|
||||||
if (busy) return false
|
if (busy) return false
|
||||||
@@ -256,6 +282,9 @@ class RescheduleViewModel @Inject constructor(
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
MoveOutcome.Failed
|
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
|
busy = false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
@@ -340,6 +369,7 @@ class RescheduleViewModel @Inject constructor(
|
|||||||
val request = prepared.request
|
val request = prepared.request
|
||||||
if (endsBeforeItStarts(prepared, scope)) {
|
if (endsBeforeItStarts(prepared, scope)) {
|
||||||
_outcome.value = MoveOutcome.BlockedSeriesEnd
|
_outcome.value = MoveOutcome.BlockedSeriesEnd
|
||||||
|
_abandoned.value += 1
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_outcome.value = try {
|
_outcome.value = try {
|
||||||
@@ -351,6 +381,7 @@ class RescheduleViewModel @Inject constructor(
|
|||||||
repository.updateOccurrence(
|
repository.updateOccurrence(
|
||||||
request.eventId,
|
request.eventId,
|
||||||
request.beginMillis,
|
request.beginMillis,
|
||||||
|
prepared.original,
|
||||||
prepared.updated,
|
prepared.updated,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -375,6 +406,9 @@ class RescheduleViewModel @Inject constructor(
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
MoveOutcome.Failed
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.runtime.withFrameNanos
|
import androidx.compose.runtime.withFrameNanos
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.layout.LayoutCoordinates
|
import androidx.compose.ui.layout.LayoutCoordinates
|
||||||
import androidx.compose.ui.layout.boundsInRoot
|
import androidx.compose.ui.layout.boundsInRoot
|
||||||
@@ -44,7 +45,11 @@ import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
|||||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
|
import kotlinx.datetime.plus
|
||||||
import kotlinx.datetime.LocalDateTime
|
import kotlinx.datetime.LocalDateTime
|
||||||
import kotlinx.datetime.LocalTime
|
import kotlinx.datetime.LocalTime
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
@@ -66,17 +71,148 @@ private val AUTO_SCROLL_STEP = 16.dp
|
|||||||
data class TimelineDrag(
|
data class TimelineDrag(
|
||||||
val event: EventInstance,
|
val event: EventInstance,
|
||||||
val date: LocalDate,
|
val date: LocalDate,
|
||||||
|
/**
|
||||||
|
* Where the dragged *block's* top edge now sits, in minutes from [date]'s
|
||||||
|
* midnight. The gesture's own quantity — what snaps, what the haptics tick
|
||||||
|
* on, and what the drop is expressed in. Not the event's start: for the tail
|
||||||
|
* of an event that began the day before, the two are a clip offset apart.
|
||||||
|
*/
|
||||||
val startMin: Int,
|
val startMin: Int,
|
||||||
val endMin: Int,
|
/** The event's new start, from [date]'s midnight — negative if it began earlier. */
|
||||||
val topLeftInRoot: Offset,
|
val eventStartMin: Int,
|
||||||
val sizePx: IntSize,
|
val eventSpanMin: Int,
|
||||||
|
/** The event as the grid would draw it, one piece per day column it covers. */
|
||||||
|
val pieces: List<TimelineDragPiece>,
|
||||||
|
/**
|
||||||
|
* True when [pieces] is only [edgeDragSlice]'s stand-in — the event has left
|
||||||
|
* every column this timeline shows. A hint under the finger, not where the
|
||||||
|
* drop lands, so it is dropped once the gesture ends ([finish]).
|
||||||
|
*/
|
||||||
|
val edgeOnly: Boolean = false,
|
||||||
) {
|
) {
|
||||||
/** What the snap haptics key off: one tick per changed slot, not per frame. */
|
/** What the snap haptics key off: one tick per changed slot, not per frame. */
|
||||||
val slot: Pair<LocalDate, Int> get() = date to startMin
|
val slot: Pair<LocalDate, Int> get() = date to startMin
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Where a finished drag asks its event to go. */
|
/**
|
||||||
data class TimelineDrop(val event: EventInstance, val date: LocalDate, val startMin: Int)
|
* One day column's share of a dragged event, ready to draw. Both halves of an
|
||||||
|
* event crossing midnight are previewed, whichever half the finger picked up:
|
||||||
|
* showing only the held one made the event read as growing rather than moving,
|
||||||
|
* since the day beside it still stood at the block's old extent (#253).
|
||||||
|
*/
|
||||||
|
data class TimelineDragPiece(
|
||||||
|
val topLeftInRoot: Offset,
|
||||||
|
val sizePx: IntSize,
|
||||||
|
val continuesBefore: Boolean,
|
||||||
|
val continuesAfter: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How a run of [spanMin] minutes beginning [eventStartMin] minutes into a day —
|
||||||
|
* negative when it began on an earlier one — falls across day columns, as
|
||||||
|
* offsets from that day. Every slice but the first and last is a whole day, and
|
||||||
|
* the flags say which edges are cuts rather than the event's own ends.
|
||||||
|
*
|
||||||
|
* [within] bounds the day offsets that have a column to draw in, so a very long
|
||||||
|
* event doesn't slice days no one can see.
|
||||||
|
*/
|
||||||
|
internal fun dragSlices(
|
||||||
|
eventStartMin: Int,
|
||||||
|
spanMin: Int,
|
||||||
|
within: IntRange? = null,
|
||||||
|
): List<DragSlice> {
|
||||||
|
val span = spanMin.coerceAtLeast(0)
|
||||||
|
val end = eventStartMin + span
|
||||||
|
val days = dragDayRange(eventStartMin, spanMin)
|
||||||
|
val first = days.first
|
||||||
|
val last = days.last
|
||||||
|
val from = maxOf(first, within?.first ?: first)
|
||||||
|
val to = minOf(last, within?.last ?: last)
|
||||||
|
if (from > to) return emptyList()
|
||||||
|
return (from..to).map { day ->
|
||||||
|
val dayStart = day * MINUTES_PER_DAY
|
||||||
|
val sliceStart = maxOf(eventStartMin - dayStart, 0)
|
||||||
|
val sliceEnd = minOf(end - dayStart, MINUTES_PER_DAY)
|
||||||
|
DragSlice(
|
||||||
|
dayOffset = day,
|
||||||
|
startMin = sliceStart,
|
||||||
|
spanMin = sliceEnd - sliceStart,
|
||||||
|
continuesBefore = day > first,
|
||||||
|
continuesAfter = day < last,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one piece to draw when a drag has carried the whole event clear of the
|
||||||
|
* columns this timeline shows, so [dragSlices] has nothing to hand back — where a
|
||||||
|
* short clipped tail lands at its [dragFloorMin]: the event then belongs to the
|
||||||
|
* day before, which Day view has no column for, and the copy would blink out from
|
||||||
|
* under the finger mid-gesture. Pinned to the top of the nearest visible column
|
||||||
|
* at minimum height instead, cut at that edge, so it reads as having slid off the
|
||||||
|
* top. Null whenever a real slice exists. The drop is unaffected either way — it
|
||||||
|
* reads [TimelineDrag.startMin], not the pieces.
|
||||||
|
*/
|
||||||
|
internal fun edgeDragSlice(eventStartMin: Int, spanMin: Int, within: IntRange): DragSlice? {
|
||||||
|
if (dragDayRange(eventStartMin, spanMin).last >= within.first) return null
|
||||||
|
return DragSlice(
|
||||||
|
dayOffset = within.first,
|
||||||
|
startMin = 0,
|
||||||
|
spanMin = 0,
|
||||||
|
continuesBefore = true,
|
||||||
|
continuesAfter = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The day offsets a run of [spanMin] minutes beginning [eventStartMin] covers,
|
||||||
|
* before any clipping to what a timeline shows. One rule, so [dragSlices] and
|
||||||
|
* [edgeDragSlice] can't drift apart on where an event's days begin and end.
|
||||||
|
*/
|
||||||
|
private fun dragDayRange(eventStartMin: Int, spanMin: Int): IntRange {
|
||||||
|
val end = eventStartMin + spanMin.coerceAtLeast(0)
|
||||||
|
val first = eventStartMin.floorDiv(MINUTES_PER_DAY)
|
||||||
|
// A zero-length event still has a block, on the one day it sits in.
|
||||||
|
return first..maxOf((end - 1).floorDiv(MINUTES_PER_DAY), first)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far above its day's midnight a block's top edge may be dragged. Zero for
|
||||||
|
* an ordinary block, whose top edge is the event's own start. A tail clipped at
|
||||||
|
* midnight has to go lower: its top edge *is* midnight and stays there however
|
||||||
|
* much earlier the event moves, so pinning it at zero made dragging the second
|
||||||
|
* half earlier a no-op (#253). It may rise until the event's end reaches one
|
||||||
|
* snap step into this day — or by a single step, for a tail too short to afford
|
||||||
|
* even that, which then leaves the day for the one before it.
|
||||||
|
*/
|
||||||
|
internal fun dragFloorMin(clipOffsetMin: Int, eventSpanMin: Int): Int =
|
||||||
|
if (clipOffsetMin > 0) {
|
||||||
|
minOf(-DRAG_SNAP_MINUTES, DRAG_SNAP_MINUTES + clipOffsetMin - eventSpanMin)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One day's share of a dragged event, before it is placed on screen. */
|
||||||
|
internal data class DragSlice(
|
||||||
|
val dayOffset: Int,
|
||||||
|
val startMin: Int,
|
||||||
|
val spanMin: Int,
|
||||||
|
val continuesBefore: Boolean,
|
||||||
|
val continuesAfter: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a finished drag asks its event to go. [date]/[startMin] are where the
|
||||||
|
* dragged *block's* top edge landed, which is the event's own start only for a
|
||||||
|
* block that isn't clipped — see [clipOffsetMin] and [startInstant].
|
||||||
|
*/
|
||||||
|
data class TimelineDrop(
|
||||||
|
val event: EventInstance,
|
||||||
|
val date: LocalDate,
|
||||||
|
val startMin: Int,
|
||||||
|
/** Minutes from the event's start to the dragged block's top edge. */
|
||||||
|
val clipOffsetMin: Int = 0,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The timeline's live geometry, republished on every layout. Plain fields rather
|
* The timeline's live geometry, republished on every layout. Plain fields rather
|
||||||
@@ -134,12 +270,14 @@ class TimelineDragController {
|
|||||||
private set
|
private set
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where the settled drop came from, and which event row it belongs to. The
|
* The event row and start instant the settled drop came from. The instance id
|
||||||
* instance id alone can't identify the source block for the length of the
|
* alone can't identify the source for the length of the write: the provider
|
||||||
* write: the provider regenerates `Instances` rows, so a re-read carrying the
|
* regenerates `Instances` rows, so a re-read carrying the *old* time can
|
||||||
* *old* time can arrive under a new instance id.
|
* arrive under a new instance id. Matching the start instant rather than a
|
||||||
|
* single day and slot keeps *both* halves of an event that crosses midnight
|
||||||
|
* ghosted until the write lands (#253).
|
||||||
*/
|
*/
|
||||||
private var settledOrigin: Triple<Long, LocalDate, Int>? by mutableStateOf(null)
|
private var settledOrigin: Pair<Long, Instant>? by mutableStateOf(null)
|
||||||
|
|
||||||
/** Whether a finger is on a block right now — [settling] is not dragging. */
|
/** Whether a finger is on a block right now — [settling] is not dragging. */
|
||||||
var isDragging: Boolean by mutableStateOf(false)
|
var isDragging: Boolean by mutableStateOf(false)
|
||||||
@@ -153,18 +291,42 @@ class TimelineDragController {
|
|||||||
var settledOnGrid: Boolean by mutableStateOf(false)
|
var settledOnGrid: Boolean by mutableStateOf(false)
|
||||||
private set
|
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
|
||||||
|
private set
|
||||||
|
|
||||||
private var source: TimedBlock? = null
|
private var source: TimedBlock? = null
|
||||||
private var grab = Offset.Zero
|
private var grab = Offset.Zero
|
||||||
private var pointer = Offset.Zero
|
private var pointer = Offset.Zero
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far the picked-up block's top edge sits past the event's own start —
|
||||||
|
* nonzero only for a block clipped at midnight, which is the tail of an
|
||||||
|
* event that began the day before (#253). The gesture tracks the block the
|
||||||
|
* finger is on; this is what turns where it lands back into an event start.
|
||||||
|
*/
|
||||||
|
private var clipOffsetMin = 0
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The slot the block already occupied when it was picked up — not the same
|
* The slot the block already occupied when it was picked up — not the same
|
||||||
* as its start, since the target snaps to the grid (09:07 lifts to 09:00).
|
* as its start, since the target snaps to the grid (09:07 lifts to 09:00).
|
||||||
*/
|
*/
|
||||||
private var originSlot: Pair<LocalDate, Int>? = null
|
private var originSlot: Pair<LocalDate, Int>? = null
|
||||||
|
|
||||||
fun begin(block: TimedBlock, pointerInRoot: Offset, blockInRoot: Offset) {
|
fun begin(
|
||||||
|
block: TimedBlock,
|
||||||
|
clipOffsetMin: Int,
|
||||||
|
pointerInRoot: Offset,
|
||||||
|
blockInRoot: Offset,
|
||||||
|
) {
|
||||||
source = block
|
source = block
|
||||||
|
this.clipOffsetMin = clipOffsetMin
|
||||||
settling = null
|
settling = null
|
||||||
isDragging = true
|
isDragging = true
|
||||||
liftedInstanceId = block.event.instanceId
|
liftedInstanceId = block.event.instanceId
|
||||||
@@ -182,6 +344,7 @@ class TimelineDragController {
|
|||||||
|
|
||||||
fun cancel() {
|
fun cancel() {
|
||||||
source = null
|
source = null
|
||||||
|
clipOffsetMin = 0
|
||||||
isDragging = false
|
isDragging = false
|
||||||
liftedInstanceId = null
|
liftedInstanceId = null
|
||||||
originSlot = null
|
originSlot = null
|
||||||
@@ -192,32 +355,41 @@ class TimelineDragController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether [block] is the one whose copy is in flight, and so must stay a
|
* Whether [block] is part of the event whose copy is in flight, and so must
|
||||||
* ghost: the instance the finger picked up, or once dropped whatever now
|
* stay a ghost: the instance the finger picked up, or once dropped whatever
|
||||||
* sits in the slot it left.
|
* the re-read now carries at the time it came from — both halves of it, for
|
||||||
|
* an event that crosses midnight (#253).
|
||||||
*/
|
*/
|
||||||
fun ghosts(block: TimedBlock, date: LocalDate): Boolean {
|
fun ghosts(block: TimedBlock): Boolean {
|
||||||
if (liftedInstanceId == null) return false
|
if (liftedInstanceId == null) return false
|
||||||
if (block.event.instanceId == liftedInstanceId) return true
|
if (block.event.instanceId == liftedInstanceId) return true
|
||||||
val (eventId, originDate, originMin) = settledOrigin ?: return false
|
val (eventId, start) = settledOrigin ?: return false
|
||||||
return block.event.eventId == eventId &&
|
return block.event.eventId == eventId && block.event.start == start
|
||||||
date == originDate &&
|
|
||||||
block.startMin == originMin
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What a day column now holds, so a settled drop can tell when the grid has
|
* 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
|
* caught up with it. Matched on the landing slot plus either the event row
|
||||||
* or its title, since a single-occurrence move writes a new `eventId`.
|
* or its title, since a single-occurrence move writes a new `eventId`. A
|
||||||
|
* blank title matches nothing: every untitled event has one (#253).
|
||||||
*/
|
*/
|
||||||
fun noteGrid(date: LocalDate, blocks: List<TimedBlock>) {
|
fun noteGrid(date: LocalDate, blocks: List<TimedBlock>) {
|
||||||
val landed = settling ?: return
|
val landed = settling ?: return
|
||||||
if (settledOnGrid || landed.date != date) return
|
if (settledOnGrid) return
|
||||||
|
// 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
|
||||||
settledOnGrid = blocks.any { block ->
|
settledOnGrid = blocks.any { block ->
|
||||||
block.startMin == landed.startMin &&
|
block.startMin == slice.startMin &&
|
||||||
(
|
(
|
||||||
block.event.eventId == landed.event.eventId ||
|
block.event.eventId == landed.event.eventId ||
|
||||||
block.event.title == landed.event.title
|
(
|
||||||
|
landed.event.title.isNotBlank() &&
|
||||||
|
block.event.title == landed.event.title
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -230,12 +402,18 @@ class TimelineDragController {
|
|||||||
fun finish(): TimelineDrop? {
|
fun finish(): TimelineDrop? {
|
||||||
val landed = drag
|
val landed = drag
|
||||||
val origin = originSlot
|
val origin = originSlot
|
||||||
|
val clipOffset = clipOffsetMin
|
||||||
cancel()
|
cancel()
|
||||||
if (landed == null || landed.slot == origin) return null
|
if (landed == null || landed.slot == origin) return null
|
||||||
settling = landed
|
// An edge piece is a hint for the finger, not a preview of the landing
|
||||||
|
// slot: the event belongs to a day this timeline has no column for, so
|
||||||
|
// [noteGrid] can never seat it and it would sit at the top of today for
|
||||||
|
// the whole settle timeout, long after the event moved off-screen.
|
||||||
|
settling = if (landed.edgeOnly) landed.copy(pieces = emptyList()) else landed
|
||||||
liftedInstanceId = landed.event.instanceId
|
liftedInstanceId = landed.event.instanceId
|
||||||
settledOrigin = origin?.let { (date, min) -> Triple(landed.event.eventId, date, min) }
|
settledOrigin = landed.event.eventId to landed.event.start
|
||||||
return TimelineDrop(landed.event, landed.date, landed.startMin)
|
settledAbandonedMark = abandonedTicks?.value ?: 0
|
||||||
|
return TimelineDrop(landed.event, landed.date, landed.startMin, clipOffset)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -271,28 +449,47 @@ class TimelineDragController {
|
|||||||
val origin = grid.positionInRoot()
|
val origin = grid.positionInRoot()
|
||||||
val rawMinutes = (pointer.y - grab.y - origin.y) / hourPx * 60f
|
val rawMinutes = (pointer.y - grab.y - origin.y) / hourPx * 60f
|
||||||
val snapped = (rawMinutes / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES
|
val snapped = (rawMinutes / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES
|
||||||
// Clamp the start into the target day; a tail past midnight is fine.
|
val eventSpan = (block.event.end - block.event.start).inWholeMinutes.toInt()
|
||||||
val startMin = snapped.coerceIn(0, MINUTES_PER_DAY - DRAG_SNAP_MINUTES)
|
.coerceAtLeast(0)
|
||||||
// The event's own length, not the block's: TimedBlock.endMin is clipped
|
val floor = dragFloorMin(clipOffsetMin, eventSpan)
|
||||||
// at midnight, which would draw a 22:00–02:00 event as a two-hour copy.
|
val startMin = snapped.coerceIn(floor, MINUTES_PER_DAY - DRAG_SNAP_MINUTES)
|
||||||
val span = (block.event.end - block.event.start).inWholeMinutes.toInt().coerceAtLeast(0)
|
val eventStartMin = startMin - clipOffsetMin
|
||||||
// The column the finger is over on screen, and the day that column shows.
|
|
||||||
val column = ((pointer.x - origin.x) / columnPx).toInt().coerceIn(0, days.lastIndex)
|
val column = ((pointer.x - origin.x) / columnPx).toInt().coerceIn(0, days.lastIndex)
|
||||||
val dayIndex = if (geometry.isRtl) days.lastIndex - column else column
|
val dayIndex = if (geometry.isRtl) days.lastIndex - column else column
|
||||||
val height = maxOf(span / 60f * hourPx, MIN_EVENT_FRACTION * hourPx)
|
val visible = -dayIndex..days.lastIndex - dayIndex
|
||||||
|
val onGrid = dragSlices(eventStartMin, eventSpan, within = visible)
|
||||||
|
val slices = onGrid.ifEmpty {
|
||||||
|
listOfNotNull(edgeDragSlice(eventStartMin, eventSpan, visible))
|
||||||
|
}
|
||||||
drag = TimelineDrag(
|
drag = TimelineDrag(
|
||||||
event = block.event,
|
event = block.event,
|
||||||
date = days[dayIndex],
|
date = days[dayIndex],
|
||||||
startMin = startMin,
|
startMin = startMin,
|
||||||
endMin = startMin + span,
|
eventStartMin = eventStartMin,
|
||||||
topLeftInRoot = Offset(
|
eventSpanMin = eventSpan,
|
||||||
x = origin.x + column * columnPx,
|
// Bounded to the columns this timeline actually shows: a day it
|
||||||
y = origin.y + startMin / 60f * hourPx,
|
// doesn't has no piece to draw. The write is unaffected.
|
||||||
),
|
pieces = slices
|
||||||
sizePx = IntSize(
|
.map { slice ->
|
||||||
(columnPx - geometry.columnGapPx).roundToInt(),
|
val day = dayIndex + slice.dayOffset
|
||||||
height.roundToInt(),
|
val col = if (geometry.isRtl) days.lastIndex - day else day
|
||||||
),
|
TimelineDragPiece(
|
||||||
|
topLeftInRoot = Offset(
|
||||||
|
x = origin.x + col * columnPx,
|
||||||
|
y = origin.y + slice.startMin / 60f * hourPx,
|
||||||
|
),
|
||||||
|
sizePx = IntSize(
|
||||||
|
(columnPx - geometry.columnGapPx).roundToInt(),
|
||||||
|
maxOf(
|
||||||
|
slice.spanMin / 60f * hourPx,
|
||||||
|
MIN_EVENT_FRACTION * hourPx,
|
||||||
|
).roundToInt(),
|
||||||
|
),
|
||||||
|
continuesBefore = slice.continuesBefore,
|
||||||
|
continuesAfter = slice.continuesAfter,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
edgeOnly = onGrid.isEmpty(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,24 +531,52 @@ fun rememberTimelineDragController(): TimelineDragController {
|
|||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
controller.geometry.edgePx = with(density) { AUTO_SCROLL_EDGE.toPx() }
|
controller.geometry.edgePx = with(density) { AUTO_SCROLL_EDGE.toPx() }
|
||||||
controller.geometry.stepPx = with(density) { AUTO_SCROLL_STEP.toPx() }
|
controller.geometry.stepPx = with(density) { AUTO_SCROLL_STEP.toPx() }
|
||||||
|
controller.abandonedTicks = LocalEventMove.current?.abandoned
|
||||||
return controller
|
return controller
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True when [block] actually begins on [day] rather than being the tail of an
|
* How far this block's top edge sits past its event's start, in minutes — zero
|
||||||
* event that started earlier. A clipped block's top edge is midnight, not the
|
* for a block that begins on [day], and the part already run for the tail of an
|
||||||
* event's start, so dragging it would move the event to a time it never had.
|
* event that started earlier, whose top edge is midnight rather than the event's
|
||||||
|
* start. Dragging such a tail moves the event by where *its own* top edge lands,
|
||||||
|
* so the offset has to come back off at the drop (#253).
|
||||||
*/
|
*/
|
||||||
fun TimedBlock.beginsOn(day: LocalDate, zone: TimeZone): Boolean =
|
fun TimedBlock.clipOffsetMinutes(day: LocalDate, zone: TimeZone): Int {
|
||||||
event.start.toLocalDateTime(zone).date == day
|
val start = event.start.toLocalDateTime(zone)
|
||||||
|
val days = (day.toEpochDays() - start.date.toEpochDays()).toInt()
|
||||||
|
val minutes = days * MINUTES_PER_DAY + startMin - start.time.toSecondOfDay() / 60
|
||||||
|
return minutes.coerceAtLeast(0)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The instant a drop asks for, read in the zone the timeline is drawn in. A drop
|
* True when the event began before [day] — this block is its tail, cut at
|
||||||
* into a spring-forward gap has no instant of its own; `toInstant` resolves it
|
* midnight rather than starting where the event does.
|
||||||
* forward by the missing hour, and the block visibly settles there.
|
|
||||||
*/
|
*/
|
||||||
fun TimelineDrop.startInstant(zone: TimeZone): Instant =
|
fun TimedBlock.continuesBefore(day: LocalDate, zone: TimeZone): Boolean =
|
||||||
LocalDateTime(date, LocalTime(startMin / 60, startMin % 60)).toInstant(zone)
|
event.start < day.atStartOfDayIn(zone)
|
||||||
|
|
||||||
|
/** True when the event runs past [day]'s midnight, so this block is cut there. */
|
||||||
|
fun TimedBlock.continuesAfter(day: LocalDate, zone: TimeZone): Boolean =
|
||||||
|
event.end > day.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The instant a drop asks for — the *event's* new start, so the tail of an
|
||||||
|
* overnight event drops back onto the day its own start belongs to. Read in the
|
||||||
|
* zone the timeline is drawn in. A drop into a spring-forward gap has no instant
|
||||||
|
* of its own; `toInstant` resolves it forward by the missing hour, and the block
|
||||||
|
* visibly settles there.
|
||||||
|
*/
|
||||||
|
fun TimelineDrop.startInstant(zone: TimeZone): Instant {
|
||||||
|
// Wall clock throughout, and past this day at either end: a block's top edge
|
||||||
|
// can be dragged above its midnight and a tail's below the next one, so the
|
||||||
|
// start is resolved as a day plus a minute of it rather than as an offset
|
||||||
|
// from an instant — which would shift by an hour across a DST boundary.
|
||||||
|
val fromMidnight = startMin - clipOffsetMin
|
||||||
|
val day = date.plus(fromMidnight.floorDiv(MINUTES_PER_DAY), DateTimeUnit.DAY)
|
||||||
|
val minute = fromMidnight.mod(MINUTES_PER_DAY)
|
||||||
|
return LocalDateTime(day, LocalTime(minute / 60, minute % 60)).toInstant(zone)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A beat after the grid draws the drop, so its own block is under way before the
|
* A beat after the grid draws the drop, so its own block is under way before the
|
||||||
@@ -384,7 +609,6 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
|
|||||||
val use24Hour = LocalUse24HourFormat.current
|
val use24Hour = LocalUse24HourFormat.current
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
val reduceMotion = rememberReduceMotion()
|
val reduceMotion = rememberReduceMotion()
|
||||||
val density = LocalDensity.current
|
|
||||||
val moveInFlight = moveInFlight()
|
val moveInFlight = moveInFlight()
|
||||||
|
|
||||||
// Both live here rather than beside the controller: they read [drag], which
|
// Both live here rather than beside the controller: they read [drag], which
|
||||||
@@ -397,9 +621,14 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
|
|||||||
// recurring drop's scope dialog is up — and then until the grid draws the
|
// recurring drop's scope dialog is up — and then until the grid draws the
|
||||||
// drop itself.
|
// drop itself.
|
||||||
var handingOver by remember(controller.settling) { mutableStateOf(false) }
|
var handingOver by remember(controller.settling) { mutableStateOf(false) }
|
||||||
LaunchedEffect(controller.settling, moveInFlight, controller.settledOnGrid) {
|
val abandoned = abandonedMoves()
|
||||||
|
LaunchedEffect(controller.settling, moveInFlight, controller.settledOnGrid, abandoned) {
|
||||||
if (controller.settling == null || moveInFlight) return@LaunchedEffect
|
if (controller.settling == null || moveInFlight) return@LaunchedEffect
|
||||||
delay(if (controller.settledOnGrid) SETTLE_GRACE_MILLIS else SETTLE_TIMEOUT_MILLIS)
|
// Past the mark this drop was let go on, it was abandoned: nothing will
|
||||||
|
// land and there is nothing to wait for.
|
||||||
|
if (abandoned == controller.settledAbandonedMark) {
|
||||||
|
delay(if (controller.settledOnGrid) SETTLE_GRACE_MILLIS else SETTLE_TIMEOUT_MILLIS)
|
||||||
|
}
|
||||||
handingOver = true
|
handingOver = true
|
||||||
controller.handOver()
|
controller.handOver()
|
||||||
delay(SETTLE_FADE_MILLIS.toLong())
|
delay(SETTLE_FADE_MILLIS.toLong())
|
||||||
@@ -428,49 +657,84 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
|
|||||||
)
|
)
|
||||||
val fill = eventFill(drag.event.color, dark, soften)
|
val fill = eventFill(drag.event.color, dark, soften)
|
||||||
val title = drag.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = drag.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
// An end past midnight wraps rather than saturating, so a four-hour
|
// Read off the event rather than the held block, so grabbing either half
|
||||||
// event dragged to 22:00 reads "22:00–02:00" and not "22:00–24:00".
|
// of an event that crosses midnight names the same hours. An end past
|
||||||
val endMin = if (drag.endMin > MINUTES_PER_DAY) {
|
// midnight wraps rather than saturating, so a four-hour event dragged to
|
||||||
drag.endMin % MINUTES_PER_DAY
|
// 22:00 reads "22:00–02:00" and not "22:00–24:00".
|
||||||
} else {
|
val startMin = drag.eventStartMin.mod(MINUTES_PER_DAY)
|
||||||
drag.endMin
|
val rawEnd = startMin + drag.eventSpanMin
|
||||||
}
|
val endMin = if (rawEnd > MINUTES_PER_DAY) rawEnd % MINUTES_PER_DAY else rawEnd
|
||||||
val label = "${formatMinuteOfDay(drag.startMin, use24Hour, locale)}–" +
|
val label = "${formatMinuteOfDay(startMin, use24Hour, locale)}–" +
|
||||||
formatMinuteOfDay(endMin, use24Hour, locale)
|
formatMinuteOfDay(endMin, use24Hour, locale)
|
||||||
Box(
|
// The tallest piece carries the range: on the smallest it would be
|
||||||
modifier = Modifier
|
// clipped away, which is exactly the case when a short tail is held.
|
||||||
// Absolute: these are root coordinates, and the direction-aware
|
val labelled = drag.pieces.indices.maxByOrNull { drag.pieces[it].sizePx.height }
|
||||||
// offset would mirror them across the screen in an RTL layout.
|
drag.pieces.forEachIndexed { index, piece ->
|
||||||
.absoluteOffset {
|
DragCopy(
|
||||||
IntOffset(
|
topLeftInRoot = piece.topLeftInRoot,
|
||||||
(drag.topLeftInRoot.x - origin.x).roundToInt(),
|
overlayOrigin = origin,
|
||||||
(drag.topLeftInRoot.y - origin.y).roundToInt(),
|
sizePx = piece.sizePx,
|
||||||
)
|
fill = fill,
|
||||||
}
|
shape = timedBlockShape(piece.continuesBefore, piece.continuesAfter),
|
||||||
.size(
|
lift = lift,
|
||||||
width = with(density) { drag.sizePx.width.toDp() },
|
alpha = copyAlpha,
|
||||||
height = with(density) { drag.sizePx.height.toDp() },
|
title = title,
|
||||||
)
|
// Once for the whole event: repeated, it would name it per day.
|
||||||
.padding(horizontal = 1.dp)
|
label = label.takeIf { index == labelled },
|
||||||
.graphicsLayer {
|
)
|
||||||
scaleX = 1f + 0.02f * lift
|
}
|
||||||
scaleY = 1f + 0.02f * lift
|
}
|
||||||
shadowElevation = 8.dp.toPx() * lift
|
}
|
||||||
alpha = copyAlpha
|
|
||||||
shape = RoundedCornerShape(4.dp)
|
/** One piece of a dragged block, floating over the calendar. */
|
||||||
clip = false
|
@Composable
|
||||||
}
|
private fun DragCopy(
|
||||||
.background(fill, RoundedCornerShape(4.dp))
|
topLeftInRoot: Offset,
|
||||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
overlayOrigin: Offset,
|
||||||
) {
|
sizePx: IntSize,
|
||||||
Column {
|
fill: Color,
|
||||||
Text(
|
shape: RoundedCornerShape,
|
||||||
text = title,
|
lift: Float,
|
||||||
style = MaterialTheme.typography.labelMedium,
|
alpha: Float,
|
||||||
maxLines = 1,
|
title: String,
|
||||||
overflow = TextOverflow.Ellipsis,
|
label: String?,
|
||||||
color = eventInk(fill, alpha = 0.85f),
|
) {
|
||||||
|
val density = LocalDensity.current
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
// Absolute: these are root coordinates, and the direction-aware
|
||||||
|
// offset would mirror them across the screen in an RTL layout.
|
||||||
|
.absoluteOffset {
|
||||||
|
IntOffset(
|
||||||
|
(topLeftInRoot.x - overlayOrigin.x).roundToInt(),
|
||||||
|
(topLeftInRoot.y - overlayOrigin.y).roundToInt(),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
.size(
|
||||||
|
width = with(density) { sizePx.width.toDp() },
|
||||||
|
height = with(density) { sizePx.height.toDp() },
|
||||||
|
)
|
||||||
|
.padding(horizontal = 1.dp)
|
||||||
|
.graphicsLayer {
|
||||||
|
scaleX = 1f + 0.02f * lift
|
||||||
|
scaleY = 1f + 0.02f * lift
|
||||||
|
shadowElevation = 8.dp.toPx() * lift
|
||||||
|
this.alpha = alpha
|
||||||
|
this.shape = shape
|
||||||
|
clip = false
|
||||||
|
}
|
||||||
|
.background(fill, shape)
|
||||||
|
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
color = eventInk(fill, alpha = 0.85f),
|
||||||
|
)
|
||||||
|
if (label != null) {
|
||||||
Text(
|
Text(
|
||||||
text = label,
|
text = label,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
|||||||
@@ -91,7 +91,10 @@ import de.jeanlucmakiola.calendula.ui.common.NowLine
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
|
import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay
|
import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
|
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
|
||||||
import de.jeanlucmakiola.calendula.ui.common.beginsOn
|
import de.jeanlucmakiola.calendula.ui.common.clipOffsetMinutes
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.continuesAfter
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.continuesBefore
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.timedBlockShape
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed
|
import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
|
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
|
import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
|
||||||
@@ -104,6 +107,7 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe
|
|||||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||||
import de.jeanlucmakiola.calendula.ui.common.declinedDecoration
|
import de.jeanlucmakiola.calendula.ui.common.declinedDecoration
|
||||||
@@ -351,7 +355,7 @@ private fun DayContent(
|
|||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
onDrop = { drop ->
|
onDrop = { drop ->
|
||||||
val took = move?.move(
|
move?.move(
|
||||||
MoveRequest(
|
MoveRequest(
|
||||||
eventId = drop.event.eventId,
|
eventId = drop.event.eventId,
|
||||||
beginMillis = drop.event.start.toEpochMilliseconds(),
|
beginMillis = drop.event.start.toEpochMilliseconds(),
|
||||||
@@ -359,9 +363,6 @@ private fun DayContent(
|
|||||||
target = MoveTarget.Start(drop.startInstant(zone)),
|
target = MoveTarget.Start(drop.startInstant(zone)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
// Refused, so nothing will land: let the copy go now
|
|
||||||
// rather than hold it out for a settle that never comes.
|
|
||||||
if (took != true) dragController.release()
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -519,7 +520,7 @@ private fun AllDayBar(
|
|||||||
val fill = eventFill(event.color, dark, soften)
|
val fill = eventFill(event.color, dark, soften)
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.background(fill, RoundedCornerShape(4.dp))
|
.background(fill, EventChipShape)
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||||
.semantics { contentDescription = title },
|
.semantics { contentDescription = title },
|
||||||
@@ -744,24 +745,30 @@ private fun EventBlock(
|
|||||||
val fill = eventFill(block.event.color, dark, soften)
|
val fill = eventFill(block.event.color, dark, soften)
|
||||||
val zone = remember { TimeZone.currentSystemDefault() }
|
val zone = remember { TimeZone.currentSystemDefault() }
|
||||||
val moveAction = eventMoveAction(block.event)
|
val moveAction = eventMoveAction(block.event)
|
||||||
// A block clipped at the top continues from the previous day: its top edge
|
val draggable = eventDragAllowed(block.event)
|
||||||
// is midnight, not the event's start, so dragging it would invent a time.
|
// The drop takes this offset back off, so a tail clipped at midnight lands
|
||||||
val draggable = eventDragAllowed(block.event) && block.beginsOn(date, zone)
|
// where the event's own start belongs (#253).
|
||||||
|
val clipOffset = remember(block, date, zone) { block.clipOffsetMinutes(date, zone) }
|
||||||
|
val shape = remember(block, date, zone) {
|
||||||
|
timedBlockShape(block.continuesBefore(date, zone), block.continuesAfter(date, zone))
|
||||||
|
}
|
||||||
val dragModifier = rememberEventDragSource(
|
val dragModifier = rememberEventDragSource(
|
||||||
enabled = draggable,
|
enabled = draggable,
|
||||||
key = block.event.instanceId,
|
key = block.event.instanceId,
|
||||||
onPickUp = { pointer, blockRoot, _ -> dragController.begin(block, pointer, blockRoot) },
|
onPickUp = { pointer, blockRoot, _ ->
|
||||||
|
dragController.begin(block, clipOffset, pointer, blockRoot)
|
||||||
|
},
|
||||||
onMove = dragController::move,
|
onMove = dragController::move,
|
||||||
onDrop = { dragController.finish()?.let(onDrop) },
|
onDrop = { dragController.finish()?.let(onDrop) },
|
||||||
onCancel = dragController::cancel,
|
onCancel = dragController::cancel,
|
||||||
)
|
)
|
||||||
val lifted = draggable && dragController.ghosts(block, date)
|
val lifted = draggable && dragController.ghosts(block)
|
||||||
val ghost = ghostAlpha(lifted)
|
val ghost = ghostAlpha(lifted)
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
// The source stays put as a ghost while its floating copy travels.
|
// The source stays put as a ghost while its floating copy travels.
|
||||||
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
|
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
|
||||||
.background(fill, RoundedCornerShape(4.dp))
|
.background(fill, shape)
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
// After clickable, so it is the inner node and wins the main pass;
|
// After clickable, so it is the inner node and wins the main pass;
|
||||||
// the tap still works, since a drag consumes the up.
|
// the tap still works, since a drag consumes the up.
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ import androidx.compose.material3.Scaffold
|
|||||||
import androidx.compose.material3.SegmentedButton
|
import androidx.compose.material3.SegmentedButton
|
||||||
import androidx.compose.material3.SegmentedButtonDefaults
|
import androidx.compose.material3.SegmentedButtonDefaults
|
||||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||||
|
import androidx.compose.material3.SnackbarDuration
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
@@ -264,13 +265,16 @@ fun EventEditScreen(
|
|||||||
viewModel.reset()
|
viewModel.reset()
|
||||||
onSaved()
|
onSaved()
|
||||||
}
|
}
|
||||||
|
// A failed save leaves the form looking unchanged, so the snackbar is
|
||||||
|
// the only sign anything happened — long rather than the default
|
||||||
|
// flash (Codeberg #234: it read as "nothing happens at all").
|
||||||
SaveUiState.Failed -> {
|
SaveUiState.Failed -> {
|
||||||
viewModel.consumeSaveResult()
|
viewModel.consumeSaveResult()
|
||||||
snackbarHostState.showSnackbar(saveFailedMessage)
|
snackbarHostState.showSnackbar(saveFailedMessage, duration = SnackbarDuration.Long)
|
||||||
}
|
}
|
||||||
SaveUiState.NeedsPermission -> {
|
SaveUiState.NeedsPermission -> {
|
||||||
viewModel.consumeSaveResult()
|
viewModel.consumeSaveResult()
|
||||||
snackbarHostState.showSnackbar(writeDeniedMessage)
|
snackbarHostState.showSnackbar(writeDeniedMessage, duration = SnackbarDuration.Long)
|
||||||
}
|
}
|
||||||
// AwaitingScope/AwaitingConflict/Gone render as dialogs below.
|
// AwaitingScope/AwaitingConflict/Gone render as dialogs below.
|
||||||
else -> Unit
|
else -> Unit
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.edit
|
package de.jeanlucmakiola.calendula.ui.edit
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
@@ -56,6 +57,8 @@ import kotlin.time.Duration.Companion.minutes
|
|||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
private const val TAG = "EventEdit"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where a prefilled [EventEditViewModel.openImported] form came from. The sources
|
* Where a prefilled [EventEditViewModel.openImported] form came from. The sources
|
||||||
* want different reminder handling (#49), and differ in whether they own the
|
* want different reminder handling (#49), and differ in whether they own the
|
||||||
@@ -734,7 +737,12 @@ class EventEditViewModel @Inject constructor(
|
|||||||
} else {
|
} else {
|
||||||
when (scope) {
|
when (scope) {
|
||||||
RecurringWriteScope.ThisEvent ->
|
RecurringWriteScope.ThisEvent ->
|
||||||
repository.updateOccurrence(target.eventId, target.beginMillis, form)
|
repository.updateOccurrence(
|
||||||
|
target.eventId,
|
||||||
|
target.beginMillis,
|
||||||
|
target.original,
|
||||||
|
form,
|
||||||
|
)
|
||||||
RecurringWriteScope.ThisAndFollowing ->
|
RecurringWriteScope.ThisAndFollowing ->
|
||||||
repository.updateEventFromOccurrence(
|
repository.updateEventFromOccurrence(
|
||||||
eventId = target.eventId,
|
eventId = target.eventId,
|
||||||
@@ -751,7 +759,15 @@ class EventEditViewModel @Inject constructor(
|
|||||||
throw e
|
throw e
|
||||||
} catch (e: SecurityException) {
|
} catch (e: SecurityException) {
|
||||||
SaveUiState.NeedsPermission
|
SaveUiState.NeedsPermission
|
||||||
|
} catch (e: NoSuchEventException) {
|
||||||
|
// The event or occurrence is already gone: the same answer the
|
||||||
|
// pre-check gives, and better than a bare "couldn't save".
|
||||||
|
SaveUiState.Gone
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
// The user only gets a generic snackbar, so without this a failed
|
||||||
|
// write leaves nothing to report (Codeberg #234). Scope and event
|
||||||
|
// id only — never the form's content.
|
||||||
|
Log.w(TAG, "Save failed (scope=$scope, eventId=${target?.eventId})", e)
|
||||||
SaveUiState.Failed
|
SaveUiState.Failed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import androidx.compose.foundation.verticalScroll
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.PriorityHigh
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@@ -93,15 +94,11 @@ fun ImportScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Hoisted target calendar so the always-visible top-bar Import action can
|
// Hoisted target calendar so the always-visible top-bar Import action can
|
||||||
// read it without the user scrolling to a bottom button. Defaults to the
|
// read it without the user scrolling to a bottom button (see
|
||||||
// first *local* calendar — the first row the picker shows ("Your calendars"
|
// [defaultImportTarget] for the choice). Re-defaults when the "many" list
|
||||||
// group leads) — so the pre-selection lines up with the top of the list;
|
// first arrives (keyed on it), then holds the pick.
|
||||||
// falls back to the first calendar if there are no local ones. Re-defaults
|
|
||||||
// when the "many" list first arrives (keyed on it), then holds the pick.
|
|
||||||
val many = state as? ImportUiState.Many
|
val many = state as? ImportUiState.Many
|
||||||
val defaultTarget = many?.calendars?.let { cals ->
|
val defaultTarget = many?.let { defaultImportTarget(it.calendars, it.fileCalendarName) }
|
||||||
(cals.firstOrNull { it.isLocal } ?: cals.firstOrNull())?.id
|
|
||||||
}
|
|
||||||
var selected by rememberSaveable(defaultTarget) { mutableStateOf(defaultTarget) }
|
var selected by rememberSaveable(defaultTarget) { mutableStateOf(defaultTarget) }
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
@@ -223,6 +220,10 @@ private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Part of the file never reached the calendar, so the screen doesn't get to
|
||||||
|
// read as a plain success.
|
||||||
|
val stopped = state.summary.notAttempted > 0
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxSize().padding(24.dp),
|
Modifier.fillMaxSize().padding(24.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
@@ -236,22 +237,43 @@ private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
|
|||||||
scaleY = badgeScale.value
|
scaleY = badgeScale.value
|
||||||
}
|
}
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
.background(
|
||||||
|
if (stopped) {
|
||||||
|
MaterialTheme.colorScheme.errorContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.primaryContainer
|
||||||
|
},
|
||||||
|
),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Check,
|
if (stopped) Icons.Default.PriorityHigh else Icons.Default.Check,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
tint = if (stopped) {
|
||||||
|
MaterialTheme.colorScheme.onErrorContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
},
|
||||||
modifier = Modifier.size(56.dp),
|
modifier = Modifier.size(56.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.import_done_title),
|
stringResource(
|
||||||
|
if (stopped) R.string.import_done_stopped_title else R.string.import_done_title,
|
||||||
|
),
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
|
if (state.summary.failed > 0) {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.import_done_failed_note),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
if (state.summary.skippedDuplicate > 0) {
|
if (state.summary.skippedDuplicate > 0) {
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
@@ -261,6 +283,21 @@ private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
|
|||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// The one note that isn't about individual events: the counts below cover
|
||||||
|
// the part of the file that was tried, so say how much never was.
|
||||||
|
if (stopped) {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
pluralStringResource(
|
||||||
|
R.plurals.import_done_stopped,
|
||||||
|
state.summary.notAttempted,
|
||||||
|
state.summary.notAttempted,
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth(),
|
Modifier.fillMaxWidth(),
|
||||||
@@ -290,6 +327,19 @@ private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
|
|||||||
onContainer = MaterialTheme.colorScheme.onSurfaceVariant,
|
onContainer = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (state.summary.failed > 0) {
|
||||||
|
ImportStatCard(
|
||||||
|
count = state.summary.failed,
|
||||||
|
label = stringResource(R.string.import_done_failed_label),
|
||||||
|
contentDescription = pluralStringResource(
|
||||||
|
R.plurals.import_done_failed,
|
||||||
|
state.summary.failed,
|
||||||
|
state.summary.failed,
|
||||||
|
),
|
||||||
|
container = MaterialTheme.colorScheme.errorContainer,
|
||||||
|
onContainer = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
Button(
|
Button(
|
||||||
@@ -301,7 +351,7 @@ private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A big-number tonal tile summarising one import outcome (added / skipped). */
|
/** A big-number tonal tile summarising one import outcome (added / skipped / failed). */
|
||||||
@Composable
|
@Composable
|
||||||
private fun RowScope.ImportStatCard(
|
private fun RowScope.ImportStatCard(
|
||||||
count: Int,
|
count: Int,
|
||||||
@@ -343,6 +393,9 @@ private fun WarningText(warning: IcsParseWarning) {
|
|||||||
IcsParseWarning.EventWithoutStartSkipped -> stringResource(R.string.import_warning_no_start)
|
IcsParseWarning.EventWithoutStartSkipped -> stringResource(R.string.import_warning_no_start)
|
||||||
IcsParseWarning.AttendeesIgnored -> stringResource(R.string.import_warning_attendees)
|
IcsParseWarning.AttendeesIgnored -> stringResource(R.string.import_warning_attendees)
|
||||||
IcsParseWarning.UnknownTimezone -> stringResource(R.string.import_warning_timezone)
|
IcsParseWarning.UnknownTimezone -> stringResource(R.string.import_warning_timezone)
|
||||||
|
IcsParseWarning.TasksImportedAsEvents -> stringResource(R.string.import_warning_tasks)
|
||||||
|
IcsParseWarning.RecurrenceRuleRepaired ->
|
||||||
|
stringResource(R.string.import_warning_recurrence_repaired)
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
text = text,
|
text = text,
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ sealed interface ImportUiState {
|
|||||||
val events: List<ParsedIcsEvent>,
|
val events: List<ParsedIcsEvent>,
|
||||||
val warnings: Set<IcsParseWarning>,
|
val warnings: Set<IcsParseWarning>,
|
||||||
val calendars: List<CalendarSource>,
|
val calendars: List<CalendarSource>,
|
||||||
|
/**
|
||||||
|
* The calendar the file says its events came from, when every one of
|
||||||
|
* them names the same — used to preselect a matching target (see
|
||||||
|
* [fileCalendarName]).
|
||||||
|
*/
|
||||||
|
val fileCalendarName: String? = null,
|
||||||
) : ImportUiState
|
) : ImportUiState
|
||||||
|
|
||||||
data class Done(val summary: IcsImportSummary) : ImportUiState
|
data class Done(val summary: IcsImportSummary) : ImportUiState
|
||||||
@@ -92,6 +98,7 @@ class ImportViewModel @Inject constructor(
|
|||||||
warnings = parsed.warnings,
|
warnings = parsed.warnings,
|
||||||
calendars = repository.calendars().first()
|
calendars = repository.calendars().first()
|
||||||
.filter { it.isEventTarget },
|
.filter { it.isEventTarget },
|
||||||
|
fileCalendarName = fileCalendarName(parsed.events),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -113,3 +120,33 @@ class ImportViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one calendar [events] all came from, or null.
|
||||||
|
*
|
||||||
|
* Every event has to agree, unnamed ones included: a foreign file where a single
|
||||||
|
* `VEVENT` carries a `CATEGORIES` tag and the rest carry nothing names no
|
||||||
|
* calendar — it just has one tagged event, and letting it pick the target would
|
||||||
|
* land the whole file wherever that tag happens to match.
|
||||||
|
*/
|
||||||
|
internal fun fileCalendarName(events: List<ParsedIcsEvent>): String? =
|
||||||
|
events.map { it.calendarName }.distinct().singleOrNull()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The target calendar to preselect for a bulk import.
|
||||||
|
*
|
||||||
|
* A calendar named like the one the file came from wins — restoring "Birthdays"
|
||||||
|
* onto the Birthdays calendar is what the user means. Otherwise the first
|
||||||
|
* *local* calendar, which is the first row the picker shows ("Your calendars"
|
||||||
|
* leads), so the pre-selection lines up with the top of the list; failing that
|
||||||
|
* the first calendar of any kind.
|
||||||
|
*/
|
||||||
|
internal fun defaultImportTarget(
|
||||||
|
calendars: List<CalendarSource>,
|
||||||
|
fileCalendarName: String?,
|
||||||
|
): Long? {
|
||||||
|
val named = fileCalendarName?.let { name ->
|
||||||
|
calendars.firstOrNull { it.displayName.equals(name, ignoreCase = true) }
|
||||||
|
}
|
||||||
|
return (named ?: calendars.firstOrNull { it.isLocal } ?: calendars.firstOrNull())?.id
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import androidx.compose.ui.layout.boundsInRoot
|
|||||||
import androidx.compose.ui.layout.positionInRoot
|
import androidx.compose.ui.layout.positionInRoot
|
||||||
import androidx.compose.ui.unit.IntSize
|
import androidx.compose.ui.unit.IntSize
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
|
||||||
@@ -33,6 +35,8 @@ class MonthRowGeometry(
|
|||||||
val isRtl: Boolean,
|
val isRtl: Boolean,
|
||||||
/** What this row currently draws in [lane] of column `col`, or null. */
|
/** What this row currently draws in [lane] of column `col`, or null. */
|
||||||
val chipAt: (col: Int, lane: Int) -> EventInstance?,
|
val chipAt: (col: Int, lane: Int) -> EventInstance?,
|
||||||
|
/** The column the chip in [lane] of column `col` is drawn from. */
|
||||||
|
val chipStart: (col: Int, lane: Int) -> Int,
|
||||||
) {
|
) {
|
||||||
/** Root top-left of the chip seated in [lane] of column [col]. */
|
/** Root top-left of the chip seated in [lane] of column [col]. */
|
||||||
fun seat(col: Int, lane: Int): Offset? {
|
fun seat(col: Int, lane: Int): Offset? {
|
||||||
@@ -47,21 +51,43 @@ class MonthRowGeometry(
|
|||||||
* the day isn't in this week, or the chip went into the day's "+N" overflow.
|
* the day isn't in this week, or the chip went into the day's "+N" overflow.
|
||||||
*/
|
*/
|
||||||
fun seatOf(event: EventInstance, date: LocalDate): Offset? {
|
fun seatOf(event: EventInstance, date: LocalDate): Offset? {
|
||||||
val col = days.indexOf(date).takeIf { it >= 0 } ?: return null
|
val (col, lane) = chipSeat(days, laneCount, chipAt, chipStart, event, date) ?: return null
|
||||||
val lane = (0 until laneCount).firstOrNull { lane ->
|
|
||||||
chipAt(col, lane)?.let { isSameEvent(it, event) } == true
|
|
||||||
} ?: return null
|
|
||||||
return seat(col, lane)
|
return seat(col, lane)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The column and lane a row draws [event]'s chip in, given it covers [date] —
|
||||||
|
* null when the row seats no such chip. The column is the *left end* of the bar
|
||||||
|
* within this row, not [date]'s own: a bar spanning several days is drawn once,
|
||||||
|
* from its first column, so an event dropped onto the second day it covers is
|
||||||
|
* seated a column (or more) to the left of where it landed (#253).
|
||||||
|
*/
|
||||||
|
internal fun chipSeat(
|
||||||
|
days: List<LocalDate>,
|
||||||
|
laneCount: Int,
|
||||||
|
chipAt: (col: Int, lane: Int) -> EventInstance?,
|
||||||
|
chipStart: (col: Int, lane: Int) -> Int,
|
||||||
|
event: EventInstance,
|
||||||
|
date: LocalDate,
|
||||||
|
): Pair<Int, Int>? {
|
||||||
|
val col = days.indexOf(date).takeIf { it >= 0 } ?: return null
|
||||||
|
val lane = (0 until laneCount).firstOrNull { lane ->
|
||||||
|
chipAt(col, lane)?.let { isSameEvent(it, event) } == true
|
||||||
|
} ?: return null
|
||||||
|
return chipStart(col, lane) to lane
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether [chip] is the moved event [moved] as the grid now holds it. Neither id
|
* Whether [chip] is the moved event [moved] as the grid now holds it. Neither id
|
||||||
* alone will do: re-read instances get new instance ids, and a single-occurrence
|
* alone will do: re-read instances get new instance ids, and a single-occurrence
|
||||||
* move writes a new event id — the title survives both.
|
* move writes a new event id — the title survives both. A *blank* title is no
|
||||||
|
* evidence at all, though: every untitled event carries one, so matching on it
|
||||||
|
* made any two of them the same event (#253).
|
||||||
*/
|
*/
|
||||||
private fun isSameEvent(chip: EventInstance, moved: EventInstance): Boolean =
|
internal fun isSameEvent(chip: EventInstance, moved: EventInstance): Boolean =
|
||||||
chip.eventId == moved.eventId || chip.title == moved.title
|
chip.eventId == moved.eventId ||
|
||||||
|
(moved.title.isNotBlank() && chip.title == moved.title)
|
||||||
|
|
||||||
/** A chip in flight, in root coordinates so it can be drawn in an overlay. */
|
/** A chip in flight, in root coordinates so it can be drawn in an overlay. */
|
||||||
data class MonthChipDrag(
|
data class MonthChipDrag(
|
||||||
@@ -134,6 +160,16 @@ class MonthDragController {
|
|||||||
*/
|
*/
|
||||||
private var undoable: MonthChipDrag? = null
|
private var undoable: MonthChipDrag? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 chip was let go. */
|
||||||
|
var settledAbandonedMark: Int = 0
|
||||||
|
private set
|
||||||
|
|
||||||
private var event: EventInstance? = null
|
private var event: EventInstance? = null
|
||||||
private var grabDate: LocalDate? = null
|
private var grabDate: LocalDate? = null
|
||||||
private var grab = Offset.Zero
|
private var grab = Offset.Zero
|
||||||
@@ -222,6 +258,7 @@ class MonthDragController {
|
|||||||
liftedInstanceId = last.event.instanceId
|
liftedInstanceId = last.event.instanceId
|
||||||
settledGhost = true
|
settledGhost = true
|
||||||
settledInRoot = null
|
settledInRoot = null
|
||||||
|
settledAbandonedMark = abandonedTicks?.value ?: 0
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,6 +279,7 @@ class MonthDragController {
|
|||||||
liftedInstanceId = landed.event.instanceId
|
liftedInstanceId = landed.event.instanceId
|
||||||
settledGhost = true
|
settledGhost = true
|
||||||
undoable = settling
|
undoable = settling
|
||||||
|
settledAbandonedMark = abandonedTicks?.value ?: 0
|
||||||
return MonthChipDrop(landed.event, landed.grabDate, target)
|
return MonthChipDrop(landed.event, landed.grabDate, target)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,6 +336,10 @@ class MonthDragController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun rememberMonthDragController(): MonthDragController = remember { MonthDragController() }
|
fun rememberMonthDragController(): MonthDragController {
|
||||||
|
val controller = remember { MonthDragController() }
|
||||||
|
controller.abandonedTicks = LocalEventMove.current?.abandoned
|
||||||
|
return controller
|
||||||
|
}
|
||||||
|
|
||||||
val LocalMonthDrag = compositionLocalOf<MonthDragController?> { null }
|
val LocalMonthDrag = compositionLocalOf<MonthDragController?> { null }
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ import de.jeanlucmakiola.calendula.ui.common.MoveTarget
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.MoveRequest
|
import de.jeanlucmakiola.calendula.ui.common.MoveRequest
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||||
import de.jeanlucmakiola.calendula.ui.common.moveInFlight
|
import de.jeanlucmakiola.calendula.ui.common.moveInFlight
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.abandonedMoves
|
||||||
import de.jeanlucmakiola.calendula.ui.common.OnUndoStarted
|
import de.jeanlucmakiola.calendula.ui.common.OnUndoStarted
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
||||||
import de.jeanlucmakiola.calendula.ui.common.DragSnapHaptics
|
import de.jeanlucmakiola.calendula.ui.common.DragSnapHaptics
|
||||||
@@ -145,6 +146,8 @@ import de.jeanlucmakiola.calendula.ui.common.declinedDecoration
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.monthBarShape
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||||
@@ -505,11 +508,14 @@ private fun MonthDragOverlay(controller: MonthDragController) {
|
|||||||
val glide = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
|
val glide = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
|
||||||
val glideSpec = MaterialTheme.motionScheme.fastSpatialSpec<Offset>()
|
val glideSpec = MaterialTheme.motionScheme.fastSpatialSpec<Offset>()
|
||||||
val seat = controller.settledInRoot
|
val seat = controller.settledInRoot
|
||||||
LaunchedEffect(controller.settling, moveInFlight, seat) {
|
val abandoned = abandonedMoves()
|
||||||
|
LaunchedEffect(controller.settling, moveInFlight, seat, abandoned) {
|
||||||
val landed = controller.settling
|
val landed = controller.settling
|
||||||
if (landed == null || moveInFlight) return@LaunchedEffect
|
if (landed == null || moveInFlight) return@LaunchedEffect
|
||||||
if (seat == null) {
|
if (seat == null) {
|
||||||
delay(MONTH_SETTLE_TIMEOUT_MILLIS)
|
// Past the mark this drop was let go on, it was abandoned and no
|
||||||
|
// seat is ever coming.
|
||||||
|
if (abandoned == controller.settledAbandonedMark) delay(MONTH_SETTLE_TIMEOUT_MILLIS)
|
||||||
} else {
|
} else {
|
||||||
if (!gliding) glide.snapTo(landed.topLeftInRoot)
|
if (!gliding) glide.snapTo(landed.topLeftInRoot)
|
||||||
gliding = true
|
gliding = true
|
||||||
@@ -563,7 +569,7 @@ private fun MonthDragOverlay(controller: MonthDragController) {
|
|||||||
scaleY = 1f + 0.04f * lift
|
scaleY = 1f + 0.04f * lift
|
||||||
shadowElevation = 8.dp.toPx() * lift
|
shadowElevation = 8.dp.toPx() * lift
|
||||||
alpha = copyAlpha
|
alpha = copyAlpha
|
||||||
shape = RoundedCornerShape(4.dp)
|
shape = EventChipShape
|
||||||
clip = false
|
clip = false
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -1919,6 +1925,7 @@ private fun MonthWeekRow(
|
|||||||
laneCount = MAX_EVENT_ROWS,
|
laneCount = MAX_EVENT_ROWS,
|
||||||
isRtl = isRtl,
|
isRtl = isRtl,
|
||||||
chipAt = { col, lane -> week.chipAt(col, lane, MAX_EVENT_ROWS) },
|
chipAt = { col, lane -> week.chipAt(col, lane, MAX_EVENT_ROWS) },
|
||||||
|
chipStart = { col, lane -> week.chipStartCol(col, lane) },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2310,7 +2317,7 @@ private fun monthChipDragModifier(
|
|||||||
// How many columns the finger crossed — grabbing the middle of a
|
// How many columns the finger crossed — grabbing the middle of a
|
||||||
// multi-day bar shifts by what it travelled, not to where it landed.
|
// multi-day bar shifts by what it travelled, not to where it landed.
|
||||||
val delta = (drop.targetDate.toEpochDays() - drop.grabDate.toEpochDays()).toInt()
|
val delta = (drop.targetDate.toEpochDays() - drop.grabDate.toEpochDays()).toInt()
|
||||||
val took = moveScope?.move(
|
moveScope?.move(
|
||||||
MoveRequest(
|
MoveRequest(
|
||||||
eventId = drop.event.eventId,
|
eventId = drop.event.eventId,
|
||||||
beginMillis = drop.event.start.toEpochMilliseconds(),
|
beginMillis = drop.event.start.toEpochMilliseconds(),
|
||||||
@@ -2318,9 +2325,6 @@ private fun monthChipDragModifier(
|
|||||||
target = MoveTarget.ByDays(delta),
|
target = MoveTarget.ByDays(delta),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
// Refused, so nothing will land: let the copy go now rather than
|
|
||||||
// hold the chip out for a settle that can never arrive.
|
|
||||||
if (took != true) controller?.release()
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onCancel = { controller?.cancel() },
|
onCancel = { controller?.cancel() },
|
||||||
@@ -2436,12 +2440,7 @@ private fun MonthBar(
|
|||||||
val lifted = monthDrag?.liftedInstanceId == event.instanceId ||
|
val lifted = monthDrag?.liftedInstanceId == event.instanceId ||
|
||||||
monthDrag?.isSettledChip(event, days) == true
|
monthDrag?.isSettledChip(event, days) == true
|
||||||
val ghost = ghostAlpha(lifted)
|
val ghost = ghostAlpha(lifted)
|
||||||
val shape = RoundedCornerShape(
|
val shape = monthBarShape(continuesLeft, continuesRight)
|
||||||
topStart = if (continuesLeft) 0.dp else 4.dp,
|
|
||||||
bottomStart = if (continuesLeft) 0.dp else 4.dp,
|
|
||||||
topEnd = if (continuesRight) 0.dp else 4.dp,
|
|
||||||
bottomEnd = if (continuesRight) 0.dp else 4.dp,
|
|
||||||
)
|
|
||||||
Box(
|
Box(
|
||||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||||
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
|
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
|
||||||
|
|||||||
@@ -84,6 +84,16 @@ fun MonthWeek.chipAt(col: Int, lane: Int, laneCap: Int): EventInstance? {
|
|||||||
return timedByDay[days[col]].orEmpty().take(free.size).getOrNull(index)
|
return timedByDay[days[col]].orEmpty().take(free.size).getOrNull(index)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The column the chip drawn in lane [lane] of column [col] begins in. A bar is
|
||||||
|
* drawn once, from its own first column, so a chip seated by [chipAt] on the
|
||||||
|
* second day it covers belongs a column (or more) to the left (#253). Answers
|
||||||
|
* [col] for a single-day chip, and for an empty slot no one should be asking
|
||||||
|
* about.
|
||||||
|
*/
|
||||||
|
fun MonthWeek.chipStartCol(col: Int, lane: Int): Int =
|
||||||
|
spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }?.startCol ?: col
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The events on [day] that [laneEvents] had no lane left for — its exact
|
* The events on [day] that [laneEvents] had no lane left for — its exact
|
||||||
* complement, in the same bars-then-pills order. Returned as events rather than
|
* complement, in the same bars-then-pills order. Returned as events rather than
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
|||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
import de.jeanlucmakiola.calendula.ui.week.coversDay
|
import de.jeanlucmakiola.calendula.ui.week.coversDay
|
||||||
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
|
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
|
||||||
|
import de.jeanlucmakiola.calendula.ui.week.spansMultipleDays
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -417,8 +418,11 @@ internal fun layoutCalendarWeek(
|
|||||||
zone: TimeZone,
|
zone: TimeZone,
|
||||||
): MonthWeek {
|
): MonthWeek {
|
||||||
val weekEvents = instances.filter { ev -> days.any { ev.coversDay(it, zone) } }
|
val weekEvents = instances.filter { ev -> days.any { ev.coversDay(it, zone) } }
|
||||||
|
// A bar is decided by the *event*, not by how much of it this row holds: one
|
||||||
|
// crossing midnight into the next week row covers a single day in each, and
|
||||||
|
// counting per row drew it as two unrelated pills (#253).
|
||||||
val (bars, singles) = weekEvents.partition { ev ->
|
val (bars, singles) = weekEvents.partition { ev ->
|
||||||
ev.isAllDay || days.count { ev.coversDay(it, zone) } > 1
|
ev.isAllDay || ev.spansMultipleDays(zone)
|
||||||
}
|
}
|
||||||
val spans = layoutAllDay(bars, days, zone).map { s ->
|
val spans = layoutAllDay(bars, days, zone).map { s ->
|
||||||
MonthSpan(
|
MonthSpan(
|
||||||
|
|||||||
@@ -100,7 +100,10 @@ import de.jeanlucmakiola.calendula.ui.common.MoveTarget
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
|
import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay
|
import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
|
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
|
||||||
import de.jeanlucmakiola.calendula.ui.common.beginsOn
|
import de.jeanlucmakiola.calendula.ui.common.clipOffsetMinutes
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.continuesAfter
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.continuesBefore
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.timedBlockShape
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed
|
import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
|
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
|
import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
|
||||||
@@ -108,6 +111,7 @@ import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.startInstant
|
import de.jeanlucmakiola.calendula.ui.common.startInstant
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||||
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
||||||
@@ -387,7 +391,7 @@ private fun WeekContent(
|
|||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
onDrop = { drop ->
|
onDrop = { drop ->
|
||||||
val took = move?.move(
|
move?.move(
|
||||||
MoveRequest(
|
MoveRequest(
|
||||||
eventId = drop.event.eventId,
|
eventId = drop.event.eventId,
|
||||||
beginMillis = drop.event.start.toEpochMilliseconds(),
|
beginMillis = drop.event.start.toEpochMilliseconds(),
|
||||||
@@ -395,9 +399,6 @@ private fun WeekContent(
|
|||||||
target = MoveTarget.Start(drop.startInstant(zone)),
|
target = MoveTarget.Start(drop.startInstant(zone)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
// Refused, so nothing will land: let the copy go now
|
|
||||||
// rather than hold it out for a settle that never comes.
|
|
||||||
if (took != true) dragController.release()
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -656,7 +657,7 @@ private fun AllDayBar(
|
|||||||
val fill = eventFill(event.color, dark, soften)
|
val fill = eventFill(event.color, dark, soften)
|
||||||
Box(
|
Box(
|
||||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||||
.background(fill, RoundedCornerShape(4.dp))
|
.background(fill, EventChipShape)
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||||
.semantics { contentDescription = title },
|
.semantics { contentDescription = title },
|
||||||
@@ -912,24 +913,30 @@ private fun EventBlock(
|
|||||||
val fill = eventFill(block.event.color, dark, soften)
|
val fill = eventFill(block.event.color, dark, soften)
|
||||||
val zone = remember { TimeZone.currentSystemDefault() }
|
val zone = remember { TimeZone.currentSystemDefault() }
|
||||||
val moveAction = eventMoveAction(block.event)
|
val moveAction = eventMoveAction(block.event)
|
||||||
// A block clipped at the top continues from the previous day: its top edge
|
val draggable = eventDragAllowed(block.event)
|
||||||
// is midnight, not the event's start, so dragging it would invent a time.
|
// The drop takes this offset back off, so a tail clipped at midnight lands
|
||||||
val draggable = eventDragAllowed(block.event) && block.beginsOn(date, zone)
|
// where the event's own start belongs (#253).
|
||||||
|
val clipOffset = remember(block, date, zone) { block.clipOffsetMinutes(date, zone) }
|
||||||
|
val shape = remember(block, date, zone) {
|
||||||
|
timedBlockShape(block.continuesBefore(date, zone), block.continuesAfter(date, zone))
|
||||||
|
}
|
||||||
val dragModifier = rememberEventDragSource(
|
val dragModifier = rememberEventDragSource(
|
||||||
enabled = draggable,
|
enabled = draggable,
|
||||||
key = block.event.instanceId,
|
key = block.event.instanceId,
|
||||||
onPickUp = { pointer, blockRoot, _ -> dragController.begin(block, pointer, blockRoot) },
|
onPickUp = { pointer, blockRoot, _ ->
|
||||||
|
dragController.begin(block, clipOffset, pointer, blockRoot)
|
||||||
|
},
|
||||||
onMove = dragController::move,
|
onMove = dragController::move,
|
||||||
onDrop = { dragController.finish()?.let(onDrop) },
|
onDrop = { dragController.finish()?.let(onDrop) },
|
||||||
onCancel = dragController::cancel,
|
onCancel = dragController::cancel,
|
||||||
)
|
)
|
||||||
val lifted = draggable && dragController.ghosts(block, date)
|
val lifted = draggable && dragController.ghosts(block)
|
||||||
val ghost = ghostAlpha(lifted)
|
val ghost = ghostAlpha(lifted)
|
||||||
Box(
|
Box(
|
||||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||||
// The source stays put as a ghost while its floating copy travels.
|
// The source stays put as a ghost while its floating copy travels.
|
||||||
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
|
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
|
||||||
.background(fill, RoundedCornerShape(4.dp))
|
.background(fill, shape)
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
// After clickable, so it is the inner node and wins the main pass;
|
// After clickable, so it is the inner node and wins the main pass;
|
||||||
// the tap still works, since a drag consumes the up.
|
// the tap still works, since a drag consumes the up.
|
||||||
|
|||||||
@@ -206,6 +206,17 @@ internal fun EventInstance.coversDay(day: LocalDate, zone: TimeZone): Boolean {
|
|||||||
return start < dayEnd && end > dayStart
|
return start < dayEnd && end > dayStart
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if this event touches more than one calendar day in [zone] — an all-day
|
||||||
|
* event covering a range, or a timed one crossing midnight. Occurrences are
|
||||||
|
* contiguous, so covering the day after the first is the whole question.
|
||||||
|
*/
|
||||||
|
internal fun EventInstance.spansMultipleDays(zone: TimeZone): Boolean {
|
||||||
|
val anchor = if (isAllDay) TimeZone.UTC else zone
|
||||||
|
val secondDay = start.toLocalDateTime(anchor).date.plus(1, DateTimeUnit.DAY)
|
||||||
|
return coversDay(secondDay, zone)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clip [events] to a single [day] and assign lanes so overlapping events render
|
* Clip [events] to a single [day] and assign lanes so overlapping events render
|
||||||
* side-by-side. Lane count is computed per overlap-cluster (a maximal run of
|
* side-by-side. Lane count is computed per overlap-cluster (a maximal run of
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget
|
||||||
|
|
||||||
|
import android.app.AlarmManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.appwidget.AppWidgetManager
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.core.content.getSystemService
|
||||||
|
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver
|
||||||
|
import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Duration.Companion.hours
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds the app's own wake-up for the next local midnight, so the home-screen
|
||||||
|
* widgets roll "today" over on the day boundary (#228).
|
||||||
|
*
|
||||||
|
* The widgets used to lean on `ACTION_DATE_CHANGED`, which is not an exempted
|
||||||
|
* implicit broadcast — a manifest-declared receiver has not been given it since
|
||||||
|
* Android 8, leaving only the throttled `updatePeriodMillis`.
|
||||||
|
*
|
||||||
|
* Exactly one alarm exists at a time and every firing re-arms the next, the same
|
||||||
|
* shape as [de.jeanlucmakiola.calendula.data.reminders.ReminderAlarmScheduler].
|
||||||
|
* Deliberately **inexact**: `setAndAllowWhileIdle` needs no permission and
|
||||||
|
* survives doze (plain `set` does not), a rollover a few minutes late is
|
||||||
|
* invisible on a sleeping screen, and exact alarms stay reserved for snooze.
|
||||||
|
*/
|
||||||
|
object WidgetRolloverScheduler {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire just *after* midnight: an alarm delivered a few milliseconds early
|
||||||
|
* would still read the old date and re-arm for an instant later.
|
||||||
|
*/
|
||||||
|
internal val ROLLOVER_SLACK = 5.seconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arm the next rollover, or cancel a pending one when no widget is placed.
|
||||||
|
* Idempotent, so every trigger (boot, app start, widget added/removed, the
|
||||||
|
* rollover itself, a clock or timezone change) can just call it.
|
||||||
|
*/
|
||||||
|
fun sync(context: Context) {
|
||||||
|
val appContext = context.applicationContext
|
||||||
|
val alarmManager = appContext.getSystemService<AlarmManager>() ?: return
|
||||||
|
val pendingIntent = rolloverPendingIntent(appContext)
|
||||||
|
if (!hasPlacedWidgets(appContext)) {
|
||||||
|
alarmManager.cancel(pendingIntent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val triggerAt = nextRolloverAt(Clock.System.now(), TimeZone.currentSystemDefault())
|
||||||
|
alarmManager.setAndAllowWhileIdle(
|
||||||
|
AlarmManager.RTC_WAKEUP, triggerAt.toEpochMilliseconds(), pendingIntent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The instant just after the next local midnight following [now] in [zone].
|
||||||
|
*
|
||||||
|
* The *actual* start of day, not 00:00, so it holds where a DST jump means
|
||||||
|
* midnight never happens (Havana) and where a date-line move skips a whole
|
||||||
|
* local date (Apia, December 2011) — the loop walks on to the next real day.
|
||||||
|
*
|
||||||
|
* The mirror case — a zone rewinding *across* midnight, so the day starts
|
||||||
|
* twice — resolves to the earlier start and runs an hour ahead of the clock.
|
||||||
|
* No live tz entry does that (Brazil dropped DST in 2019) and
|
||||||
|
* `updatePeriodMillis` covers it, so it isn't worth state to detect.
|
||||||
|
*/
|
||||||
|
fun nextRolloverAt(now: Instant, zone: TimeZone): Instant {
|
||||||
|
val date = now.toLocalDateTime(zone).date
|
||||||
|
var days = 1
|
||||||
|
while (days <= MAX_LOOKAHEAD_DAYS) {
|
||||||
|
val candidate = date.plus(days, DateTimeUnit.DAY).atStartOfDayIn(zone) + ROLLOVER_SLACK
|
||||||
|
if (candidate > now) return candidate
|
||||||
|
days++
|
||||||
|
}
|
||||||
|
// Unreachable for any zone in the tz database. Deliberately an hour and
|
||||||
|
// not the slack: a 5-second retry would just hit this branch again and
|
||||||
|
// wake the device in a loop.
|
||||||
|
return now + 1.hours
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasPlacedWidgets(context: Context): Boolean {
|
||||||
|
val manager = AppWidgetManager.getInstance(context) ?: return false
|
||||||
|
return PROVIDERS.any {
|
||||||
|
manager.getAppWidgetIds(ComponentName(context, it)).isNotEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rolloverPendingIntent(context: Context): PendingIntent =
|
||||||
|
PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
ROLLOVER_REQUEST_CODE,
|
||||||
|
Intent(context, WidgetUpdateReceiver::class.java)
|
||||||
|
.setAction(WidgetUpdateReceiver.ACTION_ROLLOVER),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val PROVIDERS = listOf(
|
||||||
|
MonthWidgetReceiver::class.java,
|
||||||
|
AgendaWidgetReceiver::class.java,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Fixed: there is only ever one rollover alarm, and re-arming must replace it. */
|
||||||
|
private const val ROLLOVER_REQUEST_CODE = 0x0DA1
|
||||||
|
|
||||||
|
/** A gap of more than a couple of days does not exist in any tz database entry. */
|
||||||
|
private const val MAX_LOOKAHEAD_DAYS = 3
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.widget
|
|||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.os.SystemClock
|
||||||
import androidx.glance.appwidget.updateAll
|
import androidx.glance.appwidget.updateAll
|
||||||
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
|
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
|
||||||
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
|
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
|
||||||
@@ -10,32 +11,110 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Redraws both home-screen widgets when their data goes stale. Triggered by:
|
* Redraws both home-screen widgets when their data goes stale, and keeps the
|
||||||
|
* midnight rollover alarm armed. Triggered by:
|
||||||
* - `PROVIDER_CHANGED` from the calendar provider — fires on any data change,
|
* - `PROVIDER_CHANGED` from the calendar provider — fires on any data change,
|
||||||
* so it covers both the app's own writes and external sync.
|
* so it covers both the app's own writes and external sync.
|
||||||
* - `DATE_CHANGED` / `TIME_SET` / `TIMEZONE_CHANGED` — so "today" highlighting
|
* - [ACTION_ROLLOVER], the app's own alarm from [WidgetRolloverScheduler] —
|
||||||
* and the upcoming window roll over at midnight / on a clock change.
|
* the day boundary, so "today" highlighting and the agenda's past-event
|
||||||
|
* dimming move on (#228).
|
||||||
|
* - `TIME_SET` / `TIMEZONE_CHANGED` — the day boundary moved, so redraw *and*
|
||||||
|
* re-arm.
|
||||||
|
* - `BOOT_COMPLETED` / `MY_PACKAGE_REPLACED` — both wipe pending alarms; the
|
||||||
|
* latter is also what arms installs upgrading into the fix.
|
||||||
*
|
*
|
||||||
* Both widgets also carry an `updatePeriodMillis` backstop in their provider
|
* `DATE_CHANGED` rides along as a free extra the system may or may not send —
|
||||||
* XML, and the month widget's refresh button forces an immediate redraw.
|
* see [WidgetRolloverScheduler]. The backstops are `updatePeriodMillis` in the
|
||||||
|
* provider XML and the month widget's refresh button.
|
||||||
|
*
|
||||||
|
* Every action that dropped, consumed or invalidated the alarm re-arms it, and
|
||||||
|
* `PROVIDER_CHANGED` re-arms at most once every [DATA_REARM_INTERVAL_MILLIS]
|
||||||
|
* ([dataRearmDue]). The alarm is inexact, so the system may drop it outright in
|
||||||
|
* a restricted standby bucket; nothing in ordinary daily use would then notice,
|
||||||
|
* and the widget sits on yesterday's date. A calendar change is the one signal
|
||||||
|
* that keeps arriving — but it arrives on *every* write (each drag, each event
|
||||||
|
* of a thousand-event import), which is what the interval is for.
|
||||||
|
*
|
||||||
|
* Exported for the system broadcasts; an extra redraw from another app is
|
||||||
|
* harmless.
|
||||||
*/
|
*/
|
||||||
class WidgetUpdateReceiver : BroadcastReceiver() {
|
class WidgetUpdateReceiver : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
val pending = goAsync()
|
// Exported, so anything can reach it with an explicit intent. Nothing
|
||||||
|
// here crosses a trust boundary, but narrowing to the actions we asked
|
||||||
|
// for keeps a stray broadcast from costing two wide provider reads.
|
||||||
|
if (intent.action !in HANDLED_ACTIONS) return
|
||||||
val appContext = context.applicationContext
|
val appContext = context.applicationContext
|
||||||
|
val rearm = intent.action in REARM_ACTIONS ||
|
||||||
|
(intent.action == Intent.ACTION_PROVIDER_CHANGED && dataRearmDue())
|
||||||
|
// The host sends APPWIDGET_UPDATE after the re-arm-only ones anyway, so
|
||||||
|
// redrawing there would only repeat the work in a cold process, at the
|
||||||
|
// moment the device is most contended.
|
||||||
|
val redraw = intent.action !in REARM_ONLY_ACTIONS
|
||||||
|
val pending = goAsync()
|
||||||
// Calendar data may have changed (sync / our own write) — drop the cached
|
// Calendar data may have changed (sync / our own write) — drop the cached
|
||||||
// month window so the widgets reload fresh. Month paging does NOT call
|
// month window so the widgets reload fresh. Month paging does NOT call
|
||||||
// this, so arrow taps stay instant.
|
// this, so arrow taps stay instant.
|
||||||
invalidateMonthWidgetCache()
|
if (redraw) invalidateMonthWidgetCache()
|
||||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||||
try {
|
try {
|
||||||
AgendaWidget().updateAll(appContext)
|
// Re-armed first, so the next day boundary is covered whatever
|
||||||
MonthWidget().updateAll(appContext)
|
// the redraw does — and off the receiving thread, since it is a
|
||||||
|
// handful of binder calls (`CalendulaApp` moves it for the same
|
||||||
|
// reason).
|
||||||
|
if (rearm) WidgetRolloverScheduler.sync(appContext)
|
||||||
|
if (redraw) {
|
||||||
|
AgendaWidget().updateAll(appContext)
|
||||||
|
MonthWidget().updateAll(appContext)
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
pending.finish()
|
pending.finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** The app's own midnight wake-up; see [WidgetRolloverScheduler]. */
|
||||||
|
const val ACTION_ROLLOVER = "de.jeanlucmakiola.calendula.widget.ROLLOVER"
|
||||||
|
|
||||||
|
/** Both wipe pending alarms, and the host redraws the widgets itself after them. */
|
||||||
|
private val REARM_ONLY_ACTIONS = setOf(
|
||||||
|
Intent.ACTION_BOOT_COMPLETED,
|
||||||
|
Intent.ACTION_MY_PACKAGE_REPLACED,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The actions that leave no alarm standing: the two above wipe it, the
|
||||||
|
* rollover consumed itself, and a clock, zone or day change moved the
|
||||||
|
* boundary the pending one was set for.
|
||||||
|
*/
|
||||||
|
internal val REARM_ACTIONS = REARM_ONLY_ACTIONS + setOf(
|
||||||
|
ACTION_ROLLOVER,
|
||||||
|
Intent.ACTION_TIME_CHANGED,
|
||||||
|
Intent.ACTION_TIMEZONE_CHANGED,
|
||||||
|
Intent.ACTION_DATE_CHANGED,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal val HANDLED_ACTIONS = REARM_ACTIONS + Intent.ACTION_PROVIDER_CHANGED
|
||||||
|
|
||||||
|
/** How rarely a calendar change may re-arm; see the class doc. */
|
||||||
|
private const val DATA_REARM_INTERVAL_MILLIS = 15 * 60_000L
|
||||||
|
|
||||||
|
/** Uptime of the last data-change re-arm, 0 for none in this process. */
|
||||||
|
private val lastDataRearmMillis = AtomicLong(0L)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a calendar change may re-arm now — the backstop for an alarm
|
||||||
|
* the system dropped, throttled so a burst of writes costs one pass.
|
||||||
|
*/
|
||||||
|
private fun dataRearmDue(): Boolean {
|
||||||
|
val now = SystemClock.elapsedRealtime()
|
||||||
|
val last = lastDataRearmMillis.get()
|
||||||
|
val due = last == 0L || now - last >= DATA_REARM_INTERVAL_MILLIS
|
||||||
|
return due && lastDataRearmMillis.compareAndSet(last, now)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package de.jeanlucmakiola.calendula.widget.agenda
|
package de.jeanlucmakiola.calendula.widget.agenda
|
||||||
|
|
||||||
|
import android.appwidget.AppWidgetManager
|
||||||
|
import android.content.Context
|
||||||
import androidx.glance.appwidget.GlanceAppWidget
|
import androidx.glance.appwidget.GlanceAppWidget
|
||||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||||
|
import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Host-facing receiver for the agenda widget. Declared in the manifest with the
|
* Host-facing receiver for the agenda widget. Declared in the manifest with the
|
||||||
@@ -10,4 +13,32 @@ import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
|||||||
*/
|
*/
|
||||||
class AgendaWidgetReceiver : GlanceAppWidgetReceiver() {
|
class AgendaWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||||
override val glanceAppWidget: GlanceAppWidget = AgendaWidget()
|
override val glanceAppWidget: GlanceAppWidget = AgendaWidget()
|
||||||
|
|
||||||
|
/** First agenda widget placed — start rolling "today" over at midnight (#228). */
|
||||||
|
override fun onEnabled(context: Context) {
|
||||||
|
super.onEnabled(context)
|
||||||
|
WidgetRolloverScheduler.sync(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last agenda widget removed. [WidgetRolloverScheduler.sync] cancels only if
|
||||||
|
* no month widget is left either.
|
||||||
|
*/
|
||||||
|
override fun onDisabled(context: Context) {
|
||||||
|
super.onDisabled(context)
|
||||||
|
WidgetRolloverScheduler.sync(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Self-heal on the system's own `updatePeriodMillis` wake-up — see
|
||||||
|
* [de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver.onUpdate].
|
||||||
|
*/
|
||||||
|
override fun onUpdate(
|
||||||
|
context: Context,
|
||||||
|
appWidgetManager: AppWidgetManager,
|
||||||
|
appWidgetIds: IntArray,
|
||||||
|
) {
|
||||||
|
super.onUpdate(context, appWidgetManager, appWidgetIds)
|
||||||
|
WidgetRolloverScheduler.sync(context)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,7 +152,17 @@ class ShiftMonthAction : ActionCallback {
|
|||||||
val delta = parameters[deltaKey] ?: 0
|
val delta = parameters[deltaKey] ?: 0
|
||||||
updateAppWidgetState(context, glanceId) { prefs ->
|
updateAppWidgetState(context, glanceId) { prefs ->
|
||||||
val cur = prefs[MONTH_INDEX_KEY] ?: currentMonthIndex(systemZone())
|
val cur = prefs[MONTH_INDEX_KEY] ?: currentMonthIndex(systemZone())
|
||||||
prefs[MONTH_INDEX_KEY] = cur + delta
|
val next = cur + delta
|
||||||
|
// Landing back on the current month clears the key, so the widget
|
||||||
|
// goes back to *following* the date rather than being pinned to
|
||||||
|
// whichever month was current at the tap. Paging out and back is the
|
||||||
|
// workaround #228's reporter used, and pinning it there would have
|
||||||
|
// stuck them on that month once it stopped being the current one.
|
||||||
|
if (next == currentMonthIndex(systemZone())) {
|
||||||
|
prefs.remove(MONTH_INDEX_KEY)
|
||||||
|
} else {
|
||||||
|
prefs[MONTH_INDEX_KEY] = next
|
||||||
|
}
|
||||||
}
|
}
|
||||||
MonthWidget().update(context.applicationContext, glanceId)
|
MonthWidget().update(context.applicationContext, glanceId)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package de.jeanlucmakiola.calendula.widget.month
|
package de.jeanlucmakiola.calendula.widget.month
|
||||||
|
|
||||||
|
import android.appwidget.AppWidgetManager
|
||||||
|
import android.content.Context
|
||||||
import androidx.glance.appwidget.GlanceAppWidget
|
import androidx.glance.appwidget.GlanceAppWidget
|
||||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||||
|
import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Host-facing receiver for the month widget. Declared in the manifest with the
|
* Host-facing receiver for the month widget. Declared in the manifest with the
|
||||||
@@ -9,4 +12,35 @@ import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
|||||||
*/
|
*/
|
||||||
class MonthWidgetReceiver : GlanceAppWidgetReceiver() {
|
class MonthWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||||
override val glanceAppWidget: GlanceAppWidget = MonthWidget()
|
override val glanceAppWidget: GlanceAppWidget = MonthWidget()
|
||||||
|
|
||||||
|
/** First month widget placed — start rolling "today" over at midnight (#228). */
|
||||||
|
override fun onEnabled(context: Context) {
|
||||||
|
super.onEnabled(context)
|
||||||
|
WidgetRolloverScheduler.sync(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last month widget removed. [WidgetRolloverScheduler.sync] cancels only if
|
||||||
|
* no agenda widget is left either.
|
||||||
|
*/
|
||||||
|
override fun onDisabled(context: Context) {
|
||||||
|
super.onDisabled(context)
|
||||||
|
WidgetRolloverScheduler.sync(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `updatePeriodMillis` backstop is the one wake-up the *system* still
|
||||||
|
* owns, so it doubles as the alarm's self-heal: anything that drops a
|
||||||
|
* pending alarm without a broadcast (force-stop, battery restriction, an OEM
|
||||||
|
* freeze) is repaired here rather than on the next app open. Re-arming
|
||||||
|
* closer to midnight also narrows the inexact delivery window.
|
||||||
|
*/
|
||||||
|
override fun onUpdate(
|
||||||
|
context: Context,
|
||||||
|
appWidgetManager: AppWidgetManager,
|
||||||
|
appWidgetIds: IntArray,
|
||||||
|
) {
|
||||||
|
super.onUpdate(context, appWidgetManager, appWidgetIds)
|
||||||
|
WidgetRolloverScheduler.sync(context)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,4 +146,442 @@
|
|||||||
<string name="event_edit_recurrence_end_until">На дату</string>
|
<string name="event_edit_recurrence_end_until">На дату</string>
|
||||||
<string name="event_edit_recurrence_end_count">Пасьля некалькіх разоў</string>
|
<string name="event_edit_recurrence_end_count">Пасьля некалькіх разоў</string>
|
||||||
<string name="event_edit_recurrence_times">разы(оў)</string>
|
<string name="event_edit_recurrence_times">разы(оў)</string>
|
||||||
|
<string name="app_name">Calendula</string>
|
||||||
|
<string name="event_edit_error_recurrence_ends_before_start">Паўторы заканчваюцца да пачатку падзеі</string>
|
||||||
|
<string name="event_availability_busy">Заняты</string>
|
||||||
|
<string name="event_access_default">Па змаўчаньні</string>
|
||||||
|
<string name="event_access_public">Публічны</string>
|
||||||
|
<string name="event_detail_all_day">Цэлы дзень</string>
|
||||||
|
<string name="event_detail_calendar">Каляндар</string>
|
||||||
|
<string name="event_detail_calendar_unknown">Невядомы каляндар</string>
|
||||||
|
<string name="event_detail_location">Месцазнаходжаньне</string>
|
||||||
|
<string name="event_detail_description">Апісаньне</string>
|
||||||
|
<string name="event_detail_attendees">Удзельнікі</string>
|
||||||
|
<string name="event_detail_recurrence">Паўтор</string>
|
||||||
|
<string name="event_detail_recurring">Паўтаральная падзея</string>
|
||||||
|
<string name="recurrence_daily">Кожны дзень</string>
|
||||||
|
<string name="recurrence_weekly">Кожны тыдзень</string>
|
||||||
|
<string name="recurrence_monthly">Кожны месяц</string>
|
||||||
|
<string name="recurrence_yearly">Кожны год</string>
|
||||||
|
<string name="recurrence_every_n_days">Кожныя %1$d сут.</string>
|
||||||
|
<string name="recurrence_every_n_weeks">Кожныя %1$d тыд.</string>
|
||||||
|
<string name="recurrence_every_n_months">Кожныя %1$d мес.</string>
|
||||||
|
<string name="recurrence_every_n_years">Кожныя %1$d гады(оў)</string>
|
||||||
|
<string name="recurrence_on_days">%1$s у %2$s</string>
|
||||||
|
<string name="recurrence_with_until">з %1$s да %2$s</string>
|
||||||
|
<string name="recurrence_with_count">%1$s, %2$d разы(оў)</string>
|
||||||
|
<string name="event_detail_not_found">Гэтай падзеі больш не існуе.</string>
|
||||||
|
<string name="event_attendee_accepted">Прынята</string>
|
||||||
|
<string name="event_attendee_declined">Адхілена</string>
|
||||||
|
<string name="event_attendee_tentative">Пад пытаньнем</string>
|
||||||
|
<string name="event_attendee_needs_action">Няма адказу</string>
|
||||||
|
<string name="event_attendee_unknown">—</string>
|
||||||
|
<string name="event_detail_reminders">Напаміны</string>
|
||||||
|
<string name="event_detail_timezone">Часавы пояс</string>
|
||||||
|
<string name="event_status_tentative">Пад пытаньнем</string>
|
||||||
|
<string name="event_status_cancelled">Скасавана</string>
|
||||||
|
<string name="event_availability_free">Свабодна</string>
|
||||||
|
<string name="event_access_private">Прыватна</string>
|
||||||
|
<string name="event_access_confidential">Канфідэнцыйна</string>
|
||||||
|
<string name="event_access_default_summary">Што звычайна робіць гэты каляндар</string>
|
||||||
|
<string name="event_access_public_summary">Поўныя зьвесткі могуць бачыць усе, хто мае доступ</string>
|
||||||
|
<string name="event_access_private_summary">Іншыя бачаць толькі тое, што вы занятыя</string>
|
||||||
|
<string name="event_access_confidential_summary">Пазначана як канфідэнцыйнае: што гэта значыць, залежыць ад уліковага запісу календара</string>
|
||||||
|
<string name="event_attendee_organizer">Арганізатар</string>
|
||||||
|
<string name="event_attendee_optional">Неабавязкова</string>
|
||||||
|
<string name="event_attendee_resource">Рэсурс</string>
|
||||||
|
<string name="event_detail_self_response">Ваш адказ: %1$s</string>
|
||||||
|
<string name="reminder_at_time">На момант падзеі</string>
|
||||||
|
<string name="reminder_default">Напамін па змаўчаньні</string>
|
||||||
|
<plurals name="reminder_minutes">
|
||||||
|
<item quantity="one">За %d хвіліну</item>
|
||||||
|
<item quantity="few">За %d хв.</item>
|
||||||
|
<item quantity="many">За %d хв.</item>
|
||||||
|
<item quantity="other">За %d хв.</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_hours">
|
||||||
|
<item quantity="one">За %d гадзіну</item>
|
||||||
|
<item quantity="few">За %d гадз.</item>
|
||||||
|
<item quantity="many">За %d гадз.</item>
|
||||||
|
<item quantity="other">За %d гадз.</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_days">
|
||||||
|
<item quantity="one">За %d суткі</item>
|
||||||
|
<item quantity="few">За %d сут.</item>
|
||||||
|
<item quantity="many">За %d сут.</item>
|
||||||
|
<item quantity="other">За %d сут.</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_weeks">
|
||||||
|
<item quantity="one">За %d тыдзень</item>
|
||||||
|
<item quantity="few">За %d тыдз.</item>
|
||||||
|
<item quantity="many">За %d тыдз.</item>
|
||||||
|
<item quantity="other">За %d тыдз.</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_minutes">
|
||||||
|
<item quantity="one">%d хвіліна</item>
|
||||||
|
<item quantity="few">%d хв.</item>
|
||||||
|
<item quantity="many">%d хв.</item>
|
||||||
|
<item quantity="other">%d хв.</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_hours">
|
||||||
|
<item quantity="one">%d гадзіна</item>
|
||||||
|
<item quantity="few">%d гадз.</item>
|
||||||
|
<item quantity="many">%d гадз.</item>
|
||||||
|
<item quantity="other">%d гадз.</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_days">
|
||||||
|
<item quantity="one">%d суткі</item>
|
||||||
|
<item quantity="few">%d сут.</item>
|
||||||
|
<item quantity="many">%d сут.</item>
|
||||||
|
<item quantity="other">%d сут.</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="duration_hours_minutes">%1$s гадз. %2$s хв.</string>
|
||||||
|
<string name="duration_custom_max">Макс. %1$s</string>
|
||||||
|
<plurals name="duration_weeks">
|
||||||
|
<item quantity="one">%d тыдзень</item>
|
||||||
|
<item quantity="few">%d тыдз.</item>
|
||||||
|
<item quantity="many">%d тыдз.</item>
|
||||||
|
<item quantity="other">%d тыдз.</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="event_untitled">(без загалоўка)</string>
|
||||||
|
<string name="onboarding_step_counter">Крок %1$d з %2$d</string>
|
||||||
|
<string name="onboarding_backup_title">Вашы падзеі знаходзяцца толькі тут</string>
|
||||||
|
<string name="onboarding_backup_body">На гэтым тэлефоне не адбываецца сынхранізацыя зь якім-небудзь уліковым запісам, і страта азначае страту вашага календара. Calendula можа зрабіць рэзервую копію для вас.</string>
|
||||||
|
<string name="onboarding_backup_benefit_folder_title">Каталёг на ваш выбар</string>
|
||||||
|
<string name="onboarding_backup_benefit_folder_body">Рэзэрвовыя копіі — гэта звычайныя файлы .ics. Захоўвайце іх у месцы, дзе яны сынхранізуюцца, або на SD-карце.</string>
|
||||||
|
<string name="onboarding_backup_benefit_daily_title">Раз за суткі, аўтаматычна</string>
|
||||||
|
<string name="onboarding_backup_benefit_daily_body">Calendula робіць экспарт вашых лякальных календароў у фонавым рэжыме. Зьмяніць пэрыядычнасьць можна ў наладах.</string>
|
||||||
|
<string name="onboarding_backup_enable_button">Выбраць каталёг і зрабіць рэзэрвовую копію</string>
|
||||||
|
<string name="onboarding_backup_skip_button">Ня зараз</string>
|
||||||
|
<string name="onboarding_view_title">Што павінна адкрыцца першым?</string>
|
||||||
|
<string name="onboarding_month_style_title">Як мусіць выглядаць месяц?</string>
|
||||||
|
<string name="onboarding_view_continue_button">Далей</string>
|
||||||
|
<string name="onboarding_back">Назад</string>
|
||||||
|
<string name="onboarding_visibility_button">Зразумела</string>
|
||||||
|
<string name="onboarding_done_title">Усё зроблена</string>
|
||||||
|
<string name="onboarding_done_body">Вашы календары гатовыя. Любы выбар, зроблены зараз, можна пазьней зьмяніць у наладах.</string>
|
||||||
|
<string name="onboarding_done_button">Адкрыць мой каляндар</string>
|
||||||
|
<string name="reminder_channel_name">Напаміны пра падзеі</string>
|
||||||
|
<string name="reminder_channel_description">Абвесткі ў час напамінаў пра вашы падзеі</string>
|
||||||
|
<string name="reminder_onboarding_title">Не прапускаць ніводную падзею</string>
|
||||||
|
<string name="reminder_onboarding_body">Сыстэма Android сама не паказвае напаміны — гэта мусіць рабіць дадатак календара. Хай гэта робіць Calendula.</string>
|
||||||
|
<string name="reminder_benefit_delivery_title">Напаміны, дастаўленыя</string>
|
||||||
|
<string name="reminder_benefit_delivery_body">Кожны напамін пра вашы падзеі прыходзіць у выглядзе своечасовай абвесткі.</string>
|
||||||
|
<string name="reminder_benefit_duplicates_title">Карыстаецеся яшчэ адным дадаткам календара?</string>
|
||||||
|
<string name="reminder_benefit_duplicates_body">Калі іншы дадатак таксама выдае напаміны, вы будзеце бачыць іх двойчы — адключэце іх там або тут.</string>
|
||||||
|
<string name="reminder_benefit_reversible_title">Гэта можна зьмяніць у які заўгодна час</string>
|
||||||
|
<string name="reminder_benefit_reversible_body">Перамыкач знаходзіцца ў наладах, у разьдзеле «Абвесткі».</string>
|
||||||
|
<string name="reminder_onboarding_enable_button">Уключыць напаміны</string>
|
||||||
|
<string name="reminder_onboarding_skip_button">Ня зараз</string>
|
||||||
|
<string name="reminder_action_snooze">Адкласьці</string>
|
||||||
|
<string name="reminder_action_dismiss">Адхіліць</string>
|
||||||
|
<string name="reminder_day_tomorrow">Заўтра</string>
|
||||||
|
<string name="reminder_day_yesterday">Учора</string>
|
||||||
|
<string name="view_month">Месяц</string>
|
||||||
|
<string name="view_week">Тыдзень</string>
|
||||||
|
<string name="view_day">Дзень</string>
|
||||||
|
<string name="view_agenda">Расклад</string>
|
||||||
|
<string name="view_section">Выгляд</string>
|
||||||
|
<string name="drawer_jump_to_date">Пераход да даты</string>
|
||||||
|
<string name="agenda_today_action">Сёньня</string>
|
||||||
|
<string name="agenda_header_today">Сёньня</string>
|
||||||
|
<string name="agenda_header_tomorrow">Заўтра</string>
|
||||||
|
<string name="agenda_empty_title">Усё завершана</string>
|
||||||
|
<string name="agenda_no_more_today">Сёньня больш няма падзеяў</string>
|
||||||
|
<string name="agenda_span_starts">Пачатак: %1$s</string>
|
||||||
|
<string name="agenda_span_ends">Заканчэньне: %1$s</string>
|
||||||
|
<string name="today_jump_action">Перайсці на сёньня</string>
|
||||||
|
<string name="search_action">Пошук</string>
|
||||||
|
<string name="search_hint">Пошук падзеяў</string>
|
||||||
|
<string name="search_back">Назад</string>
|
||||||
|
<string name="search_clear">Ачысьціць</string>
|
||||||
|
<string name="search_idle_hint">Пошук падзеяў па загалоўках, месцазнаходжаньні або зацемках.</string>
|
||||||
|
<string name="search_empty">Няма падзей, якія адпавядаюць «%1$s».</string>
|
||||||
|
<plurals name="search_selected_count">
|
||||||
|
<item quantity="one">Вылучана: %d</item>
|
||||||
|
<item quantity="few">Вылучана: %d</item>
|
||||||
|
<item quantity="many">Вылучана: %d</item>
|
||||||
|
<item quantity="other">Вылучана: %d</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="search_in_descriptions">Знойдзена ў апісаньнях</string>
|
||||||
|
<string name="search_selection_close">Зьняць вылучэньне</string>
|
||||||
|
<string name="search_select_all">Вылучыць усё</string>
|
||||||
|
<string name="search_delete_selected">Выдаліць вылучанае</string>
|
||||||
|
<plurals name="search_delete_title">
|
||||||
|
<item quantity="one">Выдаліць падзеі (%d)?</item>
|
||||||
|
<item quantity="few">Выдаліць падзеі (%d)?</item>
|
||||||
|
<item quantity="many">Выдаліць падзеі (%d)?</item>
|
||||||
|
<item quantity="other">Выдаліць падзеі (%d)?</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="search_delete_done">
|
||||||
|
<item quantity="one">Выдаленая %d падзея</item>
|
||||||
|
<item quantity="few">Выдалена падзеяў: %d</item>
|
||||||
|
<item quantity="many">Выдалена падзеяў: %d</item>
|
||||||
|
<item quantity="other">Выдалена падзеяў: %d</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="search_delete_partial">Выдалена %1$d, не атрымалася выдаліць %2$d</string>
|
||||||
|
<string name="search_delete_denied_partial">Выдалена %1$d, затым быў скасаваны дазвол на запіс</string>
|
||||||
|
<string name="widget_agenda_title">Наступнае</string>
|
||||||
|
<string name="widget_agenda_label">Расклад Calendula</string>
|
||||||
|
<string name="widget_month_label">Месяц Calendula</string>
|
||||||
|
<string name="widget_refresh">Абнавіць</string>
|
||||||
|
<string name="widget_new_event">Новая падзея</string>
|
||||||
|
<string name="widget_needs_permission">Адкрыйце Calendula, каб дазволіць доступ да календара</string>
|
||||||
|
<string name="widget_prev_month">Папярэдні месяц</string>
|
||||||
|
<string name="widget_next_month">Наступны месяц</string>
|
||||||
|
<string name="widget_today">Сёньня</string>
|
||||||
|
<string name="filter_title">Календары</string>
|
||||||
|
<string name="settings_title">Налады</string>
|
||||||
|
<string name="settings_back">Назад</string>
|
||||||
|
<string name="back">Назад</string>
|
||||||
|
<string name="settings_section_appearance">Афармленьне</string>
|
||||||
|
<string name="settings_theme">Тэма</string>
|
||||||
|
<string name="settings_theme_system">Сыстэмная</string>
|
||||||
|
<string name="settings_theme_light">Сьветлая</string>
|
||||||
|
<string name="settings_theme_dark">Цёмная</string>
|
||||||
|
<string name="settings_theme_hint">Сьветлы або цёмны выгляд дадатку. Выбар будзе ўжыты адразу ж.</string>
|
||||||
|
<string name="settings_theme_system_summary">Зараз: %1$s</string>
|
||||||
|
<string name="settings_default_view">Выгляд па змаўчаньні</string>
|
||||||
|
<string name="settings_dynamic_color">Дынамічны колер</string>
|
||||||
|
<string name="settings_dynamic_color_unavailable">Патрабуецца сыстэма Android вэрсіі 12 або навейшай</string>
|
||||||
|
<string name="settings_soften_colors">Гарманізацыя колераў календара</string>
|
||||||
|
<string name="settings_soften_colors_summary">Вытрымліваць адценьне і яркасьць кожнага календара, каб кожная падзея заставалася чытэльнай, а колеры спалучаліся. Адключыць, каб паказваць зыходныя колеры з крыніцы календара.</string>
|
||||||
|
<string name="settings_font_headings">Шрыфт загалоўкаў</string>
|
||||||
|
<string name="settings_font_body">Шрыфт тэксту</string>
|
||||||
|
<string name="settings_font_system">Сыстэмны</string>
|
||||||
|
<string name="font_atkinson_hyperlegible">Atkinson Hyperlegible</string>
|
||||||
|
<string name="font_lora">Lora</string>
|
||||||
|
<string name="font_jetbrains_mono">JetBrains Mono</string>
|
||||||
|
<string name="settings_font_choose_file">Выбраць файл…</string>
|
||||||
|
<string name="settings_font_custom_selected">Карыстальніцкі</string>
|
||||||
|
<string name="settings_font_import_failed">Не атрымалася прачытаць файлы шрыфту</string>
|
||||||
|
<string name="settings_font_specimen_heading">Чацьвер, 14 траўня</string>
|
||||||
|
<string name="settings_font_specimen_body">Камандны разгляд а 10:00, затым абед з Робінам у кавярні на Марктпляц.</string>
|
||||||
|
<string name="settings_week_start">Тыдзень пачынаецца з</string>
|
||||||
|
<string name="settings_week_start_auto">Аўтаматычна</string>
|
||||||
|
<string name="settings_week_start_auto_summary">Зараз: %1$s</string>
|
||||||
|
<string name="settings_week_numbers">Нумары тыдняў</string>
|
||||||
|
<string name="settings_week_numbers_summary">Паказваць нумары тыдняў календара ў рэжыме прагляду месяца</string>
|
||||||
|
<string name="settings_today_toolbar">Кнопка «Сёньня» на панэлі</string>
|
||||||
|
<string name="settings_today_toolbar_summary">Паказваць кнопку пераходу да сёньняшняга дня на панэлі прыладаў замест плаваючай кнопкі</string>
|
||||||
|
<string name="settings_app_name">Назва дадатку</string>
|
||||||
|
<string name="settings_app_name_summary">Паказаць Calendula як «Каляндар» на панэлі запуску. На панэлі запуску зьменіцца толькі назва. Пасьля пераключэньня можа зьмяніцца месцазнаходжаньне абразка.</string>
|
||||||
|
<string name="settings_time_format">Фармат часу</string>
|
||||||
|
<string name="settings_time_format_auto">Аўта</string>
|
||||||
|
<string name="settings_time_format_12h">12-гадз. (2:00 пасьля абеду)</string>
|
||||||
|
<string name="settings_time_format_24h">24-гадз. (14:00)</string>
|
||||||
|
<string name="settings_time_format_auto_summary">Сыстэмная налада: %1$s</string>
|
||||||
|
<string name="settings_hour_lines">Лініі гадзінаў</string>
|
||||||
|
<string name="settings_hour_lines_summary">Паказваць раздзяляльную лінію для кожнай гадзіны ў рэжыме прагляду тыдня і дня</string>
|
||||||
|
<string name="settings_timeline_scale">Вышыня гадзіны</string>
|
||||||
|
<string name="settings_timeline_scale_hint">Колькі прасторы па вэртыкалі даецца на гадзіну ў рэжыме прагляду тыдня і дня. Гэтая налада агульная для абодвух рэжымаў прагляду. Таксама можна двума пальцамі перамяшчаць лінію, каб задаць нейкую іншую вышыню для гадзінаў.</string>
|
||||||
|
<string name="timeline_scale_fit_day">Цэлыя суткі</string>
|
||||||
|
<string name="timeline_scale_fit_day_summary">На экране ўсе 24 гадзіны, прагортваць ня трэба</string>
|
||||||
|
<string name="timeline_scale_compact">Кампактна</string>
|
||||||
|
<string name="timeline_scale_compact_summary">На экране больш гадзінаў, блёкі меншыя</string>
|
||||||
|
<string name="timeline_scale_regular">Звычайна</string>
|
||||||
|
<string name="timeline_scale_regular_summary">Стандартны інтэрвал</string>
|
||||||
|
<string name="timeline_scale_comfortable">Камфортна</string>
|
||||||
|
<string name="timeline_scale_comfortable_summary">Большыя блёкі, больш прагорткі</string>
|
||||||
|
<string name="timeline_scale_custom">Карыстальніцкі варыянт</string>
|
||||||
|
<string name="timeline_scale_custom_summary">Вышыня ў выніку перамяшчэньня лініі пальцамі</string>
|
||||||
|
<string name="settings_dim_completed">Зацямняць завершаныя падзеі</string>
|
||||||
|
<string name="settings_dim_completed_summary">Зацямняць завершаныя падзеі ў рэжыме прагляду месяца і тыдня</string>
|
||||||
|
<string name="settings_drag_to_reschedule">Перацягваць, каб пераплянаваць</string>
|
||||||
|
<string name="settings_drag_to_reschedule_summary">Перанесьці падзею, перацягнуўшы яе на іншы дзень або час</string>
|
||||||
|
<string name="settings_past_events">Мінулыя падзеі</string>
|
||||||
|
<string name="settings_past_events_show">Паказваць</string>
|
||||||
|
<string name="settings_past_events_dim">Зацямняць</string>
|
||||||
|
<string name="settings_past_events_hide">Хаваць</string>
|
||||||
|
<string name="settings_past_events_sample_morning">Камандны разгляд</string>
|
||||||
|
<string name="settings_past_events_sample_midday">Абед з Робінам</string>
|
||||||
|
<string name="settings_past_events_sample_evening">Рэпетыцыя ў хоры</string>
|
||||||
|
<string name="settings_agenda_header">Расклад</string>
|
||||||
|
<string name="settings_agenda_range">Дыяпазон раскладу</string>
|
||||||
|
<string name="settings_agenda_range_hint">Наколькі далёка наперад паказваюцца падзеі на экране раскладу.</string>
|
||||||
|
<string name="settings_agenda_widget_range">Дыяпазон віджэта раскладу</string>
|
||||||
|
<string name="settings_agenda_widget_range_hint">Наколькі далёка наперад паказваюцца падзеі раскладу на галоўным экране віджэта.</string>
|
||||||
|
<string name="settings_widget_size">Памер віджэта раскладу</string>
|
||||||
|
<string name="settings_widget_size_hint">Наколькі вялікім выглядае галоўны экран, на які выводзіцца тэкст раскладу. Віджэт у рэжыме прагляду месяца ня мае падобнага парамэтра, ягоная сетка займае ўсю выдзеленую прастору.</string>
|
||||||
|
<string name="settings_widget_size_small">Дробны</string>
|
||||||
|
<string name="settings_widget_size_medium">Сярэдні</string>
|
||||||
|
<string name="settings_widget_size_large">Буйны</string>
|
||||||
|
<string name="settings_widget_size_extra_large">Вельмі буйны</string>
|
||||||
|
<string name="settings_widget_size_summary">Тэкст падзеі %1$d%%</string>
|
||||||
|
<string name="settings_agenda_show_today">Заўсёды паказваць сёньняшні дзень</string>
|
||||||
|
<string name="settings_agenda_show_today_hint">Сёньняшні дзень застаецца ў верхняй частцы раскладу і віджэта, нават калі падзеяў больш няма.</string>
|
||||||
|
<string name="settings_agenda_range_bar">Панэль дыяпазона</string>
|
||||||
|
<string name="settings_agenda_range_bar_hint">Паказваць уверсе панэль раскладу зь пералікам паказаных дат і кнопку для пераключэньня дыяпазону для сэсіі</string>
|
||||||
|
<string name="agenda_range_day">Сёньня</string>
|
||||||
|
<string name="agenda_range_this_week">Гэты тыдзень</string>
|
||||||
|
<string name="agenda_range_this_month">Гэты месяц</string>
|
||||||
|
<string name="agenda_range_week">Наступныя 7 сутак</string>
|
||||||
|
<string name="agenda_range_month">Наступныя 30 сутак</string>
|
||||||
|
<string name="agenda_range_custom">Свой варыянт…</string>
|
||||||
|
<string name="agenda_range_custom_hint">Суткі</string>
|
||||||
|
<string name="agenda_range_override_hint">Толькі цяпер. Пры паўторным адкрыцьці Calendula дыяпазон скінецца да захаванага значэньня.</string>
|
||||||
|
<string name="agenda_range_showing_label">Паказаныя ўсе будучыя падзеі для</string>
|
||||||
|
<plurals name="agenda_range_days">
|
||||||
|
<item quantity="one">%d суткі</item>
|
||||||
|
<item quantity="few">%d сут.</item>
|
||||||
|
<item quantity="many">%d сут.</item>
|
||||||
|
<item quantity="other">%d сут.</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="settings_section_views">Рэжымы прагляду</string>
|
||||||
|
<string name="settings_special_dates_type_birthday">Дні нараджэньня</string>
|
||||||
|
<string name="settings_special_dates_type_anniversary">Юбілейныя даты</string>
|
||||||
|
<string name="settings_special_dates_type_custom">Іншыя даты</string>
|
||||||
|
<string name="settings_special_dates_template">Фармат загалоўка</string>
|
||||||
|
<string name="settings_special_dates_template_hint">Ужываць {name} для імя кантакту і {year} для года (год нараджэньня або год пачатку юбілейнай падзеі; схаваны, калі невядомы).</string>
|
||||||
|
<string name="settings_special_dates_reminders">Напаміны</string>
|
||||||
|
<string name="settings_special_dates_show_year">Паказаць год</string>
|
||||||
|
<string name="settings_special_dates_show_year_hint">Пазначаць {year} (год) у загалоўках, калі вядомы</string>
|
||||||
|
<string name="settings_special_dates_sync_now">Сынхранізаваць зараз</string>
|
||||||
|
<string name="settings_special_dates_never_synced">Пакуль не сынхранізавана</string>
|
||||||
|
<string name="settings_special_dates_last_synced">Апошняя сынхранізацыя %1$s</string>
|
||||||
|
<string name="settings_special_dates_calendar_hint">Задаць у наладах колер і бачнасьць кожнага календара.</string>
|
||||||
|
<string name="settings_calendar_reminders_managed_hint">Задаць асаблівыя даты кантактаў</string>
|
||||||
|
<string name="settings_special_dates_paused_title">Прыпынена</string>
|
||||||
|
<string name="settings_special_dates_paused_hint">Calendula больш ня можа чытаць вашы кантакты, таму гэтыя календары не абнаўляюцца.</string>
|
||||||
|
<string name="settings_special_dates_grant">Дазволіць доступ</string>
|
||||||
|
<string name="settings_special_dates_disable_title">Адключыць даты кантактаў?</string>
|
||||||
|
<string name="settings_special_dates_disable_all_message">Пры гэты выдаляцца календары з датамі кантактаў і адпаведныя падзеі. Даданыя да іх напаміны або заўвагі зьнікнуць.</string>
|
||||||
|
<string name="settings_special_dates_disable_type_message">Пры гэтым выдаліцца каляндар «%1$s» зь ягонымі падзеямі. Даданыя да іх напаміны або зацемкі зьнікнуць.</string>
|
||||||
|
<string name="settings_special_dates_disable_confirm">Адключыць</string>
|
||||||
|
<string name="dialog_save">Захаваць</string>
|
||||||
|
<string name="settings_section_about">Аб дадатку</string>
|
||||||
|
<string name="settings_license">Ліцэнзія</string>
|
||||||
|
<string name="settings_license_value">MIT</string>
|
||||||
|
<string name="settings_about_author">аўтар: Жан-Люк Макіёла</string>
|
||||||
|
<string name="settings_about_source">Зыходны код</string>
|
||||||
|
<string name="settings_about_privacy">Палітыка канфідэнцыйнасьці</string>
|
||||||
|
<string name="settings_about_support">Падтрымка распрацоўкі</string>
|
||||||
|
<string name="settings_about_version">Вэрсія %1$s</string>
|
||||||
|
<string name="settings_about_logo_desc">Абразок дадатку Calendula</string>
|
||||||
|
<string name="settings_report_problem">Паведаміць пра праблему</string>
|
||||||
|
<string name="settings_report_problem_hint">Адправіць справаздачу аб краху або адкрыць сыстэму адсочваньня праблемаў</string>
|
||||||
|
<string name="calendars_title">Календары</string>
|
||||||
|
<string name="calendars_local_header">Вашыя календары</string>
|
||||||
|
<string name="calendars_local_empty">Пакуль няма лякальных календароў. Стварыць, каб захоўваць падзеі толькі на гэтай прыладзе.</string>
|
||||||
|
<string name="calendars_add">Дадаць каляндар</string>
|
||||||
|
<string name="calendars_visibility_hint">Адключыць каляндар, каб схаваць яго на гэтай прыладзе — падзеі і напаміны зь яго зьнікнуць з дадатку. Гэты перамыкач выкарыстоўваюць іншыя дадаткі календара, таму яны таксама яго схаваюць. Нічога не выдаляецца, гэта не ўплывае на іншыя прылады, і вы можаце ўключыць яго зноў тут у які заўгодна час.</string>
|
||||||
|
<string name="calendars_visibility_a11y">Паказаць «%1$s»</string>
|
||||||
|
<string name="calendars_visibility_notice_title">Некаторыя календары адключаныя</string>
|
||||||
|
<string name="calendars_visibility_notice_message">Calendula паказвае календары, уключаныя на гэтай прыладзе, а некаторыя з вашых адключаны. Каб убачыць падзеі ў іх, уключэце іх у разьдзеле «Налады» → «Календары».</string>
|
||||||
|
<string name="calendar_picker_missing_title">Не хапае календара?</string>
|
||||||
|
<string name="calendar_picker_missing_summary">Магчыма ён адключаны, у рэжыме толькі чытаньня, або запоўнены з вашых кантактаў — кіруйце календарамі тут.</string>
|
||||||
|
<string name="calendars_state_read_only">Толькі чытаньне</string>
|
||||||
|
<string name="settings_month_header">Месяц</string>
|
||||||
|
<string name="settings_month_view_style">Стыль прагляду месяца</string>
|
||||||
|
<string name="month_style_paged">Старонкі</string>
|
||||||
|
<string name="month_style_paged_summary">Адзін месяц на ўвесь экран. Каб пабачыць іншы месяц, праводзьце пальцам улева або ўправа.</string>
|
||||||
|
<string name="month_style_continuous">Месяцы з прагорткай</string>
|
||||||
|
<string name="month_style_continuous_summary">Кожны месяц мае ўласны загаловак, яны разьдзяляюцца невялікімі прамежкамі.</string>
|
||||||
|
<string name="month_style_dense">Тыдні з плынным пераходам</string>
|
||||||
|
<string name="month_style_dense_summary">Тыдні йдуць без перапынку, кожны месяц перацякае ў наступны без прагалу паміж імі.</string>
|
||||||
|
<string name="month_style_split">Разьдзельны</string>
|
||||||
|
<string name="month_style_split_summary">Кампактная сетка пазначае дні з падзеямі, а дзень, на які вы націскаеце, адлюстроўваецца ўнізе.</string>
|
||||||
|
<string name="month_split_no_events">Нічога не заплянавана</string>
|
||||||
|
<string name="month_split_expand">Паказваць месяц цалкам</string>
|
||||||
|
<string name="month_split_collapse">Паказваць падзеі дня</string>
|
||||||
|
<string name="settings_quick_switch_header">Кнопка хуткага пераключэньня</string>
|
||||||
|
<string name="settings_quick_switch_hint">Выбраць рэжымы прагляду для цыклічнага пераключэньня кнопкай у верхнім правым куце. Перацягваць для зьмены парадку. Адключаныя рэжымы прагляду застаюцца даступнымі праз мэню навігацыі</string>
|
||||||
|
<string name="settings_drawer_order_header">Мэню навігацыі</string>
|
||||||
|
<string name="settings_drawer_order_hint">Перацягваць для зьмены парадку рэжымаў прагляду, пералічаных у мэню навігацыі.</string>
|
||||||
|
<string name="reorder_drag_handle">Перацягваць для зьмены парадку</string>
|
||||||
|
<string name="settings_section_event_form">Шаблён новай падзеі</string>
|
||||||
|
<string name="settings_form_fields_hint">Палі, якія паказваюцца па змаўчаньні; усе астатнія знаходзіцца ў раздзеле «Дадатковыя палі»</string>
|
||||||
|
<string name="settings_autofocus_title">Фокус на загалоўку новай падзеі</string>
|
||||||
|
<string name="settings_autofocus_title_hint">Пры старце новай падзеі курсор зьмяшчаецца ў полі загалоўка, адразу ж адкрываецца клявіятура.</string>
|
||||||
|
<string name="settings_event_duration">Працягласьць па змаўчаньні</string>
|
||||||
|
<string name="settings_event_duration_hint">Колькі часу працягваецца новая падзея, пакуль ня будзе зьменены час заканчэньня. На падзеі на цэлы дзень гэта не ўплывае.</string>
|
||||||
|
<string name="settings_calendar_durations_title">Працягласьць для кожнага календара</string>
|
||||||
|
<string name="settings_calendar_durations_hint">Задаць календару ўласную працягласьць па змаўчаньні — напр. 8 гадз. для працоўных зьменаў.</string>
|
||||||
|
<string name="settings_calendar_duration_inherits">Па змаўчаньні (%1$s)</string>
|
||||||
|
<string name="settings_calendar_duration_use_default">Ужываць працягласьць па змаўчаньні (%1$s)</string>
|
||||||
|
<string name="settings_color_unsupported">Дазволіць колеры ў непадтрыманых календарох</string>
|
||||||
|
<string name="settings_color_unsupported_hint">У некаторых календарох (напрыклад, у некаторых CalDAV) няма апублікаванага набору колераў; карыстальніцкі колер падзеі можа быць выдалены або перазапісаны пры наступнай сынхранізацыі. Гэтае абмежаваньне падобных календароў Calendula ня ў стане выправіць.</string>
|
||||||
|
<string name="settings_section_notifications">Абвесткі</string>
|
||||||
|
<string name="settings_reminders">Напаміны пра падзеі</string>
|
||||||
|
<string name="settings_reminders_hint">Напаміны зьяўляюцца двойчы? Іх таксама публікуе іншы дадатак календара — адключэце іх у адным з двух.</string>
|
||||||
|
<string name="settings_default_reminder">Напамін па змаўчаньні</string>
|
||||||
|
<string name="settings_default_reminder_allday">Падзеі на цэлы дзень</string>
|
||||||
|
<string name="settings_allday_reminder_time">Час напаміну аб падзеях на цэлы дзень</string>
|
||||||
|
<string name="settings_allday_reminder_time_hint">Напаміны аб падзеях на цэлы дзень зьяўляюцца ў %1$s</string>
|
||||||
|
<string name="settings_allday_reminder_fires_at">Час абвесткі: %1$s</string>
|
||||||
|
<string name="reminder_none">Няма</string>
|
||||||
|
<string name="reminder_use_default">Выкарыстоўваць напамін па змаўчаньні</string>
|
||||||
|
<string name="reminder_custom_amount">Велічыня</string>
|
||||||
|
<string name="reminder_custom_set">Задаць</string>
|
||||||
|
<string name="settings_calendar_reminders_title">Напаміны для асобных календароў</string>
|
||||||
|
<string name="settings_calendar_reminders_hint">Перавызначыць наладу па змаўчаньні для кожнага календара — асобна для падзеяў з пэўным часам і на цэлы дзень. Каляндар можа захаваць наладу па змаўчаньні, выдаліць яе або задаць сваю ўласную.</string>
|
||||||
|
<string name="settings_calendar_reminder_inherits">Па змаўчаньні (%1$s)</string>
|
||||||
|
<string name="settings_reliable_delivery">Надзейная дастаўка</string>
|
||||||
|
<string name="settings_reliable_delivery_hint">Сыстэма Android можа затрымліваць напаміны ў мэтах ашчаднага расходу зараду акумулятара. Зрабеце выключэньне для Calendula, каб яны зьяўляліся своечасова.</string>
|
||||||
|
<string name="settings_reliable_delivery_exempt">Выключэньне з рэжыму аптымізацыі акумулятара — напаміны прыходзяць своечасова.</string>
|
||||||
|
<string name="settings_snooze_duration">Працягласць адкладу</string>
|
||||||
|
<string name="settings_section_calendars">Календары</string>
|
||||||
|
<string name="settings_manage_calendars">Кіраваньне календарамі</string>
|
||||||
|
<string name="settings_manage_calendars_hint">Стварэньне лякальных календароў; кіраваньне сынхранізаванымі</string>
|
||||||
|
<string name="settings_section_language">Мова</string>
|
||||||
|
<string name="settings_language">Мова дадатку</string>
|
||||||
|
<string name="settings_language_auto">Сыстэмная</string>
|
||||||
|
<string name="settings_translate">Дапамагчы зь перакладам</string>
|
||||||
|
<string name="settings_translate_hint">Дадаць мову або палепшыць пераклад у Weblate</string>
|
||||||
|
<string name="settings_group_look">Зьнешні выгляд і паводзіны</string>
|
||||||
|
<string name="settings_group_data">Даныя</string>
|
||||||
|
<string name="settings_group_app">Дадатак</string>
|
||||||
|
<string name="settings_group_about">Аб дадатку</string>
|
||||||
|
<string name="settings_appearance_subtitle">Тэма, колеры, шрыфты</string>
|
||||||
|
<string name="settings_views_subtitle">Выгляд, макет, парадак па змаўчаньні</string>
|
||||||
|
<string name="settings_event_form_subtitle">Палі і паводзіны па змаўчаньні</string>
|
||||||
|
<string name="settings_notifications_subtitle">Напаміны і дастаўка</string>
|
||||||
|
<string name="settings_special_dates_subtitle">Дні нараджэньня і юбілейныя даты кантактаў</string>
|
||||||
|
<string name="settings_widgets_subtitle">Віджэт раскладу, плітка хуткіх налад</string>
|
||||||
|
<string name="settings_backup_subtitle">Экспарт, імпарт, аўтаматычнае рэзэрвовае капіяваньне</string>
|
||||||
|
<string name="settings_section_widgets">Віджэты і пліткі</string>
|
||||||
|
<string name="settings_widgets_hint">Парамэтры віджэтаў на галоўным экране і пліткі хуткіх наладаў. Дадаць віджэты на галоўны экран праз доўгае націсканьне.</string>
|
||||||
|
<string name="settings_section_backup">Рэзервовае капіяваньне і аднаўленьне</string>
|
||||||
|
<string name="settings_views_all_header">Усе рэжымы прагляду</string>
|
||||||
|
<string name="settings_week_day_header">Тыдзень і дзень</string>
|
||||||
|
<string name="settings_default_view_hint">Рэжым прагляду пры запуску Calendula.</string>
|
||||||
|
<string name="settings_week_start_hint">Дзень пачатку тыдня для ўсіх рэжымаў прагляду і віджэтаў.</string>
|
||||||
|
<string name="settings_time_format_hint">Як запісваецца час у дадатку. Аўтаматычна — значыць у адпаведнасьці з наладамі вашай сыстэмы.</string>
|
||||||
|
<string name="settings_past_events_hint">Што адбываецца з завершанымі падзеямі распарадку.</string>
|
||||||
|
<string name="settings_dynamic_color_summary">Колеры для дадатку са шпалераў.</string>
|
||||||
|
<string name="settings_section_special_dates">Асаблівыя даты кантактаў</string>
|
||||||
|
<string name="settings_special_dates_enable">Паказваць даты кантактаў</string>
|
||||||
|
<string name="settings_special_dates_enable_hint">Адлюстроўваць дні нараджэньня і іншыя даты вашых кантактаў у лякальных календарох. Чытаюцца толькі кантакты на гэтай прыладзе — нічога не запампоўваецца, вашы кантакты ніколі не зьмяняюцца.</string>
|
||||||
|
<string name="calendars_state_not_synced">Не сынхранізавана з гэтай прыладай</string>
|
||||||
|
<string name="calendars_state_managed">Запоўнена з вашых кантактаў</string>
|
||||||
|
<string name="calendars_managed_delete_locked">Гэты каляндар запоўнены данымі з вашых кантактаў, таму Calendula створыць яго зноў пры наступнай сынхранізацыі. Каб выдаліць яго, адключэце асаблівыя даты ў меню «Налады» → «Асаблівыя даты».</string>
|
||||||
|
<string name="calendars_synced_header">Сынхранізаваныя календары</string>
|
||||||
|
<string name="calendars_synced_hint">Яны паступаюць з уліковых запісаў на вашай прыладзе. Стварайце і рэдагуйце іх у асобным дадатку.</string>
|
||||||
|
<string name="calendars_manage_in_app">Кіраваць у дадатку</string>
|
||||||
|
<string name="calendars_account_from_source">%1$s (%2$s)</string>
|
||||||
|
<string name="calendars_account_menu_a11y">Дадатковыя парамэтры для %1$s</string>
|
||||||
|
<string name="calendars_enable_all">Уключыць усе</string>
|
||||||
|
<string name="calendars_disable_all">Адключыць усе</string>
|
||||||
|
<string name="calendars_add_account">Дадаць ул. запіс</string>
|
||||||
|
<string name="calendars_new_title">Новы каляндар</string>
|
||||||
|
<string name="calendars_edit_title">Рэдагаваць каляндар</string>
|
||||||
|
<string name="calendars_name_label">Назва</string>
|
||||||
|
<string name="calendars_color_label">Колер</string>
|
||||||
|
<string name="calendars_description_hint">Дадаць апісаньне</string>
|
||||||
|
<string name="calendars_delete_confirm_title">Выдаліць каляндар?</string>
|
||||||
|
<string name="calendars_delete_confirm_message">Каляндар «%1$s» зь ягонымі падзеямі назаўжды выдалены з гэтай прылады.</string>
|
||||||
|
<string name="calendars_write_error">Не атрымалася захаваць зьмены.</string>
|
||||||
|
<string name="calendars_backup_header">Рэзэрвовае капіяваньне</string>
|
||||||
|
<string name="calendars_backup_hint">Лякальныя календары ні з чым не сынхранізуюцца, таму экспартуйце іх у файл .ics, каб захаваць копію.</string>
|
||||||
|
<string name="calendars_backup_action">Экспарт у файл .ics</string>
|
||||||
|
<string name="calendars_export_title">Экспартаваць календары</string>
|
||||||
|
<string name="calendars_export_hint">Выбраць календары для запісу ў файл .ics.</string>
|
||||||
|
<string name="calendars_export_action">Экспарт</string>
|
||||||
|
<string name="calendars_restore_header">Аднавіць</string>
|
||||||
|
<string name="calendars_restore_action">Аднавіць з файла .ics</string>
|
||||||
|
<string name="calendars_restore_hint">Імпартаваць падзеі з рэзэрвовай копіі або іншага дадатку календара.</string>
|
||||||
|
<string name="calendars_auto_backup">Аўтаматычнае рэзэрвовае капіяваньне</string>
|
||||||
|
<string name="calendars_auto_backup_hint">Пэрыядычны экспарт лякальных календароў у каталёг у выглядзе файла .ics.</string>
|
||||||
|
<string name="calendars_auto_backup_folder">Каталёг рэзэрвовага капіяваньня</string>
|
||||||
|
<string name="calendars_auto_backup_folder_unset">Націсьніце, каб выбраць каталёг</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -43,25 +43,25 @@
|
|||||||
<string name="day_today_action">Dnes</string>
|
<string name="day_today_action">Dnes</string>
|
||||||
<string name="event_detail_back">Zpět</string>
|
<string name="event_detail_back">Zpět</string>
|
||||||
<string name="event_detail_edit">Upravit</string>
|
<string name="event_detail_edit">Upravit</string>
|
||||||
<string name="event_detail_delete">Vymazat</string>
|
<string name="event_detail_delete">Odstranit</string>
|
||||||
<string name="event_detail_share">Sdílet</string>
|
<string name="event_detail_share">Sdílet</string>
|
||||||
<string name="event_detail_duplicate">Kopírovat</string>
|
<string name="event_detail_duplicate">Kopírovat</string>
|
||||||
<string name="event_share_chooser_title">Sdílet událost</string>
|
<string name="event_share_chooser_title">Sdílet událost</string>
|
||||||
<string name="event_share_failed">Tuto událost nelze sdílet.</string>
|
<string name="event_share_failed">Tuto událost nelze sdílet.</string>
|
||||||
<string name="event_delete_title">Odstranit událost?</string>
|
<string name="event_delete_title">Odstranit událost?</string>
|
||||||
<string name="event_delete_body">Událost je odstraněna z kalendáře i ze všech synchronizovaných zařízení.</string>
|
<string name="event_delete_body">Událost je odstraněna z kalendáře i ze všech synchronizovaných zařízení.</string>
|
||||||
<string name="event_delete_recurring_title">Smazat opakující se událost</string>
|
<string name="event_delete_recurring_title">Odstranit opakující se událost</string>
|
||||||
<string name="event_delete_option_occurrence">Pouze tuto událost</string>
|
<string name="event_delete_option_occurrence">Pouze tuto událost</string>
|
||||||
<string name="event_delete_option_following">Tuto a všechny následující události</string>
|
<string name="event_delete_option_following">Tuto a všechny následující události</string>
|
||||||
<string name="event_delete_option_series">Všechny události v sérii</string>
|
<string name="event_delete_option_series">Všechny události v sérii</string>
|
||||||
<string name="event_edit_recurring_title">Upravit opakující se událost</string>
|
<string name="event_edit_recurring_title">Upravit opakující se událost</string>
|
||||||
<string name="event_delete_failed">Tuto událost nelze odstranit</string>
|
<string name="event_delete_failed">Tuto událost nelze odstranit</string>
|
||||||
<string name="event_delete_write_denied">Calendula potřebuje oprávnění k zápisu, aby mohla mazat události</string>
|
<string name="event_delete_write_denied">Calendula potřebuje oprávnění k zápisu, aby mohla odstraňovat události</string>
|
||||||
<string name="event_move_action">Přesunout…</string>
|
<string name="event_move_action">Přesunout…</string>
|
||||||
<string name="event_move_recurring_title">Přesunout opakující se událost</string>
|
<string name="event_move_recurring_title">Přesunout opakující se událost</string>
|
||||||
<string name="event_move_occurrence_only">Přesunutí celé série by rozhodilo pořadí dnů, na které série připadá, přesunout lze tedy pouze tuto událost.</string>
|
<string name="event_move_occurrence_only">Přesunutí celé série by rozhodilo pořadí dnů, na které série připadá, přesunout lze tedy pouze tuto událost.</string>
|
||||||
<string name="event_move_done">Přesunuto do %1$s</string>
|
<string name="event_move_done">Přesunuto do %1$s</string>
|
||||||
<string name="event_move_undo">Zpět</string>
|
<string name="event_move_undo">Vrátit</string>
|
||||||
<string name="event_move_undone">Vráceno zpět</string>
|
<string name="event_move_undone">Vráceno zpět</string>
|
||||||
<string name="event_move_failed">Událost nelze přesunout</string>
|
<string name="event_move_failed">Událost nelze přesunout</string>
|
||||||
<string name="event_move_write_denied">Calendula k přesouvání události vyžaduje oprávnění k zápisu</string>
|
<string name="event_move_write_denied">Calendula k přesouvání události vyžaduje oprávnění k zápisu</string>
|
||||||
@@ -111,7 +111,7 @@
|
|||||||
<string name="event_edit_color">Barva</string>
|
<string name="event_edit_color">Barva</string>
|
||||||
<string name="event_edit_color_default">Barva kalendáře</string>
|
<string name="event_edit_color_default">Barva kalendáře</string>
|
||||||
<string name="event_edit_color_custom">Vlastní barva</string>
|
<string name="event_edit_color_custom">Vlastní barva</string>
|
||||||
<string name="event_edit_color_reset">Obnovit</string>
|
<string name="event_edit_color_reset">Znovu nastavit</string>
|
||||||
<string name="event_edit_color_unsupported">Pro tento kalendář není k dispozici</string>
|
<string name="event_edit_color_unsupported">Pro tento kalendář není k dispozici</string>
|
||||||
<string name="event_edit_color_unsupported_hint">Tento kalendář nemá žádnou sadu barev. Vlastní barvy kalendářů můžete povolit v nastavení.</string>
|
<string name="event_edit_color_unsupported_hint">Tento kalendář nemá žádnou sadu barev. Vlastní barvy kalendářů můžete povolit v nastavení.</string>
|
||||||
<string name="event_edit_color_sync_warning">Tato barva může být během příští synchronizace kalendáře ztracena nebo přepsána.</string>
|
<string name="event_edit_color_sync_warning">Tato barva může být během příští synchronizace kalendáře ztracena nebo přepsána.</string>
|
||||||
@@ -121,8 +121,8 @@
|
|||||||
<string name="event_edit_conflict_overwrite_hint">Pouze u upravených polí dojde k přepsání vnějšího zdroje</string>
|
<string name="event_edit_conflict_overwrite_hint">Pouze u upravených polí dojde k přepsání vnějšího zdroje</string>
|
||||||
<string name="event_edit_conflict_discard">Zrušit změny</string>
|
<string name="event_edit_conflict_discard">Zrušit změny</string>
|
||||||
<string name="event_edit_conflict_discard_hint">Událost zůstává beze změny</string>
|
<string name="event_edit_conflict_discard_hint">Událost zůstává beze změny</string>
|
||||||
<string name="event_edit_gone_title">Událost byla smazána</string>
|
<string name="event_edit_gone_title">Událost byla odstraněna</string>
|
||||||
<string name="event_edit_gone_body">Během úpravy došlo ke smazání události, například pomocí jiného zařízení. Vaše změny již nelze uložit.</string>
|
<string name="event_edit_gone_body">Během úpravy došlo k odstranění události, například pomocí jiného zařízení. Vaše změny již nelze uložit.</string>
|
||||||
<string name="import_reminder_prompt_title">Chcete použít výchozí upomínku?</string>
|
<string name="import_reminder_prompt_title">Chcete použít výchozí upomínku?</string>
|
||||||
<string name="import_reminder_prompt_body_none">Tato událost byla importována bez nastavené upomínky.</string>
|
<string name="import_reminder_prompt_body_none">Tato událost byla importována bez nastavené upomínky.</string>
|
||||||
<plurals name="import_reminder_prompt_body_existing">
|
<plurals name="import_reminder_prompt_body_existing">
|
||||||
@@ -250,4 +250,86 @@
|
|||||||
<string name="onboarding_backup_benefit_folder_title">Složka, kterou si vyberete</string>
|
<string name="onboarding_backup_benefit_folder_title">Složka, kterou si vyberete</string>
|
||||||
<string name="onboarding_backup_benefit_folder_body">Zálohy jsou obyčejné soubory v .ics formátu — uložte je někam, kde se synchronizují, nebo na SD kartu.</string>
|
<string name="onboarding_backup_benefit_folder_body">Zálohy jsou obyčejné soubory v .ics formátu — uložte je někam, kde se synchronizují, nebo na SD kartu.</string>
|
||||||
<string name="onboarding_backup_benefit_daily_title">Jednou denně, automaticky</string>
|
<string name="onboarding_backup_benefit_daily_title">Jednou denně, automaticky</string>
|
||||||
|
<string name="onboarding_backup_benefit_daily_body">Calendula exportuje vaše místní kalendáře na pozadí. Frekvenci lze změnit v nastavení.</string>
|
||||||
|
<string name="onboarding_backup_enable_button">Vyberte složku a proveďte zálohu</string>
|
||||||
|
<string name="onboarding_backup_skip_button">Teď ne</string>
|
||||||
|
<string name="onboarding_view_title">Co by se mělo otevřít nejdříve?</string>
|
||||||
|
<string name="onboarding_month_style_title">Jak by mělo vypadat měsíční rozvržení?</string>
|
||||||
|
<string name="onboarding_view_continue_button">Pokračovat</string>
|
||||||
|
<string name="onboarding_back">Zpět</string>
|
||||||
|
<string name="onboarding_visibility_button">Jasně</string>
|
||||||
|
<string name="onboarding_done_title">Vše je připraveno</string>
|
||||||
|
<string name="onboarding_done_body">Vaše kalendáře jsou připraveny. Veškeré volby lze později změnit v nastavení.</string>
|
||||||
|
<string name="onboarding_done_button">Otevřít můj kalendář</string>
|
||||||
|
<string name="reminder_channel_name">Upomínky událostí</string>
|
||||||
|
<string name="reminder_channel_description">Oznámení v časech upomínek vašich událostí</string>
|
||||||
|
<string name="reminder_onboarding_title">Ať vám neunikne žádná událost</string>
|
||||||
|
<string name="reminder_onboarding_body">Android sám o sobě nezobrazuje upomínky událostí — to musí zajistit aplikace kalendáře. Nechte tuto práci na Calendule.</string>
|
||||||
|
<string name="reminder_benefit_delivery_title">Upomínky, doručené</string>
|
||||||
|
<string name="reminder_benefit_delivery_body">Upomínky k vašim událostem vám přijdou v danou dobu jako oznámení.</string>
|
||||||
|
<string name="reminder_benefit_duplicates_title">Používáte ještě nějakou jinou aplikaci s kalendářem?</string>
|
||||||
|
<string name="reminder_benefit_duplicates_body">Pokud i jiná aplikace odesílá upomínky, uvidíte je dvakrát — v jedné z aplikací je musíte vypnout.</string>
|
||||||
|
<string name="reminder_benefit_reversible_title">Lze kdykoliv změnit</string>
|
||||||
|
<string name="reminder_benefit_reversible_body">Tento přepínač naleznete v Nastavení, v části Oznámení.</string>
|
||||||
|
<string name="reminder_onboarding_enable_button">Zapnout upomínky</string>
|
||||||
|
<string name="reminder_onboarding_skip_button">Později</string>
|
||||||
|
<string name="reminder_action_snooze">Odložit</string>
|
||||||
|
<string name="reminder_action_dismiss">Zrušit</string>
|
||||||
|
<string name="reminder_day_tomorrow">Zítra</string>
|
||||||
|
<string name="reminder_day_yesterday">Včera</string>
|
||||||
|
<string name="view_month">Měsíc</string>
|
||||||
|
<string name="view_week">Týden</string>
|
||||||
|
<string name="view_day">Den</string>
|
||||||
|
<string name="view_agenda">Program</string>
|
||||||
|
<string name="view_section">Zobrazení</string>
|
||||||
|
<string name="drawer_jump_to_date">Přejít na datum</string>
|
||||||
|
<string name="agenda_today_action">Dnes</string>
|
||||||
|
<string name="agenda_header_today">Dnes</string>
|
||||||
|
<string name="agenda_header_tomorrow">Zítra</string>
|
||||||
|
<string name="agenda_empty_title">Teď už jsi v obraze</string>
|
||||||
|
<string name="agenda_no_more_today">Dnes bez dalších událostí</string>
|
||||||
|
<string name="agenda_span_starts">Začátek %1$s</string>
|
||||||
|
<string name="agenda_span_ends">Konec %1$s</string>
|
||||||
|
<string name="today_jump_action">Přejít na dnešek</string>
|
||||||
|
<string name="search_action">Hledat</string>
|
||||||
|
<string name="search_hint">Vyhledat události</string>
|
||||||
|
<string name="search_back">Zpět</string>
|
||||||
|
<string name="search_clear">Vymazat</string>
|
||||||
|
<string name="search_idle_hint">Prohledávejte své události podle názvu, místa konání nebo poznámek.</string>
|
||||||
|
<string name="search_empty">Žádná událost neodpovídá “%1$s”.</string>
|
||||||
|
<plurals name="search_selected_count">
|
||||||
|
<item quantity="one">%d označený</item>
|
||||||
|
<item quantity="few">%d označené</item>
|
||||||
|
<item quantity="many">%d označených</item>
|
||||||
|
<item quantity="other">%d označených</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="search_in_descriptions">Nalezeno v popisech</string>
|
||||||
|
<string name="search_selection_close">Zrušit výběr</string>
|
||||||
|
<string name="search_select_all">Vybrat vše</string>
|
||||||
|
<string name="search_delete_selected">Odstranit vybrané</string>
|
||||||
|
<plurals name="search_delete_title">
|
||||||
|
<item quantity="one">Odstranit %d událost?</item>
|
||||||
|
<item quantity="few">Odstranit %d události?</item>
|
||||||
|
<item quantity="many">Odstranit %d událostí?</item>
|
||||||
|
<item quantity="other">Odstranit %d událostí?</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="search_delete_done">
|
||||||
|
<item quantity="one">%d událost odstraněna</item>
|
||||||
|
<item quantity="few">%d události odstraněny</item>
|
||||||
|
<item quantity="many">%d událostí odstraněno</item>
|
||||||
|
<item quantity="other">%d událostí odstraněno</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="search_delete_partial">%1$d odstraněno, u %2$d se nepodařilo</string>
|
||||||
|
<string name="search_delete_denied_partial">%1$d odstraněno, poté byl odepřen přístup pro zápis</string>
|
||||||
|
<string name="settings_special_dates_disable_all_message">Tímto se smažou kalendáře spojené s určitým kontaktem a všechny události v nich obsažené. Veškerá upomínky a poznámky, které jste do nich přidali, budou ztraceny.</string>
|
||||||
|
<string name="widget_agenda_title">Nadcházející</string>
|
||||||
|
<string name="widget_agenda_label">Události Calendula</string>
|
||||||
|
<string name="widget_month_label">Měsíc Calendula</string>
|
||||||
|
<string name="widget_refresh">Obnovit</string>
|
||||||
|
<string name="widget_new_event">Nová událost</string>
|
||||||
|
<string name="widget_needs_permission">Otevřete Calendulu a povolte přístup ke kalendáři</string>
|
||||||
|
<string name="widget_prev_month">Předchozí měsíc</string>
|
||||||
|
<string name="widget_next_month">Následující měsíc</string>
|
||||||
|
<string name="widget_today">Dnes</string>
|
||||||
|
<string name="filter_title">Kalendáře</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
65
app/src/main/res/values-hu/strings.xml
Normal file
65
app/src/main/res/values-hu/strings.xml
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string-array name="backup_interval_units">
|
||||||
|
<item>Minutes</item>
|
||||||
|
<item>Hours</item>
|
||||||
|
<item>Days</item>
|
||||||
|
<item>Weeks</item>
|
||||||
|
</string-array>
|
||||||
|
<string name="app_name">Calendula</string>
|
||||||
|
<string name="app_tagline">Egy modern naptár.</string>
|
||||||
|
<string name="state_loading">Töltés…</string>
|
||||||
|
<string name="state_retry">Újra</string>
|
||||||
|
<string name="state_failure_unknown">Hiba történt.</string>
|
||||||
|
<string name="state_failure_permission">Naptár hozzáférés szükséges.</string>
|
||||||
|
<string name="state_failure_permission_action">Hozzáférés engedélyezése</string>
|
||||||
|
<string name="state_failure_no_calendars">Nincs naptár beállítva.</string>
|
||||||
|
<string name="state_failure_no_calendars_action">Rendszernaptár beállítások megnyitása</string>
|
||||||
|
<string name="state_failure_provider">A naptárat nem lehetett beolvasni.</string>
|
||||||
|
<string name="permission_rationale_title">Lásd az eseményeid, szépen</string>
|
||||||
|
<string name="permission_rationale_body">A Calendula alkalmazásnak hozzáférésre van szüksége a naptáradhoz az események megjelenítéséhez és kezeléséhez.</string>
|
||||||
|
<string name="permission_request_button">Naptár hozzáférés engedélyezése</string>
|
||||||
|
<string name="permission_denied_title">Naptár hozzáférés megtagadva</string>
|
||||||
|
<string name="permission_denied_body">A Calendula nem tud eseményeket megjeleníteni naptár-hozzáférés nélkül. Ezt a jogosultságot a rendszerbeállításokban adhatja meg újra.</string>
|
||||||
|
<string name="permission_open_settings_button">Rendszerbeállítások megnyitása</string>
|
||||||
|
<string name="permission_retry_button">Próbáld újra</string>
|
||||||
|
<string name="permission_benefit_private_title">Eleve privát</string>
|
||||||
|
<string name="permission_benefit_private_body">Nem kell internetelérés, nincs telemetria — semmi sem hagyja el a telefonod.</string>
|
||||||
|
<string name="permission_benefit_sync_title">Az összes naptárad, egy helyen</string>
|
||||||
|
<string name="permission_benefit_sync_body">Google, CalDAV, helyi — minden, ami szinkronizálva van az eszközzel, egyszerűen megjelenik.</string>
|
||||||
|
<string name="permission_benefit_privacy_body">Nulla telemetria, nulla analitika, nulla hirdetés.</string>
|
||||||
|
<string name="permission_privacy_footnote">Az eszközödön marad · nem kell internetelérés</string>
|
||||||
|
<string name="month_prev">Előző hónap</string>
|
||||||
|
<string name="month_next">Következő hónap</string>
|
||||||
|
<string name="month_today_action">Ma</string>
|
||||||
|
<string name="month_more_actions">További műveletek</string>
|
||||||
|
<string name="month_open_menu">Menü megnyitása</string>
|
||||||
|
<string name="month_action_settings">Beállítások</string>
|
||||||
|
<string name="month_a11y_today_prefix">Ma</string>
|
||||||
|
<string name="week_today_action">Ez a hét</string>
|
||||||
|
<string name="week_number_label">Hét</string>
|
||||||
|
<string name="day_today_action">Ma</string>
|
||||||
|
<string name="event_detail_back">Vissza</string>
|
||||||
|
<string name="event_detail_edit">Szerkeszt</string>
|
||||||
|
<string name="event_detail_delete">Töröl</string>
|
||||||
|
<string name="event_detail_share">Megoszt</string>
|
||||||
|
<string name="event_detail_duplicate">Másolat</string>
|
||||||
|
<string name="event_share_chooser_title">Esemény megosztása</string>
|
||||||
|
<string name="event_share_failed">Ezt az eseményt nem lehet megosztani.</string>
|
||||||
|
<string name="event_delete_title">Esemény törlése?</string>
|
||||||
|
<string name="event_delete_body">Az esemény törlődik a naptárból és minden olyan eszközről, amelyhez szinkronizálódik.</string>
|
||||||
|
<string name="event_delete_recurring_title">Ismétlődő esemény törlése</string>
|
||||||
|
<string name="event_delete_option_occurrence">Csak ezt az eseményt</string>
|
||||||
|
<string name="event_delete_option_following">Ezt és az összes további eseményt</string>
|
||||||
|
<string name="event_delete_option_series">A sorozat összes eseményét</string>
|
||||||
|
<string name="event_edit_recurring_title">Ismétlődő esemény szerkesztése</string>
|
||||||
|
<string name="event_delete_failed">Nem sikerült törölni az eseményt</string>
|
||||||
|
<string name="event_delete_write_denied">A Calendula írási hozzáférést igényel az események törléséhez</string>
|
||||||
|
<string name="event_move_action">Áthelyez…</string>
|
||||||
|
<string name="event_move_recurring_title">Ismétlődő esemény áthelyezése</string>
|
||||||
|
<string name="event_move_occurrence_only">A teljes sorozat áthelyezése megváltoztatná, hogy mely napokra esik, így csak ez az esemény helyezhető át.</string>
|
||||||
|
<string name="event_move_done">Áthelyezve ide: %1$s</string>
|
||||||
|
<string name="event_move_undo">Visszavon</string>
|
||||||
|
<string name="event_move_undone">Visszavont áthelyezése</string>
|
||||||
|
<string name="event_detail_calendar">Naptár</string>
|
||||||
|
</resources>
|
||||||
@@ -133,7 +133,7 @@
|
|||||||
<string name="import_reminder_prompt_apply">Применить стандартное</string>
|
<string name="import_reminder_prompt_apply">Применить стандартное</string>
|
||||||
<string name="import_reminder_prompt_keep">Оставить как есть</string>
|
<string name="import_reminder_prompt_keep">Оставить как есть</string>
|
||||||
<string name="event_edit_recurrence_none">Не повторяется</string>
|
<string name="event_edit_recurrence_none">Не повторяется</string>
|
||||||
<string name="event_edit_recurrence_custom">Свой</string>
|
<string name="event_edit_recurrence_custom">Другое</string>
|
||||||
<string name="event_edit_recurrence_every">Каждый</string>
|
<string name="event_edit_recurrence_every">Каждый</string>
|
||||||
<string name="recurrence_unit_days">дни</string>
|
<string name="recurrence_unit_days">дни</string>
|
||||||
<string name="recurrence_unit_weeks">недели</string>
|
<string name="recurrence_unit_weeks">недели</string>
|
||||||
@@ -167,13 +167,181 @@
|
|||||||
<string name="event_attendee_declined">Отклонено</string>
|
<string name="event_attendee_declined">Отклонено</string>
|
||||||
<string name="event_attendee_needs_action">Без ответа</string>
|
<string name="event_attendee_needs_action">Без ответа</string>
|
||||||
<string name="event_detail_reminders">Напоминания</string>
|
<string name="event_detail_reminders">Напоминания</string>
|
||||||
<string name="event_detail_timezone">Временная зона</string>
|
<string name="event_detail_timezone">Часовой пояс</string>
|
||||||
<string name="event_status_cancelled">Отменён</string>
|
<string name="event_status_cancelled">Отменён</string>
|
||||||
<string name="event_availability_free">Бесплатно</string>
|
<string name="event_availability_free">Свободен/свободна</string>
|
||||||
<string name="event_access_private">Приватно</string>
|
<string name="event_access_private">Личное</string>
|
||||||
<string name="event_access_confidential">Конфиденциально</string>
|
<string name="event_access_confidential">Конфиденциальное</string>
|
||||||
<string name="event_access_public_summary">Любой с доступом может видеть все детали</string>
|
<string name="event_access_public_summary">Общедоступные детали</string>
|
||||||
<string name="event_access_private_summary">Другие видят только, что вы заняты</string>
|
<string name="event_access_private_summary">Другим показывается, что вы заняты</string>
|
||||||
<string name="event_detail_self_response">Ваш ответ: %1$s</string>
|
<string name="event_detail_self_response">Ваш ответ: %1$s</string>
|
||||||
<string name="reminder_at_time">Во время начала события</string>
|
<string name="reminder_at_time">Во время начала события</string>
|
||||||
|
<string name="event_edit_recurrence_next">Следующий: %1$s</string>
|
||||||
|
<string name="event_edit_recurrence_times">раз</string>
|
||||||
|
<string name="event_detail_attendees">Участники</string>
|
||||||
|
<string name="event_detail_recurrence">Повторяется</string>
|
||||||
|
<string name="event_edit_error_recurrence_ends_before_start">Повторы заканчиваются до начала мероприятия</string>
|
||||||
|
<string name="event_attendee_unknown">—</string>
|
||||||
|
<string name="event_access_default_summary">Стандартная настройка календаря</string>
|
||||||
|
<string name="event_access_confidential_summary">Помечено как личное; что это означает, зависит от учетной записи календаря</string>
|
||||||
|
<string name="event_attendee_organizer">Организатор</string>
|
||||||
|
<string name="event_attendee_optional">Необязательно</string>
|
||||||
|
<string name="reminder_default">Уведомление по умолчанию</string>
|
||||||
|
<plurals name="reminder_minutes">
|
||||||
|
<item quantity="one">%d минута до</item>
|
||||||
|
<item quantity="few">%d минуты до</item>
|
||||||
|
<item quantity="many">%d минут до</item>
|
||||||
|
<item quantity="other">%d минут до</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_hours">
|
||||||
|
<item quantity="one">%d час до</item>
|
||||||
|
<item quantity="few">%d часа до</item>
|
||||||
|
<item quantity="many">%d часов до</item>
|
||||||
|
<item quantity="other">%d часов до</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_days">
|
||||||
|
<item quantity="one">%d день до</item>
|
||||||
|
<item quantity="few">%d дня до</item>
|
||||||
|
<item quantity="many">%d дней до</item>
|
||||||
|
<item quantity="other">%d дней до</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_weeks">
|
||||||
|
<item quantity="one">%d неделя до</item>
|
||||||
|
<item quantity="few">%d недели до</item>
|
||||||
|
<item quantity="many">%d недель до</item>
|
||||||
|
<item quantity="other">%d недель до</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_minutes">
|
||||||
|
<item quantity="one">%d минута</item>
|
||||||
|
<item quantity="few">%d минуты</item>
|
||||||
|
<item quantity="many">%d минут</item>
|
||||||
|
<item quantity="other">%d минут</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_hours">
|
||||||
|
<item quantity="one">%d час</item>
|
||||||
|
<item quantity="few">%d часа</item>
|
||||||
|
<item quantity="many">%d часов</item>
|
||||||
|
<item quantity="other">%d часов</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_days">
|
||||||
|
<item quantity="one">%d день</item>
|
||||||
|
<item quantity="few">%d дня</item>
|
||||||
|
<item quantity="many">%d дней</item>
|
||||||
|
<item quantity="other">%d дней</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="duration_hours_minutes">%1$s %2$s</string>
|
||||||
|
<string name="duration_custom_max">Максимум %1$s</string>
|
||||||
|
<plurals name="duration_weeks">
|
||||||
|
<item quantity="one">%d неделя</item>
|
||||||
|
<item quantity="few">%d недели</item>
|
||||||
|
<item quantity="many">%d недель</item>
|
||||||
|
<item quantity="other">%d недель</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="event_untitled">(Без названия)</string>
|
||||||
|
<string name="onboarding_step_counter">Шаг %1$d из %2$d</string>
|
||||||
|
<string name="onboarding_backup_body">На этом телефоне ничего не синхронизируется с учетной записью, поэтому, потеряв его, вы потеряете и свой календарь. Calendula может создать для вас резервную копию.</string>
|
||||||
|
<string name="onboarding_backup_benefit_folder_title">Папка на выбор</string>
|
||||||
|
<string name="onboarding_backup_benefit_folder_body">Резервные копии сохраняются как .ics, поэтому сохраните их в синхронизируемом месте или на SD-карте.</string>
|
||||||
|
<string name="onboarding_backup_benefit_daily_title">Раз в день</string>
|
||||||
|
<string name="onboarding_backup_benefit_daily_body">Calendula экспортирует ваши локальные калдендари в фоновом режиме. Выставите как часто в Настройках.</string>
|
||||||
|
<string name="onboarding_backup_enable_button">Выберите папку и создайте резервную копию</string>
|
||||||
|
<string name="onboarding_backup_skip_button">Не сейчас</string>
|
||||||
|
<string name="onboarding_view_title">Что открыть первым?</string>
|
||||||
|
<string name="onboarding_month_style_title">Вид месяца?</string>
|
||||||
|
<string name="onboarding_view_continue_button">Продолжить</string>
|
||||||
|
<string name="onboarding_back">Назад</string>
|
||||||
|
<string name="onboarding_visibility_button">Хорошо</string>
|
||||||
|
<string name="onboarding_done_title">Всё готово</string>
|
||||||
|
<string name="onboarding_done_body">Ваши календари готовы. Все выбранные настройки можно изменить позже.</string>
|
||||||
|
<string name="onboarding_done_button">Открыть мой календарь</string>
|
||||||
|
<string name="reminder_benefit_duplicates_title">Используете иное приложение календаря?</string>
|
||||||
|
<string name="reminder_benefit_duplicates_body">Если несколько приложений отвечают за уведомления, вы увидите дубликаты — отключите их в одном месте.</string>
|
||||||
|
<string name="reminder_onboarding_enable_button">Включить напоминания</string>
|
||||||
|
<string name="reminder_onboarding_skip_button">Не сейчас</string>
|
||||||
|
<string name="reminder_action_snooze">Отложить</string>
|
||||||
|
<string name="reminder_action_dismiss">Отключить</string>
|
||||||
|
<string name="reminder_day_tomorrow">Завтра</string>
|
||||||
|
<string name="reminder_day_yesterday">Вчера</string>
|
||||||
|
<string name="view_month">Месяц</string>
|
||||||
|
<string name="view_week">Неделя</string>
|
||||||
|
<string name="view_day">День</string>
|
||||||
|
<string name="view_agenda">Расписание</string>
|
||||||
|
<string name="view_section">Вид</string>
|
||||||
|
<string name="drawer_jump_to_date">Выбрать дату</string>
|
||||||
|
<string name="agenda_today_action">Сегодня</string>
|
||||||
|
<string name="agenda_header_today">Сегодня</string>
|
||||||
|
<string name="agenda_header_tomorrow">Завтра</string>
|
||||||
|
<string name="agenda_empty_title">Вы везде успели</string>
|
||||||
|
<string name="agenda_no_more_today">На сегодня — всё</string>
|
||||||
|
<string name="agenda_span_starts">Начало в %1$s</string>
|
||||||
|
<string name="agenda_span_ends">Конец в %1$s</string>
|
||||||
|
<string name="today_jump_action">Сегодня</string>
|
||||||
|
<string name="search_action">Поиск</string>
|
||||||
|
<string name="search_hint">Поиск мероприятий</string>
|
||||||
|
<string name="search_back">Назад</string>
|
||||||
|
<string name="search_clear">Очистить</string>
|
||||||
|
<string name="search_idle_hint">Поиск по названию, локации или описанию.</string>
|
||||||
|
<string name="search_empty">Не найдено по запросу \"%1$s\".</string>
|
||||||
|
<plurals name="search_selected_count">
|
||||||
|
<item quantity="one">%d выбран</item>
|
||||||
|
<item quantity="few">%d выбрано</item>
|
||||||
|
<item quantity="many">%d выбрано</item>
|
||||||
|
<item quantity="other">%d выбрано</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="search_in_descriptions">Найдено в описаниях</string>
|
||||||
|
<string name="search_select_all">Выбрать всё</string>
|
||||||
|
<string name="search_delete_selected">Удалить выбранное</string>
|
||||||
|
<string name="search_selection_close">Отменить выбранное</string>
|
||||||
|
<plurals name="search_delete_title">
|
||||||
|
<item quantity="one">Удалить %d мероприятие?</item>
|
||||||
|
<item quantity="few">Удалить %d мероприятия?</item>
|
||||||
|
<item quantity="many">Удалить %d мероприятий?</item>
|
||||||
|
<item quantity="other">Удалить %d мероприятий?</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="search_delete_done">
|
||||||
|
<item quantity="one">%d мероприятие удалено</item>
|
||||||
|
<item quantity="few">%d мероприятия удалено</item>
|
||||||
|
<item quantity="many">%d мероприятий удалено</item>
|
||||||
|
<item quantity="other">%d мероприятий удалено</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="search_delete_partial">%1$d удалено, %2$d не вышло</string>
|
||||||
|
<string name="widget_agenda_title">Следующее</string>
|
||||||
|
<string name="widget_agenda_label">Расписание от Calendula</string>
|
||||||
|
<string name="widget_month_label">Месяц от Calendula</string>
|
||||||
|
<string name="widget_refresh">Обновить</string>
|
||||||
|
<string name="widget_new_event">Новое мероприятие</string>
|
||||||
|
<string name="widget_needs_permission">Откройте Calendula, чтобы дать доступ к календарю</string>
|
||||||
|
<string name="widget_prev_month">Предыдущий месяц</string>
|
||||||
|
<string name="widget_next_month">Следующий месяц</string>
|
||||||
|
<string name="widget_today">Сегодня</string>
|
||||||
|
<string name="filter_title">Календари</string>
|
||||||
|
<string name="settings_title">Настройки</string>
|
||||||
|
<string name="settings_back">Назад</string>
|
||||||
|
<string name="back">Назад</string>
|
||||||
|
<string name="settings_section_appearance">Внешний вид</string>
|
||||||
|
<string name="settings_theme">Тема</string>
|
||||||
|
<string name="settings_theme_system">По умолчанию системы</string>
|
||||||
|
<string name="settings_theme_light">Светлая</string>
|
||||||
|
<string name="settings_theme_dark">Тёмная</string>
|
||||||
|
<string name="settings_theme_hint">Моментально меняет веншний вид приложения: светлый или тёмный</string>
|
||||||
|
<string name="settings_theme_system_summary">Выбрано: %1$s</string>
|
||||||
|
<string name="settings_default_view">Вид по умолчанию</string>
|
||||||
|
<string name="settings_dynamic_color">Динамические цвета</string>
|
||||||
|
<string name="settings_dynamic_color_unavailable">Требуется Android 12 или более поздняя версия</string>
|
||||||
|
<string name="settings_soften_colors">Гармонизировать цвета календаря</string>
|
||||||
|
<string name="settings_soften_colors_summary">Изменяет яркость цветов не меняя оттенок, чтобы создать единую цветовую палитру. Отключите для показа стандартных цветов подключённого календаря.</string>
|
||||||
|
<string name="settings_font_headings">Шрифт заголовков</string>
|
||||||
|
<string name="settings_font_body">Основной шрифт</string>
|
||||||
|
<string name="settings_font_system">По умолчанию</string>
|
||||||
|
<string name="font_atkinson_hyperlegible">Atkinson Hyperlegible</string>
|
||||||
|
<string name="font_lora">Lora</string>
|
||||||
|
<string name="font_jetbrains_mono">JetBrains Mono</string>
|
||||||
|
<string name="settings_font_choose_file">Выбрать файл…</string>
|
||||||
|
<string name="settings_font_custom_selected">Пользовательский шрифт</string>
|
||||||
|
<string name="settings_font_import_failed">Не удалось прочитать этот файл как шрифт</string>
|
||||||
|
<string name="settings_font_specimen_heading">Четверг, 14 мая</string>
|
||||||
|
<string name="settings_font_specimen_body">Рабочая встреча в 10:00, затем обед с Александром в кафе на улице Пушкина.</string>
|
||||||
|
<string name="settings_week_start">Неделя начинается в</string>
|
||||||
|
<string name="settings_week_start_auto">Автоматически</string>
|
||||||
|
<string name="settings_week_start_auto_summary">Выбрано: %1$s</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -187,4 +187,80 @@
|
|||||||
<string name="event_access_public_summary">Kdekoľvek s prístupom vidí všetky detaily</string>
|
<string name="event_access_public_summary">Kdekoľvek s prístupom vidí všetky detaily</string>
|
||||||
<string name="event_access_private_summary">Ostatný vidia iba že ste zaneprázdnený</string>
|
<string name="event_access_private_summary">Ostatný vidia iba že ste zaneprázdnený</string>
|
||||||
<string name="event_access_confidential_summary">Označené ako utajené; záleží na kalendárnom účtu čo toto znamená</string>
|
<string name="event_access_confidential_summary">Označené ako utajené; záleží na kalendárnom účtu čo toto znamená</string>
|
||||||
|
<plurals name="duration_minutes">
|
||||||
|
<item quantity="one">%d minúta</item>
|
||||||
|
<item quantity="few">%d minúty</item>
|
||||||
|
<item quantity="many">%d minút</item>
|
||||||
|
<item quantity="other">%d minút</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="event_attendee_organizer">Usporiadateľ</string>
|
||||||
|
<string name="event_attendee_optional">Voliteľné</string>
|
||||||
|
<string name="event_attendee_resource">Zdroj</string>
|
||||||
|
<string name="event_detail_self_response">Vaša odpoveď: %1$s</string>
|
||||||
|
<string name="reminder_at_time">V čas udalosti</string>
|
||||||
|
<string name="reminder_default">Predvolená pripomienka</string>
|
||||||
|
<plurals name="reminder_minutes">
|
||||||
|
<item quantity="one">%d minútu predtým</item>
|
||||||
|
<item quantity="few">%d minúty predtým</item>
|
||||||
|
<item quantity="many">%d minút predtým</item>
|
||||||
|
<item quantity="other">%d minút predtým</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_hours">
|
||||||
|
<item quantity="one">%d hodinu predtým</item>
|
||||||
|
<item quantity="few">%d hodiny predtým</item>
|
||||||
|
<item quantity="many">%d hodín predtým</item>
|
||||||
|
<item quantity="other">%d hodín predtým</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_days">
|
||||||
|
<item quantity="one">%d deň predtým</item>
|
||||||
|
<item quantity="few">%d dní predtým</item>
|
||||||
|
<item quantity="many">%d dní predtým</item>
|
||||||
|
<item quantity="other">%d dní predtým</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_weeks">
|
||||||
|
<item quantity="one">%d týždeň predtým</item>
|
||||||
|
<item quantity="few">%d týždne predtým</item>
|
||||||
|
<item quantity="many">%d týždňov predtým</item>
|
||||||
|
<item quantity="other">%d týždňov predtým</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_hours">
|
||||||
|
<item quantity="one">%d hodina</item>
|
||||||
|
<item quantity="few">%d hodiny</item>
|
||||||
|
<item quantity="many">%d hodín</item>
|
||||||
|
<item quantity="other">%d hodín</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_days">
|
||||||
|
<item quantity="one">%d deň</item>
|
||||||
|
<item quantity="few">%d dni</item>
|
||||||
|
<item quantity="many">%d dní</item>
|
||||||
|
<item quantity="other">%d dní</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="duration_hours_minutes">%1$s %2$s</string>
|
||||||
|
<string name="duration_custom_max">Najviac %1$s</string>
|
||||||
|
<plurals name="duration_weeks">
|
||||||
|
<item quantity="one">%d týždeň</item>
|
||||||
|
<item quantity="few">%d týždne</item>
|
||||||
|
<item quantity="many">%d týždňov</item>
|
||||||
|
<item quantity="other">%d týždňov</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="event_untitled">(Bez názvu)</string>
|
||||||
|
<string name="onboarding_step_counter">Krok %1$d z %2$d</string>
|
||||||
|
<string name="onboarding_backup_title">Vaše udalosti sú iba tu</string>
|
||||||
|
<string name="onboarding_backup_body">Nič na vašom telefóne sa nesynchronizuje z účtom. Ak ho stratíte, prídete aj o svoj kalendár. Calendula môže vytvoriť lokálnu zálohu.</string>
|
||||||
|
<string name="onboarding_backup_benefit_folder_title">Priečinok, ktorý si vyberiete</string>
|
||||||
|
<string name="onboarding_backup_benefit_folder_body">Zálohy sú obyčajné .ics súbory – uložte je niekam kde sa môžu synchronizovať alebo na SD kartu.</string>
|
||||||
|
<string name="onboarding_backup_benefit_daily_title">Automaticky jeden krát za deň</string>
|
||||||
|
<string name="onboarding_backup_benefit_daily_body">Calendula exportuje vaše lokálne kalendáre na pozadí. V nastaveniach môžete zmeniť ako často.</string>
|
||||||
|
<string name="onboarding_backup_enable_button">Vyberte priečinok a spravte zálohu</string>
|
||||||
|
<string name="onboarding_backup_skip_button">Nie teraz</string>
|
||||||
|
<string name="onboarding_view_title">Čo by sa malo otvoriť ako prvé?</string>
|
||||||
|
<string name="onboarding_month_style_title">Ako by mal vyzerať rozloženie kalendára?</string>
|
||||||
|
<string name="onboarding_view_continue_button">Pokračovať</string>
|
||||||
|
<string name="onboarding_back">Späť</string>
|
||||||
|
<string name="onboarding_visibility_button">Jasné</string>
|
||||||
|
<string name="onboarding_done_title">Všetko je pripravené</string>
|
||||||
|
<string name="onboarding_done_body">Vaše kalendáre sú pripravené. Všetky voľby môžete neskôr zmeniť v nastaveniach.</string>
|
||||||
|
<string name="onboarding_done_button">Otvoriť môj kalendár</string>
|
||||||
|
<string name="reminder_channel_name">Pripomienky udalostí</string>
|
||||||
|
<string name="reminder_channel_description">Notifikácie v časoch pripomienok vašich udalostí</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -6,34 +6,33 @@
|
|||||||
<item>Days</item>
|
<item>Days</item>
|
||||||
<item>Weeks</item>
|
<item>Weeks</item>
|
||||||
</string-array>
|
</string-array>
|
||||||
<string name="app_name">Calendula</string>
|
<string name="app_tagline">现代化的日历应用</string>
|
||||||
<string name="app_tagline">一个现代化的日历应用</string>
|
|
||||||
<string name="state_loading">加载中…</string>
|
<string name="state_loading">加载中…</string>
|
||||||
<string name="state_retry">推迟</string>
|
<string name="state_retry">重试</string>
|
||||||
<string name="state_failure_unknown">错误!</string>
|
<string name="state_failure_unknown">出错了。</string>
|
||||||
<string name="state_failure_permission">需要日历权限</string>
|
<string name="state_failure_permission">需要日历访问权限。</string>
|
||||||
<string name="state_failure_permission_action">授予访问权限</string>
|
<string name="state_failure_permission_action">授予访问权限</string>
|
||||||
<string name="state_failure_no_calendars">无已设置的日历</string>
|
<string name="state_failure_no_calendars">未配置任何日历。</string>
|
||||||
<string name="state_failure_no_calendars_action">打开系统日历设置</string>
|
<string name="state_failure_no_calendars_action">打开系统日历设置</string>
|
||||||
<string name="state_failure_provider">无法读取日历</string>
|
<string name="state_failure_provider">无法读取日历。</string>
|
||||||
<string name="permission_rationale_title">优雅地查看你的所有活动</string>
|
<string name="permission_rationale_title">优雅地查看您所有的事件</string>
|
||||||
<string name="permission_rationale_body">Calendula需要获取权限来访问您的日历来展示和管理您的活动。所有权限均会在使用前申请——不必担心隐私泄露。</string>
|
<string name="permission_rationale_body">Calendula 需要访问您的日历,以展示和管理您的事件。</string>
|
||||||
<string name="permission_request_button">授予日历权限</string>
|
<string name="permission_request_button">授予日历访问权限</string>
|
||||||
<string name="permission_denied_title">日历权限不可用</string>
|
<string name="permission_denied_title">日历访问被拒绝</string>
|
||||||
<string name="permission_denied_body">Calendula 无法显示没有日历访问权限的事件。你可以在系统设置里再次授予它。</string>
|
<string name="permission_denied_body">Calendula 无法在没有日历访问权限的情况下显示事件。您可以在系统设置中重新授予权限。</string>
|
||||||
<string name="permission_open_settings_button">打开系统设置</string>
|
<string name="permission_open_settings_button">打开系统设置</string>
|
||||||
<string name="permission_retry_button">重试</string>
|
<string name="permission_retry_button">重试</string>
|
||||||
<string name="permission_benefit_private_title">保留在设备</string>
|
<string name="permission_benefit_private_title">从设计上保护隐私</string>
|
||||||
<string name="permission_benefit_private_body">您的日历数据将会在本地读取,而不会被上传。</string>
|
<string name="permission_benefit_private_body">没有网络权限,没有遥测——任何数据都不会离开您的手机。</string>
|
||||||
<string name="permission_benefit_sync_title">所有日历</string>
|
<string name="permission_benefit_sync_title">所有日历汇聚一处</string>
|
||||||
<string name="permission_benefit_sync_body">Google、CalDAV、本地设备——任何同步到设备的东西都会直接显示出来。</string>
|
<string name="permission_benefit_sync_body">Google、CalDAV、本地日历——只要同步到设备就会自动显示。</string>
|
||||||
<string name="permission_benefit_privacy_title">无追踪器</string>
|
<string name="permission_benefit_privacy_title">永无追踪</string>
|
||||||
<string name="permission_benefit_privacy_body">零遥测,零分析,没有广告。</string>
|
<string name="permission_benefit_privacy_body">零遥测、零分析、无广告。</string>
|
||||||
<string name="permission_privacy_footnote">数据仅保留在本地设备 ·无网络权限</string>
|
<string name="permission_privacy_footnote">数据保留在您的设备 · 无网络权限</string>
|
||||||
<string name="month_prev">上个月</string>
|
<string name="month_prev">上月</string>
|
||||||
<string name="month_next">下个月</string>
|
<string name="month_next">下月</string>
|
||||||
<string name="month_today_action">今天</string>
|
<string name="month_today_action">今天</string>
|
||||||
<string name="month_more_actions">更多选项</string>
|
<string name="month_more_actions">更多操作</string>
|
||||||
<string name="month_open_menu">打开菜单</string>
|
<string name="month_open_menu">打开菜单</string>
|
||||||
<string name="month_action_settings">设置</string>
|
<string name="month_action_settings">设置</string>
|
||||||
<string name="month_a11y_today_prefix">今天</string>
|
<string name="month_a11y_today_prefix">今天</string>
|
||||||
@@ -43,80 +42,572 @@
|
|||||||
<string name="event_detail_edit">编辑</string>
|
<string name="event_detail_edit">编辑</string>
|
||||||
<string name="event_detail_delete">删除</string>
|
<string name="event_detail_delete">删除</string>
|
||||||
<string name="event_detail_share">分享</string>
|
<string name="event_detail_share">分享</string>
|
||||||
<string name="event_share_chooser_title">分享活动</string>
|
<string name="event_share_chooser_title">分享事件</string>
|
||||||
<string name="event_share_failed">无法分享该活动。</string>
|
<string name="event_share_failed">无法分享此事件。</string>
|
||||||
<string name="event_delete_title">删除活动?</string>
|
<string name="event_delete_title">删除事件?</string>
|
||||||
<string name="event_delete_body">该活动将从你的日历以及所有同步设备中移除。</string>
|
<string name="event_delete_body">该事件将从您的日历以及所有同步设备中移除。</string>
|
||||||
<string name="event_delete_recurring_title">删除重复活动</string>
|
<string name="event_delete_recurring_title">删除重复事件</string>
|
||||||
<string name="event_delete_option_occurrence">仅该活动</string>
|
<string name="event_delete_option_occurrence">仅此事件</string>
|
||||||
<string name="event_delete_option_following">当前及接下来的所有活动</string>
|
<string name="event_delete_option_following">此事件及其后的所有事件</string>
|
||||||
<string name="event_delete_option_series">系列中的所有活动</string>
|
<string name="event_delete_option_series">整个系列的所有事件</string>
|
||||||
<string name="event_edit_recurring_title">编辑重复活动</string>
|
<string name="event_edit_recurring_title">编辑重复事件</string>
|
||||||
<string name="event_delete_failed">无法删除该活动</string>
|
<string name="event_delete_failed">无法删除事件</string>
|
||||||
<string name="event_delete_write_denied">Calendula需要权限读取或删除活动</string>
|
<string name="event_delete_write_denied">Calendula 需要写入权限才能删除事件</string>
|
||||||
<string name="dialog_cancel">取消</string>
|
<string name="dialog_cancel">取消</string>
|
||||||
<string name="dialog_ok">确认</string>
|
<string name="dialog_ok">确定</string>
|
||||||
<string name="event_edit_new_title">新活动</string>
|
<string name="event_edit_new_title">新事件</string>
|
||||||
<string name="event_edit_close">关闭</string>
|
<string name="event_edit_close">关闭</string>
|
||||||
<string name="event_edit_save">保存</string>
|
<string name="event_edit_save">保存</string>
|
||||||
<string name="event_edit_title_hint">增加标题</string>
|
<string name="event_edit_title_hint">添加标题</string>
|
||||||
<string name="event_edit_managed_hint">由“%1$s”管理——标题、日期和重复内容会与你的联系人保持同步。提醒、位置和笔记由你自行编辑。</string>
|
<string name="event_edit_managed_hint">由“%1$s”管理——标题、日期和重复会从您的联系人同步。提醒、位置和备注由您编辑。</string>
|
||||||
<string name="event_edit_starts">开始</string>
|
<string name="event_edit_starts">开始</string>
|
||||||
<string name="event_edit_ends">结束</string>
|
<string name="event_edit_ends">结束</string>
|
||||||
<string name="event_edit_error_end_before_start">报错:结束时间早于起始时间</string>
|
<string name="event_edit_error_end_before_start">结束时间早于开始时间</string>
|
||||||
<string name="event_edit_error_no_calendar">无可写入的日历</string>
|
<string name="event_edit_error_no_calendar">没有可写入的日历</string>
|
||||||
<string name="event_edit_save_failed">无法保存该活动</string>
|
<string name="event_edit_save_failed">无法保存事件</string>
|
||||||
<string name="event_edit_write_denied">Calendula需要写入权限来创建活动</string>
|
<string name="event_edit_write_denied">Calendula 需要写入权限才能创建事件</string>
|
||||||
<string name="event_edit_more_fields">其他领域</string>
|
<string name="event_edit_more_fields">更多字段</string>
|
||||||
<string name="event_edit_add">新增</string>
|
<string name="event_edit_add">添加</string>
|
||||||
<string name="event_edit_add_reminder">新建提醒</string>
|
<string name="event_edit_add_reminder">添加提醒</string>
|
||||||
<string name="event_edit_remove_reminder">移除提醒</string>
|
<string name="event_edit_remove_reminder">移除提醒</string>
|
||||||
<string name="event_edit_attendees">访问者</string>
|
<string name="event_edit_attendees">参与者</string>
|
||||||
<string name="event_edit_add_guest">新增访问者</string>
|
<string name="event_edit_add_guest">添加参与者</string>
|
||||||
<string name="event_edit_add_guest_hint">通过 Email 新增一个访问者…</string>
|
<string name="event_edit_add_guest_hint">通过电子邮件添加参与者…</string>
|
||||||
<string name="event_edit_add_guest_from_contacts">从联系人中添加访问者</string>
|
<string name="event_edit_add_guest_from_contacts">从联系人添加</string>
|
||||||
<string name="event_edit_location_from_contacts">从联系人中添加地址</string>
|
<string name="event_edit_location_from_contacts">从联系人选取地址</string>
|
||||||
<string name="event_edit_remove_guest">移除访问者</string>
|
<string name="event_edit_remove_guest">移除参与者</string>
|
||||||
<string name="event_edit_attendee_required">必填</string>
|
<string name="event_edit_attendee_required">必需</string>
|
||||||
<string name="event_edit_attendee_optional">可选项</string>
|
<string name="event_edit_attendee_optional">可选</string>
|
||||||
<string name="event_edit_attendees_note_synced">Calendula 不发送邀请函。你的日历账户同步时可能会给访客发邮件。</string>
|
<string name="event_edit_attendees_note_synced">Calendula 不发送邀请。您的日历帐户同步时可能会通过邮件通知参与者。</string>
|
||||||
<string name="event_edit_attendees_note_local">保存在本设备,其他访客不会被提醒。</string>
|
<string name="event_edit_attendees_note_local">存储于本设备,不会通知任何人。</string>
|
||||||
<string name="event_edit_reminder_custom">自定义</string>
|
<string name="event_edit_reminder_custom">自定义</string>
|
||||||
<string name="reminder_unit_minutes">分钟</string>
|
<string name="reminder_unit_minutes">分钟</string>
|
||||||
<string name="reminder_unit_hours">小时</string>
|
<string name="reminder_unit_hours">小时</string>
|
||||||
<string name="reminder_unit_days">天</string>
|
<string name="reminder_unit_days">天</string>
|
||||||
<string name="reminder_unit_weeks">周</string>
|
<string name="reminder_unit_weeks">周</string>
|
||||||
<string name="event_edit_availability">空闲</string>
|
<string name="event_edit_availability">忙闲状态</string>
|
||||||
<string name="event_edit_visibility">隐私设置</string>
|
<string name="event_edit_visibility">可见性</string>
|
||||||
<string name="event_edit_color">色彩</string>
|
<string name="event_edit_color">颜色</string>
|
||||||
<string name="event_edit_color_default">日历颜色</string>
|
<string name="event_edit_color_default">日历颜色</string>
|
||||||
<string name="event_edit_color_custom">自定义颜色</string>
|
<string name="event_edit_color_custom">自定义颜色</string>
|
||||||
<string name="event_edit_color_reset">重置</string>
|
<string name="event_edit_color_reset">重置</string>
|
||||||
<string name="event_edit_color_unsupported">该日历不可用</string>
|
<string name="event_edit_color_unsupported">此日历不支持</string>
|
||||||
<string name="event_edit_color_unsupported_hint">此日历未设置任何颜色集。您可以在“设置”中为此类日历启用自定义颜色。</string>
|
<string name="event_edit_color_unsupported_hint">此日历未提供颜色集。您可以在“设置”中为此类日历允许自定义颜色。</string>
|
||||||
<string name="event_edit_color_sync_warning">该日历在下一次同步时可能会删除或覆盖该颜色。</string>
|
<string name="event_edit_color_sync_warning">此日历下次同步时可能会丢弃或覆盖该颜色。</string>
|
||||||
<string name="event_edit_conflict_title">其他设备的活动已更改</string>
|
<string name="event_edit_conflict_title">事件已在别处更改</string>
|
||||||
<string name="event_edit_conflict_body">您在编辑时,该事件已被同步或其他应用修改。您的修改要怎么处理?</string>
|
<string name="event_edit_conflict_body">您编辑时,此事件已通过同步或其他应用被修改。您的更改应如何处理?</string>
|
||||||
<string name="event_edit_conflict_overwrite">保存我的更改</string>
|
<string name="event_edit_conflict_overwrite">保存我的更改</string>
|
||||||
<string name="event_edit_conflict_overwrite_hint">仅您编辑的字段会覆盖外部更改</string>
|
<string name="event_edit_conflict_overwrite_hint">仅覆盖您编辑过的字段</string>
|
||||||
<string name="event_edit_conflict_discard">丢弃我的更改</string>
|
<string name="event_edit_conflict_discard">丢弃我的更改</string>
|
||||||
<string name="event_edit_conflict_discard_hint">该活动保持现状</string>
|
<string name="event_edit_conflict_discard_hint">事件保持当前状态</string>
|
||||||
<string name="event_edit_gone_title">活动被删除</string>
|
<string name="event_edit_gone_title">事件已被删除</string>
|
||||||
<string name="event_edit_gone_body">这个事件在您编辑期间已被删除了(比如在另一台设备上)。您的修改现在无法保存。</string>
|
<string name="event_edit_gone_body">该事件在您编辑期间已被删除(例如在另一设备上)。您的更改无法再保存。</string>
|
||||||
<string name="event_edit_recurrence_none">确认无需重复</string>
|
<string name="event_edit_recurrence_none">不重复</string>
|
||||||
<string name="event_edit_recurrence_custom">自定义</string>
|
<string name="event_edit_recurrence_custom">自定义</string>
|
||||||
<string name="event_edit_recurrence_every">每</string>
|
<string name="event_edit_recurrence_every">每</string>
|
||||||
<string name="recurrence_unit_days">天</string>
|
<string name="recurrence_unit_days">天</string>
|
||||||
<string name="recurrence_unit_weeks">周</string>
|
<string name="recurrence_unit_weeks">周</string>
|
||||||
<string name="recurrence_unit_months">月</string>
|
<string name="recurrence_unit_months">月</string>
|
||||||
<string name="recurrence_unit_years">年</string>
|
<string name="recurrence_unit_years">年</string>
|
||||||
<string name="event_edit_recurrence_ends">结束时间</string>
|
<string name="event_edit_recurrence_ends">结束</string>
|
||||||
<string name="event_edit_recurrence_end_never">从不</string>
|
<string name="event_edit_recurrence_end_never">永不</string>
|
||||||
<string name="event_edit_recurrence_end_until">于指定日期</string>
|
<string name="event_edit_recurrence_end_until">于指定日期</string>
|
||||||
<string name="event_edit_recurrence_end_count">重复几次后</string>
|
<string name="event_edit_recurrence_end_count">在指定次数后</string>
|
||||||
<string name="event_edit_recurrence_times">次</string>
|
<string name="event_edit_recurrence_times">次</string>
|
||||||
<string name="event_edit_error_recurrence_ends_before_start">报错:结束日期早于开始日期</string>
|
<string name="event_edit_error_recurrence_ends_before_start">重复结束日期早于事件开始时间</string>
|
||||||
<string name="event_availability_busy">占用中</string>
|
<string name="event_availability_busy">忙碌</string>
|
||||||
<string name="event_access_default">默认</string>
|
<string name="event_access_default">默认</string>
|
||||||
<string name="event_access_public">公开</string>
|
<string name="event_access_public">公开</string>
|
||||||
|
<string name="event_detail_duplicate">复制</string>
|
||||||
|
<string name="event_move_action">移动…</string>
|
||||||
|
<string name="event_move_recurring_title">删除重复活动</string>
|
||||||
|
<string name="event_move_occurrence_only">移动整个系列会改变其发生日期,因此只能移动此事件。</string>
|
||||||
|
<string name="event_move_done">已移动到 %1$s</string>
|
||||||
|
<string name="event_move_undo">撤销</string>
|
||||||
|
<string name="event_move_undone">已撤销移动</string>
|
||||||
|
<string name="event_move_failed">无法保存该活动</string>
|
||||||
|
<string name="event_move_write_denied">Calendula需要权限读取或删除活动</string>
|
||||||
|
<string name="event_move_gone">该事件已不存在</string>
|
||||||
|
<string name="event_move_blocked_series_end">无法将事件移至其系列结束日期之后</string>
|
||||||
|
<string name="event_edit_timezone_device">设备时区</string>
|
||||||
|
<string name="event_edit_timezone_device_summary">跟随您所在位置</string>
|
||||||
|
<string name="event_edit_timezone_search">搜索时区</string>
|
||||||
|
<string name="event_edit_timezone_recent">最近使用</string>
|
||||||
|
<string name="event_edit_timezone_all">所有时区</string>
|
||||||
|
<string name="event_edit_timezone_none">没有与“%1$s”匹配的时区</string>
|
||||||
|
<string name="event_edit_timezone_local_time">%1$s 您的本地时间</string>
|
||||||
|
<string name="import_reminder_prompt_title">应用默认提醒?</string>
|
||||||
|
<string name="import_reminder_prompt_body_none">此事件导入时没有提醒。</string>
|
||||||
|
<plurals name="import_reminder_prompt_body_existing">
|
||||||
|
<item quantity="other">此事件导入时包含 %1$d 个提醒。</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="app_name">Calendula</string>
|
||||||
|
<string name="week_number_label">第</string>
|
||||||
|
<string name="import_reminder_prompt_apply">应用默认</string>
|
||||||
|
<string name="import_reminder_prompt_keep">保留原样</string>
|
||||||
|
<string name="event_edit_recurrence_incomplete">请输入 1 到 999 之间的数字</string>
|
||||||
|
<string name="event_edit_recurrence_next">下一次:%1$s</string>
|
||||||
|
<string name="event_edit_recurrence_next_none">此规则永不重复</string>
|
||||||
|
<string name="event_detail_all_day">全天</string>
|
||||||
|
<string name="event_detail_calendar">Calendula</string>
|
||||||
|
<string name="event_detail_calendar_unknown">未知日历</string>
|
||||||
|
<string name="event_detail_location">地点</string>
|
||||||
|
<string name="event_detail_description">描述</string>
|
||||||
|
<string name="event_detail_attendees">参与者</string>
|
||||||
|
<string name="event_detail_recurrence">重复</string>
|
||||||
|
<string name="event_detail_recurring">重复事件</string>
|
||||||
|
<string name="recurrence_daily">每天</string>
|
||||||
|
<string name="recurrence_weekly">每周</string>
|
||||||
|
<string name="recurrence_monthly">每月</string>
|
||||||
|
<string name="recurrence_yearly">每年</string>
|
||||||
|
<string name="recurrence_every_n_days">每 %1$d 天</string>
|
||||||
|
<string name="recurrence_every_n_weeks">每 %1$d 周</string>
|
||||||
|
<string name="recurrence_every_n_months">每 %1$d 月</string>
|
||||||
|
<string name="recurrence_every_n_years">每 %1$d 年</string>
|
||||||
|
<string name="recurrence_on_days">%1$s(%2$s)</string>
|
||||||
|
<string name="recurrence_with_until">%1$s,直到 %2$s</string>
|
||||||
|
<string name="recurrence_with_count">%1$s,共 %2$d 次</string>
|
||||||
|
<string name="event_detail_not_found">此事件已不存在。</string>
|
||||||
|
<string name="event_attendee_accepted">已接受</string>
|
||||||
|
<string name="event_attendee_declined">已拒绝</string>
|
||||||
|
<string name="event_attendee_tentative">待定</string>
|
||||||
|
<string name="event_attendee_needs_action">未回复</string>
|
||||||
|
<string name="event_attendee_unknown">—</string>
|
||||||
|
<string name="event_detail_reminders">提醒</string>
|
||||||
|
<string name="event_detail_timezone">时区</string>
|
||||||
|
<string name="event_status_tentative">待定</string>
|
||||||
|
<string name="event_status_cancelled">取消</string>
|
||||||
|
<string name="event_availability_free">空闲</string>
|
||||||
|
<string name="event_access_private">私密</string>
|
||||||
|
<string name="event_access_confidential">机密</string>
|
||||||
|
<string name="event_access_default_summary">按日历默认设置</string>
|
||||||
|
<string name="event_access_public_summary">有权限者可见全部详情</string>
|
||||||
|
<string name="event_access_private_summary">他人仅看到您忙碌</string>
|
||||||
|
<string name="event_access_confidential_summary">标记为机密,具体含义由日历帐户决定</string>
|
||||||
|
<string name="event_attendee_organizer">组织者</string>
|
||||||
|
<string name="event_attendee_optional">可选项</string>
|
||||||
|
<string name="event_attendee_resource">资源</string>
|
||||||
|
<string name="event_detail_self_response">您的回复:%1$s</string>
|
||||||
|
<string name="reminder_at_time">事件开始时</string>
|
||||||
|
<string name="reminder_default">默认提醒</string>
|
||||||
|
<plurals name="reminder_minutes">
|
||||||
|
<item quantity="other">提前 %d 分钟</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_hours">
|
||||||
|
<item quantity="other">提前 %d 小时</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_days">
|
||||||
|
<item quantity="other">提前 %d 天</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_weeks">
|
||||||
|
<item quantity="other">提前 %d 周</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_minutes">
|
||||||
|
<item quantity="other">%d分钟</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_hours">
|
||||||
|
<item quantity="other">%d 小时</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_days">
|
||||||
|
<item quantity="other">%d 天</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="duration_hours_minutes">%1$s %2$s</string>
|
||||||
|
<string name="duration_custom_max">最多 %1$s</string>
|
||||||
|
<plurals name="duration_weeks">
|
||||||
|
<item quantity="other">%d 周</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="event_untitled">(无标题)</string>
|
||||||
|
<string name="onboarding_step_counter">第 %1$d 步,共 %2$d 步</string>
|
||||||
|
<string name="onboarding_backup_title">您的事件仅保存在这里</string>
|
||||||
|
<string name="onboarding_backup_body">本手机上的事件不会同步到任何帐户,丢失手机将丢失日历。Calendula 可以为您创建备份。</string>
|
||||||
|
<string name="onboarding_backup_benefit_folder_title">您选择的文件夹</string>
|
||||||
|
<string name="onboarding_backup_benefit_folder_body">备份为纯 .ics 文件——可存放在同步文件夹或 SD 卡中。</string>
|
||||||
|
<string name="onboarding_backup_benefit_daily_title">每天自动备份</string>
|
||||||
|
<string name="onboarding_backup_benefit_daily_body">Calendula 会在后台导出您的本地日历。您可以在“设置”中更改频率。</string>
|
||||||
|
<string name="onboarding_backup_enable_button">选择文件夹并备份</string>
|
||||||
|
<string name="onboarding_backup_skip_button">暂不</string>
|
||||||
|
<string name="onboarding_view_title">默认打开哪个视图?</string>
|
||||||
|
<string name="onboarding_month_style_title">月视图样式?</string>
|
||||||
|
<string name="onboarding_view_continue_button">继续</string>
|
||||||
|
<string name="onboarding_back">返回</string>
|
||||||
|
<string name="onboarding_visibility_button">知道了</string>
|
||||||
|
<string name="onboarding_done_title">一切就绪</string>
|
||||||
|
<string name="onboarding_done_body">您的日历已就绪。所有选择均可在“设置”中更改。</string>
|
||||||
|
<string name="onboarding_done_button">打开我的日历</string>
|
||||||
|
<string name="reminder_channel_name">事件提醒</string>
|
||||||
|
<string name="reminder_channel_description">按事件提醒时间推送的通知</string>
|
||||||
|
<string name="reminder_onboarding_title">不错过任何事件</string>
|
||||||
|
<string name="reminder_onboarding_body">Android 本身不会显示事件提醒——需要日历应用来承担。让 Calendula 接管这项工作吧。</string>
|
||||||
|
<string name="reminder_benefit_delivery_title">准时送达提醒</string>
|
||||||
|
<string name="reminder_benefit_delivery_body">您事件中的每个提醒都会按时以通知形式送达。</string>
|
||||||
|
<string name="reminder_benefit_duplicates_title">使用第二个日历应用?</string>
|
||||||
|
<string name="reminder_benefit_duplicates_body">如果另一个应用也发送提醒,您会看到两次——请在其中一个中关闭。</string>
|
||||||
|
<string name="reminder_benefit_reversible_title">随时更改</string>
|
||||||
|
<string name="reminder_benefit_reversible_body">开关位于“设置”中的“通知”下。</string>
|
||||||
|
<string name="reminder_onboarding_enable_button">开启提醒</string>
|
||||||
|
<string name="reminder_onboarding_skip_button">暂不</string>
|
||||||
|
<string name="reminder_action_snooze">稍后提醒</string>
|
||||||
|
<string name="reminder_action_dismiss">关闭</string>
|
||||||
|
<string name="reminder_day_tomorrow">明天</string>
|
||||||
|
<string name="reminder_day_yesterday">昨天</string>
|
||||||
|
<string name="view_month">月</string>
|
||||||
|
<string name="view_week">周</string>
|
||||||
|
<string name="view_day">日</string>
|
||||||
|
<string name="view_agenda">日程</string>
|
||||||
|
<string name="view_section">视图</string>
|
||||||
|
<string name="drawer_jump_to_date">跳转到日期</string>
|
||||||
|
<string name="agenda_today_action">今天</string>
|
||||||
|
<string name="agenda_header_today">今天</string>
|
||||||
|
<string name="agenda_header_tomorrow">明天</string>
|
||||||
|
<string name="agenda_empty_title">暂无待办</string>
|
||||||
|
<string name="agenda_no_more_today">今天没有更多事件</string>
|
||||||
|
<string name="agenda_span_starts">开始 %1$s</string>
|
||||||
|
<string name="agenda_span_ends">结束 %1$s</string>
|
||||||
|
<string name="today_jump_action">去今天</string>
|
||||||
|
<string name="search_action">搜索</string>
|
||||||
|
<string name="search_hint">搜索事件</string>
|
||||||
|
<string name="search_back">返回</string>
|
||||||
|
<string name="search_clear">清除</string>
|
||||||
|
<string name="search_idle_hint">按标题、地点或备注搜索您的事件。</string>
|
||||||
|
<string name="search_empty">没有与“%1$s”匹配的事件。</string>
|
||||||
|
<plurals name="search_selected_count">
|
||||||
|
<item quantity="other">已选 %d 个</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="search_in_descriptions">在描述中找到</string>
|
||||||
|
<string name="search_selection_close">取消选择</string>
|
||||||
|
<string name="search_select_all">全选</string>
|
||||||
|
<string name="search_delete_selected">删除所选</string>
|
||||||
|
<plurals name="search_delete_title">
|
||||||
|
<item quantity="other">删除 %d个活动?</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="search_delete_done">
|
||||||
|
<item quantity="other">删除了%d个活动</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="search_delete_partial">已删除 %1$d 个,%2$d 个无法删除</string>
|
||||||
|
<string name="search_delete_denied_partial">已删除 %1$d 个,之后写入权限被撤</string>
|
||||||
|
<string name="widget_agenda_title">即将开始</string>
|
||||||
|
<string name="widget_agenda_label">Calendula 日程</string>
|
||||||
|
<string name="widget_month_label">Calendula 月视图</string>
|
||||||
|
<string name="widget_refresh">刷新</string>
|
||||||
|
<string name="widget_new_event">新活动</string>
|
||||||
|
<string name="widget_needs_permission">打开 Calendula 授予日历访问权限</string>
|
||||||
|
<string name="widget_prev_month">上个月</string>
|
||||||
|
<string name="widget_next_month">下个月</string>
|
||||||
|
<string name="widget_today">今天</string>
|
||||||
|
<string name="filter_title">日历</string>
|
||||||
|
<string name="settings_title">设置</string>
|
||||||
|
<string name="settings_back">返回</string>
|
||||||
|
<string name="back">返回</string>
|
||||||
|
<string name="settings_section_appearance">外观</string>
|
||||||
|
<string name="settings_theme">主题</string>
|
||||||
|
<string name="settings_theme_system">跟随系统</string>
|
||||||
|
<string name="settings_theme_light">浅色</string>
|
||||||
|
<string name="settings_theme_dark">深色</string>
|
||||||
|
<string name="settings_theme_hint">应用使用浅色或深色主题。选择后立即生效。</string>
|
||||||
|
<string name="settings_theme_system_summary">当前为 %1$s</string>
|
||||||
|
<string name="settings_default_view">默认视图</string>
|
||||||
|
<string name="settings_dynamic_color">动态颜色</string>
|
||||||
|
<string name="settings_dynamic_color_unavailable">需要 Android 12 或更高版本</string>
|
||||||
|
<string name="settings_soften_colors">协调日历颜色</string>
|
||||||
|
<string name="settings_soften_colors_summary">保留每个日历的色调但均衡亮度,使所有事件清晰可读且色彩协调。关闭则显示日历源的原始颜色。</string>
|
||||||
|
<string name="settings_font_headings">标题字体</string>
|
||||||
|
<string name="settings_font_body">正文字体</string>
|
||||||
|
<string name="settings_font_system">系统默认</string>
|
||||||
|
<string name="font_atkinson_hyperlegible">Atkinson Hyperlegible</string>
|
||||||
|
<string name="font_lora">Lora</string>
|
||||||
|
<string name="font_jetbrains_mono">JetBrains Mono</string>
|
||||||
|
<string name="settings_font_choose_file">选择文件…</string>
|
||||||
|
<string name="settings_font_custom_selected">自定义字体</string>
|
||||||
|
<string name="settings_font_import_failed">无法将文件识别为字体</string>
|
||||||
|
<string name="settings_font_specimen_heading">2026年8月28日 星期四</string>
|
||||||
|
<string name="settings_font_specimen_body">团队评审 10:00,然后与 Robin 在集市广场咖啡馆共进午餐。</string>
|
||||||
|
<string name="settings_week_start">每周起始日</string>
|
||||||
|
<string name="settings_week_start_auto">自动</string>
|
||||||
|
<string name="settings_week_start_auto_summary">当前为 %1$s</string>
|
||||||
|
<string name="settings_week_numbers">周数</string>
|
||||||
|
<string name="settings_week_numbers_summary">在月视图中显示日历周数</string>
|
||||||
|
<string name="settings_today_toolbar">工具栏中的“今天”按钮</string>
|
||||||
|
<string name="settings_today_toolbar_summary">在工具栏中显示“跳至今天”按钮,而非浮动按钮</string>
|
||||||
|
<string name="settings_app_name">应用名称</string>
|
||||||
|
<string name="settings_app_name_summary">在启动器中显示为“日历”。仅启动器名称改变;切换后图标可能移动到新位置。</string>
|
||||||
|
<string name="settings_time_format">时间格式</string>
|
||||||
|
<string name="settings_time_format_auto">自动</string>
|
||||||
|
<string name="settings_time_format_12h">12 小时制(下午 2:00)</string>
|
||||||
|
<string name="settings_time_format_24h">24 小时制(14:00)</string>
|
||||||
|
<string name="settings_time_format_auto_summary">跟随系统:%1$s</string>
|
||||||
|
<string name="settings_hour_lines">整点分隔线</string>
|
||||||
|
<string name="settings_hour_lines_summary">在周视图和日视图中显示每个整点的分隔线</string>
|
||||||
|
<string name="settings_timeline_scale">小时高度</string>
|
||||||
|
<string name="settings_timeline_scale_hint">周视图和日视图中每小时占用的垂直空间。两者共享此设置。您也可以用双指捏合时间线设置任意高度。</string>
|
||||||
|
<string name="timeline_scale_fit_day">适应全天</string>
|
||||||
|
<string name="timeline_scale_fit_day_summary">24 小时一屏显示,无需滚动</string>
|
||||||
|
<string name="timeline_scale_compact">紧凑</string>
|
||||||
|
<string name="timeline_scale_compact_summary">每屏显示更多小时,区块更小</string>
|
||||||
|
<string name="timeline_scale_regular">常规</string>
|
||||||
|
<string name="timeline_scale_regular_summary">标准间距</string>
|
||||||
|
<string name="timeline_scale_comfortable">舒适</string>
|
||||||
|
<string name="timeline_scale_comfortable_summary">区块更宽敞,需更多滚动</string>
|
||||||
|
<string name="timeline_scale_custom">自定义</string>
|
||||||
|
<string name="timeline_scale_custom_summary">您捏合时间线所设定的高度</string>
|
||||||
|
<string name="settings_dim_completed">变暗已完成事件</string>
|
||||||
|
<string name="settings_dim_completed_summary">在月视图和周视图中淡化已结束的事件</string>
|
||||||
|
<string name="settings_drag_to_reschedule">拖拽重新安排</string>
|
||||||
|
<string name="settings_drag_to_reschedule_summary">通过拖拽事件到另一天或时间来移动它</string>
|
||||||
|
<string name="settings_past_events">过去事件</string>
|
||||||
|
<string name="settings_past_events_show">显示</string>
|
||||||
|
<string name="settings_past_events_dim">变暗</string>
|
||||||
|
<string name="settings_past_events_hide">隐藏</string>
|
||||||
|
<string name="settings_past_events_sample_morning">团队评审</string>
|
||||||
|
<string name="settings_past_events_sample_midday">与 Robin 午餐</string>
|
||||||
|
<string name="settings_past_events_sample_evening">合唱团练习</string>
|
||||||
|
<string name="settings_agenda_header">日程</string>
|
||||||
|
<string name="settings_agenda_range">日程范围</string>
|
||||||
|
<string name="settings_agenda_range_hint">“日程”视图列出未来事件的时间跨度。</string>
|
||||||
|
<string name="settings_agenda_widget_range">日程小部件范围</string>
|
||||||
|
<string name="settings_agenda_widget_range_hint">主屏幕日程小部件列出未来事件的时间跨度。</string>
|
||||||
|
<string name="settings_widget_size">日程小部件大小</string>
|
||||||
|
<string name="settings_widget_size_hint">主屏幕日程小部件绘制文字的大小。月视图小部件无此设置——它的网格始终适应您给定的空间。</string>
|
||||||
|
<string name="settings_widget_size_small">小</string>
|
||||||
|
<string name="settings_widget_size_medium">中</string>
|
||||||
|
<string name="settings_widget_size_large">大</string>
|
||||||
|
<string name="settings_widget_size_extra_large">特大</string>
|
||||||
|
<string name="settings_widget_size_summary">事件文字 %1$d%%</string>
|
||||||
|
<string name="settings_agenda_show_today">始终显示今天</string>
|
||||||
|
<string name="settings_agenda_show_today_hint">即使今天没有剩余事件,也将今天保持在日程及其小部件顶部。</string>
|
||||||
|
<string name="settings_agenda_range_bar">范围栏</string>
|
||||||
|
<string name="settings_agenda_range_bar_hint">在日程顶部显示一个条,标明显示的日期范围,并提供一个按钮用于临时切换范围</string>
|
||||||
|
<string name="agenda_range_day">今天</string>
|
||||||
|
<string name="agenda_range_this_week">本周</string>
|
||||||
|
<string name="agenda_range_this_month">本月</string>
|
||||||
|
<string name="agenda_range_week">未来 7 天</string>
|
||||||
|
<string name="agenda_range_month">未来 30 天</string>
|
||||||
|
<string name="agenda_range_custom">自定义…</string>
|
||||||
|
<string name="agenda_range_custom_hint">天数</string>
|
||||||
|
<string name="agenda_range_override_hint">仅本次生效——重新打开 Calendula 时恢复为已保存的范围。</string>
|
||||||
|
<string name="agenda_range_showing_label">显示所有未来事件</string>
|
||||||
|
<plurals name="agenda_range_days">
|
||||||
|
<item quantity="other">%d 天</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="settings_section_views">视图</string>
|
||||||
|
<string name="settings_month_header">月视图</string>
|
||||||
|
<string name="settings_month_view_style">月视图样式</string>
|
||||||
|
<string name="month_style_paged">分页</string>
|
||||||
|
<string name="month_style_paged_summary">一屏显示一个月,左右滑动切换月份。</string>
|
||||||
|
<string name="month_style_continuous">滚动月份</string>
|
||||||
|
<string name="month_style_continuous_summary">每个月在其标题下,与下个月之间略有间距。</string>
|
||||||
|
<string name="month_style_dense">无缝周</string>
|
||||||
|
<string name="month_style_dense_summary">各周连续排列,月份之间无间隙。</string>
|
||||||
|
<string name="month_style_split">拆分</string>
|
||||||
|
<string name="month_style_split_summary">紧凑网格标记有事件的日子,点击的日期在下方列出。</string>
|
||||||
|
<string name="month_split_no_events">无安排</string>
|
||||||
|
<string name="month_split_expand">显示整月</string>
|
||||||
|
<string name="month_split_collapse">显示当天事件</string>
|
||||||
|
<string name="settings_quick_switch_header">快速切换按钮</string>
|
||||||
|
<string name="settings_quick_switch_hint">选择右上角按钮循环切换的视图,并拖动排序。关闭的视图仍可从导航菜单访问。</string>
|
||||||
|
<string name="settings_drawer_order_header">导航菜单</string>
|
||||||
|
<string name="settings_drawer_order_hint">拖动排序导航菜单中的视图。</string>
|
||||||
|
<string name="reorder_drag_handle">拖动以排序</string>
|
||||||
|
<string name="settings_section_event_form">新建事件表单</string>
|
||||||
|
<string name="settings_form_fields_hint">默认显示的字段——其他字段位于“更多字段”下</string>
|
||||||
|
<string name="settings_autofocus_title">新建事件时聚焦标题</string>
|
||||||
|
<string name="settings_autofocus_title_hint">创建新事件时,光标自动置于标题字段并弹出键盘。</string>
|
||||||
|
<string name="settings_event_duration">默认时长</string>
|
||||||
|
<string name="settings_event_duration_hint">新事件在您更改结束时间之前的默认时长。全天事件不受影响。</string>
|
||||||
|
<string name="settings_calendar_durations_title">按日历设置时长</string>
|
||||||
|
<string name="settings_calendar_durations_hint">为某个日历单独设置默认时长——例如工作班次设为 8 小时。</string>
|
||||||
|
<string name="settings_calendar_duration_inherits">默认(%1$s)</string>
|
||||||
|
<string name="settings_calendar_duration_use_default">使用默认时长(%1$s)</string>
|
||||||
|
<string name="settings_color_unsupported">允许在不支持的日历上使用颜色</string>
|
||||||
|
<string name="settings_color_unsupported_hint">某些日历(例如某些 CalDAV)未发布颜色集;自定义事件颜色可能会在下一次同步时被丢弃或覆盖。这是那些日历的限制,并非 Calendula 可修复的问题。</string>
|
||||||
|
<string name="settings_section_notifications">通知</string>
|
||||||
|
<string name="settings_reminders">事件提醒</string>
|
||||||
|
<string name="settings_reminders_hint">看到重复提醒?另一个日历应用也在发送——请关闭其中一个。</string>
|
||||||
|
<string name="settings_default_reminder">默认提醒</string>
|
||||||
|
<string name="settings_default_reminder_allday">全天事件</string>
|
||||||
|
<string name="settings_allday_reminder_time">全天事件提醒时间</string>
|
||||||
|
<string name="settings_allday_reminder_time_hint">全天事件的提醒在 %1$s 触发</string>
|
||||||
|
<string name="settings_allday_reminder_fires_at">在 %1$s 通知</string>
|
||||||
|
<string name="reminder_none">无</string>
|
||||||
|
<string name="reminder_use_default">使用默认提醒</string>
|
||||||
|
<string name="reminder_custom_amount">数量</string>
|
||||||
|
<string name="reminder_custom_set">设置</string>
|
||||||
|
<string name="settings_calendar_reminders_title">按日历设置提醒</string>
|
||||||
|
<string name="settings_calendar_reminders_hint">为每个日历覆盖默认提醒——分别针对定时事件和全天事件。日历可保持默认、不使用提醒,或设置自己的提醒。</string>
|
||||||
|
<string name="settings_calendar_reminder_inherits">默认(%1$s)</string>
|
||||||
|
<string name="settings_reliable_delivery">可靠送达</string>
|
||||||
|
<string name="settings_reliable_delivery_hint">Android 可能为省电延迟提醒。豁免 Calendula 以使其准时送达。</string>
|
||||||
|
<string name="settings_reliable_delivery_exempt">豁免电池优化——提醒准时送达。</string>
|
||||||
|
<string name="settings_snooze_duration">贪睡时长</string>
|
||||||
|
<string name="settings_section_calendars">日历</string>
|
||||||
|
<string name="settings_manage_calendars">管理日历</string>
|
||||||
|
<string name="settings_manage_calendars_hint">创建本地日历;管理同步日历</string>
|
||||||
|
<string name="settings_section_language">语言</string>
|
||||||
|
<string name="settings_language">应用语言</string>
|
||||||
|
<string name="settings_language_auto">系统默认</string>
|
||||||
|
<string name="settings_translate">帮助翻译</string>
|
||||||
|
<string name="settings_translate_hint">在 Weblate 上添加或改进语言</string>
|
||||||
|
<string name="settings_group_look">外观与行为</string>
|
||||||
|
<string name="settings_group_data">数据</string>
|
||||||
|
<string name="settings_group_app">应用</string>
|
||||||
|
<string name="settings_group_about">关于</string>
|
||||||
|
<string name="settings_appearance_subtitle">主题、颜色、字体</string>
|
||||||
|
<string name="settings_views_subtitle">默认视图、布局、顺序</string>
|
||||||
|
<string name="settings_event_form_subtitle">默认字段和行为</string>
|
||||||
|
<string name="settings_notifications_subtitle">提醒与送达</string>
|
||||||
|
<string name="settings_special_dates_subtitle">联系人生日和纪念日</string>
|
||||||
|
<string name="settings_widgets_subtitle">日程小部件、快速设置磁贴</string>
|
||||||
|
<string name="settings_backup_subtitle">导出、导入、自动备份</string>
|
||||||
|
<string name="settings_section_widgets">小部件与磁贴</string>
|
||||||
|
<string name="settings_widgets_hint">主屏幕小部件和快速设置磁贴的设置。长按主屏幕即可添加小部件。</string>
|
||||||
|
<string name="settings_section_backup">备份与恢复</string>
|
||||||
|
<string name="settings_views_all_header">所有视图</string>
|
||||||
|
<string name="settings_week_day_header">周与日</string>
|
||||||
|
<string name="settings_default_view_hint">启动 Calendula 时默认打开的视图。</string>
|
||||||
|
<string name="settings_week_start_hint">所有视图和小部件中每周开始的日子。</string>
|
||||||
|
<string name="settings_time_format_hint">整个应用的时间显示格式。自动跟随系统设置。</string>
|
||||||
|
<string name="settings_past_events_hint">日程对已结束事件的处理方式。</string>
|
||||||
|
<string name="settings_dynamic_color_summary">从壁纸中提取应用颜色。</string>
|
||||||
|
<string name="settings_section_special_dates">联系人特殊日期</string>
|
||||||
|
<string name="settings_special_dates_enable">显示联系人日期</string>
|
||||||
|
<string name="settings_special_dates_enable_hint">将联系人生日和其他日期镜像到本地日历。仅读取本设备上的联系人——不会上传任何内容,也不会修改您的联系人。</string>
|
||||||
|
<string name="settings_special_dates_type_birthday">生日</string>
|
||||||
|
<string name="settings_special_dates_type_anniversary">纪念日</string>
|
||||||
|
<string name="settings_special_dates_type_custom">其他日期</string>
|
||||||
|
<string name="settings_special_dates_template">标题格式</string>
|
||||||
|
<string name="settings_special_dates_template_hint">使用 {name} 表示联系人,{year} 表示年份(出生年份或纪念日开始年份;未知时隐藏)。</string>
|
||||||
|
<string name="settings_special_dates_reminders">提醒</string>
|
||||||
|
<string name="settings_special_dates_show_year">显示年份</string>
|
||||||
|
<string name="settings_special_dates_show_year_hint">在标题中包含 {year}(当已知时)</string>
|
||||||
|
<string name="settings_special_dates_sync_now">立即同步</string>
|
||||||
|
<string name="settings_special_dates_never_synced">尚未同步</string>
|
||||||
|
<string name="settings_special_dates_last_synced">上次同步:%1$s</string>
|
||||||
|
<string name="settings_special_dates_calendar_hint">在“日历”设置中分别设置每个日历的颜色和可见性。</string>
|
||||||
|
<string name="settings_calendar_reminders_managed_hint">在“联系人特殊日期”中设置</string>
|
||||||
|
<string name="settings_special_dates_paused_title">已暂停</string>
|
||||||
|
<string name="settings_special_dates_paused_hint">Calendula 无法读取您的联系人,因此这些日历未更新。</string>
|
||||||
|
<string name="settings_special_dates_grant">授予访问权限</string>
|
||||||
|
<string name="settings_special_dates_disable_title">关闭联系人日期?</string>
|
||||||
|
<string name="settings_special_dates_disable_all_message">这将删除联系人日期日历及其事件。您添加的任何提醒或备注将丢失。</string>
|
||||||
|
<string name="settings_special_dates_disable_type_message">这将删除“%1$s”日历及其事件。您添加的任何提醒或备注将丢失。</string>
|
||||||
|
<string name="settings_special_dates_disable_confirm">关闭</string>
|
||||||
|
<string name="dialog_save">保存</string>
|
||||||
|
<string name="settings_section_about">关于</string>
|
||||||
|
<string name="settings_license">许可协议</string>
|
||||||
|
<string name="settings_license_value">MIT</string>
|
||||||
|
<string name="settings_about_author">作者 Jean-Luc Makiola</string>
|
||||||
|
<string name="settings_about_source">源代码</string>
|
||||||
|
<string name="settings_about_privacy">隐私政策</string>
|
||||||
|
<string name="settings_about_support">支持开发</string>
|
||||||
|
<string name="settings_about_version">版本 %1$s</string>
|
||||||
|
<string name="settings_about_logo_desc">Calendula 应用图标</string>
|
||||||
|
<string name="settings_report_problem">报告问题</string>
|
||||||
|
<string name="settings_report_problem_hint">发送崩溃报告或打开问题追踪器</string>
|
||||||
|
<string name="calendars_title">日历</string>
|
||||||
|
<string name="calendars_local_header">您的日历</string>
|
||||||
|
<string name="calendars_local_empty">尚未创建本地日历。创建一个以仅在本设备保存事件。</string>
|
||||||
|
<string name="calendars_add">添加日历</string>
|
||||||
|
<string name="calendars_visibility_hint">关闭日历可隐藏其在设备上的显示——事件会从应用中消失,同时停止提醒。此开关与其他日历应用共用,因此它们也会隐藏。不会删除任何内容,不会影响其他设备,您随时可在此重新开启。</string>
|
||||||
|
<string name="calendars_visibility_a11y">显示“%1$s”</string>
|
||||||
|
<string name="calendars_visibility_notice_title">部分日历已关闭</string>
|
||||||
|
<string name="calendars_visibility_notice_message">Calendula 显示本设备已开启的日历,但您有一些是关闭的。要查看事件,请在“设置”→“日历”中将其开启。</string>
|
||||||
|
<string name="calendar_picker_missing_title">找不到日历?</string>
|
||||||
|
<string name="calendar_picker_missing_summary">可能已关闭、只读,或由联系人填充——在此管理您的日历。</string>
|
||||||
|
<string name="calendars_state_read_only">只读</string>
|
||||||
|
<string name="calendars_state_not_synced">未同步到本设备</string>
|
||||||
|
<string name="calendars_state_managed">由联系人填充</string>
|
||||||
|
<string name="calendars_managed_delete_locked">此日历由联系人填充,Calendula 下次同步时会重新创建。要删除它,请在“设置”→“特殊日期”中关闭相关功能。</string>
|
||||||
|
<string name="calendars_synced_header">同步日历</string>
|
||||||
|
<string name="calendars_synced_hint">来自设备上的帐户。请在对应的应用中创建和编辑。</string>
|
||||||
|
<string name="calendars_manage_in_app">在应用中管理</string>
|
||||||
|
<string name="calendars_account_from_source">%1$s(%2$s)</string>
|
||||||
|
<string name="calendars_account_menu_a11y">%1$s 的更多选项</string>
|
||||||
|
<string name="calendars_enable_all">启用全部</string>
|
||||||
|
<string name="calendars_disable_all">禁用全部</string>
|
||||||
|
<string name="calendars_add_account">添加帐户</string>
|
||||||
|
<string name="calendars_new_title">新建日历</string>
|
||||||
|
<string name="calendars_edit_title">编辑日历</string>
|
||||||
|
<string name="calendars_name_label">名称</string>
|
||||||
|
<string name="calendars_color_label">色彩</string>
|
||||||
|
<string name="calendars_description_hint">添加描述</string>
|
||||||
|
<string name="calendars_delete_confirm_title">删除日历?</string>
|
||||||
|
<string name="calendars_delete_confirm_message">“%1$s”及其所有事件将从本设备永久删除。</string>
|
||||||
|
<string name="calendars_write_error">无法保存更改。</string>
|
||||||
|
<string name="calendars_backup_header">返回</string>
|
||||||
|
<string name="calendars_backup_hint">本地日历不会同步到任何地方,请导出为 .ics 文件以保留副本。</string>
|
||||||
|
<string name="calendars_backup_action">导出为 .ics 文件</string>
|
||||||
|
<string name="calendars_export_title">导出日历</string>
|
||||||
|
<string name="calendars_export_hint">选择要包含在 .ics 文件中的日历。</string>
|
||||||
|
<string name="calendars_export_action">导出</string>
|
||||||
|
<string name="calendars_restore_header">恢复</string>
|
||||||
|
<string name="calendars_restore_action">从 .ics 文件恢复</string>
|
||||||
|
<string name="calendars_restore_hint">从备份或其他日历应用导入事件。</string>
|
||||||
|
<string name="calendars_auto_backup">自动备份</string>
|
||||||
|
<string name="calendars_auto_backup_hint">定期将本地日历导出为 .ics 文件保存到文件夹。</string>
|
||||||
|
<string name="calendars_auto_backup_folder">备份文件夹</string>
|
||||||
|
<string name="calendars_auto_backup_folder_unset">点击选择文件夹</string>
|
||||||
|
<string name="calendars_auto_backup_interval">间隔</string>
|
||||||
|
<string name="calendars_auto_backup_every">每 %1$s</string>
|
||||||
|
<string name="calendars_auto_backup_interval_min">最短 30 分钟。</string>
|
||||||
|
<string name="calendars_auto_backup_status_never">尚未自动备份</string>
|
||||||
|
<string name="calendars_auto_backup_status_ok">上次备份:%1$s</string>
|
||||||
|
<string name="calendars_auto_backup_status_failed">上次备份失败:%1$s</string>
|
||||||
|
<string name="backup_channel_name">备份</string>
|
||||||
|
<string name="backup_channel_description">自动备份反复失败时发出警告。</string>
|
||||||
|
<string name="backup_failed_title">自动备份失败</string>
|
||||||
|
<string name="backup_failed_text">Calendula 无法写入备份文件。请在“设置”中检查备份文件夹。</string>
|
||||||
|
<string name="calendars_backup_failed">无法导出备份。</string>
|
||||||
|
<plurals name="calendars_backup_done">
|
||||||
|
<item quantity="other">已导出 %d 个事件。</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="import_title">导入事件</string>
|
||||||
|
<string name="import_target_header">添加至日历</string>
|
||||||
|
<string name="import_empty">此文件中未找到事件。</string>
|
||||||
|
<string name="import_failed">无法读取此文件。</string>
|
||||||
|
<string name="import_no_calendar">没有可写入的日历用于导入。请先创建本地日历。</string>
|
||||||
|
<string name="import_done_title">导入完成</string>
|
||||||
|
<string name="import_done_dedup_note">已跳过日历中已存在的事件。</string>
|
||||||
|
<string name="import_done_added_label">已添加</string>
|
||||||
|
<string name="import_done_skipped_label">重复项</string>
|
||||||
|
<string name="import_close">关闭</string>
|
||||||
|
<string name="import_warning_recurrence">已跳过重复事件中部分已修改的发生项。</string>
|
||||||
|
<string name="import_warning_no_start">已跳过无开始时间的事件。</string>
|
||||||
|
<string name="import_warning_attendees">未导入参与者列表。</string>
|
||||||
|
<string name="import_warning_timezone">未知时区已回退为设备时区。</string>
|
||||||
|
<string name="import_button">导入</string>
|
||||||
|
<plurals name="import_title_count">
|
||||||
|
<item quantity="other">正在导入 %d 个事件</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="import_event_count">
|
||||||
|
<item quantity="other">此文件中包含 %d 个事件。</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="import_action">
|
||||||
|
<item quantity="other">导入 %d 个事件</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="import_done_imported">
|
||||||
|
<item quantity="other">已导入 %d 个事件。</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="import_done_skipped">
|
||||||
|
<item quantity="other">已跳过 %d 个(已存在于该日历)。</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="shortcut_new_event_short">新事件</string>
|
||||||
|
<string name="shortcut_new_event_long">创建新事件</string>
|
||||||
|
<string name="qs_tile_new_event_label">新事件</string>
|
||||||
|
<string name="settings_qs_tile">添加快速设置磁贴</string>
|
||||||
|
<string name="settings_qs_tile_hint">在快速设置面板中添加“新事件”磁贴。</string>
|
||||||
|
<string name="crash_dialog_title">%1$s 已崩溃</string>
|
||||||
|
<string name="crash_dialog_message">%1$s 上次意外关闭。您可以将此报告作为问题发送以帮助修复。报告在您选择共享之前仅保留在设备上,不含任何个人数据或日历内容——仅包含以下技术细节。</string>
|
||||||
|
<string name="crash_dialog_report">报告</string>
|
||||||
|
<string name="crash_dialog_dismiss">暂不</string>
|
||||||
|
<string name="crash_report_issue_title">崩溃报告</string>
|
||||||
|
<string name="crash_report_clip_label">%1$s 崩溃报告</string>
|
||||||
|
<string name="crash_report_copied">报告已复制到剪贴板</string>
|
||||||
|
<string name="crash_report_open_failed">无法打开问题追踪器。报告已在剪贴板中。</string>
|
||||||
|
<string name="crash_report_body_template">感谢您报告 %1$s 的崩溃。请补充您记得的操作内容,然后提交。\n\n### 发生了什么\n\n\n### 崩溃报告\n%2$s\n</string>
|
||||||
|
<string name="crash_report_body_paste">_(报告太长,无法通过此链接附带——请从剪贴板粘贴。)_</string>
|
||||||
|
<string name="special_dates_calendar_birthday">生日</string>
|
||||||
|
<string name="special_dates_calendar_anniversary">纪念日</string>
|
||||||
|
<string name="special_dates_calendar_custom">特殊日期</string>
|
||||||
|
<string name="special_dates_default_title_birthday">{name} 的生日({year})</string>
|
||||||
|
<string name="special_dates_default_title_anniversary">{name} 的纪念日({year})</string>
|
||||||
|
<string name="special_dates_default_title_custom">{name}</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -231,6 +231,8 @@
|
|||||||
<string name="event_detail_self_response">Your response: %1$s</string>
|
<string name="event_detail_self_response">Your response: %1$s</string>
|
||||||
<string name="reminder_at_time">At time of event</string>
|
<string name="reminder_at_time">At time of event</string>
|
||||||
<string name="reminder_default">Default reminder</string>
|
<string name="reminder_default">Default reminder</string>
|
||||||
|
<!-- A reminder that fires after the event starts, e.g. "2 hours after". -->
|
||||||
|
<string name="reminder_after">%1$s after</string>
|
||||||
<plurals name="reminder_minutes">
|
<plurals name="reminder_minutes">
|
||||||
<item quantity="one">%d minute before</item>
|
<item quantity="one">%d minute before</item>
|
||||||
<item quantity="other">%d minutes before</item>
|
<item quantity="other">%d minutes before</item>
|
||||||
@@ -693,11 +695,16 @@
|
|||||||
<string name="import_done_dedup_note">Events already in the calendar were skipped.</string>
|
<string name="import_done_dedup_note">Events already in the calendar were skipped.</string>
|
||||||
<string name="import_done_added_label">Added</string>
|
<string name="import_done_added_label">Added</string>
|
||||||
<string name="import_done_skipped_label">Duplicates</string>
|
<string name="import_done_skipped_label">Duplicates</string>
|
||||||
|
<string name="import_done_failed_label">Not added</string>
|
||||||
|
<string name="import_done_failed_note">Some events couldn\'t be added and were left out.</string>
|
||||||
|
<string name="import_done_stopped_title">Import stopped</string>
|
||||||
<string name="import_close">Close</string>
|
<string name="import_close">Close</string>
|
||||||
<string name="import_warning_recurrence">Some changed occurrences of recurring events were skipped.</string>
|
<string name="import_warning_recurrence">Some changed occurrences of recurring events were skipped.</string>
|
||||||
<string name="import_warning_no_start">An event without a start time was skipped.</string>
|
<string name="import_warning_no_start">An event without a start time was skipped.</string>
|
||||||
<string name="import_warning_attendees">Guest lists weren\'t imported.</string>
|
<string name="import_warning_attendees">Guest lists weren\'t imported.</string>
|
||||||
<string name="import_warning_timezone">An unknown time zone fell back to your device\'s.</string>
|
<string name="import_warning_timezone">An unknown time zone fell back to your device\'s.</string>
|
||||||
|
<string name="import_warning_tasks">Tasks in this file were added as events.</string>
|
||||||
|
<string name="import_warning_recurrence_repaired">A faulty repeat rule was repaired.</string>
|
||||||
<string name="import_button">Import</string>
|
<string name="import_button">Import</string>
|
||||||
<plurals name="import_title_count">
|
<plurals name="import_title_count">
|
||||||
<item quantity="one">Importing %d event</item>
|
<item quantity="one">Importing %d event</item>
|
||||||
@@ -711,6 +718,10 @@
|
|||||||
<item quantity="one">Import %d event</item>
|
<item quantity="one">Import %d event</item>
|
||||||
<item quantity="other">Import %d events</item>
|
<item quantity="other">Import %d events</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
<plurals name="import_done_stopped">
|
||||||
|
<item quantity="one">The calendar stopped accepting events part way through. %d event from the file was never added.</item>
|
||||||
|
<item quantity="other">The calendar stopped accepting events part way through. %d events from the file were never added.</item>
|
||||||
|
</plurals>
|
||||||
<plurals name="import_done_imported">
|
<plurals name="import_done_imported">
|
||||||
<item quantity="one">Imported %d event.</item>
|
<item quantity="one">Imported %d event.</item>
|
||||||
<item quantity="other">Imported %d events.</item>
|
<item quantity="other">Imported %d events.</item>
|
||||||
@@ -719,6 +730,10 @@
|
|||||||
<item quantity="one">Skipped %d already in this calendar.</item>
|
<item quantity="one">Skipped %d already in this calendar.</item>
|
||||||
<item quantity="other">Skipped %d already in this calendar.</item>
|
<item quantity="other">Skipped %d already in this calendar.</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
<plurals name="import_done_failed">
|
||||||
|
<item quantity="one">%d event couldn\'t be added.</item>
|
||||||
|
<item quantity="other">%d events couldn\'t be added.</item>
|
||||||
|
</plurals>
|
||||||
<!-- Launcher long-press shortcuts -->
|
<!-- Launcher long-press shortcuts -->
|
||||||
<string name="shortcut_new_event_short">New event</string>
|
<string name="shortcut_new_event_short">New event</string>
|
||||||
<string name="shortcut_new_event_long">Create a new event</string>
|
<string name="shortcut_new_event_long">Create a new event</string>
|
||||||
|
|||||||
@@ -10,14 +10,17 @@
|
|||||||
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
|
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<locale android:name="en" />
|
<locale android:name="en" />
|
||||||
<locale android:name="ar" />
|
<locale android:name="ar" />
|
||||||
|
<locale android:name="be" />
|
||||||
<locale android:name="cs" />
|
<locale android:name="cs" />
|
||||||
<locale android:name="de" />
|
<locale android:name="de" />
|
||||||
<locale android:name="es" />
|
<locale android:name="es" />
|
||||||
<locale android:name="fr" />
|
<locale android:name="fr" />
|
||||||
|
<locale android:name="hu" />
|
||||||
<locale android:name="it" />
|
<locale android:name="it" />
|
||||||
<locale android:name="ja" />
|
<locale android:name="ja" />
|
||||||
<locale android:name="pl" />
|
<locale android:name="pl" />
|
||||||
<locale android:name="pt-BR" />
|
<locale android:name="pt-BR" />
|
||||||
<locale android:name="ru" />
|
<locale android:name="ru" />
|
||||||
|
<locale android:name="sk" />
|
||||||
<locale android:name="zh-CN" />
|
<locale android:name="zh-CN" />
|
||||||
</locale-config>
|
</locale-config>
|
||||||
|
|||||||
@@ -143,6 +143,76 @@ class AllDayReminderEncodingTest {
|
|||||||
assertThat(fireFromNext).isEqualTo(LocalTime.of(9, 0)) // correct
|
assertThat(fireFromNext).isEqualTo(LocalTime.of(9, 0)) // correct
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an imported yearly series samples at its next occurrence, not its anchor`() {
|
||||||
|
// Fossify anchors a year-less contact birthday at 1970, when Berlin had
|
||||||
|
// no DST; sampling there fires a modern June occurrence at 10:00 instead
|
||||||
|
// of the 09:00 the user picked (Codeberg #225).
|
||||||
|
val anchor = LocalDate.of(1970, 6, 3)
|
||||||
|
val today = LocalDate.of(2026, 6, 1)
|
||||||
|
|
||||||
|
val sampled = importedAllDayReminderDate(anchor, "FREQ=YEARLY;BYMONTH=6", today)
|
||||||
|
assertThat(sampled).isEqualTo(LocalDate.of(2026, 6, 3))
|
||||||
|
|
||||||
|
val raw = toProviderAllDayMinutes(0, sampled, berlin, nineAm)
|
||||||
|
val fire = actualFire(raw, LocalDate.of(2027, 6, 3))
|
||||||
|
.let(java.time.Instant::ofEpochMilli).atZone(berlin).toLocalTime()
|
||||||
|
assertThat(fire).isEqualTo(LocalTime.of(9, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the lead time still reads back from the anchor's own date`() {
|
||||||
|
// The offset is sampled at the next occurrence but decoded against
|
||||||
|
// DTSTART, so the two must still agree on the whole-day lead time.
|
||||||
|
val anchor = LocalDate.of(1970, 6, 3)
|
||||||
|
val sampled = importedAllDayReminderDate(anchor, "FREQ=YEARLY", LocalDate.of(2026, 6, 1))
|
||||||
|
|
||||||
|
for (semantic in listOf(0, 1_440, 2_880)) {
|
||||||
|
val raw = toProviderAllDayMinutes(semantic, sampled, berlin, nineAm)
|
||||||
|
assertThat(fromProviderAllDayMinutes(raw, anchor, berlin, nineAm)).isEqualTo(semantic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a one-off import samples at its own date, a stale series at today`() {
|
||||||
|
val past = LocalDate.of(1970, 6, 3)
|
||||||
|
val today = LocalDate.of(2026, 6, 1)
|
||||||
|
val future = LocalDate.of(2027, 3, 4)
|
||||||
|
|
||||||
|
assertThat(importedAllDayReminderDate(past, null, today)).isEqualTo(past)
|
||||||
|
assertThat(importedAllDayReminderDate(past, "FREQ=WEEKLY;BYDAY=MO", today)).isEqualTo(today)
|
||||||
|
assertThat(importedAllDayReminderDate(future, "FREQ=WEEKLY", today)).isEqualTo(future)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the provider default sentinel survives a round trip untouched`() {
|
||||||
|
// -1 is Reminders.MINUTES_DEFAULT, not an offset. Decoded it landed on
|
||||||
|
// the start date and came back as 0 — a concrete at-start alarm on the
|
||||||
|
// detail screen and in an exported file.
|
||||||
|
assertThat(toProviderAllDayMinutes(-1, winter, berlin, nineAm)).isEqualTo(-1)
|
||||||
|
assertThat(fromProviderAllDayMinutes(-1, winter, berlin, nineAm)).isEqualTo(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a real encoding that is negative is still decoded`() {
|
||||||
|
// "At time of event" encodes negative — the sentinel check must be the
|
||||||
|
// exact value, not any negative one.
|
||||||
|
val raw = toProviderAllDayMinutes(0, winter, berlin, nineAm)
|
||||||
|
assertThat(raw).isLessThan(0)
|
||||||
|
assertThat(fromProviderAllDayMinutes(raw, winter, berlin, nineAm)).isEqualTo(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an encoding that would land on the sentinel is nudged off it`() {
|
||||||
|
// 01:01 Berlin in winter (CET, +1) is 00:01 UTC — one minute past the
|
||||||
|
// event's UTC-midnight DTSTART, i.e. exactly -1. Left there it would read
|
||||||
|
// back as "use the account default" and the real reminder would be gone
|
||||||
|
// from the screen and from an export.
|
||||||
|
val raw = toProviderAllDayMinutes(0, winter, berlin, 61)
|
||||||
|
assertThat(raw).isEqualTo(-2)
|
||||||
|
assertThat(fromProviderAllDayMinutes(raw, winter, berlin, 61)).isEqualTo(0)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a winter-anchored offset drifts one hour on a summer occurrence`() {
|
fun `a winter-anchored offset drifts one hour on a summer occurrence`() {
|
||||||
// Known limitation: one fixed MINUTES per series can't track DST. An
|
// Known limitation: one fixed MINUTES per series can't track DST. An
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
|||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.assertThrows
|
||||||
import org.junit.jupiter.api.io.TempDir
|
import org.junit.jupiter.api.io.TempDir
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
|
|
||||||
@@ -642,7 +643,12 @@ class CalendarRepositoryImplTest {
|
|||||||
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
|
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
|
||||||
)
|
)
|
||||||
|
|
||||||
val id = repo.updateOccurrence(eventId = 42L, beginMillis = 1_000L, form = form)
|
val id = repo.updateOccurrence(
|
||||||
|
eventId = 42L,
|
||||||
|
beginMillis = 1_000L,
|
||||||
|
original = form,
|
||||||
|
form = form,
|
||||||
|
)
|
||||||
|
|
||||||
assertThat(id).isEqualTo(88L)
|
assertThat(id).isEqualTo(88L)
|
||||||
assertThat(fake.updatedOccurrences).containsExactly(Triple(42L, 1_000L, form))
|
assertThat(fake.updatedOccurrences).containsExactly(Triple(42L, 1_000L, form))
|
||||||
@@ -801,6 +807,113 @@ class CalendarRepositoryImplTest {
|
|||||||
assertThat(fake.importedEvents.map { it.second }).containsExactly(3L, 3L)
|
assertThat(fake.importedEvents.map { it.second }).containsExactly(3L, 3L)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `importEvents carries on past an event the provider rejects`(
|
||||||
|
@TempDir tempDir: Path,
|
||||||
|
) = runTest {
|
||||||
|
// A foreign export can hold a row the provider throws on outright; that
|
||||||
|
// must cost the user one event, not the whole file (Codeberg #225).
|
||||||
|
val fake = FakeCalendarDataSource().apply { failingImportSummaries += "bad" }
|
||||||
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||||
|
val events = listOf(
|
||||||
|
parsedEvent("a@x", summary = "good"),
|
||||||
|
parsedEvent("b@x", summary = "bad"),
|
||||||
|
parsedEvent("c@x", summary = "good"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val summary = repo.importEvents(targetCalendarId = 3L, events = events)
|
||||||
|
|
||||||
|
assertThat(summary.imported).isEqualTo(2)
|
||||||
|
assertThat(summary.failed).isEqualTo(1)
|
||||||
|
assertThat(fake.importedEvents.map { it.first.uid }).containsExactly("a@x", "c@x")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `importEvents stops at a permission failure and reports what landed`(
|
||||||
|
@TempDir tempDir: Path,
|
||||||
|
) = runTest {
|
||||||
|
// The permission or the target calendar is gone, so every remaining insert
|
||||||
|
// would fail the same way. Counted per event, the screen reported "N events
|
||||||
|
// couldn't be added" for a file it never had a chance at; thrown, it
|
||||||
|
// reported that nothing landed — and since only events carrying a UID dedup,
|
||||||
|
// the retry that invites duplicated everything that already had.
|
||||||
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
importsBeforeError = 2
|
||||||
|
writeError = SecurityException("WRITE_CALENDAR revoked")
|
||||||
|
}
|
||||||
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||||
|
|
||||||
|
val summary = repo.importEvents(
|
||||||
|
targetCalendarId = 3L,
|
||||||
|
events = List(5) { parsedEvent("e$it@x") },
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(summary.imported).isEqualTo(2)
|
||||||
|
// The one that threw and the two never reached.
|
||||||
|
assertThat(summary.notAttempted).isEqualTo(3)
|
||||||
|
assertThat(summary.failed).isEqualTo(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a stopped import does not count known duplicates as work left to do`(
|
||||||
|
@TempDir tempDir: Path,
|
||||||
|
) = runTest {
|
||||||
|
// The remainder is not all work: a UID already in the calendar would have
|
||||||
|
// been skipped whatever happened, so counting it against a retry
|
||||||
|
// overstates how much of the file is missing.
|
||||||
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
existingUidsResult = setOf("e3@x", "e4@x")
|
||||||
|
importsBeforeError = 1
|
||||||
|
writeError = SecurityException("WRITE_CALENDAR revoked")
|
||||||
|
}
|
||||||
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||||
|
|
||||||
|
val summary = repo.importEvents(
|
||||||
|
targetCalendarId = 3L,
|
||||||
|
events = List(5) { parsedEvent("e$it@x") },
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(summary.imported).isEqualTo(1)
|
||||||
|
// e1 threw and e2 was never reached; e3 and e4 were duplicates either way.
|
||||||
|
assertThat(summary.notAttempted).isEqualTo(2)
|
||||||
|
assertThat(summary.skippedDuplicate).isEqualTo(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `importEvents looks the target palette up once, not per event`(
|
||||||
|
@TempDir tempDir: Path,
|
||||||
|
) = runTest {
|
||||||
|
var lookups = 0
|
||||||
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
publishedEventColorsResult = { lookups++; emptyList() }
|
||||||
|
}
|
||||||
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||||
|
|
||||||
|
repo.importEvents(3L, List(5) { parsedEvent("e$it@x") })
|
||||||
|
|
||||||
|
assertThat(lookups).isEqualTo(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `importEvents matches colours against the uncurated palette`(
|
||||||
|
@TempDir tempDir: Path,
|
||||||
|
) = runTest {
|
||||||
|
// Curation is a picker concession — it folds look-alikes and drops the
|
||||||
|
// neutrals from an oversized palette — but every published key is one
|
||||||
|
// the calendar accepts, so an imported colour matches against all of
|
||||||
|
// them (Codeberg #225).
|
||||||
|
val published = listOf(EventColorOption("1", 0xFF808080.toInt()))
|
||||||
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
publishedEventColorsResult = { published }
|
||||||
|
eventColorPaletteResult = { emptyList() }
|
||||||
|
}
|
||||||
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||||
|
|
||||||
|
repo.importEvents(3L, listOf(parsedEvent("a@x")))
|
||||||
|
|
||||||
|
assertThat(fake.lastImportPalette).isEqualTo(published)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `exportEvents forwards the chosen calendar-id subset to the data source`(
|
fun `exportEvents forwards the chosen calendar-id subset to the data source`(
|
||||||
@TempDir tempDir: Path,
|
@TempDir tempDir: Path,
|
||||||
@@ -825,9 +938,10 @@ class CalendarRepositoryImplTest {
|
|||||||
assertThat(fake.lastExportableEventsCalendarIds).isNull()
|
assertThat(fake.lastExportableEventsCalendarIds).isNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parsedEvent(uid: String?) = de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent(
|
private fun parsedEvent(uid: String?, summary: String = "E") =
|
||||||
|
de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent(
|
||||||
uid = uid,
|
uid = uid,
|
||||||
summary = "E",
|
summary = summary,
|
||||||
start = Instant.fromEpochMilliseconds(1_000_000_000L),
|
start = Instant.fromEpochMilliseconds(1_000_000_000L),
|
||||||
end = Instant.fromEpochMilliseconds(1_000_003_600L),
|
end = Instant.fromEpochMilliseconds(1_000_003_600L),
|
||||||
isAllDay = false,
|
isAllDay = false,
|
||||||
|
|||||||
@@ -128,7 +128,8 @@ class EventWriteMapperTest {
|
|||||||
original: EventForm,
|
original: EventForm,
|
||||||
updated: EventForm,
|
updated: EventForm,
|
||||||
series: Long = seriesStart,
|
series: Long = seriesStart,
|
||||||
): Map<String, Any?> = buildEventUpdateValues(original, updated, series, berlin)
|
exdate: String? = null,
|
||||||
|
): Map<String, Any?> = buildEventUpdateValues(original, updated, series, exdate, berlin)
|
||||||
|
|
||||||
/** The instant [local] names in [zoneId], as the provider would store it. */
|
/** The instant [local] names in [zoneId], as the provider would store it. */
|
||||||
private fun instantAt(local: String, zoneId: String): Long =
|
private fun instantAt(local: String, zoneId: String): Long =
|
||||||
@@ -497,6 +498,7 @@ class EventWriteMapperTest {
|
|||||||
occurrenceMillis = 1_784_102_400_000L,
|
occurrenceMillis = 1_784_102_400_000L,
|
||||||
dtStartMillis = 1_783_929_600_000L,
|
dtStartMillis = 1_783_929_600_000L,
|
||||||
rrule = "FREQ=DAILY;COUNT=5",
|
rrule = "FREQ=DAILY;COUNT=5",
|
||||||
|
rdate = null,
|
||||||
duration = "PT1H",
|
duration = "PT1H",
|
||||||
timezone = "Europe/Berlin",
|
timezone = "Europe/Berlin",
|
||||||
allDay = 0,
|
allDay = 0,
|
||||||
@@ -520,6 +522,7 @@ class EventWriteMapperTest {
|
|||||||
occurrenceMillis = 1_784_102_400_000L,
|
occurrenceMillis = 1_784_102_400_000L,
|
||||||
dtStartMillis = 1_783_929_600_000L,
|
dtStartMillis = 1_783_929_600_000L,
|
||||||
rrule = "FREQ=DAILY;COUNT=5",
|
rrule = "FREQ=DAILY;COUNT=5",
|
||||||
|
rdate = null,
|
||||||
duration = "PT1H",
|
duration = "PT1H",
|
||||||
timezone = "Europe/Berlin",
|
timezone = "Europe/Berlin",
|
||||||
allDay = 0,
|
allDay = 0,
|
||||||
@@ -535,6 +538,7 @@ class EventWriteMapperTest {
|
|||||||
occurrenceMillis = 1_784_102_400_000L,
|
occurrenceMillis = 1_784_102_400_000L,
|
||||||
dtStartMillis = 1_783_929_600_000L,
|
dtStartMillis = 1_783_929_600_000L,
|
||||||
rrule = "FREQ=DAILY;COUNT=5",
|
rrule = "FREQ=DAILY;COUNT=5",
|
||||||
|
rdate = null,
|
||||||
duration = "PT1H",
|
duration = "PT1H",
|
||||||
timezone = "Europe/Berlin",
|
timezone = "Europe/Berlin",
|
||||||
allDay = 0,
|
allDay = 0,
|
||||||
@@ -542,6 +546,27 @@ class EventWriteMapperTest {
|
|||||||
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260715T080000Z")
|
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260715T080000Z")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an all-day drop folds away a padded repeat of the same day`() {
|
||||||
|
// Compared verbatim, the two spellings of 15 July are different strings:
|
||||||
|
// the day ends up excluded twice, and exdateContains — the guard against
|
||||||
|
// detaching the same occurrence twice — misses it entirely.
|
||||||
|
val occurrence = 1_784_073_600_000L // 2026-07-15T00:00:00Z
|
||||||
|
val values = buildOccurrenceExdateValues(
|
||||||
|
existingExdate = "20260715T000000Z",
|
||||||
|
occurrenceMillis = occurrence,
|
||||||
|
dtStartMillis = 1_783_900_800_000L,
|
||||||
|
rrule = "FREQ=WEEKLY",
|
||||||
|
rdate = null,
|
||||||
|
duration = "P1D",
|
||||||
|
timezone = "UTC",
|
||||||
|
allDay = 1,
|
||||||
|
)
|
||||||
|
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260715")
|
||||||
|
assertThat(exdateContains("20260715T000000Z", occurrence, isAllDay = true, timezone = null))
|
||||||
|
.isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `all-day exdate drop uses the date-only form`() {
|
fun `all-day exdate drop uses the date-only form`() {
|
||||||
// An all-day DTSTART sits at UTC midnight, so the date reads off UTC.
|
// An all-day DTSTART sits at UTC midnight, so the date reads off UTC.
|
||||||
@@ -550,6 +575,7 @@ class EventWriteMapperTest {
|
|||||||
occurrenceMillis = 1_784_073_600_000L, // 2026-07-15T00:00:00Z
|
occurrenceMillis = 1_784_073_600_000L, // 2026-07-15T00:00:00Z
|
||||||
dtStartMillis = 1_783_900_800_000L,
|
dtStartMillis = 1_783_900_800_000L,
|
||||||
rrule = "FREQ=YEARLY",
|
rrule = "FREQ=YEARLY",
|
||||||
|
rdate = null,
|
||||||
duration = "P1D",
|
duration = "P1D",
|
||||||
timezone = "UTC",
|
timezone = "UTC",
|
||||||
allDay = 1,
|
allDay = 1,
|
||||||
@@ -558,6 +584,439 @@ class EventWriteMapperTest {
|
|||||||
assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(1)
|
assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an exdate list zoned the way the provider writes it is read in that zone`() {
|
||||||
|
// AOSP's RecurrenceSet keeps the TZID parameter's *value*, not the
|
||||||
|
// parameter: `Europe/Berlin;<stamps>`, no `TZID=`. Matching only the
|
||||||
|
// iCalendar spelling read every list the provider itself wrote as if its
|
||||||
|
// floating stamps were UTC — hours off for any zone but UTC.
|
||||||
|
assertThat(exdateListZone("Europe/Berlin;20260708T100000", "UTC"))
|
||||||
|
.isEqualTo(ZoneId.of("Europe/Berlin"))
|
||||||
|
assertThat(exdateListZone("TZID=Europe/Berlin;20260708T100000", "UTC"))
|
||||||
|
.isEqualTo(ZoneId.of("Europe/Berlin"))
|
||||||
|
// No prefix, or one this device can't place: the row's own zone.
|
||||||
|
assertThat(exdateListZone("20260708T100000", "Europe/Berlin"))
|
||||||
|
.isEqualTo(ZoneId.of("Europe/Berlin"))
|
||||||
|
assertThat(exdateListZone("W. Europe Standard Time;20260708T100000", "Europe/Berlin"))
|
||||||
|
.isEqualTo(ZoneId.of("Europe/Berlin"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a bare-zoned exclusion list splits on the instants its prefix names`() {
|
||||||
|
assertThat(
|
||||||
|
exdateAfter(
|
||||||
|
"Europe/Berlin;20260708T100000,20260729T100000",
|
||||||
|
instantAt("2026-07-15T08:00", "UTC"),
|
||||||
|
isAllDay = false,
|
||||||
|
timezone = null,
|
||||||
|
),
|
||||||
|
).isEqualTo("20260729T080000Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an exdate drop keeps a list it cannot read whole, prefix and all`() {
|
||||||
|
// Stripping the prefix off a list and re-emitting the stamps it can't
|
||||||
|
// parse leaves them floating: "09:00 in Berlin" silently becomes 09:00 in
|
||||||
|
// whatever zone the next reader picks.
|
||||||
|
val values = buildOccurrenceExdateValues(
|
||||||
|
existingExdate = "Europe/Berlin;20260722T090000",
|
||||||
|
occurrenceMillis = 1_784_073_600_000L, // 2026-07-15T00:00:00Z
|
||||||
|
dtStartMillis = 1_783_900_800_000L,
|
||||||
|
rrule = "FREQ=WEEKLY",
|
||||||
|
rdate = null,
|
||||||
|
duration = "P1D",
|
||||||
|
timezone = "Europe/Berlin",
|
||||||
|
allDay = 1,
|
||||||
|
)
|
||||||
|
assertThat(values[CalendarContract.Events.EXDATE])
|
||||||
|
.isEqualTo("Europe/Berlin;20260722T090000,20260715")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a new exclusion joins an unreadable timed list in that list's own form`() {
|
||||||
|
val values = buildOccurrenceExdateValues(
|
||||||
|
existingExdate = "Europe/Berlin;sometime next Tuesday",
|
||||||
|
occurrenceMillis = 1_784_102_400_000L, // 2026-07-15T08:00:00Z == 10:00 Berlin
|
||||||
|
dtStartMillis = 1_783_929_600_000L,
|
||||||
|
rrule = "FREQ=DAILY",
|
||||||
|
rdate = null,
|
||||||
|
duration = "PT1H",
|
||||||
|
timezone = "Europe/Berlin",
|
||||||
|
allDay = 0,
|
||||||
|
)
|
||||||
|
assertThat(values[CalendarContract.Events.EXDATE])
|
||||||
|
.isEqualTo("Europe/Berlin;sometime next Tuesday,20260715T100000")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an exdate drop rewrites RDATE along with the rule`() {
|
||||||
|
// A series may be a list of dates with no rule at all. Left out of the
|
||||||
|
// rewrite, the provider has only DTSTART to re-expand from and collapses
|
||||||
|
// the series to its first occurrence.
|
||||||
|
val values = buildOccurrenceExdateValues(
|
||||||
|
existingExdate = null,
|
||||||
|
occurrenceMillis = 1_784_102_400_000L,
|
||||||
|
dtStartMillis = 1_783_929_600_000L,
|
||||||
|
rrule = null,
|
||||||
|
rdate = "20260716T080000Z,20260722T080000Z",
|
||||||
|
duration = "PT1H",
|
||||||
|
timezone = "Europe/Berlin",
|
||||||
|
allDay = 0,
|
||||||
|
)
|
||||||
|
assertThat(values[CalendarContract.Events.RDATE])
|
||||||
|
.isEqualTo("20260716T080000Z,20260722T080000Z")
|
||||||
|
assertThat(values[CalendarContract.Events.RRULE]).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- EXDATE follows the series when its times change (Codeberg #248) ---
|
||||||
|
|
||||||
|
/** A weekly series whose displayed occurrence runs 15 July 2026, 09:00–10:00. */
|
||||||
|
private fun julySeries(): EventForm = form(
|
||||||
|
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
|
||||||
|
).copy(rrule = "FREQ=WEEKLY")
|
||||||
|
|
||||||
|
/** [julySeries] pushed to [hour]:00, the shift an "all events" time edit makes. */
|
||||||
|
private fun EventForm.atHour(hour: Int): EventForm = copy(
|
||||||
|
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(hour, 0)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(hour + 1, 0)),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a series time edit moves its exclusions with the anchor`() {
|
||||||
|
// The bug: the stamp stayed at the old instant, which the moved series no
|
||||||
|
// longer generates, so the occurrence the user deleted came back.
|
||||||
|
val series = instantAt("2026-07-01T09:00", "Europe/Berlin")
|
||||||
|
val original = julySeries()
|
||||||
|
// 8 July 09:00 Berlin (CEST, +2) == 07:00Z; at 10:00 it must read 08:00Z.
|
||||||
|
val values = update(original, original.atHour(10), series, "20260708T070000Z")
|
||||||
|
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260708T080000Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an exclusion keeps its wall clock across a DST boundary`() {
|
||||||
|
// Anchor and edited occurrence are in July (CEST, +2); the excluded
|
||||||
|
// occurrence sits in January (CET, +1). Shifting by the millisecond delta
|
||||||
|
// measured at the edited occurrence would leave it an hour off.
|
||||||
|
val series = instantAt("2026-01-07T09:00", "Europe/Berlin")
|
||||||
|
val original = julySeries()
|
||||||
|
// 14 January 09:00 Berlin == 08:00Z; at 10:00 it must read 09:00Z.
|
||||||
|
val values = update(original, original.atHour(10), series, "20260114T080000Z")
|
||||||
|
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260114T090000Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `pinning a series to another zone re-resolves its exclusions there`() {
|
||||||
|
val series = instantAt("2026-07-01T09:00", "Europe/Berlin")
|
||||||
|
val original = julySeries()
|
||||||
|
val values = update(
|
||||||
|
original,
|
||||||
|
original.copy(timezone = "Asia/Tokyo"),
|
||||||
|
series,
|
||||||
|
"20260716T070000Z",
|
||||||
|
)
|
||||||
|
// The exclusion still reads 09:00 — now 09:00 in Tokyo (+9) == 00:00Z.
|
||||||
|
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260716T000000Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an all-day series move shifts its exclusions by whole days`() {
|
||||||
|
val series = instantAt("2026-07-01T00:00", "UTC")
|
||||||
|
val original = form(
|
||||||
|
isAllDay = true,
|
||||||
|
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
|
||||||
|
).copy(rrule = "FREQ=WEEKLY")
|
||||||
|
val moved = original.copy(
|
||||||
|
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(update(original, moved, series, "20260722")[CalendarContract.Events.EXDATE])
|
||||||
|
.isEqualTo("20260724")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an all-day exclusion a sync adapter padded still moves with the series`() {
|
||||||
|
// The bug: only the bare yyyyMMdd form parsed, so a padded midnight stamp
|
||||||
|
// bailed the whole list and buildEventUpdateValues wrote no EXDATE at all
|
||||||
|
// — the deleted occurrence came back the moment the series was re-timed.
|
||||||
|
val series = instantAt("2026-07-01T00:00", "UTC")
|
||||||
|
val original = form(
|
||||||
|
isAllDay = true,
|
||||||
|
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
|
||||||
|
).copy(rrule = "FREQ=WEEKLY")
|
||||||
|
val moved = original.copy(
|
||||||
|
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
|
||||||
|
)
|
||||||
|
|
||||||
|
val values = update(original, moved, series, "20260722T000000Z")
|
||||||
|
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260724")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an all-day exclusion padded without a Z is read the same way`() {
|
||||||
|
// DAVx5 and AOSP's own RecurrenceSet write a floating midnight for a
|
||||||
|
// date value. Only the trailing-Z spelling parsed, so the floating one
|
||||||
|
// bailed the whole list and the deleted occurrence came back.
|
||||||
|
val series = instantAt("2026-07-01T00:00", "UTC")
|
||||||
|
val original = form(
|
||||||
|
isAllDay = true,
|
||||||
|
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
|
||||||
|
).copy(rrule = "FREQ=WEEKLY")
|
||||||
|
val moved = original.copy(
|
||||||
|
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
|
||||||
|
)
|
||||||
|
|
||||||
|
val values = update(original, moved, series, "20260722T000000")
|
||||||
|
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260724")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a padded all-day stamp naming any other time is still left alone`() {
|
||||||
|
// Not a form an all-day series is written in, so it could be naming
|
||||||
|
// something else entirely — guessing could exclude the wrong occurrence.
|
||||||
|
val series = instantAt("2026-07-01T00:00", "UTC")
|
||||||
|
val original = form(
|
||||||
|
isAllDay = true,
|
||||||
|
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
|
||||||
|
).copy(rrule = "FREQ=WEEKLY")
|
||||||
|
val moved = original.copy(
|
||||||
|
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
|
||||||
|
)
|
||||||
|
|
||||||
|
val values = update(original, moved, series, "20260722T090000Z")
|
||||||
|
assertThat(values).doesNotContainKey(CalendarContract.Events.EXDATE)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `switching a series to all-day rewrites its exclusions as dates`() {
|
||||||
|
// The two forms aren't interchangeable: a date-time stamp on an all-day
|
||||||
|
// series matches no occurrence, so the exclusion would be lost.
|
||||||
|
val series = instantAt("2026-07-01T09:00", "Europe/Berlin")
|
||||||
|
val original = julySeries()
|
||||||
|
val values = update(original, original.copy(isAllDay = true), series, "20260722T070000Z")
|
||||||
|
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260722")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `switching a series back to timed rewrites its exclusions as instants`() {
|
||||||
|
val series = instantAt("2026-07-01T00:00", "UTC")
|
||||||
|
val original = form(
|
||||||
|
isAllDay = true,
|
||||||
|
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
|
||||||
|
).copy(rrule = "FREQ=WEEKLY")
|
||||||
|
val timed = original.copy(
|
||||||
|
isAllDay = false,
|
||||||
|
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
|
||||||
|
)
|
||||||
|
// The excluded day gains the new 09:00 Berlin time-of-day == 07:00Z.
|
||||||
|
assertThat(update(original, timed, series, "20260722")[CalendarContract.Events.EXDATE])
|
||||||
|
.isEqualTo("20260722T070000Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a text-only edit leaves the exclusions alone`() {
|
||||||
|
val original = julySeries()
|
||||||
|
val values = update(original, original.copy(title = "Renamed"), exdate = "20260722T070000Z")
|
||||||
|
assertThat(values).doesNotContainKey(CalendarContract.Events.EXDATE)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `changing only the rule rewrites no exclusions`() {
|
||||||
|
// The times are untouched, so every surviving occurrence keeps its instant
|
||||||
|
// and the stamps still name the right ones.
|
||||||
|
val original = julySeries()
|
||||||
|
val values = update(original, original.copy(rrule = "FREQ=DAILY"), exdate = "20260722T070000Z")
|
||||||
|
assertThat(values[CalendarContract.Events.RRULE]).isEqualTo("FREQ=DAILY")
|
||||||
|
assertThat(values).doesNotContainKey(CalendarContract.Events.EXDATE)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a zoned exdate list is read in the zone its prefix names`() {
|
||||||
|
// AOSP's RecurrenceSet form, TZID=<zone>;<floating stamps>. Read as UTC the
|
||||||
|
// stamps name the wrong instants; skipped, every exclusion of the series is
|
||||||
|
// dropped the moment it is re-timed.
|
||||||
|
val original = julySeries()
|
||||||
|
val values = update(
|
||||||
|
original,
|
||||||
|
original.atHour(10),
|
||||||
|
instantAt("2026-07-01T09:00", "Europe/Berlin"),
|
||||||
|
"TZID=Europe/Berlin;20260722T090000",
|
||||||
|
)
|
||||||
|
// 22 July 09:00 Berlin (CEST, +2) == 07:00Z; the +1h edit puts it at 08:00Z.
|
||||||
|
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260722T080000Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a zoned list whose zone this device cannot place falls back to the event's`() {
|
||||||
|
// Exchange spells zones its own way, so ZoneId.of refuses the name. The
|
||||||
|
// series' own zone is what such a list is written in anyway.
|
||||||
|
val original = julySeries()
|
||||||
|
val values = update(
|
||||||
|
original,
|
||||||
|
original.atHour(10),
|
||||||
|
instantAt("2026-07-01T09:00", "Europe/Berlin"),
|
||||||
|
"TZID=W. Europe Standard Time;20260722T090000",
|
||||||
|
)
|
||||||
|
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260722T080000Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an exdate form we do not write is left untouched`() {
|
||||||
|
// A stale stamp excludes nothing; a mangled one could exclude the wrong
|
||||||
|
// occurrence, so anything unreadable is left exactly as it stands.
|
||||||
|
val original = julySeries()
|
||||||
|
val values = update(
|
||||||
|
original,
|
||||||
|
original.atHour(10),
|
||||||
|
instantAt("2026-07-01T09:00", "Europe/Berlin"),
|
||||||
|
"sometime next Tuesday",
|
||||||
|
)
|
||||||
|
assertThat(values).doesNotContainKey(CalendarContract.Events.EXDATE)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `removing the recurrence clears the exclusions with it`() {
|
||||||
|
// Dormant, not harmless: adding a recurrence back later would punch the old
|
||||||
|
// holes into the new one.
|
||||||
|
val original = julySeries()
|
||||||
|
val values = update(original, original.copy(rrule = null), exdate = "20260722T070000Z")
|
||||||
|
assertThat(values).containsEntry(CalendarContract.Events.EXDATE, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `undoing a series move lands the exclusions back where they started`() {
|
||||||
|
val series = instantAt("2026-01-07T09:00", "Europe/Berlin")
|
||||||
|
val original = julySeries()
|
||||||
|
val moved = original.copy(
|
||||||
|
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(14, 30)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(15, 30)),
|
||||||
|
)
|
||||||
|
val exdate = "20260722T070000Z"
|
||||||
|
|
||||||
|
val forward = update(original, moved, series, exdate)
|
||||||
|
val movedExdate = forward[CalendarContract.Events.EXDATE] as String
|
||||||
|
assertThat(movedExdate).isNotEqualTo(exdate)
|
||||||
|
|
||||||
|
val back = update(
|
||||||
|
moved,
|
||||||
|
original,
|
||||||
|
forward[CalendarContract.Events.DTSTART] as Long,
|
||||||
|
movedExdate,
|
||||||
|
)
|
||||||
|
assertThat(back[CalendarContract.Events.EXDATE]).isEqualTo(exdate)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a series with no exclusions writes no exdate column`() {
|
||||||
|
val series = instantAt("2026-07-01T09:00", "Europe/Berlin")
|
||||||
|
val original = julySeries()
|
||||||
|
assertThat(update(original, original.atHour(10), series, exdate = null))
|
||||||
|
.doesNotContainKey(CalendarContract.Events.EXDATE)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- exdateAfter (the exclusions a "this and following" split inherits) ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a split carries only the exclusions past the split point`() {
|
||||||
|
// The occurrence at the split point is the one being edited, so it exists
|
||||||
|
// by definition — a stale exclusion for it would swallow the edit whole.
|
||||||
|
assertThat(
|
||||||
|
exdateAfter(
|
||||||
|
existingExdate = "20260708T080000Z,20260715T080000Z,20260722T080000Z",
|
||||||
|
beginMillis = instantAt("2026-07-15T08:00", "UTC"),
|
||||||
|
isAllDay = false,
|
||||||
|
timezone = null,
|
||||||
|
),
|
||||||
|
).isEqualTo("20260722T080000Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a split carries nothing when every exclusion is behind it`() {
|
||||||
|
assertThat(
|
||||||
|
exdateAfter(
|
||||||
|
"20260708T080000Z",
|
||||||
|
instantAt("2026-07-15T08:00", "UTC"),
|
||||||
|
isAllDay = false,
|
||||||
|
timezone = null,
|
||||||
|
),
|
||||||
|
).isNull()
|
||||||
|
assertThat(exdateAfter(null, 0L, isAllDay = false, timezone = null)).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `all-day exclusions split on their UTC date`() {
|
||||||
|
assertThat(
|
||||||
|
exdateAfter(
|
||||||
|
"20260708,20260722",
|
||||||
|
instantAt("2026-07-15T00:00", "UTC"),
|
||||||
|
isAllDay = true,
|
||||||
|
timezone = null,
|
||||||
|
),
|
||||||
|
).isEqualTo("20260722")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an unreadable exclusion carries nothing across a split`() {
|
||||||
|
assertThat(
|
||||||
|
exdateAfter(
|
||||||
|
"20260722T080000Z,sometime next Tuesday",
|
||||||
|
instantAt("2026-07-15T08:00", "UTC"),
|
||||||
|
isAllDay = false,
|
||||||
|
timezone = null,
|
||||||
|
),
|
||||||
|
).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a zoned exclusion list splits on the instants its prefix names`() {
|
||||||
|
// Canonicalised on the way out: the prefix that explained how to read the
|
||||||
|
// stamps doesn't survive the filter, so the survivors can't stay floating.
|
||||||
|
assertThat(
|
||||||
|
exdateAfter(
|
||||||
|
"TZID=Europe/Berlin;20260708T100000,20260729T100000",
|
||||||
|
instantAt("2026-07-15T08:00", "UTC"),
|
||||||
|
isAllDay = false,
|
||||||
|
timezone = null,
|
||||||
|
),
|
||||||
|
).isEqualTo("20260729T080000Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `split exclusions move by the same shift as the new series start`() {
|
||||||
|
// What the split path composes: filter to the tail, then re-time it by the
|
||||||
|
// shift the user applied to the occurrence they split at.
|
||||||
|
val original = julySeries()
|
||||||
|
val carried = shiftedExdate(
|
||||||
|
existingExdate = exdateAfter(
|
||||||
|
"20260716T070000Z",
|
||||||
|
instantAt("2026-07-15T07:00", "UTC"),
|
||||||
|
isAllDay = false,
|
||||||
|
timezone = null,
|
||||||
|
),
|
||||||
|
original = original,
|
||||||
|
updated = original.atHour(11),
|
||||||
|
zone = berlin,
|
||||||
|
)
|
||||||
|
// 16 July 09:00 Berlin, pushed two hours, is 11:00 Berlin == 09:00Z.
|
||||||
|
assertThat(carried).isEqualTo("20260716T090000Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `nothing to shift yields no exdate`() {
|
||||||
|
assertThat(shiftedExdate(null, julySeries(), julySeries().atHour(10), berlin)).isNull()
|
||||||
|
assertThat(shiftedExdate(" ", julySeries(), julySeries().atHour(10), berlin)).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
// --- per-event colour ---
|
// --- per-event colour ---
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -746,4 +1205,173 @@ class EventWriteMapperTest {
|
|||||||
val values = buildCopiedExceptionValues(exceptionSnapshot(duration = "P900S", dtEndMillis = null))
|
val values = buildCopiedExceptionValues(exceptionSnapshot(duration = "P900S", dtEndMillis = null))
|
||||||
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P900S")
|
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P900S")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a detached all-day occurrence matches its date-only exdate stamp`() {
|
||||||
|
val parent = buildOccurrenceExdateValues(
|
||||||
|
existingExdate = null,
|
||||||
|
occurrenceMillis = 1_781_136_000_000L, // 2026-06-11T00:00:00Z
|
||||||
|
dtStartMillis = 1_749_600_000_000L,
|
||||||
|
rrule = "FREQ=YEARLY",
|
||||||
|
rdate = null,
|
||||||
|
duration = "P1D",
|
||||||
|
timezone = "UTC",
|
||||||
|
allDay = 1,
|
||||||
|
)
|
||||||
|
val detached = form(
|
||||||
|
isAllDay = true,
|
||||||
|
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
|
||||||
|
).copy(rrule = "FREQ=YEARLY").toDetachedOccurrence()
|
||||||
|
val inserted = buildEventInsertValues(
|
||||||
|
form = detached,
|
||||||
|
uid = "uid@calendula",
|
||||||
|
times = detached.toWriteTimes(berlin),
|
||||||
|
)
|
||||||
|
assertThat(parent[CalendarContract.Events.EXDATE]).isEqualTo("20260611")
|
||||||
|
assertThat(inserted[CalendarContract.Events.DTSTART]).isEqualTo(1_781_136_000_000L)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a detached all-day occurrence stays on UTC midnights`() {
|
||||||
|
val edited = form(
|
||||||
|
isAllDay = true,
|
||||||
|
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
|
||||||
|
).copy(title = "Birthday", rrule = "FREQ=YEARLY")
|
||||||
|
val detached = edited.toDetachedOccurrence()
|
||||||
|
|
||||||
|
val values = buildEventInsertValues(
|
||||||
|
form = detached,
|
||||||
|
uid = "uid@calendula",
|
||||||
|
times = detached.toWriteTimes(berlin),
|
||||||
|
)
|
||||||
|
assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(1)
|
||||||
|
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("UTC")
|
||||||
|
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_136_000_000L)
|
||||||
|
// Exclusive DTEND — the next UTC midnight.
|
||||||
|
assertThat(values[CalendarContract.Events.DTEND]).isEqualTo(1_781_222_400_000L)
|
||||||
|
assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a detached occurrence carries every edited field onto the new row`() {
|
||||||
|
// Built from the form, not cloned from the parent: a field dropped here
|
||||||
|
// is an edit silently lost.
|
||||||
|
val edited = form(timezone = "America/New_York").copy(
|
||||||
|
title = " Standup ",
|
||||||
|
location = "Room 2",
|
||||||
|
description = "notes",
|
||||||
|
reminders = listOf(10),
|
||||||
|
availability = Availability.Free,
|
||||||
|
accessLevel = AccessLevel.Private,
|
||||||
|
rrule = "FREQ=DAILY",
|
||||||
|
)
|
||||||
|
val detached = edited.toDetachedOccurrence()
|
||||||
|
|
||||||
|
val values = buildEventInsertValues(
|
||||||
|
form = detached,
|
||||||
|
uid = "uid@calendula",
|
||||||
|
times = detached.toWriteTimes(berlin),
|
||||||
|
)
|
||||||
|
assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Standup")
|
||||||
|
assertThat(values[CalendarContract.Events.EVENT_LOCATION]).isEqualTo("Room 2")
|
||||||
|
assertThat(values[CalendarContract.Events.DESCRIPTION]).isEqualTo("notes")
|
||||||
|
assertThat(values[CalendarContract.Events.AVAILABILITY])
|
||||||
|
.isEqualTo(CalendarContract.Events.AVAILABILITY_FREE)
|
||||||
|
assertThat(values[CalendarContract.Events.ACCESS_LEVEL])
|
||||||
|
.isEqualTo(CalendarContract.Events.ACCESS_PRIVATE)
|
||||||
|
// The pinned zone survives — never re-anchored to the device.
|
||||||
|
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("America/New_York")
|
||||||
|
assertThat(values[CalendarContract.Events.UID_2445]).isEqualTo("uid@calendula")
|
||||||
|
// Reminders aren't columns; the insert path seeds them from the form.
|
||||||
|
assertThat(detached.reminders).containsExactly(10)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a detached occurrence drops the series rule and becomes a one-off row`() {
|
||||||
|
val edited = form().copy(title = "Moved", rrule = "FREQ=WEEKLY;BYDAY=TH")
|
||||||
|
val detached = edited.toDetachedOccurrence()
|
||||||
|
|
||||||
|
val values = buildEventInsertValues(
|
||||||
|
form = detached,
|
||||||
|
uid = "uid@calendula",
|
||||||
|
times = detached.toWriteTimes(berlin),
|
||||||
|
)
|
||||||
|
assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Moved")
|
||||||
|
// A surviving rule would insert a second *series* overlapping the first
|
||||||
|
// (Codeberg #234's stray duplicate).
|
||||||
|
assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE)
|
||||||
|
assertThat(values).doesNotContainKey(CalendarContract.Events.DURATION)
|
||||||
|
// A one-off row carries DTEND rather than a duration.
|
||||||
|
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L)
|
||||||
|
assertThat(values[CalendarContract.Events.DTEND]).isEqualTo(1_781_170_200_000L)
|
||||||
|
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an exclusion is found anywhere in a multi-entry exdate list`() {
|
||||||
|
// Whitespace after a comma is legal in the stored column.
|
||||||
|
assertThat(
|
||||||
|
exdateContains(
|
||||||
|
"20260604T080000Z, 20260611T080000Z,20260618T080000Z",
|
||||||
|
1_781_164_800_000L,
|
||||||
|
isAllDay = false,
|
||||||
|
timezone = null,
|
||||||
|
),
|
||||||
|
).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an occurrence already excluded is recognised, timed and all-day`() {
|
||||||
|
// Detaching twice would leave a second standalone copy.
|
||||||
|
assertThat(
|
||||||
|
exdateContains("20260611T080000Z", 1_781_164_800_000L, isAllDay = false, timezone = null),
|
||||||
|
).isTrue()
|
||||||
|
assertThat(
|
||||||
|
exdateContains("20260611", 1_781_136_000_000L, isAllDay = true, timezone = null),
|
||||||
|
).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an occurrence not in the exdate list is not mistaken for an excluded one`() {
|
||||||
|
assertThat(exdateContains(null, 1_781_164_800_000L, isAllDay = false, timezone = null))
|
||||||
|
.isFalse()
|
||||||
|
assertThat(exdateContains("", 1_781_164_800_000L, isAllDay = false, timezone = null))
|
||||||
|
.isFalse()
|
||||||
|
// A neighbouring occurrence must not match — the guard is per-instant.
|
||||||
|
assertThat(
|
||||||
|
exdateContains("20260610T080000Z", 1_781_164_800_000L, isAllDay = false, timezone = null),
|
||||||
|
).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the detached row lands exactly where the parent's exdate removes it`() {
|
||||||
|
// The two halves must agree on the instant, or the user sees the
|
||||||
|
// occurrence twice or not at all.
|
||||||
|
val edited = form().copy(title = "Renamed", rrule = "FREQ=WEEKLY")
|
||||||
|
val occurrenceMillis = 1_781_164_800_000L
|
||||||
|
|
||||||
|
val parent = buildOccurrenceExdateValues(
|
||||||
|
existingExdate = null,
|
||||||
|
occurrenceMillis = occurrenceMillis,
|
||||||
|
dtStartMillis = 1_780_560_000_000L,
|
||||||
|
rrule = "FREQ=WEEKLY",
|
||||||
|
rdate = null,
|
||||||
|
duration = "P5400S",
|
||||||
|
timezone = "Europe/Berlin",
|
||||||
|
allDay = 0,
|
||||||
|
)
|
||||||
|
val detached = edited.toDetachedOccurrence()
|
||||||
|
val inserted = buildEventInsertValues(
|
||||||
|
form = detached,
|
||||||
|
uid = "uid@calendula",
|
||||||
|
times = detached.toWriteTimes(berlin),
|
||||||
|
)
|
||||||
|
assertThat(parent[CalendarContract.Events.EXDATE]).isEqualTo("20260611T080000Z")
|
||||||
|
assertThat(inserted[CalendarContract.Events.DTSTART]).isEqualTo(occurrenceMillis)
|
||||||
|
// The parent keeps its own anchor and rule — only this occurrence leaves.
|
||||||
|
assertThat(parent[CalendarContract.Events.DTSTART]).isEqualTo(1_780_560_000_000L)
|
||||||
|
assertThat(parent[CalendarContract.Events.RRULE]).isEqualTo("FREQ=WEEKLY")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
|||||||
var searchResult: (String) -> List<SearchCandidate> = { _ -> emptyList() }
|
var searchResult: (String) -> List<SearchCandidate> = { _ -> emptyList() }
|
||||||
var eventDetailResult: (Long) -> EventDetail? = { null }
|
var eventDetailResult: (Long) -> EventDetail? = { null }
|
||||||
var eventColorPaletteResult: (Long) -> List<EventColorOption> = { emptyList() }
|
var eventColorPaletteResult: (Long) -> List<EventColorOption> = { emptyList() }
|
||||||
|
var publishedEventColorsResult: (Long) -> List<EventColorOption> = { emptyList() }
|
||||||
var exportableEventsResult: List<IcsEvent> = emptyList()
|
var exportableEventsResult: List<IcsEvent> = emptyList()
|
||||||
/** The [calendarIds] the last [exportableEvents] call received (null = all). */
|
/** The [calendarIds] the last [exportableEvents] call received (null = all). */
|
||||||
var lastExportableEventsCalendarIds: Set<Long>? = null
|
var lastExportableEventsCalendarIds: Set<Long>? = null
|
||||||
@@ -81,7 +82,12 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
|||||||
eventDetailResult(eventId)
|
eventDetailResult(eventId)
|
||||||
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
||||||
eventColorPaletteResult(calendarId)
|
eventColorPaletteResult(calendarId)
|
||||||
override fun exportableEvents(calendarIds: Set<Long>?): List<IcsEvent> {
|
override fun publishedEventColors(calendarId: Long): List<EventColorOption> =
|
||||||
|
publishedEventColorsResult(calendarId)
|
||||||
|
override fun exportableEvents(
|
||||||
|
calendarIds: Set<Long>?,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
): List<IcsEvent> {
|
||||||
lastExportableEventsCalendarIds = calendarIds
|
lastExportableEventsCalendarIds = calendarIds
|
||||||
return exportableEventsResult
|
return exportableEventsResult
|
||||||
}
|
}
|
||||||
@@ -91,8 +97,25 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
|||||||
/** (event, targetCalendarId) pairs passed to [insertImportedEvent]. */
|
/** (event, targetCalendarId) pairs passed to [insertImportedEvent]. */
|
||||||
val importedEvents = mutableListOf<Pair<ParsedIcsEvent, Long>>()
|
val importedEvents = mutableListOf<Pair<ParsedIcsEvent, Long>>()
|
||||||
|
|
||||||
override fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long {
|
/** Thrown instead of [writeError] for events whose summary is in this set. */
|
||||||
writeError?.let { throw it }
|
val failingImportSummaries = mutableSetOf<String>()
|
||||||
|
|
||||||
|
/** Imports let through before [writeError] applies, for part-way failures. */
|
||||||
|
var importsBeforeError: Int = 0
|
||||||
|
private var imports = 0
|
||||||
|
|
||||||
|
/** The [colorPalette] the last [insertImportedEvent] call received. */
|
||||||
|
var lastImportPalette: List<EventColorOption> = emptyList()
|
||||||
|
|
||||||
|
override fun insertImportedEvent(
|
||||||
|
event: ParsedIcsEvent,
|
||||||
|
calendarId: Long,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
colorPalette: List<EventColorOption>,
|
||||||
|
): Long {
|
||||||
|
lastImportPalette = colorPalette
|
||||||
|
if (imports++ >= importsBeforeError) writeError?.let { throw it }
|
||||||
|
if (event.summary in failingImportSummaries) error("rejected: ${event.summary}")
|
||||||
importedEvents += event to calendarId
|
importedEvents += event to calendarId
|
||||||
return nextInsertId
|
return nextInsertId
|
||||||
}
|
}
|
||||||
@@ -173,6 +196,7 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
|||||||
override fun updateOccurrence(
|
override fun updateOccurrence(
|
||||||
eventId: Long,
|
eventId: Long,
|
||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
|
original: EventForm,
|
||||||
form: EventForm,
|
form: EventForm,
|
||||||
allDayReminderTimeMinutes: Int,
|
allDayReminderTimeMinutes: Int,
|
||||||
): Long {
|
): Long {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.provider.CalendarContract
|
|||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.time.ZoneId
|
||||||
|
|
||||||
class IcsExportMapperTest {
|
class IcsExportMapperTest {
|
||||||
|
|
||||||
@@ -20,7 +21,11 @@ class IcsExportMapperTest {
|
|||||||
EventExportProjection.IDX_AVAILABILITY to CalendarContract.Events.AVAILABILITY_BUSY,
|
EventExportProjection.IDX_AVAILABILITY to CalendarContract.Events.AVAILABILITY_BUSY,
|
||||||
)
|
)
|
||||||
|
|
||||||
val event = reader.toIcsEvent(reminderMinutes = listOf(10), calendarName = "Personal")
|
val event = reader.toIcsEvent(
|
||||||
|
reminderMinutes = listOf(10),
|
||||||
|
calendarName = "Personal",
|
||||||
|
allDayReminderTimeMinutes = NINE_AM,
|
||||||
|
)
|
||||||
|
|
||||||
assertThat(event.uid).isEqualTo("abc@host")
|
assertThat(event.uid).isEqualTo("abc@host")
|
||||||
assertThat(event.summary).isEqualTo("Standup")
|
assertThat(event.summary).isEqualTo("Standup")
|
||||||
@@ -47,7 +52,11 @@ class IcsExportMapperTest {
|
|||||||
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
|
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
|
||||||
)
|
)
|
||||||
|
|
||||||
val event = reader.toIcsEvent(reminderMinutes = emptyList(), calendarName = null)
|
val event = reader.toIcsEvent(
|
||||||
|
reminderMinutes = emptyList(),
|
||||||
|
calendarName = null,
|
||||||
|
allDayReminderTimeMinutes = NINE_AM,
|
||||||
|
)
|
||||||
|
|
||||||
assertThat(event.uid).isEqualTo("7-1000000@calendula")
|
assertThat(event.uid).isEqualTo("7-1000000@calendula")
|
||||||
assertThat(event.recurrenceRule).isEqualTo("FREQ=WEEKLY")
|
assertThat(event.recurrenceRule).isEqualTo("FREQ=WEEKLY")
|
||||||
@@ -65,6 +74,224 @@ class IcsExportMapperTest {
|
|||||||
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
|
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
|
||||||
)
|
)
|
||||||
|
|
||||||
assertThat(reader.toIcsEvent(emptyList(), null).isAllDay).isTrue()
|
assertThat(reader.toIcsEvent(emptyList(), null, NINE_AM).isAllDay).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an all-day reminder is exported as its whole-day lead time`() {
|
||||||
|
// The raw offset encodes the firing time and is normally negative; left
|
||||||
|
// raw it would be written as a trigger after the event and dropped, so
|
||||||
|
// the backup would come back with no reminder at all.
|
||||||
|
val reader = MapColumnReader(
|
||||||
|
EventExportProjection.IDX_ID to 1L,
|
||||||
|
EventExportProjection.IDX_TITLE to "Anna Birthday",
|
||||||
|
EventExportProjection.IDX_DTSTART to 1_782_864_000_000L, // 2026-07-01 UTC, CEST
|
||||||
|
EventExportProjection.IDX_DTEND to 1_782_950_400_000L,
|
||||||
|
EventExportProjection.IDX_ALL_DAY to 1,
|
||||||
|
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
|
||||||
|
)
|
||||||
|
|
||||||
|
val onTheDay = reader.toIcsEvent(listOf(-420), null, NINE_AM, BERLIN)
|
||||||
|
val dayBefore = reader.toIcsEvent(listOf(1_020), null, NINE_AM, BERLIN)
|
||||||
|
|
||||||
|
assertThat(onTheDay.reminderMinutes).containsExactly(0)
|
||||||
|
assertThat(dayBefore.reminderMinutes).containsExactly(1_440)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val NINE_AM = 9 * 60
|
||||||
|
val BERLIN: ZoneId = ZoneId.of("Europe/Berlin")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a recurring row carries its EXDATE exclusions`() {
|
||||||
|
val reader = MapColumnReader(
|
||||||
|
EventExportProjection.IDX_ID to 7L,
|
||||||
|
EventExportProjection.IDX_TITLE to "Weekly",
|
||||||
|
EventExportProjection.IDX_DTSTART to 1_000_000L,
|
||||||
|
EventExportProjection.IDX_DURATION to "P3600S",
|
||||||
|
EventExportProjection.IDX_ALL_DAY to 0,
|
||||||
|
EventExportProjection.IDX_RRULE to "FREQ=WEEKLY",
|
||||||
|
EventExportProjection.IDX_EXDATE to "20260901T080000Z,20260908T080000Z",
|
||||||
|
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
|
||||||
|
)
|
||||||
|
|
||||||
|
val event = reader.toIcsEvent(
|
||||||
|
reminderMinutes = emptyList(),
|
||||||
|
calendarName = null,
|
||||||
|
allDayReminderTimeMinutes = NINE_AM,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(event.exDates)
|
||||||
|
.containsExactly("20260901T080000Z", "20260908T080000Z").inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an all-day EXDATE keeps only the day, however the adapter padded it`() {
|
||||||
|
val reader = MapColumnReader(
|
||||||
|
EventExportProjection.IDX_ID to 8L,
|
||||||
|
EventExportProjection.IDX_TITLE to "Holiday",
|
||||||
|
EventExportProjection.IDX_DTSTART to 0L,
|
||||||
|
EventExportProjection.IDX_DURATION to "P1D",
|
||||||
|
EventExportProjection.IDX_ALL_DAY to 1,
|
||||||
|
EventExportProjection.IDX_RRULE to "FREQ=YEARLY",
|
||||||
|
EventExportProjection.IDX_EXDATE to "20260901,20270901T000000Z",
|
||||||
|
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
|
||||||
|
)
|
||||||
|
|
||||||
|
val event = reader.toIcsEvent(
|
||||||
|
reminderMinutes = emptyList(),
|
||||||
|
calendarName = null,
|
||||||
|
allDayReminderTimeMinutes = NINE_AM,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(event.exDates).containsExactly("20260901", "20270901").inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a TZID-prefixed EXDATE is resolved to UTC, not handed on verbatim`() {
|
||||||
|
// The provider stores a zoned exclusion as AOSP RecurrenceSet does. Passed
|
||||||
|
// through, the writer would emit `EXDATE:TZID=Europe/Berlin;2026...` — the
|
||||||
|
// parameter belongs before the colon, so the line is unreadable.
|
||||||
|
val reader = MapColumnReader(
|
||||||
|
EventExportProjection.IDX_ID to 11L,
|
||||||
|
EventExportProjection.IDX_TITLE to "Weekly",
|
||||||
|
EventExportProjection.IDX_DTSTART to 1_000_000L,
|
||||||
|
EventExportProjection.IDX_DURATION to "P3600S",
|
||||||
|
EventExportProjection.IDX_ALL_DAY to 0,
|
||||||
|
EventExportProjection.IDX_RRULE to "FREQ=WEEKLY",
|
||||||
|
EventExportProjection.IDX_EXDATE to
|
||||||
|
"TZID=Europe/Berlin;20260722T090000,20260729T090000",
|
||||||
|
EventExportProjection.IDX_EVENT_TIMEZONE to "Europe/Berlin",
|
||||||
|
)
|
||||||
|
|
||||||
|
val event = reader.toIcsEvent(
|
||||||
|
reminderMinutes = emptyList(),
|
||||||
|
calendarName = null,
|
||||||
|
allDayReminderTimeMinutes = NINE_AM,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 09:00 Berlin in July is CEST (+2) == 07:00Z.
|
||||||
|
assertThat(event.exDates)
|
||||||
|
.containsExactly("20260722T070000Z", "20260729T070000Z").inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a zoned EXDATE in the provider's own bare form resolves in that zone`() {
|
||||||
|
// AOSP's RecurrenceSet stores the TZID parameter's value alone. Read as
|
||||||
|
// if it were unzoned, 09:00 Berlin exported as 09:00Z — two hours out,
|
||||||
|
// so the exclusion no longer matched its occurrence in the file.
|
||||||
|
val reader = MapColumnReader(
|
||||||
|
EventExportProjection.IDX_ID to 14L,
|
||||||
|
EventExportProjection.IDX_TITLE to "Weekly",
|
||||||
|
EventExportProjection.IDX_DTSTART to 1_000_000L,
|
||||||
|
EventExportProjection.IDX_DURATION to "P3600S",
|
||||||
|
EventExportProjection.IDX_ALL_DAY to 0,
|
||||||
|
EventExportProjection.IDX_RRULE to "FREQ=WEEKLY",
|
||||||
|
EventExportProjection.IDX_EXDATE to "Europe/Berlin;20260722T090000",
|
||||||
|
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
|
||||||
|
)
|
||||||
|
|
||||||
|
val event = reader.toIcsEvent(
|
||||||
|
reminderMinutes = emptyList(),
|
||||||
|
calendarName = null,
|
||||||
|
allDayReminderTimeMinutes = NINE_AM,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(event.exDates).containsExactly("20260722T070000Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a TZID this device cannot place falls back to the row's own zone`() {
|
||||||
|
// Exchange spells zones its own way, so ZoneId.of refuses the name.
|
||||||
|
// Stripping the prefix and emitting the stamp as it stands would export a
|
||||||
|
// floating time against a `DTSTART;TZID=…` line — every reader in another
|
||||||
|
// zone resolves it to another instant and the exclusion stops matching.
|
||||||
|
val reader = MapColumnReader(
|
||||||
|
EventExportProjection.IDX_ID to 13L,
|
||||||
|
EventExportProjection.IDX_TITLE to "Weekly",
|
||||||
|
EventExportProjection.IDX_DTSTART to 1_000_000L,
|
||||||
|
EventExportProjection.IDX_DURATION to "P3600S",
|
||||||
|
EventExportProjection.IDX_ALL_DAY to 0,
|
||||||
|
EventExportProjection.IDX_RRULE to "FREQ=WEEKLY",
|
||||||
|
EventExportProjection.IDX_EXDATE to
|
||||||
|
"TZID=W. Europe Standard Time;20260722T090000",
|
||||||
|
EventExportProjection.IDX_EVENT_TIMEZONE to "Europe/Berlin",
|
||||||
|
)
|
||||||
|
|
||||||
|
val event = reader.toIcsEvent(
|
||||||
|
reminderMinutes = emptyList(),
|
||||||
|
calendarName = null,
|
||||||
|
allDayReminderTimeMinutes = NINE_AM,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(event.exDates).containsExactly("20260722T070000Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an all-day EXDATE loses a TZID prefix along with its time`() {
|
||||||
|
val reader = MapColumnReader(
|
||||||
|
EventExportProjection.IDX_ID to 12L,
|
||||||
|
EventExportProjection.IDX_TITLE to "Holiday",
|
||||||
|
EventExportProjection.IDX_DTSTART to 0L,
|
||||||
|
EventExportProjection.IDX_DURATION to "P1D",
|
||||||
|
EventExportProjection.IDX_ALL_DAY to 1,
|
||||||
|
EventExportProjection.IDX_RRULE to "FREQ=YEARLY",
|
||||||
|
EventExportProjection.IDX_EXDATE to "TZID=Europe/Berlin;20260901T000000",
|
||||||
|
EventExportProjection.IDX_EVENT_TIMEZONE to "Europe/Berlin",
|
||||||
|
)
|
||||||
|
|
||||||
|
val event = reader.toIcsEvent(
|
||||||
|
reminderMinutes = emptyList(),
|
||||||
|
calendarName = null,
|
||||||
|
allDayReminderTimeMinutes = NINE_AM,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(event.exDates).containsExactly("20260901")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a default-lead all-day reminder is not decoded into an at-start alarm`() {
|
||||||
|
// MINUTES_DEFAULT (-1) says "use the account default", not "0 minutes
|
||||||
|
// before". Decoded, it landed on the start date and became a real alarm
|
||||||
|
// that IcsWriter no longer dropped.
|
||||||
|
val reader = MapColumnReader(
|
||||||
|
EventExportProjection.IDX_ID to 13L,
|
||||||
|
EventExportProjection.IDX_TITLE to "Birthday",
|
||||||
|
EventExportProjection.IDX_DTSTART to 0L,
|
||||||
|
EventExportProjection.IDX_DURATION to "P1D",
|
||||||
|
EventExportProjection.IDX_ALL_DAY to 1,
|
||||||
|
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
|
||||||
|
)
|
||||||
|
|
||||||
|
val event = reader.toIcsEvent(
|
||||||
|
reminderMinutes = listOf(-1),
|
||||||
|
calendarName = null,
|
||||||
|
allDayReminderTimeMinutes = NINE_AM,
|
||||||
|
zone = BERLIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(event.reminderMinutes).containsExactly(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a one-off row exports no exclusions even if the column is set`() {
|
||||||
|
val reader = MapColumnReader(
|
||||||
|
EventExportProjection.IDX_ID to 9L,
|
||||||
|
EventExportProjection.IDX_TITLE to "Standup",
|
||||||
|
EventExportProjection.IDX_DTSTART to 1_000_000L,
|
||||||
|
EventExportProjection.IDX_DTEND to 1_900_000L,
|
||||||
|
EventExportProjection.IDX_ALL_DAY to 0,
|
||||||
|
EventExportProjection.IDX_EXDATE to "20260901T080000Z",
|
||||||
|
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
|
||||||
|
)
|
||||||
|
|
||||||
|
val event = reader.toIcsEvent(
|
||||||
|
reminderMinutes = emptyList(),
|
||||||
|
calendarName = null,
|
||||||
|
allDayReminderTimeMinutes = NINE_AM,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(event.exDates).isEmpty()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import android.provider.CalendarContract
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
class ImportedEventValuesTest {
|
||||||
|
|
||||||
|
private val dayStart = Instant.parse("2026-08-19T00:00:00Z")
|
||||||
|
|
||||||
|
private fun allDay(
|
||||||
|
rrule: String? = null,
|
||||||
|
exDates: List<String> = emptyList(),
|
||||||
|
color: Int? = null,
|
||||||
|
days: Int = 1,
|
||||||
|
) = ParsedIcsEvent(
|
||||||
|
uid = "u@x",
|
||||||
|
summary = "Anna Birthday",
|
||||||
|
start = dayStart,
|
||||||
|
end = Instant.fromEpochMilliseconds(dayStart.toEpochMilliseconds() + days * 86_400_000L),
|
||||||
|
isAllDay = true,
|
||||||
|
zoneId = "UTC",
|
||||||
|
recurrenceRule = rrule,
|
||||||
|
exDates = exDates,
|
||||||
|
color = color,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun values(event: ParsedIcsEvent, palette: List<EventColorOption> = emptyList()) =
|
||||||
|
buildImportedEventValues(event, calendarId = 7L, uid = "u@x", palette = palette)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a one-off carries DTEND and no recurrence`() {
|
||||||
|
val v = values(allDay())
|
||||||
|
assertThat(v[CalendarContract.Events.DTEND])
|
||||||
|
.isEqualTo(dayStart.toEpochMilliseconds() + 86_400_000L)
|
||||||
|
assertThat(v).doesNotContainKey(CalendarContract.Events.DURATION)
|
||||||
|
assertThat(v[CalendarContract.Events.ALL_DAY]).isEqualTo(1)
|
||||||
|
assertThat(v[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("UTC")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a recurring row carries DURATION instead of DTEND`() {
|
||||||
|
val v = values(allDay(rrule = "FREQ=YEARLY;INTERVAL=1"))
|
||||||
|
assertThat(v).doesNotContainKey(CalendarContract.Events.DTEND)
|
||||||
|
assertThat(v[CalendarContract.Events.RRULE]).isEqualTo("FREQ=YEARLY;INTERVAL=1")
|
||||||
|
assertThat(v[CalendarContract.Events.DURATION]).isEqualTo("P1D")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an all-day series is never zero days long`() {
|
||||||
|
// A degenerate span would expand into no instances at all — the series
|
||||||
|
// would simply not exist (Codeberg #225).
|
||||||
|
val v = values(allDay(rrule = "FREQ=YEARLY;INTERVAL=1", days = 0))
|
||||||
|
assertThat(v[CalendarContract.Events.DURATION]).isEqualTo("P1D")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `EXDATEs ride along with the recurrence`() {
|
||||||
|
val v = values(allDay(rrule = "FREQ=DAILY", exDates = listOf("20260820", "20260822")))
|
||||||
|
assertThat(v[CalendarContract.Events.EXDATE]).isEqualTo("20260820,20260822")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `EXDATEs are not written without a recurrence to exclude from`() {
|
||||||
|
val v = values(allDay(exDates = listOf("20260820")))
|
||||||
|
assertThat(v).doesNotContainKey(CalendarContract.Events.EXDATE)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an imported colour is written raw when the account publishes no palette`() {
|
||||||
|
val v = values(allDay(color = -2818048))
|
||||||
|
assertThat(v[CalendarContract.Events.EVENT_COLOR]).isEqualTo(-2818048)
|
||||||
|
assertThat(v[CalendarContract.Events.EVENT_COLOR_KEY]).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an imported colour snaps to the nearest published palette key`() {
|
||||||
|
val palette = listOf(
|
||||||
|
EventColorOption(key = "1", argb = 0xFF0000FF.toInt()), // blue
|
||||||
|
EventColorOption(key = "2", argb = 0xFFFF4500.toInt()), // orangered
|
||||||
|
EventColorOption(key = "3", argb = 0xFF008000.toInt()), // green
|
||||||
|
)
|
||||||
|
// Tomato — visually an orangered, nothing like the blue or the green.
|
||||||
|
val v = values(allDay(color = 0xFFFF6347.toInt()), palette)
|
||||||
|
|
||||||
|
assertThat(v[CalendarContract.Events.EVENT_COLOR_KEY]).isEqualTo("2")
|
||||||
|
// A raw colour alongside a key is what a palette calendar rejects.
|
||||||
|
assertThat(v).doesNotContainKey(CalendarContract.Events.EVENT_COLOR)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an event with no colour touches neither colour column`() {
|
||||||
|
val v = values(allDay())
|
||||||
|
assertThat(v).doesNotContainKey(CalendarContract.Events.EVENT_COLOR)
|
||||||
|
assertThat(v).doesNotContainKey(CalendarContract.Events.EVENT_COLOR_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a timed event keeps its own zone and a seconds duration`() {
|
||||||
|
val start = Instant.parse("2026-08-19T08:00:00Z")
|
||||||
|
val event = ParsedIcsEvent(
|
||||||
|
uid = "u@x",
|
||||||
|
summary = "Standup",
|
||||||
|
start = start,
|
||||||
|
end = Instant.parse("2026-08-19T08:15:00Z"),
|
||||||
|
isAllDay = false,
|
||||||
|
zoneId = "Europe/Berlin",
|
||||||
|
recurrenceRule = "FREQ=WEEKLY",
|
||||||
|
)
|
||||||
|
|
||||||
|
val v = values(event)
|
||||||
|
assertThat(v[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin")
|
||||||
|
assertThat(v[CalendarContract.Events.DURATION]).isEqualTo("P900S")
|
||||||
|
assertThat(v[CalendarContract.Events.ALL_DAY]).isEqualTo(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The provider's "use the account default" sentinel is translated out here, so
|
||||||
|
* the pure planner only ever sees offsets it can resolve (#75).
|
||||||
|
*/
|
||||||
|
class ProviderReminderMinutesTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the account-default sentinel is dropped`() {
|
||||||
|
// Taken as an offset it armed an alarm a minute *after* the event began,
|
||||||
|
// while the detail screen said "Default reminder" for the same row.
|
||||||
|
assertThat(mapOf(1L to listOf(-1)).withoutProviderDefaults())
|
||||||
|
.containsExactly(1L, emptyList<Int>())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a real reminder beside a default one survives`() {
|
||||||
|
assertThat(mapOf(1L to listOf(-1, 10)).withoutProviderDefaults())
|
||||||
|
.containsExactly(1L, listOf(10))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an all-day encoding that is negative is not mistaken for the sentinel`() {
|
||||||
|
// An all-day offset carries the firing time of day, so it is routinely
|
||||||
|
// negative; a `< 0` test would silence reminders that do fire.
|
||||||
|
assertThat(mapOf(1L to listOf(-420, -2_000)).withoutProviderDefaults())
|
||||||
|
.containsExactly(1L, listOf(-420, -2_000))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.ics
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class IcsColorTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a raw Android colour int round-trips`() {
|
||||||
|
assertThat(parseIcsColorValue("-2818048")).isEqualTo(-2818048)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a CSS3 name resolves opaque`() {
|
||||||
|
assertThat(parseIcsColorValue("tomato")).isEqualTo(0xFFFF6347.toInt())
|
||||||
|
assertThat(parseIcsColorValue("REBECCAPURPLE")).isEqualTo(0xFF663399.toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `hex forms are accepted with and without alpha`() {
|
||||||
|
assertThat(parseIcsColorValue("#ff6347")).isEqualTo(0xFFFF6347.toInt())
|
||||||
|
assertThat(parseIcsColorValue("#80ff6347")).isEqualTo(0x80FF6347.toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a colour with no alpha byte is made opaque`() {
|
||||||
|
// 0x00FF6347 as a decimal int — an RGB triple that lost its alpha.
|
||||||
|
assertThat(parseIcsColorValue("16737095")).isEqualTo(0xFFFF6347.toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unusable values resolve to null`() {
|
||||||
|
assertThat(parseIcsColorValue(null)).isNull()
|
||||||
|
assertThat(parseIcsColorValue("")).isNull()
|
||||||
|
// Fossify interpolates a nullable calendar straight into the property.
|
||||||
|
assertThat(parseIcsColorValue("null")).isNull()
|
||||||
|
assertThat(parseIcsColorValue("chartreusey")).isNull()
|
||||||
|
assertThat(parseIcsColorValue("#abc")).isNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,607 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.ics
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reading the Simple Calendar / Fossify export dialect (Codeberg #225).
|
||||||
|
*
|
||||||
|
* Every fixture is shaped as `IcsExporter.writeEvent` emits it — property order,
|
||||||
|
* the `X-FOSSIFY-*` extensions and the `P0DT1H5M0S` duration spelling included.
|
||||||
|
*
|
||||||
|
* Their all-day `DTEND` is *usually* RFC-correct and must not be "corrected":
|
||||||
|
* `Event.endTS` anchors a UI-created all-day event at noon of its last day
|
||||||
|
* (`EventActivity.getStartEndTimes`), or at the exclusive midnight for a
|
||||||
|
* CalDAV-sourced one, and the exporter's `+ TWELVE_HOURS` rounds either to the
|
||||||
|
* following midnight. Shifting those by a day would corrupt them.
|
||||||
|
*
|
||||||
|
* The exception — and the whole of #225 — is events mirrored from Contacts.
|
||||||
|
* `MainActivity` builds those with `startTS = endTS = timestamp`, so `+ 12h`
|
||||||
|
* lands back on the starting day and they export zero length.
|
||||||
|
*/
|
||||||
|
class IcsFossifyImportTest {
|
||||||
|
|
||||||
|
private val parser = IcsParser(TimeZone.of("Europe/Berlin"))
|
||||||
|
|
||||||
|
private fun fossify(vararg body: String) = (
|
||||||
|
listOf(
|
||||||
|
"BEGIN:VCALENDAR",
|
||||||
|
"PRODID:-//Fossify//NONSGML Event Calendar//EN",
|
||||||
|
"VERSION:2.0",
|
||||||
|
) + body + listOf("END:VCALENDAR")
|
||||||
|
).joinToString("\r\n")
|
||||||
|
|
||||||
|
private fun days(event: ParsedIcsEvent): Long =
|
||||||
|
(event.end - event.start).inWholeMilliseconds / 86_400_000L
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an all-day event keeps the exact span the file gives it`() {
|
||||||
|
val result = parser.parse(
|
||||||
|
fossify(
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
"SUMMARY:Anna Birthday",
|
||||||
|
"UID:abc123",
|
||||||
|
"X-FOSSIFY-CATEGORY-COLOR:-1155931",
|
||||||
|
"DTSTART;VALUE=DATE:19900412",
|
||||||
|
"DTEND;VALUE=DATE:19900413",
|
||||||
|
"X-FOSSIFY-MISSING-YEAR:0",
|
||||||
|
"RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=4",
|
||||||
|
"END:VEVENT",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val event = result.events.single()
|
||||||
|
assertThat(event.isAllDay).isTrue()
|
||||||
|
assertThat(days(event)).isEqualTo(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `contact-mirrored birthdays export zero length and must not vanish`() {
|
||||||
|
// The reported failure (#225). Fossify mirrors Contacts birthdays and
|
||||||
|
// anniversaries with startTS == endTS, so the exporter's +12h rounds
|
||||||
|
// back to the starting day: DTSTART == DTEND. Read literally these are
|
||||||
|
// yearly series of zero-length occurrences, which the provider expands
|
||||||
|
// into nothing at all — the birthdays import "successfully" and are
|
||||||
|
// then nowhere to be seen.
|
||||||
|
val text = checkNotNull(
|
||||||
|
javaClass.classLoader?.getResourceAsStream("ics/fossify-contact-birthdays.ics"),
|
||||||
|
).use { it.readBytes().toString(Charsets.UTF_8) }
|
||||||
|
|
||||||
|
val result = parser.parse(text)
|
||||||
|
|
||||||
|
assertThat(result.events).hasSize(3)
|
||||||
|
result.events.forEach {
|
||||||
|
assertThat(it.isAllDay).isTrue()
|
||||||
|
assertThat(days(it)).isEqualTo(1)
|
||||||
|
assertThat(it.recurrenceRule).startsWith("FREQ=YEARLY")
|
||||||
|
// Their all-day reminder encoding: "on the day", not midnight UTC.
|
||||||
|
assertThat(it.semanticReminderMinutes()).containsExactly(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a real Fossify holiday file imports unchanged`() {
|
||||||
|
// Shipped inside Fossify Calendar (assets/holidays/AT/public.ics). Its
|
||||||
|
// PRODID names Fossify, so any producer-sniffing shortcut would corrupt
|
||||||
|
// it — the file is plain, conformant iCalendar.
|
||||||
|
val text = checkNotNull(
|
||||||
|
javaClass.classLoader?.getResourceAsStream("ics/fossify-holidays-at.ics"),
|
||||||
|
).use { it.readBytes().toString(Charsets.UTF_8) }
|
||||||
|
|
||||||
|
val result = parser.parse(text)
|
||||||
|
|
||||||
|
assertThat(result.events.map { it.summary })
|
||||||
|
.containsExactly("Neujahr", "Heilige Drei Könige").inOrder()
|
||||||
|
assertThat(result.events.map { days(it) }).containsExactly(1L, 1L)
|
||||||
|
assertThat(result.events.map { it.recurrenceRule }).containsExactly("FREQ=YEARLY", "FREQ=YEARLY")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an all-day event with no DTEND lasts one day`() {
|
||||||
|
// RFC 5545 3.6.1, and the span the provider needs to expand a series.
|
||||||
|
val result = parser.parse(
|
||||||
|
fossify(
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
"SUMMARY:Holiday",
|
||||||
|
"DTSTART;VALUE=DATE:20260819",
|
||||||
|
"END:VEVENT",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(days(result.events.single())).isEqualTo(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a zero-length all-day event is widened rather than left to vanish`() {
|
||||||
|
val result = parser.parse(
|
||||||
|
fossify(
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
"SUMMARY:Holiday",
|
||||||
|
"DTSTART;VALUE=DATE:20260819",
|
||||||
|
"DTEND;VALUE=DATE:20260819",
|
||||||
|
"END:VEVENT",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(days(result.events.single())).isEqualTo(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a malformed repeat rule is repaired rather than passed to the provider`() {
|
||||||
|
val result = parser.parse(
|
||||||
|
fossify(
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
"SUMMARY:Standup",
|
||||||
|
"DTSTART:20260819T080000Z",
|
||||||
|
"DTEND:20260819T081500Z",
|
||||||
|
"RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=",
|
||||||
|
"END:VEVENT",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(result.events.single().recurrenceRule).isEqualTo("FREQ=WEEKLY;INTERVAL=1")
|
||||||
|
assertThat(result.warnings).contains(IcsParseWarning.RecurrenceRuleRepaired)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a task is imported as an event and reported`() {
|
||||||
|
val result = parser.parse(
|
||||||
|
fossify(
|
||||||
|
"BEGIN:VTODO",
|
||||||
|
"SUMMARY:Pay rent",
|
||||||
|
"DTSTART;VALUE=DATE:20260901",
|
||||||
|
"END:VTODO",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val task = result.events.single()
|
||||||
|
assertThat(task.isTask).isTrue()
|
||||||
|
assertThat(task.summary).isEqualTo("Pay rent")
|
||||||
|
assertThat(days(task)).isEqualTo(1)
|
||||||
|
assertThat(result.warnings).contains(IcsParseWarning.TasksImportedAsEvents)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a task dated only by DUE still imports`() {
|
||||||
|
val result = parser.parse(
|
||||||
|
fossify(
|
||||||
|
"BEGIN:VTODO",
|
||||||
|
"SUMMARY:File taxes",
|
||||||
|
"DUE;VALUE=DATE:20260901",
|
||||||
|
"END:VTODO",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(result.events.single().summary).isEqualTo("File taxes")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a task with both DTSTART and DUE spans between them`() {
|
||||||
|
val result = parser.parse(
|
||||||
|
fossify(
|
||||||
|
"BEGIN:VTODO",
|
||||||
|
"SUMMARY:Write report",
|
||||||
|
"DTSTART:20260901T090000Z",
|
||||||
|
"DUE:20260901T170000Z",
|
||||||
|
"END:VTODO",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val task = result.events.single()
|
||||||
|
assertThat(task.start).isEqualTo(Instant.parse("2026-09-01T09:00:00Z"))
|
||||||
|
assertThat(task.end).isEqualTo(Instant.parse("2026-09-01T17:00:00Z"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a DUE ahead of the DTSTART is still the end`() {
|
||||||
|
// Property order isn't guaranteed; DUE must not be taken for the start.
|
||||||
|
val result = parser.parse(
|
||||||
|
fossify(
|
||||||
|
"BEGIN:VTODO",
|
||||||
|
"SUMMARY:Write report",
|
||||||
|
"DUE:20260901T170000Z",
|
||||||
|
"DTSTART:20260901T090000Z",
|
||||||
|
"END:VTODO",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val task = result.events.single()
|
||||||
|
assertThat(task.start).isEqualTo(Instant.parse("2026-09-01T09:00:00Z"))
|
||||||
|
assertThat(task.end).isEqualTo(Instant.parse("2026-09-01T17:00:00Z"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the per-event colour wins over the calendar colour`() {
|
||||||
|
val result = parser.parse(
|
||||||
|
fossify(
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
"SUMMARY:Holiday",
|
||||||
|
"X-FOSSIFY-CATEGORY-COLOR:-1155931",
|
||||||
|
"COLOR:tomato",
|
||||||
|
"X-FOSSIFY-EVENT-COLOR:-2818048",
|
||||||
|
"DTSTART:20260819T080000Z",
|
||||||
|
"DTEND:20260819T090000Z",
|
||||||
|
"END:VEVENT",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(result.events.single().color).isEqualTo(-2818048)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the calendar colour stands in when the event has none of its own`() {
|
||||||
|
val result = parser.parse(
|
||||||
|
fossify(
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
"SUMMARY:Holiday",
|
||||||
|
"X-FOSSIFY-CATEGORY-COLOR:-1155931",
|
||||||
|
"DTSTART:20260819T080000Z",
|
||||||
|
"DTEND:20260819T090000Z",
|
||||||
|
"END:VEVENT",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(result.events.single().color).isEqualTo(-1155931)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the legacy Simple Calendar colour spellings are read too`() {
|
||||||
|
val result = parser.parse(
|
||||||
|
listOf(
|
||||||
|
"BEGIN:VCALENDAR",
|
||||||
|
"PRODID:-//Simple Mobile Tools//NONSGML Event Calendar//EN",
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
"SUMMARY:Holiday",
|
||||||
|
"X-SMT-CATEGORY-COLOR:-1155931",
|
||||||
|
"DTSTART:20260819T080000Z",
|
||||||
|
"DTEND:20260819T090000Z",
|
||||||
|
"END:VEVENT",
|
||||||
|
"END:VCALENDAR",
|
||||||
|
).joinToString("\r\n"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(result.events.single().color).isEqualTo(-1155931)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the literal null Fossify writes for a missing calendar is not a colour`() {
|
||||||
|
// Both properties interpolate a nullable calendar straight into the value.
|
||||||
|
val result = parser.parse(
|
||||||
|
fossify(
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
"SUMMARY:Holiday",
|
||||||
|
"X-FOSSIFY-CATEGORY-COLOR:null",
|
||||||
|
"CATEGORIES:null",
|
||||||
|
"DTSTART:20260819T080000Z",
|
||||||
|
"DTEND:20260819T090000Z",
|
||||||
|
"END:VEVENT",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val event = result.events.single()
|
||||||
|
assertThat(event.color).isNull()
|
||||||
|
assertThat(event.calendarName).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `CATEGORIES names the source calendar`() {
|
||||||
|
val result = parser.parse(
|
||||||
|
fossify(
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
"SUMMARY:Anna Birthday",
|
||||||
|
"CATEGORIES:Birthdays",
|
||||||
|
"DTSTART;VALUE=DATE:20260819",
|
||||||
|
"DTEND;VALUE=DATE:20260820",
|
||||||
|
"END:VEVENT",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(result.events.single().calendarName).isEqualTo("Birthdays")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `CATEGORIES does not outrank a property that names the calendar outright`() {
|
||||||
|
// Elsewhere CATEGORIES is a tag list, so it only stands in where nothing
|
||||||
|
// else named the calendar — X-WR-CALNAME and our own label win.
|
||||||
|
val result = IcsParser().parse(
|
||||||
|
listOf(
|
||||||
|
"BEGIN:VCALENDAR",
|
||||||
|
"X-WR-CALNAME:Work",
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
"SUMMARY:Standup",
|
||||||
|
"CATEGORIES:Holiday",
|
||||||
|
"DTSTART:20260819T080000Z",
|
||||||
|
"DTEND:20260819T090000Z",
|
||||||
|
"END:VEVENT",
|
||||||
|
"END:VCALENDAR",
|
||||||
|
).joinToString("\r\n"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(result.events.single().calendarName).isEqualTo("Work")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `X-WR-CALNAME names the calendar wherever in the file it sits`() {
|
||||||
|
// Why the property is scanned up front instead of in document order: an
|
||||||
|
// exporter that writes it at the tail still names the calendar every
|
||||||
|
// event in the file came from.
|
||||||
|
val result = IcsParser().parse(
|
||||||
|
listOf(
|
||||||
|
"BEGIN:VCALENDAR",
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
"SUMMARY:Standup",
|
||||||
|
"DTSTART:20260819T080000Z",
|
||||||
|
"DTEND:20260819T090000Z",
|
||||||
|
"END:VEVENT",
|
||||||
|
"X-WR-CALNAME:Work",
|
||||||
|
"END:VCALENDAR",
|
||||||
|
).joinToString("\r\n"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(result.events.single().calendarName).isEqualTo("Work")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a multi-valued CATEGORIES is a tag list, not a calendar name`() {
|
||||||
|
val result = parser.parse(
|
||||||
|
fossify(
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
"SUMMARY:Standup",
|
||||||
|
"CATEGORIES:Meeting,Personal",
|
||||||
|
"DTSTART:20260819T080000Z",
|
||||||
|
"DTEND:20260819T090000Z",
|
||||||
|
"END:VEVENT",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(result.events.single().calendarName).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a reminder written as a positive trigger is not read as a lead time`() {
|
||||||
|
// The exporter flips the sign for reminders stored below -1
|
||||||
|
// (`sign = if (reminder.minutes < -1) "" else "-"`), so a trigger can
|
||||||
|
// point after the start. On an all-day event that means a time of day.
|
||||||
|
val result = parser.parse(
|
||||||
|
fossify(
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
"SUMMARY:Anna Birthday",
|
||||||
|
"DTSTART;VALUE=DATE:20260819",
|
||||||
|
"DTEND;VALUE=DATE:20260820",
|
||||||
|
"BEGIN:VALARM",
|
||||||
|
"ACTION:DISPLAY",
|
||||||
|
"TRIGGER:P0DT9H0M0S",
|
||||||
|
"END:VALARM",
|
||||||
|
"END:VEVENT",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val event = result.events.single()
|
||||||
|
assertThat(event.reminderMinutes).containsExactly(-540)
|
||||||
|
assertThat(event.semanticReminderMinutes()).containsExactly(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an all-day reminder whose firing time is after noon keeps its full day`() {
|
||||||
|
// The raw offset is `days * 1440 - timeOfDay`, so an 18:00 notification
|
||||||
|
// time puts "1 day before" only six hours ahead of the UTC midnight.
|
||||||
|
// Rounding to the nearest day would read that as "on the day".
|
||||||
|
val result = parser.parse(
|
||||||
|
fossify(
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
"SUMMARY:Anna Birthday",
|
||||||
|
"DTSTART;VALUE=DATE:20260819",
|
||||||
|
"DTEND;VALUE=DATE:20260820",
|
||||||
|
"BEGIN:VALARM",
|
||||||
|
"ACTION:DISPLAY",
|
||||||
|
"TRIGGER:-P0DT6H0M0S",
|
||||||
|
"END:VALARM",
|
||||||
|
"END:VEVENT",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(result.events.single().semanticReminderMinutes()).containsExactly(1440)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an all-day reminder rounds to whole days before`() {
|
||||||
|
val result = parser.parse(
|
||||||
|
fossify(
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
"SUMMARY:Anna Birthday",
|
||||||
|
"DTSTART;VALUE=DATE:20260819",
|
||||||
|
"DTEND;VALUE=DATE:20260820",
|
||||||
|
"BEGIN:VALARM",
|
||||||
|
"ACTION:DISPLAY",
|
||||||
|
// 15h before midnight == "1 day before at 09:00".
|
||||||
|
"TRIGGER:-P0DT15H0M0S",
|
||||||
|
"END:VALARM",
|
||||||
|
"END:VEVENT",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(result.events.single().semanticReminderMinutes()).containsExactly(1440)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a timed reminder keeps its exact lead time`() {
|
||||||
|
// Their P0DT1H5M0S duration spelling, from Parser.getDurationCode.
|
||||||
|
val result = parser.parse(
|
||||||
|
fossify(
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
"SUMMARY:Standup",
|
||||||
|
"DTSTART:20260819T080000Z",
|
||||||
|
"DTEND:20260819T081500Z",
|
||||||
|
"BEGIN:VALARM",
|
||||||
|
"ACTION:DISPLAY",
|
||||||
|
"TRIGGER:-P0DT0H10M0S",
|
||||||
|
"END:VALARM",
|
||||||
|
"END:VEVENT",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(result.events.single().semanticReminderMinutes()).containsExactly(10)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a timed trigger that fires after the start is dropped, not clamped`() {
|
||||||
|
// A legal RFC 5545 follow-up alarm. Modelling it as a lead time of zero
|
||||||
|
// would invent a notification at the start the file never asked for.
|
||||||
|
val result = parser.parse(
|
||||||
|
fossify(
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
"SUMMARY:Standup",
|
||||||
|
"DTSTART:20260819T080000Z",
|
||||||
|
"DTEND:20260819T081500Z",
|
||||||
|
"BEGIN:VALARM",
|
||||||
|
"ACTION:DISPLAY",
|
||||||
|
"TRIGGER:PT30M",
|
||||||
|
"END:VALARM",
|
||||||
|
"END:VEVENT",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(result.events.single().semanticReminderMinutes()).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an all-day series' EXDATE day codes carry over`() {
|
||||||
|
val result = parser.parse(
|
||||||
|
fossify(
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
"SUMMARY:Standup",
|
||||||
|
"DTSTART;VALUE=DATE:20260819",
|
||||||
|
"DTEND;VALUE=DATE:20260820",
|
||||||
|
"RRULE:FREQ=DAILY;INTERVAL=1",
|
||||||
|
"EXDATE:20260820",
|
||||||
|
"EXDATE:20260822",
|
||||||
|
"END:VEVENT",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(result.events.single().exDates).containsExactly("20260820", "20260822").inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an all-day EXDATE keeps the day it spells out, whatever zone it carries`() {
|
||||||
|
// A midnight east of UTC resolves to an instant on the previous day;
|
||||||
|
// reading that back in UTC would exclude a day that isn't an occurrence
|
||||||
|
// and leave the one the user deleted in place.
|
||||||
|
val result = parser.parse(
|
||||||
|
fossify(
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
"SUMMARY:Standup",
|
||||||
|
"DTSTART;VALUE=DATE:20260819",
|
||||||
|
"DTEND;VALUE=DATE:20260820",
|
||||||
|
"RRULE:FREQ=DAILY",
|
||||||
|
"EXDATE;TZID=Europe/Berlin:20260821T000000",
|
||||||
|
"EXDATE;VALUE=DATE:20260823",
|
||||||
|
"END:VEVENT",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(result.events.single().exDates)
|
||||||
|
.containsExactly("20260821", "20260823").inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a timed series' bare EXDATE day code is resolved against the series time`() {
|
||||||
|
// They store excluded occurrences as day codes and write them out that
|
||||||
|
// way even for a timed series, where the property alone is ambiguous.
|
||||||
|
val result = parser.parse(
|
||||||
|
fossify(
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
"SUMMARY:Gym",
|
||||||
|
"DTSTART:20260819T170000Z",
|
||||||
|
"DTEND:20260819T180000Z",
|
||||||
|
"RRULE:FREQ=DAILY;INTERVAL=1",
|
||||||
|
"EXDATE:20260820",
|
||||||
|
"END:VEVENT",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(result.events.single().exDates).containsExactly("20260820T170000Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a floating EXDATE is read in the series' zone, not the device's`() {
|
||||||
|
// RFC 5545 ties EXDATE to DTSTART's form: with no Z and no TZID the
|
||||||
|
// value is New York wall time. Reading it in the device's Berlin zone
|
||||||
|
// would land on an instant no occurrence has, and the excluded
|
||||||
|
// occurrence would come back.
|
||||||
|
val result = parser.parse(
|
||||||
|
fossify(
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
"SUMMARY:Standup",
|
||||||
|
"DTSTART;TZID=America/New_York:20260819T100000",
|
||||||
|
"DTEND;TZID=America/New_York:20260819T103000",
|
||||||
|
"RRULE:FREQ=WEEKLY",
|
||||||
|
"EXDATE:20260826T100000",
|
||||||
|
"END:VEVENT",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(result.events.single().exDates).containsExactly("20260826T140000Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `EXDATE is dropped along with an unusable recurrence rule`() {
|
||||||
|
val result = parser.parse(
|
||||||
|
fossify(
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
"SUMMARY:Gym",
|
||||||
|
"DTSTART:20260819T170000Z",
|
||||||
|
"DTEND:20260819T180000Z",
|
||||||
|
"RRULE:INTERVAL=1",
|
||||||
|
"EXDATE:20260820",
|
||||||
|
"END:VEVENT",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val event = result.events.single()
|
||||||
|
assertThat(event.recurrenceRule).isNull()
|
||||||
|
assertThat(event.exDates).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a whole export imports every component`() {
|
||||||
|
val result = parser.parse(
|
||||||
|
fossify(
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
"SUMMARY:Anna Birthday",
|
||||||
|
"UID:abc123",
|
||||||
|
"X-FOSSIFY-CATEGORY-COLOR:-1155931",
|
||||||
|
"CATEGORIES:Birthdays",
|
||||||
|
"LAST-MODIFIED:20260101T120000Z",
|
||||||
|
"TRANSP:TRANSPARENT",
|
||||||
|
"DTSTART;VALUE=DATE:19900412",
|
||||||
|
"DTEND;VALUE=DATE:19900413",
|
||||||
|
"X-FOSSIFY-MISSING-YEAR:0",
|
||||||
|
"DTSTAMP:20260819T100000Z",
|
||||||
|
"CLASS:PUBLIC",
|
||||||
|
"STATUS:CONFIRMED",
|
||||||
|
"RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=4",
|
||||||
|
"END:VEVENT",
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
"SUMMARY:Standup",
|
||||||
|
"UID:def456",
|
||||||
|
"DTSTART:20260819T080000Z",
|
||||||
|
"DTEND:20260819T081500Z",
|
||||||
|
"RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=TU",
|
||||||
|
"END:VEVENT",
|
||||||
|
"BEGIN:VTODO",
|
||||||
|
"SUMMARY:Pay rent",
|
||||||
|
"UID:ghi789",
|
||||||
|
"DTSTART;VALUE=DATE:20260901",
|
||||||
|
"END:VTODO",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(result.events.map { it.summary })
|
||||||
|
.containsExactly("Anna Birthday", "Standup", "Pay rent").inOrder()
|
||||||
|
// Nothing may reach the provider as a zero-length all-day row.
|
||||||
|
assertThat(result.events.none { it.isAllDay && days(it) == 0L }).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -165,4 +165,36 @@ class IcsParserTest {
|
|||||||
assertThat(result.events.map { it.uid }).containsExactly("good")
|
assertThat(result.events.map { it.uid }).containsExactly("good")
|
||||||
assertThat(result.warnings).contains(IcsParseWarning.EventWithoutStartSkipped)
|
assertThat(result.warnings).contains(IcsParseWarning.EventWithoutStartSkipped)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `round-trips a timed series' deleted occurrences`() {
|
||||||
|
val event = IcsEvent(
|
||||||
|
uid = "u20@calendula",
|
||||||
|
summary = "Weekly",
|
||||||
|
start = instantUtc(2026, 6, 18, 13, 0),
|
||||||
|
end = instantUtc(2026, 6, 18, 13, 30),
|
||||||
|
isAllDay = false,
|
||||||
|
zoneId = "UTC",
|
||||||
|
recurrenceRule = "FREQ=WEEKLY",
|
||||||
|
exDates = listOf("20260625T130000Z"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(roundTrip(event).exDates).containsExactly("20260625T130000Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `round-trips an all-day series' deleted occurrences`() {
|
||||||
|
val event = IcsEvent(
|
||||||
|
uid = "u21@calendula",
|
||||||
|
summary = "Holiday",
|
||||||
|
start = LocalDate(2026, 6, 18).atStartOfDayIn(TimeZone.UTC),
|
||||||
|
end = LocalDate(2026, 6, 19).atStartOfDayIn(TimeZone.UTC),
|
||||||
|
isAllDay = true,
|
||||||
|
zoneId = "UTC",
|
||||||
|
recurrenceRule = "FREQ=YEARLY",
|
||||||
|
exDates = listOf("20270618"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(roundTrip(event).exDates).containsExactly("20270618")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.ics
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class IcsRecurrenceTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a well-formed rule is unchanged`() {
|
||||||
|
val result = sanitizeRrule("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE")
|
||||||
|
|
||||||
|
assertThat(result.rule).isEqualTo("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE")
|
||||||
|
assertThat(result.repaired).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the RRULE prefix is stripped`() {
|
||||||
|
assertThat(sanitizeRrule("RRULE:FREQ=DAILY").rule).isEqualTo("FREQ=DAILY")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an empty list part is dropped`() {
|
||||||
|
val bareByDay = sanitizeRrule("FREQ=WEEKLY;INTERVAL=1;BYDAY=")
|
||||||
|
assertThat(bareByDay.rule).isEqualTo("FREQ=WEEKLY;INTERVAL=1")
|
||||||
|
assertThat(bareByDay.repaired).isTrue()
|
||||||
|
|
||||||
|
assertThat(sanitizeRrule("FREQ=WEEKLY;BYDAY=MO,,WE;BYMONTH=").rule)
|
||||||
|
.isEqualTo("FREQ=WEEKLY;BYDAY=MO,WE")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a nonsensical INTERVAL or COUNT is dropped, not fatal`() {
|
||||||
|
assertThat(sanitizeRrule("FREQ=DAILY;INTERVAL=0").rule).isEqualTo("FREQ=DAILY")
|
||||||
|
assertThat(sanitizeRrule("FREQ=DAILY;INTERVAL=every").rule).isEqualTo("FREQ=DAILY")
|
||||||
|
assertThat(sanitizeRrule("FREQ=DAILY;COUNT=0").rule).isEqualTo("FREQ=DAILY")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a rule without a usable FREQ is unsalvageable`() {
|
||||||
|
assertThat(sanitizeRrule("INTERVAL=1;BYDAY=MO").rule).isNull()
|
||||||
|
assertThat(sanitizeRrule("FREQ=FORTNIGHTLY;INTERVAL=1").rule).isNull()
|
||||||
|
assertThat(sanitizeRrule("FREQ=;INTERVAL=1").rule).isNull()
|
||||||
|
assertThat(sanitizeRrule("").rule).isNull()
|
||||||
|
assertThat(sanitizeRrule(null).rule).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `no rule at all is not a repair`() {
|
||||||
|
assertThat(sanitizeRrule(null).repaired).isFalse()
|
||||||
|
assertThat(sanitizeRrule("").repaired).isFalse()
|
||||||
|
assertThat(sanitizeRrule("FREQ=FORTNIGHTLY").repaired).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `normalising is not repairing`() {
|
||||||
|
// Case and a trailing separator change the text but lose nothing, and
|
||||||
|
// telling the user their file was faulty over either is crying wolf.
|
||||||
|
val lowercase = sanitizeRrule("freq=daily;count=3")
|
||||||
|
assertThat(lowercase.rule).isEqualTo("FREQ=DAILY;COUNT=3")
|
||||||
|
assertThat(lowercase.repaired).isFalse()
|
||||||
|
|
||||||
|
val trailing = sanitizeRrule("FREQ=DAILY;")
|
||||||
|
assertThat(trailing.rule).isEqualTo("FREQ=DAILY")
|
||||||
|
assertThat(trailing.repaired).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parts the provider's parser has no entry for are dropped`() {
|
||||||
|
// EventRecurrence.parse throws on any part name outside its own table,
|
||||||
|
// so passing one on would cost the whole event (Codeberg #225).
|
||||||
|
val result = sanitizeRrule("FREQ=MONTHLY;RSCALE=GREGORIAN;BYMONTHDAY=15")
|
||||||
|
|
||||||
|
assertThat(result.rule).isEqualTo("FREQ=MONTHLY;BYMONTHDAY=15")
|
||||||
|
assertThat(result.repaired).isTrue()
|
||||||
|
assertThat(sanitizeRrule("FREQ=YEARLY;X-THING=7").rule).isEqualTo("FREQ=YEARLY")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `WKST survives, a nonsense weekday does not`() {
|
||||||
|
assertThat(sanitizeRrule("FREQ=WEEKLY;WKST=SU").rule).isEqualTo("FREQ=WEEKLY;WKST=SU")
|
||||||
|
assertThat(sanitizeRrule("FREQ=WEEKLY;WKST=XX").rule).isEqualTo("FREQ=WEEKLY")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a BYDAY item that isn't a weekday is dropped`() {
|
||||||
|
assertThat(sanitizeRrule("FREQ=MONTHLY;BYDAY=-1FR,1,MO").rule)
|
||||||
|
.isEqualTo("FREQ=MONTHLY;BYDAY=-1FR,MO")
|
||||||
|
assertThat(sanitizeRrule("FREQ=WEEKLY;BYDAY=mo,tu").rule)
|
||||||
|
.isEqualTo("FREQ=WEEKLY;BYDAY=MO,TU")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a zero is dropped from the by-position parts only`() {
|
||||||
|
assertThat(sanitizeRrule("FREQ=MONTHLY;BYMONTHDAY=0,15").rule)
|
||||||
|
.isEqualTo("FREQ=MONTHLY;BYMONTHDAY=15")
|
||||||
|
assertThat(sanitizeRrule("FREQ=DAILY;BYHOUR=0,9").rule)
|
||||||
|
.isEqualTo("FREQ=DAILY;BYHOUR=0,9")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an out-of-range numeric item is dropped`() {
|
||||||
|
// EventRecurrence.parseNumberList range-checks every one of these and
|
||||||
|
// throws out of insert, so being a number isn't enough (Codeberg #225).
|
||||||
|
assertThat(sanitizeRrule("FREQ=MONTHLY;BYMONTHDAY=15,32").rule)
|
||||||
|
.isEqualTo("FREQ=MONTHLY;BYMONTHDAY=15")
|
||||||
|
assertThat(sanitizeRrule("FREQ=DAILY;BYHOUR=25").rule).isEqualTo("FREQ=DAILY")
|
||||||
|
assertThat(sanitizeRrule("FREQ=YEARLY;BYMONTH=13").rule).isEqualTo("FREQ=YEARLY")
|
||||||
|
assertThat(sanitizeRrule("FREQ=YEARLY;BYWEEKNO=60").rule).isEqualTo("FREQ=YEARLY")
|
||||||
|
assertThat(sanitizeRrule("FREQ=YEARLY;BYYEARDAY=400").rule).isEqualTo("FREQ=YEARLY")
|
||||||
|
assertThat(sanitizeRrule("FREQ=DAILY;BYSECOND=60").rule).isEqualTo("FREQ=DAILY")
|
||||||
|
assertThat(sanitizeRrule("FREQ=DAILY;BYHOUR=25").repaired).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the edges of each range survive`() {
|
||||||
|
assertThat(sanitizeRrule("FREQ=MONTHLY;BYMONTHDAY=-31,31").repaired).isFalse()
|
||||||
|
assertThat(sanitizeRrule("FREQ=YEARLY;BYYEARDAY=-366,366;BYWEEKNO=-53,53").repaired)
|
||||||
|
.isFalse()
|
||||||
|
assertThat(sanitizeRrule("FREQ=DAILY;BYHOUR=23;BYMINUTE=59;BYSECOND=59").repaired)
|
||||||
|
.isFalse()
|
||||||
|
// BYSETPOS is the one part the provider leaves unbounded.
|
||||||
|
assertThat(sanitizeRrule("FREQ=MONTHLY;BYDAY=MO;BYSETPOS=-1,400").rule)
|
||||||
|
.isEqualTo("FREQ=MONTHLY;BYDAY=MO;BYSETPOS=-1,400")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an UNTIL that isn't a timestamp is dropped`() {
|
||||||
|
assertThat(sanitizeRrule("FREQ=DAILY;UNTIL=20260819T235959Z").rule)
|
||||||
|
.isEqualTo("FREQ=DAILY;UNTIL=20260819T235959Z")
|
||||||
|
assertThat(sanitizeRrule("FREQ=DAILY;UNTIL=2026-08-19").rule).isEqualTo("FREQ=DAILY")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `UNTIL and COUNT together keep UNTIL`() {
|
||||||
|
// EventRecurrence.parse throws outright on a rule carrying both.
|
||||||
|
val both = sanitizeRrule("FREQ=WEEKLY;COUNT=10;UNTIL=20260101T000000Z")
|
||||||
|
|
||||||
|
assertThat(both.rule).isEqualTo("FREQ=WEEKLY;UNTIL=20260101T000000Z")
|
||||||
|
assertThat(both.repaired).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a COUNT the UNTIL check never sees is left alone`() {
|
||||||
|
// An UNTIL dropped for its shape doesn't take the COUNT down with it.
|
||||||
|
val staleUntil = sanitizeRrule("FREQ=WEEKLY;COUNT=10;UNTIL=2026-01-01")
|
||||||
|
|
||||||
|
assertThat(staleUntil.rule).isEqualTo("FREQ=WEEKLY;COUNT=10")
|
||||||
|
assertThat(staleUntil.repaired).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a part without a value separator is reported as repaired`() {
|
||||||
|
val noEquals = sanitizeRrule("FREQ=WEEKLY;BYDAY")
|
||||||
|
|
||||||
|
assertThat(noEquals.rule).isEqualTo("FREQ=WEEKLY")
|
||||||
|
assertThat(noEquals.repaired).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a trailing separator is normalisation, not a repair`() {
|
||||||
|
val trailing = sanitizeRrule("FREQ=DAILY;")
|
||||||
|
|
||||||
|
assertThat(trailing.rule).isEqualTo("FREQ=DAILY")
|
||||||
|
assertThat(trailing.repaired).isFalse()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -149,4 +149,55 @@ class IcsWriterTest {
|
|||||||
// Stable across calls — a re-export of the same row yields the same UID.
|
// Stable across calls — a re-export of the same row yields the same UID.
|
||||||
assertThat(deriveIcsUid(null, 7, 1000)).isEqualTo(deriveIcsUid(null, 7, 1000))
|
assertThat(deriveIcsUid(null, 7, 1000)).isEqualTo(deriveIcsUid(null, 7, 1000))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a timed series writes its exclusions as UTC stamps`() {
|
||||||
|
val event = IcsEvent(
|
||||||
|
uid = "u9@calendula",
|
||||||
|
summary = "Weekly",
|
||||||
|
start = instantUtc(2026, 6, 18, 13, 0),
|
||||||
|
end = instantUtc(2026, 6, 18, 13, 30),
|
||||||
|
isAllDay = false,
|
||||||
|
zoneId = "UTC",
|
||||||
|
recurrenceRule = "FREQ=WEEKLY",
|
||||||
|
exDates = listOf("20260625T130000Z", "20260702T130000Z"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(lines(listOf(event)))
|
||||||
|
.contains("EXDATE:20260625T130000Z,20260702T130000Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an all-day series marks its exclusions VALUE=DATE`() {
|
||||||
|
val start = LocalDate(2026, 6, 18).atStartOfDayIn(TimeZone.UTC)
|
||||||
|
val event = IcsEvent(
|
||||||
|
uid = "u10@calendula",
|
||||||
|
summary = "Holiday",
|
||||||
|
start = start,
|
||||||
|
end = LocalDate(2026, 6, 19).atStartOfDayIn(TimeZone.UTC),
|
||||||
|
isAllDay = true,
|
||||||
|
zoneId = "UTC",
|
||||||
|
recurrenceRule = "FREQ=YEARLY",
|
||||||
|
exDates = listOf("20270618"),
|
||||||
|
)
|
||||||
|
|
||||||
|
// RFC 5545 ties EXDATE's value type to DTSTART's; a bare day code
|
||||||
|
// without VALUE=DATE reads as a malformed DATE-TIME.
|
||||||
|
assertThat(lines(listOf(event))).contains("EXDATE;VALUE=DATE:20270618")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a one-off event never writes an EXDATE`() {
|
||||||
|
val event = IcsEvent(
|
||||||
|
uid = "u11@calendula",
|
||||||
|
summary = "Standup",
|
||||||
|
start = instantUtc(2026, 6, 18, 13, 0),
|
||||||
|
end = instantUtc(2026, 6, 18, 13, 30),
|
||||||
|
isAllDay = false,
|
||||||
|
zoneId = "UTC",
|
||||||
|
exDates = listOf("20260625T130000Z"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(lines(listOf(event)).none { it.startsWith("EXDATE") }).isTrue()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -286,9 +286,53 @@ class RescheduleViewModelTest {
|
|||||||
vm.move(toWednesday())
|
vm.move(toWednesday())
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val abandoned = vm.abandoned.value
|
||||||
assertThat(vm.undo(undo)).isFalse()
|
assertThat(vm.undo(undo)).isFalse()
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
assertThat(fake.updatedEvents).hasSize(1)
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an undo that fails lets the held chip go`(@TempDir tempDir: Path) = runTest(dispatcher) {
|
||||||
|
// undoStarted has already sent the chip back on its journey by the time
|
||||||
|
// the inverse write is attempted. Without a tick here nothing ever seats
|
||||||
|
// it, and the overlay waits out its whole settle timeout for a chip that
|
||||||
|
// is never coming.
|
||||||
|
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
|
||||||
|
val vm = viewModel(tempDir, fake)
|
||||||
|
vm.move(oneHourLater())
|
||||||
|
advanceUntilIdle()
|
||||||
|
val undo = (vm.outcome.value as MoveOutcome.Moved).undo!!
|
||||||
|
|
||||||
|
val abandoned = vm.abandoned.value
|
||||||
|
fake.writeError = SecurityException("permission revoked")
|
||||||
|
assertThat(vm.undo(undo)).isTrue()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.WriteDenied)
|
||||||
|
assertThat(vm.abandoned.value).isEqualTo(abandoned + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an undo that lands leaves the abandoned count alone`(
|
||||||
|
@TempDir tempDir: Path,
|
||||||
|
) = runTest(dispatcher) {
|
||||||
|
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
|
||||||
|
val vm = viewModel(tempDir, fake)
|
||||||
|
vm.move(oneHourLater())
|
||||||
|
advanceUntilIdle()
|
||||||
|
val undo = (vm.outcome.value as MoveOutcome.Moved).undo!!
|
||||||
|
|
||||||
|
val abandoned = vm.abandoned.value
|
||||||
|
vm.undo(undo)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.Undone)
|
||||||
|
assertThat(vm.abandoned.value).isEqualTo(abandoned)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -494,4 +538,61 @@ class RescheduleViewModelTest {
|
|||||||
assertThat(fake.updatedEvents).isEmpty()
|
assertThat(fake.updatedEvents).isEmpty()
|
||||||
assertThat(fake.updatedOccurrences).isEmpty()
|
assertThat(fake.updatedOccurrences).isEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a drop that lands nothing says so, since move() already answered yes`(
|
||||||
|
@TempDir tempDir: Path,
|
||||||
|
) = runTest(dispatcher) {
|
||||||
|
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
|
||||||
|
val vm = viewModel(tempDir, fake)
|
||||||
|
|
||||||
|
// Refused in prepare: nowhere to move to.
|
||||||
|
assertThat(vm.move(
|
||||||
|
MoveRequest(
|
||||||
|
eventId = 42L,
|
||||||
|
beginMillis = beginMillis,
|
||||||
|
endMillis = endMillis,
|
||||||
|
target = MoveTarget.Start(Instant.fromEpochMilliseconds(beginMillis)),
|
||||||
|
),
|
||||||
|
)).isTrue()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertThat(vm.abandoned.value).isEqualTo(1)
|
||||||
|
|
||||||
|
// Refused by the provider, after the write was attempted.
|
||||||
|
fake.writeError = SecurityException("revoked")
|
||||||
|
vm.move(oneHourLater())
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.WriteDenied)
|
||||||
|
assertThat(vm.abandoned.value).isEqualTo(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a scope dialog dismissed leaves nothing waiting on the write`(
|
||||||
|
@TempDir tempDir: Path,
|
||||||
|
) = runTest(dispatcher) {
|
||||||
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
eventDetailResult = { detail(rrule = "FREQ=WEEKLY") }
|
||||||
|
}
|
||||||
|
val vm = viewModel(tempDir, fake)
|
||||||
|
|
||||||
|
vm.move(oneHourLater())
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertThat(vm.abandoned.value).isEqualTo(0)
|
||||||
|
|
||||||
|
vm.cancelScope()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertThat(vm.abandoned.value).isEqualTo(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a written move never counts as abandoned`(@TempDir tempDir: Path) = runTest(dispatcher) {
|
||||||
|
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
|
||||||
|
val vm = viewModel(tempDir, fake)
|
||||||
|
|
||||||
|
vm.move(oneHourLater())
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertThat(vm.outcome.value).isInstanceOf(MoveOutcome.Moved::class.java)
|
||||||
|
assertThat(vm.abandoned.value).isEqualTo(0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY
|
||||||
|
import de.jeanlucmakiola.calendula.ui.week.layoutDay
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.atTime
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import androidx.compose.foundation.shape.CornerSize
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which edges a timed block is cut on, and how much of a dragged piece its day
|
||||||
|
* can show (#253).
|
||||||
|
*/
|
||||||
|
class TimedBlockShapeTest {
|
||||||
|
|
||||||
|
private val zone = TimeZone.UTC
|
||||||
|
private val day1 = LocalDate(2026, 9, 5)
|
||||||
|
private val day2 = day1.plus(1, DateTimeUnit.DAY)
|
||||||
|
private val day3 = day2.plus(1, DateTimeUnit.DAY)
|
||||||
|
|
||||||
|
private fun event(from: LocalDateTimeIsh, to: LocalDateTimeIsh) = EventInstance(
|
||||||
|
instanceId = 1L, eventId = 1L, calendarId = 1L, title = "E",
|
||||||
|
start = from.date.atTime(from.hour, 0).toInstant(zone),
|
||||||
|
end = to.date.atTime(to.hour, 0).toInstant(zone),
|
||||||
|
isAllDay = false, color = 0xFF112233.toInt(), location = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class LocalDateTimeIsh(val date: LocalDate, val hour: Int)
|
||||||
|
|
||||||
|
private fun at(date: LocalDate, hour: Int) = LocalDateTimeIsh(date, hour)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a same-day block is cut on neither edge`() {
|
||||||
|
val ev = event(at(day1, 9), at(day1, 10))
|
||||||
|
val block = layoutDay(listOf(ev), day1, zone).single()
|
||||||
|
|
||||||
|
assertThat(block.continuesBefore(day1, zone)).isFalse()
|
||||||
|
assertThat(block.continuesAfter(day1, zone)).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an overnight event is cut at the foot of its first day and the head of its second`() {
|
||||||
|
val ev = event(at(day1, 20), at(day2, 8))
|
||||||
|
|
||||||
|
val head = layoutDay(listOf(ev), day1, zone).single()
|
||||||
|
assertThat(head.continuesBefore(day1, zone)).isFalse()
|
||||||
|
assertThat(head.continuesAfter(day1, zone)).isTrue()
|
||||||
|
|
||||||
|
val tail = layoutDay(listOf(ev), day2, zone).single()
|
||||||
|
assertThat(tail.continuesBefore(day2, zone)).isTrue()
|
||||||
|
assertThat(tail.continuesAfter(day2, zone)).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a day wholly inside a long event is cut on both edges`() {
|
||||||
|
val ev = event(at(day1, 20), at(day3, 8))
|
||||||
|
val middle = layoutDay(listOf(ev), day2, zone).single()
|
||||||
|
|
||||||
|
assertThat(middle.continuesBefore(day2, zone)).isTrue()
|
||||||
|
assertThat(middle.continuesAfter(day2, zone)).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an event ending exactly at midnight is not cut`() {
|
||||||
|
val ev = event(at(day1, 20), at(day2, 0))
|
||||||
|
val block = layoutDay(listOf(ev), day1, zone).single()
|
||||||
|
|
||||||
|
assertThat(block.continuesAfter(day1, zone)).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `only the cut edges lose their corners`() {
|
||||||
|
val square = 0.dp
|
||||||
|
assertThat(timedBlockShape(continuesBefore = true, continuesAfter = false).topStart)
|
||||||
|
.isEqualTo(CornerSize(square))
|
||||||
|
assertThat(timedBlockShape(continuesBefore = true, continuesAfter = false).bottomStart)
|
||||||
|
.isEqualTo(CornerSize(EVENT_CHIP_CORNER))
|
||||||
|
assertThat(timedBlockShape(continuesBefore = false, continuesAfter = true).topStart)
|
||||||
|
.isEqualTo(CornerSize(EVENT_CHIP_CORNER))
|
||||||
|
assertThat(timedBlockShape(continuesBefore = false, continuesAfter = true).bottomEnd)
|
||||||
|
.isEqualTo(CornerSize(square))
|
||||||
|
}
|
||||||
|
|
||||||
|
private val fourHours = 4 * 60
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an event that fits its day is drawn as one uncut piece`() {
|
||||||
|
val pieces = dragSlices(eventStartMin = 10 * 60, spanMin = fourHours)
|
||||||
|
|
||||||
|
assertThat(pieces).hasSize(1)
|
||||||
|
assertThat(pieces.single().dayOffset).isEqualTo(0)
|
||||||
|
assertThat(pieces.single().spanMin).isEqualTo(fourHours)
|
||||||
|
assertThat(pieces.single().continuesBefore).isFalse()
|
||||||
|
assertThat(pieces.single().continuesAfter).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `dragging the start half re-splits both days as it moves`() {
|
||||||
|
// Held at 22:00: two hours on this day, two on the next.
|
||||||
|
assertThat(dragSlices(22 * 60, fourHours).map { it.spanMin })
|
||||||
|
.containsExactly(2 * 60, 2 * 60).inOrder()
|
||||||
|
// Carried up to 21:00 the first day takes three, so the second day's end
|
||||||
|
// comes up by exactly the hour the first day gained.
|
||||||
|
assertThat(dragSlices(21 * 60, fourHours).map { it.spanMin })
|
||||||
|
.containsExactly(3 * 60, 60).inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `dragging the end half splits the same way, one day back`() {
|
||||||
|
// The tail's top edge is at midnight and the event began two hours
|
||||||
|
// earlier, so it starts at -120 relative to the day the finger is on.
|
||||||
|
val pieces = dragSlices(eventStartMin = -2 * 60, spanMin = fourHours)
|
||||||
|
|
||||||
|
assertThat(pieces.map { it.dayOffset }).containsExactly(-1, 0).inOrder()
|
||||||
|
assertThat(pieces.map { it.spanMin }).containsExactly(2 * 60, 2 * 60).inOrder()
|
||||||
|
// The piece on the earlier day ends at midnight; the held one starts there.
|
||||||
|
assertThat(pieces.first().startMin).isEqualTo(22 * 60)
|
||||||
|
assertThat(pieces.first().continuesAfter).isTrue()
|
||||||
|
assertThat(pieces.last().startMin).isEqualTo(0)
|
||||||
|
assertThat(pieces.last().continuesBefore).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the earlier half shrinks away as the end half is pulled down its own day`() {
|
||||||
|
// Tail top dragged from midnight down to 01:00: one hour left behind.
|
||||||
|
assertThat(dragSlices(-2 * 60 + 60, fourHours).map { it.spanMin })
|
||||||
|
.containsExactly(60, 3 * 60).inOrder()
|
||||||
|
// At 02:00 the event no longer crosses midnight at all.
|
||||||
|
val whole = dragSlices(0, fourHours)
|
||||||
|
assertThat(whole).hasSize(1)
|
||||||
|
assertThat(whole.single().continuesBefore).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a day wholly inside a long event is cut on both sides`() {
|
||||||
|
val pieces = dragSlices(eventStartMin = 22 * 60, spanMin = 30 * 60)
|
||||||
|
|
||||||
|
assertThat(pieces.map { it.dayOffset }).containsExactly(0, 1, 2).inOrder()
|
||||||
|
assertThat(pieces[1].spanMin).isEqualTo(MINUTES_PER_DAY)
|
||||||
|
assertThat(pieces[1].continuesBefore).isTrue()
|
||||||
|
assertThat(pieces[1].continuesAfter).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an event ending exactly at midnight takes no piece of the next day`() {
|
||||||
|
val pieces = dragSlices(eventStartMin = 22 * 60, spanMin = 2 * 60)
|
||||||
|
|
||||||
|
assertThat(pieces).hasSize(1)
|
||||||
|
assertThat(pieces.single().continuesAfter).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a zero-length event still has a piece to draw`() {
|
||||||
|
val pieces = dragSlices(eventStartMin = 9 * 60, spanMin = 0)
|
||||||
|
|
||||||
|
assertThat(pieces).hasSize(1)
|
||||||
|
assertThat(pieces.single().dayOffset).isEqualTo(0)
|
||||||
|
assertThat(pieces.single().startMin).isEqualTo(9 * 60)
|
||||||
|
assertThat(pieces.single().spanMin).isEqualTo(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `only the days there are columns for are sliced`() {
|
||||||
|
// A week's worth of event, previewed on a timeline showing three days
|
||||||
|
// from the one the finger is on.
|
||||||
|
val pieces = dragSlices(0, 7 * MINUTES_PER_DAY, within = 0..2)
|
||||||
|
|
||||||
|
assertThat(pieces.map { it.dayOffset }).containsExactly(0, 1, 2).inOrder()
|
||||||
|
assertThat(pieces.last().continuesAfter).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an event outside the columns shown is sliced into nothing`() {
|
||||||
|
assertThat(dragSlices(-3 * MINUTES_PER_DAY, 60, within = 0..2)).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the copy never blinks out from under the finger (#253) ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a short tail dragged to its floor still has a piece to draw`() {
|
||||||
|
// 23:50-00:05: on the tail's day the clip offset is 10 and the floor is
|
||||||
|
// one snap step above midnight, which puts the whole event on the day
|
||||||
|
// before. Day view has no column for that day, so dragSlices hands back
|
||||||
|
// nothing and the floating copy used to vanish mid-gesture.
|
||||||
|
val span = 15
|
||||||
|
val floor = dragFloorMin(clipOffsetMin = 10, eventSpanMin = span)
|
||||||
|
val eventStartMin = floor - 10
|
||||||
|
val visible = 0..0
|
||||||
|
|
||||||
|
assertThat(dragSlices(eventStartMin, span, visible)).isEmpty()
|
||||||
|
val edge = edgeDragSlice(eventStartMin, span, visible)
|
||||||
|
assertThat(edge).isNotNull()
|
||||||
|
assertThat(edge!!.dayOffset).isEqualTo(0)
|
||||||
|
assertThat(edge.startMin).isEqualTo(0)
|
||||||
|
assertThat(edge.continuesBefore).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an event a visible column does hold gets no edge piece`() {
|
||||||
|
assertThat(edgeDragSlice(9 * 60, 60, 0..0)).isNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
||||||
|
import de.jeanlucmakiola.calendula.ui.week.layoutDay
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.atTime
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a timeline drop asks for, for the head and the tail of an event that
|
||||||
|
* crosses midnight (#253). The tail's top edge is midnight rather than the
|
||||||
|
* event's start, so the drop has to take that offset back off.
|
||||||
|
*/
|
||||||
|
class TimelineDropTest {
|
||||||
|
|
||||||
|
private val zone = TimeZone.UTC
|
||||||
|
private val sat = LocalDate(2026, 9, 5)
|
||||||
|
private val sun = sat.plus(1, DateTimeUnit.DAY)
|
||||||
|
|
||||||
|
// Sat 5 Sep 20:00 to Sun 6 Sep 08:00.
|
||||||
|
private val overnight = EventInstance(
|
||||||
|
instanceId = 1L,
|
||||||
|
eventId = 1L,
|
||||||
|
calendarId = 1L,
|
||||||
|
title = "Night shift",
|
||||||
|
start = sat.atTime(20, 0).toInstant(zone),
|
||||||
|
end = sun.atTime(8, 0).toInstant(zone),
|
||||||
|
isAllDay = false,
|
||||||
|
color = 0xFF112233.toInt(),
|
||||||
|
location = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun blockOn(day: LocalDate): TimedBlock =
|
||||||
|
layoutDay(listOf(overnight), day, zone).single()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the head block carries no clip offset`() {
|
||||||
|
val head = blockOn(sat)
|
||||||
|
assertThat(head.startMin).isEqualTo(20 * 60)
|
||||||
|
assertThat(head.clipOffsetMinutes(sat, zone)).isEqualTo(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the tail block is offset by the part that already ran`() {
|
||||||
|
val tail = blockOn(sun)
|
||||||
|
assertThat(tail.startMin).isEqualTo(0)
|
||||||
|
// 20:00 to midnight is four hours of the event already gone.
|
||||||
|
assertThat(tail.clipOffsetMinutes(sun, zone)).isEqualTo(4 * 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
@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)
|
||||||
|
|
||||||
|
assertThat(drop.startInstant(zone)).isEqualTo(target.atTime(20, 0).toInstant(zone))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `dropping the tail moves the event by the days the tail travelled`() {
|
||||||
|
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)
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
assertThat(drop.startInstant(zone))
|
||||||
|
.isEqualTo(LocalDate(2026, 9, 2).atTime(20, 0).toInstant(zone))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
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)
|
||||||
|
|
||||||
|
assertThat(drop.startInstant(zone)).isEqualTo(sat.atTime(23, 0).toInstant(zone))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
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)
|
||||||
|
|
||||||
|
// 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))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a top edge dragged past the next midnight resolves onto the day after`() {
|
||||||
|
val drop = TimelineDrop(overnight, sat, 25 * 60, clipOffsetMin = 0)
|
||||||
|
|
||||||
|
assertThat(drop.startInstant(zone)).isEqualTo(sun.atTime(1, 0).toInstant(zone))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a short tail can still be dragged a step earlier`() {
|
||||||
|
// 23:50 to 00:05: five minutes of tail, less than one snap step, which
|
||||||
|
// a floor pinned at midnight left with nowhere to go (#253).
|
||||||
|
assertThat(dragFloorMin(clipOffsetMin = 10, eventSpanMin = 15))
|
||||||
|
.isEqualTo(-DRAG_SNAP_MINUTES)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a long tail may rise until its event ends just inside the day`() {
|
||||||
|
// Sat 20:00 to Sun 08:00, held by the Sunday half.
|
||||||
|
assertThat(dragFloorMin(clipOffsetMin = 4 * 60, eventSpanMin = 12 * 60))
|
||||||
|
.isEqualTo(DRAG_SNAP_MINUTES - 8 * 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an unclipped block cannot be dragged above its own midnight`() {
|
||||||
|
assertThat(dragFloorMin(clipOffsetMin = 0, eventSpanMin = 60)).isEqualTo(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a drop across a DST boundary keeps the time of day it was let go at`() {
|
||||||
|
// Europe/Berlin springs forward at 02:00 on 29 March 2026, between the
|
||||||
|
// event's start and the midnight its tail is cut at.
|
||||||
|
val berlin = TimeZone.of("Europe/Berlin")
|
||||||
|
val sun = LocalDate(2026, 3, 29)
|
||||||
|
val overnightIntoDst = EventInstance(
|
||||||
|
instanceId = 3L, eventId = 3L, calendarId = 1L, title = "Night shift",
|
||||||
|
start = LocalDate(2026, 3, 28).atTime(22, 0).toInstant(berlin),
|
||||||
|
end = sun.atTime(6, 0).toInstant(berlin),
|
||||||
|
isAllDay = false, color = 0xFF112233.toInt(), location = null,
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
|
||||||
|
assertThat(drop.startInstant(berlin))
|
||||||
|
.isEqualTo(LocalDate(2026, 3, 21).atTime(22, 0).toInstant(berlin))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a block that is not clipped is unaffected wherever it sits`() {
|
||||||
|
val plain = EventInstance(
|
||||||
|
instanceId = 2L, eventId = 2L, calendarId = 1L, title = "Standup",
|
||||||
|
start = sat.atTime(9, 0).toInstant(zone),
|
||||||
|
end = sat.atTime(10, 0).toInstant(zone),
|
||||||
|
isAllDay = false, color = 0xFF112233.toInt(), location = null,
|
||||||
|
)
|
||||||
|
val block = layoutDay(listOf(plain), sat, zone).single()
|
||||||
|
assertThat(block.clipOffsetMinutes(sat, zone)).isEqualTo(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.imports
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/** Which calendar a bulk import lands on before the user touches the picker. */
|
||||||
|
class ImportTargetTest {
|
||||||
|
|
||||||
|
private fun cal(id: Long, name: String, local: Boolean = false) = CalendarSource(
|
||||||
|
id = id,
|
||||||
|
displayName = name,
|
||||||
|
accountName = "acc@local",
|
||||||
|
accountType = if (local) "LOCAL" else "com.google",
|
||||||
|
color = 0,
|
||||||
|
isVisibleInSystem = true,
|
||||||
|
isLocal = local,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val calendars = listOf(
|
||||||
|
cal(1L, "Work"),
|
||||||
|
cal(2L, "Personal", local = true),
|
||||||
|
cal(3L, "Birthdays", local = true),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a calendar named like the file's wins`() {
|
||||||
|
assertThat(defaultImportTarget(calendars, "Birthdays")).isEqualTo(3L)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the name match ignores case`() {
|
||||||
|
assertThat(defaultImportTarget(calendars, "birthdays")).isEqualTo(3L)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an unmatched name falls back to the first local calendar`() {
|
||||||
|
assertThat(defaultImportTarget(calendars, "Holidays")).isEqualTo(2L)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `no name at all falls back to the first local calendar`() {
|
||||||
|
assertThat(defaultImportTarget(calendars, null)).isEqualTo(2L)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `with no local calendars the first of any kind is taken`() {
|
||||||
|
assertThat(defaultImportTarget(listOf(cal(1L, "Work")), null)).isEqualTo(1L)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an empty list preselects nothing`() {
|
||||||
|
assertThat(defaultImportTarget(emptyList(), "Birthdays")).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun event(calendarName: String?) = ParsedIcsEvent(
|
||||||
|
uid = "u@x",
|
||||||
|
summary = "E",
|
||||||
|
start = Instant.fromEpochMilliseconds(0L),
|
||||||
|
end = Instant.fromEpochMilliseconds(3_600_000L),
|
||||||
|
isAllDay = false,
|
||||||
|
zoneId = "UTC",
|
||||||
|
calendarName = calendarName,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a file whose events all name one calendar names it`() {
|
||||||
|
assertThat(fileCalendarName(List(3) { event("Birthdays") })).isEqualTo("Birthdays")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `disagreeing events name none`() {
|
||||||
|
assertThat(fileCalendarName(listOf(event("Birthdays"), event("Anniversaries")))).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `one tagged event among unnamed ones names none`() {
|
||||||
|
// CATEGORIES is a tag list everywhere but the Fossify family, so a lone
|
||||||
|
// tagged event must not decide where the whole file lands.
|
||||||
|
assertThat(fileCalendarName(listOf(event("Holiday")) + List(9) { event(null) })).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a file that names nothing names none`() {
|
||||||
|
assertThat(fileCalendarName(List(3) { event(null) })).isNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.month
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.atTime
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a week row draws a moved chip — what the settling copy glides onto
|
||||||
|
* (#68, #253).
|
||||||
|
*/
|
||||||
|
class ChipSeatTest {
|
||||||
|
|
||||||
|
private val monday = LocalDate(2026, 6, 8)
|
||||||
|
private val days = (0..6).map { monday.plus(it, DateTimeUnit.DAY) }
|
||||||
|
|
||||||
|
private fun event(id: Long, title: String = "E") = EventInstance(
|
||||||
|
instanceId = id,
|
||||||
|
eventId = id,
|
||||||
|
calendarId = 1L,
|
||||||
|
title = title,
|
||||||
|
start = monday.atTime(9, 0).toInstant(TimeZone.UTC),
|
||||||
|
end = monday.atTime(10, 0).toInstant(TimeZone.UTC),
|
||||||
|
isAllDay = false,
|
||||||
|
color = 0xFF112233.toInt(),
|
||||||
|
location = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A grid holding [seated] in lane 0 as one bar across [cols], and nothing
|
||||||
|
* else — the seating a [MonthWeek] gives a span.
|
||||||
|
*/
|
||||||
|
private fun row(seated: EventInstance, cols: IntRange) = MonthWeek(
|
||||||
|
days = days,
|
||||||
|
spans = listOf(
|
||||||
|
MonthSpan(
|
||||||
|
event = seated,
|
||||||
|
startCol = cols.first,
|
||||||
|
endCol = cols.last,
|
||||||
|
lane = 0,
|
||||||
|
continuesLeft = false,
|
||||||
|
continuesRight = false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
timedByDay = emptyMap(),
|
||||||
|
countByDay = emptyMap(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A grid holding one single-day pill per day of [cols], all of [seated]. */
|
||||||
|
private fun pills(seated: EventInstance, cols: IntRange) = MonthWeek(
|
||||||
|
days = days,
|
||||||
|
spans = emptyList(),
|
||||||
|
timedByDay = cols.associate { days[it] to listOf(seated) },
|
||||||
|
countByDay = emptyMap(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun seat(week: MonthWeek, event: EventInstance, date: LocalDate) = chipSeat(
|
||||||
|
days = days,
|
||||||
|
laneCount = 3,
|
||||||
|
chipAt = { col, lane -> week.chipAt(col, lane, 3) },
|
||||||
|
chipStart = { col, lane -> week.chipStartCol(col, lane) },
|
||||||
|
event = event,
|
||||||
|
date = date,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a single-day chip is seated in its own column`() {
|
||||||
|
val ev = event(1L)
|
||||||
|
assertThat(seat(row(ev, 3..3), ev, days[3])).isEqualTo(3 to 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a bar is seated from its first column, not the day it was dropped on`() {
|
||||||
|
val ev = event(1L)
|
||||||
|
// Covers Wed–Thu; dropped on the Thursday it now runs into.
|
||||||
|
assertThat(seat(row(ev, 2..3), ev, days[3])).isEqualTo(2 to 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a bar carried in from the previous week is seated at column zero`() {
|
||||||
|
val ev = event(1L)
|
||||||
|
assertThat(seat(row(ev, 0..4), ev, days[4])).isEqualTo(0 to 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a row that does not hold the event seats nothing`() {
|
||||||
|
val ev = event(1L)
|
||||||
|
assertThat(seat(row(ev, 2..3), ev, days[5])).isNull()
|
||||||
|
assertThat(seat(row(ev, 2..3), ev, LocalDate(2026, 7, 1))).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a re-read chip is matched by title once its event id has changed`() {
|
||||||
|
// A single-occurrence move writes a new event id; the title survives.
|
||||||
|
val moved = event(1L, title = "Standup")
|
||||||
|
val reRead = event(9L, title = "Standup")
|
||||||
|
assertThat(seat(row(reRead, 3..3), moved, days[3])).isEqualTo(3 to 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `two untitled events are not the same event`() {
|
||||||
|
val moved = event(1L, title = "")
|
||||||
|
val other = event(2L, title = "")
|
||||||
|
assertThat(isSameEvent(other, moved)).isFalse()
|
||||||
|
assertThat(seat(row(other, 3..3), moved, days[3])).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a daily series seats each occurrence in its own column`() {
|
||||||
|
// Every column holds an occurrence of the same event id, so a walk left
|
||||||
|
// by identity would run to the start of the row (#253).
|
||||||
|
val ev = event(1L, title = "Standup")
|
||||||
|
assertThat(seat(pills(ev, 0..6), ev, days[4])).isEqualTo(4 to 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,19 @@ class MonthLayoutTest {
|
|||||||
location = null,
|
location = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** A timed event running from [date] 20:00 into the next day at [endHour]. */
|
||||||
|
private fun overnight(date: LocalDate, endHour: Int = 8) = EventInstance(
|
||||||
|
instanceId = 7L,
|
||||||
|
eventId = 7L,
|
||||||
|
calendarId = 1L,
|
||||||
|
title = "Night shift",
|
||||||
|
start = at(date, 20),
|
||||||
|
end = at(date.plus(1, DateTimeUnit.DAY), endHour),
|
||||||
|
isAllDay = false,
|
||||||
|
color = 0xFF112233.toInt(),
|
||||||
|
location = null,
|
||||||
|
)
|
||||||
|
|
||||||
/** All-day events live at UTC midnights with an *exclusive* end. */
|
/** All-day events live at UTC midnights with an *exclusive* end. */
|
||||||
private fun allDay(
|
private fun allDay(
|
||||||
from: LocalDate,
|
from: LocalDate,
|
||||||
@@ -162,6 +175,43 @@ class MonthLayoutTest {
|
|||||||
assertThat(week.timedByDay[LocalDate(2026, 6, 11)]).isEmpty()
|
assertThat(week.timedByDay[LocalDate(2026, 6, 11)]).isEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an event crossing midnight is one bar, not two pills, on both sides of a week seam`() {
|
||||||
|
// Sun 14 Jun 20:00 to Mon 15 Jun 08:00 — the seam of a Monday-anchored
|
||||||
|
// grid: one day in this row, one in the next. Counting per row made each
|
||||||
|
// a standalone pill with nothing saying they were one event (#253).
|
||||||
|
val overnight = overnight(LocalDate(2026, 6, 14))
|
||||||
|
val head = layoutCalendarWeek(weekOf8th, listOf(overnight), zone)
|
||||||
|
assertThat(head.timedByDay.values.flatten()).isEmpty()
|
||||||
|
val headSpan = head.spans.single()
|
||||||
|
assertThat(headSpan.startCol).isEqualTo(6)
|
||||||
|
assertThat(headSpan.endCol).isEqualTo(6)
|
||||||
|
assertThat(headSpan.continuesLeft).isFalse()
|
||||||
|
assertThat(headSpan.continuesRight).isTrue()
|
||||||
|
|
||||||
|
val tailRow = layoutCalendarWeek(
|
||||||
|
weekOf8th.map { it.plus(7, DateTimeUnit.DAY) },
|
||||||
|
listOf(overnight),
|
||||||
|
zone,
|
||||||
|
)
|
||||||
|
assertThat(tailRow.timedByDay.values.flatten()).isEmpty()
|
||||||
|
val tailSpan = tailRow.spans.single()
|
||||||
|
assertThat(tailSpan.startCol).isEqualTo(0)
|
||||||
|
assertThat(tailSpan.endCol).isEqualTo(0)
|
||||||
|
assertThat(tailSpan.continuesLeft).isTrue()
|
||||||
|
assertThat(tailSpan.continuesRight).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an event ending exactly at midnight stays a single-day pill`() {
|
||||||
|
val untilMidnight = overnight(LocalDate(2026, 6, 10), endHour = 0)
|
||||||
|
val week = layoutCalendarWeek(weekOf8th, listOf(untilMidnight), zone)
|
||||||
|
|
||||||
|
assertThat(week.spans).isEmpty()
|
||||||
|
assertThat(week.timedByDay[LocalDate(2026, 6, 10)]).hasSize(1)
|
||||||
|
assertThat(week.timedByDay[LocalDate(2026, 6, 11)]).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `countByDay totals bars and pills on each date`() {
|
fun `countByDay totals bars and pills on each date`() {
|
||||||
val span = allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12), id = 1L)
|
val span = allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12), id = 1L)
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.hours
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rollover alarm is what makes the widgets stop highlighting yesterday
|
||||||
|
* (#228), so the "when is the next local midnight" arithmetic is the one piece
|
||||||
|
* worth pinning down — especially where midnight is not 00:00.
|
||||||
|
*/
|
||||||
|
class WidgetRolloverSchedulerTest {
|
||||||
|
|
||||||
|
private val berlin = TimeZone.of("Europe/Berlin")
|
||||||
|
|
||||||
|
private fun at(local: String, zone: TimeZone): Instant =
|
||||||
|
LocalDateTime.parse(local).toInstant(zone)
|
||||||
|
|
||||||
|
private fun nextRollover(local: String, zone: TimeZone = berlin): Instant =
|
||||||
|
WidgetRolloverScheduler.nextRolloverAt(at(local, zone), zone)
|
||||||
|
|
||||||
|
// --- the ordinary day ----------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `midday rolls over at the coming midnight`() {
|
||||||
|
val next = nextRollover("2026-08-27T12:00:00")
|
||||||
|
assertThat(next).isEqualTo(at("2026-08-28T00:00:05", berlin))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a second before midnight still targets tonight, not tomorrow night`() {
|
||||||
|
val next = nextRollover("2026-08-27T23:59:59")
|
||||||
|
assertThat(next).isEqualTo(at("2026-08-28T00:00:05", berlin))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `at midnight exactly the target is the next day, never the current instant`() {
|
||||||
|
// Re-arming after a firing must move a whole day on, or the widget wakes
|
||||||
|
// itself in a tight loop.
|
||||||
|
val now = at("2026-08-28T00:00:00", berlin)
|
||||||
|
val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
|
||||||
|
assertThat(next).isEqualTo(at("2026-08-29T00:00:05", berlin))
|
||||||
|
assertThat(next - now).isGreaterThan(Duration.ZERO)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `re-arming from the slack instant itself moves a full day on`() {
|
||||||
|
// What actually happens in practice: the receiver runs at midnight + slack.
|
||||||
|
val now = at("2026-08-28T00:00:05", berlin)
|
||||||
|
assertThat(WidgetRolloverScheduler.nextRolloverAt(now, berlin))
|
||||||
|
.isEqualTo(at("2026-08-29T00:00:05", berlin))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the result is always in the future for every minute of a day`() {
|
||||||
|
val zone = berlin
|
||||||
|
// Spans Berlin's 2024 spring-forward: the likeliest source of a target
|
||||||
|
// in the past, i.e. an alarm that fires immediately, forever.
|
||||||
|
var probe = LocalDateTime.parse("2024-03-29T00:00:00").toInstant(zone)
|
||||||
|
val end = LocalDateTime.parse("2024-04-01T00:00:00").toInstant(zone)
|
||||||
|
while (probe < end) {
|
||||||
|
assertThat(WidgetRolloverScheduler.nextRolloverAt(probe, zone)).isGreaterThan(probe)
|
||||||
|
probe += 1.minutes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- daylight saving -----------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `spring forward keeps the rollover one day away, not one hour short`() {
|
||||||
|
// Berlin skipped 02:00-03:00 on 31 March 2024, so that day was 23h long.
|
||||||
|
// A rollover computed as "now + 24h" would land at 01:00 on 1 April.
|
||||||
|
val now = at("2024-03-30T12:00:00", berlin)
|
||||||
|
val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
|
||||||
|
assertThat(next).isEqualTo(at("2024-03-31T00:00:05", berlin))
|
||||||
|
assertThat(next - now).isLessThan(24.hours)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fall back does not overshoot into the repeated hour`() {
|
||||||
|
// Berlin repeated 02:00-03:00 on 27 October 2024: a 25h day, so
|
||||||
|
// "now + 24h" would land at 23:00 on the 26th and never roll over.
|
||||||
|
val now = at("2024-10-26T12:00:00", berlin)
|
||||||
|
val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
|
||||||
|
assertThat(next).isEqualTo(at("2024-10-27T00:00:05", berlin))
|
||||||
|
assertThat(next.toLocalDateTime(berlin).date).isEqualTo(LocalDate.parse("2024-10-27"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a zone that repeats midnight takes the first start of day`() {
|
||||||
|
// Sao Paulo used to end DST by moving 00:00 back to 23:00, so the day
|
||||||
|
// began twice. Pinned as the accepted trade: the widget runs an hour
|
||||||
|
// ahead until the next redraw. No live zone does this since 2019.
|
||||||
|
val saoPaulo = TimeZone.of("America/Sao_Paulo")
|
||||||
|
val next = WidgetRolloverScheduler.nextRolloverAt(
|
||||||
|
at("2018-02-16T12:00:00", saoPaulo), saoPaulo,
|
||||||
|
)
|
||||||
|
val local = next.toLocalDateTime(saoPaulo)
|
||||||
|
assertThat(local.date).isEqualTo(LocalDate.parse("2018-02-17"))
|
||||||
|
assertThat(local.hour).isEqualTo(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a zone where midnight does not exist rolls over at the real start of day`() {
|
||||||
|
// Cuba starts DST at 00:00, so 11 March 2018 began at 01:00 in Havana.
|
||||||
|
// Targeting a literal 00:00 there would arm an instant on the wrong day.
|
||||||
|
val havana = TimeZone.of("America/Havana")
|
||||||
|
val next = WidgetRolloverScheduler.nextRolloverAt(at("2018-03-10T12:00:00", havana), havana)
|
||||||
|
val local = next.toLocalDateTime(havana)
|
||||||
|
assertThat(local.date).isEqualTo(LocalDate.parse("2018-03-11"))
|
||||||
|
assertThat(local.hour).isEqualTo(1)
|
||||||
|
assertThat(local.minute).isEqualTo(0)
|
||||||
|
// And it is genuinely the first instant of that date, not a guess.
|
||||||
|
assertThat(next).isEqualTo(
|
||||||
|
LocalDate.parse("2018-03-11").atStartOfDayIn(havana) +
|
||||||
|
WidgetRolloverScheduler.ROLLOVER_SLACK,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- timezone changes ----------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the same instant rolls over at different times in different zones`() {
|
||||||
|
// TIMEZONE_CHANGED must re-arm to the new local midnight: the arithmetic
|
||||||
|
// follows the zone, not a cached offset.
|
||||||
|
val instant = at("2026-08-27T12:00:00", berlin)
|
||||||
|
val tokyo = TimeZone.of("Asia/Tokyo")
|
||||||
|
val berlinNext = WidgetRolloverScheduler.nextRolloverAt(instant, berlin)
|
||||||
|
val tokyoNext = WidgetRolloverScheduler.nextRolloverAt(instant, tokyo)
|
||||||
|
assertThat(tokyoNext).isNotEqualTo(berlinNext)
|
||||||
|
assertThat(tokyoNext).isLessThan(berlinNext)
|
||||||
|
assertThat(tokyoNext.toLocalDateTime(tokyo).hour).isEqualTo(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a half-hour offset zone still lands on its own midnight`() {
|
||||||
|
val kathmandu = TimeZone.of("Asia/Kathmandu")
|
||||||
|
val next = WidgetRolloverScheduler.nextRolloverAt(
|
||||||
|
at("2026-08-27T12:00:00", kathmandu), kathmandu,
|
||||||
|
)
|
||||||
|
val local = next.toLocalDateTime(kathmandu)
|
||||||
|
assertThat(local.date.toString()).isEqualTo("2026-08-28")
|
||||||
|
assertThat(local.hour).isEqualTo(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the wiring ----------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the receiver actually handles the action the alarm is sent with`() {
|
||||||
|
// The single point where the whole fix would die silently: the alarm
|
||||||
|
// fires, the receiver drops it on the action guard, nothing redraws.
|
||||||
|
assertThat(WidgetUpdateReceiver.HANDLED_ACTIONS)
|
||||||
|
.contains(WidgetUpdateReceiver.ACTION_ROLLOVER)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a data change is handled, and re-arms only on the throttle`() {
|
||||||
|
// PROVIDER_CHANGED fires on every calendar write — each event of a bulk
|
||||||
|
// import, each drop of a drag — so it is not an unconditional re-arm.
|
||||||
|
// It is not dropped either: the alarm is inexact and the system may lose
|
||||||
|
// it, and this is the only signal that keeps arriving to notice.
|
||||||
|
assertThat(WidgetUpdateReceiver.HANDLED_ACTIONS)
|
||||||
|
.containsAtLeast(Intent.ACTION_PROVIDER_CHANGED, Intent.ACTION_DATE_CHANGED)
|
||||||
|
assertThat(WidgetUpdateReceiver.REARM_ACTIONS)
|
||||||
|
.doesNotContain(Intent.ACTION_PROVIDER_CHANGED)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `everything that loses the alarm re-arms it`() {
|
||||||
|
assertThat(WidgetUpdateReceiver.REARM_ACTIONS).containsAtLeast(
|
||||||
|
WidgetUpdateReceiver.ACTION_ROLLOVER,
|
||||||
|
Intent.ACTION_BOOT_COMPLETED,
|
||||||
|
Intent.ACTION_MY_PACKAGE_REPLACED,
|
||||||
|
Intent.ACTION_TIME_CHANGED,
|
||||||
|
Intent.ACTION_TIMEZONE_CHANGED,
|
||||||
|
Intent.ACTION_DATE_CHANGED,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
64
app/src/test/resources/ics/fossify-contact-birthdays.ics
Normal file
64
app/src/test/resources/ics/fossify-contact-birthdays.ics
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
BEGIN:VCALENDAR
|
||||||
|
PRODID:-//Fossify//NONSGML Event Calendar//EN
|
||||||
|
VERSION:2.0
|
||||||
|
BEGIN:VEVENT
|
||||||
|
SUMMARY:Anna Schmidt
|
||||||
|
UID:101
|
||||||
|
X-FOSSIFY-CATEGORY-COLOR:-1155931
|
||||||
|
CATEGORIES:Birthdays
|
||||||
|
LAST-MODIFIED:20250615T150640Z
|
||||||
|
TRANSP:TRANSPARENT
|
||||||
|
DTSTART;VALUE=DATE:19900412
|
||||||
|
DTEND;VALUE=DATE:19900412
|
||||||
|
X-FOSSIFY-MISSING-YEAR:0
|
||||||
|
DTSTAMP:20260819T120000Z
|
||||||
|
CLASS:PUBLIC
|
||||||
|
STATUS:CONFIRMED
|
||||||
|
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=4
|
||||||
|
BEGIN:VALARM
|
||||||
|
DESCRIPTION:Reminder
|
||||||
|
ACTION:DISPLAY
|
||||||
|
TRIGGER:P0DT9H0M0S
|
||||||
|
END:VALARM
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
SUMMARY:Opa
|
||||||
|
UID:102
|
||||||
|
X-FOSSIFY-CATEGORY-COLOR:-1155931
|
||||||
|
CATEGORIES:Birthdays
|
||||||
|
LAST-MODIFIED:20250615T150640Z
|
||||||
|
TRANSP:TRANSPARENT
|
||||||
|
DTSTART;VALUE=DATE:19700603
|
||||||
|
DTEND;VALUE=DATE:19700603
|
||||||
|
X-FOSSIFY-MISSING-YEAR:1
|
||||||
|
DTSTAMP:20260819T120000Z
|
||||||
|
CLASS:PUBLIC
|
||||||
|
STATUS:CONFIRMED
|
||||||
|
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=6
|
||||||
|
BEGIN:VALARM
|
||||||
|
DESCRIPTION:Reminder
|
||||||
|
ACTION:DISPLAY
|
||||||
|
TRIGGER:P0DT9H0M0S
|
||||||
|
END:VALARM
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
SUMMARY:Hochzeitstag
|
||||||
|
UID:103
|
||||||
|
X-FOSSIFY-CATEGORY-COLOR:-1155931
|
||||||
|
CATEGORIES:Anniversaries
|
||||||
|
LAST-MODIFIED:20250615T150640Z
|
||||||
|
TRANSP:TRANSPARENT
|
||||||
|
DTSTART;VALUE=DATE:20050917
|
||||||
|
DTEND;VALUE=DATE:20050917
|
||||||
|
X-FOSSIFY-MISSING-YEAR:0
|
||||||
|
DTSTAMP:20260819T120000Z
|
||||||
|
CLASS:PUBLIC
|
||||||
|
STATUS:CONFIRMED
|
||||||
|
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=9
|
||||||
|
BEGIN:VALARM
|
||||||
|
DESCRIPTION:Reminder
|
||||||
|
ACTION:DISPLAY
|
||||||
|
TRIGGER:P0DT9H0M0S
|
||||||
|
END:VALARM
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
25
app/src/test/resources/ics/fossify-holidays-at.ics
Normal file
25
app/src/test/resources/ics/fossify-holidays-at.ics
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
CALSCALE:GREGORIAN
|
||||||
|
PRODID:Fossify Calendar Holiday Generator
|
||||||
|
METHOD:PUBLISH
|
||||||
|
X-PUBLISHED-TTL:PT1H
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:fossify_c10f9ad245dc2e57fb3652abee6ed8a06744f0bd
|
||||||
|
SUMMARY:Neujahr
|
||||||
|
DTSTAMP:20260526T132315Z
|
||||||
|
DTSTART;VALUE=DATE:19800101
|
||||||
|
DTEND;VALUE=DATE:19800102
|
||||||
|
STATUS:CONFIRMED
|
||||||
|
RRULE:FREQ=YEARLY
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:fossify_8a60ccf65b65265f768dd33a0b7aefc7620e3ee2
|
||||||
|
SUMMARY:Heilige Drei Könige
|
||||||
|
DTSTAMP:20260526T132315Z
|
||||||
|
DTSTART;VALUE=DATE:19800106
|
||||||
|
DTEND;VALUE=DATE:19800107
|
||||||
|
STATUS:CONFIRMED
|
||||||
|
RRULE:FREQ=YEARLY
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
@@ -346,6 +346,101 @@ a latch that disables its own scheduling once a real broadcast arrives — canno
|
|||||||
be copied, because our failure mode includes a broadcast that arrives with no
|
be copied, because our failure mode includes a broadcast that arrives with no
|
||||||
alert row behind it.
|
alert row behind it.
|
||||||
|
|
||||||
|
## Importing foreign `.ics`
|
||||||
|
|
||||||
|
`IcsParser` is deliberately liberal, because the files it is handed were written
|
||||||
|
by other people's calendars. What that costs us, learned from reading the Simple
|
||||||
|
Calendar / Fossify exporter (#225):
|
||||||
|
|
||||||
|
**An all-day event may never reach the provider zero days long.** This is what
|
||||||
|
#225 was: Fossify mirrors Contacts birthdays and anniversaries with
|
||||||
|
`startTS = endTS` (`MainActivity`), so its exporter's `dayCode(endTS + 12h)`
|
||||||
|
rounds back to the starting day and writes `DTEND == DTSTART`. Taken literally
|
||||||
|
that is a yearly series of zero-length occurrences, which the provider expands
|
||||||
|
into no instances at all — the import reports success and the birthdays are
|
||||||
|
nowhere. Nothing is logged, because nothing failed. `resolveEnd` floors an
|
||||||
|
all-day span at a day (also RFC 5545 §3.6.1, which gives a `DATE` `DTSTART` with
|
||||||
|
no `DTEND` a one-day duration) and `buildImportedEventValues` floors an all-day
|
||||||
|
`DURATION` at `P1D`.
|
||||||
|
|
||||||
|
Do **not** generalise that into correcting the producer's `DTEND` by sniffing its
|
||||||
|
`PRODID`. The same exporter is right everywhere else: `Event.endTS` anchors a
|
||||||
|
UI-created all-day event at *noon* of its last day
|
||||||
|
(`EventActivity.getStartEndTimes`), or at the exclusive midnight when the row
|
||||||
|
came from CalDAV, and `+ TWELVE_HOURS` rounds either to the following midnight.
|
||||||
|
Shifting those would push every one of them out by a day. The holiday files
|
||||||
|
bundled inside Fossify are a second trap: their `PRODID` reads
|
||||||
|
`Fossify Calendar Holiday Generator` and their content is plain conformant
|
||||||
|
iCalendar. Both that file and a contact-birthday export are kept as fixtures in
|
||||||
|
`app/src/test/resources/ics`. Clamp the degenerate case; never rewrite the
|
||||||
|
conformant one.
|
||||||
|
|
||||||
|
**One bad event must not cost the file.** The provider validates `RRULE` through
|
||||||
|
`EventRecurrence.parse`, which *throws* — out of `insert`, not out of a later
|
||||||
|
read. That parser works off a fixed table of part names and throws on anything
|
||||||
|
outside it, so `sanitizeRrule` is a **whitelist**: unrecognised parts (RFC 7529
|
||||||
|
`RSCALE`, vendor `X-` extensions) and unusable values go, and only a missing
|
||||||
|
`FREQ` gives up on the rule. It reports whether it actually *dropped* something,
|
||||||
|
separately from the rule text it returns — case and a trailing `;` normalise away
|
||||||
|
and must not raise the "faulty repeat rule" warning at the user. Behind all that,
|
||||||
|
`CalendarRepositoryImpl.importEvents` isolates each event, counting rejects into
|
||||||
|
`IcsImportSummary.failed` rather than unwinding the batch and reporting only
|
||||||
|
"couldn't read this file".
|
||||||
|
|
||||||
|
Smaller dialect handling: `VTODO` components import as events (Calendula models
|
||||||
|
no tasks; dropping them silently lost half of some exports), a single-valued
|
||||||
|
`CATEGORIES` stands in for the `X-WR-CALNAME` Fossify never writes and preselects
|
||||||
|
a target calendar of that name — as a *fallback* only, since everywhere else the
|
||||||
|
property is a tag list and must not outrank something that names the calendar
|
||||||
|
outright — bare `EXDATE` day codes on a timed series are resolved against the
|
||||||
|
series' own time of day (as is a floating `EXDATE` DATE-TIME — RFC 5545 ties it
|
||||||
|
to `DTSTART`'s zone, not the device's) while an all-day `EXDATE` keeps the day it
|
||||||
|
spells out rather than the day its resolved instant lands on in UTC, and an
|
||||||
|
all-day `VALARM` trigger pointing *after* the start — which is how that family
|
||||||
|
encodes "on the day at 09:00" — is read as zero days before. On a *timed* event
|
||||||
|
the same shape means a follow-up alarm and is dropped: clamping it to zero would
|
||||||
|
invent a reminder at the start that the file never asked for.
|
||||||
|
|
||||||
|
All-day reminder offsets are **whole days, rounded up** in both directions. A
|
||||||
|
file's raw offset is `days × 1440 − timeOfDay`, so rounding to the nearest day
|
||||||
|
would drop a day for every producer whose all-day notifications fire after noon
|
||||||
|
("1 day before at 18:00" arrives as `TRIGGER:-PT6H`). The export side is the
|
||||||
|
mirror image: an all-day row's raw provider `MINUTES` has the firing time encoded
|
||||||
|
into it and is normally *negative*, which the writer used to discard as a trigger
|
||||||
|
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.
|
||||||
|
|
||||||
|
That re-encode is sampled where the reminder will actually *fire*, not at
|
||||||
|
`DTSTART` (`importedAllDayReminderDate`). An imported series' `DTSTART` is only an
|
||||||
|
anchor and is routinely ancient — Fossify writes a year-less contact birthday at
|
||||||
|
1970, and a real birth year is usually older still — from a year whose timezone
|
||||||
|
rules predate DST across most of Europe. Sampling the UTC offset there skews every
|
||||||
|
modern occurrence by the delta, the same trap `nextYearlyOccurrence` exists for on
|
||||||
|
the managed special-dates path. Decoding is unaffected and stays anchored at
|
||||||
|
`DTSTART`: it only asks which local *day* the encoded instant falls on.
|
||||||
|
|
||||||
|
Colour arrives as a raw ARGB from an app with no idea which account it is landing
|
||||||
|
in. A calendar whose account publishes a palette rejects a raw `EVENT_COLOR`, so
|
||||||
|
`buildImportedEventValues` snaps the imported colour to the nearest published key
|
||||||
|
(`nearestTo`, measured in Oklab) and writes the raw value only where there is no
|
||||||
|
palette. The match runs against `publishedEventColors`, **not** the curated
|
||||||
|
`eventColorPalette`: curation is a picker concession that folds look-alikes and
|
||||||
|
drops the neutrals outright from an oversized palette, but every published key is
|
||||||
|
one the calendar accepts. The single-event review form deliberately keeps no colour at all: its
|
||||||
|
first question is which calendar to use, and answering it clears the colour
|
||||||
|
anyway.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
JUnit 5 + Truth + Turbine on the JVM. The seams that make it work:
|
JUnit 5 + Truth + Turbine on the JVM. The seams that make it work:
|
||||||
|
|||||||
10
fastlane/metadata/android/en-US/changelogs/21904.txt
Normal file
10
fastlane/metadata/android/en-US/changelogs/21904.txt
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
Fixed
|
||||||
|
• An occurrence you deleted from a repeating event stays deleted — through a re-timing, a backup and a restore.
|
||||||
|
• "Only this event" now saves your edit on calendars that have never synced, and deleting one from an event that doesn't repeat deletes the event.
|
||||||
|
• A reminder left on your account's default no longer goes off as the event starts.
|
||||||
|
• The widgets turn the page at midnight instead of leaving yesterday as "today".
|
||||||
|
• An event running past midnight can be dragged by either half.
|
||||||
|
• One event the calendar refuses no longer costs you the whole import, and an import cut short tells you how far it got.
|
||||||
|
|
||||||
|
Added
|
||||||
|
• Belarusian, Hungarian and Slovak, from community translators on Weblate.
|
||||||
@@ -26,7 +26,7 @@ lifecycleCompose = "2.10.0"
|
|||||||
androidxTestRules = "1.7.0"
|
androidxTestRules = "1.7.0"
|
||||||
# Glance: 1.1.1 is the latest stable (1.2.0 is still rc, 1.3.0 alpha).
|
# Glance: 1.1.1 is the latest stable (1.2.0 is still rc, 1.3.0 alpha).
|
||||||
glance = "1.1.1"
|
glance = "1.1.1"
|
||||||
work = "2.10.1"
|
work = "2.11.2"
|
||||||
documentfile = "1.1.0"
|
documentfile = "1.1.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
|
|||||||
Reference in New Issue
Block a user