diff --git a/.gitattributes b/.gitattributes index 9ac4914..9e860d9 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,3 +5,4 @@ *.jpg binary *.gif binary *.webp binary +app/src/test/resources/ics/*.ics -text diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml index 46fe5fc..b250ba6 100644 --- a/.gitea/workflows/release.yaml +++ b/.gitea/workflows/release.yaml @@ -551,12 +551,11 @@ jobs: env: VERSION: ${{ needs.detect.outputs.version }} VERSION_CODE: ${{ needs.detect.outputs.version_code }} - # Where the bundle lands. `internal` by default so a release reaches - # testers rather than the public, and promotion to production stays a - # deliberate human action in the Play Console — the same posture as - # holding UI releases for on-device review. Override with the PLAY_TRACK - # repo variable once the flow is trusted. - PLAY_TRACK: ${{ vars.PLAY_TRACK || 'internal' }} + # Where the bundle lands. `production` — the release itself is already the + # gate (a bumped versionName only reaches main after on-device review), so + # a second manual promotion in the Play Console bought nothing but delay. + # Override with the PLAY_TRACK repo variable to stage a release instead. + PLAY_TRACK: ${{ vars.PLAY_TRACK || 'production' }} PLAY_RELEASE_STATUS: ${{ vars.PLAY_RELEASE_STATUS || 'completed' }} # Set PLAY_DRY_RUN=true to validate the edit against the API and discard # it instead of committing — used to rehearse the first upload. diff --git a/CHANGELOG.md b/CHANGELOG.md index f30c64a..ae0ac2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,152 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 mid-word at large font sizes. The view switcher, the title and the agenda's range bar also line up with the grid underneath them ([#165]). +## [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 + +### Added +- **Czech, Japanese and Russian**, all three contributed as community + translations through + [Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/). + They are early — Czech covers about a third of the app, Russian a fifth, + Japanese has just started — 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 James Trivette (Czech), ame0011 + (Japanese), and chip and Pablo (Russian); help finishing them is very welcome. + +### Changed +- **Brazilian Portuguese is now complete**, covering the whole app. Thanks to + bkrz for carrying it the rest of the way. + +## [2.19.2] — 2026-08-17 + +### Changed +- **A meeting you declined is now struck through** wherever it appears — month, + week, day, agenda, search and both widgets — and no longer schedules a + reminder. Declining an invitation in Google Calendar left the event looking + like any other in Calendula, and it still notified you about a meeting you had + said no to. It stays visible rather than disappearing: the organiser still + expects an answer from you, and the slot is still spoken for ([#180]). + +### Fixed +- **Tapping an event in month view opens the event**, not the day it sits on. + Every month style is affected — page, rolling, seamless weeks — and until now + the only way to reach an event from the month was to open its day first and + find it again there. Tapping anywhere else in the cell still opens the day + ([#187]). +- **An edit made to an event now shows the moment you re-open it.** Adding a + description to an event and opening it again showed the sheet as it was before + the save, because the detail was only re-read when a different occurrence was + opened ([#196]). +- **The month widget's arrows stopped working after a couple of taps.** The grid + was serialised as roughly 740 views, and one update ran to half a megabyte — + more than the launcher's buffer takes. The third update overran it, and Android + responded by dropping the whole widget host, which killed updates for *every* + widget on the home screen, ours and other apps', until the launcher rebound. + The grid now draws 192 views, and a resized widget reflows to its new size + instead of clipping ([#214]). +- Tapping a widget's refresh or month arrows redraws that widget by its own id + instead of asking Android to update all of them, which does nothing in a + process the tap has just woken from cold ([#18]). +- **Jump-to-today in seamless weeks lands on the current week** instead of + leaving a sliver of the previous row on screen ([#191]). +- Day and week view no longer run their events flush against the right edge + ([#192]). + ## [2.19.1] — 2026-08-11 ### Added @@ -1428,3 +1574,14 @@ automatically, with zero telemetry and no internet permission. [#173]: https://codeberg.org/jlmakiola/calendula/issues/173 [#164]: https://codeberg.org/jlmakiola/calendula/issues/164 [#165]: https://codeberg.org/jlmakiola/calendula/issues/165 +[#180]: https://codeberg.org/jlmakiola/calendula/issues/180 +[#187]: https://codeberg.org/jlmakiola/calendula/issues/187 +[#191]: https://codeberg.org/jlmakiola/calendula/issues/191 +[#192]: https://codeberg.org/jlmakiola/calendula/issues/192 +[#196]: https://codeberg.org/jlmakiola/calendula/issues/196 +[#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 diff --git a/README.md b/README.md index 7cf96f7..0dbc7b6 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ Reads, writes, and reminds — on top of the system calendar, with zero network

Get it on F-Droid   +Get it on Google PlayGet it on Obtainium   Support me on Ko-fi @@ -86,7 +88,7 @@ Pick whichever channel you already use — they all install the same app: | [Official F-Droid](#f-droid-recommended) | On F-Droid's build schedule | Recommended; no extra setup | | [Self-hosted F-Droid repo](#self-hosted-f-droid-repo-fastest-updates) | Minutes after a release | Fastest; needs the repo added once | | [Codeberg release / Obtainium](#codeberg-release--obtainium) | Per release | Plain APK download, or automated by Obtainium | -| [Google Play](#google-play-coming-soon) | — | Coming soon | +| [Google Play](#google-play) | Per release | Signed with Google's key — see below | | [Build from source](#build-from-source) | Whenever you build | Full control | ### F-Droid (recommended) @@ -131,18 +133,14 @@ or do it by hand: *Add App* → paste `https://codeberg.org/jlmakiola/calendula` → *Add*. Either way, Obtainium tracks the releases and prompts you when a new one appears. -### Google Play (coming soon) +### Google Play -Calendula is on its way to Google Play as an additional channel. It isn't live -yet — this section gets a link once it is. Play builds will be signed with -Google's key rather than mine, so switching between Play and any other channel -will require an uninstall. +Calendula is live on Google Play: +**[play.google.com/store/apps/details?id=de.jeanlucmakiola.calendula](https://play.google.com/store/apps/details?id=de.jeanlucmakiola.calendula)**. -> **Testers wanted.** Play requires a round of closed testing before the app can -> go public, and I'm still looking for testers. If you'd like to help, email -> **[business@jeanlucmakiola.de](mailto:business@jeanlucmakiola.de)** with the -> Google account address you want to use — that address is what I need to add you -> to the closed test. +Play builds are signed with Google's key rather than mine, so switching between +Play and any other channel requires an uninstall (and with it, a fresh start for +app settings — your events live in the system calendar and are unaffected). ### Build from source @@ -151,7 +149,7 @@ The build is a plain Gradle build with no proprietary dependencies — see Official F-Droid, the self-hosted repo, and the Codeberg releases all share the same signing key, so you can switch freely between them without -reinstalling. +reinstalling. Google Play is the exception — see above. ## 📚 Documentation diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8e66663..3dae3a6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -28,8 +28,8 @@ android { // which builds this version and then creates the matching vX.Y.Z tag + // release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 + // PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md. - versionCode = 21901 - versionName = "2.19.1" + versionCode = 21904 + versionName = "2.19.4" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index ef98b3a..6181354 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -50,3 +50,9 @@ # the real names also survives app updates, which would otherwise renumber the # obfuscated name and orphan the stored mapping. -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 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1db01ea..a7e97b6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -330,9 +330,13 @@ + PROVIDER_CHANGED on any data change (our writes and external sync). + The day boundary arrives as the app's own ROLLOVER alarm (#228), by + 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. --> @@ -346,6 +350,8 @@ + + diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt b/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt index c11701e..e705be3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt @@ -10,6 +10,7 @@ import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceScheduler import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceWorker +import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler import de.jeanlucmakiola.floret.crash.CrashConfig import de.jeanlucmakiola.floret.crash.CrashReporter import kotlinx.coroutines.CoroutineScope @@ -44,6 +45,19 @@ class CalendulaApp : Application() { reconcileSpecialDates() reconcileCalendarVisibility() 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) + } } /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/AllDayReminderEncoding.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/AllDayReminderEncoding.kt index 94021e6..1ed2f59 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/AllDayReminderEncoding.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/AllDayReminderEncoding.kt @@ -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 * [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 - * the provider fires *after* DTSTART; this is valid and must not be clamped. - * A negative [semanticMinutes] is the "provider default" sentinel and passes - * through unchanged. + * the provider fires *after* DTSTART; this is valid and must not be clamped. The + * one negative it never is is [MINUTES_DEFAULT]: the sentinel and the encoding + * 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( semanticMinutes: Int, @@ -41,12 +45,13 @@ internal fun toProviderAllDayMinutes( zone: ZoneId, timeOfDayMinutes: Int, ): Int { - if (semanticMinutes < 0) return semanticMinutes + if (semanticMinutes == MINUTES_DEFAULT) return semanticMinutes val utcMidnight = startDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli() val fire = startDate.minusDays((semanticMinutes / MINUTES_PER_DAY).toLong()) .atTime(LocalTime.of(timeOfDayMinutes / 60, timeOfDayMinutes % 60)) .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) } +/** + * 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 * [rawMinutes] — the inverse of [toProviderAllDayMinutes], for the form and the * detail screen. Delegates to [allDayLeadDays], so the day displayed is the day * 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( rawMinutes: Int, startDate: LocalDate, zone: ZoneId, 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 +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt index 1b8507f..9417d96 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt @@ -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.ics.IcsEvent import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent +import de.jeanlucmakiola.calendula.domain.ics.semanticReminderMinutes import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt import kotlinx.datetime.toJavaLocalDate import java.time.Instant @@ -84,14 +85,25 @@ interface CalendarDataSource { */ fun eventColorPalette(calendarId: Long): List + /** + * 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 + /** * Every master/one-off event of the writable local calendars, mapped for a * whole-calendar `.ics` backup. Modified-occurrence and cancelled-exception * rows are excluded (see [EventExportProjection]). When [calendarIds] is * given, only those calendars are exported (still intersected with the * eligible set); `null` exports every eligible calendar. + * [allDayReminderTimeMinutes]: needed to write all-day reminders as whole-day + * lead times rather than raw provider offsets (see [toIcsEvent]). */ - fun exportableEvents(calendarIds: Set? = null): List + fun exportableEvents(calendarIds: Set?, allDayReminderTimeMinutes: Int): List /** * 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 - * minting one when absent); returns the new `Events._ID`. Reminders are - * written as the file's raw lead minutes (METHOD_ALERT). + * minting one when absent); returns the new `Events._ID`. + * + * [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, + ): Long /** * Create a new device-only (`ACCOUNT_TYPE_LOCAL`) calendar the app owns; @@ -232,14 +253,21 @@ interface CalendarDataSource { ): Long /** - * Change a single occurrence of a recurring event by inserting a - * modified-occurrence exception at [beginMillis] (the occurrence's - * `Instances.BEGIN`) carrying [form]'s values; returns the exception - * row's `Events._ID`. [allDayReminderTimeMinutes]: see [insertEvent]. + * Change a single occurrence of a recurring event at [beginMillis] (the + * occurrence's `Instances.BEGIN`) to [form]'s values; returns the + * `Events._ID` of the row now holding them. + * + * 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( eventId: Long, beginMillis: Long, + original: EventForm, form: EventForm, allDayReminderTimeMinutes: Int, ): Long @@ -721,7 +749,10 @@ class AndroidCalendarDataSource @Inject constructor( } } - override fun eventColorPalette(calendarId: Long): List { + override fun eventColorPalette(calendarId: Long): List = + publishedEventColors(calendarId).curatedForPicker() + + override fun publishedEventColors(calendarId: Long): List { val account = calendarAccount(calendarId) ?: return emptyList() return resolver.query( CalendarContract.Colors.CONTENT_URI, @@ -739,11 +770,13 @@ class AndroidCalendarDataSource @Inject constructor( c.mapAll { EventColorOption(key = it.getString(0).orEmpty(), argb = it.getInt(1)) } } ?.filter { it.key.isNotEmpty() } - ?.curatedForPicker() ?: emptyList() } - override fun exportableEvents(calendarIds: Set?): List { + override fun exportableEvents( + calendarIds: Set?, + allDayReminderTimeMinutes: Int, + ): List { // Only the local calendars the app owns and can write — synced calendars // already have a backup (their server). Exclude the managed special-dates // mirror calendars: their events are derived from contacts, not authored @@ -778,6 +811,7 @@ class AndroidCalendarDataSource @Inject constructor( reader.toIcsEvent( reminderMinutes = queryReminders(eventId).map { it.minutes }, calendarName = names[calendarId], + allDayReminderTimeMinutes = allDayReminderTimeMinutes, ) } } ?: emptyList() @@ -786,76 +820,83 @@ class AndroidCalendarDataSource @Inject constructor( override fun existingUids(calendarId: Long): Set = resolver.query( CalendarContract.Events.CONTENT_URI, 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.UID_2445} IS NOT NULL", + "${CalendarContract.Events.UID_2445} IS NOT NULL AND " + + "${CalendarContract.Events.DELETED} = 0", arrayOf(calendarId.toString()), null, )?.use { c -> buildSet { while (c.moveToNext()) c.getString(0)?.takeIf { it.isNotEmpty() }?.let(::add) } } ?: emptySet() - override fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long { - val startMillis = event.start.toEpochMillis() - val endMillis = event.end.toEpochMillis() - val values = ContentValues().apply { - put(CalendarContract.Events.CALENDAR_ID, calendarId) + override fun insertImportedEvent( + event: ParsedIcsEvent, + calendarId: Long, + allDayReminderTimeMinutes: Int, + colorPalette: List, + ): Long { + val values = buildImportedEventValues( + event = event, + calendarId = calendarId, // Preserve the file's UID so a re-import dedups against it; mint one // only when the source event carried none. - put( - CalendarContract.Events.UID_2445, - event.uid?.takeIf { it.isNotBlank() } ?: "${UUID.randomUUID()}@calendula", - ) - 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) + uid = event.uid?.takeIf { it.isNotBlank() } ?: "${UUID.randomUUID()}@calendula", + palette = colorPalette, + ) + val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values.toContentValues()) ?: throw WriteFailedException("import event into calendar id=$calendarId") val eventId = ContentUris.parseId(uri) - // Raw lead minutes straight from the file's VALARMs (best-effort, like insertEvent). - event.reminderMinutes.distinct().filter { it >= 0 }.forEach { minutes -> + val zone = ZoneId.systemDefault() + // 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 { put(CalendarContract.Reminders.EVENT_ID, eventId) - put(CalendarContract.Reminders.MINUTES, minutes) + put(CalendarContract.Reminders.MINUTES, providerMinutes) 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") } } 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. */ private fun calendarAccount(calendarId: Long): CalendarAccount? = resolver.query( ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarId), @@ -976,10 +1017,12 @@ class AndroidCalendarDataSource @Inject constructor( updated: EventForm, allDayReminderTimeMinutes: Int, ) { + val row = querySeriesRow(eventId) val values = buildEventUpdateValues( original = original, updated = updated, - seriesDtStartMillis = querySeriesRow(eventId).dtStartMillis, + seriesDtStartMillis = row.dtStartMillis, + seriesExdate = row.exdate, zone = ZoneId.systemDefault(), ) if (values.isNotEmpty()) { @@ -1191,9 +1234,22 @@ class AndroidCalendarDataSource @Inject constructor( override fun updateOccurrence( eventId: Long, beginMillis: Long, + original: EventForm, form: EventForm, allDayReminderTimeMinutes: Int, ): 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. val values = buildOccurrenceExceptionValues( form = form, @@ -1212,6 +1268,90 @@ class AndroidCalendarDataSource @Inject constructor( 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( eventId: Long, beginMillis: Long, @@ -1228,10 +1368,71 @@ class AndroidCalendarDataSource @Inject constructor( } // Insert the new series first: if it fails, the original is untouched. val newEventId = insertEvent(updated, allDayReminderTimeMinutes) + carrySplitExdate(newEventId, row, beginMillis, original, updated) truncateSeries(eventId, row, beginMillis) 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) { val row = querySeriesRow(eventId) // 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._SYNC_ID, CalendarContract.Events.EXDATE, + CalendarContract.Events.RDATE, ), null, null, null, )?.use { c -> @@ -1296,6 +1498,7 @@ class AndroidCalendarDataSource @Inject constructor( allDay = c.getInt(4), syncId = c.getString(5), exdate = c.getString(6), + rdate = c.getString(7), ) } else { null @@ -1311,7 +1514,16 @@ class AndroidCalendarDataSource @Inject constructor( /** Null on a local calendar (and before a synced event's first push). */ val syncId: 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]. */ fun truncationCutoff(beginMillis: Long): Long = previousLocalDayEndUtcMillis( beginMillis = beginMillis, @@ -1502,6 +1714,12 @@ class AndroidCalendarDataSource @Inject constructor( override fun deleteOccurrence(eventId: Long, beginMillis: Long) { 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) { // No _sync_id — a local calendar, or a synced event not pushed yet. // A cancelled exception can only attach to its parent through @@ -1515,6 +1733,7 @@ class AndroidCalendarDataSource @Inject constructor( occurrenceMillis = beginMillis, dtStartMillis = row.dtStartMillis, rrule = row.rrule, + rdate = row.rdate, duration = row.duration, timezone = row.timezone, allDay = row.allDay, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt index b47c83f..ac33e13 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt @@ -88,9 +88,15 @@ interface CalendarRepository { /** * 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); diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt index f7b677a..e02bc1d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.calendula.data.calendar +import android.util.Log import de.jeanlucmakiola.floret.time.toEpochMillis import de.jeanlucmakiola.calendula.data.di.IoDispatcher import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs @@ -26,6 +27,7 @@ import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.withContext import java.util.concurrent.atomic.AtomicLong import kotlin.time.Instant @@ -210,26 +212,60 @@ class CalendarRepositoryImpl @Inject constructor( } override suspend fun exportEvents(calendarIds: Set?) = - withContext(io) { dataSource.exportableEvents(calendarIds) } + withContext(io) { dataSource.exportableEvents(calendarIds, allDayReminderTimeMinutes()) } override suspend fun importEvents( targetCalendarId: Long, events: List, ): IcsImportSummary = withContext(io) { 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 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, // keeping a restore idempotent (no overwrite this pass). if (event.uid != null && event.uid in existing) { skipped++ - } else { - dataSource.insertImportedEvent(event, targetCalendarId) + continue + } + try { + dataSource.insertImportedEvent(event, targetCalendarId, allDayMinutes, palette) 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) { @@ -262,9 +298,12 @@ class CalendarRepositoryImpl @Inject constructor( override suspend fun updateOccurrence( eventId: Long, beginMillis: Long, + original: EventForm, form: EventForm, ): Long = withContext(io) { - dataSource.updateOccurrence(eventId, beginMillis, form, allDayReminderTimeMinutes()) + dataSource.updateOccurrence( + eventId, beginMillis, original, form, allDayReminderTimeMinutes(), + ) } override suspend fun updateEventFromOccurrence( @@ -288,6 +327,10 @@ class CalendarRepositoryImpl @Inject constructor( ) = withContext(io) { dataSource.deleteEventFromOccurrence(eventId, beginMillis) } + + private companion object { + const val TAG = "CalendarRepository" + } } private fun Flow.reQuery(block: suspend () -> T): Flow = flow { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt index 8e64db5..138da17 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt @@ -3,14 +3,23 @@ package de.jeanlucmakiola.calendula.data.calendar import android.provider.CalendarContract import de.jeanlucmakiola.calendula.domain.AccessLevel import de.jeanlucmakiola.calendula.domain.Availability +import de.jeanlucmakiola.calendula.domain.EventColorOption 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.toJavaLocalDateTime import java.time.Duration import java.time.Instant import java.time.ZoneId 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.LocalTime as JavaLocalTime /** Provider-ready DTSTART / DTEND / EVENT_TIMEZONE for an event write. */ internal data class EventWriteTimes( @@ -134,18 +143,21 @@ internal fun buildEventInsertValues( * * Time fields travel together (the provider validates them as a unit): * - 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** * 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 - * replaces DTEND, RRULE is written. This keeps past 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. + * replaces DTEND, RRULE is written, and the row's exclusions + * ([seriesExdate], the current EXDATE) travel with the anchor. This keeps past + * 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( original: EventForm, updated: EventForm, seriesDtStartMillis: Long, + seriesExdate: String?, zone: ZoneId, ): Map = buildMap { if (updated.title.trim() != original.title.trim()) { @@ -185,6 +197,10 @@ internal fun buildEventUpdateValues( put(CalendarContract.Events.DTEND, newTimes.dtEndMillis) put(CalendarContract.Events.RRULE, 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 { // 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) @@ -206,6 +222,14 @@ internal fun buildEventUpdateValues( put(CalendarContract.Events.DTEND, null) put(CalendarContract.Events.RRULE, updated.rrule) 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)) } +/** + * 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 * 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 * 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 - * of the same occurrence is folded away). All-day series take the `VALUE=DATE` - * form (`yyyyMMdd`), timed ones the UTC date-time form (`yyyyMMddTHHmmssZ`). + * of the same occurrence is folded away — [mergedExdate]). */ internal fun buildOccurrenceExdateValues( existingExdate: String?, occurrenceMillis: Long, dtStartMillis: Long, rrule: String?, + rdate: String?, duration: String?, timezone: String?, allDay: Int, -): Map { - val stamp = formatExdateStamp(occurrenceMillis, isAllDay = allDay != 0) - val existing = existingExdate?.split(',') - ?.map { it.trim() } - ?.filter { it.isNotEmpty() } - .orEmpty() - val merged = (existing + stamp).distinct().joinToString(",") - return mapOf( - CalendarContract.Events.EXDATE to merged, - CalendarContract.Events.DTSTART to dtStartMillis, - CalendarContract.Events.RRULE to rrule, - CalendarContract.Events.DURATION to duration, - CalendarContract.Events.EVENT_TIMEZONE to timezone, - CalendarContract.Events.ALL_DAY to allDay, - ) +): Map = mapOf( + CalendarContract.Events.EXDATE to + mergedExdate(existingExdate, occurrenceMillis, allDay != 0, timezone), + CalendarContract.Events.DTSTART to dtStartMillis, + CalendarContract.Events.RRULE to rrule, + CalendarContract.Events.RDATE to rdate, + CalendarContract.Events.DURATION to duration, + CalendarContract.Events.EVENT_TIMEZONE to timezone, + CalendarContract.Events.ALL_DAY to allDay, +) + +/** + * [existingExdate] with the occurrence at [occurrenceMillis] excluded as well. + * + * 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 = 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;`), and so does DAVx5; the + * iCalendar `TZID=;` 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 * are UTC: the provider stores an all-day DTSTART at UTC midnight, so its date * reads off the UTC calendar day. */ -private fun formatExdateStamp(occurrenceMillis: Long, isAllDay: Boolean): String { - val utc = Instant.ofEpochMilli(occurrenceMillis).atZone(ZoneOffset.UTC) - return if (isAllDay) { - "%04d%02d%02d".format(utc.year, utc.monthValue, utc.dayOfMonth) +private fun formatExdateStamp(occurrenceMillis: Long, isAllDay: Boolean): String = + formatExdateStamp( + local = Instant.ofEpochMilli(occurrenceMillis).atZone(ZoneOffset.UTC).toLocalDateTime(), + 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 { - "%04d%02d%02dT%02d%02d%02dZ".format( - utc.year, utc.monthValue, utc.dayOfMonth, - utc.hour, utc.minute, utc.second, - ) + local.atZone(zone).withZoneSameInstant(ZoneOffset.UTC).format(TIMED_EXDATE) } + +/** + * [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 * [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.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, +): Map = 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 +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapper.kt index ce8befa..d20dad8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapper.kt @@ -5,6 +5,9 @@ import de.jeanlucmakiola.calendula.domain.EventStatus import de.jeanlucmakiola.calendula.domain.ics.IcsEvent import de.jeanlucmakiola.calendula.domain.ics.deriveIcsUid 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] @@ -12,14 +15,23 @@ import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis * [calendarName] the display name of its calendar (emitted as * `X-CALENDULA-CALENDAR`). Pure given a [ColumnReader] — JVM-tested with * 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( reminderMinutes: List, calendarName: String?, + allDayReminderTimeMinutes: Int, + zone: ZoneId = ZoneId.systemDefault(), ): IcsEvent { val eventId = getLong(EventExportProjection.IDX_ID) val dtStart = getLong(EventExportProjection.IDX_DTSTART) 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 // 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(), start = dtStart.toKotlinInstantFromEpochMillis(), end = end.toKotlinInstantFromEpochMillis(), - isAllDay = getInt(EventExportProjection.IDX_ALL_DAY) != 0, + isAllDay = isAllDay, zoneId = getString(EventExportProjection.IDX_EVENT_TIMEZONE)?.takeIf { it.isNotBlank() } ?: "UTC", recurrenceRule = rrule, + exDates = if (rrule == null) { + emptyList() + } else { + exportExDates( + getString(EventExportProjection.IDX_EXDATE), + isAllDay, + getString(EventExportProjection.IDX_EVENT_TIMEZONE), + ) + }, location = getString(EventExportProjection.IDX_LOCATION), 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, availability = mapAvailability(getInt(EventExportProjection.IDX_AVAILABILITY)), 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, `;`, + * 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 { + 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() +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapper.kt index 6c72def..3a3eb92 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapper.kt @@ -1,6 +1,7 @@ package de.jeanlucmakiola.calendula.data.calendar import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis +import android.provider.CalendarContract import android.util.Log import de.jeanlucmakiola.calendula.domain.EventInstance @@ -38,5 +39,7 @@ internal fun ColumnReader.toEventInstance(): EventInstance? { isAllDay = getInt(InstanceProjection.IDX_ALL_DAY) != 0, color = color, location = getString(InstanceProjection.IDX_LOCATION), + isDeclined = getInt(InstanceProjection.IDX_SELF_ATTENDEE_STATUS) == + CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt index ef58672..b535aee 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt @@ -52,6 +52,7 @@ internal object InstanceProjection { CalendarContract.Instances.EVENT_COLOR, CalendarContract.Instances.CALENDAR_COLOR, CalendarContract.Instances.EVENT_LOCATION, + CalendarContract.Instances.SELF_ATTENDEE_STATUS, ) const val IDX_INSTANCE_ID = 0 @@ -64,6 +65,7 @@ internal object InstanceProjection { const val IDX_EVENT_COLOR = 7 const val IDX_CALENDAR_COLOR = 8 const val IDX_LOCATION = 9 + const val IDX_SELF_ATTENDEE_STATUS = 10 } internal object EventDetailProjection { @@ -119,10 +121,12 @@ internal object EventDetailProjection { /** * Master/one-off Events rows for a whole-calendar backup. Unlike * [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 - * (`ORIGINAL_ID IS NULL`), so RECURRENCE-ID overrides and EXDATEs aren't - * exported yet — a documented v1 limit (import skips them too). + * (`ORIGINAL_ID IS NULL`), so RECURRENCE-ID overrides aren't exported — a + * documented v1 limit (import skips them too). EXDATE is unaffected by that + * filter: it lives on the master row, not on an exception of its own. */ internal object EventExportProjection { val COLUMNS: Array = arrayOf( @@ -135,6 +139,7 @@ internal object EventExportProjection { CalendarContract.Events.ALL_DAY, CalendarContract.Events.EVENT_TIMEZONE, CalendarContract.Events.RRULE, + CalendarContract.Events.EXDATE, CalendarContract.Events.EVENT_LOCATION, CalendarContract.Events.DESCRIPTION, CalendarContract.Events.STATUS, @@ -151,11 +156,12 @@ internal object EventExportProjection { const val IDX_ALL_DAY = 6 const val IDX_EVENT_TIMEZONE = 7 const val IDX_RRULE = 8 - const val IDX_LOCATION = 9 - const val IDX_DESCRIPTION = 10 - const val IDX_STATUS = 11 - const val IDX_AVAILABILITY = 12 - const val IDX_CALENDAR_ID = 13 + const val IDX_EXDATE = 9 + const val IDX_LOCATION = 10 + const val IDX_DESCRIPTION = 11 + const val IDX_STATUS = 12 + const val IDX_AVAILABILITY = 13 + const val IDX_CALENDAR_ID = 14 } /** @@ -182,6 +188,7 @@ internal object SearchProjection { CalendarContract.Events.RDATE, // Excerpted, not just filtered on: a hit has to show what it matched. CalendarContract.Events.DESCRIPTION, + CalendarContract.Events.SELF_ATTENDEE_STATUS, ) const val IDX_ID = 0 @@ -197,6 +204,7 @@ internal object SearchProjection { const val IDX_RRULE = 10 const val IDX_RDATE = 11 const val IDX_DESCRIPTION = 12 + const val IDX_SELF_ATTENDEE_STATUS = 13 } /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/ProviderReminderMinutes.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/ProviderReminderMinutes.kt new file mode 100644 index 0000000..33fcafd --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/ProviderReminderMinutes.kt @@ -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>.withoutProviderDefaults(): Map> = + mapValues { (_, minutes) -> minutes.filter { it != MINUTES_DEFAULT } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt index d3baf96..a0e23b9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.calendula.data.calendar +import android.provider.CalendarContract import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis @@ -42,5 +43,7 @@ internal fun ColumnReader.toSearchResult(): EventInstance? { location = getString(SearchProjection.IDX_LOCATION), isRecurring = !getString(SearchProjection.IDX_RRULE).isNullOrEmpty() || !getString(SearchProjection.IDX_RDATE).isNullOrEmpty(), + isDeclined = getInt(SearchProjection.IDX_SELF_ATTENDEE_STATUS) == + CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderInstanceSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderInstanceSource.kt index 3b1c2e9..6b13564 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderInstanceSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderInstanceSource.kt @@ -44,9 +44,16 @@ class ProviderReminderInstanceSource @Inject constructor( // `visible` is the flag the app's one visibility model writes (#75). // The status clause mirrors CalendarDataSource.instances: NULL means // "normal", so a bare `!= CANCELED` would drop every ordinary event. + // An invitation the user declined is answered — it stays on the calendar + // struck through, but it plans nothing (#180). NULL again means "no + // answer recorded", which is not a "no". val selection = "${CalendarContract.Calendars.VISIBLE} = 1 AND " + "(${CalendarContract.Instances.STATUS} IS NULL OR " + - "${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED})" + "${CalendarContract.Instances.STATUS} != " + + "${CalendarContract.Events.STATUS_CANCELED}) AND " + + "(${CalendarContract.Instances.SELF_ATTENDEE_STATUS} IS NULL OR " + + "${CalendarContract.Instances.SELF_ATTENDEE_STATUS} != " + + "${CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED})" return context.contentResolver.query( uri, OCCURRENCE_PROJECTION, selection, null, null, )?.use { c -> diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderScanner.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderScanner.kt index a4f447f..3c89ca9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderScanner.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderScanner.kt @@ -7,6 +7,7 @@ import android.util.Log import androidx.core.content.ContextCompat import dagger.hilt.android.qualifiers.ApplicationContext import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource +import de.jeanlucmakiola.calendula.data.calendar.withoutProviderDefaults import de.jeanlucmakiola.calendula.data.di.IoDispatcher import de.jeanlucmakiola.calendula.data.prefs.ReminderStatePrefs 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 planned = planReminders( instances = occurrences, - minutesByEvent = source.reminderMinutes(occurrences.map { it.eventId }), + minutesByEvent = source.reminderMinutes(occurrences.map { it.eventId }) + .withoutProviderDefaults(), zone = ZoneId.systemDefault(), allDayTimeMinutes = settingsPrefs.allDayReminderTimeMinutes.first(), ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventColorPalette.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventColorPalette.kt index 9b44fde..8365639 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventColorPalette.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventColorPalette.kt @@ -110,3 +110,17 @@ private const val CURATION_TRIGGER_SIZE = 36 * apart that two swatches never read as the same colour in the grid. */ 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.nearestTo(argb: Int): EventColorOption? { + val target = oklchOf(argb) + return minByOrNull { oklchOf(it.argb).distanceTo(target) } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt index 4021109..e6328e5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt @@ -67,6 +67,13 @@ data class EventInstance( * Instances query already yields one row per occurrence. */ val isRecurring: Boolean = false, + /** + * This device user answered "no" to the invitation + * (`Events.SELF_ATTENDEE_STATUS`). The event stays on the calendar — it is + * still an appointment someone expects an answer about — but every surface + * strikes it through, and it plans no reminders (#180). + */ + val isDeclined: Boolean = false, ) /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsColor.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsColor.kt new file mode 100644 index 0000000..d6ddc63 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsColor.kt @@ -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 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" diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsEvent.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsEvent.kt index 6e82538..19da17b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsEvent.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsEvent.kt @@ -22,6 +22,12 @@ data class IcsEvent( val zoneId: String, /** Bare RRULE value (no `RRULE:` prefix), or null for a one-off event. */ 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 = emptyList(), val location: String? = null, val description: String? = null, /** Reminder lead times in minutes before start (raw provider offsets). */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsParser.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsParser.kt index dc7e7e3..9c5693a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsParser.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsParser.kt @@ -7,14 +7,25 @@ import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalTime import kotlinx.datetime.TimeZone import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.number import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +import kotlin.math.ceil 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], * but [uid] is nullable (an incoming event may carry none; the insert layer * then assigns one). Times are absolute instants; [isAllDay]/[zoneId] mirror * 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( val uid: String?, @@ -30,8 +41,45 @@ data class ParsedIcsEvent( val status: EventStatus = EventStatus.Confirmed, val availability: Availability = Availability.Busy, 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 = 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 = 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. */ enum class IcsParseWarning { /** 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). */ 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( @@ -52,8 +106,28 @@ data class IcsParseResult( val warnings: Set, ) -/** 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 @@ -62,6 +136,9 @@ data class IcsImportSummary(val imported: Int, val skippedDuplicate: Int) * (`RECURRENCE-ID`, attendees, unresolved `TZID`) are reported as [warnings] * rather than silently dropped. `VTIMEZONE` blocks are skipped — a `TZID` is * 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()) { @@ -69,42 +146,57 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault val lines = unfoldLines(text) val events = mutableListOf() val warnings = mutableSetOf() - 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 while (i < lines.size) { val line = parseContentLine(lines[i]) if (line == null) { i++; continue } + val component = when { + line.isBegin("VEVENT") -> "VEVENT" + line.isBegin("VTODO") -> "VTODO" + else -> null + } when { - line.isBegin("VEVENT") -> { - val end = indexOfEnd(lines, i + 1, "VEVENT") - parseVevent(lines.subList(i + 1, end), calendarName, warnings) - ?.let(events::add) + component != null -> { + val end = indexOfEnd(lines, i + 1, component) + parseComponent( + body = lines.subList(i + 1, end), + fileCalendarName = calendarName, + warnings = warnings, + isTask = component == "VTODO", + )?.let(events::add) i = end + 1 } line.isBegin("VTIMEZONE") -> { // Skipped wholesale; TZIDs resolve against the OS tz database. i = indexOfEnd(lines, i + 1, "VTIMEZONE") + 1 } - line.name == "X-WR-CALNAME" -> { - calendarName = unescapeText(line.value).trim().ifEmpty { null } - i++ - } else -> i++ } } + if (events.any { it.isTask }) warnings.add(IcsParseWarning.TasksImportedAsEvents) return IcsParseResult(events, warnings) } - private fun parseVevent( + private fun parseComponent( body: List, fileCalendarName: String?, warnings: MutableSet, + isTask: Boolean, ): ParsedIcsEvent? { var uid: String? = null var summary = "" var dtStart: IcsDateTime? = null var dtEnd: IcsDateTime? = null + var due: IcsDateTime? = null var duration: String? = null var rrule: String? = null var location: String? = null @@ -112,7 +204,11 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault var status = EventStatus.Confirmed var availability = Availability.Busy var calendarName = fileCalendarName + var category: String? = null + var eventColor: Int? = null + var calendarColor: Int? = null val reminders = mutableListOf() + val exDateLines = mutableListOf() var skipAsOverride = false var i = 0 @@ -129,9 +225,11 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault "UID" -> uid = line.value.trim().ifEmpty { null } "SUMMARY" -> summary = unescapeText(line.value) "DTSTART" -> dtStart = parseIcsDateTime(line, warnings) + "DUE" -> if (isTask) due = parseIcsDateTime(line, warnings) "DTEND" -> dtEnd = parseIcsDateTime(line, warnings) "DURATION" -> duration = line.value.trim() "RRULE" -> rrule = line.value.trim().ifEmpty { null } + "EXDATE" -> exDateLines.add(line) "LOCATION" -> location = unescapeText(line.value).ifEmpty { null } "DESCRIPTION" -> description = unescapeText(line.value).ifEmpty { null } "STATUS" -> status = mapIcsStatus(line.value) @@ -142,6 +240,19 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault "ATTENDEE" -> warnings.add(IcsParseWarning.AttendeesIgnored) "X-CALENDULA-CALENDAR" -> 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++ } @@ -150,37 +261,125 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault warnings.add(IcsParseWarning.ModifiedOccurrenceSkipped) 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 { warnings.add(IcsParseWarning.EventWithoutStartSkipped) return null } - val end = dtEnd - ?: duration?.let { - start.copy( - instant = Instant.fromEpochMilliseconds( - start.instant.toEpochMilliseconds() + parseRfc2445DurationMillis(it), - ), - ) - } - ?: start + val recurrence = sanitizeRrule(rrule) + if (recurrence.repaired) warnings.add(IcsParseWarning.RecurrenceRuleRepaired) + val end = resolveEnd(start, dtEnd, duration) return ParsedIcsEvent( uid = uid, summary = summary, start = start.instant, - end = end.instant, + end = end, isAllDay = start.isAllDay, zoneId = start.zoneId, - recurrenceRule = rrule, + recurrenceRule = recurrence.rule, location = location, description = description, reminderMinutes = reminders.distinct(), status = status, 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, start: IcsDateTime): List { + 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): Int? { val trigger = body.asSequence() .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. if (trigger.params["VALUE"].equals("DATE-TIME", true)) return null val millis = parseRfc2445DurationMillis(trigger.value) - // Negative = before start (the normal case) → positive lead minutes. - return (-millis / 60_000L).toInt().coerceAtLeast(0) + return (-millis / 60_000L).toInt() } private fun parseIcsDateTime(line: IcsContentLine, warnings: MutableSet): IcsDateTime? { @@ -218,6 +416,34 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault fun IcsContentLine.isBegin(component: String) = 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:` at/after [from], or list end. */ fun indexOfEnd(lines: List, from: Int, component: String): Int { var i = from diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsRecurrence.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsRecurrence.kt new file mode 100644 index 0000000..7f67573 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsRecurrence.kt @@ -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() + + /** 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) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsWriter.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsWriter.kt index 8d3e604..c26dd9c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsWriter.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/IcsWriter.kt @@ -46,6 +46,7 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) { appendTimes(event) event.recurrenceRule?.takeIf { it.isNotBlank() } ?.let { add("RRULE:${it.removePrefix("RRULE:")}") } + appendExDates(event) event.location?.takeIf { it.isNotBlank() } ?.let { add("LOCATION:${escapeText(it)}") } 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.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.appendAlarm(minutes: Int, summary: String) { add("BEGIN:VALARM") add("ACTION:DISPLAY") diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/ParsedIcsForm.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/ParsedIcsForm.kt index 3e8ff04..d2609a9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/ParsedIcsForm.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/ParsedIcsForm.kt @@ -13,6 +13,11 @@ import kotlinx.datetime.toLocalDateTime * calendar, exactly like a fresh create — the user confirms the target and * reviews everything before saving. Mirrors `EventDetail.toEditForm`'s all-day * 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 { val (start, end) = if (isAllDay) { @@ -31,7 +36,7 @@ fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm { end = end, location = location.orEmpty(), description = description.orEmpty(), - reminders = reminderMinutes.distinct().sorted(), + reminders = semanticReminderMinutes(), availability = availability, rrule = recurrenceRule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() }, ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/reminders/ReminderPlan.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/reminders/ReminderPlan.kt index ef17d08..559cc51 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/reminders/ReminderPlan.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/reminders/ReminderPlan.kt @@ -63,6 +63,12 @@ private const val MINUTES_PER_DAY = 1_440 * for *which day* it means ([allDayLeadDays]) and take the hour from * [allDayTimeMinutes], recomposed against each occurrence's own date in [zone]. * 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( instances: List, @@ -70,17 +76,19 @@ fun planReminders( zone: ZoneId, allDayTimeMinutes: Int, ): List = instances.flatMap { instance -> - minutesByEvent[instance.eventId].orEmpty().distinct().map { minutes -> - PlannedReminder( - instance = instance, - minutes = minutes, - alarmMillis = if (instance.isAllDay) { - allDayAlarmMillis(instance.beginMillis, minutes, zone, allDayTimeMinutes) - } else { - instance.beginMillis - minutes * MILLIS_PER_MINUTE - }, - ) - } + minutesByEvent[instance.eventId].orEmpty() + .distinct() + .map { minutes -> + PlannedReminder( + instance = instance, + minutes = minutes, + alarmMillis = if (instance.isAllDay) { + 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. */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt index fa32b89..2ef93d5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -319,9 +319,10 @@ fun CalendarHost( EventMoveScope( movableCalendarIds = movableCalendarIds, dragEnabled = dragToReschedule, - move = reschedule::move, + move = { reschedule.move(it) }, inFlight = reschedule.inFlight, undoStarted = reschedule.undoStarted, + abandoned = reschedule.abandoned, edit = onEditEvent, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRows.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRows.kt index f2fdd95..4b72c36 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRows.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRows.kt @@ -24,11 +24,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha +import de.jeanlucmakiola.calendula.ui.common.declinedTitle import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.eventAccent @@ -129,8 +131,8 @@ internal fun AgendaEventRow( val title = event.title.ifBlank { stringResource(R.string.event_untitled) } GroupedRow( modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier, - title = title, - summary = agendaTimeSummary(event, day, zone), + title = declinedTitle(title, event.isDeclined), + summary = AnnotatedString(agendaTimeSummary(event, day, zone)), position = position, minHeight = 64.dp, leading = { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventChipShape.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventChipShape.kt new file mode 100644 index 0000000..6bc0d12 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventChipShape.kt @@ -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, + ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveScope.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveScope.kt index c338e40..c49456b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveScope.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveScope.kt @@ -35,8 +35,12 @@ class EventMoveScope( * accidental drags the setting exists to stop. */ 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 * dialog is up. A flow rather than a value so this scope stays the same @@ -45,6 +49,12 @@ class EventMoveScope( val inFlight: StateFlow, /** Ticks when an undo write begins — see `RescheduleViewModel.undoStarted`. */ val undoStarted: StateFlow, + /** + * 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, /** Open an event in the edit form — the pointer-free route to the same change. */ val edit: (EventInstance) -> Unit, ) { @@ -57,6 +67,8 @@ private val NEVER_IN_FLIGHT = MutableStateFlow(false) 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. * The drag overlays hold a landed block on its target for this window. @@ -67,6 +79,17 @@ fun moveInFlight(): Boolean { 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 * this composable came on screen. diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGutter.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGutter.kt index 2a4beec..cf4449e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGutter.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGutter.kt @@ -36,6 +36,14 @@ val GUTTER_WIDTH = 48.dp */ val GUTTER_CONTENT_START_INSET = 8.dp +/** + * End inset for everything that lines up with the day columns — the week's day + * header, the all-day strip and the timeline itself. Without it the last column + * runs flush into the screen edge while the gutter gives the other side room + * (#192); 8dp mirrors [GUTTER_CONTENT_START_INSET]. + */ +val TIMELINE_CONTENT_END_INSET = 8.dp + private val BADGE_HEIGHT = 20.dp /** How far the fixed hour labels recede while a block is being dragged. */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/PastEvents.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/PastEvents.kt index d2b9056..0e73450 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/PastEvents.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/PastEvents.kt @@ -1,6 +1,10 @@ package de.jeanlucmakiola.calendula.ui.common import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextDecoration import kotlin.time.Instant /** @@ -14,3 +18,30 @@ val LocalDimCutoff = compositionLocalOf { null } /** Opacity applied to a completed/past event chip when it is dimmed. */ const val EventDimAlpha = 0.4f + +/** + * How a declined invitation's title is struck through (#180) — the mark every + * surface uses to say "you answered no", chosen over hiding the event because it + * is still something the organiser expects you at. Null for everything else, so + * it drops straight into a `Text`'s `textDecoration`. + */ +fun declinedDecoration(isDeclined: Boolean): TextDecoration? = + if (isDeclined) TextDecoration.LineThrough else null + +/** [declinedDecoration] for the rows that take styled text rather than a `String`. */ +fun declinedTitle(title: String, isDeclined: Boolean): AnnotatedString = if (isDeclined) { + AnnotatedString(title, SpanStyle(textDecoration = TextDecoration.LineThrough)) +} else { + AnnotatedString(title) +} + +/** [declinedTitle] over already-styled text, e.g. a search hit's marked runs. */ +fun declinedTitle(title: AnnotatedString, isDeclined: Boolean): AnnotatedString = + if (!isDeclined) { + title + } else { + buildAnnotatedString { + append(title) + addStyle(SpanStyle(textDecoration = TextDecoration.LineThrough), 0, title.length) + } + } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ReminderFormatting.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ReminderFormatting.kt index 1ee9778..3812dee 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ReminderFormatting.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ReminderFormatting.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.data.calendar.MINUTES_DEFAULT import de.jeanlucmakiola.floret.reminders.ReminderUnit /** 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 - * line: "Default reminder" (negative = the provider default), "At time of - * event" (0), "10 minutes before", "1 hour before", … Shared by the detail - * screen, the event form and the default-reminder settings so the wording - * never drifts. + * line: "Default reminder" ([MINUTES_DEFAULT]), "At time of event" (0), + * "10 minutes before", "1 hour before", … Shared by the detail screen, the event + * form and the default-reminder settings so the wording 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 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_after, durationLabel(-minutes)) minutes % 10_080 == 0 -> pluralStringResource(R.plurals.reminder_weeks, minutes / 10_080, minutes / 10_080) minutes % 1_440 == 0 -> diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt index 21d6257..003506b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt @@ -140,6 +140,17 @@ class RescheduleViewModel @Inject constructor( */ val inFlight: StateFlow = _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 = _abandoned.asStateFlow() + 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 - * be written and the caller must let its held copy go now — waiting on - * [inFlight] would strand it until the settle timeout instead. + * be written; [abandoned] ticks for that refusal too, so a held copy is let + * go by the one signal rather than by every caller remembering to. + * + * The Boolean is diagnostic — the tests read it, [EventMoveScope] does not + * hand it on. A caller that gated on it would be back to releasing its copy + * from two places, and it answers before the write is resolved anyway. */ fun move(request: MoveRequest): Boolean { - if (busy || _scopePrompt.value != null) return false + if (busy || _scopePrompt.value != null) { + _abandoned.value += 1 + return false + } busy = true viewModelScope.launch { val prepared = prepare(request) if (prepared == null) { + _abandoned.value += 1 busy = false return@launch } @@ -231,6 +250,7 @@ class RescheduleViewModel @Inject constructor( pending = null busy = false _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 * than closing and reopening. False when another write is already running — * the chip hides its action for that window, so this is the backstop. + * + * [abandoned] ticks once an inverse that *started* fails to land: [undoStarted] + * has sent the chip back on its journey by then and nothing else will seat it. + * A refusal ticks nothing — it launched no journey of its own, and the counter + * is global, so a tick here would release some other drop's held copy before + * the grid has drawn it. */ fun undo(undo: MoveUndo): Boolean { if (busy) return false @@ -256,6 +282,9 @@ class RescheduleViewModel @Inject constructor( } catch (e: Exception) { MoveOutcome.Failed } + // Read off the outcome, the way [write] does — no second copy of the + // same fact to drift from it. + if (_outcome.value !is MoveOutcome.Undone) _abandoned.value += 1 busy = false } return true @@ -340,6 +369,7 @@ class RescheduleViewModel @Inject constructor( val request = prepared.request if (endsBeforeItStarts(prepared, scope)) { _outcome.value = MoveOutcome.BlockedSeriesEnd + _abandoned.value += 1 return } _outcome.value = try { @@ -351,6 +381,7 @@ class RescheduleViewModel @Inject constructor( repository.updateOccurrence( request.eventId, request.beginMillis, + prepared.original, prepared.updated, ) @@ -375,6 +406,9 @@ class RescheduleViewModel @Inject constructor( } catch (e: Exception) { MoveOutcome.Failed } + // Nothing landed, so the copy the view is holding has nothing to fade + // into — let it go now rather than at the end of the settle timeout. + if (_outcome.value !is MoveOutcome.Moved) _abandoned.value += 1 } /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt index c62c8f6..c05c976 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt @@ -25,6 +25,7 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.LayoutCoordinates 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.locale.currentLocale import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.StateFlow +import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.LocalDate +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.plus import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalTime import kotlinx.datetime.TimeZone @@ -66,17 +71,148 @@ private val AUTO_SCROLL_STEP = 16.dp data class TimelineDrag( val event: EventInstance, 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 endMin: Int, - val topLeftInRoot: Offset, - val sizePx: IntSize, + /** The event's new start, from [date]'s midnight — negative if it began earlier. */ + val eventStartMin: Int, + val eventSpanMin: Int, + /** The event as the grid would draw it, one piece per day column it covers. */ + val pieces: List, + /** + * 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. */ val slot: Pair 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 { + 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 @@ -134,12 +270,14 @@ class TimelineDragController { private set /** - * Where the settled drop came from, and which event row it belongs to. The - * instance id alone can't identify the source block for the length of the - * write: the provider regenerates `Instances` rows, so a re-read carrying the - * *old* time can arrive under a new instance id. + * The event row and start instant the settled drop came from. The instance id + * alone can't identify the source for the length of the write: the provider + * regenerates `Instances` rows, so a re-read carrying the *old* time can + * 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? by mutableStateOf(null) + private var settledOrigin: Pair? by mutableStateOf(null) /** Whether a finger is on a block right now — [settling] is not dragging. */ var isDragging: Boolean by mutableStateOf(false) @@ -153,18 +291,42 @@ class TimelineDragController { var settledOnGrid: Boolean by mutableStateOf(false) private set + /** + * The abandoned-move counter, read at the drop so the mark can't be a + * composition behind the tick it is compared against. + */ + var abandonedTicks: StateFlow? = 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 grab = 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 * as its start, since the target snaps to the grid (09:07 lifts to 09:00). */ private var originSlot: Pair? = null - fun begin(block: TimedBlock, pointerInRoot: Offset, blockInRoot: Offset) { + fun begin( + block: TimedBlock, + clipOffsetMin: Int, + pointerInRoot: Offset, + blockInRoot: Offset, + ) { source = block + this.clipOffsetMin = clipOffsetMin settling = null isDragging = true liftedInstanceId = block.event.instanceId @@ -182,6 +344,7 @@ class TimelineDragController { fun cancel() { source = null + clipOffsetMin = 0 isDragging = false liftedInstanceId = null originSlot = null @@ -192,32 +355,41 @@ class TimelineDragController { } /** - * Whether [block] is the one whose copy is in flight, and so must stay a - * ghost: the instance the finger picked up, or once dropped whatever now - * sits in the slot it left. + * Whether [block] is part of the event whose copy is in flight, and so must + * stay a ghost: the instance the finger picked up, or once dropped whatever + * 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 (block.event.instanceId == liftedInstanceId) return true - val (eventId, originDate, originMin) = settledOrigin ?: return false - return block.event.eventId == eventId && - date == originDate && - block.startMin == originMin + val (eventId, start) = settledOrigin ?: return false + return block.event.eventId == eventId && block.event.start == start } /** * 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 - * 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) { 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 -> - block.startMin == landed.startMin && + block.startMin == slice.startMin && ( 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? { val landed = drag val origin = originSlot + val clipOffset = clipOffsetMin cancel() 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 - settledOrigin = origin?.let { (date, min) -> Triple(landed.event.eventId, date, min) } - return TimelineDrop(landed.event, landed.date, landed.startMin) + settledOrigin = landed.event.eventId to landed.event.start + settledAbandonedMark = abandonedTicks?.value ?: 0 + return TimelineDrop(landed.event, landed.date, landed.startMin, clipOffset) } /** @@ -271,28 +449,47 @@ class TimelineDragController { val origin = grid.positionInRoot() val rawMinutes = (pointer.y - grab.y - origin.y) / hourPx * 60f val snapped = (rawMinutes / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES - // Clamp the start into the target day; a tail past midnight is fine. - val startMin = snapped.coerceIn(0, MINUTES_PER_DAY - DRAG_SNAP_MINUTES) - // The event's own length, not the block's: TimedBlock.endMin is clipped - // at midnight, which would draw a 22:00–02:00 event as a two-hour copy. - val span = (block.event.end - block.event.start).inWholeMinutes.toInt().coerceAtLeast(0) - // The column the finger is over on screen, and the day that column shows. + val eventSpan = (block.event.end - block.event.start).inWholeMinutes.toInt() + .coerceAtLeast(0) + val floor = dragFloorMin(clipOffsetMin, eventSpan) + val startMin = snapped.coerceIn(floor, MINUTES_PER_DAY - DRAG_SNAP_MINUTES) + val eventStartMin = startMin - clipOffsetMin val column = ((pointer.x - origin.x) / columnPx).toInt().coerceIn(0, days.lastIndex) 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( event = block.event, date = days[dayIndex], startMin = startMin, - endMin = startMin + span, - topLeftInRoot = Offset( - x = origin.x + column * columnPx, - y = origin.y + startMin / 60f * hourPx, - ), - sizePx = IntSize( - (columnPx - geometry.columnGapPx).roundToInt(), - height.roundToInt(), - ), + eventStartMin = eventStartMin, + eventSpanMin = eventSpan, + // Bounded to the columns this timeline actually shows: a day it + // doesn't has no piece to draw. The write is unaffected. + pieces = slices + .map { slice -> + val day = dayIndex + slice.dayOffset + 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 controller.geometry.edgePx = with(density) { AUTO_SCROLL_EDGE.toPx() } controller.geometry.stepPx = with(density) { AUTO_SCROLL_STEP.toPx() } + controller.abandonedTicks = LocalEventMove.current?.abandoned return controller } /** - * True when [block] actually begins on [day] rather than being the tail of an - * event that started earlier. A clipped block's top edge is midnight, not the - * event's start, so dragging it would move the event to a time it never had. + * How far this block's top edge sits past its event's start, in minutes — zero + * for a block that begins on [day], and the part already run for the tail of an + * 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 = - event.start.toLocalDateTime(zone).date == day +fun TimedBlock.clipOffsetMinutes(day: LocalDate, zone: TimeZone): Int { + 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 - * 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. + * True when the event began before [day] — this block is its tail, cut at + * midnight rather than starting where the event does. */ -fun TimelineDrop.startInstant(zone: TimeZone): Instant = - LocalDateTime(date, LocalTime(startMin / 60, startMin % 60)).toInstant(zone) +fun TimedBlock.continuesBefore(day: LocalDate, zone: TimeZone): Boolean = + 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 @@ -384,7 +609,6 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = val use24Hour = LocalUse24HourFormat.current val locale = currentLocale() val reduceMotion = rememberReduceMotion() - val density = LocalDensity.current val moveInFlight = moveInFlight() // 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 // drop itself. 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 - 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 controller.handOver() delay(SETTLE_FADE_MILLIS.toLong()) @@ -428,51 +657,86 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = ) val fill = eventFill(drag.event.color, dark, soften) val title = drag.event.title.ifBlank { stringResource(R.string.event_untitled) } - // An end past midnight wraps rather than saturating, so a four-hour - // event dragged to 22:00 reads "22:00–02:00" and not "22:00–24:00". - val endMin = if (drag.endMin > MINUTES_PER_DAY) { - drag.endMin % MINUTES_PER_DAY - } else { - drag.endMin - } - val label = "${formatMinuteOfDay(drag.startMin, use24Hour, locale)}–" + + // Read off the event rather than the held block, so grabbing either half + // of an event that crosses midnight names the same hours. An end past + // midnight wraps rather than saturating, so a four-hour event dragged to + // 22:00 reads "22:00–02:00" and not "22:00–24:00". + val startMin = drag.eventStartMin.mod(MINUTES_PER_DAY) + val rawEnd = startMin + drag.eventSpanMin + val endMin = if (rawEnd > MINUTES_PER_DAY) rawEnd % MINUTES_PER_DAY else rawEnd + val label = "${formatMinuteOfDay(startMin, use24Hour, locale)}–" + formatMinuteOfDay(endMin, use24Hour, locale) - 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( - (drag.topLeftInRoot.x - origin.x).roundToInt(), - (drag.topLeftInRoot.y - origin.y).roundToInt(), - ) - } - .size( - width = with(density) { drag.sizePx.width.toDp() }, - height = with(density) { drag.sizePx.height.toDp() }, - ) - .padding(horizontal = 1.dp) - .graphicsLayer { - scaleX = 1f + 0.02f * lift - scaleY = 1f + 0.02f * lift - shadowElevation = 8.dp.toPx() * lift - alpha = copyAlpha - shape = RoundedCornerShape(4.dp) - clip = false - } - .background(fill, RoundedCornerShape(4.dp)) - .padding(horizontal = 4.dp, vertical = 2.dp), - ) { - Column { - val titleOverflow = eventTitleOverflow() - Text( - text = title, - style = MaterialTheme.typography.labelMedium, - maxLines = 1, - overflow = titleOverflow.overflow, - softWrap = titleOverflow.softWrap, - color = eventInk(fill, alpha = 0.85f), + // The tallest piece carries the range: on the smallest it would be + // clipped away, which is exactly the case when a short tail is held. + val labelled = drag.pieces.indices.maxByOrNull { drag.pieces[it].sizePx.height } + drag.pieces.forEachIndexed { index, piece -> + DragCopy( + topLeftInRoot = piece.topLeftInRoot, + overlayOrigin = origin, + sizePx = piece.sizePx, + fill = fill, + shape = timedBlockShape(piece.continuesBefore, piece.continuesAfter), + lift = lift, + alpha = copyAlpha, + title = title, + // Once for the whole event: repeated, it would name it per day. + label = label.takeIf { index == labelled }, + ) + } + } +} + +/** One piece of a dragged block, floating over the calendar. */ +@Composable +private fun DragCopy( + topLeftInRoot: Offset, + overlayOrigin: Offset, + sizePx: IntSize, + fill: Color, + shape: RoundedCornerShape, + lift: Float, + alpha: Float, + title: String, + label: String?, +) { + 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 { + val titleOverflow = eventTitleOverflow() + Text( + text = title, + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = titleOverflow.overflow, + softWrap = titleOverflow.softWrap, + color = eventInk(fill, alpha = 0.85f), + ) + if (label != null) { Text( text = label, style = MaterialTheme.typography.labelSmall, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt index e912c83..c2ab537 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt @@ -90,7 +90,10 @@ import de.jeanlucmakiola.calendula.ui.common.NowLine import de.jeanlucmakiola.calendula.ui.common.TimelineDragController import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay 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.eventMoveAction import de.jeanlucmakiola.calendula.ui.common.eventTitleOverflow @@ -104,8 +107,10 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.calendula.ui.common.next 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.eventInk +import de.jeanlucmakiola.calendula.ui.common.declinedDecoration import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat @@ -118,6 +123,7 @@ import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH import de.jeanlucmakiola.calendula.ui.common.HourGutter +import de.jeanlucmakiola.calendula.ui.common.TIMELINE_CONTENT_END_INSET import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay import de.jeanlucmakiola.calendula.ui.week.TimedBlock @@ -350,7 +356,7 @@ private fun DayContent( onEventClick = onEventClick, onCreateAt = onCreateAt, onDrop = { drop -> - val took = move?.move( + move?.move( MoveRequest( eventId = drop.event.eventId, beginMillis = drop.event.start.toEpochMilliseconds(), @@ -358,9 +364,6 @@ private fun DayContent( 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() }, ) } @@ -478,7 +481,11 @@ private fun AllDayStrip( // Height is hoisted + animated so it resizes smoothly; padding sits // inside it so the content area is lanes * row height. .height(height) - .padding(vertical = ALL_DAY_VERTICAL_PADDING), + .padding( + top = ALL_DAY_VERTICAL_PADDING, + bottom = ALL_DAY_VERTICAL_PADDING, + end = TIMELINE_CONTENT_END_INSET, + ), ) { // Keep the gutter-width offset so the bars line up with the day column. Spacer(Modifier.width(GUTTER_WIDTH)) @@ -520,7 +527,7 @@ private fun AllDayBar( val fill = eventFill(event.color, dark, soften) Box( modifier = modifier - .background(fill, RoundedCornerShape(4.dp)) + .background(fill, EventChipShape) .clickable(onClick = onClick) .padding(horizontal = 6.dp, vertical = 2.dp) .semantics { contentDescription = title }, @@ -534,6 +541,7 @@ private fun AllDayBar( overflow = titleOverflow.overflow, softWrap = titleOverflow.softWrap, color = eventInk(fill), + textDecoration = declinedDecoration(event.isDeclined), ) } } @@ -579,6 +587,7 @@ private fun Timeline( modifier = Modifier .weight(1f) .fillMaxHeight() + .padding(end = TIMELINE_CONTENT_END_INSET) .clip(RoundedCornerShape(16.dp)) .verticalScroll(scrollState) .onGloballyPositioned { dragController.geometry.viewport = it }, @@ -745,24 +754,30 @@ private fun EventBlock( val fill = eventFill(block.event.color, dark, soften) val zone = remember { TimeZone.currentSystemDefault() } val moveAction = eventMoveAction(block.event) - // A block clipped at the top continues from the previous day: its top edge - // is midnight, not the event's start, so dragging it would invent a time. - val draggable = eventDragAllowed(block.event) && block.beginsOn(date, zone) + val draggable = eventDragAllowed(block.event) + // The drop takes this offset back off, so a tail clipped at midnight lands + // 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( enabled = draggable, key = block.event.instanceId, - onPickUp = { pointer, blockRoot, _ -> dragController.begin(block, pointer, blockRoot) }, + onPickUp = { pointer, blockRoot, _ -> + dragController.begin(block, clipOffset, pointer, blockRoot) + }, onMove = dragController::move, onDrop = { dragController.finish()?.let(onDrop) }, onCancel = dragController::cancel, ) - val lifted = draggable && dragController.ghosts(block, date) + val lifted = draggable && dragController.ghosts(block) val ghost = ghostAlpha(lifted) Box( modifier = modifier // The source stays put as a ghost while its floating copy travels. .then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier) - .background(fill, RoundedCornerShape(4.dp)) + .background(fill, shape) .clickable(onClick = onClick) // After clickable, so it is the inner node and wins the main pass; // the tap still works, since a drag consumes the up. @@ -783,6 +798,7 @@ private fun EventBlock( overflow = titleOverflow.overflow, softWrap = titleOverflow.softWrap, color = eventInk(fill, alpha = 0.85f), + textDecoration = declinedDecoration(block.event.isDeclined), ) } if (showTime) { @@ -803,7 +819,12 @@ private fun DayLoading() { // Same scale resolution as the loaded timeline, so the skeleton's column // doesn't resize the moment the real day arrives. val totalHeight = scale.hourHeight(maxHeight) * 24 - Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(end = TIMELINE_CONTENT_END_INSET) + .verticalScroll(scrollState), + ) { Spacer(Modifier.width(GUTTER_WIDTH)) Box( modifier = Modifier diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt index 353a9ea..cd068cb 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt @@ -48,9 +48,14 @@ class EventDetailViewModel @Inject constructor( ) : ViewModel() { private val _target = MutableStateFlow(null) - // Bumped by retry() to re-run the load for the same target. + // Bumped by retry() and by re-opening the target already shown, to re-run + // the load without changing the target. private val _reload = MutableStateFlow(0) + // Last target whose content is already on screen; a re-read of it skips the + // Loading skeleton so the sheet doesn't blank out between two identical reads. + private var loadedTarget: Target? = null + private val _deleteState = MutableStateFlow(DeleteUiState.Idle) val deleteState: StateFlow = _deleteState.asStateFlow() @@ -58,11 +63,14 @@ class EventDetailViewModel @Inject constructor( combine(_target, _reload) { target, _ -> target } .flatMapLatest { target -> if (target == null) { + loadedTarget = null flowOf(EventDetailUiState.Loading) } else { flow { - emit(EventDetailUiState.Loading) - emit(loadDetail(target)) + if (loadedTarget != target) emit(EventDetailUiState.Loading) + val loaded = loadDetail(target) + loadedTarget = target.takeIf { loaded is EventDetailUiState.Success } + emit(loaded) } } } @@ -78,9 +86,15 @@ class EventDetailViewModel @Inject constructor( * the occurrence's own times (from `CalendarContract.Instances`); they * override the series DTSTART/DTEND so recurring events show the correct * date instead of the first occurrence. + * + * Re-opening the *same* occurrence always re-reads it. The view model + * outlives the sheet, and a `StateFlow` conflates an identical value away, + * so assigning the target alone would leave an edit that changed no time — + * adding a description, say (#196) — showing the state from before the save. */ fun open(eventId: Long, beginMillis: Long, endMillis: Long) { - _target.value = Target(eventId, beginMillis, endMillis) + val target = Target(eventId, beginMillis, endMillis) + if (_target.value == target) _reload.value += 1 else _target.value = target } /** Re-run the current load after a failure. */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index 4f36992..034935c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -64,6 +64,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface @@ -264,13 +265,16 @@ fun EventEditScreen( viewModel.reset() 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 -> { viewModel.consumeSaveResult() - snackbarHostState.showSnackbar(saveFailedMessage) + snackbarHostState.showSnackbar(saveFailedMessage, duration = SnackbarDuration.Long) } SaveUiState.NeedsPermission -> { viewModel.consumeSaveResult() - snackbarHostState.showSnackbar(writeDeniedMessage) + snackbarHostState.showSnackbar(writeDeniedMessage, duration = SnackbarDuration.Long) } // AwaitingScope/AwaitingConflict/Gone render as dialogs below. else -> Unit diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt index 2c3dc61..503db44 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.calendula.ui.edit +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel @@ -56,6 +57,8 @@ import kotlin.time.Duration.Companion.minutes import kotlin.time.Instant import javax.inject.Inject +private const val TAG = "EventEdit" + /** * Where a prefilled [EventEditViewModel.openImported] form came from. The sources * want different reminder handling (#49), and differ in whether they own the @@ -734,7 +737,12 @@ class EventEditViewModel @Inject constructor( } else { when (scope) { RecurringWriteScope.ThisEvent -> - repository.updateOccurrence(target.eventId, target.beginMillis, form) + repository.updateOccurrence( + target.eventId, + target.beginMillis, + target.original, + form, + ) RecurringWriteScope.ThisAndFollowing -> repository.updateEventFromOccurrence( eventId = target.eventId, @@ -751,7 +759,15 @@ class EventEditViewModel @Inject constructor( throw e } catch (e: SecurityException) { 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) { + // 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 } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt index 56e6602..a3b7690 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.PriorityHigh import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api @@ -93,15 +94,11 @@ fun ImportScreen( } // 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 - // first *local* calendar — the first row the picker shows ("Your calendars" - // group leads) — so the pre-selection lines up with the top of the list; - // 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. + // read it without the user scrolling to a bottom button (see + // [defaultImportTarget] for the choice). Re-defaults when the "many" list + // first arrives (keyed on it), then holds the pick. val many = state as? ImportUiState.Many - val defaultTarget = many?.calendars?.let { cals -> - (cals.firstOrNull { it.isLocal } ?: cals.firstOrNull())?.id - } + val defaultTarget = many?.let { defaultImportTarget(it.calendars, it.fileCalendarName) } var selected by rememberSaveable(defaultTarget) { mutableStateOf(defaultTarget) } 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( Modifier.fillMaxSize().padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally, @@ -236,22 +237,43 @@ private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) { scaleY = badgeScale.value } .clip(CircleShape) - .background(MaterialTheme.colorScheme.primaryContainer), + .background( + if (stopped) { + MaterialTheme.colorScheme.errorContainer + } else { + MaterialTheme.colorScheme.primaryContainer + }, + ), contentAlignment = Alignment.Center, ) { Icon( - Icons.Default.Check, + if (stopped) Icons.Default.PriorityHigh else Icons.Default.Check, contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimaryContainer, + tint = if (stopped) { + MaterialTheme.colorScheme.onErrorContainer + } else { + MaterialTheme.colorScheme.onPrimaryContainer + }, modifier = Modifier.size(56.dp), ) } Spacer(Modifier.height(24.dp)) 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, 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) { Spacer(Modifier.height(8.dp)) Text( @@ -261,6 +283,21 @@ private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) { 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)) Row( Modifier.fillMaxWidth(), @@ -290,6 +327,19 @@ private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) { 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)) 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 private fun RowScope.ImportStatCard( count: Int, @@ -343,6 +393,9 @@ private fun WarningText(warning: IcsParseWarning) { IcsParseWarning.EventWithoutStartSkipped -> stringResource(R.string.import_warning_no_start) IcsParseWarning.AttendeesIgnored -> stringResource(R.string.import_warning_attendees) 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, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportViewModel.kt index 723132b..46a18a2 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/imports/ImportViewModel.kt @@ -43,6 +43,12 @@ sealed interface ImportUiState { val events: List, val warnings: Set, val calendars: List, + /** + * 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 data class Done(val summary: IcsImportSummary) : ImportUiState @@ -92,6 +98,7 @@ class ImportViewModel @Inject constructor( warnings = parsed.warnings, calendars = repository.calendars().first() .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): 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, + 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 +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthDrag.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthDrag.kt index f3dcf3c..809a618 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthDrag.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthDrag.kt @@ -13,6 +13,8 @@ import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.unit.IntSize import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.ui.common.LocalEventMove +import kotlinx.coroutines.flow.StateFlow import kotlinx.datetime.LocalDate import kotlin.math.abs @@ -33,6 +35,8 @@ class MonthRowGeometry( val isRtl: Boolean, /** What this row currently draws in [lane] of column `col`, or null. */ 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]. */ 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. */ fun seatOf(event: EventInstance, date: LocalDate): Offset? { - 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 + val (col, lane) = chipSeat(days, laneCount, chipAt, chipStart, event, date) ?: return null 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, + laneCount: Int, + chipAt: (col: Int, lane: Int) -> EventInstance?, + chipStart: (col: Int, lane: Int) -> Int, + event: EventInstance, + date: LocalDate, +): Pair? { + 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 * 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 = - chip.eventId == moved.eventId || chip.title == moved.title +internal fun isSameEvent(chip: EventInstance, moved: EventInstance): Boolean = + 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. */ data class MonthChipDrag( @@ -134,6 +160,16 @@ class MonthDragController { */ 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? = 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 grabDate: LocalDate? = null private var grab = Offset.Zero @@ -222,6 +258,7 @@ class MonthDragController { liftedInstanceId = last.event.instanceId settledGhost = true settledInRoot = null + settledAbandonedMark = abandonedTicks?.value ?: 0 return true } @@ -242,6 +279,7 @@ class MonthDragController { liftedInstanceId = landed.event.instanceId settledGhost = true undoable = settling + settledAbandonedMark = abandonedTicks?.value ?: 0 return MonthChipDrop(landed.event, landed.grabDate, target) } @@ -298,6 +336,10 @@ class MonthDragController { } @Composable -fun rememberMonthDragController(): MonthDragController = remember { MonthDragController() } +fun rememberMonthDragController(): MonthDragController { + val controller = remember { MonthDragController() } + controller.abandonedTicks = LocalEventMove.current?.abandoned + return controller +} val LocalMonthDrag = compositionLocalOf { null } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index e77e858..ae4f177 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -19,6 +19,8 @@ import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement @@ -94,6 +96,7 @@ import de.jeanlucmakiola.calendula.ui.common.MoveTarget import de.jeanlucmakiola.calendula.ui.common.MoveRequest import de.jeanlucmakiola.calendula.ui.common.LocalEventMove import de.jeanlucmakiola.calendula.ui.common.moveInFlight +import de.jeanlucmakiola.calendula.ui.common.abandonedMoves import de.jeanlucmakiola.calendula.ui.common.OnUndoStarted import de.jeanlucmakiola.calendula.ui.common.ghostAlpha import de.jeanlucmakiola.calendula.ui.common.DragSnapHaptics @@ -112,6 +115,7 @@ import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource @@ -140,9 +144,12 @@ import de.jeanlucmakiola.calendula.ui.common.CALENDAR_SWIPE_THRESHOLD import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha +import de.jeanlucmakiola.calendula.ui.common.declinedDecoration import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors 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.eventInk import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute @@ -447,6 +454,7 @@ fun MonthScreen( showWeekNumbers = showWeekNumbers, onRetry = jumpToToday, onOpenDay = onOpenDay, + onEventClick = onEventClick, ) } else if (viewStyle == MonthViewStyle.Split) { SplitMonthContent( @@ -471,6 +479,7 @@ fun MonthScreen( onSwipePrev = goPrev, onRetry = jumpToToday, onOpenDay = onOpenDay, + onEventClick = onEventClick, ) } } @@ -507,11 +516,14 @@ private fun MonthDragOverlay(controller: MonthDragController) { val glide = remember { Animatable(Offset.Zero, Offset.VectorConverter) } val glideSpec = MaterialTheme.motionScheme.fastSpatialSpec() val seat = controller.settledInRoot - LaunchedEffect(controller.settling, moveInFlight, seat) { + val abandoned = abandonedMoves() + LaunchedEffect(controller.settling, moveInFlight, seat, abandoned) { val landed = controller.settling if (landed == null || moveInFlight) return@LaunchedEffect 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 { if (!gliding) glide.snapTo(landed.topLeftInRoot) gliding = true @@ -565,7 +577,7 @@ private fun MonthDragOverlay(controller: MonthDragController) { scaleY = 1f + 0.04f * lift shadowElevation = 8.dp.toPx() * lift alpha = copyAlpha - shape = RoundedCornerShape(4.dp) + shape = EventChipShape clip = false }, ) @@ -581,6 +593,7 @@ private fun MonthContent( onSwipePrev: () -> Unit, onRetry: () -> Unit, onOpenDay: (LocalDate) -> Unit, + onEventClick: (EventInstance) -> Unit, ) { val slideSpec = rememberCalendarSlideSpec() val fadeSpec = rememberCalendarFadeSpec() @@ -607,6 +620,7 @@ private fun MonthContent( state = s, showWeekNumbers = showWeekNumbers, onOpenDay = onOpenDay, + onEventClick = onEventClick, ) } } @@ -625,6 +639,7 @@ private fun ContinuousMonthContent( showWeekNumbers: Boolean, onRetry: () -> Unit, onOpenDay: (LocalDate) -> Unit, + onEventClick: (EventInstance) -> Unit, ) { when (state) { // The scrolling styles get their own skeleton rather than the paged @@ -639,6 +654,7 @@ private fun ContinuousMonthContent( listState = listState, showWeekNumbers = showWeekNumbers, onOpenDay = onOpenDay, + onEventClick = onEventClick, ) } else { ContinuousMonthGrid( @@ -646,6 +662,7 @@ private fun ContinuousMonthContent( listState = listState, showWeekNumbers = showWeekNumbers, onOpenDay = onOpenDay, + onEventClick = onEventClick, ) } } @@ -747,7 +764,8 @@ private val CELL_SHAPE = RoundedCornerShape(CELL_CORNER) /** Width of the split style's selected-day outline. */ private val SPLIT_SELECTION_STROKE = 1.5.dp -private const val MAX_EVENT_ROWS = 3 +/** Lanes of bars/pills a day cell draws before the rest become overflow dots. */ +internal const val MAX_EVENT_ROWS = 3 /** * Row height in the continuous grid. The paged grid divides the viewport between @@ -765,11 +783,15 @@ private val CONTINUOUS_ROW_HEIGHT = 112.dp */ private val CONTINUOUS_MONTH_GAP = 20.dp +/** Gap between the weekday header and the seamless stream's first week row. */ +private val DENSE_HEADER_GAP = 4.dp + @Composable internal fun MonthGrid( state: MonthUiState.Success, showWeekNumbers: Boolean, onOpenDay: (LocalDate) -> Unit, + onEventClick: (EventInstance) -> Unit, /** See [MonthWeekRow]'s `selected`: an anchor for the morph, never a mark. */ selected: LocalDate? = null, ) { @@ -790,6 +812,7 @@ internal fun MonthGrid( inMonth = { it.month == month.month && it.year == month.year }, showWeekNumbers = showWeekNumbers, onOpenDay = onOpenDay, + onEventClick = onEventClick, selected = selected, modifier = Modifier .fillMaxWidth() @@ -820,6 +843,7 @@ internal fun ContinuousMonthGrid( listState: LazyListState, showWeekNumbers: Boolean, onOpenDay: (LocalDate) -> Unit, + onEventClick: (EventInstance) -> Unit, modifier: Modifier = Modifier, ) { val monthCount = remember { continuousMonthCount() } @@ -847,6 +871,7 @@ internal fun ContinuousMonthGrid( today = state.today, showWeekNumbers = showWeekNumbers, onOpenDay = onOpenDay, + onEventClick = onEventClick, ) } } @@ -866,6 +891,7 @@ private fun ContinuousMonthBlock( today: LocalDate, showWeekNumbers: Boolean, onOpenDay: (LocalDate) -> Unit, + onEventClick: (EventInstance) -> Unit, ) { val rowCount = remember(month, weekStart) { weekRowsInMonth(month, weekStart) } Column( @@ -888,6 +914,7 @@ private fun ContinuousMonthBlock( blankOutside = true, showWeekNumbers = showWeekNumbers, onOpenDay = onOpenDay, + onEventClick = onEventClick, modifier = Modifier .fillMaxWidth() .height(CONTINUOUS_ROW_HEIGHT), @@ -946,6 +973,7 @@ internal fun DenseMonthGrid( listState: LazyListState, showWeekNumbers: Boolean, onOpenDay: (LocalDate) -> Unit, + onEventClick: (EventInstance) -> Unit, modifier: Modifier = Modifier, ) { val weekCount = remember(state.weekStart) { continuousWeekCount(state.weekStart) } @@ -953,10 +981,15 @@ internal fun DenseMonthGrid( state = listState, modifier = modifier .fillMaxSize() - .padding(horizontal = AppBarSpacing.Inset), + // The gap under the weekday header is a real margin, not content + // padding: a lazy list scrolls its rows *through* the before-content + // padding, so scrolling to a week landed its row that far down with + // the tail of the previous one showing above it (#191). + .padding(horizontal = AppBarSpacing.Inset) + .padding(top = DENSE_HEADER_GAP), verticalArrangement = Arrangement.spacedBy(2.dp), // Bottom inset clears the FAB stack so the last row stays tappable. - contentPadding = PaddingValues(top = 4.dp, bottom = 96.dp), + contentPadding = PaddingValues(bottom = 96.dp), ) { items(count = weekCount, key = { it }) { index -> val week = state.weeksByIndex[index] @@ -971,6 +1004,7 @@ internal fun DenseMonthGrid( inMonth = { true }, showWeekNumbers = showWeekNumbers, onOpenDay = onOpenDay, + onEventClick = onEventClick, labelMonthOnFirst = true, modifier = Modifier .fillMaxWidth() @@ -1198,6 +1232,9 @@ private fun SplitMonthBody( onSelectDay(it) onSetExpanded(false) }, + // A tapped chip is asking for that event, not for its day, so it + // opens the detail from here too rather than collapsing (#187). + onEventClick = onEventClick, onCollapse = { onSetExpanded(false) }, ) } else { @@ -1298,6 +1335,7 @@ private fun SplitMonthExpanded( showWeekNumbers: Boolean, swipeModifier: Modifier, onPickDay: (LocalDate) -> Unit, + onEventClick: (EventInstance) -> Unit, onCollapse: () -> Unit, ) { val slideSpec = rememberCalendarSlideSpec() @@ -1320,6 +1358,7 @@ private fun SplitMonthExpanded( state = s, showWeekNumbers = showWeekNumbers, onOpenDay = onPickDay, + onEventClick = onEventClick, selected = sel, ) } @@ -1800,7 +1839,7 @@ private fun ContinuousMonthSkeleton(dense: Boolean) { modifier = Modifier.padding(bottom = 8.dp), ) } else { - Spacer(Modifier.height(4.dp)) + Spacer(Modifier.height(DENSE_HEADER_GAP)) } // More rows than a viewport holds; the clip takes the overflow. repeat(6) { @@ -1848,6 +1887,7 @@ private fun MonthWeekRow( inMonth: (LocalDate) -> Boolean, showWeekNumbers: Boolean, onOpenDay: (LocalDate) -> Unit, + onEventClick: (EventInstance) -> Unit, modifier: Modifier = Modifier, blankOutside: Boolean = false, labelMonthOnFirst: Boolean = false, @@ -1896,6 +1936,7 @@ private fun MonthWeekRow( laneCount = MAX_EVENT_ROWS, isRtl = isRtl, chipAt = { col, lane -> week.chipAt(col, lane, MAX_EVENT_ROWS) }, + chipStart = { col, lane -> week.chipStartCol(col, lane) }, ), ) } @@ -2146,11 +2187,17 @@ private fun MonthWeekRow( } } - // Tap layer: in month view a tap on any day opens that day. Padded and + // Tap layer: a tap on a chip opens that event, anything else opens the + // day (#187). The chips take no pointer input of their own — this + // layer covers them — so the lane under the finger is resolved + // geometrically, exactly as the drag pickup above does it; the down + // position is read on the initial pass, which consumes nothing and so + // leaves both the click and a pickup in flight untouched. Padded and // clipped to the background pill so the ripple matches it. A blanked // cell isn't part of this month, so it takes no taps either. + val downY = remember(week.days.size) { FloatArray(week.days.size) { NO_DOWN_Y } } Row(Modifier.matchParentSize()) { - week.days.forEach { d -> + week.days.forEachIndexed { col, d -> if (blankOutside && !inMonth(d)) { Spacer(Modifier.weight(1f).fillMaxHeight()) } else { @@ -2158,9 +2205,33 @@ private fun MonthWeekRow( Modifier .weight(1f) .fillMaxHeight() + .pointerInput(col) { + awaitEachGesture { + downY[col] = awaitFirstDown( + requireUnconsumed = false, + pass = PointerEventPass.Initial, + ).position.y + } + } .padding(horizontal = CELL_GAP, vertical = 1.dp) .clip(CELL_SHAPE) - .clickable { onOpenDay(d) }, + .clickable { + // Cleared on read: a click with no fresh down + // (TalkBack, D-pad) would otherwise resolve the + // previous tap's position and reopen its chip. + val cellY = downY[col] + downY[col] = NO_DOWN_Y + val chip = week.chipAtCellY( + col = col, + cellY = cellY, + bandTopInCell = bandTopInCell( + cellCoordinates, + bandCoordinates, + ), + rowHeightPx = rowHeightPx, + ) + if (chip != null) onEventClick(chip) else onOpenDay(d) + }, ) } } @@ -2169,6 +2240,43 @@ private fun MonthWeekRow( } } +/** + * How far the event band sits below the top of the row's day-column box, or null + * while either is unmeasured. Read off the live coordinates rather than summed + * from the padding constants, so it can't drift from what the row actually drew. + */ +private fun bandTopInCell( + cell: Array, + band: Array, +): Float? { + val cellTop = cell[0]?.takeIf { it.isAttached }?.positionInRoot()?.y ?: return null + val bandTop = band[0]?.takeIf { it.isAttached }?.positionInRoot()?.y ?: return null + return bandTop - cellTop +} + +/** + * Stand-in [cellY] for "no touch down recorded", which resolves to no chip. + */ +private const val NO_DOWN_Y = Float.NEGATIVE_INFINITY + +/** + * The chip at [cellY] in column [col], where [cellY] is measured from the top of + * the row's day-column box. Null for a tap above the band (the day number), on an + * empty lane, or on the overflow dots — all of which mean "open the day", the + * dots included: their point is that the day holds more than fits. + */ +internal fun MonthWeek.chipAtCellY( + col: Int, + cellY: Float, + bandTopInCell: Float?, + rowHeightPx: Float, +): EventInstance? { + if (bandTopInCell == null || rowHeightPx <= 0f) return null + val bandY = cellY - bandTopInCell + if (bandY < 0f) return null + return chipAt(col, (bandY / rowHeightPx).toInt(), MAX_EVENT_ROWS) +} + /** * The row-level pickup for month chips: resolves which chip the press landed on * from the geometry the row just laid out, and abandons the gesture on empty @@ -2220,7 +2328,7 @@ private fun monthChipDragModifier( // How many columns the finger crossed — grabbing the middle of a // multi-day bar shifts by what it travelled, not to where it landed. val delta = (drop.targetDate.toEpochDays() - drop.grabDate.toEpochDays()).toInt() - val took = moveScope?.move( + moveScope?.move( MoveRequest( eventId = drop.event.eventId, beginMillis = drop.event.start.toEpochMilliseconds(), @@ -2228,9 +2336,6 @@ private fun monthChipDragModifier( 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() }, @@ -2346,12 +2451,7 @@ private fun MonthBar( val lifted = monthDrag?.liftedInstanceId == event.instanceId || monthDrag?.isSettledChip(event, days) == true val ghost = ghostAlpha(lifted) - val shape = RoundedCornerShape( - 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, - ) + val shape = monthBarShape(continuesLeft, continuesRight) Box( modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier) .then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier) @@ -2371,6 +2471,7 @@ private fun MonthBar( overflow = titleOverflow.overflow, softWrap = titleOverflow.softWrap, color = eventInk(fill), + textDecoration = declinedDecoration(event.isDeclined), ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt index 7bc6618..ffe8d27 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt @@ -45,6 +45,7 @@ internal fun MonthStylePreview( state = sample.month, showWeekNumbers = false, onOpenDay = {}, + onEventClick = {}, ) MonthViewStyle.Continuous -> ContinuousMonthGrid( state = sample.continuous, @@ -55,6 +56,7 @@ internal fun MonthStylePreview( ), showWeekNumbers = false, onOpenDay = {}, + onEventClick = {}, ) MonthViewStyle.Dense -> DenseMonthGrid( state = sample.continuous, @@ -66,6 +68,7 @@ internal fun MonthStylePreview( ), showWeekNumbers = false, onOpenDay = {}, + onEventClick = {}, ) MonthViewStyle.Split -> { SplitMonthGrid( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt index 56624d4..434760e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt @@ -84,6 +84,16 @@ fun MonthWeek.chipAt(col: Int, lane: Int, laneCap: Int): EventInstance? { 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 * complement, in the same bars-then-pills order. Returned as events rather than diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt index c4cab41..f15345a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt @@ -12,6 +12,7 @@ import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.ui.week.coversDay import de.jeanlucmakiola.calendula.ui.week.layoutAllDay +import de.jeanlucmakiola.calendula.ui.week.spansMultipleDays import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -417,8 +418,11 @@ internal fun layoutCalendarWeek( zone: TimeZone, ): MonthWeek { 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 -> - ev.isAllDay || days.count { ev.coversDay(it, zone) } > 1 + ev.isAllDay || ev.spansMultipleDays(zone) } val spans = layoutAllDay(bars, days, zone).map { s -> MonthSpan( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt index 508dcbe..68dfcda 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt @@ -85,6 +85,7 @@ import de.jeanlucmakiola.calendula.domain.SearchMonth import de.jeanlucmakiola.floret.identity.animateItemMotion import de.jeanlucmakiola.floret.identity.fadeThrough import de.jeanlucmakiola.floret.identity.predictiveBack +import de.jeanlucmakiola.calendula.ui.common.declinedTitle import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.InlineTextField import de.jeanlucmakiola.floret.components.Position @@ -519,7 +520,7 @@ private fun SearchResultRow( // Faded like a past event anywhere else in the app — search reaches back // through the whole history. modifier = if (hit.isPast) modifier.alpha(EventDimAlpha) else modifier, - title = marked(event.title, hit.titleSpans, highlight), + title = declinedTitle(marked(event.title, hit.titleSpans, highlight), event.isDeclined), summary = searchSummary(hit, highlight), position = position, minHeight = 64.dp, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index 9fdfc5f..5c34de6 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -89,6 +89,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha +import de.jeanlucmakiola.calendula.ui.common.declinedDecoration import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement import de.jeanlucmakiola.calendula.ui.common.ghostAlpha @@ -98,7 +99,10 @@ import de.jeanlucmakiola.calendula.ui.common.MoveTarget import de.jeanlucmakiola.calendula.ui.common.TimelineDragController import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay 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.eventMoveAction import de.jeanlucmakiola.calendula.ui.common.eventTitleOverflow @@ -107,6 +111,7 @@ import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController import de.jeanlucmakiola.calendula.ui.common.startInstant import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff 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.eventInk import de.jeanlucmakiola.calendula.ui.common.NowLine @@ -129,6 +134,7 @@ import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay import de.jeanlucmakiola.calendula.ui.common.GUTTER_CONTENT_START_INSET import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH import de.jeanlucmakiola.calendula.ui.common.HourGutter +import de.jeanlucmakiola.calendula.ui.common.TIMELINE_CONTENT_END_INSET import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec @@ -386,7 +392,7 @@ private fun WeekContent( onOpenDay = onOpenDay, onCreateAt = onCreateAt, onDrop = { drop -> - val took = move?.move( + move?.move( MoveRequest( eventId = drop.event.eventId, beginMillis = drop.event.start.toEpochMilliseconds(), @@ -394,9 +400,6 @@ private fun WeekContent( 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() }, ) } @@ -513,7 +516,7 @@ private fun WeekDayHeader( Row( modifier = Modifier .fillMaxWidth() - .padding(top = 4.dp, bottom = 8.dp), + .padding(top = 4.dp, bottom = 8.dp, end = TIMELINE_CONTENT_END_INSET), ) { // Mirror the day-column layout (empty weekday line + spacer) so the // badge lines up vertically with the date numbers. The start inset centres @@ -609,7 +612,11 @@ private fun AllDayStrip( // Height is hoisted + animated so it slides and resizes smoothly; // padding sits inside it so the content area is lanes * row height. .height(height) - .padding(vertical = ALL_DAY_VERTICAL_PADDING), + .padding( + top = ALL_DAY_VERTICAL_PADDING, + bottom = ALL_DAY_VERTICAL_PADDING, + end = TIMELINE_CONTENT_END_INSET, + ), ) { // Keep the gutter-width offset so the bars line up with the day columns. Spacer(Modifier.width(GUTTER_WIDTH)) @@ -657,7 +664,7 @@ private fun AllDayBar( val fill = eventFill(event.color, dark, soften) Box( modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier) - .background(fill, RoundedCornerShape(4.dp)) + .background(fill, EventChipShape) .clickable(onClick = onClick) .padding(horizontal = 6.dp, vertical = 2.dp) .semantics { contentDescription = title }, @@ -671,6 +678,7 @@ private fun AllDayBar( overflow = titleOverflow.overflow, softWrap = titleOverflow.softWrap, color = eventInk(fill), + textDecoration = declinedDecoration(event.isDeclined), ) } } @@ -717,6 +725,7 @@ private fun Timeline( modifier = Modifier .weight(1f) .fillMaxHeight() + .padding(end = TIMELINE_CONTENT_END_INSET) .clip(RoundedCornerShape(16.dp)) .verticalScroll(scrollState) .onGloballyPositioned { dragController.geometry.viewport = it }, @@ -913,24 +922,30 @@ private fun EventBlock( val fill = eventFill(block.event.color, dark, soften) val zone = remember { TimeZone.currentSystemDefault() } val moveAction = eventMoveAction(block.event) - // A block clipped at the top continues from the previous day: its top edge - // is midnight, not the event's start, so dragging it would invent a time. - val draggable = eventDragAllowed(block.event) && block.beginsOn(date, zone) + val draggable = eventDragAllowed(block.event) + // The drop takes this offset back off, so a tail clipped at midnight lands + // 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( enabled = draggable, key = block.event.instanceId, - onPickUp = { pointer, blockRoot, _ -> dragController.begin(block, pointer, blockRoot) }, + onPickUp = { pointer, blockRoot, _ -> + dragController.begin(block, clipOffset, pointer, blockRoot) + }, onMove = dragController::move, onDrop = { dragController.finish()?.let(onDrop) }, onCancel = dragController::cancel, ) - val lifted = draggable && dragController.ghosts(block, date) + val lifted = draggable && dragController.ghosts(block) val ghost = ghostAlpha(lifted) Box( modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier) // The source stays put as a ghost while its floating copy travels. .then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier) - .background(fill, RoundedCornerShape(4.dp)) + .background(fill, shape) .clickable(onClick = onClick) // After clickable, so it is the inner node and wins the main pass; // the tap still works, since a drag consumes the up. @@ -951,6 +966,7 @@ private fun EventBlock( overflow = titleOverflow.overflow, softWrap = titleOverflow.softWrap, color = eventInk(fill, alpha = 0.85f), + textDecoration = declinedDecoration(block.event.isDeclined), ) } if (showTime) { @@ -988,7 +1004,12 @@ private fun WeekLoading() { // Same scale resolution as the loaded timeline, so the skeleton's // columns don't resize the moment the real week arrives. val totalHeight = scale.hourHeight(maxHeight) * 24 - Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(end = TIMELINE_CONTENT_END_INSET) + .verticalScroll(scrollState), + ) { Spacer(Modifier.width(GUTTER_WIDTH)) repeat(7) { Box( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt index 71f603c..81a3b49 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt @@ -206,6 +206,17 @@ internal fun EventInstance.coversDay(day: LocalDate, zone: TimeZone): Boolean { 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 * side-by-side. Lane count is computed per overlap-cluster (a maximal run of diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverScheduler.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverScheduler.kt new file mode 100644 index 0000000..b08c987 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverScheduler.kt @@ -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() ?: 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 +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTheme.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTheme.kt index a529f0f..d679fe5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTheme.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTheme.kt @@ -4,6 +4,7 @@ import android.os.Build import androidx.compose.runtime.Composable import androidx.glance.GlanceTheme import androidx.glance.material3.ColorProviders +import androidx.glance.text.TextDecoration import de.jeanlucmakiola.calendula.ui.theme.CalendulaDarkFallback import de.jeanlucmakiola.calendula.ui.theme.CalendulaLightFallback @@ -34,3 +35,10 @@ fun CalendulaGlanceTheme(content: @Composable () -> Unit) { } GlanceTheme(colors = colors, content = content) } + +/** + * Glance's counterpart to the app's `declinedDecoration`: a declined invitation + * reads the same on the home screen as it does inside the app (#180). + */ +fun glanceDeclinedDecoration(isDeclined: Boolean): TextDecoration = + if (isDeclined) TextDecoration.LineThrough else TextDecoration.None diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetUpdateReceiver.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetUpdateReceiver.kt index 18d5e81..b4972aa 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetUpdateReceiver.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetUpdateReceiver.kt @@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.widget import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.os.SystemClock import androidx.glance.appwidget.updateAll import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget import de.jeanlucmakiola.calendula.widget.month.MonthWidget @@ -10,32 +11,110 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob 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, * so it covers both the app's own writes and external sync. - * - `DATE_CHANGED` / `TIME_SET` / `TIMEZONE_CHANGED` — so "today" highlighting - * and the upcoming window roll over at midnight / on a clock change. + * - [ACTION_ROLLOVER], the app's own alarm from [WidgetRolloverScheduler] — + * 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 - * XML, and the month widget's refresh button forces an immediate redraw. + * `DATE_CHANGED` rides along as a free extra the system may or may not send — + * 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() { 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 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 // month window so the widgets reload fresh. Month paging does NOT call // this, so arrow taps stay instant. - invalidateMonthWidgetCache() + if (redraw) invalidateMonthWidgetCache() CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { try { - AgendaWidget().updateAll(appContext) - MonthWidget().updateAll(appContext) + // Re-armed first, so the next day boundary is covered whatever + // 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 { 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) + } + } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt index c40288a..622d846 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.calendula.widget.agenda +import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration import android.content.Context import android.content.res.Configuration import androidx.compose.runtime.Composable @@ -24,7 +25,6 @@ import androidx.glance.appwidget.cornerRadius import androidx.glance.appwidget.lazy.LazyColumn import androidx.glance.appwidget.lazy.items import androidx.glance.appwidget.provideContent -import androidx.glance.appwidget.updateAll import androidx.glance.background import androidx.glance.currentState import androidx.glance.state.PreferencesGlanceStateDefinition @@ -74,11 +74,6 @@ import kotlinx.datetime.toLocalDateTime import kotlin.time.Instant import java.util.Locale -/** - * "Upcoming" agenda widget — a continuously scrolling list of the next ~30 days - * of events grouped under day headers (the Google "Schedule" widget model). - * Reuses the app's [groupAgendaDays] grouping so it matches the in-app agenda. - */ /** * Per-instance Glance state key holding the agenda range (as [AgendaRange.storageValue]). * The range is read reactively in the composition ([currentState]) so a settings @@ -113,6 +108,11 @@ internal val AGENDA_SHOW_TODAY_STATE_KEY = booleanPreferencesKey("agenda_show_to */ internal val AGENDA_SIZE_KEY = stringPreferencesKey("widget_size") +/** + * "Upcoming" agenda widget — a continuously scrolling list of the next ~30 days + * of events grouped under day headers (the Google "Schedule" widget model). + * Reuses the app's [groupAgendaDays] grouping so it matches the in-app agenda. + */ class AgendaWidget : GlanceAppWidget() { override val stateDefinition = PreferencesGlanceStateDefinition @@ -135,17 +135,23 @@ class AgendaWidget : GlanceAppWidget() { } } -/** Re-reads the calendar and redraws the widget (header refresh button). */ +/** + * Redraws the widget (header refresh button). Targets the tapped widget's own id + * rather than `updateAll`, whose provider-name lookup is empty in a process a tap + * woke from cold — see `ShiftMonthAction` (#18). A cold process re-reads the + * calendar in the `provideGlance` preamble; a live session only recomposes from + * the snapshot it already has (see [AGENDA_RANGE_KEY]). + */ class RefreshAgendaAction : ActionCallback { override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { - AgendaWidget().updateAll(context.applicationContext) + AgendaWidget().update(context.applicationContext, glanceId) } } /** * Upper bound on rows handed to the [LazyColumn], so the serialized RemoteViews * stays well inside the binder transaction limit regardless of range and calendar - * size (see the [SizeMode.Exact] note above). Far more than fits on screen — a + * size (see the [SizeMode.Single] note above). Far more than fits on screen — a * user scrolling a home-screen widget past a hundred rows is not a case worth * risking a failed update for. */ @@ -416,7 +422,11 @@ private fun EventRow( Text( text = title, maxLines = 1, - style = TextStyle(color = titleColor, fontSize = metrics.eventTitle), + style = TextStyle( + color = titleColor, + fontSize = metrics.eventTitle, + textDecoration = glanceDeclinedDecoration(event.isDeclined), + ), ) Text( text = eventTimeSummary(context, event, day, is24Hour), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidgetReceiver.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidgetReceiver.kt index 4e1b088..736da00 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidgetReceiver.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidgetReceiver.kt @@ -1,7 +1,10 @@ package de.jeanlucmakiola.calendula.widget.agenda +import android.appwidget.AppWidgetManager +import android.content.Context import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidgetReceiver +import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler /** * 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() { 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) + } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt index 24f19a3..353db0c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.calendula.widget.month +import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration import android.content.Context import android.content.res.Configuration import androidx.compose.runtime.Composable @@ -25,7 +26,6 @@ import androidx.glance.appwidget.action.actionStartActivity import androidx.glance.appwidget.cornerRadius import androidx.glance.appwidget.provideContent import androidx.glance.appwidget.state.updateAppWidgetState -import androidx.glance.appwidget.updateAll import androidx.glance.background import androidx.glance.currentState import androidx.glance.layout.Alignment @@ -33,6 +33,7 @@ import androidx.glance.layout.Box import androidx.glance.layout.Column import androidx.glance.layout.Row import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxHeight import androidx.glance.layout.fillMaxSize import androidx.glance.layout.fillMaxWidth import androidx.glance.layout.height @@ -97,10 +98,27 @@ private fun yearMonthOf(index: Int): YearMonth = * inter-cell seam, with rounded end caps. The displayed month lives in Glance * state and is read reactively in the composition ([currentState]) so the arrows * move it via plain recomposition, not a (here-unreliable) widget session reload. + * + * Everything here is written to keep the published `RemoteViews` small. A widget + * update reaches the launcher as a *oneway* binder call, and the host's async + * buffer is 1 MB for all of it; overrun throws `TransactionTooLargeException` and + * the system drops the whole host, freezing every widget on the home screen until + * the launcher rebinds. [SizeMode.Exact] serialises the grid once per size the + * host reports, so the ceiling arrives at half the view count you would expect: a + * grid that emitted a view per cell blew past it after three taps (#214). Every + * row here emits only what it fills. */ class MonthWidget : GlanceAppWidget() { override val stateDefinition = PreferencesGlanceStateDefinition + + /** + * Exact rather than [SizeMode.Single] or [SizeMode.Responsive], both of which + * return early from `GlanceAppWidget.resize` — under those the grid is never + * recomposed for a new size and a resized widget clips instead of reflowing. + * Exact costs one serialised copy per host size, which is affordable now that + * a rendering is ~190 views rather than ~740. + */ override val sizeMode = SizeMode.Exact override suspend fun provideGlance(context: Context, id: GlanceId) { @@ -118,15 +136,35 @@ class MonthWidget : GlanceAppWidget() { } } -/** Step the displayed month by the `delta` action parameter (±1). */ +/** + * Step the displayed month by the `delta` action parameter (±1). + * + * Redrawn through [GlanceAppWidget.update] with the id the callback was handed, + * never `updateAll`. `updateAll` resolves its targets through the provider-name + * -> app-widget-id map Glance persists for the *receiver*, and in a process that + * has done nothing else yet — the one a tap wakes after a reboot — that lookup + * comes back empty and the redraw is dropped: the state write lands, nothing + * recomposes, and the arrows read as dead until something else opens the app and + * starts a session (#18). The tapped widget's own id needs no lookup. + */ class ShiftMonthAction : ActionCallback { override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { val delta = parameters[deltaKey] ?: 0 updateAppWidgetState(context, glanceId) { prefs -> 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().updateAll(context.applicationContext) + MonthWidget().update(context.applicationContext, glanceId) } companion object { @@ -134,11 +172,11 @@ class ShiftMonthAction : ActionCallback { } } -/** Jump the displayed month back to the current month. */ +/** Jump the displayed month back to the current month. See [ShiftMonthAction]. */ class ResetMonthAction : ActionCallback { override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { updateAppWidgetState(context, glanceId) { prefs -> prefs.remove(MONTH_INDEX_KEY) } - MonthWidget().updateAll(context.applicationContext) + MonthWidget().update(context.applicationContext, glanceId) } } @@ -286,29 +324,75 @@ private fun WeekRow( colW: Dp, modifier: GlanceModifier, ) { - Column(modifier = modifier.fillMaxWidth()) { - // Day numbers. - Row(modifier = GlanceModifier.fillMaxWidth()) { + val context = LocalContext.current + val hidden = week.hiddenPerDay() + Box(modifier = modifier.fillMaxWidth()) { + // Day-open targets, one full-height strip per column, sitting *under* the + // content: a tap anywhere in a day still opens it, as in the app, without + // every cell in the column carrying a click target of its own. The content + // above is not clickable, so touches fall through to this layer; only an + // event bar opts out to open its own detail. + Row(modifier = GlanceModifier.fillMaxSize()) { week.days.forEach { date -> - DayNumber( - date = date, - isToday = date == today, - inMonth = date.month == currentMonth, - colW = colW, - ) + Box( + modifier = GlanceModifier + .width(colW) + .fillMaxHeight() + .clickable(openDayAction(context, date)), + ) {} } } - Spacer(GlanceModifier.height(2.dp)) - // One lane row per event row. A multi-day span is a single Box spanning - // its columns (colW * n) so it's connected with no seam and rounded ends. - repeat(MAX_LANES) { lane -> - LaneRow(week = week, lane = lane, dark = dark, soften = soften, colW = colW) - Spacer(GlanceModifier.height(1.dp)) + Column(modifier = GlanceModifier.fillMaxWidth()) { + // Day numbers. Fixed height so every week's number row lines up whether + // or not it holds today's larger pill. + Row( + modifier = GlanceModifier.fillMaxWidth().height(DAY_NUMBER_HEIGHT), + verticalAlignment = Alignment.CenterVertically, + ) { + week.days.forEach { date -> + DayNumber( + date = date, + isToday = date == today, + inMonth = date.month == currentMonth, + colW = colW, + ) + } + } + Spacer(GlanceModifier.height(2.dp)) + // One lane row per event row. A multi-day span is a single Box spanning + // its columns (colW * n) so it's connected with no seam and rounded ends. + // Lanes past the last one this week fills emit nothing at all. + repeat(week.usedLanes()) { lane -> + LaneRow(week = week, lane = lane, dark = dark, soften = soften, colW = colW) + Spacer(GlanceModifier.height(1.dp)) + } + if (hidden.any { it > 0 }) OverflowRow(hidden = hidden, colW = colW) } - OverflowRow(week = week, colW = colW) } } +/** + * How many lanes this week actually fills, counting from the top. The grid draws + * that many rows instead of a fixed [MAX_LANES]; an empty lane costs three views + * per day and most weeks leave the last two empty. + */ +private fun MonthWeek.usedLanes(): Int { + for (lane in MAX_LANES - 1 downTo 0) { + val filled = spans.any { it.lane == lane } || + days.indices.any { col -> timedEventAt(this, lane, col, days[col]) != null } + if (filled) return lane + 1 + } + return 0 +} + +/** Events per day that no lane had room for — the "+N" counts, 0 where all fit. */ +private fun MonthWeek.hiddenPerDay(): List = days.mapIndexed { col, date -> + val shownSpans = spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES } + val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0) + val timedShown = minOf(freeSlots, timedByDay[date].orEmpty().size) + ((countByDay[date] ?: 0) - shownSpans - timedShown).coerceAtLeast(0) +} + /** * Open [date]'s day view rooted in the month view (so back returns to the month * grid) — the same target the in-app month grid uses when a day cell is tapped. @@ -318,64 +402,93 @@ private fun WeekRow( private fun openDayAction(context: Context, date: LocalDate) = actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month)) +/** + * A day's number. Every day but today is a bare centred [Text] carrying the column + * width itself — a wrapping Box costs three views, and there are 42 of these. + * Today keeps its filled circle, which does need the Box. + */ @Composable private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) { - val context = LocalContext.current - Box( - modifier = GlanceModifier - .width(colW) - .height(DAY_NUMBER_HEIGHT) - .clickable(openDayAction(context, date)), - contentAlignment = Alignment.Center, - ) { + val style = TextStyle( + color = when { + isToday -> GlanceTheme.colors.onPrimary + inMonth -> GlanceTheme.colors.onSurface + else -> GlanceTheme.colors.onSurfaceVariant + }, + fontSize = 11.sp, + fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal, + textAlign = TextAlign.Center, + ) + if (!isToday) { + Text(text = date.day.toString(), style = style, modifier = GlanceModifier.width(colW)) + return + } + Box(modifier = GlanceModifier.width(colW), contentAlignment = Alignment.Center) { Box( modifier = GlanceModifier .size(DAY_NUMBER_HEIGHT) - .then(if (isToday) GlanceModifier.cornerRadius(DAY_NUMBER_HEIGHT / 2).background(GlanceTheme.colors.primary) else GlanceModifier), + .cornerRadius(DAY_NUMBER_HEIGHT / 2) + .background(GlanceTheme.colors.primary), contentAlignment = Alignment.Center, ) { - Text( - text = date.day.toString(), - style = TextStyle( - color = when { - isToday -> GlanceTheme.colors.onPrimary - inMonth -> GlanceTheme.colors.onSurface - else -> GlanceTheme.colors.onSurfaceVariant - }, - fontSize = 11.sp, - fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal, - ), - ) + Text(text = date.day.toString(), style = style) } } } +/** A bar occupying [cols] columns, or — with a null event — that many blank ones. */ +private data class LaneCell(val event: EventInstance?, val cols: Int) + +/** + * A lane split into bars and the gaps between them, with consecutive blank columns + * merged into one gap: the week's day-open taps come from the strip underneath, so + * a gap needs no per-column view and a mostly-empty lane collapses to a spacer. + */ +private fun MonthWeek.laneCells(lane: Int): List { + val cells = mutableListOf() + var gap = 0 + var col = 0 + fun closeGap() { + if (gap > 0) cells += LaneCell(null, gap) + gap = 0 + } + while (col < 7) { + val span = spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol } + val timed = if (span == null) timedEventAt(this, lane, col, days[col]) else null + when { + span != null -> { + closeGap() + cells += LaneCell(span.event, span.endCol - col + 1) + col = span.endCol + 1 + } + timed != null -> { + closeGap() + cells += LaneCell(timed, 1) + col += 1 + } + else -> { + gap += 1 + col += 1 + } + } + } + closeGap() + return cells +} + @Composable private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, soften: Boolean, colW: Dp) { - val context = LocalContext.current Row(modifier = GlanceModifier.fillMaxWidth()) { - var col = 0 - while (col < 7) { - val span = week.spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol } - if (span != null) { - val cols = span.endCol - col + 1 - SpanBar(event = span.event, dark = dark, soften = soften, width = colW * cols) - col = span.endCol + 1 + week.laneCells(lane).forEach { cell -> + if (cell.event == null) { + Spacer(GlanceModifier.width(colW * cell.cols).height(LANE_HEIGHT)) } else { - val timed = timedEventAt(week, lane, col, week.days[col]) - if (timed != null) { - SpanBar(event = timed, dark = dark, soften = soften, width = colW) - } else { - // Empty lane cell: a tap opens that day, so blank space in a - // day column is a day-open target just like the number is. - Box( - GlanceModifier - .width(colW) - .height(LANE_HEIGHT) - .clickable(openDayAction(context, week.days[col])), - ) {} - } - col += 1 + SpanBar( + event = cell.event, + dark = dark, + soften = soften, + width = colW * cell.cols, + ) } } } @@ -414,41 +527,39 @@ private fun SpanBar(event: EventInstance, dark: Boolean, soften: Boolean, width: Text( text = event.title.ifBlank { context.getString(R.string.event_untitled) }, maxLines = 1, - style = TextStyle(color = ColorProvider(eventInk(fill)), fontSize = 9.sp), + style = TextStyle( + color = ColorProvider(eventInk(fill)), + fontSize = 9.sp, + textDecoration = glanceDeclinedDecoration(event.isDeclined), + ), modifier = GlanceModifier.padding(horizontal = 3.dp), ) } } } +/** The "+N" row, drawn only for weeks that hide something, gaps merged as in a lane. */ @Composable -private fun OverflowRow(week: MonthWeek, colW: Dp) { - val context = LocalContext.current +private fun OverflowRow(hidden: List, colW: Dp) { Row(modifier = GlanceModifier.fillMaxWidth()) { - week.days.forEachIndexed { col, date -> - val shownSpans = week.spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES } - val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0) - val timedShown = minOf(freeSlots, week.timedByDay[date].orEmpty().size) - val hidden = (week.countByDay[date] ?: 0) - shownSpans - timedShown - // The overflow row is part of the day column too: tapping it (whether - // it shows "+N" or is blank) opens that day, same as the app. - Box( - modifier = GlanceModifier - .width(colW) - .height(LANE_HEIGHT) - .clickable(openDayAction(context, date)), - contentAlignment = Alignment.CenterStart, - ) { - if (hidden > 0) { - Text( - text = "+$hidden", - maxLines = 1, - style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 9.sp), - modifier = GlanceModifier.padding(start = 3.dp), - ) - } + var gap = 0 + hidden.forEach { count -> + if (count == 0) { + gap += 1 + return@forEach } + if (gap > 0) { + Spacer(GlanceModifier.width(colW * gap).height(LANE_HEIGHT)) + gap = 0 + } + Text( + text = "+$count", + maxLines = 1, + style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 9.sp), + modifier = GlanceModifier.width(colW).padding(start = 3.dp), + ) } + if (gap > 0) Spacer(GlanceModifier.width(colW * gap).height(LANE_HEIGHT)) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetReceiver.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetReceiver.kt index e450f0c..08e4a3e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetReceiver.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetReceiver.kt @@ -1,7 +1,10 @@ package de.jeanlucmakiola.calendula.widget.month +import android.appwidget.AppWidgetManager +import android.content.Context import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidgetReceiver +import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler /** * 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() { 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) + } } diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index a984f2a..3825331 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -16,14 +16,14 @@ فتح إعدادات تقويم النظام تعذّر قراءة التقويم. شاهد جميع أحداثك، بشكل جميل - Calendula يحتاج للوصول إلى تقويمك لعرض و إدارة أحداثك. هذا كل ما يطلبه مقدمًا — ولا يخرج أي شيء من جهازك على الإطلاق. + Calendula يحتاج للوصول إلى تقويمك لعرض و إدارة أحداثك. منح الوصول إلى التقويم رُفِض الوصول إلى التقويم Calendula لا يمكنه عرض الأحداث بدون الوصول إلى التقويم. يمكنك منحه مُجددًا في إعدادات النظام. أعِد المحاولة فتح إعدادات النظام - يبقى على جهازك - تقويماتك تتِم قراءتها محليًا ولا تُغادر الهاتف أبدًا. + الخصوصية حسب التصميم + لا يوجد إذن بالإنترنت، ولا تتبع عن بُعد — لا شيء يغادر هاتفك أبدًا. جميع تقويماتك، مع بعض جوجل، CalDAV، محلي — أي شيء تمت مزامنته مع الجهاز يظهر فورًا. لا تتبع، أبدًا @@ -38,7 +38,7 @@ النظام فاتح داكن - افتح القائمة + فتح القائمة المزيد من الإجراءات تعديل حذف @@ -126,6 +126,7 @@ لا يتكرر تم استيراد هذا الحدث بدون أي تذكير. + تم استيراد هذا الحدث مع (%1$d) لا تذكيرات. تم استيراد هذا الحدث مع %1$d تذكير. تم استيراد هذا الحدث مع %1$d تذكيران. تم استيراد هذا الحدث مع %1$d تذكيرات. @@ -175,9 +176,9 @@ في وقت الحدث (بدون عنوان) تذكيرات الحدث - الإشعارات في أوقات التذكير الخاصه بأحداثك + الإشعارات في أوقات التذكير بأحداثك لا تفوّت أي حدث أبدًا - Android لا يعرض تذكيرات الأحداث من نفسه — بل يجب أن يقوم تطبيق تقويم بذلك. دع Calendula يتولى هذه المهمة. + أندرويد لا يعرض تذكيرات الأحداث من نفسه — بل يجب أن يقوم تطبيق تقويم بذلك. دع Calendula يتولى هذه المهمة. كل تذكير لأحداثك يصِل كإشعار، في الوقت المحدد تمامًا. هل تستخدم تطبيق تقويم ثانٍ؟ إذا تطبيق آخر أيضًا ينشر تذكيرات، فستراهم مرتين — قم بإيقاف تشغيلهم هناك أو هنا. @@ -237,22 +238,22 @@ ٢٤-ساعة (١٤:٠٠) اليوم إظهار اليوم دائمًا - اترك اليوم في أعلى الجدول و ودجته، حتى بعد عدم وجود أي شيء اليوم. + اترك اليوم في أعلى الجدول و أداته المصغرة، حتى بعد عدم وجود أي شيء اليوم. هذا الشهر هذا الأسبوع إظهار إخفاء - جدول + الجدول المنطقة الزمنية للجهاز البحث عن المناطق الزمنية جميع المناطق الزمنية - لا منطقة زمنية تطابق \"%1$s\" + لا منطقة زمنية تطابق ”%1$s“ %1$s توقيتك - الأحدث + الأخيرة أدخل رقمًا من ١ إلى ٩٩٩ (%d) لا دقائق - (%d) دقيقة واحده + %d دقيقة واحده %d دقيقتان %d دقائق %d دقيقة @@ -260,7 +261,7 @@ (%d) لا ساعات - (%d) ساعة واحده + %d ساعة واحده %d ساعتان %d ساعات %d ساعة @@ -268,7 +269,7 @@ (%d) لا أيام - (%d) يوم واحد + %d يوم واحد %d يومان %d أيام %d يوم @@ -276,7 +277,7 @@ (%d) لا أسابيع - (%d) أسبوع واحد + %d أسبوع واحد %d أسبوعان %d أسابيع %d أسبوع @@ -285,13 +286,13 @@ يبدأ %1$s ينتهي %1$s الانتقال إلى اليوم - تحديث + إعادة تحميل اسم التطبيق تعتيم الأحداث المكتملة تعتيم الأحداث التي انتهت بالفعل في عرض الشهر والأسبوع زر اليوم في شريط الأدوات إظهار زر الانتقال إلى اليوم في شريط الأدوات بدلاً من زر عائم - اعرض Calendula كـ \"Calendar\" في مشغّل التطبيقات الخاص بك. فقط الاسم في المشغّل يتغير؛ وقد ينتقل الرمز إلى مكان جديد بعد التبديل. + اعرض Calendula كـ ”Calendar“ في مشغّل التطبيقات الخاص بك. فقط الاسم في المشغّل يتغير؛ وقد ينتقل الرمز إلى مكان جديد بعد التبديل. تعتيم الأحداث السابقة مدى الجدول @@ -314,7 +315,6 @@ الخانات المعروضة افتراضيًا — كل شيء آخر موجود ضمن \"المزيد من الخانات\" نموذج حدث جديد زر التبديل السريع - التقويم الحالي %1$s سواء التطبيق فاتحًا أو داكنًا. الاختيار يُطبق على الفور. الحالي %1$s @@ -329,14 +329,14 @@ كبير جدًا (%d) لا أيام - (%d) يوم واحد + %d يوم واحد %d يومان %d أيام %d يوم %d يوم - اعرض شريطًا في الجزء العلوي من الجدول يحدد التواريخ المعروضة، مع زر لتبديل النطاق للجلسة - شريط النطاق + اعرض شريطًا في الجزء العلوي من الجدول يحدد التواريخ المعروضة، مع زر لتبديل المدى للجلسة + شريط المدى عرض الشهر تمرير الشهور أسابيع سلسة @@ -383,7 +383,7 @@ التالي: %1$s (%d) لا دقائق قبل - (%d) دقيقة واحده قبل + %d دقيقة واحده قبل %d دقيقتان قبل %d دقائق قبل %d دقيقة قبل @@ -391,7 +391,7 @@ (%d) لا ساعات قبل - (%d) ساعة واحده قبل + %d ساعة واحده قبل %d ساعتان قبل %d ساعات قبل %d ساعة قبل @@ -399,7 +399,7 @@ (%d) لا أيام قبل - (%d) يوم واحد قبل + %d يوم واحد قبل %d يومان قبل %d أيام قبل %d يوم قبل @@ -407,13 +407,13 @@ (%d) لا أسابيع قبل - (%d) أسبوع واحد قبل + %d أسبوع واحد قبل %d أسبوعان قبل %d أسابيع قبل %d أسبوع قبل %d أسبوع قبل - يوجد المفتاح في الإعدادات، ضمن الإشعارات. + يوجد مفتاح التبديل في الإعدادات، ضمن الإشعارات. القيمة إنشاء تقويمات محلية؛ إدارة التقويمات المتزامنة مدة التأجيل @@ -424,7 +424,7 @@ العرض، التخطيط، الترتيب الخانات الافتراضية و السلوك التذكيرات و التسليم - أعياد ميلاد جهات الاتصال و احتفالات ذكرى + أعياد ميلاد جهات الاتصال و الذكرات السنوية أعياد الميلاد تواريخ أخرى الترخيص @@ -432,7 +432,7 @@ إيقاف زامن الآن لم تتم المزامنة بعد - لم يعد بإمكان Calendula قراءة جهات الاتصال الخاصة بك، لذلك لا يتم تحديث هذه التقويمات. + لم يعد بإمكان Calendula قراءة جهات اتصالك، لذلك لا يتم تحديث هذه التقويمات. الإدارة في التطبيق تفعيل الكل تعطيل الكل @@ -451,13 +451,13 @@ منح الوصول تصدير كملف ‎.ics تصدير التقويمات - اختر التقويمات التي تريد تضمينها في ‎.ics الملف. + اختر التقويمات التي تريد تضمينها في ملف ‎.ics. تصدير استعادة استعادة من ملف ‎.ics استيراد الأحداث من نسخة احتياطية أو تطبيق تقويم آخر. النسخ الاحتياطي التلقائي - قم بتصدير تقويماتك المحلية بشكل دوري إلى مجلد كـ ‎.ics الملف. + قم بتصدير تقويماتك المحلية بشكل دوري إلى مجلد كـ ملف ‎.ics. مجلد النسخ الاحتياطي اضغط لاختيار مجلد كل %1$s @@ -472,7 +472,7 @@ تعذّر تصدير النسخة الاحتياطية. تم تصدير (%d) لا أحداث. - تم تصدير (%d) حدث واحد. + تم تصدير %d حدث واحد. تم تصدير %d حدثان. تم تصدير %d أحداث. تم تصدير %d حدث. @@ -500,11 +500,11 @@ إضافة مربع الإعدادات السريعة استيراد ليس الآن - تم نسخ التقرير إلى الحافظة - تعذّر فتح متتبع المشكلات. التقرير موجود في الحافظة الخاصة بك. + تم نسخ التقرير إلى حافظتك + تعذّر فتح متتبع المشكلات. التقرير موجود في حافظتك. تقرير العطل %1$s تقرير العطل - _(كان التقرير طويلًا جدًا لهذا الرابط — الصقه من الحافظة هنا.)_ + _(كان التقرير طويلًا جدًا لهذا الرابط — الصقه من حافظتك هنا.)_ أعياد الميلاد التواريخ الخاصة هذه القاعدة لا تتكرر أبدًا @@ -515,18 +515,18 @@ إظهار الشهر بأكمله الأسبوع و اليوم طريقة العرض التي يفتحها Calendula عندما تقوم ببدءه. - اليوم الذي يبدأ به كل أسبوع، في جميع طرق العرض والودجتز. + اليوم الذي يبدأ به كل أسبوع، في جميع طُرق العرض والأدوات المصغّرة. كيف الأوقات تُكتب في جميع أنحاء التطبيق. تلقائيًا يتبع إعداد نظامك. ما الذي يفعله جدول بالأحداث التي انتهت بالفعل. إظهار \"%1$s\" بعض التقويمات متوقفة - Calendula يعرض الآن التقويمات التي تم تشغيلها لهذا الجهاز، لذلك ما تراه وما يذكرك لم يعد بإمكانه أن يختلف. بعض تقويماتك متوقفة حاليًا — تم إيقافها هنا أو في تطبيق تقويم آخر. أعد تشغيل أي منها في الإعدادات ← التقويمات. + Calendula يعرض التقويمات التي تم تشغيلها لهذا الجهاز، وبعض من خاصتك متوقفة. لرؤية أحداثهم، قم بتشغيلهم ضمن الإعدادات ← التقويمات. تفتقد تقويمًا؟ - قد يكون متوقفًا عن التشغيل، للقراءة فقط أو مملوءًا من جهات الاتصال الخاصة بك — يمكنك إدارة تقويماتك هنا. + قد يكون متوقفًا عن التشغيل، للقراءة فقط أو مملوءًا من جهات اتصالك — يمكنك إدارة تقويماتك هنا. للقراءة فقط لم تتم المزامنة مع هذا الجهاز تم ملؤه من جهات اتصالك - تم ملء هذا التقويم من جهات الاتصال الخاصة بك، لذلك سيقوم Calendula بإنشائه مرة أخرى في المزامنة التالية. قم بإيقاف تشغيل التواريخ الخاصة ضمن الإعدادات ← التواريخ الخاصة لحذفها. + تم ملء هذا التقويم من جهات اتصالك، لذلك سيقوم Calendula بإنشائه مرة أخرى في المزامنة التالية. قم بإيقاف تشغيل التواريخ الخاصة ضمن الإعدادات ← التواريخ الخاصة لحذفها. كحد أقصى %1$s الافتراضي (%1$s) استخدام المدة الافتراضية (%1$s) @@ -542,4 +542,167 @@ العادي مجموعات أوسع، تمرير أكثر الارتفاع الذي قمت بضغظت المخطط الزمني إليه + يتابعك أينما كنت + مُعَلَّم كـ سري؛ ما يعنيه ذلك متروك لحساب التقويم + نص الحدث %1$d%% + امنح تقويم طوله الافتراضي خاص به — للمثال. ٨ ساعات لمناوبات العمل. + تصدير، استيراد، النسخ الاحتياطي التلقائي + كل طُرق العرض + المدة لكل تقويم + تم النقل إلى %1$s + تراجع + تم التراجع عن النقل + تعذّر نقل الحدث + Calendula يحتاج إلى صلاحية للكتابة لنقل الأحداث + لم يعد هذا الحدث موجودًا + الخطوة %1$d من %2$d + أحداثك تعيش فقط هنا + لا شئ على هذا الهاتف تتم مزامنته لحساب، لذلك فقدانها سيؤدي لفقدان تقويمك. Calendula يمكنه كتابة نسخة احتياطية لك. + النسخ الاحتياطية هي ملفات .ics عادية — ضعها في مكان ما يزامن، أو على بطاقة SD. + مرة واحدة يوميًا، من تلقاء نفسه + Calendula يقم بتصدير تقويماتك المحلية في الخلفية. يمكنك تغيير مدى المرات في الإعدادات. + اختر مجلد وانسخ احتياطيًا + ليس الآن + ما الذي يجب أن يفتح أولاً؟ + كيف يجب أن يبدو الشهر؟ + متابعة + رجوع + فهمت + تم إعدادك بالكامل + تقويماتك جاهزه. يمكن تغيير كل ما اخترته للتو لاحقًا في الإعدادات. + افتح تقويمي + + تم تحديد (%d) + تم تحديد %d + تم تحديد %d + تم تحديد %d + تم تحديد %d + تم تحديد %d + + عُثِر في الأوصاف + إلغاء التحديد + تحديد الكل + حذف المُحدّد + + حذف (%d) حدث؟ + حذف %d حدث واحد؟ + حذف %d حدثان؟ + حذف %d أحداث؟ + حذف %d حدث؟ + حذف %d حدث؟ + + + تم حذف (%d) لا حدث + تم حذف %d حدث واحد + تم حذف %d حدثان + تم حذف %d أحداث + تم حذف %d حدث + تم حذف %d حدث + + تم حذف %1$d، وتعذّر حذف %2$d + تم حذف %1$d، ثم تم سحب صلاحية للكتابة + + تم تخطي (%d) بالفعل في هذا التقويم. + تم تخطي %d بالفعل في هذا التقويم. + تم تخطي %d بالفعل في هذا التقويم. + تم تخطي %d بالفعل في هذا التقويم. + تم تخطي %d بالفعل في هذا التقويم. + تم تخطي %d بالفعل في هذا التقويم. + + + تم استيراد (%d) لا أحداث. + تم استيراد %d حدث واحد. + تم استيراد %d حدثان. + تم استيراد %d أحداث. + تم استيراد %d حدث. + تم استيراد %d حدث. + + + استيراد (%d) لا أحداث + استيراد %d حدث واحد + استيراد %d حدثان + استيراد %d أحداث + استيراد %d حدث + استيراد %d حدث + + + (%d) لا أحداث في هذا الملف. + %d حدث واحد في هذا الملف. + %d حدثان في هذا الملف. + %d أحداث في هذا الملف. + %d حدث في هذا الملف. + %d حدث في هذا الملف. + + + يتم استيراد (%d) لا أحداث + يتم استيراد %d حدث واحد + يتم استيراد %d حدثان + يتم استيراد %d أحداث + يتم استيراد %d حدث + يتم استيراد %d حدث + + نقل الحدث المتكرر + فقط الحقول التي عدلتها هي التي ستحل محل التغيير الخارجي + اسحب لإعادة الترتيب + اسحب لإعادة ترتيب طرق العرض المدرجة في قائمة التنقل. + قائمة التنقل + أحداث يوم كامل + وقت تذكير اليوم الكامل + لا شئ + أندرويد قد يؤخر التذكيرات لتوفير البطارية. استثنِ Calendula لكي تصل في الوقت المحدد. + التذكيرات لكل تقويم + MIT + متوقف + اضبط لون كل تقويم وإمكانية ظهوره في إعدادات التقويمات. + قم بإيقاف تقويم لإخفائه على هذا الجهاز — تختفي أحداثه من التطبيق ويتوقف عن تذكيرك. هذا هو مفتاح التبديل نفسه الذي تستخدمه تطبيقات التقويم الأخرى خاصتك، لذلك تقوم بإخفائه أيضًا. لا شيء يتم حذفه، ولا يتأثر أي جهاز آخر، ويمكنك إعادة تشغيله من هنا في أي وقت. + يتم الإشعار في %1$s + مدى أداة الجدول + حجم أداة الجدول + أداة الجدول، عنصر الإعدادات السريعة + السحب لتعديل الموعد + انقل حدثًا عن طريق سحبه إلى يوم أو وقت آخر + لا يمكن نقل حدث بعد نهاية سلسلته + تقرير + اختر أي طُرق العرض التي يتم التنقل بينها باستخدام الزر في أعلى اليمين، واسحب لإعادة ترتيبهم. تظل طُرق العرض التي تم إيقافها متاحة من قائمة التنقل. + %1$s (%2$s) + %1$s %2$s + إعدادات لأدوات الشاشة الرئيسية وعنصر الإعدادات السريعة. أضف أداة عن طريق الضغط لفترة طويلة على شاشتك الرئيسية. + لم يتم استيراد قوائم الضيوف. + معفى من تحسين البطارية — تصل التذكيرات في الوقت المحدد. + توصيل موثوق + تنطلق التذكيرات لأحداث التي تستمر طوال اليوم عند %1$s + فقط للآن — يعيد التعيين للمدى المحفوظ خاصتك عند إعادة فتح Calendula. + مراجعة الفريق + غداء مع روبن + ممارسة الجوقة + آخر مزامنة %1$s + يؤدي هذا لحذف التقويم ”%1$s“ وأحداثه. سيتم فقدان أي تذكيرات أو ملاحظات أضفتها إليه. + عيد ميلاد {name} ({year}) + نقل… + الخميس، 14 مايو + مراجعة الفريق في 10:00، ثم الغداء مع روبن في المقهى في ماركتبلاتز. + الأدوات المصغّرة والعناصر + مدى حجم النص الذي يعرضه جدول أداة الشاشة الرئيسية. أداة الشهر ليس لديه إعداد — تتناسب شبكته دائمًا نفسه للمساحة التي تمنحها إيّاه. + كم مدى يعرض جدول أداة الشاشة الرئيسية الأحداث. + كم مدى يعرض شاشة الجدول الأحداث. + Calendula + Atkinson Hyperlegible + إظهار السنة + تضمين {year} في العناوين عندما يكون معروفًا + {name} + ذكرى {name} السنوية ({year}) + الذكرات السنوية + أُغلق %1$s بشكل غير متوقع في المرة الأخيرة. يمكنك المساعدة في إصلاحه عن طريق إرسال هذا التقرير كمشكلة. سيبقى على جهازك حتى تختار مشاركته، ولا يتضمن أي بيانات شخصية أو محتوى تقويم — فقط التفاصيل الفنية أدناه. + Lora + JetBrains Mono + مجلد تختاره + منطقة زمنية غير معروفة ترجع إلى الخاصة بجهازك. + تعيين في تواريخ جهات الاتصال الخاصة + يؤدي هذا إلى حذف تقويمات تواريخ جهات الاتصال وأحداثها. سيتم فقدان أي تذكيرات أو ملاحظات أضفتها إليها. + الذكرات السنوية + استخدم {name} لجهة الاتصال و{year} للسنة (سنة الميلاد، أو سنة بدء الذكرى السنوية؛ مخفية عندما تكون غير معروفة). + صيغة العنوان + تجاوز الافتراضي لكل تقويم — بشكل منفصل للمحدد بوقت وأحداث طوال اليوم. يمكن لتقويم الاحتفاظ بالافتراضي، إسقاطه أو تعيين خاصّته. + سيؤدي نقل السلسلة بأكملها لتغيير الأيام التي تقع فيها، لذلك يمكن نقل هذا الحدث فقط. + أنت على اطلاع diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml new file mode 100644 index 0000000..d488623 --- /dev/null +++ b/app/src/main/res/values-be/strings.xml @@ -0,0 +1,587 @@ + + + + Minutes + Hours + Days + Weeks + + Сучасны каляндар. + Загрузка… + Паўтарыць спробу + Узнікла нейкая праблема. + Патрабуецца доступ да календара. + Дазволіць доступ + Календары не сканфігураваныя. + Адкрыць сыстэмныя налады календароў + Немагчыма прачытаць каляндар. + Прагляд усіх падзеяў у прыгожым выглядзе + Для паказу і кіраваньня падзеямі Calendula патрабуецца доступ да вашага календара. + Дазволіць доступ да календара + Няма доступу да календара + Calendula ня можа паказваць падзеі бяз доступу да календара. + Адкрыць сыстэмныя налады + Паўтарыць спробу + Распрацавана з акцэнтам на прыватнасьць + Бяз доступу да Інтэрнэту, без тэлемэтрыі — нічога і ніколі не пакідае ваш тэлефон. + Усе вашы календары разам + Google, CalDAV, лякальныя — паказваюцца ўсе, сынхранізаваныя з прыладай. + Ніякага адсочваньня, ніколі + Без тэлемэтрыі, без аналітыкі, без рэклямы. + Усё застаецца на прыладзе, доступ да Інтэрнэту не патрабуецца + Папярэдні месяц + Наступны месяц + Сёньня + Іншыя дзеяньні + Адкрыць мэню + Налады + Сёньня + Гэты тыдзень + Тдз. + Сёньня + Назад + Праўка + Выдаліць + Падзяліцца + Дублікат + Падзяліцца падзеяй + Не атрымалася падзяліцца гэтай падзеяй. + Выдаліць падзею? + Падзея выдаленая з вашага календара і з усіх прыладаў, зь якімі адбываецца сынхранізацыя. + Выдаліць пэрыядычную падзею + Толькі гэтую падзею + Гэтую і ўсе наступныя падзеі + Усе падзеі ў пасьлядоўнасьці + Рэдагаваць пэрыядычную падзею + Не атрымалася выдаліць падзею + Для выдаленьня падзеяў Calendula патрабуецца дазвол на запіс + Перанесці… + Перанесці пэрыядычную падзею + Перанос усёй пасьлядоўнасьці зьменіць дні, на якія яна прыпадае, таму перанесці можна толькі гэтую падзею. + Перанесена на %1$s + АдрабІць + Перанос скасаваны + Не атрымалася перанесьці падзею + Для пераносу падзеяў Calendula патрабуецца дазвол на запіс + Гэтай падзеі больш не існуе + Нельга перанесьці падзею на дату пасьля заканчэньня яе пасьлядоўнасьці + Адмена + ОК + Новая падзея + Закрыць + Захаваць + Дадаць загаловак + Кіруецца «%1$s» — загаловак, дата і паўтор сынхранізуюцца з вашымі кантактамі. Напаміны, месцазнаходжаньне і нататкі даступныя вам для рэдагаваньня. + Пачатак + Заканчэньне + Заканчваецца раней, чым пачынаецца + Няма календара, у які дазволены запіс + Не атрымалася захаваць падзею + Для стварэньня падзеяў Calendula патрабуецца дазвол на запіс + Дадатковыя палі + Дадаць + Дадаць напамін + Выдаліць напамін + Наведвальнікі + Дадаць наведвальніка + Дадаць наведвальніка праз электронную пошту… + Дадаць з кантактаў + Выбраць адрас з кантактаў + Выдаліць наведвальніка + Неабходны + Неабавязковы + Calendula не рассылае запрашэньні. Наведвальнікі могуць атрымаць электронныя лісты з вашага ўліковага запісу календара пры сынхранізацыі. + Захавана на гэтай прыладзе. Ніхто не атрымаў абвесткі. + Карыстальніцкі + хв. + гадз. + сут. + тыд. + Даступнасьць + Бачнасьць + Часавы пояс прылады + Сочыць за вашым месцазнаходжаньнем + Пошук часавых паясоў + Апошнія + Усе часавыя паясы + Няма часавага поясу, які адпавядае «%1$s» + %1$s паводле вашага часу + Колер + Колер календара + Карыстальніцкі колер + Скід + Недаступна для гэтага календара + Для гэтага календара не апублікаваны набор колераў. Для падобных календароў у наладах можна задаць карыстальніцкія колеры. + Пры наступнай сынхранізацыі гэты каляндар можа страціць або перазапісаць колер. + Падзея зьмянілася ў іншым месцы + Падчас рэдагаваньня вамі гэтая падзея была зьменена ў выніку сынхранізацыі або іншай праграмай. Што адбудзецца з вашымі зьменамі? + Захаваць мае змены + Толькі адрэдагаваныя вамі палі перазапішуць зьнешнія зьмены + Адкінуць мае зьмены + Падзея застаецца такой, якой яна ёсьць зараз + Падзея выдалена + Гэтая падзея ўжо выдаленая, напрыклад на іншай прыладзе. Вашыя зьмены болей немагчыма захаваць. + Ужыць напамін па змаўчаньні? + Гэтая падзея была імпартаваная без напаміну. + + "Гэтая падзея была імпартаваная з наст. колькасьцю напамінаў: %1$d." + "Гэтая падзея была імпартаваная з наст. колькасьцю напамінаў: %1$d." + "Гэтая падзея была імпартаваная з наст. колькасьцю напамінаў: %1$d." + "Гэтая падзея была імпартаваная з наст. колькасьцю напамінаў: %1$d." + + Ужыць змаўчаньні + Пакінуць як ёсьць + Не паўтараецца + Карыстальніцкі варыянт + Кожн. + сут. + тыд. + мес. + год/гады + Увядзеце лік ад 1 да 999 + Наступ.: %1$s + Гэтае правіла ніколі не паўтараецца + Заканчэньне + Ніколі + На дату + Пасьля некалькіх разоў + разы(оў) + Calendula + Паўторы заканчваюцца да пачатку падзеі + Заняты + Па змаўчаньні + Публічны + Цэлы дзень + Каляндар + Невядомы каляндар + Месцазнаходжаньне + Апісаньне + Удзельнікі + Паўтор + Паўтаральная падзея + Кожны дзень + Кожны тыдзень + Кожны месяц + Кожны год + Кожныя %1$d сут. + Кожныя %1$d тыд. + Кожныя %1$d мес. + Кожныя %1$d гады(оў) + %1$s у %2$s + з %1$s да %2$s + %1$s, %2$d разы(оў) + Гэтай падзеі больш не існуе. + Прынята + Адхілена + Пад пытаньнем + Няма адказу + + Напаміны + Часавы пояс + Пад пытаньнем + Скасавана + Свабодна + Прыватна + Канфідэнцыйна + Што звычайна робіць гэты каляндар + Поўныя зьвесткі могуць бачыць усе, хто мае доступ + Іншыя бачаць толькі тое, што вы занятыя + Пазначана як канфідэнцыйнае: што гэта значыць, залежыць ад уліковага запісу календара + Арганізатар + Неабавязкова + Рэсурс + Ваш адказ: %1$s + На момант падзеі + Напамін па змаўчаньні + + За %d хвіліну + За %d хв. + За %d хв. + За %d хв. + + + За %d гадзіну + За %d гадз. + За %d гадз. + За %d гадз. + + + За %d суткі + За %d сут. + За %d сут. + За %d сут. + + + За %d тыдзень + За %d тыдз. + За %d тыдз. + За %d тыдз. + + + %d хвіліна + %d хв. + %d хв. + %d хв. + + + %d гадзіна + %d гадз. + %d гадз. + %d гадз. + + + %d суткі + %d сут. + %d сут. + %d сут. + + %1$s гадз. %2$s хв. + Макс. %1$s + + %d тыдзень + %d тыдз. + %d тыдз. + %d тыдз. + + (без загалоўка) + Крок %1$d з %2$d + Вашы падзеі знаходзяцца толькі тут + На гэтым тэлефоне не адбываецца сынхранізацыя зь якім-небудзь уліковым запісам, і страта азначае страту вашага календара. Calendula можа зрабіць рэзервую копію для вас. + Каталёг на ваш выбар + Рэзэрвовыя копіі — гэта звычайныя файлы .ics. Захоўвайце іх у месцы, дзе яны сынхранізуюцца, або на SD-карце. + Раз за суткі, аўтаматычна + Calendula робіць экспарт вашых лякальных календароў у фонавым рэжыме. Зьмяніць пэрыядычнасьць можна ў наладах. + Выбраць каталёг і зрабіць рэзэрвовую копію + Ня зараз + Што павінна адкрыцца першым? + Як мусіць выглядаць месяц? + Далей + Назад + Зразумела + Усё зроблена + Вашы календары гатовыя. Любы выбар, зроблены зараз, можна пазьней зьмяніць у наладах. + Адкрыць мой каляндар + Напаміны пра падзеі + Абвесткі ў час напамінаў пра вашы падзеі + Не прапускаць ніводную падзею + Сыстэма Android сама не паказвае напаміны — гэта мусіць рабіць дадатак календара. Хай гэта робіць Calendula. + Напаміны, дастаўленыя + Кожны напамін пра вашы падзеі прыходзіць у выглядзе своечасовай абвесткі. + Карыстаецеся яшчэ адным дадаткам календара? + Калі іншы дадатак таксама выдае напаміны, вы будзеце бачыць іх двойчы — адключэце іх там або тут. + Гэта можна зьмяніць у які заўгодна час + Перамыкач знаходзіцца ў наладах, у разьдзеле «Абвесткі». + Уключыць напаміны + Ня зараз + Адкласьці + Адхіліць + Заўтра + Учора + Месяц + Тыдзень + Дзень + Расклад + Выгляд + Пераход да даты + Сёньня + Сёньня + Заўтра + Усё завершана + Сёньня больш няма падзеяў + Пачатак: %1$s + Заканчэньне: %1$s + Перайсці на сёньня + Пошук + Пошук падзеяў + Назад + Ачысьціць + Пошук падзеяў па загалоўках, месцазнаходжаньні або зацемках. + Няма падзей, якія адпавядаюць «%1$s». + + Вылучана: %d + Вылучана: %d + Вылучана: %d + Вылучана: %d + + Знойдзена ў апісаньнях + Зьняць вылучэньне + Вылучыць усё + Выдаліць вылучанае + + Выдаліць падзеі (%d)? + Выдаліць падзеі (%d)? + Выдаліць падзеі (%d)? + Выдаліць падзеі (%d)? + + + Выдаленая %d падзея + Выдалена падзеяў: %d + Выдалена падзеяў: %d + Выдалена падзеяў: %d + + Выдалена %1$d, не атрымалася выдаліць %2$d + Выдалена %1$d, затым быў скасаваны дазвол на запіс + Наступнае + Расклад Calendula + Месяц Calendula + Абнавіць + Новая падзея + Адкрыйце Calendula, каб дазволіць доступ да календара + Папярэдні месяц + Наступны месяц + Сёньня + Календары + Налады + Назад + Назад + Афармленьне + Тэма + Сыстэмная + Сьветлая + Цёмная + Сьветлы або цёмны выгляд дадатку. Выбар будзе ўжыты адразу ж. + Зараз: %1$s + Выгляд па змаўчаньні + Дынамічны колер + Патрабуецца сыстэма Android вэрсіі 12 або навейшай + Гарманізацыя колераў календара + Вытрымліваць адценьне і яркасьць кожнага календара, каб кожная падзея заставалася чытэльнай, а колеры спалучаліся. Адключыць, каб паказваць зыходныя колеры з крыніцы календара. + Шрыфт загалоўкаў + Шрыфт тэксту + Сыстэмны + Atkinson Hyperlegible + Lora + JetBrains Mono + Выбраць файл… + Карыстальніцкі + Не атрымалася прачытаць файлы шрыфту + Чацьвер, 14 траўня + Камандны разгляд а 10:00, затым абед з Робінам у кавярні на Марктпляц. + Тыдзень пачынаецца з + Аўтаматычна + Зараз: %1$s + Нумары тыдняў + Паказваць нумары тыдняў календара ў рэжыме прагляду месяца + Кнопка «Сёньня» на панэлі + Паказваць кнопку пераходу да сёньняшняга дня на панэлі прыладаў замест плаваючай кнопкі + Назва дадатку + Паказаць Calendula як «Каляндар» на панэлі запуску. На панэлі запуску зьменіцца толькі назва. Пасьля пераключэньня можа зьмяніцца месцазнаходжаньне абразка. + Фармат часу + Аўта + 12-гадз. (2:00 пасьля абеду) + 24-гадз. (14:00) + Сыстэмная налада: %1$s + Лініі гадзінаў + Паказваць раздзяляльную лінію для кожнай гадзіны ў рэжыме прагляду тыдня і дня + Вышыня гадзіны + Колькі прасторы па вэртыкалі даецца на гадзіну ў рэжыме прагляду тыдня і дня. Гэтая налада агульная для абодвух рэжымаў прагляду. Таксама можна двума пальцамі перамяшчаць лінію, каб задаць нейкую іншую вышыню для гадзінаў. + Цэлыя суткі + На экране ўсе 24 гадзіны, прагортваць ня трэба + Кампактна + На экране больш гадзінаў, блёкі меншыя + Звычайна + Стандартны інтэрвал + Камфортна + Большыя блёкі, больш прагорткі + Карыстальніцкі варыянт + Вышыня ў выніку перамяшчэньня лініі пальцамі + Зацямняць завершаныя падзеі + Зацямняць завершаныя падзеі ў рэжыме прагляду месяца і тыдня + Перацягваць, каб пераплянаваць + Перанесьці падзею, перацягнуўшы яе на іншы дзень або час + Мінулыя падзеі + Паказваць + Зацямняць + Хаваць + Камандны разгляд + Абед з Робінам + Рэпетыцыя ў хоры + Расклад + Дыяпазон раскладу + Наколькі далёка наперад паказваюцца падзеі на экране раскладу. + Дыяпазон віджэта раскладу + Наколькі далёка наперад паказваюцца падзеі раскладу на галоўным экране віджэта. + Памер віджэта раскладу + Наколькі вялікім выглядае галоўны экран, на які выводзіцца тэкст раскладу. Віджэт у рэжыме прагляду месяца ня мае падобнага парамэтра, ягоная сетка займае ўсю выдзеленую прастору. + Дробны + Сярэдні + Буйны + Вельмі буйны + Тэкст падзеі %1$d%% + Заўсёды паказваць сёньняшні дзень + Сёньняшні дзень застаецца ў верхняй частцы раскладу і віджэта, нават калі падзеяў больш няма. + Панэль дыяпазона + Паказваць уверсе панэль раскладу зь пералікам паказаных дат і кнопку для пераключэньня дыяпазону для сэсіі + Сёньня + Гэты тыдзень + Гэты месяц + Наступныя 7 сутак + Наступныя 30 сутак + Свой варыянт… + Суткі + Толькі цяпер. Пры паўторным адкрыцьці Calendula дыяпазон скінецца да захаванага значэньня. + Паказаныя ўсе будучыя падзеі для + + %d суткі + %d сут. + %d сут. + %d сут. + + Рэжымы прагляду + Дні нараджэньня + Юбілейныя даты + Іншыя даты + Фармат загалоўка + Ужываць {name} для імя кантакту і {year} для года (год нараджэньня або год пачатку юбілейнай падзеі; схаваны, калі невядомы). + Напаміны + Паказаць год + Пазначаць {year} (год) у загалоўках, калі вядомы + Сынхранізаваць зараз + Пакуль не сынхранізавана + Апошняя сынхранізацыя %1$s + Задаць у наладах колер і бачнасьць кожнага календара. + Задаць асаблівыя даты кантактаў + Прыпынена + Calendula больш ня можа чытаць вашы кантакты, таму гэтыя календары не абнаўляюцца. + Дазволіць доступ + Адключыць даты кантактаў? + Пры гэты выдаляцца календары з датамі кантактаў і адпаведныя падзеі. Даданыя да іх напаміны або заўвагі зьнікнуць. + Пры гэтым выдаліцца каляндар «%1$s» зь ягонымі падзеямі. Даданыя да іх напаміны або зацемкі зьнікнуць. + Адключыць + Захаваць + Аб дадатку + Ліцэнзія + MIT + аўтар: Жан-Люк Макіёла + Зыходны код + Палітыка канфідэнцыйнасьці + Падтрымка распрацоўкі + Вэрсія %1$s + Абразок дадатку Calendula + Паведаміць пра праблему + Адправіць справаздачу аб краху або адкрыць сыстэму адсочваньня праблемаў + Календары + Вашыя календары + Пакуль няма лякальных календароў. Стварыць, каб захоўваць падзеі толькі на гэтай прыладзе. + Дадаць каляндар + Адключыць каляндар, каб схаваць яго на гэтай прыладзе — падзеі і напаміны зь яго зьнікнуць з дадатку. Гэты перамыкач выкарыстоўваюць іншыя дадаткі календара, таму яны таксама яго схаваюць. Нічога не выдаляецца, гэта не ўплывае на іншыя прылады, і вы можаце ўключыць яго зноў тут у які заўгодна час. + Паказаць «%1$s» + Некаторыя календары адключаныя + Calendula паказвае календары, уключаныя на гэтай прыладзе, а некаторыя з вашых адключаны. Каб убачыць падзеі ў іх, уключэце іх у разьдзеле «Налады» → «Календары». + Не хапае календара? + Магчыма ён адключаны, у рэжыме толькі чытаньня, або запоўнены з вашых кантактаў — кіруйце календарамі тут. + Толькі чытаньне + Месяц + Стыль прагляду месяца + Старонкі + Адзін месяц на ўвесь экран. Каб пабачыць іншы месяц, праводзьце пальцам улева або ўправа. + Месяцы з прагорткай + Кожны месяц мае ўласны загаловак, яны разьдзяляюцца невялікімі прамежкамі. + Тыдні з плынным пераходам + Тыдні йдуць без перапынку, кожны месяц перацякае ў наступны без прагалу паміж імі. + Разьдзельны + Кампактная сетка пазначае дні з падзеямі, а дзень, на які вы націскаеце, адлюстроўваецца ўнізе. + Нічога не заплянавана + Паказваць месяц цалкам + Паказваць падзеі дня + Кнопка хуткага пераключэньня + Выбраць рэжымы прагляду для цыклічнага пераключэньня кнопкай у верхнім правым куце. Перацягваць для зьмены парадку. Адключаныя рэжымы прагляду застаюцца даступнымі праз мэню навігацыі + Мэню навігацыі + Перацягваць для зьмены парадку рэжымаў прагляду, пералічаных у мэню навігацыі. + Перацягваць для зьмены парадку + Шаблён новай падзеі + Палі, якія паказваюцца па змаўчаньні; усе астатнія знаходзіцца ў раздзеле «Дадатковыя палі» + Фокус на загалоўку новай падзеі + Пры старце новай падзеі курсор зьмяшчаецца ў полі загалоўка, адразу ж адкрываецца клявіятура. + Працягласьць па змаўчаньні + Колькі часу працягваецца новая падзея, пакуль ня будзе зьменены час заканчэньня. На падзеі на цэлы дзень гэта не ўплывае. + Працягласьць для кожнага календара + Задаць календару ўласную працягласьць па змаўчаньні — напр. 8 гадз. для працоўных зьменаў. + Па змаўчаньні (%1$s) + Ужываць працягласьць па змаўчаньні (%1$s) + Дазволіць колеры ў непадтрыманых календарох + У некаторых календарох (напрыклад, у некаторых CalDAV) няма апублікаванага набору колераў; карыстальніцкі колер падзеі можа быць выдалены або перазапісаны пры наступнай сынхранізацыі. Гэтае абмежаваньне падобных календароў Calendula ня ў стане выправіць. + Абвесткі + Напаміны пра падзеі + Напаміны зьяўляюцца двойчы? Іх таксама публікуе іншы дадатак календара — адключэце іх у адным з двух. + Напамін па змаўчаньні + Падзеі на цэлы дзень + Час напаміну аб падзеях на цэлы дзень + Напаміны аб падзеях на цэлы дзень зьяўляюцца ў %1$s + Час абвесткі: %1$s + Няма + Выкарыстоўваць напамін па змаўчаньні + Велічыня + Задаць + Напаміны для асобных календароў + Перавызначыць наладу па змаўчаньні для кожнага календара — асобна для падзеяў з пэўным часам і на цэлы дзень. Каляндар можа захаваць наладу па змаўчаньні, выдаліць яе або задаць сваю ўласную. + Па змаўчаньні (%1$s) + Надзейная дастаўка + Сыстэма Android можа затрымліваць напаміны ў мэтах ашчаднага расходу зараду акумулятара. Зрабеце выключэньне для Calendula, каб яны зьяўляліся своечасова. + Выключэньне з рэжыму аптымізацыі акумулятара — напаміны прыходзяць своечасова. + Працягласць адкладу + Календары + Кіраваньне календарамі + Стварэньне лякальных календароў; кіраваньне сынхранізаванымі + Мова + Мова дадатку + Сыстэмная + Дапамагчы зь перакладам + Дадаць мову або палепшыць пераклад у Weblate + Зьнешні выгляд і паводзіны + Даныя + Дадатак + Аб дадатку + Тэма, колеры, шрыфты + Выгляд, макет, парадак па змаўчаньні + Палі і паводзіны па змаўчаньні + Напаміны і дастаўка + Дні нараджэньня і юбілейныя даты кантактаў + Віджэт раскладу, плітка хуткіх налад + Экспарт, імпарт, аўтаматычнае рэзэрвовае капіяваньне + Віджэты і пліткі + Парамэтры віджэтаў на галоўным экране і пліткі хуткіх наладаў. Дадаць віджэты на галоўны экран праз доўгае націсканьне. + Рэзервовае капіяваньне і аднаўленьне + Усе рэжымы прагляду + Тыдзень і дзень + Рэжым прагляду пры запуску Calendula. + Дзень пачатку тыдня для ўсіх рэжымаў прагляду і віджэтаў. + Як запісваецца час у дадатку. Аўтаматычна — значыць у адпаведнасьці з наладамі вашай сыстэмы. + Што адбываецца з завершанымі падзеямі распарадку. + Колеры для дадатку са шпалераў. + Асаблівыя даты кантактаў + Паказваць даты кантактаў + Адлюстроўваць дні нараджэньня і іншыя даты вашых кантактаў у лякальных календарох. Чытаюцца толькі кантакты на гэтай прыладзе — нічога не запампоўваецца, вашы кантакты ніколі не зьмяняюцца. + Не сынхранізавана з гэтай прыладай + Запоўнена з вашых кантактаў + Гэты каляндар запоўнены данымі з вашых кантактаў, таму Calendula створыць яго зноў пры наступнай сынхранізацыі. Каб выдаліць яго, адключэце асаблівыя даты ў меню «Налады» → «Асаблівыя даты». + Сынхранізаваныя календары + Яны паступаюць з уліковых запісаў на вашай прыладзе. Стварайце і рэдагуйце іх у асобным дадатку. + Кіраваць у дадатку + %1$s (%2$s) + Дадатковыя парамэтры для %1$s + Уключыць усе + Адключыць усе + Дадаць ул. запіс + Новы каляндар + Рэдагаваць каляндар + Назва + Колер + Дадаць апісаньне + Выдаліць каляндар? + Каляндар «%1$s» зь ягонымі падзеямі назаўжды выдалены з гэтай прылады. + Не атрымалася захаваць зьмены. + Рэзэрвовае капіяваньне + Лякальныя календары ні з чым не сынхранізуюцца, таму экспартуйце іх у файл .ics, каб захаваць копію. + Экспарт у файл .ics + Экспартаваць календары + Выбраць календары для запісу ў файл .ics. + Экспарт + Аднавіць + Аднавіць з файла .ics + Імпартаваць падзеі з рэзэрвовай копіі або іншага дадатку календара. + Аўтаматычнае рэзэрвовае капіяваньне + Пэрыядычны экспарт лякальных календароў у каталёг у выглядзе файла .ics. + Каталёг рэзэрвовага капіяваньня + Націсьніце, каб выбраць каталёг + diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml new file mode 100644 index 0000000..34abdc2 --- /dev/null +++ b/app/src/main/res/values-cs/strings.xml @@ -0,0 +1,335 @@ + + + + Minutes + Hours + Days + Weeks + + Calendula + Moderní kalendář. + Načítá se… + Opakovat + Něco se pokazilo. + Je nutný přístup ke kalendáři. + Udělit přístup + Nejsou konfigurovány žádné kalendáře. + Udělit přístup ke kalendáři + Calendula potřebuje přístup k vašemu kalendáři, aby mohla zobrazovat a spravovat vaše události. + Všechny vaše kalendáře na jednom místě + Google, CalDAV, lokální kalendáře — cokoliv synchronizovaného se zařízením. + Navrženo pro soukromí + Žádný přístup k internetu, žádná telemetrie — nikdy nedojde k úniku dat z vašeho telefonu. + Krok %1$d z %2$d + Prohlížejte všechny události, hezky a přehledně + Přístup ke kalendáři byl odepřen + Calendula nemůže zobrazovat události bez uděleného přístupu ke kalendáři. Přístup můžete znovu udělit v nastavení systému. + Otevřít nastavení kalendáře systému + Otevřít nastavení systému + Zkusit to znovu + Nepodařilo se načíst kalendář. + Žádné sledování, nikdy + Žádná telemetrie, žádné analytické nástroje, bez reklam. + Zůstává na vašem zařízení · bez přístupu k internetu + Předchozí měsíc + Následující měsíc + Dnes + Další akce + Otevřít nabídku + Nastavení + Dnes + Tento týden + týd. + Dnes + Zpět + Upravit + Odstranit + Sdílet + Kopírovat + Sdílet událost + Tuto událost nelze sdílet. + Odstranit událost? + Událost je odstraněna z kalendáře i ze všech synchronizovaných zařízení. + Odstranit opakující se událost + Pouze tuto událost + Tuto a všechny následující události + Všechny události v sérii + Upravit opakující se událost + Tuto událost nelze odstranit + Calendula potřebuje oprávnění k zápisu, aby mohla odstraňovat události + Přesunout… + Přesunout opakující se událost + Přesunutí celé série by rozhodilo pořadí dnů, na které série připadá, přesunout lze tedy pouze tuto událost. + Přesunuto do %1$s + Vrátit + Vráceno zpět + Událost nelze přesunout + Calendula k přesouvání události vyžaduje oprávnění k zápisu + Tato událost již neexistuje + Událost nelze přesunout za konec dané série + Zrušit + OK + Nová událost + Zavřít + Uložit + Přidat název + Spravováno aplikací „%1$s“ — název, datum a opakování je synchronizováno z vašich kontaktů. Upomínky, polohu a poznámky si můžete upravit sami. + Začátek + Konec + Končí dříve, než začíná + Není k dispozici žádný kalendář, který by umožňoval zápis + Nelze uložit událost + Calendula pro vytváření událostí vyžaduje oprávnění k zápisu + další pole + Přidat + Přidat upomínku + Vymazat upomínku + Hosté + Přidat hosta + Přidat hosta e-mailem… + Přidat z kontaktů + Vybrat adresu z kontaktů + Odstranit hosta + Požadované + Volitelné + Calendula nezasílá pozvánky. Váš účet kalendáře může hosty zvát e-mailem při synchronizaci. + Uloženo v tomto zařízení. Nikomu nejsou odesílány notifikace. + Vlastní + minuty + hodiny + dny + týdny + Dostupnost + Viditelnost + Časové pásmo zařízení + Monitoruje vaši polohu, ať už jste kdekoliv + Vyhledat časové pásmo + Nedávné + Všechna časová pásma + Žádné časové pásmo neodpovídá “%1$s” + %1$s vašeho času + Barva + Barva kalendáře + Vlastní barva + Znovu nastavit + Pro tento kalendář není k dispozici + Tento kalendář nemá žádnou sadu barev. Vlastní barvy kalendářů můžete povolit v nastavení. + Tato barva může být během příští synchronizace kalendáře ztracena nebo přepsána. + Ke změně události došlo na jiném místě. + Během úpravy došlo ke změně události — v rámci synchronizace nebo pomocí jiné aplikací. Co se má stát s vašimi změnami? + Uložit změny + Pouze u upravených polí dojde k přepsání vnějšího zdroje + Zrušit změny + Událost zůstává beze změny + Událost byla odstraněna + 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. + Chcete použít výchozí upomínku? + Tato událost byla importována bez nastavené upomínky. + + Tato událost byla importována s %1$d upomínkou. + Tato událost byla importována s %1$d upomínkami. + Tato událost byla importována s %1$d upomínkami. + Tato událost byla importována s %1$d upomínkami. + + Nastavit výchozí + Nechat beze změny + Neopakuje se + Vlastní + Každý + dny + týdny + měsíce + roky + Zadejte číslo v rozmezí 1 až 999 + Následující: %1$s + Toto pravidlo se nikdy neopakuje + Konec + Nikdy + K určitému datu + Po určitém počtu opakování + opakování + Opakování končí před začátkem události + Zaneprázdněný + Výchozí + Veřejný + Celý den + Kalendář + Neznámý kalendář + Umístění + Popis + Účastníci + Opakování + Opakující se událost + Každý den + Každý týden + Každý měsíc + Každý rok + Každý %1$d. den + Každý %1$d. týden + Každý %1$d. měsíc + Každý %1$d. rok + %1$s v %2$s + %1$s do%2$s + %1$s, %2$d opakování + Tato událost již neexistuje. + Přijato + Odmítnuto + Předběžné + Bez odezvy + + Upomínky + Časové pásmo + Přeběžné + Zrušeno + Volné + Soukromé + Utajené + Běžná činnost kalendáře + Kdokoliv s možností přístupu uvidí veškeré podrobnosti + Ostatní uvidí pouze to, že jste zaneprázdněn + Označeno jako utajené; záleží na daném účtu kalendáře, jak s tímto štítkem naloží + Pořadatel + Volitelné + Zdroj + Vaše odpověď: %1$s + V době konání události + Výchozí upomínka + + %d minutu předtím + %d minuty předtím + %d minut předtím + %d minut předtím + + + %d hodinu předtím + %d hodiny předtím + %d hodin předtím + %d hodin předtím + + + %d den předtím + %d dny předtím + %d dnů předtím + %d dnů předtím + + + %d týden předtím + %d týdny předtím + %d týdnů předtím + %d týdnů předtím + + + %d minuta + %d minuty + %d minut + %d minut + + + %d hodina + %d hodiny + %d hodin + %d hodin + + + %d den + %d dny + %d dnů + %d dnů + + %1$s %2$s + Nanejvýš %1$s + + %d týden + %d týdny + %d týdnů + %d týdnů + + (Bez názvu) + Vaši událost naleznete pouze zde + V tomto telefonu se nesynchronizuje žádný účet, takže pokud ho ztratíte, přijdete i o svůj kalendář. Calendula může vytvořit lokální zálohu. + Složka, kterou si vyberete + Zálohy jsou obyčejné soubory v .ics formátu — uložte je někam, kde se synchronizují, nebo na SD kartu. + Jednou denně, automaticky + Calendula exportuje vaše místní kalendáře na pozadí. Frekvenci lze změnit v nastavení. + Vyberte složku a proveďte zálohu + Teď ne + Co by se mělo otevřít nejdříve? + Jak by mělo vypadat měsíční rozvržení? + Pokračovat + Zpět + Jasně + Vše je připraveno + Vaše kalendáře jsou připraveny. Veškeré volby lze později změnit v nastavení. + Otevřít můj kalendář + Upomínky událostí + Oznámení v časech upomínek vašich událostí + Ať vám neunikne žádná událost + Android sám o sobě nezobrazuje upomínky událostí — to musí zajistit aplikace kalendáře. Nechte tuto práci na Calendule. + Upomínky, doručené + Upomínky k vašim událostem vám přijdou v danou dobu jako oznámení. + Používáte ještě nějakou jinou aplikaci s kalendářem? + Pokud i jiná aplikace odesílá upomínky, uvidíte je dvakrát — v jedné z aplikací je musíte vypnout. + Lze kdykoliv změnit + Tento přepínač naleznete v Nastavení, v části Oznámení. + Zapnout upomínky + Později + Odložit + Zrušit + Zítra + Včera + Měsíc + Týden + Den + Program + Zobrazení + Přejít na datum + Dnes + Dnes + Zítra + Teď už jsi v obraze + Dnes bez dalších událostí + Začátek %1$s + Konec %1$s + Přejít na dnešek + Hledat + Vyhledat události + Zpět + Vymazat + Prohledávejte své události podle názvu, místa konání nebo poznámek. + Žádná událost neodpovídá “%1$s”. + + %d označený + %d označené + %d označených + %d označených + + Nalezeno v popisech + Zrušit výběr + Vybrat vše + Odstranit vybrané + + Odstranit %d událost? + Odstranit %d události? + Odstranit %d událostí? + Odstranit %d událostí? + + + %d událost odstraněna + %d události odstraněny + %d událostí odstraněno + %d událostí odstraněno + + %1$d odstraněno, u %2$d se nepodařilo + %1$d odstraněno, poté byl odepřen přístup pro zápis + 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. + Nadcházející + Události Calendula + Měsíc Calendula + Obnovit + Nová událost + Otevřete Calendulu a povolte přístup ke kalendáři + Předchozí měsíc + Následující měsíc + Dnes + Kalendáře + diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml new file mode 100644 index 0000000..20200c6 --- /dev/null +++ b/app/src/main/res/values-hu/strings.xml @@ -0,0 +1,65 @@ + + + + Minutes + Hours + Days + Weeks + + Calendula + Egy modern naptár. + Töltés… + Újra + Hiba történt. + Naptár hozzáférés szükséges. + Hozzáférés engedélyezése + Nincs naptár beállítva. + Rendszernaptár beállítások megnyitása + A naptárat nem lehetett beolvasni. + Lásd az eseményeid, szépen + 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. + Naptár hozzáférés engedélyezése + Naptár hozzáférés megtagadva + 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. + Rendszerbeállítások megnyitása + Próbáld újra + Eleve privát + Nem kell internetelérés, nincs telemetria — semmi sem hagyja el a telefonod. + Az összes naptárad, egy helyen + Google, CalDAV, helyi — minden, ami szinkronizálva van az eszközzel, egyszerűen megjelenik. + Nulla telemetria, nulla analitika, nulla hirdetés. + Az eszközödön marad · nem kell internetelérés + Előző hónap + Következő hónap + Ma + További műveletek + Menü megnyitása + Beállítások + Ma + Ez a hét + Hét + Ma + Vissza + Szerkeszt + Töröl + Megoszt + Másolat + Esemény megosztása + Ezt az eseményt nem lehet megosztani. + Esemény törlése? + Az esemény törlődik a naptárból és minden olyan eszközről, amelyhez szinkronizálódik. + Ismétlődő esemény törlése + Csak ezt az eseményt + Ezt és az összes további eseményt + A sorozat összes eseményét + Ismétlődő esemény szerkesztése + Nem sikerült törölni az eseményt + A Calendula írási hozzáférést igényel az események törléséhez + Áthelyez… + Ismétlődő esemény áthelyezése + A teljes sorozat áthelyezése megváltoztatná, hogy mely napokra esik, így csak ez az esemény helyezhető át. + Áthelyezve ide: %1$s + Visszavon + Visszavont áthelyezése + Naptár + diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 2c36753..e30fe10 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -17,14 +17,14 @@ Apri le impostazioni calendario di sistema Errore nella lettura del calendario. Gestisci magnificamente tutti i tuoi eventi - Calendula ha bisogno di accedere al tuo calendario per mostrare e gestire gli eventi. È l\'unica cosa di cui ha bisogno: nessuna informazione lascerà mai il tuo dispositivo. + Calendula ha bisogno di accedere al tuo calendario per mostrare e gestire gli eventi. Concedi l\'accesso al calendario Accesso al calendario negato Calendula non può mostrare gli eventi senza accesso al calendario. Puoi concederlo dalle impostazioni di sistema. Apri le impostazioni di sistema Riprova - Rimane sul tuo dispositivo - I tuoi calendari sono letti localmente e non lasciano mai il tuo dispositivo. + Privato by design + Nessun accesso a internet, nessun tracking: nulla lascia il tuo dispositivo. Tutti i tuoi calendari, insieme Google, CalDAV, locali - qualsiasi calendario sincronizzato sul dispositivo, semplicemente, compare. Nessun tracking, per sempre @@ -301,8 +301,8 @@ Lingua di sistema Aiuta a tradurre Aggiungi o migliora una traduzione su Weblate - Tema, vista di default, inizio settimana - Campi di default per nuovi eventi + Tema, colori, font + Campi di default e comportamento A proposito Licenza MIT @@ -414,7 +414,7 @@ Questa settimana Promemoria per gli eventi Promemoria per eventi standard - Promemoria evento + Promemoria Nuovo evento Gestito da “%1$s” - titolo, data e ripetizioni sono sincronizzate dai tuoi contatti. Promemoria e note sono modificabili dall\'utente. Carattere del titolo @@ -423,13 +423,13 @@ Scegli file… Font personalizzato Impossibile leggere il file come font - Viste + Vista Pulsante di cambio rapido Scegli attraverso quali viste scorrere col pulsante in alto a destra, trascinale per riordinarle. Le viste disattivate rimangono raggiungibili dal menù di navigazione. Menù di navigazione Trascina per riordinare le viste elencate nel menù di navigazione. Trascina per riordinare - Ordine del pulsante di cambio rapido e del menù + Vista di default, layout, ordine Compleanni e anniversari dei contatti Date speciali dei contatti Mostra date dei contatti @@ -478,8 +478,8 @@ Ieri Nessun altro evento per oggi Indietro - Attenua i colori del calendario - I colori dei calendari e degli eventi vengono attenuati per adattarsi al tema. Deseleziona per mostrare i colori naturali. + Armonizza i colori del calendario + Mantieni i colori del calendario ma attenua la luminosità, così gli eventi rimangono leggibili e i colori sono armonizzati. Deseleziona per mostrare i colori originali. Numeri di settimana Mostra i numeri di settimana nella vista Mese Mostra sempre Oggi @@ -499,4 +499,151 @@ Importando %d eventi Importando %d eventi + Sposta… + Sposta un evento ricorrente + Spostato al %1$s + Annulla ripianificazione + Ripianificazione annullata + Impossibile ripianificare l\'evento + Calendula ha bisogno dei permessi di scrittura per ripianificare gli eventi + Questo evento è stato eliminato. + Non è possibile ripianificare un evento oltre la data di fine della sua serie + Fuso orario del dispositivo + Segue il fuso orario in cui ti trovi + Seleziona fuso orario + Recenti + Tutti i fusi orari + Nessun fuso corrisponde a \"%1$s\" + %1$s nel tuo fuso orario + Inserisci un numero tra 1 e 999 + Prossimo evento: %1$s + Questa regola non si ripete + In base all\'impostazione del calendario + Chiunque acceda può vedere i dettagli completi + Gli altri vedranno solo che sei impegnato + Confidenziale; dipende dalle impostazioni dell\'account + %1$s %2$s + Al più %1$s + Step %1$d di %2$d + I tuoi eventi rimangono qui + Nulla viene sincronizzato, perdere i dati presenti su questo dispositivo significa perdere i tuoi calendari. Calendula può fare un backup per te. + Una cartella a scelta + I backup sono file .ics. Mettili al sicuro sul cloud o su una scheda SD. + Giornaliero, in automatico + Calendula esporta i tuoi calendari locali in background. Selezione la frequenza nelle Impostazioni. + Seleziona la cartella ed esegui il backup + Non ora + Cosa viene aperto per primo? + Seleziona la vista Mese + Continua + Indietro + Capito + Tutto fatto + I tuoi calendari sono pronti. Tutto ciò che hai scelto può essere modificato più tardi nelle Impostazioni. + Apri i miei calendari + Inizia alle %1$s + Finisce alle %1$s + Vai ad oggi + + %d selezionato + %d selezionati + %d selezionati + + Trovato nelle descrizioni + Cancella selezione + Seleziona tutti + Elimina i selezionati + + Eliminare l\'evento? + Eliminare l\'evento? + + + Evento eliminato + Evento eliminato + + %1$d eliminati, %2$d no + %1$d eliminati, poi permessi di scrittura negati + Ripianificare l\'intera serie cambia i giorni in cui cade, quindi puoi ripianificare solo questo evento. + Tema chiaro o scuro. La modifica viene applicata immediatamente. + Al momento %1$s + Giovedì, 14 Maggio + Team review alle 10:00, poi pranzo con Ugo al bar a Piazza di Spagna. + Al momento %1$s + Pulsante Oggi nella toolbar + Mostra il pulsante \"vai alla data\" nella toolbar invece che come pulsante flottante + Nome app + Mostra Calendula come \"Calendario\" nel launcher. Il nome cambia solo nel launcher; l\'icona potrebbe spostarsi dopo la modifica. + Da sistema: %1$s + Altezza ore + Spazio occupato verticalmente da un\'ora nella vista Giorno e Settimana. Puoi anche modificare l\'altezza come preverisci pizzicando la timeline. + Occupa la giornata intera + Tutte le 24 ore in una schermata, senza scrolling + Compatto + Più ore per schermata, blocchi più piccoli + Regolare + La spaziatura standard + Comodo + Blocchi più ampi, più scrolling + Personalizza + L\'altezza a cui hai portato la timeline + Trascina per ripianificare + Ripianifica un evento trascinandolo in un altro giorno o ora + Team review + Pranzo con Ugo + Allenamento di scacchi + Dimensioni del widget Agenda + Dimensioni del testo del widget agenda. Il widget mensile non ha impostazioni, si adatta da solo alla dimensione data. + Piccolo + Medio + Grande + Molto grande + Carattere %1$d%% + Vista Mese + Stile vista Mese + Pagine + Viene mostrato un singolo mese. Scorri a destra o a sinistra per cambiare mese. + Scorrimento + Ciò che fa l\'Agenda con eventi conclusi. + Deduci il colore dell\'app dallo sfondo. + Politica per la Privacy + Deseleziona un calendario per non mostrarlo sul dispositivo: scompariranno gli eventi e non riceverai più promemoria. Questa impostazione agisce anche su altre app calendario. Nulla viene eliminato e il calendario non scompare da nessun altro dispositivo. Puoi riselezionarlo in ogni momento. + Mostra \"%1$s\" + Alcuni calendari non sono attivi + Calendula mostra solo i calendari che sono attivi sul dispositivo, e alcuni non lo sono. Per vedere gli eventi di quest\'ultimi, attivali dalle Impostazioni. + Manca un calendario? + Può essere disattivato, in sola lettura o compilato in base ai tuoi contatti. Gestisci qui i tuoi calendari. + Sola lettura + Non sincronizzato su questo dispositivo + Compilato dai tuoi contatti + Questo calendario è compilato in base ai tuoi contatti, quindi Calendula lo creerà di nuovo alla prossima sincronizzazione. Per eliminarlo disattiva l\'opzione \"Date Speciali\" nelle Impostazioni. + %1$s (%2$s) + Ogni mese è sotto la sua testata, con un piccolo spazio a separarlo dal mese successivo. + Settimane continue + Le settimane scorrono senza interruzione, ogni mese succede a quello precedente. + Diviso + Una griglia compatta indica i giorni con eventi. Gli eventi del giorno selezionato sono elencati sotto. + Nulla in programma + Mostra il mese completo + Mostra gli eventi del giorno + Durata standard + Quanto durano gli eventi se non modifichi l\'orario di fine. Non ha effetto sugli eventi giornalieri. + Durata per calendario + Assegna a ciascun calendario la sua durata standard (e.g. 8 ore per il calendario dei turni lavorativi). + Default (%1$s) + Usa la durata standard (%1$s) + Promemoria alle %1$s + Aspetto e Comportamento + Dati + App + A proposito + Widget Agenda e riquadro Impostazioni rapide + Export, Import, backup automatici + Widget & Riquardi + Impostazioni per i widget e il riquardo delle Impostazioni rapide. Aggiungi i widget tenendo premuto sulla home. + Backup e ripristino + Tutte le viste + Giorno e settimana + La vista di default all\'apertura dell\'app. + Il giorno in cui inizia ogni settimana, in tutta l\'app e i suoi widget. + Formato degli orari in tutta l\'app. Di default viene seguito il formato di sistema. diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml new file mode 100644 index 0000000..487b810 --- /dev/null +++ b/app/src/main/res/values-ja/strings.xml @@ -0,0 +1,82 @@ + + + + Minutes + Hours + Days + Weeks + + モダンなカレンダー。 + 読み込み中… + リトライ + 今日 + 設定 + 説明 + リマインダーを追加 + 保存 + 終日 + + + + バックアップと復元 + 誕生日 + 今すぐ同期 + 最終同期 %1$s + 通知 + 日付に移動 + カレンダーへのアクセス権限が必要です。 + Calendulaで予定を表示・管理するには、カレンダーへのアクセス許可が必要です。 + カレンダーへのアクセスを許可 + カレンダーへのアクセスが拒否されました + Calendulaはカレンダーへのアクセス権がないと予定を表示できません。システム設定から再度許可をお願いします。 + システム設定を開く + 再試行 + プライバシーを重視した設計 + インターネットへのアクセス権限は不要で、テレメトリも送信しません。すべてのデータはスマートフォン内に保持されます。 + すべてのカレンダーをひとつに + Google、CalDAV、ローカルなど、デバイスに同期されているすべてのカレンダーが自動的に表示されます。 + 削除 + 共有 + 複製 + 予定を削除しますか? + この予定はカレンダーだけでなく、同期しているすべてのデバイスからも削除されます。 + 定期的な予定を削除 + この予定のみ + この予定とそれ以降すべて + 定期的な予定を編集 + 予定を削除できませんでした + 毎年 + + 毎週 + 毎月 + + 時間 + + 毎日 + 前日 + ウィジットとタイル + 週の開始日 + 週番号 + ライト + ダーク + 見出しのフォント + 本文のフォント + システムの標準 + ファイルを選択… + 自動 + 現在は %1$s + アプリの名前 + 言語 + アプリの言語 + システムの既定 + アプリ + テーマ、カラー、フォント + プライバシーポリシー + 開発を支援する + ライセンス + 問題を報告 + 設定 + テーマ + システム + ダイナミックカラー + diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 629871c..3c869f9 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -16,14 +16,14 @@ Otwórz systemowe ustawienia kalendarza Nie udało się kalendarza. Zobacz wszystkie swoje wydarzenia w pięknej oprawie - Calendula potrzebuje dostępu do Twojego kalendarza, aby wyświetlać wydarzenia i nimi zarządzać. To jedyne, o co prosi na wstępie — i żadne dane nigdy nie opuszczają Twojego urządzenia. + Calendula potrzebuje dostępu do Twojego kalendarza, aby wyświetlać wydarzenia i nimi zarządzać. Przyznaj dostęp do kalendarza Brak dostępu do kalendarza Calendula nie może wyświetlać wydarzeń bez dostępu do kalendarza. Możesz go ponownie przyznać w ustawieniach systemowych. Otwórz ustawienia systemowe Spróbuj ponownie - Pozostaje na Twoim urządzeniu - Twoje kalendarze są odczytywane lokalnie i nigdy nie opuszczają telefonu. + Prywatność z założenia + Brak uprawnień do internetu, brak telemetrii — nic nigdy nie opuszcza Twojego telefonu. Wszystkie Twoje kalendarze w jednym miejscu Google, CalDAV, lokalne — wszystko, co jest zsynchronizowane z urządzeniem, po prostu się pojawia. Nigdy żadnego śledzenia @@ -333,7 +333,7 @@ Domyślne przypomnienie Wydarzenia całodniowe Godzina przypomnienia o wydarzeniach całodniowych - Przypomnienia o wydarzeniach całodniowych zostaną pojawiają się o %1$s + Przypomnienia o wydarzeniach całodniowych pojawiają się o %1$s Brak Użyj domyślnego przypomnienia jednostek @@ -353,10 +353,10 @@ Domyślny systemu Pomóż w tłumaczeniu Dodaj lub ulepsz tłumaczenie w Weblate - Motyw, domyślny widok, pierwszy dzień tygodnia - Układ widoku miesiąca, przycisk szybkiego przełączania, kolejność menu - Domyślne pola dla nowych wydarzeń - Przypomnienia o wydarzeniach + Motyw, kolory, czcionki + Domyślny widok, układ, kolejność + Domyślne pola i zachowanie + Przypomnienia i dostarczanie Urodziny i rocznice kontaktów Szczególne daty kontaktów Pokaż daty kontaktów @@ -509,8 +509,8 @@ Jutro Wczoraj Brak zaplanowanych wydarzeń - Zredukuj intensywność kolorów kalendarza - Dopasuj intensywność kolorów kalendarza i wydarzeń do motywu. Wyłącz tę opcję, aby wyświetlać oryginalne kolory ze źródła kalendarza. + Harmonizuj kolory kalendarza + Zachowaj odcień każdego kalendarza, ale wyrównaj jego jasność, tak aby każde wydarzenie pozostało czytelne, a kolory współgrały ze sobą. Wyłącz tę opcję, aby wyświetlać oryginalne kolory ze źródła kalendarza. Zawsze pokazuj dzisiejszy dzień Zachowaj dzisiejszy dzień na górze agendy i widżetu, nawet gdy nie ma już na dziś żadnych zadań. Urodziny @@ -543,4 +543,128 @@ Brak planów Pokaż cały miesiąc Pokaż wydarzenia dnia + To wydarzenie już nie istnieje + Przenieś… + Przenieś wydarzenie cykliczne + Przeniesienie całej serii zmieniłoby dni, w których wypadają wydarzenia, więc można przenieść tylko to wystąpienie. + Przeniesiono na %1$s + Cofnij + Cofnięto przeniesienie + Nie udało się przenieść wydarzenia + Calendula wymaga uprawnień do zapisu, aby móc przenosić wydarzenia + Nie można przenieść wydarzenia poza datę zakończenia jego serii + Następne: %1$s + Ta reguła nie ma powtórzeń + Standardowe ustawienie tego kalendarza + Każdy, kto ma dostęp, widzi pełne szczegóły + Inni widzą tylko to, że jesteś zajęty + Oznaczone jako poufne; co to oznacza, zależy od konta kalendarza + %1$s %2$s + Maksymalnie %1$s + Krok %1$d z %2$d + Twoje wydarzenia istnieją tylko tutaj + Nic z tego telefonu nie synchronizuje się z kontem, więc jego utrata oznaczałaby utratę kalendarza. Calendula może utworzyć dla Ciebie kopię zapasową. + Wybrany przez Ciebie folder + Kopie zapasowe to zwykłe pliki .ics — umieść je w miejscu, które się synchronizuje, albo na karcie SD. + Raz dziennie, automatycznie + Calendula eksportuje Twoje lokalne kalendarze w tle. Możesz zmienić częstotliwość w Ustawieniach. + Wybierz folder i utwórz kopię zapasową + Co powinno się otworzyć jako pierwsze? + Jak powinien wyglądać miesiąc? + Kontynuuj + Wstecz + Rozumiem + Wszystko gotowe + Twoje kalendarze są gotowe. Wszystko co właśnie wybrałeś możesz później zmienić w Ustawieniach. + Otwórz mój kalendarz + + %d wybrane + %d wybrane + %d wybranych + %d wybranych + + Znaleziono w opisach + Anuluj wybór + Wybierz wszystkie + Usuń wybrane + + Usunąć %d wydarzenie? + Usunąć %d wydarzenia? + Usunąć %d wydarzeń? + Usunąć %d wydarzeń? + + + Usunięto %d wydarzenie + Usunięto %d wydarzenia + Usunięto %d wydarzeń + Usunięto %d wydarzeń + + %1$d usunięto, %2$d nie udało się + %1$d usunięto, następnie utracono uprawnienia do zapisu + Czy aplikacja ma być jasna, czy ciemna. Wybór zostanie zastosowany natychmiast. + Obecnie %1$s + czwartek, 14 maja + Przegląd zespołu o 10:00, a potem lunch z Robinem w kawiarni na rynku. + Obecnie %1$s + Zgodnie z systemem: %1$s + Wysokość godzin + Ile miejsca w pionie zajmuje jedna godzina w widoku tygodnia i dnia. Oba widoki korzystają z tego ustawienia. Możesz również rozciągnąć lub ścisnąć oś czasu dwoma palcami, aby ustawić dowolną wysokość pomiędzy. + Cały dzień na ekranie + Cała doba na jednym ekranie, bez przewijania + Kompaktowa + Więcej godzin na ekranie, mniejsze bloki + Standardowa + Standardowy rozmiar + Wygodna + Większe bloki, więcej przewijania + Własna + Wysokość, którą wybrałeś gestem „uszczypnięcia” osi czasu + Przeciągnij, aby zmienić termin + Przenieś wydarzenie, przeciągając je na inny dzień lub godzinę + Przegląd zespołu + Lunch z Robin + Próba chóru + Rozmiar widżetu agendy + Rozmiar tekstu wyświetlanego przez widżet agendy na ekranie domowym. Widżet miesiąca nie posiada tego ustawienia — jego siatka zawsze dopasowuje się do przydzielonego miejsca. + Mały + Średni + Duży + Bardzo duży + Tekst wydarzenia %1$d%% + Domyślny czas trwania + Czas trwania nowego wydarzenia do momentu zmiany godziny jego zakończenia. Nie dotyczy to wydarzeń całodniowych. + Czas trwania dla poszczególnych kalendarzy + Przypisz kalendarzowi własną domyślną długość — np. 8 godzin dla zmian w pracy. + Domyślny (%1$s) + Używaj domyślnego czasu trwania (%1$s) + Powiadamia o %1$s + Wygląd i zachowanie + Dane + Aplikacja + O aplikacji + Widżet agendy, kafelek Szybkich ustawień + Eksport, import, automatyczna kopia zapasowa + Widżety i kafelki + Ustawienia widżetów ekranu głównego i kafelka Szybkich ustawień. Aby dodać widżet, naciśnij i przytrzymaj na ekranie domowym. + Kopia zapasowa i przywracanie + Wszystkie widoki + Tydzień i dzień + Widok, który pojawia się po uruchomieniu aplikacji Calendula. + Dzień, od którego zaczyna się każdy tydzień we wszystkich widokach i widżetach. + Format godziny w aplikacji. Opcja automatyczna dostosowuje się do ustawień systemowych. + Co agenda robi z wydarzeniami, które już się zakończyły. + Dopasuj kolory aplikacji do swojej tapety. + Polityka prywatności + Wyłącz kalendarz, aby ukryć go na tym urządzeniu — jego wydarzenia znikną z aplikacji i przestaniesz otrzymywać powiadomienia. To ten sam przełącznik, którego używają inne aplikacje kalendarza, więc one również go ukryją. Nic nie zostanie usunięte, nie wpłynie to na inne urządzenia, a kalendarz możesz tu włączyć ponownie w dowolnym momencie. + Pokaż \"%1$s\" + Niektóre kalendarze są wyłączone + Calendula wyświetla kalendarze włączone na tym urządzeniu, a niektóre z Twoich są wyłączone. Aby zobaczyć ich wydarzenia, włącz je w sekcji Ustawienia → Kalendarze. + Brakuje jakiegoś kalendarza? + Może być wyłączony, tylko do odczytu lub wypełniony na podstawie Twoich kontaktów — zarządzaj swoimi kalendarzami tutaj. + Tylko do odczytu + Niezsynchronizowany z tym urządzeniem + Wypełniony na podstawie Twoich kontaktów + Ten kalendarz jest uzupełniany na podstawie Twoich kontaktów, więc Calendula utworzy go ponownie przy następnej synchronizacji. Aby go usunąć, wyłącz daty szczególne w menu Ustawienia → Szczególne daty kontaktów. + %1$s (%2$s) + Nie teraz diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 95e1ae6..cda4ec4 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -207,4 +207,445 @@ %d minutos Conceder acesso + Mover… + Mover evento recorrente + Mover a série inteira mudaria os dias em que o evento cai, então somente este evento pode se mover. + Movido para %1$s + Desfazer + Mover desfeito + Não foi possível mover o evento + O Calendula precisa de permissão de escrita para mover eventos + Este evento não existe mais + Impossível mover um evento após o fim de sua série + Talvez + + %d hora + %d horas + %d horas + + + %d dia + %d dias + %d dias + + %1$s %2$s + No máximo %1$s + + %d semana + %d semanas + %d semanas + + (Sem título) + Passo %1$d de %2$d + Seus eventos vivem somente aqui + Nada neste aparelho sincroniza para uma conta, então perdê-lo levaria seu calendário junto. O Calendula pode criar um backup para você. + Uma pasta à sua escolha + Os backups são arquivos .ics — coloque-os em um lugar para sincronizar, ou um cartão SD. + Uma vez por dia, sozinho + O Calendula exporta seus calendários locais em segundo plano. Mude a frequência em Ajustes. + Escolher pasta e fazer backup + Agora não + O que deve abrir primeiro? + Como o Mês deve se parecer? + Continuar + Voltar + Entendi + Tudo pronto + Seus calendários estão prontos. Todas as suas escolhas podem ser alteradas depois em Ajustes. + Abrir meu calendário + Lembretes de evento + Notificações na hora dos lembretes dos seus eventos + Nunca perca um evento + O Android não mostra lembretes sozinho — um app de calendário deve fazer isso. Deixe o Calendula cuidar disso. + Lembretes, entregues + Todos os seus lembretes de evento chegam em notificação, na hora certa. + Usando outro app de calendário? + Se outro app também postar lembretes, você verá eles em dobro — desligue aqui ou lá. + Mude a qualquer hora + O botão está em Ajustes, dentro de Notificações. + Ativar lembretes + Agora não + Soneca + Dispensar + Amanhã + Ontem + Mês + Semana + Dia + Agenda + Vista + Ir para data + Hoje + Hoje + Amanhã + Tudo em dia + Sem mais eventos hoje + Começa às %1$s + Termina às %1$s + Ir para Hoje + Buscar + Buscar eventos + Voltar + Limpar + Busque seus eventos por título, local ou notas. + Nenhum evento corresponde a \"%1$s\". + + %d selecionado + %d selecionados + %d selecionados + + Encontrado em descrições + Cancelar seleção + Selecionar tudo + Deletar selecionados + + Deletar %d evento? + Deletar %d eventos? + Deletar %d eventos? + + + %d evento deletado + %d eventos deletados + %d eventos deletados + + %1$d deletado, %2$d não pôde ser deletado + %1$d deletado, depois permissão de escrita removida + A seguir + Agenda do Calendula + Mês do Calendula + Atualizar + Novo evento + Abra o Calendula para conceder acesso ao calendário + Mês passado + Mês que vem + Hoje + Calendários + Ajustes + Voltar + Voltar + Aparência + Tema + Sistema + Claro + Escuro + Se o app é claro ou escuro. Aplica imediatamente. + No momento %1$s + Vista padrão + Cor dinâmica + Requer Android 12 ou superior + Harmonizar cores do calendário + Mantenha cada tom do calendário até mesmo sem brilho, para que cada evento continue legível e as cores combinem. Desative para mostrar as cores cruas da raiz do calendário. + Fonte dos cabeçalhos + Fonte do corpo + Padrão do sistema + Atkinson Hiperlegível + Lora + JetBrains Mono + Escolher arquivo… + Fonte personalizada + Não pôde ler arquivo como fonte + Quinta-feira, 14 de maio + Reunião de equipe às 10:00, depois almoço com Robin no café em Markplatz. + A semana inicia em + Automático + No momento %1$s + Números das semanas + Mostrar números das semanas na vista do mês + Botão \"Hoje\" na barra de ferramentas + Mostrar um botão \"Hoje\" na barra de ferramentas em vez de um botão flutuante + Nome do app + Mostrar o Calendula como \"Calendário\" no seu launcher. Somente o nome do launcher muda; o ícone pode mudar para outro lugar após a troca. + Formato de hora + Automático + 12 horas (2:00 PM) + 24 horas (14:00) + Seguir o sistema: %1$s + Linhas de hora + Mostrar um separador a cada hora nas vistas de semana e dia + Altura da hora + Quanto espaço vertical que a hora ocupa nas vistas de semana e dia. Ambas as vistas compartilham esse ajuste. Você também pode pinçar a linha do tempo com dois dedos para colocar uma altura no meio. + Encaixar o dia todo + 24 horas na tela, sem rolagem + Compacto + Mais horas por tela, blocos menores + Regular + Espaçamento padrão + Confortável + Blocos maiores, mais rolagem + Personalizado + A altura em que você pinçou a linha do tempo + Esmaecer eventos passados + Esmaecer eventos que já terminaram nas vistas do mês e semana + Arraste para reagendar + Mova um evento arrastando para outro dia ou hora + Eventos passados + Mostrar + Esmaecer + Ocultar + Revisão de equipe + Almoço com Robin + Treino do coral + Agenda + Amplitude da agenda + O quanto à frente a tela de Agenda lista eventos. + Amplitude do widget de Agenda + O quanto à frente o widget de Agenda lista eventos. + Tamanho do widget de Agenda + O quão largo o texto do widget de Agenda é desenhado. O widget de mês não tem ajuste — sua grade sempre encaixa no espaço que você der. + Pequeno + Médio + Grande + Extra grande + Texto do evento %1$d%% + Sempre mostrar Hoje + Mantenha o dia de hoje no topo da agenda e no widget, mesmo que não tenha mais nada hoje. + Barra de amplitude + Mostrar uma barra no topo da agenda nomeando as datas mostradas, com um botão para mudar a amplitude para a sessão + Hoje + Essa semana + Esse mês + Próximos 7 dias + Próximos 30 dias + Personalizado… + Dias + Só por agora — reajusta para sua amplitude salva ao reabrir o Calendula. + Mostrando eventos a seguir para + + %d dia + %d dias + %d dias + + Vistas + Vista do mês + Estilo de vista do mês + Páginas + Um mês ocupa a tela. Deslize para os lados para mudar o mês. + Meses contínuos + Cada mês tem o próprio cabeçalho, com um espaço separando-o do próximo. + Semanas integradas + As semanas seguem sem separação, cada mês terminando no início do outro sem distinção entre eles. + Dividido + Uma grade compacta marca os dias com eventos, e o dia clicado é listado abaixo. + Nada agendado + Mostrar o mês inteiro + Mostrar eventos do dia + Botão de troca rápida + Escolha por quais vistas o botão superior direito circula, e arraste para reordenar. Vistas desativadas continuam acessíveis pelo menu de navegação. + Menu de navegação + Arraste para reordenar vistas listadas no menu de navegação. + Arraste para reordenar + Novo formulário de evento + Campos padrão — o restante está dentro de \"Mais campos\" + Focar no título em novo evento + Ao iniciar um novo evento, colocar o cursor no campo de título e abrir o teclado. + Duração padrão + Quanto tempo um novo evento dura até você mudar o tempo de encerramento. Eventos de dia inteiro não são afetados. + Duração por calendário + Dar a um calendário a sua própria duração padrão — ex.: 8 horas para trabalho. + Padrão (%1$s) + Usar duração padrão (%1$s) + Permitir cores em calendários não suportados + Alguns calendários (ex.: certos CalDAV) não publicam cores; uma cor de evento personalizada pode ser apagada ou sobrescrita na próxima sincronização. Esta é uma limitação destes calendários, e não é algo que o Calendula pode consertar. + Notificações + Lembretes de evento + Vendo lembretes dobrados? Outro calendário está postando também — desligue em um dos dois. + Lembrete padrão + Eventos de dia todo + Tempo de lembrete de dia todo + Lembretes para eventos de dia todo disparam às %1$s + Notifica às %1$s + Nenhum + Usar lembrete padrão + Quantidade + Ajustar + Lembretes por calendário + Mude o padrão por calendário — separadamente para eventos com horário e de dia todo. Um calendário pode manter o padrão, removê-lo, ou ajustar seu próprio. + Padrão (%1$s) + Entrega confiável + O Android pode atrasar lembretes para economizar bateria. Autorize o Calendula para que possam chegar na hora. + Isentar de otimização de bateria — lembretes chegam na hora. + Duração de soneca + Calendários + Gerenciar calendários + Crie calendários locais; gerencie os sincronizados + Idioma + Idioma do app + Padrão do sistema + Ajude a traduzir + Adicione ou melhore um idioma no Weblate + Aparência e comportamento + Dados + App + Sobre + Tema, cores, fontes + Vista, layout e ordem padrão + Campos e comportamento padrão + Lembretes e entrega + Aniversários e datas de contato + Widget de Agenda, bloco de Ajustes Rápidos + Exportar, importar, backup automático + WIdgets e blocos + Ajustes para widgets da tela inicial e bloco de Ajustes Rápidos. Adicione um widget pressionando e segurando a tela inicial. + Backup e restaurar + Todas as vistas + Semana e dia + A vista que o Calendula abre ao iniciar. + O dia em que cada semana inicia, em todas as vistas e widgets. + Como os horários são escritos pelo app. \"Automático\" segue a configuração do sistema. + O que a agenda faz com eventos que já terminaram. + Use as cores do app de acordo com seu papel de parede. + Datas especiais de contatos + Mostrar datas de contatos + Espelhe os aniversários e outras datas dos contatos para calendários locais. Lê os contatos somente neste aparelho — nada é enviado, e seus contatos nunca são alterados. + Aniversários + Aniversários de relacionamento + Outras datas + Formato do título + Use {nome} para o contato e {ano} para o ano (de nascimento, ou de início do relacionamento; oculto quando não informado). + Lembretes + Mostrar ano + Incluir {ano} nos títulos caso informado + Sincronizar agora + Não sincronizado ainda + Sincronizado por último %1$s + Ajustar cada cor e visibilidade de calendário nos ajustes de Calendários. + Ajustar em datas especiais de Contatos + Pausado + O Calendula não consegue mais ler seus contatos, então estes calendários não estão atualizando. + Desligar datas de contatos? + Isto deleta calendários de datas de contatos e seus eventos. Qualquer lembrete ou nota adicionada serão perdidos. + Isto deleta o calendário \"%1$s\" e seus eventos. Qualquer lembrete ou nota adicionados serão perdidos. + Desativar + Salvar + Sobre + Licença + MIT + por Jean-Luc Makiola + Código-fonte + Política de privacidade + Apoie o desenvolvimento + Versão %1$s + Ícone de app do Calendula + Reportar um problema + Envie um reporte de falha ou abra o tracker de problemas + Calendários + Seus calendários + Nenhum calendário local ainda. Crie um para manter eventos somente neste dispositivo. + Adicionar calendário + Desative um calendário para escondê-lo neste aparelho — seus eventos desaparecem do app e param de ativar lembretes. Este é o mesmo botão que seus outros apps de calendário usam, então eles também escondem. Nada é deletado, nenhum outro aparelho é afetado, e você pode reativar aqui a qualquer momento. + Mostrar \"%1$s\" + Alguns calendários estão desativados + O Calendula mostra os calendários que estão ativados neste dispositivo, e alguns dos seus estão desativados. Para ver seus eventos, ative-os em Ajustes → Calendários. + Sentindo falta de um calendário? + Pode estar desativado, somente-leitura, ou preenchido dos seus contatos — gerencie seus calendários aqui. + Somente leitura + Não sincronizado neste dispositivo + Preenchido dos seus contatos + Este calendário é preenchido de seus contatos, então o Calendula o criaria de novo na próxima sincronização. Desative datas especiais em Ajustes → Datas especiais para deletar. + Calendários sincronizados + Estes são de contas no seu dispositivo. Crie e edite-os em seus próprios apps. + Gerenciar no app + %1$s (%2$s) + Mais opções para %1$s + Habilitar todos + Desabilitar todos + Adicionar conta + Novo calendário + Editar calendário + Nome + Cor + Adicionar descrição + Deletar calendário? + \"%1$s\" e todos os seus eventos serão permanentemente removidos deste dispositivo. + Não foi possível salvar a alteração. + Backup + Calendários locais não são sincronizados, então exporte-os para um arquivo .ics para manter uma cópia. + Exportar como arquivo .ics + Exportar calendários + Escolha quais calendários incluir no arquivo .ics. + Exportar + Restaurar + Restaurar de um arquivo .ics + Importar eventos de um backup ou outro app de calendário. + Backup automático + Exportar periodicamente seu calendário local para uma pasta como um arquivo .ics. + Pasta de backup + Toque para escolher uma pasta + Intervalo + A cada %1$s + Mínimo de 30 minutos. + Nenhum backup automático ainda + Último backup: %1$s + Último backup falho: %1$s + Backup + Avisa se os backups automáticos falharem repetidamente. + Backup automático falhou + O Calendula não pôde salvar o arquivo de backup. Verifique a pasta de backup em Ajustes. + Não foi possível exportar o backup. + + Exportou %d evento. + Exportou %d eventos. + Exportou %d eventos. + + Importar eventos + Adicionar ao calendário + Nenhum evento encontrado neste arquivo. + Não foi possível ler este arquivo. + Nenhum calendário salvável para importar. Crie um calendário local primeiro. + Importação concluída + Eventos já no calendário foram pulados. + Adicionado + Duplicatas + Fechar + Algumas ocorrências alteradas de eventos recorrentes foram puladas. + Um evento sem horário de início foi pulado. + Listas de convidados não foram importadas. + Um fuso-horário desconhecido foi alterado para o do seu dispositivo. + Importar + + Importando %d evento + Importando %d eventos + Importando %d eventos + + + %d evento neste arquivo. + %d eventos neste arquivo. + %d eventos neste arquivo. + + + Importar %d evento + Importar %d eventos + Importar %d eventos + + + Importou %d evento. + Importou %d eventos. + Importou %d eventos. + + + Pulou %d já neste calendário. + Pulou %d já neste calendário. + Pulou %d já neste calendário. + + Novo evento + Criar novo evento + Novo evento + Adicionar bloco Ajustes Rápidos + Adicionar bloo \"Novo evento\" no painel Ajustes Rápidos. + %1$s quebrou + %1$s fechou inesperadamente da última vez. Você pode ajudar a consertar enviando este reporte como um problema. Ele fica no seu dispositivo até que decida enviá-lo, e não inclui dados pessoais ou conteúdo de calendário — somente os detalhes técnicos abaixo. + Reportar + Agora não + Reportar quebra + %1$s relatório de quebra + Relatório copiado para a área de transferência + Não foi possível abrir o tracker de problemas. O relatório está na área de transferência. + Obrigado por reportar uma quebra em %1$s. Por favor, escreva o que estava fazendo, depois envie. \n### O que aconteceu \n\n\n### Relatório de quebra \n%2$s\n + _(O reporte era muito longo para este link — cole da área de transferência aqui.)_ + Aniversários + Aniversários de relacionamento + Datas especiais + Aniversário de {nome} ({ano}) + Aniversário de relacionamento de {nome} ({ano}) + {nome} diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 7688090..8b66bb6 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -17,14 +17,14 @@ Перейти в системные настройки календаря Не удалось просмотреть календарь. Следите за всеми своими событиями. С красотой - Calendula требуется доступ к вашему календарю, чтобы показывать и управлять вашими событиями. Это всё, что приложение требует с самого начала – и никакая информация не покидает ваш девайс. + Calendula требуется доступ к вашему календарю, чтобы показывать и управлять вашими событиями. Это всё, что приложение требует с самого начала – и никакая информация не покидает ваше устройство. Дать доступ к календарю В доступе к календарю отказано Calendula не может показать события без доступа к календарю. Предоставьте его снова в настройках телефона. Открыть настройки телефона Попытайтесь снова - Остаётся на вашем устройстве - Ваши календари просматриваются локально и информация никогда не покидает устройство. + Приватность по умолчанию + Нет доступа к интернету, нету телеметрии — ничто не покидает ваше устройство. Все ваши календари. Вместе Google, CalDAV, локальный календарь – всё синхронизированное с устройством просто подключается. Никакого отслеживания. Никогда @@ -62,4 +62,286 @@ Закрыть Сохранить Добавить имя + Перенести… + Перенести повторяющиеся события + Перенесено на %1$s + Отменить + Перемещение всей серии изменит дни на которые она попадёт, поэтому можно переместить только это событие. + Перемещение отменено + Не удалось переместить событие + Приложению Calendula требуется доступ на запись, чтобы перемещать события + Это событие больше не существует + Нельзя переместить событие дальше за пределы его серии + Управляется «%1$s» — название, дата и повторение синхронизируются с ваших контактов. Напоминания, местоположения и заметки могут быть редактированы вами. + Начинается + Заканчивается + Заканчивается раньше, чем начинается + Нет календарей с правами на запись + Не удалось сохранить событие + Приложению Calendula требуется доступ на запись, чтобы создавать события + Больше полей + Добавить + Добавить напоминание + Удалить напоминание + Гости + Добавить гостя + Добавить гостя по почте… + Добавить из контактов + Выбрать адрес из контактов + Удалить гостя + Обязательный + Необязательный + Calendula не отправляет приглашения. Ваша учётная запись календаря может отправить письма гостям при синхронизации. + Хранится на этом устройстве. Никто не получит уведомление. + Другое + минуты + часы + дни + недели + Занятость + Видимость + Часовой пояс устройства + Следует за вами, где бы вы ни были + Поиск часовых поясов + Недавние + Все часовые пояса + Часовые пояса, соответсвующие «%1$s», не найдены + %1$s по вашему времени + Цвет + Цвет календаря + Свой цвет + Сбросить + Недоступно для этого календаря + Этот календарь не публикует набор цветов. Вы можете разрешить пользовательские цвета для таких календарей в Настройках. + Этот календарь может потерять или перезаписать цвет при следующей синхронизации. + Событие изменено в другом месте + Пока вы редактировали, это событие было изменено — синхронизацией или другим приложением. Что вы хотите сделать с вашими изменениями? + Сохранить мои изменения + Только изменённые вами поля, перезаписывают внешние изменения + Отменить мои изменения + Событие остаётся таким, как сейчас + Событие удалено + Это событие было удалено, пока вы его редактировали, например на другом устройстве. Ваши изменения больше не могут быть сохранены. + Применить ваше стандартное напоминание? + Это событие было импортировано без каких-либо напоминаний. + + Это событие было импортировано с %1$d напоминанием. + Это событие было импортировано с %1$d напоминаниями. + Это событие было импортировано с %1$d напоминаниями. + Это событие было импортировано с %1$d напоминаниями. + + Применить стандартное + Оставить как есть + Не повторяется + Другое + Каждый + дни + недели + месяцы + года + Введите число от 1 до 999 + Это правило никогда не повторяется + Заканчивается + Никогда + До + После определённого количества + Занят + Стандартный + Публичный + Весь день + Календарь + Неизвестный календарь + Местоположение + Описание + Повторяющееся событие + Каждый день + Каждую неделю + Каждый месяц + Каждый год + Каждые %1$d дней + Каждые %1$d недель + Каждые %1$d месяцев + Каждые %1$d лет + Это событие больше не существует. + Принято + Отклонено + Без ответа + Напоминания + Часовой пояс + Отменён + Свободен/свободна + Личное + Конфиденциальное + Общедоступные детали + Другим показывается, что вы заняты + Ваш ответ: %1$s + Во время начала события + Следующий: %1$s + раз + Участники + Повторяется + Повторы заканчиваются до начала мероприятия + + Стандартная настройка календаря + Помечено как личное; что это означает, зависит от учетной записи календаря + Организатор + Необязательно + Уведомление по умолчанию + + %d минута до + %d минуты до + %d минут до + %d минут до + + + %d час до + %d часа до + %d часов до + %d часов до + + + %d день до + %d дня до + %d дней до + %d дней до + + + %d неделя до + %d недели до + %d недель до + %d недель до + + + %d минута + %d минуты + %d минут + %d минут + + + %d час + %d часа + %d часов + %d часов + + + %d день + %d дня + %d дней + %d дней + + %1$s %2$s + Максимум %1$s + + %d неделя + %d недели + %d недель + %d недель + + (Без названия) + Шаг %1$d из %2$d + На этом телефоне ничего не синхронизируется с учетной записью, поэтому, потеряв его, вы потеряете и свой календарь. Calendula может создать для вас резервную копию. + Папка на выбор + Резервные копии сохраняются как .ics, поэтому сохраните их в синхронизируемом месте или на SD-карте. + Раз в день + Calendula экспортирует ваши локальные калдендари в фоновом режиме. Выставите как часто в Настройках. + Выберите папку и создайте резервную копию + Не сейчас + Что открыть первым? + Вид месяца? + Продолжить + Назад + Хорошо + Всё готово + Ваши календари готовы. Все выбранные настройки можно изменить позже. + Открыть мой календарь + Используете иное приложение календаря? + Если несколько приложений отвечают за уведомления, вы увидите дубликаты — отключите их в одном месте. + Включить напоминания + Не сейчас + Отложить + Отключить + Завтра + Вчера + Месяц + Неделя + День + Расписание + Вид + Выбрать дату + Сегодня + Сегодня + Завтра + Вы везде успели + На сегодня — всё + Начало в %1$s + Конец в %1$s + Сегодня + Поиск + Поиск мероприятий + Назад + Очистить + Поиск по названию, локации или описанию. + Не найдено по запросу \"%1$s\". + + %d выбран + %d выбрано + %d выбрано + %d выбрано + + Найдено в описаниях + Выбрать всё + Удалить выбранное + Отменить выбранное + + Удалить %d мероприятие? + Удалить %d мероприятия? + Удалить %d мероприятий? + Удалить %d мероприятий? + + + %d мероприятие удалено + %d мероприятия удалено + %d мероприятий удалено + %d мероприятий удалено + + %1$d удалено, %2$d не вышло + Следующее + Расписание от Calendula + Месяц от Calendula + Обновить + Новое мероприятие + Откройте Calendula, чтобы дать доступ к календарю + Предыдущий месяц + Следующий месяц + Сегодня + Календари + Настройки + Назад + Назад + Внешний вид + Тема + По умолчанию системы + Светлая + Тёмная + Моментально меняет веншний вид приложения: светлый или тёмный + Выбрано: %1$s + Вид по умолчанию + Динамические цвета + Требуется Android 12 или более поздняя версия + Гармонизировать цвета календаря + Изменяет яркость цветов не меняя оттенок, чтобы создать единую цветовую палитру. Отключите для показа стандартных цветов подключённого календаря. + Шрифт заголовков + Основной шрифт + По умолчанию + Atkinson Hyperlegible + Lora + JetBrains Mono + Выбрать файл… + Пользовательский шрифт + Не удалось прочитать этот файл как шрифт + Четверг, 14 мая + Рабочая встреча в 10:00, затем обед с Александром в кафе на улице Пушкина. + Неделя начинается в + Автоматически + Выбрано: %1$s diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml new file mode 100644 index 0000000..db30984 --- /dev/null +++ b/app/src/main/res/values-sk/strings.xml @@ -0,0 +1,266 @@ + + + + Minutes + Hours + Days + Weeks + + Calendula + Moderný kalendár. + Načítava sa… + Opakovať + Niečo sa pokazilo. + Je potrebný prístup ku kalendáru. + Udeliť prístup + Nie sú nakonfigurované žiadne kalendáre. + Otvoriť systémové nastavenie kalendára + Nepodarilo sa načítať kalendár. + Prehľadávajte všetky svoje udalosti v krásnom prevedení + Calendula potrebuje prístup k vášmu kalendáru, aby mohla zobraziť a spravovať vaše udalosti. + Udeliť prístup k kalendáru + Prístup ku kalendáru bol zamietnutý + Calendula nemôže zobraziť udalosti bez prístupu ku kalendáru. Môžete ho znova udeliť v nastavení systému. + Otvoriť systémové nastavenia + Skúsiť znova + Súkromné už od začiatku + Žiadny prístup k internetu, žiadna telemetria - nikdy nič z vášho telefónu neunikne. + Všetky vaše kalendáre pohromade + Google, CalDAV, lokálne kalendáre - čokoľvek synchronizované zo zariadením sa proste objaví. + Nikdy žiadne sledovanie + Žiadna telemetria, žiadne analytické nástroje, bez reklám. + Ostáva na vašom zariadení · bez prístupu k internetu + Predchádzajúci mesiac + Nasledujúci mesiac + Dnes + Ďalšie možnosti + Otvoriť menu + Nastavenia + Dnes + Tento víkend + týžd. + Dnes + Späť + Upraviť + Vymazať + Zdielať + Duplikovať + Zdielať udalosť + Túto udalosť nieje možné zdieľať. + Odstrániť udalosť? + Udalosť je odstránená z vášho kalendára aj zo všetkých synchronizovaných zariadení. + Odstrániť opakujúcu sa udalosť + Iba túto udalosť + Túto aj všetky nasledujúce udalosti + Všetky udalosti v sérii + Upraviť opakujúcu sa udalosť + Túto udalosť nebolo možné odstrániť + Calendula potrebuje povolenie k zápisu, aby mohla odstrániť udalosti + Presunúť… + Presunúť opakujúcu sa udalosť + Presunútie celej série by sa zmenilo poradie dňou, na ktorú séria pripadá, takže presunúť je možné iba túto udalosť. + Presunuté na %1$s + Vrátiť späť + Navrátené naspäť + Nemožno presunúť udalosť + Calendula potrebuje povolenie k zápisu, aby mohla presúvať udalosti + Táto udalosť už neexistuje + Nie je možné presunúť udalosť za koniec danej série + Zrušiť + OK + Nová udalosť + Zavrieť + Uložiť + Pridať názov + Spravové kalendárom “%1$s” — názov, dátum a opakovanie je synchronizované z vašich kontaktov. Pripomienky, poloha a poznámky môžte upraviť. + Začiatok + Koniec + Končí pred začiatkom + Není dostupný kalendár, ktorý je možno upravovať + Nemožno udalosť uložiť + Calendula potrebuje povolenie k zápisu, aby mohla vytvárať udalosti + Viac poli + Pridať + Pridať pripomienku + Odstrániť pripomienku + Hostia + Pridať hosťa + Pridať hosťa e-mailom… + Pridať z kontaktov + Vybrať adresu z kontaktov + Odstrániť hosťa + Nutné + Voliteľné + Calendula neposiela pozvánky. Váš kalendárny účet ich môže poslať keď sa synchronizuje. + Uložené na tomto zariadení. Nikomu nebude poslaná notifikácia. + Vlastné + minúty + hodiny + dni + týždne + Dostupnosť + Viditeľnosť + Časové pásmo zariadenia + Sleduje vás, nech ste kdekoľvek + Vyhľadať časové pásmo + Nedávne + Všetky časové pásma + Žiadne časové pásmo nezodpovedá “%1$s” + %1$s vášho času + Farba + Farba kalendáru + Vlastná farba + Obnoviť + Není k dispozícii pre tento kalendár + Tento kalendár nemá vlastnú sadu farieb. Pre takýto kalendár môžte povoliť vlastné farby v nastaveniach. + Tento kalendár môže vymazať alebo prepísať farbu pri ďalšej synchronizácii. + Udalosť bola zmenená niekde inde + Počas úprav sa táto udalosť zmenila — synchronizáciou alebo inou aplikáciou. Čo sa má stať s vašimi zmenami? + Uložiť zmeny + Iba u upravených polí sa prepíše zmena z iného zdroja + Zrušiť zmeny + Udalosť ostáva bez zmeny + Udalosť odstránená + Počas úprav bola táto udalosť odstránená, napríklad na inom zariadení. Vaše zmeny už nie je možné uložiť. + Chcete použiť predvolenú pripomienku? + Táto udalosť bola importovaná bez pripomienky. + + Táto udalosť bola importovaná s %1$d pripomienkou. + Táto udalosť bola importovaná s %1$d pripomienkami. + Táto udalosť bola importovaná s %1$d pripomienkami. + Táto udalosť bola importovaná s %1$d pripomienkami. + + Nastaviť predvolené + Nechať bez zmeny + Neopakuje sa + Vlastné + Každý + dni + týždne + mesiace + roky + Zadajte číslo od 1 do 999 + Nasledujúce: %1$s + Toto pravidlo sa nikdy neopakuje + Koniec + Nikdy + K určitému dátumu + Po určitom počtu opakovaní + opakovaní + Opakovanie končí pred začiatkom udalosti + Zaneprázdnený + Predvolený + Verejný + Celý deň + Kalendár + Neznámy kalendár + Poloha + Popis + Účastníci + Opakovanie + Opakovaná udalosť + Každý deň + Každý týždeň + Každý mesiac + Každý rok + Každý %1$d. deň + Každý %1$d. týždeň + Každý %1$d. mesiac + Každý %1$d. rok + %1$s v %2$s + %1$s do%2$s + %1$s, %2$d opakovaní + Táto udalosť už neexistuje. + Akceptované + Zamietnuté + Predbežne + Bez odozvy + + Pripomienky + Časové pásmo + Predbežne + Zrušené + Voľno + Súkromné + Tajné + Bežná činnosť kalendáru + Kdekoľvek s prístupom vidí všetky detaily + Ostatný vidia iba že ste zaneprázdnený + Označené ako utajené; záleží na kalendárnom účtu čo toto znamená + + %d minúta + %d minúty + %d minút + %d minút + + Usporiadateľ + Voliteľné + Zdroj + Vaša odpoveď: %1$s + V čas udalosti + Predvolená pripomienka + + %d minútu predtým + %d minúty predtým + %d minút predtým + %d minút predtým + + + %d hodinu predtým + %d hodiny predtým + %d hodín predtým + %d hodín predtým + + + %d deň predtým + %d dní predtým + %d dní predtým + %d dní predtým + + + %d týždeň predtým + %d týždne predtým + %d týždňov predtým + %d týždňov predtým + + + %d hodina + %d hodiny + %d hodín + %d hodín + + + %d deň + %d dni + %d dní + %d dní + + %1$s %2$s + Najviac %1$s + + %d týždeň + %d týždne + %d týždňov + %d týždňov + + (Bez názvu) + Krok %1$d z %2$d + Vaše udalosti sú iba tu + 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. + Priečinok, ktorý si vyberiete + Zálohy sú obyčajné .ics súbory – uložte je niekam kde sa môžu synchronizovať alebo na SD kartu. + Automaticky jeden krát za deň + Calendula exportuje vaše lokálne kalendáre na pozadí. V nastaveniach môžete zmeniť ako často. + Vyberte priečinok a spravte zálohu + Nie teraz + Čo by sa malo otvoriť ako prvé? + Ako by mal vyzerať rozloženie kalendára? + Pokračovať + Späť + Jasné + Všetko je pripravené + Vaše kalendáre sú pripravené. Všetky voľby môžete neskôr zmeniť v nastaveniach. + Otvoriť môj kalendár + Pripomienky udalostí + Notifikácie v časoch pripomienok vašich udalostí + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 8a4c6a8..06a9fbc 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -6,34 +6,33 @@ Days Weeks - Calendula - 一个现代化的日历应用 + 现代化的日历应用 加载中… - 推迟 - 错误! - 需要日历权限 + 重试 + 出错了。 + 需要日历访问权限。 授予访问权限 - 无已设置的日历 + 未配置任何日历。 打开系统日历设置 - 无法读取日历 - 优雅地查看你的所有活动 - Calendula需要获取权限来访问您的日历来展示和管理您的活动。所有权限均会在使用前申请——不必担心隐私泄露。 - 授予日历权限 - 日历权限不可用 - Calendula 无法显示没有日历访问权限的事件。你可以在系统设置里再次授予它。 + 无法读取日历。 + 优雅地查看您所有的事件 + Calendula 需要访问您的日历,以展示和管理您的事件。 + 授予日历访问权限 + 日历访问被拒绝 + Calendula 无法在没有日历访问权限的情况下显示事件。您可以在系统设置中重新授予权限。 打开系统设置 重试 - 保留在设备 - 您的日历数据将会在本地读取,而不会被上传。 - 所有日历 - Google、CalDAV、本地设备——任何同步到设备的东西都会直接显示出来。 - 无追踪器 - 零遥测,零分析,没有广告。 - 数据仅保留在本地设备 ·无网络权限 - 上个月 - 下个月 + 从设计上保护隐私 + 没有网络权限,没有遥测——任何数据都不会离开您的手机。 + 所有日历汇聚一处 + Google、CalDAV、本地日历——只要同步到设备就会自动显示。 + 永无追踪 + 零遥测、零分析、无广告。 + 数据保留在您的设备 · 无网络权限 + 上月 + 下月 今天 - 更多选项 + 更多操作 打开菜单 设置 今天 @@ -43,80 +42,572 @@ 编辑 删除 分享 - 分享活动 - 无法分享该活动。 - 删除活动? - 该活动将从你的日历以及所有同步设备中移除。 - 删除重复活动 - 仅该活动 - 当前及接下来的所有活动 - 系列中的所有活动 - 编辑重复活动 - 无法删除该活动 - Calendula需要权限读取或删除活动 + 分享事件 + 无法分享此事件。 + 删除事件? + 该事件将从您的日历以及所有同步设备中移除。 + 删除重复事件 + 仅此事件 + 此事件及其后的所有事件 + 整个系列的所有事件 + 编辑重复事件 + 无法删除事件 + Calendula 需要写入权限才能删除事件 取消 - 确认 - 新活动 + 确定 + 新事件 关闭 保存 - 增加标题 - 由“%1$s”管理——标题、日期和重复内容会与你的联系人保持同步。提醒、位置和笔记由你自行编辑。 + 添加标题 + 由“%1$s”管理——标题、日期和重复会从您的联系人同步。提醒、位置和备注由您编辑。 开始 结束 - 报错:结束时间早于起始时间 - 无可写入的日历 - 无法保存该活动 - Calendula需要写入权限来创建活动 - 其他领域 - 新增 - 新建提醒 + 结束时间早于开始时间 + 没有可写入的日历 + 无法保存事件 + Calendula 需要写入权限才能创建事件 + 更多字段 + 添加 + 添加提醒 移除提醒 - 访问者 - 新增访问者 - 通过 Email 新增一个访问者… - 从联系人中添加访问者 - 从联系人中添加地址 - 移除访问者 - 必填 - 可选项 - Calendula 不发送邀请函。你的日历账户同步时可能会给访客发邮件。 - 保存在本设备,其他访客不会被提醒。 + 参与者 + 添加参与者 + 通过电子邮件添加参与者… + 从联系人添加 + 从联系人选取地址 + 移除参与者 + 必需 + 可选 + Calendula 不发送邀请。您的日历帐户同步时可能会通过邮件通知参与者。 + 存储于本设备,不会通知任何人。 自定义 分钟 小时 - 空闲 - 隐私设置 - 色彩 + 忙闲状态 + 可见性 + 颜色 日历颜色 自定义颜色 重置 - 该日历不可用 - 此日历未设置任何颜色集。您可以在“设置”中为此类日历启用自定义颜色。 - 该日历在下一次同步时可能会删除或覆盖该颜色。 - 其他设备的活动已更改 - 您在编辑时,该事件已被同步或其他应用修改。您的修改要怎么处理? + 此日历不支持 + 此日历未提供颜色集。您可以在“设置”中为此类日历允许自定义颜色。 + 此日历下次同步时可能会丢弃或覆盖该颜色。 + 事件已在别处更改 + 您编辑时,此事件已通过同步或其他应用被修改。您的更改应如何处理? 保存我的更改 - 仅您编辑的字段会覆盖外部更改 + 仅覆盖您编辑过的字段 丢弃我的更改 - 该活动保持现状 - 活动被删除 - 这个事件在您编辑期间已被删除了(比如在另一台设备上)。您的修改现在无法保存。 - 确认无需重复 + 事件保持当前状态 + 事件已被删除 + 该事件在您编辑期间已被删除(例如在另一设备上)。您的更改无法再保存。 + 不重复 自定义 - 结束时间 - 从不 + 结束 + 永不 于指定日期 - 重复几次后 + 在指定次数后 - 报错:结束日期早于开始日期 - 占用中 + 重复结束日期早于事件开始时间 + 忙碌 默认 公开 + 复制 + 移动… + 删除重复活动 + 移动整个系列会改变其发生日期,因此只能移动此事件。 + 已移动到 %1$s + 撤销 + 已撤销移动 + 无法保存该活动 + Calendula需要权限读取或删除活动 + 该事件已不存在 + 无法将事件移至其系列结束日期之后 + 设备时区 + 跟随您所在位置 + 搜索时区 + 最近使用 + 所有时区 + 没有与“%1$s”匹配的时区 + %1$s 您的本地时间 + 应用默认提醒? + 此事件导入时没有提醒。 + + 此事件导入时包含 %1$d 个提醒。 + + Calendula + + 应用默认 + 保留原样 + 请输入 1 到 999 之间的数字 + 下一次:%1$s + 此规则永不重复 + 全天 + Calendula + 未知日历 + 地点 + 描述 + 参与者 + 重复 + 重复事件 + 每天 + 每周 + 每月 + 每年 + 每 %1$d 天 + 每 %1$d 周 + 每 %1$d 月 + 每 %1$d 年 + %1$s(%2$s) + %1$s,直到 %2$s + %1$s,共 %2$d 次 + 此事件已不存在。 + 已接受 + 已拒绝 + 待定 + 未回复 + + 提醒 + 时区 + 待定 + 取消 + 空闲 + 私密 + 机密 + 按日历默认设置 + 有权限者可见全部详情 + 他人仅看到您忙碌 + 标记为机密,具体含义由日历帐户决定 + 组织者 + 可选项 + 资源 + 您的回复:%1$s + 事件开始时 + 默认提醒 + + 提前 %d 分钟 + + + 提前 %d 小时 + + + 提前 %d 天 + + + 提前 %d 周 + + + %d分钟 + + + %d 小时 + + + %d 天 + + %1$s %2$s + 最多 %1$s + + %d 周 + + (无标题) + 第 %1$d 步,共 %2$d 步 + 您的事件仅保存在这里 + 本手机上的事件不会同步到任何帐户,丢失手机将丢失日历。Calendula 可以为您创建备份。 + 您选择的文件夹 + 备份为纯 .ics 文件——可存放在同步文件夹或 SD 卡中。 + 每天自动备份 + Calendula 会在后台导出您的本地日历。您可以在“设置”中更改频率。 + 选择文件夹并备份 + 暂不 + 默认打开哪个视图? + 月视图样式? + 继续 + 返回 + 知道了 + 一切就绪 + 您的日历已就绪。所有选择均可在“设置”中更改。 + 打开我的日历 + 事件提醒 + 按事件提醒时间推送的通知 + 不错过任何事件 + Android 本身不会显示事件提醒——需要日历应用来承担。让 Calendula 接管这项工作吧。 + 准时送达提醒 + 您事件中的每个提醒都会按时以通知形式送达。 + 使用第二个日历应用? + 如果另一个应用也发送提醒,您会看到两次——请在其中一个中关闭。 + 随时更改 + 开关位于“设置”中的“通知”下。 + 开启提醒 + 暂不 + 稍后提醒 + 关闭 + 明天 + 昨天 + + + + 日程 + 视图 + 跳转到日期 + 今天 + 今天 + 明天 + 暂无待办 + 今天没有更多事件 + 开始 %1$s + 结束 %1$s + 去今天 + 搜索 + 搜索事件 + 返回 + 清除 + 按标题、地点或备注搜索您的事件。 + 没有与“%1$s”匹配的事件。 + + 已选 %d 个 + + 在描述中找到 + 取消选择 + 全选 + 删除所选 + + 删除 %d个活动? + + + 删除了%d个活动 + + 已删除 %1$d 个,%2$d 个无法删除 + 已删除 %1$d 个,之后写入权限被撤 + 即将开始 + Calendula 日程 + Calendula 月视图 + 刷新 + 新活动 + 打开 Calendula 授予日历访问权限 + 上个月 + 下个月 + 今天 + 日历 + 设置 + 返回 + 返回 + 外观 + 主题 + 跟随系统 + 浅色 + 深色 + 应用使用浅色或深色主题。选择后立即生效。 + 当前为 %1$s + 默认视图 + 动态颜色 + 需要 Android 12 或更高版本 + 协调日历颜色 + 保留每个日历的色调但均衡亮度,使所有事件清晰可读且色彩协调。关闭则显示日历源的原始颜色。 + 标题字体 + 正文字体 + 系统默认 + Atkinson Hyperlegible + Lora + JetBrains Mono + 选择文件… + 自定义字体 + 无法将文件识别为字体 + 2026年8月28日 星期四 + 团队评审 10:00,然后与 Robin 在集市广场咖啡馆共进午餐。 + 每周起始日 + 自动 + 当前为 %1$s + 周数 + 在月视图中显示日历周数 + 工具栏中的“今天”按钮 + 在工具栏中显示“跳至今天”按钮,而非浮动按钮 + 应用名称 + 在启动器中显示为“日历”。仅启动器名称改变;切换后图标可能移动到新位置。 + 时间格式 + 自动 + 12 小时制(下午 2:00) + 24 小时制(14:00) + 跟随系统:%1$s + 整点分隔线 + 在周视图和日视图中显示每个整点的分隔线 + 小时高度 + 周视图和日视图中每小时占用的垂直空间。两者共享此设置。您也可以用双指捏合时间线设置任意高度。 + 适应全天 + 24 小时一屏显示,无需滚动 + 紧凑 + 每屏显示更多小时,区块更小 + 常规 + 标准间距 + 舒适 + 区块更宽敞,需更多滚动 + 自定义 + 您捏合时间线所设定的高度 + 变暗已完成事件 + 在月视图和周视图中淡化已结束的事件 + 拖拽重新安排 + 通过拖拽事件到另一天或时间来移动它 + 过去事件 + 显示 + 变暗 + 隐藏 + 团队评审 + 与 Robin 午餐 + 合唱团练习 + 日程 + 日程范围 + “日程”视图列出未来事件的时间跨度。 + 日程小部件范围 + 主屏幕日程小部件列出未来事件的时间跨度。 + 日程小部件大小 + 主屏幕日程小部件绘制文字的大小。月视图小部件无此设置——它的网格始终适应您给定的空间。 + + + + 特大 + 事件文字 %1$d%% + 始终显示今天 + 即使今天没有剩余事件,也将今天保持在日程及其小部件顶部。 + 范围栏 + 在日程顶部显示一个条,标明显示的日期范围,并提供一个按钮用于临时切换范围 + 今天 + 本周 + 本月 + 未来 7 天 + 未来 30 天 + 自定义… + 天数 + 仅本次生效——重新打开 Calendula 时恢复为已保存的范围。 + 显示所有未来事件 + + %d 天 + + 视图 + 月视图 + 月视图样式 + 分页 + 一屏显示一个月,左右滑动切换月份。 + 滚动月份 + 每个月在其标题下,与下个月之间略有间距。 + 无缝周 + 各周连续排列,月份之间无间隙。 + 拆分 + 紧凑网格标记有事件的日子,点击的日期在下方列出。 + 无安排 + 显示整月 + 显示当天事件 + 快速切换按钮 + 选择右上角按钮循环切换的视图,并拖动排序。关闭的视图仍可从导航菜单访问。 + 导航菜单 + 拖动排序导航菜单中的视图。 + 拖动以排序 + 新建事件表单 + 默认显示的字段——其他字段位于“更多字段”下 + 新建事件时聚焦标题 + 创建新事件时,光标自动置于标题字段并弹出键盘。 + 默认时长 + 新事件在您更改结束时间之前的默认时长。全天事件不受影响。 + 按日历设置时长 + 为某个日历单独设置默认时长——例如工作班次设为 8 小时。 + 默认(%1$s) + 使用默认时长(%1$s) + 允许在不支持的日历上使用颜色 + 某些日历(例如某些 CalDAV)未发布颜色集;自定义事件颜色可能会在下一次同步时被丢弃或覆盖。这是那些日历的限制,并非 Calendula 可修复的问题。 + 通知 + 事件提醒 + 看到重复提醒?另一个日历应用也在发送——请关闭其中一个。 + 默认提醒 + 全天事件 + 全天事件提醒时间 + 全天事件的提醒在 %1$s 触发 + 在 %1$s 通知 + + 使用默认提醒 + 数量 + 设置 + 按日历设置提醒 + 为每个日历覆盖默认提醒——分别针对定时事件和全天事件。日历可保持默认、不使用提醒,或设置自己的提醒。 + 默认(%1$s) + 可靠送达 + Android 可能为省电延迟提醒。豁免 Calendula 以使其准时送达。 + 豁免电池优化——提醒准时送达。 + 贪睡时长 + 日历 + 管理日历 + 创建本地日历;管理同步日历 + 语言 + 应用语言 + 系统默认 + 帮助翻译 + 在 Weblate 上添加或改进语言 + 外观与行为 + 数据 + 应用 + 关于 + 主题、颜色、字体 + 默认视图、布局、顺序 + 默认字段和行为 + 提醒与送达 + 联系人生日和纪念日 + 日程小部件、快速设置磁贴 + 导出、导入、自动备份 + 小部件与磁贴 + 主屏幕小部件和快速设置磁贴的设置。长按主屏幕即可添加小部件。 + 备份与恢复 + 所有视图 + 周与日 + 启动 Calendula 时默认打开的视图。 + 所有视图和小部件中每周开始的日子。 + 整个应用的时间显示格式。自动跟随系统设置。 + 日程对已结束事件的处理方式。 + 从壁纸中提取应用颜色。 + 联系人特殊日期 + 显示联系人日期 + 将联系人生日和其他日期镜像到本地日历。仅读取本设备上的联系人——不会上传任何内容,也不会修改您的联系人。 + 生日 + 纪念日 + 其他日期 + 标题格式 + 使用 {name} 表示联系人,{year} 表示年份(出生年份或纪念日开始年份;未知时隐藏)。 + 提醒 + 显示年份 + 在标题中包含 {year}(当已知时) + 立即同步 + 尚未同步 + 上次同步:%1$s + 在“日历”设置中分别设置每个日历的颜色和可见性。 + 在“联系人特殊日期”中设置 + 已暂停 + Calendula 无法读取您的联系人,因此这些日历未更新。 + 授予访问权限 + 关闭联系人日期? + 这将删除联系人日期日历及其事件。您添加的任何提醒或备注将丢失。 + 这将删除“%1$s”日历及其事件。您添加的任何提醒或备注将丢失。 + 关闭 + 保存 + 关于 + 许可协议 + MIT + 作者 Jean-Luc Makiola + 源代码 + 隐私政策 + 支持开发 + 版本 %1$s + Calendula 应用图标 + 报告问题 + 发送崩溃报告或打开问题追踪器 + 日历 + 您的日历 + 尚未创建本地日历。创建一个以仅在本设备保存事件。 + 添加日历 + 关闭日历可隐藏其在设备上的显示——事件会从应用中消失,同时停止提醒。此开关与其他日历应用共用,因此它们也会隐藏。不会删除任何内容,不会影响其他设备,您随时可在此重新开启。 + 显示“%1$s” + 部分日历已关闭 + Calendula 显示本设备已开启的日历,但您有一些是关闭的。要查看事件,请在“设置”→“日历”中将其开启。 + 找不到日历? + 可能已关闭、只读,或由联系人填充——在此管理您的日历。 + 只读 + 未同步到本设备 + 由联系人填充 + 此日历由联系人填充,Calendula 下次同步时会重新创建。要删除它,请在“设置”→“特殊日期”中关闭相关功能。 + 同步日历 + 来自设备上的帐户。请在对应的应用中创建和编辑。 + 在应用中管理 + %1$s(%2$s) + %1$s 的更多选项 + 启用全部 + 禁用全部 + 添加帐户 + 新建日历 + 编辑日历 + 名称 + 色彩 + 添加描述 + 删除日历? + “%1$s”及其所有事件将从本设备永久删除。 + 无法保存更改。 + 返回 + 本地日历不会同步到任何地方,请导出为 .ics 文件以保留副本。 + 导出为 .ics 文件 + 导出日历 + 选择要包含在 .ics 文件中的日历。 + 导出 + 恢复 + 从 .ics 文件恢复 + 从备份或其他日历应用导入事件。 + 自动备份 + 定期将本地日历导出为 .ics 文件保存到文件夹。 + 备份文件夹 + 点击选择文件夹 + 间隔 + 每 %1$s + 最短 30 分钟。 + 尚未自动备份 + 上次备份:%1$s + 上次备份失败:%1$s + 备份 + 自动备份反复失败时发出警告。 + 自动备份失败 + Calendula 无法写入备份文件。请在“设置”中检查备份文件夹。 + 无法导出备份。 + + 已导出 %d 个事件。 + + 导入事件 + 添加至日历 + 此文件中未找到事件。 + 无法读取此文件。 + 没有可写入的日历用于导入。请先创建本地日历。 + 导入完成 + 已跳过日历中已存在的事件。 + 已添加 + 重复项 + 关闭 + 已跳过重复事件中部分已修改的发生项。 + 已跳过无开始时间的事件。 + 未导入参与者列表。 + 未知时区已回退为设备时区。 + 导入 + + 正在导入 %d 个事件 + + + 此文件中包含 %d 个事件。 + + + 导入 %d 个事件 + + + 已导入 %d 个事件。 + + + 已跳过 %d 个(已存在于该日历)。 + + 新事件 + 创建新事件 + 新事件 + 添加快速设置磁贴 + 在快速设置面板中添加“新事件”磁贴。 + %1$s 已崩溃 + %1$s 上次意外关闭。您可以将此报告作为问题发送以帮助修复。报告在您选择共享之前仅保留在设备上,不含任何个人数据或日历内容——仅包含以下技术细节。 + 报告 + 暂不 + 崩溃报告 + %1$s 崩溃报告 + 报告已复制到剪贴板 + 无法打开问题追踪器。报告已在剪贴板中。 + 感谢您报告 %1$s 的崩溃。请补充您记得的操作内容,然后提交。\n\n### 发生了什么\n\n\n### 崩溃报告\n%2$s\n + _(报告太长,无法通过此链接附带——请从剪贴板粘贴。)_ + 生日 + 纪念日 + 特殊日期 + {name} 的生日({year}) + {name} 的纪念日({year}) + {name} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5fbdf01..f405e00 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -231,6 +231,8 @@ Your response: %1$s At time of event Default reminder + + %1$s after %d minute before %d minutes before @@ -693,11 +695,16 @@ Events already in the calendar were skipped. Added Duplicates + Not added + Some events couldn\'t be added and were left out. + Import stopped Close Some changed occurrences of recurring events were skipped. An event without a start time was skipped. Guest lists weren\'t imported. An unknown time zone fell back to your device\'s. + Tasks in this file were added as events. + A faulty repeat rule was repaired. Import Importing %d event @@ -711,6 +718,10 @@ Import %d event Import %d events + + The calendar stopped accepting events part way through. %d event from the file was never added. + The calendar stopped accepting events part way through. %d events from the file were never added. + Imported %d event. Imported %d events. @@ -719,6 +730,10 @@ Skipped %d already in this calendar. Skipped %d already in this calendar. + + %d event couldn\'t be added. + %d events couldn\'t be added. + New event Create a new event diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml index e1e7784..8eeef6a 100644 --- a/app/src/main/res/xml/locales_config.xml +++ b/app/src/main/res/xml/locales_config.xml @@ -10,11 +10,17 @@ + + + + + + diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/AllDayReminderEncodingTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/AllDayReminderEncodingTest.kt index 0dd4cc4..75da13d 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/AllDayReminderEncodingTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/AllDayReminderEncodingTest.kt @@ -143,6 +143,76 @@ class AllDayReminderEncodingTest { 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 fun `a winter-anchored offset drifts one hour on a summer occurrence`() { // Known limitation: one fixed MINUTES per series can't track DST. An diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt index 1e022a6..f67f674 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import kotlin.time.Instant import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows import org.junit.jupiter.api.io.TempDir import java.nio.file.Path @@ -642,7 +643,12 @@ class CalendarRepositoryImplTest { 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(fake.updatedOccurrences).containsExactly(Triple(42L, 1_000L, form)) @@ -801,6 +807,113 @@ class CalendarRepositoryImplTest { 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 fun `exportEvents forwards the chosen calendar-id subset to the data source`( @TempDir tempDir: Path, @@ -825,9 +938,10 @@ class CalendarRepositoryImplTest { 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, - summary = "E", + summary = summary, start = Instant.fromEpochMilliseconds(1_000_000_000L), end = Instant.fromEpochMilliseconds(1_000_003_600L), isAllDay = false, diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt index e8f4a75..c369cf6 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt @@ -128,7 +128,8 @@ class EventWriteMapperTest { original: EventForm, updated: EventForm, series: Long = seriesStart, - ): Map = buildEventUpdateValues(original, updated, series, berlin) + exdate: String? = null, + ): Map = buildEventUpdateValues(original, updated, series, exdate, berlin) /** The instant [local] names in [zoneId], as the provider would store it. */ private fun instantAt(local: String, zoneId: String): Long = @@ -497,6 +498,7 @@ class EventWriteMapperTest { occurrenceMillis = 1_784_102_400_000L, dtStartMillis = 1_783_929_600_000L, rrule = "FREQ=DAILY;COUNT=5", + rdate = null, duration = "PT1H", timezone = "Europe/Berlin", allDay = 0, @@ -520,6 +522,7 @@ class EventWriteMapperTest { occurrenceMillis = 1_784_102_400_000L, dtStartMillis = 1_783_929_600_000L, rrule = "FREQ=DAILY;COUNT=5", + rdate = null, duration = "PT1H", timezone = "Europe/Berlin", allDay = 0, @@ -535,6 +538,7 @@ class EventWriteMapperTest { occurrenceMillis = 1_784_102_400_000L, dtStartMillis = 1_783_929_600_000L, rrule = "FREQ=DAILY;COUNT=5", + rdate = null, duration = "PT1H", timezone = "Europe/Berlin", allDay = 0, @@ -542,6 +546,27 @@ class EventWriteMapperTest { 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 fun `all-day exdate drop uses the date-only form`() { // 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 dtStartMillis = 1_783_900_800_000L, rrule = "FREQ=YEARLY", + rdate = null, duration = "P1D", timezone = "UTC", allDay = 1, @@ -558,6 +584,439 @@ class EventWriteMapperTest { 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;`, 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=;. 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 --- @Test @@ -746,4 +1205,173 @@ class EventWriteMapperTest { val values = buildCopiedExceptionValues(exceptionSnapshot(duration = "P900S", dtEndMillis = null)) 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") + } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt index a66fd40..e4adc62 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt @@ -23,6 +23,7 @@ internal class FakeCalendarDataSource : CalendarDataSource { var searchResult: (String) -> List = { _ -> emptyList() } var eventDetailResult: (Long) -> EventDetail? = { null } var eventColorPaletteResult: (Long) -> List = { emptyList() } + var publishedEventColorsResult: (Long) -> List = { emptyList() } var exportableEventsResult: List = emptyList() /** The [calendarIds] the last [exportableEvents] call received (null = all). */ var lastExportableEventsCalendarIds: Set? = null @@ -81,7 +82,12 @@ internal class FakeCalendarDataSource : CalendarDataSource { eventDetailResult(eventId) override fun eventColorPalette(calendarId: Long): List = eventColorPaletteResult(calendarId) - override fun exportableEvents(calendarIds: Set?): List { + override fun publishedEventColors(calendarId: Long): List = + publishedEventColorsResult(calendarId) + override fun exportableEvents( + calendarIds: Set?, + allDayReminderTimeMinutes: Int, + ): List { lastExportableEventsCalendarIds = calendarIds return exportableEventsResult } @@ -91,8 +97,25 @@ internal class FakeCalendarDataSource : CalendarDataSource { /** (event, targetCalendarId) pairs passed to [insertImportedEvent]. */ val importedEvents = mutableListOf>() - override fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long { - writeError?.let { throw it } + /** Thrown instead of [writeError] for events whose summary is in this set. */ + val failingImportSummaries = mutableSetOf() + + /** 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 = emptyList() + + override fun insertImportedEvent( + event: ParsedIcsEvent, + calendarId: Long, + allDayReminderTimeMinutes: Int, + colorPalette: List, + ): Long { + lastImportPalette = colorPalette + if (imports++ >= importsBeforeError) writeError?.let { throw it } + if (event.summary in failingImportSummaries) error("rejected: ${event.summary}") importedEvents += event to calendarId return nextInsertId } @@ -173,6 +196,7 @@ internal class FakeCalendarDataSource : CalendarDataSource { override fun updateOccurrence( eventId: Long, beginMillis: Long, + original: EventForm, form: EventForm, allDayReminderTimeMinutes: Int, ): Long { diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapperTest.kt index 552e1d9..6045ef4 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapperTest.kt @@ -4,6 +4,7 @@ import android.provider.CalendarContract import com.google.common.truth.Truth.assertThat import de.jeanlucmakiola.calendula.domain.EventStatus import org.junit.jupiter.api.Test +import java.time.ZoneId class IcsExportMapperTest { @@ -20,7 +21,11 @@ class IcsExportMapperTest { 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.summary).isEqualTo("Standup") @@ -47,7 +52,11 @@ class IcsExportMapperTest { 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.recurrenceRule).isEqualTo("FREQ=WEEKLY") @@ -65,6 +74,224 @@ class IcsExportMapperTest { 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() } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/ImportedEventValuesTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/ImportedEventValuesTest.kt new file mode 100644 index 0000000..3acd9f4 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/ImportedEventValuesTest.kt @@ -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 = 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 = 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) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapperTest.kt index a546014..1c7dd1b 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapperTest.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.calendula.data.calendar +import android.provider.CalendarContract import com.google.common.truth.Truth.assertThat import kotlin.time.Instant import org.junit.jupiter.api.Test @@ -17,6 +18,7 @@ class InstanceMapperTest { eventColor: Any? = null, calendarColor: Int = 0xFFAABBCC.toInt(), location: String? = null, + selfAttendeeStatus: Int = CalendarContract.Attendees.ATTENDEE_STATUS_NONE, ): MapColumnReader = MapColumnReader( InstanceProjection.IDX_INSTANCE_ID to instanceId, InstanceProjection.IDX_EVENT_ID to eventId, @@ -28,6 +30,7 @@ class InstanceMapperTest { InstanceProjection.IDX_EVENT_COLOR to eventColor, InstanceProjection.IDX_CALENDAR_COLOR to calendarColor, InstanceProjection.IDX_LOCATION to location, + InstanceProjection.IDX_SELF_ATTENDEE_STATUS to selfAttendeeStatus, ) @Test @@ -90,4 +93,20 @@ class InstanceMapperTest { val inst = reader(location = "Berlin").toEventInstance() assertThat(inst!!.location).isEqualTo("Berlin") } + + @Test + fun `a declined invitation is marked, any other answer is not`() { + assertThat(reader().toEventInstance()!!.isDeclined).isFalse() + assertThat( + reader(selfAttendeeStatus = CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED) + .toEventInstance()!!.isDeclined, + ).isTrue() + listOf( + CalendarContract.Attendees.ATTENDEE_STATUS_ACCEPTED, + CalendarContract.Attendees.ATTENDEE_STATUS_TENTATIVE, + CalendarContract.Attendees.ATTENDEE_STATUS_INVITED, + ).forEach { status -> + assertThat(reader(selfAttendeeStatus = status).toEventInstance()!!.isDeclined).isFalse() + } + } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/ProviderReminderMinutesTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/ProviderReminderMinutesTest.kt new file mode 100644 index 0000000..c001e0a --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/ProviderReminderMinutesTest.kt @@ -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()) + } + + @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)) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsColorTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsColorTest.kt new file mode 100644 index 0000000..0df9328 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsColorTest.kt @@ -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() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsFossifyImportTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsFossifyImportTest.kt new file mode 100644 index 0000000..d663db6 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsFossifyImportTest.kt @@ -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() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsParserTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsParserTest.kt index abdc710..851d427 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsParserTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsParserTest.kt @@ -165,4 +165,36 @@ class IcsParserTest { assertThat(result.events.map { it.uid }).containsExactly("good") 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") + } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsRecurrenceTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsRecurrenceTest.kt new file mode 100644 index 0000000..6284708 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsRecurrenceTest.kt @@ -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() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsWriterTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsWriterTest.kt index 89b06e7..bc92cfb 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsWriterTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/ics/IcsWriterTest.kt @@ -149,4 +149,55 @@ class IcsWriterTest { // Stable across calls — a re-export of the same row yields the same UID. 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() + } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModelTest.kt index c29a521..a22c6f2 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModelTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModelTest.kt @@ -286,9 +286,53 @@ class RescheduleViewModelTest { vm.move(toWednesday()) advanceUntilIdle() + val abandoned = vm.abandoned.value assertThat(vm.undo(undo)).isFalse() advanceUntilIdle() assertThat(fake.updatedEvents).hasSize(1) + // Nothing ticks: the refusal started no journey of its own, and the + // counter is global — a tick here would release the copy the drop that + // *is* in flight is holding, before the grid has drawn it. + assertThat(vm.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 @@ -494,4 +538,61 @@ class RescheduleViewModelTest { assertThat(fake.updatedEvents).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) + } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimedBlockShapeTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimedBlockShapeTest.kt new file mode 100644 index 0000000..1e4fc48 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimedBlockShapeTest.kt @@ -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() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineDropTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineDropTest.kt new file mode 100644 index 0000000..9ee1646 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineDropTest.kt @@ -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) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModelTest.kt new file mode 100644 index 0000000..2a3ae75 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModelTest.kt @@ -0,0 +1,124 @@ +package de.jeanlucmakiola.calendula.ui.detail + +import android.content.ContextWrapper +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl +import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource +import de.jeanlucmakiola.calendula.data.ics.IcsExporter +import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs +import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.calendula.domain.CalendarSource +import de.jeanlucmakiola.calendula.domain.EventDetail +import de.jeanlucmakiola.calendula.domain.EventInstance +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path +import kotlin.time.Instant + +/** + * Re-opening an occurrence must re-read it (#196): the view model outlives the + * sheet, so an edit that changed no time would otherwise show the pre-save row. + * The re-read stays silent — the loaded content must not blink back to the + * skeleton on the way. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class EventDetailViewModelTest { + + private val dispatcher = UnconfinedTestDispatcher() + + @BeforeEach fun setUp() = Dispatchers.setMain(dispatcher) + @AfterEach fun tearDown() = Dispatchers.resetMain() + + private val beginMillis = 1_781_164_800_000L + private val endMillis = beginMillis + 3_600_000L + + private fun detail(description: String?) = EventDetail( + instance = EventInstance( + instanceId = 42L, eventId = 42L, calendarId = 1L, title = "Standup", + start = Instant.fromEpochMilliseconds(beginMillis), + end = Instant.fromEpochMilliseconds(endMillis), + isAllDay = false, color = 0xFF000000.toInt(), location = null, + ), + description = description, organizer = null, attendees = emptyList(), rrule = null, + ) + + private fun viewModel(tempDir: Path, fake: FakeCalendarDataSource): EventDetailViewModel { + val prefs = CalendarPrefs( + PreferenceDataStoreFactory.create( + scope = CoroutineScope(dispatcher), + produceFile = { tempDir.resolve("detail_prefs.preferences_pb").toFile() }, + ), + ) + val settings = SettingsPrefs( + PreferenceDataStoreFactory.create( + scope = CoroutineScope(dispatcher), + produceFile = { tempDir.resolve("detail_settings.preferences_pb").toFile() }, + ), + ) + val repo = CalendarRepositoryImpl(fake, prefs, settings, dispatcher as CoroutineDispatcher) + // Only `shareUri()` touches the exporter, and nothing here shares. + return EventDetailViewModel(repo, IcsExporter(ContextWrapper(null)), dispatcher) + } + + private fun fakeSource(description: () -> String?) = FakeCalendarDataSource().apply { + calendarsResult = listOf( + CalendarSource( + id = 1L, displayName = "Cal", accountName = "acc@local", accountType = "LOCAL", + color = 0xFF112233.toInt(), isVisibleInSystem = true, canModifyContents = true, + ), + ) + eventDetailResult = { detail(description()) } + } + + @Test + fun `re-opening the same occurrence re-reads it`(@TempDir tempDir: Path) = runTest(dispatcher) { + var stored: String? = null + val vm = viewModel(tempDir, fakeSource { stored }) + val collector = launch(Job()) { vm.state.collect {} } + + vm.open(42L, beginMillis, endMillis) + advanceUntilIdle() + assertThat((vm.state.value as EventDetailUiState.Success).detail.description).isNull() + + // The edit screen saved a description; the tapped occurrence is unchanged. + stored = "Bring the roadmap" + vm.open(42L, beginMillis, endMillis) + advanceUntilIdle() + assertThat((vm.state.value as EventDetailUiState.Success).detail.description) + .isEqualTo("Bring the roadmap") + + collector.cancel() + } + + @Test + fun `the re-read does not fall back to the skeleton`(@TempDir tempDir: Path) = runTest(dispatcher) { + val vm = viewModel(tempDir, fakeSource { null }) + val seen = mutableListOf() + val collector = launch(Job()) { vm.state.collect { seen += it } } + + vm.open(42L, beginMillis, endMillis) + advanceUntilIdle() + assertThat(vm.state.value).isInstanceOf(EventDetailUiState.Success::class.java) + + seen.clear() + vm.open(42L, beginMillis, endMillis) + advanceUntilIdle() + assertThat(seen).doesNotContain(EventDetailUiState.Loading) + + collector.cancel() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/imports/ImportTargetTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/imports/ImportTargetTest.kt new file mode 100644 index 0000000..cbb1a5b --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/imports/ImportTargetTest.kt @@ -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() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ChipAtCellYTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ChipAtCellYTest.kt new file mode 100644 index 0000000..2f1ee4c --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ChipAtCellYTest.kt @@ -0,0 +1,122 @@ +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.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.YearMonth +import kotlinx.datetime.atTime +import kotlinx.datetime.plus +import kotlinx.datetime.toInstant +import org.junit.jupiter.api.Test + +/** + * Which chip a tap in a month cell lands on (#187) — the geometry the tap layer + * uses to tell "open this event" from "open this day", given that the chips take + * no pointer input of their own. + */ +class ChipAtCellYTest { + + private val zone = TimeZone.UTC + private val jul26 = YearMonth(2026, Month.JULY) + + /** Band starts 40px down the cell; each lane is 20px tall. */ + private val bandTop = 40f + private val laneHeight = 20f + + /** July 2026 starts on a Wednesday, so this row — Jul 6–12 — sits wholly inside it. */ + private fun rowOfJuly6(events: List) = + layoutMonthWeeks(jul26, DayOfWeek.MONDAY, events, zone)[1] + + private fun allDay(from: LocalDate, toInclusive: LocalDate, id: Long) = EventInstance( + instanceId = id, + eventId = id, + calendarId = 1L, + title = "A$id", + start = from.atTime(0, 0).toInstant(zone), + end = toInclusive.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(zone), + isAllDay = true, + color = 0xFF2196F3.toInt(), + location = null, + ) + + private fun timed(date: LocalDate, hour: Int, id: Long) = EventInstance( + instanceId = id, + eventId = id, + calendarId = 1L, + title = "T$id", + start = date.atTime(hour, 0).toInstant(zone), + end = date.atTime(hour + 1, 0).toInstant(zone), + isAllDay = false, + color = 0xFFF44336.toInt(), + location = null, + ) + + private fun MonthWeek.chipAt(col: Int, cellY: Float) = + chipAtCellY(col = col, cellY = cellY, bandTopInCell = bandTop, rowHeightPx = laneHeight) + + @Test + fun `a tap on a lane resolves to the chip seated there`() { + val bar = allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9), id = 1L) + val meeting = timed(LocalDate(2026, 7, 7), hour = 9, id = 2L) + val week = rowOfJuly6(listOf(bar, meeting)) + + // Jul 7 is column 1 of a Monday-anchored row starting Jul 6. + assertThat(week.chipAt(col = 1, cellY = bandTop + 5f)?.eventId).isEqualTo(1L) + assertThat(week.chipAt(col = 1, cellY = bandTop + laneHeight + 5f)?.eventId).isEqualTo(2L) + } + + @Test + fun `a multi-day bar answers on every column it covers`() { + val bar = allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9), id = 1L) + val week = rowOfJuly6(listOf(bar)) + + (1..3).forEach { col -> + assertThat(week.chipAt(col = col, cellY = bandTop + 5f)?.eventId).isEqualTo(1L) + } + // Jul 10 is past the bar's last day. + assertThat(week.chipAt(col = 4, cellY = bandTop + 5f)).isNull() + } + + @Test + fun `a tap above the band is the day number, not a chip`() { + val week = rowOfJuly6(listOf(timed(LocalDate(2026, 7, 7), hour = 9, id = 2L))) + + assertThat(week.chipAt(col = 1, cellY = bandTop - 1f)).isNull() + assertThat(week.chipAt(col = 1, cellY = 0f)).isNull() + } + + @Test + fun `a tap on an empty lane of a day that has chips falls through to the day`() { + val week = rowOfJuly6(listOf(timed(LocalDate(2026, 7, 7), hour = 9, id = 2L))) + + assertThat(week.chipAt(col = 1, cellY = bandTop + laneHeight * 2 + 5f)).isNull() + } + + @Test + fun `a tap on the overflow row opens the day rather than a hidden event`() { + val events = (1..MAX_EVENT_ROWS + 2).map { + timed(LocalDate(2026, 7, 7), hour = it, id = it.toLong()) + } + val week = rowOfJuly6(events) + + // The dots sit one lane below the last one the row draws. + val overflowY = bandTop + laneHeight * MAX_EVENT_ROWS + 2f + assertThat(week.chipAt(col = 1, cellY = overflowY)).isNull() + } + + @Test + fun `unmeasured geometry resolves to no chip`() { + val week = rowOfJuly6(listOf(timed(LocalDate(2026, 7, 7), hour = 9, id = 2L))) + + assertThat( + week.chipAtCellY(col = 1, cellY = 45f, bandTopInCell = null, rowHeightPx = laneHeight), + ).isNull() + assertThat( + week.chipAtCellY(col = 1, cellY = 45f, bandTopInCell = bandTop, rowHeightPx = 0f), + ).isNull() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ChipSeatTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ChipSeatTest.kt new file mode 100644 index 0000000..710b2ad --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ChipSeatTest.kt @@ -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) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLayoutTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLayoutTest.kt index 52e43f8..e4fc21d 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLayoutTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLayoutTest.kt @@ -44,6 +44,19 @@ class MonthLayoutTest { 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. */ private fun allDay( from: LocalDate, @@ -162,6 +175,43 @@ class MonthLayoutTest { 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 fun `countByDay totals bars and pills on each date`() { val span = allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12), id = 1L) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverSchedulerTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverSchedulerTest.kt new file mode 100644 index 0000000..8242073 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverSchedulerTest.kt @@ -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, + ) + } +} diff --git a/app/src/test/resources/ics/fossify-contact-birthdays.ics b/app/src/test/resources/ics/fossify-contact-birthdays.ics new file mode 100644 index 0000000..6c9a7b8 --- /dev/null +++ b/app/src/test/resources/ics/fossify-contact-birthdays.ics @@ -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 diff --git a/app/src/test/resources/ics/fossify-holidays-at.ics b/app/src/test/resources/ics/fossify-holidays-at.ics new file mode 100644 index 0000000..310ece3 --- /dev/null +++ b/app/src/test/resources/ics/fossify-holidays-at.ics @@ -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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d013789..780f683 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 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 JUnit 5 + Truth + Turbine on the JVM. The seams that make it work: diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 19537f7..ebb5874 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -172,9 +172,12 @@ F-Droid but not Play: Until those are fixed, Play's graphics are managed by hand in the console. Then pass `images:true` to the `listing` lane. -**Track.** Uploads go to `internal` by default; promotion to production stays a -manual action in the Play Console, matching the rule that UI releases wait for -on-device review. Override with the `PLAY_TRACK` repo variable. +**Track.** Uploads go straight to `production` at a full rollout +(`PLAY_RELEASE_STATUS=completed`). The merge to `main` is already the human +gate — a release only gets there after on-device review — so a second manual +promotion in the Play Console added delay without adding a decision. Set the +`PLAY_TRACK` repo variable (e.g. `internal`) to stage a release instead, or +`PLAY_RELEASE_STATUS=draft` to hold it unpublished. ### Manual re-sign / recovery @@ -233,7 +236,7 @@ Two consequences worth remembering: | Variable | Default | Purpose | | --- | --- | --- | -| `PLAY_TRACK` | `internal` | Play track the bundle is uploaded to. | +| `PLAY_TRACK` | `production` | Play track the bundle is uploaded to. Set to `internal`/`alpha`/`beta` to stage instead. | | `PLAY_RELEASE_STATUS` | `completed` | `completed`, `draft`, `inProgress` or `halted`. | | `PLAY_DRY_RUN` | `false` | `true` validates the Play edit against the API and discards it — use to rehearse. | diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 459651d..afdde8a 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -29,7 +29,7 @@ platform :android do supply( aab: aab, - track: options[:track] || "internal", + track: options[:track] || "production", release_status: options[:release_status] || "completed", # The APK belongs to F-Droid and the Codeberg download; Play only ever diff --git a/fastlane/metadata/android/ar/changelogs/21902.txt b/fastlane/metadata/android/ar/changelogs/21902.txt new file mode 100644 index 0000000..13c969a --- /dev/null +++ b/fastlane/metadata/android/ar/changelogs/21902.txt @@ -0,0 +1,8 @@ +إصلاحات +• النقر على حدث في عرض الشهر يفتح الحدث نفسه بدل يومه. +• التعديل يظهر فور إعادة فتح الحدث. +• لم تعد أسهم أداة الشهر تتوقف بعد بضع نقرات: تحديث ضخم كان يُسقط مضيف الأدوات في المشغّل. +• «اليوم» في الأسابيع المتصلة يصل إلى الأسبوع الحالي تماماً، وعادت مسافة الهامش على يسار عرضي اليوم والأسبوع. + +تغييرات +• الحدث الذي رفضته يظهر مشطوباً في كل مكان ولم يعد يُذكّرك به. diff --git a/fastlane/metadata/android/de-DE/changelogs/21902.txt b/fastlane/metadata/android/de-DE/changelogs/21902.txt new file mode 100644 index 0000000..d60845b --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/21902.txt @@ -0,0 +1,8 @@ +Behoben +• Ein Tippen auf einen Termin in der Monatsansicht öffnet ihn statt des Tages. +• Eine Änderung ist sofort sichtbar, wenn der Termin neu geöffnet wird. +• Die Pfeile des Monats-Widgets sterben nicht mehr nach wenigen Tipps – eine zu große Aktualisierung riss den Widget-Host mit. +• „Heute“ springt in nahtlosen Wochen genau auf die aktuelle Woche; Wochen- und Tagesansicht haben rechts wieder Abstand. + +Geändert +• Ein abgelehnter Termin ist überall durchgestrichen und erinnert nicht mehr. diff --git a/fastlane/metadata/android/en-GB/changelogs/21902.txt b/fastlane/metadata/android/en-GB/changelogs/21902.txt new file mode 100644 index 0000000..7d9cd37 --- /dev/null +++ b/fastlane/metadata/android/en-GB/changelogs/21902.txt @@ -0,0 +1,8 @@ +Fixed +• Tapping an event in month view now opens the event instead of its day. +• An edit shows straight away when you re-open the event. +• The month widget's arrows no longer die after a few taps — an oversized update was killing the launcher's widget host. +• Jump-to-today in seamless weeks lands on the current week, and day/week view no longer run flush against the right edge. + +Changed +• A meeting you declined is struck through everywhere and no longer reminds you. diff --git a/fastlane/metadata/android/en-US/changelogs/21902.txt b/fastlane/metadata/android/en-US/changelogs/21902.txt new file mode 100644 index 0000000..7d9cd37 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/21902.txt @@ -0,0 +1,8 @@ +Fixed +• Tapping an event in month view now opens the event instead of its day. +• An edit shows straight away when you re-open the event. +• The month widget's arrows no longer die after a few taps — an oversized update was killing the launcher's widget host. +• Jump-to-today in seamless weeks lands on the current week, and day/week view no longer run flush against the right edge. + +Changed +• A meeting you declined is struck through everywhere and no longer reminds you. diff --git a/fastlane/metadata/android/en-US/changelogs/21903.txt b/fastlane/metadata/android/en-US/changelogs/21903.txt new file mode 100644 index 0000000..d457d18 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/21903.txt @@ -0,0 +1,5 @@ +Added +• Calendula now speaks Czech, Japanese and Russian, contributed by community translators on Weblate. All three are early, so untranslated parts still show in English — pick one under Settings → Language. + +Changed +• The Brazilian Portuguese translation is now complete. diff --git a/fastlane/metadata/android/en-US/changelogs/21904.txt b/fastlane/metadata/android/en-US/changelogs/21904.txt new file mode 100644 index 0000000..a88d0e8 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/21904.txt @@ -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. diff --git a/fastlane/metadata/android/es-ES/changelogs/21902.txt b/fastlane/metadata/android/es-ES/changelogs/21902.txt new file mode 100644 index 0000000..aaf5e31 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/21902.txt @@ -0,0 +1,8 @@ +Corregido +• Tocar un evento en la vista de mes abre el evento, no su día. +• Un cambio se ve en cuanto vuelves a abrir el evento. +• Las flechas del widget de mes ya no se quedan muertas tras unos toques: una actualización demasiado grande tumbaba el host de widgets del launcher. +• «Hoy» en semanas continuas llega justo a la semana actual, y las vistas de día y semana vuelven a tener margen a la derecha. + +Cambios +• Un evento que has rechazado aparece tachado en todas partes y ya no avisa. diff --git a/fastlane/metadata/android/fr-FR/changelogs/21902.txt b/fastlane/metadata/android/fr-FR/changelogs/21902.txt new file mode 100644 index 0000000..2dfb272 --- /dev/null +++ b/fastlane/metadata/android/fr-FR/changelogs/21902.txt @@ -0,0 +1,8 @@ +Corrigé +• Appuyer sur un événement en vue mois ouvre l'événement, pas sa journée. +• Une modification apparaît dès que l'événement est rouvert. +• Les flèches du widget mois ne meurent plus après quelques appuis : une mise à jour trop lourde emportait l'hôte de widgets du lanceur. +• « Aujourd'hui » en semaines continues arrive pile sur la semaine en cours ; les vues jour et semaine ont de nouveau une marge à droite. + +Modifié +• Un événement refusé est barré partout et ne vous rappelle plus rien. diff --git a/fastlane/metadata/android/it-IT/changelogs/21902.txt b/fastlane/metadata/android/it-IT/changelogs/21902.txt new file mode 100644 index 0000000..ba49a8d --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/21902.txt @@ -0,0 +1,8 @@ +Corretto +• Toccare un evento nella vista mese apre l'evento, non il suo giorno. +• Una modifica si vede subito riaprendo l'evento. +• Le frecce del widget mese non muoiono più dopo pochi tocchi: un aggiornamento troppo grande abbatteva l'host dei widget del launcher. +• «Oggi» nelle settimane continue arriva esattamente sulla settimana corrente, e le viste giorno e settimana hanno di nuovo un margine a destra. + +Modifiche +• Un evento rifiutato è barrato ovunque e non invia più promemoria. diff --git a/fastlane/metadata/android/pl-PL/changelogs/21902.txt b/fastlane/metadata/android/pl-PL/changelogs/21902.txt new file mode 100644 index 0000000..ccd69ec --- /dev/null +++ b/fastlane/metadata/android/pl-PL/changelogs/21902.txt @@ -0,0 +1,8 @@ +Poprawki +• Dotknięcie wydarzenia w widoku miesiąca otwiera wydarzenie, a nie jego dzień. +• Zmiana jest widoczna od razu po ponownym otwarciu wydarzenia. +• Strzałki widżetu miesiąca nie zamierają już po kilku dotknięciach – zbyt duża aktualizacja kładła host widżetów launchera. +• „Dziś” w ciągłych tygodniach trafia dokładnie w bieżący tydzień, a widoki dnia i tygodnia znów mają margines z prawej. + +Zmiany +• Odrzucone wydarzenie jest wszędzie przekreślone i nie przypomina o sobie. diff --git a/fastlane/metadata/android/pt-BR/changelogs/21902.txt b/fastlane/metadata/android/pt-BR/changelogs/21902.txt new file mode 100644 index 0000000..9fbb84e --- /dev/null +++ b/fastlane/metadata/android/pt-BR/changelogs/21902.txt @@ -0,0 +1,8 @@ +Corrigido +• Tocar em um evento na visualização de mês abre o evento, e não o seu dia. +• Uma alteração aparece assim que o evento é reaberto. +• As setas do widget de mês não morrem mais depois de alguns toques: uma atualização grande demais derrubava o host de widgets do launcher. +• "Hoje" nas semanas contínuas chega exatamente na semana atual, e as visualizações de dia e semana voltaram a ter margem à direita. + +Alterações +• Um evento recusado fica riscado em todo lugar e não lembra mais você. diff --git a/fastlane/metadata/android/pt-PT/changelogs/21902.txt b/fastlane/metadata/android/pt-PT/changelogs/21902.txt new file mode 100644 index 0000000..819b5ec --- /dev/null +++ b/fastlane/metadata/android/pt-PT/changelogs/21902.txt @@ -0,0 +1,8 @@ +Corrigido +• Tocar num evento na vista de mês abre o evento e não o seu dia. +• Uma alteração aparece assim que o evento é reaberto. +• As setas do widget de mês já não morrem ao fim de alguns toques: uma atualização demasiado grande derrubava o anfitrião de widgets do launcher. +• «Hoje» nas semanas contínuas chega mesmo à semana atual, e as vistas de dia e semana voltam a ter margem à direita. + +Alterações +• Um evento recusado fica riscado em todo o lado e deixa de lembrar. diff --git a/fastlane/metadata/android/ru-RU/changelogs/21902.txt b/fastlane/metadata/android/ru-RU/changelogs/21902.txt new file mode 100644 index 0000000..bb6a3fe --- /dev/null +++ b/fastlane/metadata/android/ru-RU/changelogs/21902.txt @@ -0,0 +1,8 @@ +Исправлено +• Нажатие на событие в виде месяца открывает событие, а не его день. +• Изменение видно сразу при повторном открытии события. +• Стрелки виджета месяца больше не отмирают после нескольких нажатий: слишком большое обновление роняло хост виджетов лаунчера. +• «Сегодня» в непрерывных неделях попадает точно на текущую неделю, а у видов дня и недели снова есть отступ справа. + +Изменения +• Отклонённое событие везде зачёркнуто и больше не напоминает о себе. diff --git a/fastlane/metadata/android/zh-CN/changelogs/21902.txt b/fastlane/metadata/android/zh-CN/changelogs/21902.txt new file mode 100644 index 0000000..11080bf --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/21902.txt @@ -0,0 +1,8 @@ +修复 +• 在月视图中点按事件现在会打开该事件本身,而不是它所在的那一天。 +• 编辑后再次打开事件,改动会立即显示。 +• 月视图小部件的箭头不再点几下就失灵:过大的更新会拖垮启动器的小部件宿主。 +• 连续周视图中的“今天”会精确定位到本周,日视图和周视图右侧也重新留出了间距。 + +变更 +• 已拒绝的事件在各处均以删除线标记,并且不再提醒。 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ed0e351..4b1388c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,20 +1,20 @@ [versions] agp = "9.2.1" kotlin = "2.3.21" -ksp = "2.3.10" +ksp = "2.3.11" hilt = "2.60.1" coreKtx = "1.19.0" appcompat = "1.7.1" -lifecycleRuntime = "2.10.0" +lifecycleRuntime = "2.11.0" activityCompose = "1.13.0" composeBom = "2026.06.01" # Material 3 Expressive APIs currently live only in the 1.5 alpha line. # Pin explicitly to override the BOM (which ships stable 1.4.0). # Re-evaluate when 1.5.0 stable lands. -material3 = "1.5.0-alpha25" +material3 = "1.5.0-alpha26" datastore = "1.2.1" -junit = "6.1.2" -junitPlatform = "6.1.2" +junit = "6.1.3" +junitPlatform = "6.1.3" truth = "1.4.5" androidxJunit = "1.3.0" espressoCore = "3.7.0" @@ -26,7 +26,7 @@ lifecycleCompose = "2.10.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" -work = "2.10.1" +work = "2.11.2" documentfile = "1.1.0" [libraries]