Compare commits

..

81 Commits

Author SHA1 Message Date
9bf0788558 Merge remote-tracking branch 'origin/main' into release/v2.19.4 2026-08-31 21:06:15 +02:00
Jean-Luc Makiola
51a1d1e8ef Translations update from Weblate (#255)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 5s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/255
2026-08-31 21:05:58 +02:00
631c9da0a4 Translated using Weblate (Belarusian)
Currently translated at 70.2% (403 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/be/
2026-08-31 19:05:07 +00:00
f807651e59 Merge remote-tracking branch 'origin/main' into release/v2.19.4 2026-08-31 19:52:40 +02:00
Jean-Luc Makiola
5fa214b9ba Translations update from Weblate (#252)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 5s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/252
2026-08-31 19:52:03 +02:00
452a0a3d72 Merge main into release/v2.19.4
Brings #248 (EXDATE moves with the series when its times change) onto the
release branch, alongside the translations and dependency updates that landed on
main.

#245 and #248 both grew EventWriteMapper's EXDATE handling in the same place,
which conflicted. They are independent — #245 added exdateContains for the
detach path, #248 added shiftedExdate/exdateAfter for re-anchoring — so both
sides are kept, and their tests with them.
2026-08-31 19:45:48 +02:00
Jean-Luc Makiola
91431845aa Import a Fossify .ics without losing half of it (#225) (#254)
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/254
2026-08-31 19:34:23 +02:00
06909487e3 fix(ics): export a series' deleted occurrences (#225)
Import learned to read EXDATE, but the export side never wrote it: an in-app
backup and restore brought back every occurrence the user had deleted. EXDATE
lives on the master row, so the export query's ORIGINAL_ID IS NULL filter never
hid it — it just wasn't in the projection.

An all-day series' exclusions are written VALUE=DATE, since 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 the time part is dropped on the way out.
2026-08-31 19:24:20 +02:00
0b7f960511 fix(ics): keep a task's span, drop after-start timed alarms (#225)
DUE was read only when a VTODO had no DTSTART, and never as the end. A task
carrying both imported as a zero-length event instead of the span between them.
It is now resolved after the property sweep, so a DUE ahead of the DTSTART isn't
mistaken for the start either.

A timed VALARM firing after the start is a follow-up alarm and models no lead
time — clamping it to zero invented a reminder at the start that the file never
asked for. Dropped again, as the insert path did before. The clamp stays for
all-day, where it's how the Fossify family writes "on the day at 09:00".
2026-08-31 19:24:20 +02:00
89262a0be6 fix(ics): reject an RRULE carrying both UNTIL and COUNT (#225)
EventRecurrence.parse ends with two global checks, not one: a missing FREQ
throws, and so does UNTIL together with COUNT. Only the first was mirrored, so a
rule with both still threw out of insert — the failure sanitizeRrule exists to
prevent. UNTIL is kept; it bounds the series at a date a stale COUNT can't
outrun.

Also mark a part that carries text but no '=' as a repair. It took the branch
meant for a trailing ';', so the rule was altered while the user was told
nothing had changed.
2026-08-31 19:24:20 +02:00
7e7356e4c3 fix(ics): harden the import against foreign files (#225)
Review pass over the Fossify import work:

- sanitizeRrule whitelists the parts EventRecurrence actually parses, and
  reports whether it dropped anything — normalising case or a stray ";" no
  longer warns the user about a faulty repeat rule.
- CATEGORIES only stands in for a calendar name when it is single-valued and
  every event agrees; elsewhere it is a tag list and must not pick the target.
- An all-day EXDATE keeps the day it spells out instead of the day its instant
  lands on in UTC.
- An imported all-day reminder is sampled where it will fire, not at a
  recurrence anchor that Fossify dates to 1970.
- Imported colours match against every published key, not the picker's
  curated subset.
2026-08-31 19:24:20 +02:00
a2837f93ad chore: make the holidays fixture CRLF, like a real .ics 2026-08-31 19:24:20 +02:00
44d35a35df fix(ics): round all-day reminders up and stop dropping them on export (#225)
Review follow-ups on the import work:

- All-day reminder offsets are `days * 1440 - timeOfDay`, so rounding to the
  nearest day lost a day for any producer whose all-day notifications fire after
  noon ("1 day before at 18:00" arrives as -PT6H). Round up instead.
- Export decodes an all-day row's raw provider offset back to its whole-day lead
  time. It is normally negative, and the writer discards a trigger that fires
  after the event, so our own backups came back with no all-day reminder at all.
- A floating EXDATE DATE-TIME is read in the series' zone, not the device's;
  otherwise the exclusion lands on an instant no occurrence has.
- Wire the parsed calendar name up: it had no reader, so the CATEGORIES handling
  was inert. It now preselects a target calendar of that name.
2026-08-31 19:24:20 +02:00
4d9f35b849 fix(import): don't lose Fossify's contact birthdays and anniversaries (#225)
Found the actual cause. 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. We read that literally: a yearly series with
DURATION:P0D, which the provider expands into no instances at all. The
import reported success and the events were nowhere — matching the
report exactly ("birthdays, memorials, dates").

An all-day event may no longer end at or before it starts, and an all-day
DURATION is floored at P1D. Verified against the released parser, which
returns days=0.0 for all three fixture events.

Do not generalise this into sniffing PRODID: the same exporter is correct
for UI-created events (endTS anchors at noon of the last day) and for
CalDAV rows, and Fossify's bundled holiday files are conformant while
naming Fossify in their PRODID. Both are kept as fixtures.

Also: existingUids counted DELETED rows, so re-importing after deleting
events skipped everything as duplicates.

Closes #225
2026-08-31 19:24:20 +02:00
337785a426 chore: keep .ics test fixtures byte-exact (CRLF) 2026-08-31 19:24:20 +02:00
de009a6739 fix(import): make .ics import survive events the provider rejects (#225)
importEvents inserted every event in one unguarded loop, so a single row
the provider refused — it throws on a malformed RRULE straight out of
insert — aborted the batch and left the user with "couldn't read this
file" and nothing imported. Each event is now isolated and rejects are
counted into IcsImportSummary.failed and shown. sanitizeRrule drops empty
and malformed rule parts before the write.

Alongside that, several things a foreign file carries that we dropped:
VTODO components (silently lost, now imported as events), EXDATE, the
CATEGORIES calendar name, and colours — X-FOSSIFY-EVENT-COLOR / COLOR /
the category colour plus the X-SMT-* legacy spellings, snapped in Oklab
to the nearest key a palette account publishes since those reject a raw
EVENT_COLOR.

Two correctness fixes on our side: an all-day event may no longer end at
or before it starts (the provider expands a zero-length series into no
instances, so it just disappears), and imported all-day reminders now go
through the same encoding as hand-created ones instead of firing at UTC
midnight. A VALARM trigger pointing after the start is read as a time of
day rather than clamped to zero.

Note for later: their all-day DTEND is RFC-correct — endTS anchors at
noon of the last day and the exporter's +12h rounds it to the following
midnight. Do not "fix" it by sniffing PRODID; the holiday files bundled
in Fossify are conformant and name Fossify in theirs. One is kept as a
fixture.

Refs #225
2026-08-31 19:24:20 +02:00
Jean-Luc Makiola
eb406c3132 Move EXDATE with the series when its times change (#248) (#249)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/249
2026-08-31 18:17:43 +02:00
Dmitri Gabinski
3a90e94958 Translated using Weblate (Belarusian)
Currently translated at 70.2% (403 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/be/
2026-08-31 14:59:59 +00:00
Jean-Luc Makiola
248842871b fix(deps): update work to v2.11.2 (#204)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/204
2026-08-31 09:12:47 +02:00
Jean-Luc Makiola
1cab023abc Translations update from Weblate (#250)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 8s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/250
2026-08-31 09:12:01 +02:00
9ca29bb797 fix(deps): update work to v2.11.2 2026-08-31 05:02:32 +00:00
JeanWenzel
13640751d2 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (574 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/zh_Hans/
2026-08-29 03:03:54 +00:00
JeanWenzel
0c7d1a97e2 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (574 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/zh_Hans/
2026-08-28 16:11:05 +00:00
Weblate
d0202783d4 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (574 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/zh_Hans/
2026-08-28 16:11:05 +00:00
JeanWenzel
f72f810e4f Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (574 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/zh_Hans/
2026-08-28 16:11:05 +00:00
Weblate
13f4c86113 Update translation files
Updated by "Remove blank strings" add-on in Weblate.

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/
2026-08-28 02:17:51 +00:00
James Trivette
65d9f95717 Translated using Weblate (Czech)
Currently translated at 47.7% (274 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/cs/
2026-08-28 02:17:51 +00:00
JeanWenzel
19fa8203f1 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 24.5% (141 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/zh_Hans/
2026-08-28 02:17:51 +00:00
Jean-Luc Makiola
cf72dce0e7 fix: editing a single occurrence does nothing on unsynced calendars (#234) (#245)
## What was wrong

"Edit only this event" wrote a modified-occurrence exception unconditionally. That is the right shape for a synced series and the wrong one for everything else: an exception attaches to its parent through `ORIGINAL_SYNC_ID`, so on a series row with no `_sync_id` the link never forms — the insert fails or lands an orphan, the generic catch in `EventEditViewModel.performSave` turns it into a snackbar, and the scope dialog just closes again. To the reporter that read as "nothing happens, ever", with a stray copy of the event the one time the insert did land.

This is the same constraint `deleteOccurrence` has documented since #47, and the same calendars: a local calendar, Calendula's own contact special-date calendars, and — the reporter's case — a Google calendar whose rows the sync adapter has not stamped yet, which is exactly why it "shows as on-device". `deleteOccurrence` got the `_sync_id` guard in 4fea176; `updateOccurrence` never did.

## What changed

`updateOccurrence` now branches on `_sync_id` the way `deleteOccurrence` does.

- **Synced series**: the exception path, untouched. Its write shape is load-bearing and on-device verified (#16, #47).
- **No `_sync_id`**: the occurrence is excluded from the parent via EXDATE and the edited values are inserted as a standalone event on the same calendar — a detached instance, minus the `RECURRENCE-ID` the provider has no way to store here.

Two things shape the write. The parent update reuses `buildOccurrenceExdateValues` unchanged, so it keeps carrying the whole time/recurrence set — an EXDATE-only update is not a recurrence change to the provider and leaves the expanded instances standing (#47's first quirk). And the form's RRULE is stripped before the insert: the exception path gets an inherited rule cleared for free by DTSTART + DURATION, but nothing clears one here, so leaving it would insert a second *series* overlapping the first.

Ordering is chosen for the failure cases. The insert runs first, so a failure there leaves the series completely untouched — the discipline `updateEventFromOccurrence` already follows. If the EXDATE update then fails, the new row is a visible duplicate of an occurrence still in the series, so it is rolled back (best effort) before the failure surfaces. The reverse order could strand an occurrence excluded from its series with nothing standing in for it, turning an edit into a silent delete.

A second detach of the same occurrence is refused rather than silently making a second copy (reachable from a stale detail screen: the EXDATE merge folds the repeat away and the update still reports a changed row). `NoSuchEventException` from a write now maps to the same "no longer exists" state the pre-check already gives.

Smaller, in the same area: a failed save was invisible precisely because this bug was — the failure snackbar gets the long duration instead of a flash, and the catch logs the scope and event id (never the form's content) so a failure leaves something to report.

## What this costs

The detached row has no stored link back to its series — that is the whole reason the path exists — and the KDoc now says so plainly. A whole-series delete leaves it standing where an exception row would have gone with the parent; a calendar move leaves it behind; a series-wide *time* edit moves the generated instances but not the absolute-instant EXDATE hole, so the occurrence returns alongside the copy; and building the row from the form rather than cloning the parent drops `ORGANIZER`, `STATUS` and the organizer/resource attendee rows, exactly as `moveEvent` does.

The EXDATE staleness is not new — a #47 delete resurrects the same way after a series time edit — but a duplicate is a louder symptom than a resurrection. It wants fixing at the series-update end (re-stamping EXDATE alongside the DTSTART shift in `buildEventUpdateValues`, and carrying surviving stamps into the split series in `updateEventFromOccurrence`), which is a change to the "all events" path for *every* calendar type and does not belong in a targeted fix. Worth its own issue.

## How it was verified

- `./gradlew :app:testDebugUnitTest` — BUILD SUCCESSFUL, 62 suites, 0 failures.
- `./gradlew :app:lintDebug` — BUILD SUCCESSFUL, no new findings.
- `./gradlew :app:assembleDebug` — BUILD SUCCESSFUL.
- Independent adversarial code review, whose findings drove the second commit (the double-detach guard, the corrected rollback claim, and the cost documentation above). It confirmed no interleaving loses an occurrence, and cleared the drag-to-reschedule path: `RescheduleViewModel.undoFor` returns null for a recurring single-occurrence move, so the changed return value (a new event id rather than an exception id) never reaches the undo machinery.

New JVM tests cover the pure halves: the rule is dropped and the row becomes a one-off with DTEND, every edited field survives onto the inserted columns, all-day stays on UTC midnights, the detached row's DTSTART agrees with the EXDATE stamp that removes it from the parent (timed and all-day), and `exdateContains` recognises an already-excluded occurrence without matching a neighbouring one.

## What still needs a device

The provider behaviour itself cannot be confirmed on the JVM — `AndroidCalendarDataSource` has no fake-resolver harness, so `detachOccurrence`'s branch, its insert-then-EXDATE ordering and its rollback have no unit coverage. On a device, on a **local or unsynced** calendar:

1. The reported case end to end: recurring series, edit one occurrence's title, "Only this event" — the edit sticks, that occurrence alone changes, and the rest of the series survives (the #47 collapse must not reappear).
2. The same for an **all-day** yearly series (a contact birthday calendar is the natural subject) — the date-only EXDATE form excludes the right day, not the one before it.
3. A series pinned to a **non-device timezone**, and an occurrence across a **DST boundary** — the hole and the detached row must land on the same instant.
4. Editing the occurrence's **time**, not just its title, and editing the **first** occurrence of a series (DTSTART then points at an excluded instant — expected to be fine, same property the #47 delete path already has, but untested).
5. Reminders and guests on the detached row, and a colour from an account palette.
6. Regression on a **DAVx5 / Google synced** calendar: "Only this event" must still go down the exception path and behave exactly as before.
7. Drag-to-reschedule a single occurrence on an unsynced series — same path, different caller.

Closes #234

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/245
2026-08-27 20:43:42 +02:00
Jean-Luc Makiola
398cfc8906 Roll the home-screen widgets over at midnight (#228) (#246)
The month and agenda widgets did not roll over at midnight. They kept highlighting yesterday as "today", and the agenda kept dimming events against yesterday, until the user paged the month arrows or removed and re-added the widget.

**What was wrong**

The manifest asked for `DATE_CHANGED` and `WidgetUpdateReceiver`'s docs presented it as the rollover mechanism, but `DATE_CHANGED` is not on the implicit-broadcast exemption list, so a manifest-declared receiver has not been given it since Android 8. That left only `updatePeriodMillis`, which the system defers in doze and OEM skins throttle harder still. The data layer was fine all along - the month cache guard already drops its window when the anchor date changes, which is why an arrow tap fixed it instantly.

**What changed**

- `WidgetRolloverScheduler` arms a single alarm for just after the next local midnight and re-arms on every firing, the same shape as `ReminderAlarmScheduler`. Inexact (`setAndAllowWhileIdle`): no permission, survives doze, and exact alarms stay reserved for reminder snooze. It targets the actual start of day, not a literal 00:00, so it holds where DST means midnight never happens.
- Armed only while a widget is placed, via `onEnabled`/`onDisabled` on both Glance receivers; `sync()` cancels only when neither kind is left. Re-armed from boot, package-replace, time and timezone changes, app start (which is what arms existing installs upgrading into this), and from `onUpdate`, so an alarm dropped by a force-stop or an OEM freeze heals itself.
- Paging the month widget forward and back no longer pins it to that month. `ShiftMonthAction` stored an absolute index on every tap, so the workaround people used to force a redraw quietly stranded the widget on whatever month was current at the time.
- `DATE_CHANGED` stays in the filter as a free extra, but nothing depends on it and the docs no longer claim otherwise.
- Keep rule extended to `GlanceAppWidgetReceiver`, since the two receivers are now as structurally alike as the widgets that #89 collapsed.

**Verification**

`testDebugUnitTest` (775 tests, 0 failures), `lintDebug` and `assembleDebug` all pass. `assembleReleaseTest` builds and the R8 mapping confirms `MonthWidget`, `AgendaWidget` and both receivers keep their real names. 12 new unit tests cover the next-midnight arithmetic: ordinary days, the re-arming instant itself, both DST directions on frozen historical transitions, a zone whose midnight does not exist, a half-hour offset, two zones seeing the same instant, and that the receiver's action guard admits the alarm's action.

Not verified on a device - the overnight rollover on an OxygenOS-class device is the one thing that needs a real test before release.

Closes #228.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/246
2026-08-27 20:39:18 +02:00
Jean-Luc Makiola
0f1df4800d Translations update from Weblate (#247)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 8s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
Renovate / renovate (push) Successful in 1m54s
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/247
2026-08-27 20:38:38 +02:00
Weblate
7351b2d35c Translated using Weblate (Polish)
Currently translated at 100.0% (574 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/pl/
2026-08-27 18:24:01 +00:00
Bazyli Cyran
1a66a31563 Translated using Weblate (Polish)
Currently translated at 100.0% (574 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/pl/
2026-08-27 18:24:01 +00:00
Weblate
fe3c659804 Translated using Weblate (Polish)
Currently translated at 100.0% (574 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/pl/
2026-08-27 18:24:01 +00:00
Jean-Luc Makiola
a2172dce12 Google Play is live: README + production track (#244)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 8s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
Calendula is published on Google Play, so the pipeline and the docs stop treating it as pending.

**README**
- Google Play badge next to F-Droid/Obtainium, linking the store page.
- Install table: the Play row goes from "Coming soon / —" to a real channel.
- The "coming soon" section becomes a live one with the store link; the closed-testing "testers wanted" call drops out. The note that Play signs with Google's key (so switching channels needs an uninstall) stays, and the trailing shared-signing-key line now flags Play as the exception.

**Play track**
- Release uploads default to `production` instead of `internal` — in the lane (`fastlane/Fastfile`) and in the workflow default (`.gitea/workflows/release.yaml`). Merging a bumped versionName to main is already the human gate, so the manual Console promotion only added delay.
- `docs/RELEASING.md` updated to match, including the escape hatches (`PLAY_TRACK=internal` to stage, `PLAY_RELEASE_STATUS=draft` to hold).

Note: if a `PLAY_TRACK` repo variable is set on Gitea it still wins over the new default — needs checking there.

No issue for this one.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/244
2026-08-27 19:09:04 +02:00
Jean-Luc Makiola
4099f66e91 Translations update from Weblate (#232)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/232
2026-08-27 19:08:28 +02:00
49112b11b3 fix(deps): update test dependencies to v6.1.3 (#202)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/202
2026-08-27 19:07:52 +02:00
1218bb523c fix(deps): update lifecycleruntime to v2.11.0 (#203)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 5s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/203
2026-08-27 19:07:29 +02:00
17600a3905 fix(deps): update material3 (alpha) to v1.5.0-alpha26 (#235)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/235
2026-08-27 19:07:02 +02:00
Julie
db5104a7d0 Translated using Weblate (Slovak)
Currently translated at 32.0% (184 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/sk/
2026-08-27 03:40:53 +00:00
Julie
6306064fc3 Translated using Weblate (Slovak)
Currently translated at 25.0% (144 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/sk/
2026-08-26 23:38:37 +00:00
Julie
d5a4761f2c Translated using Weblate (Slovak)
Currently translated at 13.5% (78 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/sk/
2026-08-26 21:23:50 +00:00
Dmitri Gabinski
66d6681798 Translated using Weblate (Belarusian)
Currently translated at 24.9% (143 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/be/
2026-08-26 21:23:49 +00:00
Weblate
fb61169656 Update translation files
Updated by "Remove blank strings" add-on in Weblate.

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/
2026-08-26 13:57:44 +00:00
Julie
62079fba0b Added translation using Weblate (Slovak) 2026-08-26 13:57:44 +00:00
Dmitri Gabinski
cc0fc8edc3 Translated using Weblate (Belarusian)
Currently translated at 23.8% (137 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/be/
2026-08-26 13:57:43 +00:00
Sup99999
62174fcd53 Translated using Weblate (Arabic)
Currently translated at 97.7% (561 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ar/
2026-08-25 16:03:15 +00:00
Dmitri Gabinski
55c1b2ac25 Translated using Weblate (Belarusian)
Currently translated at 4.5% (26 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/be/
2026-08-25 02:58:46 +00:00
Sup99999
0ccc610144 Translated using Weblate (Arabic)
Currently translated at 95.9% (551 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ar/
2026-08-25 02:58:45 +00:00
Weblate
5d009b7dc0 Update translation files
Updated by "Remove blank strings" add-on in Weblate.

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/
2026-08-24 06:55:51 +00:00
Dmitri Gabinski
28924daf02 Added translation using Weblate (Belarusian) 2026-08-24 06:55:51 +00:00
Sup99999
e71fb2a625 Translated using Weblate (Arabic)
Currently translated at 92.6% (532 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ar/
2026-08-22 14:29:52 +00:00
SpiritOTHawk
aa5f76fc1b Translated using Weblate (Russian)
Currently translated at 28.2% (162 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ru/
2026-08-22 14:29:52 +00:00
Jean-Luc Makiola
5b099714f6 2.19.3 — Czech, Japanese and Russian in the language picker (#231)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 17m33s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 1m2s
Renovate / renovate (push) Successful in 2m24s
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/231
2026-08-22 11:18:37 +02:00
163d821d1f 2.19.3 — Czech, Japanese and Russian in the language picker
Surface the community translations that landed via Weblate but were never
listed in locales_config.xml. European Portuguese stays out: it has one
translated string.
2026-08-22 11:10:06 +02:00
Jean-Luc Makiola
fe98b1ed66 Translations update from Weblate (#223)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/223
2026-08-22 11:03:17 +02:00
James Trivette
c0270ab3a0 Translated using Weblate (Czech)
Currently translated at 36.0% (207 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/cs/
2026-08-21 13:13:33 +00:00
James Trivette
6019d6c0b8 Translated using Weblate (Czech)
Currently translated at 33.7% (194 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/cs/
2026-08-21 05:51:15 +00:00
James Trivette
19bc5967a4 Translated using Weblate (Czech)
Currently translated at 33.6% (193 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/cs/
2026-08-21 05:51:05 +00:00
ame0011
ddab3454bb Translated using Weblate (Japanese)
Currently translated at 13.9% (80 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ja/
2026-08-20 18:48:25 +00:00
ame0011
247c9ee6cd Translated using Weblate (Japanese)
Currently translated at 11.1% (64 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ja/
2026-08-20 16:29:43 +00:00
Bernardo Krzysczak
6be170cae2 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (574 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/pt_BR/
2026-08-20 16:29:42 +00:00
James Trivette
8133584f1e Translated using Weblate (Czech)
Currently translated at 16.3% (94 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/cs/
2026-08-20 09:52:46 +00:00
Weblate
4cdb42afba Update translation files
Updated by "Remove blank strings" add-on in Weblate.

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/
2026-08-20 05:43:16 +00:00
James Trivette
19a4c05e93 Added translation using Weblate (Czech) 2026-08-20 05:43:16 +00:00
ame0011
aba06affbf Translated using Weblate (Japanese)
Currently translated at 9.4% (54 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ja/
2026-08-20 05:43:16 +00:00
ame0011
2b132a5b7a Translated using Weblate (Japanese)
Currently translated at 4.3% (25 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ja/
2026-08-19 16:12:28 +00:00
Weblate
52ecce03b8 Update translation files
Updated by "Remove blank strings" add-on in Weblate.

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/
2026-08-19 09:41:07 +00:00
ame0011
0eb76db12d Added translation using Weblate (Japanese) 2026-08-19 09:41:07 +00:00
Jean-Luc Makiola
d03985eada Release 2.19.2 (#216)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 16m50s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 1m34s
Release 2.19.2 — `versionName` 2.19.2 / `versionCode` 21902. Merging this cuts the release.

A meeting you declined is struck through everywhere it appears and no longer schedules a reminder (#180). Tapping an event in month view opens the event instead of its day, in every month style (#187). An edit shows immediately when the same occurrence is re-opened (#196).

The month widget's arrows died after a couple of taps: the grid serialised ~740 views and one update ran to 450–540 KB, which overran the launcher's async binder buffer and made Android drop the whole widget host — killing updates for every widget on the home screen. The grid is down to 192 views, and `SizeMode.Exact` is kept so a resized widget reflows instead of clipping (#214). Widget taps now redraw the tapped widget by its own id rather than `updateAll`, whose provider lookup is empty in a cold-woken process (#18).

Jump-to-today in seamless weeks lands on the current week row (#191), and the day and week timelines got an end inset (#192).

Two follow-ups beyond the issues: the detail re-read keeps the loaded content on screen instead of blinking back to the skeleton, and a month chip tap with no recorded touch down (TalkBack, D-pad) no longer resolves to the previous tap's chip.

Release notes: `CHANGELOG.md` 2.19.2 section and `fastlane/metadata/android/en-US/changelogs/21902.txt` (471 chars).

Closes #18
Closes #180
Closes #187
Closes #191
Closes #192
Closes #196
Closes #214

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/216
2026-08-17 21:54:46 +02:00
Jean-Luc Makiola
34108b9c50 Translations update from Weblate (#179)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/179
2026-08-17 16:54:37 +02:00
49a0c114c2 chore(deps): update ksp monorepo to v2.3.11 (#201)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/201
2026-08-17 16:53:19 +02:00
Weblate
b1ce6f85cc Update translation files
Updated by "Remove blank strings" add-on in Weblate.

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/
2026-08-16 10:49:54 +00:00
Sup99999
ca5f87af11 Translated using Weblate (Arabic)
Currently translated at 90.0% (517 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ar/
2026-08-16 10:49:53 +00:00
chip
7445092767 Translated using Weblate (Russian)
Currently translated at 22.6% (130 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ru/
2026-08-15 10:22:21 +00:00
Sup99999
5ef66120bf Translated using Weblate (Arabic)
Currently translated at 86.5% (497 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ar/
2026-08-13 20:22:23 +00:00
chip
bda86cd73d Translated using Weblate (Russian)
Currently translated at 11.6% (67 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ru/
2026-08-13 20:22:23 +00:00
Weblate
56953b0c50 Translated using Weblate (Italian)
Currently translated at 100.0% (574 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/it/
2026-08-13 20:22:23 +00:00
Sam Gamgee
7c8cd1e145 Translated using Weblate (Italian)
Currently translated at 100.0% (574 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/it/
2026-08-13 20:22:22 +00:00
Sup99999
bce36956d1 Translated using Weblate (Arabic)
Currently translated at 86.1% (493 of 572 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ar/
2026-08-12 06:31:32 +00:00
Jean-Luc Makiola
e0fdadc7ca 2.19.1 — drag to reschedule can be turned off (#176)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 16m19s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 2m29s
Renovate / renovate (push) Successful in 3m1s
Release branch for 2.19.1. Merging this cuts the release — versionName is bumped to 2.19.1 (versionCode 21901), which is what release.yaml picks up.

### What's in it

- **Drag to reschedule can be turned off** (#173). Settings → Views → *Drag to reschedule*, on by default. Turned off, no event block in the month, week or day view registers a drag gesture. The gate is `EventMoveScope.dragEnabled` rather than the scope itself, so the screen-reader "Move event" action — which only opens the edit form — stays available either way. The setting reads as off until the stored value has actually loaded, so a cold start can't hand out a drag before the preference is known.

### Release prep

- CHANGELOG 2.19.1 section written.
- Per-version "What's New" hand-written for all 12 store locales (`ar`, `de-DE`, `en-GB`, `en-US`, `es-ES`, `fr-FR`, `it-IT`, `pl-PL`, `pt-BR`, `pt-PT`, `ru-RU`, `zh-CN`).
- `lint test assembleDebug` and `check_translations.py` green locally.
- main is one commit ahead (Weblate #175, translations only) and merges clean — not merged into the branch, it comes along with this one.

Closes #173

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/176
2026-08-11 18:20:09 +02:00
94 changed files with 7165 additions and 434 deletions

1
.gitattributes vendored
View File

@@ -5,3 +5,4 @@
*.jpg binary
*.gif binary
*.webp binary
app/src/test/resources/ics/*.ics -text

View File

@@ -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.

View File

@@ -7,6 +7,115 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### 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]).
### 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]).
## [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
@@ -23,6 +132,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
[bkrz](https://weblate.dev.jeanlucmakiola.de/user/bkrz/) for getting it
started; help finishing it is very welcome.
### Fixed
- **The language picker forgot which language was picked.** Android hands the
applied language back with its script filled in — Simplified Chinese goes in
as `zh-CN` and comes out as `zh-Hans-CN` — so after a restart no entry in
Settings → Language showed as selected, and the row above it named the
language the long way round. Only languages that imply a script were
affected.
## [2.19.0] — 2026-08-10
### Added
@@ -1406,3 +1523,13 @@ automatically, with zero telemetry and no internet permission.
[#123]: https://codeberg.org/jlmakiola/calendula/issues/123
[#163]: https://codeberg.org/jlmakiola/calendula/issues/163
[#173]: https://codeberg.org/jlmakiola/calendula/issues/173
[#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

View File

@@ -18,6 +18,8 @@ Reads, writes, and reminds — on top of the system calendar, with zero network
<p>
<a href="https://f-droid.org/packages/de.jeanlucmakiola.calendula/"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="56"></a>
&nbsp;
<a href="https://play.google.com/store/apps/details?id=de.jeanlucmakiola.calendula"><img src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png" alt="Get it on Google Play" height="56"></a>
&nbsp;
<a href="https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/calendula"><img src="https://github.com/ImranR98/Obtainium/blob/main/assets/graphics/badge_obtainium.png?raw=true" alt="Get it on Obtainium" height="56"></a>
&nbsp;
<a href="https://ko-fi.com/jeanlucmakiola"><img src="https://storage.ko-fi.com/cdn/brandasset/v2/support_me_on_kofi_badge_beige.png" alt="Support me on Ko-fi" height="56"></a>
@@ -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
<sub>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.</sub>
reinstalling. Google Play is the exception — see above.</sub>
## 📚 Documentation

View File

@@ -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 = 21903
versionName = "2.19.3"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

View File

@@ -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

View File

@@ -330,9 +330,13 @@
</receiver>
<!-- Keeps both widgets fresh: the calendar provider broadcasts
PROVIDER_CHANGED on any data change (our writes and external sync),
and the system broadcasts the date/time ones at midnight / clock
changes so "today" highlighting rolls over. -->
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. -->
<receiver
android:name=".widget.WidgetUpdateReceiver"
android:exported="true">
@@ -346,6 +350,8 @@
<action android:name="android.intent.action.DATE_CHANGED" />
<action android:name="android.intent.action.TIME_SET" />
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>

View File

@@ -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)
}
}
/**

View File

@@ -70,6 +70,34 @@ 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

View File

@@ -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<EventColorOption>
/**
* The same palette **uncurated** — every key the account publishes, in
* provider order. Curation is a display concession (it folds look-alikes and
* drops the neutrals outright from an oversized palette), so matching a
* colour that came from outside the account has to run against the full set:
* those keys are all the calendar accepts. See [eventColorPalette].
*/
fun publishedEventColors(calendarId: Long): List<EventColorOption>
/**
* Every master/one-off event of the writable local calendars, mapped for a
* 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<Long>? = null): List<IcsEvent>
fun exportableEvents(calendarIds: Set<Long>?, allDayReminderTimeMinutes: Int): List<IcsEvent>
/**
* The non-empty `Events.UID_2445` values present in [calendarId] — used to
@@ -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<EventColorOption>,
): Long
/**
* Create a new device-only (`ACCOUNT_TYPE_LOCAL`) calendar the app owns;
@@ -232,10 +253,15 @@ 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]).
* [allDayReminderTimeMinutes]: see [insertEvent].
*/
fun updateOccurrence(
eventId: Long,
@@ -721,7 +747,10 @@ class AndroidCalendarDataSource @Inject constructor(
}
}
override fun eventColorPalette(calendarId: Long): List<EventColorOption> {
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
publishedEventColors(calendarId).curatedForPicker()
override fun publishedEventColors(calendarId: Long): List<EventColorOption> {
val account = calendarAccount(calendarId) ?: return emptyList()
return resolver.query(
CalendarContract.Colors.CONTENT_URI,
@@ -739,11 +768,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<Long>?): List<IcsEvent> {
override fun exportableEvents(
calendarIds: Set<Long>?,
allDayReminderTimeMinutes: Int,
): List<IcsEvent> {
// Only the local calendars the app owns and can write — synced calendars
// already have a backup (their server). Exclude the managed special-dates
// mirror calendars: their events are derived from contacts, not authored
@@ -778,6 +809,7 @@ class AndroidCalendarDataSource @Inject constructor(
reader.toIcsEvent(
reminderMinutes = queryReminders(eventId).map { it.minutes },
calendarName = names[calendarId],
allDayReminderTimeMinutes = allDayReminderTimeMinutes,
)
}
} ?: emptyList()
@@ -786,55 +818,64 @@ class AndroidCalendarDataSource @Inject constructor(
override fun existingUids(calendarId: Long): Set<String> = 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<EventColorOption>,
): 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",
uid = event.uid?.takeIf { it.isNotBlank() } ?: "${UUID.randomUUID()}@calendula",
palette = colorPalette,
)
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)
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) {
@@ -844,18 +885,6 @@ class AndroidCalendarDataSource @Inject constructor(
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 +1005,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()) {
@@ -1194,6 +1225,13 @@ class AndroidCalendarDataSource @Inject constructor(
form: EventForm,
allDayReminderTimeMinutes: Int,
): Long {
val row = querySeriesRow(eventId)
// Stricter than deleteOccurrence's bare _sync_id check: EXDATE only means
// something on a row that recurs, so a non-recurring one keeps the
// exception path rather than getting a recurrence set written onto it.
if (row.syncId == null && !row.rrule.isNullOrBlank()) {
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 +1250,89 @@ 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, isAllDay = row.allDay != 0)) {
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,
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 +1349,66 @@ 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),
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

View File

@@ -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,43 @@ class CalendarRepositoryImpl @Inject constructor(
}
override suspend fun exportEvents(calendarIds: Set<Long>?) =
withContext(io) { dataSource.exportableEvents(calendarIds) }
withContext(io) { dataSource.exportableEvents(calendarIds, allDayReminderTimeMinutes()) }
override suspend fun importEvents(
targetCalendarId: Long,
events: List<ParsedIcsEvent>,
): 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
var failed = 0
for (event in events) {
// 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: 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)
}
override suspend fun createEvent(form: EventForm): Long = withContext(io) {
@@ -288,6 +307,10 @@ class CalendarRepositoryImpl @Inject constructor(
) = withContext(io) {
dataSource.deleteEventFromOccurrence(eventId, beginMillis)
}
private companion object {
const val TAG = "CalendarRepository"
}
}
private fun <T> Flow<Unit>.reQuery(block: suspend () -> T): Flow<T> = flow {

View File

@@ -3,13 +3,20 @@ 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.format.DateTimeFormatter
import java.time.format.ResolverStyle
import java.time.LocalDate as JavaLocalDate
import java.time.LocalDateTime as JavaLocalDateTime
/** Provider-ready DTSTART / DTEND / EVENT_TIMEZONE for an event write. */
@@ -134,18 +141,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<String, Any?> = buildMap {
if (updated.title.trim() != original.title.trim()) {
@@ -185,6 +195,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 +220,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 +265,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`
@@ -420,11 +457,7 @@ internal fun buildOccurrenceExdateValues(
allDay: Int,
): Map<String, Any?> {
val stamp = formatExdateStamp(occurrenceMillis, isAllDay = allDay != 0)
val existing = existingExdate?.split(',')
?.map { it.trim() }
?.filter { it.isNotEmpty() }
.orEmpty()
val merged = (existing + stamp).distinct().joinToString(",")
val merged = (exdateStamps(existingExdate) + stamp).distinct().joinToString(",")
return mapOf(
CalendarContract.Events.EXDATE to merged,
CalendarContract.Events.DTSTART to dtStartMillis,
@@ -435,22 +468,139 @@ internal fun buildOccurrenceExdateValues(
)
}
/**
* 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,
): Boolean {
val stamp = formatExdateStamp(occurrenceMillis, isAllDay)
return existingExdate?.split(',')?.any { it.trim() == stamp } == true
}
/**
* [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.
*
* Null when there is nothing to carry: no stamps, or a stamp in a form Calendula
* never writes (a `TZID=`-parameterised or floating one from a sync adapter). 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 wallClockShift = Duration.between(original.anchorLocal(), updated.anchorLocal())
return stamps
.map { stamp ->
val local = parseExdateStamp(stamp, original.isAllDay, fromZone) ?: 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.
*
* Null when nothing qualifies, or when a stamp can't be read (see [shiftedExdate]).
*/
internal fun exdateAfter(existingExdate: String?, beginMillis: Long, isAllDay: Boolean): String? {
val stamps = exdateStamps(existingExdate)
if (stamps.isEmpty()) return null
return stamps
.filter { stamp ->
val utc = parseExdateStamp(stamp, isAllDay, ZoneOffset.UTC) ?: return null
utc.toInstant(ZoneOffset.UTC).toEpochMilli() > beginMillis
}
.takeIf { it.isNotEmpty() }
?.joinToString(",")
}
/** The individual stamps of an EXDATE column value; it is a comma-separated list. */
private fun exdateStamps(exdate: String?): List<String> = exdate
?.split(',')
?.map { it.trim() }
?.filter { it.isNotEmpty() }
.orEmpty()
/**
* 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)
} else {
"%04d%02d%02dT%02d%02d%02dZ".format(
utc.year, utc.monthValue, utc.dayOfMonth,
utc.hour, utc.minute, utc.second,
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 {
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 but the two forms Calendula writes, so a
* caller can tell "not ours, leave it alone" from a value it may safely re-time.
*/
private fun parseExdateStamp(stamp: String, isAllDay: Boolean, zone: ZoneId): JavaLocalDateTime? =
runCatching {
if (isAllDay) {
JavaLocalDate.parse(stamp, ALL_DAY_EXDATE).atStartOfDay()
} else {
JavaLocalDateTime.parse(stamp, TIMED_EXDATE)
.atZone(ZoneOffset.UTC).withZoneSameInstant(zone).toLocalDateTime()
}
}.getOrNull()
/** `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 `EVENT_COLOR` / `EVENT_COLOR_KEY` columns for a colour selection. A
@@ -499,3 +649,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<EventColorOption>,
): Map<String, Any?> = buildMap {
val startMillis = event.start.toEpochMilliseconds()
val endMillis = event.end.toEpochMilliseconds()
put(CalendarContract.Events.CALENDAR_ID, calendarId)
put(CalendarContract.Events.UID_2445, uid)
put(CalendarContract.Events.TITLE, event.summary.trim())
put(CalendarContract.Events.ALL_DAY, if (event.isAllDay) 1 else 0)
put(CalendarContract.Events.DTSTART, startMillis)
if (event.recurrenceRule == null) {
put(CalendarContract.Events.DTEND, endMillis)
} else {
put(CalendarContract.Events.RRULE, event.recurrenceRule)
put(
CalendarContract.Events.DURATION,
importDuration(startMillis, endMillis, event.isAllDay),
)
event.exDates.takeIf { it.isNotEmpty() }
?.let { put(CalendarContract.Events.EXDATE, it.joinToString(",")) }
}
// All-day rows live at UTC midnights (the file already encodes them so);
// timed rows keep the event's own zone.
put(CalendarContract.Events.EVENT_TIMEZONE, if (event.isAllDay) "UTC" else event.zoneId)
put(CalendarContract.Events.AVAILABILITY, event.availability.toProviderValue())
put(CalendarContract.Events.STATUS, event.status.toProviderStatus())
event.location?.trim()?.takeIf { it.isNotEmpty() }
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
event.description?.trim()?.takeIf { it.isNotEmpty() }
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
event.color?.let { color ->
val key = palette.nearestTo(color)?.key
putAll(eventColorColumns(colorKey = key, color = if (key == null) color else null))
}
}
/**
* Provider `DURATION` for an imported recurring row: whole days for an all-day
* series, seconds otherwise. An all-day series is never shorter than a day —
* the provider expands a zero-length one into no instances at all, so the
* series would vanish (which is what a literal read of the Fossify family's
* inclusive DTEND used to produce; see "Importing foreign .ics" in
* docs/ARCHITECTURE.md).
*/
private fun importDuration(startMillis: Long, endMillis: Long, isAllDay: Boolean): String {
val span = (endMillis - startMillis).coerceAtLeast(0)
return if (isAllDay) "P${(span / 86_400_000L).coerceAtLeast(1)}D" else "P${span / 1_000L}S"
}
private fun EventStatus.toProviderStatus(): Int = when (this) {
EventStatus.Confirmed -> CalendarContract.Events.STATUS_CONFIRMED
EventStatus.Tentative -> CalendarContract.Events.STATUS_TENTATIVE
EventStatus.Cancelled -> CalendarContract.Events.STATUS_CANCELED
}

View File

@@ -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<Int>,
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,44 @@ 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)
},
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.
*/
internal fun exportExDates(exdate: String?, isAllDay: Boolean): List<String> = exdate
?.split(',')
?.map { it.trim() }
?.filter { it.isNotEmpty() }
?.map { if (isAllDay) it.substringBefore('T') else it }
?.distinct()
.orEmpty()

View File

@@ -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,
)
}

View File

@@ -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<String> = 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
}
/**

View File

@@ -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,
)
}

View File

@@ -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 ->

View File

@@ -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<EventColorOption>.nearestTo(argb: Int): EventColorOption? {
val target = oklchOf(argb)
return minByOrNull { oklchOf(it.argb).distanceTo(target) }
}

View File

@@ -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,
)
/**

View File

@@ -0,0 +1,80 @@
package de.jeanlucmakiola.calendula.domain.ics
/** Opaque alpha, forced onto any colour that arrives without one. */
private const val OPAQUE_ALPHA = 0xFF000000.toInt()
/**
* Resolve a colour value out of a foreign `.ics` to an opaque ARGB int.
*
* Three encodings are in the wild and all appear in the files Calendula is
* asked to import:
* - a CSS3 colour name, which is what RFC 7986 `COLOR` is defined as
* (`COLOR:tomato`);
* - a raw signed Android colour int, which the Simple Calendar / Fossify
* family writes in its `X-…-COLOR` extensions (`X-FOSSIFY-EVENT-COLOR:-2818048`);
* - `#rrggbb` / `#aarrggbb` hex, used by assorted other producers.
*
* Anything else — including the literal `null` Fossify emits when its calendar
* lookup misses — resolves to null.
*/
fun parseIcsColorValue(raw: String?): Int? {
val value = raw?.trim().orEmpty()
if (value.isEmpty()) return null
if (value.startsWith("#")) {
val hex = value.substring(1)
val parsed = hex.toLongOrNull(16)?.toInt() ?: return null
return when (hex.length) {
6 -> parsed or OPAQUE_ALPHA
8 -> parsed
else -> null
}
}
value.toIntOrNull()?.let { return if (it ushr 24 == 0) it or OPAQUE_ALPHA else it }
return cssColors[value.lowercase()]
}
/** The CSS3 extended colour keywords, the value space of RFC 7986 `COLOR`. */
private val cssColors: Map<String, Int> by lazy {
CSS3_TABLE.split(' ').associate { entry ->
val name = entry.substringBefore('=')
name to (entry.substringAfter('=').toInt(16) or OPAQUE_ALPHA)
}
}
private const val CSS3_TABLE =
"aliceblue=f0f8ff antiquewhite=faebd7 aqua=00ffff aquamarine=7fffd4 " +
"azure=f0ffff beige=f5f5dc bisque=ffe4c4 black=000000 blanchedalmond=ffebcd " +
"blue=0000ff blueviolet=8a2be2 brown=a52a2a burlywood=deb887 cadetblue=5f9ea0 " +
"chartreuse=7fff00 chocolate=d2691e coral=ff7f50 cornflowerblue=6495ed " +
"cornsilk=fff8dc crimson=dc143c cyan=00ffff darkblue=00008b darkcyan=008b8b " +
"darkgoldenrod=b8860b darkgray=a9a9a9 darkgreen=006400 darkgrey=a9a9a9 " +
"darkkhaki=bdb76b darkmagenta=8b008b darkolivegreen=556b2f darkorange=ff8c00 " +
"darkorchid=9932cc darkred=8b0000 darksalmon=e9967a darkseagreen=8fbc8f " +
"darkslateblue=483d8b darkslategray=2f4f4f darkslategrey=2f4f4f darkturquoise=00ced1 " +
"darkviolet=9400d3 deeppink=ff1493 deepskyblue=00bfff dimgray=696969 " +
"dimgrey=696969 dodgerblue=1e90ff firebrick=b22222 floralwhite=fffaf0 " +
"forestgreen=228b22 fuchsia=ff00ff gainsboro=dcdcdc ghostwhite=f8f8ff " +
"gold=ffd700 goldenrod=daa520 gray=808080 green=008000 greenyellow=adff2f " +
"grey=808080 honeydew=f0fff0 hotpink=ff69b4 indianred=cd5c5c indigo=4b0082 " +
"ivory=fffff0 khaki=f0e68c lavender=e6e6fa lavenderblush=fff0f5 lawngreen=7cfc00 " +
"lemonchiffon=fffacd lightblue=add8e6 lightcoral=f08080 lightcyan=e0ffff " +
"lightgoldenrodyellow=fafad2 lightgray=d3d3d3 lightgreen=90ee90 lightgrey=d3d3d3 " +
"lightpink=ffb6c1 lightsalmon=ffa07a lightseagreen=20b2aa lightskyblue=87cefa " +
"lightslategray=778899 lightslategrey=778899 lightsteelblue=b0c4de " +
"lightyellow=ffffe0 lime=00ff00 limegreen=32cd32 linen=faf0e6 magenta=ff00ff " +
"maroon=800000 mediumaquamarine=66cdaa mediumblue=0000cd mediumorchid=ba55d3 " +
"mediumpurple=9370db mediumseagreen=3cb371 mediumslateblue=7b68ee " +
"mediumspringgreen=00fa9a mediumturquoise=48d1cc mediumvioletred=c71585 " +
"midnightblue=191970 mintcream=f5fffa mistyrose=ffe4e1 moccasin=ffe4b5 " +
"navajowhite=ffdead navy=000080 oldlace=fdf5e6 olive=808000 olivedrab=6b8e23 " +
"orange=ffa500 orangered=ff4500 orchid=da70d6 palegoldenrod=eee8aa " +
"palegreen=98fb98 paleturquoise=afeeee palevioletred=db7093 papayawhip=ffefd5 " +
"peachpuff=ffdab9 peru=cd853f pink=ffc0cb plum=dda0dd powderblue=b0e0e6 " +
"purple=800080 rebeccapurple=663399 red=ff0000 rosybrown=bc8f8f royalblue=4169e1 " +
"saddlebrown=8b4513 salmon=fa8072 sandybrown=f4a460 seagreen=2e8b57 " +
"seashell=fff5ee sienna=a0522d silver=c0c0c0 skyblue=87ceeb slateblue=6a5acd " +
"slategray=708090 slategrey=708090 snow=fffafa springgreen=00ff7f " +
"steelblue=4682b4 tan=d2b48c teal=008080 thistle=d8bfd8 tomato=ff6347 " +
"turquoise=40e0d0 violet=ee82ee wheat=f5deb3 white=ffffff whitesmoke=f5f5f5 " +
"yellow=ffff00 yellowgreen=9acd32"

View File

@@ -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<String> = emptyList(),
val location: String? = null,
val description: String? = null,
/** Reminder lead times in minutes before start (raw provider offsets). */

View File

@@ -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<String> = emptyList(),
/** True when this came from a `VTODO` rather than a `VEVENT`. */
val isTask: Boolean = false,
)
/**
* [reminderMinutes] mapped onto Calendula's model: a lead time in minutes before
* the event, never negative.
*
* For a timed event the raw lead time already is that, and a trigger that fires
* *after* the start models no lead time at all — it is dropped rather than
* clamped, which would invent a reminder at the start the file never asked for
* (a legal `TRIGGER:PT30M` follow-up alarm is the case). All-day reminders are
* whole days before the event, fired at the user's configured time of day (see
* `AllDayReminderEncoding`), so a raw offset counts the whole days its trigger
* lands *earlier than* the event's UTC midnight — i.e. it rounds **up**.
*
* Rounding to the nearest day instead would lose a day for every producer whose
* all-day notifications fire after noon: a file's raw offset is
* `days × 1440 timeOfDay`, so "1 day before at 18:00" arrives as
* `TRIGGER:-PT6H` (raw 360) and would read as "on the day". Rounding up also
* lands Fossify's encoding correctly — it writes "on the day at 09:00" as a
* *positive* `TRIGGER:P0DT9H0M0S`, nine hours after the UTC midnight, which
* ceils to zero days before.
*/
fun ParsedIcsEvent.semanticReminderMinutes(): List<Int> = reminderMinutes
.mapNotNull { raw ->
if (isAllDay) {
ceil(raw.toDouble() / MINUTES_PER_DAY).toInt().coerceAtLeast(0) * MINUTES_PER_DAY
} else {
raw.takeIf { it >= 0 }
}
}
.distinct()
.sorted()
/** Things the parser dropped rather than failing — surfaced in the import report. */
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,16 @@ data class IcsParseResult(
val warnings: Set<IcsParseWarning>,
)
/** Outcome of a bulk `.ics` import into one calendar. */
data class IcsImportSummary(val imported: Int, val skippedDuplicate: Int)
/**
* Outcome of a bulk `.ics` import into one calendar. [failed] counts events the
* provider rejected — the import continues past them, so a single unusable
* event can't cost the user the rest of the file.
*/
data class IcsImportSummary(
val imported: Int,
val skippedDuplicate: Int,
val failed: Int = 0,
)
/**
* Hand-rolled RFC 5545 reader, the inverse of [IcsWriter]. Pure and
@@ -62,6 +124,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 +134,55 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
val lines = unfoldLines(text)
val events = mutableListOf<ParsedIcsEvent>()
val warnings = mutableSetOf<IcsParseWarning>()
var calendarName: String? = null
// Scanned up front rather than in document order: an X-WR-CALNAME after
// the first VEVENT still names the calendar every event came from.
val calendarName = lines.asSequence()
.mapNotNull(::parseContentLine)
.firstOrNull { it.name == "X-WR-CALNAME" }
?.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<String>,
fileCalendarName: String?,
warnings: MutableSet<IcsParseWarning>,
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 +190,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<Int>()
val exDateLines = mutableListOf<IcsContentLine>()
var skipAsOverride = false
var i = 0
@@ -129,9 +211,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 +226,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 +247,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<IcsContentLine>, start: IcsDateTime): List<String> {
if (lines.isEmpty()) return emptyList()
val startZone = runCatching { TimeZone.of(start.zoneId) }.getOrNull() ?: deviceZone
val startTime = start.instant.toLocalDateTime(startZone).time
return lines
.flatMap { line -> line.value.split(',').map { line to it.trim() } }
.mapNotNull { (line, token) ->
when {
token.isEmpty() -> null
start.isAllDay -> parseBasicDate(token.substringBefore('T'))?.let(::dayCode)
token.contains('T') -> parseExDateTime(token, line, startZone)?.let(::utcStamp)
else -> parseBasicDate(token)
?.let { utcStamp(LocalDateTime(it, startTime).toInstant(startZone)) }
}
}
.distinct()
}
/**
* [startZone] is the series' own zone, which a floating value (no `Z`, no
* `TZID`) has to be read in — RFC 5545 ties `EXDATE` to `DTSTART`'s form,
* and reading it in the device's zone instead would put the exclusion on an
* instant no occurrence has.
*/
private fun parseExDateTime(
token: String,
line: IcsContentLine,
startZone: TimeZone,
): Instant? {
val ldt = parseBasicDateTime(token.removeSuffix("Z")) ?: return null
val zone = when {
token.endsWith("Z") -> TimeZone.UTC
else -> line.params["TZID"]?.let { runCatching { TimeZone.of(it) }.getOrNull() }
?: startZone
}
return ldt.toInstant(zone)
}
/**
* A `VALARM`'s offset from the event start, in minutes *before* it — so a
* trigger that fires after the start (which is how the Fossify family
* encodes "on the day at 09:00") comes back negative. Null when the trigger
* isn't a usable relative offset.
*/
private fun parseAlarmMinutes(body: List<String>): Int? {
val trigger = body.asSequence()
.mapNotNull { parseContentLine(it) }
@@ -189,8 +374,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<IcsParseWarning>): IcsDateTime? {
@@ -218,6 +402,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:<component>` at/after [from], or list end. */
fun indexOfEnd(lines: List<String>, from: Int, component: String): Int {
var i = from

View File

@@ -0,0 +1,132 @@
package de.jeanlucmakiola.calendula.domain.ics
private val VALID_FREQ = setOf(
"SECONDLY", "MINUTELY", "HOURLY", "DAILY", "WEEKLY", "MONTHLY", "YEARLY",
)
private val VALID_WEEKDAYS = setOf("SU", "MO", "TU", "WE", "TH", "FR", "SA")
/**
* What `EventRecurrence` accepts in each numeric list part: a value range, plus
* whether `0` is legal. Its `parseNumberList` throws on a non-number, on an item
* outside the range *and* on a zero in the by-position parts, so checking only
* that an item is a number would still hand the provider a rule it rejects.
*
* `BYSETPOS` is the one part it leaves unbounded; `0` is dropped from it anyway
* because RFC 5545 §3.3.10 gives it no meaning.
*/
private val NUMERIC_LIST_PARTS = mapOf(
"BYSECOND" to NumericPart(0..59, zeroAllowed = true),
"BYMINUTE" to NumericPart(0..59, zeroAllowed = true),
"BYHOUR" to NumericPart(0..23, zeroAllowed = true),
"BYMONTHDAY" to NumericPart(-31..31, zeroAllowed = false),
"BYYEARDAY" to NumericPart(-366..366, zeroAllowed = false),
"BYWEEKNO" to NumericPart(-53..53, zeroAllowed = false),
"BYMONTH" to NumericPart(1..12, zeroAllowed = false),
"BYSETPOS" to NumericPart(Int.MIN_VALUE..Int.MAX_VALUE, zeroAllowed = false),
)
private class NumericPart(private val range: IntRange, private val zeroAllowed: Boolean) {
fun accepts(item: String): Boolean {
val n = item.toIntOrNull() ?: return false
return n in range && (zeroAllowed || n != 0)
}
}
/** `UNTIL` in RFC 5545 basic format; anything else fails at expansion time. */
private val UNTIL_SHAPE = Regex("""\d{8}(T\d{6}Z?)?""")
/**
* A foreign RRULE made safe to hand `CalendarContract`.
*
* [rule] is what survived, or null when there is nothing left to salvage.
* [repaired] is true only when a part was actually dropped — pure normalisation
* (case, a stray separator) doesn't count, so a conformant file is never
* reported to the user as faulty.
*/
data class SanitizedRrule(val rule: String?, val repaired: Boolean)
/**
* Salvage a foreign RRULE.
*
* The provider validates every RRULE through `EventRecurrence.parse`, which
* **throws** on a malformed rule — and the throw surfaces from `insert`, not
* from a later read. One bad rule in an imported file would therefore abort the
* write it rides on. Producers do emit such rules: Fossify's exporter writes a
* bare `;BYDAY=` whenever a weekly event carries no weekday mask
* (`Parser.getByDay` interpolates an empty day string unconditionally), which is
* the state every weekly event it imported from elsewhere is left in.
*
* That parser has a fixed table of parts and throws on any name outside it, so
* this is a whitelist: an unrecognised part (RFC 7529 `RSCALE`, a vendor `X-`
* extension) and an unusable value are dropped rather than passed on to fail the
* whole event. Only a missing or unrecognised `FREQ` — without which there is no
* rule at all — gives up entirely.
*/
fun sanitizeRrule(raw: String?): SanitizedRrule {
val rule = raw?.trim()?.removePrefix("RRULE:")?.trim().orEmpty()
if (rule.isEmpty()) return SanitizedRrule(rule = null, repaired = false)
var freqSeen = false
var repaired = false
val parts = mutableListOf<String>()
/** The items [valid] accepts, uppercased and comma-joined; null if none survive. */
fun keepItems(value: String, valid: (String) -> Boolean): String? {
val items = value.split(',').map { it.trim().uppercase() }
val kept = items.filter(valid)
if (kept.size != items.size) repaired = true
return kept.takeIf { it.isNotEmpty() }?.joinToString(",")
}
for (part in rule.split(';')) {
// A stray separator ("FREQ=DAILY;") normalises away; nothing was lost.
// A part that carries text but no '=' ("FREQ=WEEKLY;BYDAY") is a part
// being dropped, so it falls through and marks the rule repaired.
if (part.isBlank()) continue
val key = part.substringBefore('=', "").trim().uppercase()
val value = if ('=' in part) part.substringAfter('=').trim() else ""
val kept = when {
key.isEmpty() || value.isEmpty() -> null
key == "FREQ" ->
value.uppercase().takeIf { it in VALID_FREQ }
?.also { freqSeen = true }
?.let { "FREQ=$it" }
// A non-positive or non-numeric INTERVAL/COUNT is rejected by the
// provider, and a COUNT of zero would expand to no occurrences.
key == "INTERVAL" -> value.toIntOrNull()?.takeIf { it > 0 }?.let { "INTERVAL=$it" }
key == "COUNT" -> value.toIntOrNull()?.takeIf { it > 0 }?.let { "COUNT=$it" }
key == "UNTIL" ->
value.uppercase().takeIf { UNTIL_SHAPE.matches(it) }?.let { "UNTIL=$it" }
key == "WKST" ->
value.uppercase().takeIf { it in VALID_WEEKDAYS }?.let { "WKST=$it" }
key == "BYDAY" -> keepItems(value, ::isWeekdayItem)?.let { "BYDAY=$it" }
key in NUMERIC_LIST_PARTS ->
keepItems(value, NUMERIC_LIST_PARTS.getValue(key)::accepts)?.let { "$key=$it" }
else -> null
}
if (kept == null) repaired = true else parts += kept
}
// `EventRecurrence.parse` ends with *two* global checks, not one: a missing
// FREQ throws, and so does an UNTIL that arrives together with a COUNT. A
// rule carrying both is malformed per RFC 5545 §3.3.10 but real producers
// emit it, and left alone it would throw straight out of `insert` — the very
// failure this function exists to prevent. UNTIL is kept: it bounds the
// series at an absolute date, so a stale COUNT can't over-generate past it.
if (parts.any { it.startsWith("UNTIL=") }) {
if (parts.removeAll { it.startsWith("COUNT=") }) repaired = true
}
return if (freqSeen) {
SanitizedRrule(rule = parts.joinToString(";"), repaired = repaired)
} else {
SanitizedRrule(rule = null, repaired = true)
}
}
/** A `BYDAY` item: a two-letter weekday, optionally preceded by an ordinal. */
private fun isWeekdayItem(item: String): Boolean {
if (item.length < 2) return false
val ordinal = item.dropLast(2)
return item.takeLast(2) in VALID_WEEKDAYS &&
(ordinal.isEmpty() || ordinal.toIntOrNull() != null)
}

View File

@@ -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<String>.appendExDates(event: IcsEvent) {
if (event.recurrenceRule.isNullOrBlank()) return
val stamps = event.exDates.filter { it.isNotBlank() }.distinct()
if (stamps.isEmpty()) return
val prefix = if (event.isAllDay) "EXDATE;VALUE=DATE:" else "EXDATE:"
add(prefix + stamps.joinToString(","))
}
private fun MutableList<String>.appendAlarm(minutes: Int, summary: String) {
add("BEGIN:VALARM")
add("ACTION:DISPLAY")

View File

@@ -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() },
)

View File

@@ -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 = {

View File

@@ -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. */

View File

@@ -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<Instant?> { 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)
}
}

View File

@@ -106,6 +106,7 @@ import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
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 +119,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
@@ -471,7 +473,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))
@@ -525,6 +531,7 @@ private fun AllDayBar(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = eventInk(fill),
textDecoration = declinedDecoration(event.isDeclined),
)
}
}
@@ -570,6 +577,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 },
@@ -772,6 +780,7 @@ private fun EventBlock(
maxLines = if (showTime) 1 else 2,
overflow = TextOverflow.Ellipsis,
color = eventInk(fill, alpha = 0.85f),
textDecoration = declinedDecoration(block.event.isDeclined),
)
}
if (showTime) {
@@ -792,7 +801,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

View File

@@ -48,9 +48,14 @@ class EventDetailViewModel @Inject constructor(
) : ViewModel() {
private val _target = MutableStateFlow<Target?>(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>(DeleteUiState.Idle)
val deleteState: StateFlow<DeleteUiState> = _deleteState.asStateFlow()
@@ -58,11 +63,14 @@ class EventDetailViewModel @Inject constructor(
combine(_target, _reload) { target, _ -> target }
.flatMapLatest { target ->
if (target == null) {
loadedTarget = null
flowOf<EventDetailUiState>(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. */

View File

@@ -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

View File

@@ -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
@@ -751,7 +754,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
}
}

View File

@@ -93,15 +93,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(
@@ -252,6 +248,15 @@ private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
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(
@@ -290,6 +295,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 +319,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 +361,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,

View File

@@ -43,6 +43,12 @@ sealed interface ImportUiState {
val events: List<ParsedIcsEvent>,
val warnings: Set<IcsParseWarning>,
val calendars: List<CalendarSource>,
/**
* The calendar the file says its events came from, when every one of
* them names the same — used to preselect a matching target (see
* [fileCalendarName]).
*/
val fileCalendarName: String? = null,
) : ImportUiState
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<ParsedIcsEvent>): String? =
events.map { it.calendarName }.distinct().singleOrNull()
/**
* The target calendar to preselect for a bulk import.
*
* A calendar named like the one the file came from wins — restoring "Birthdays"
* onto the Birthdays calendar is what the user means. Otherwise the first
* *local* calendar, which is the first row the picker shows ("Your calendars"
* leads), so the pre-selection lines up with the top of the list; failing that
* the first calendar of any kind.
*/
internal fun defaultImportTarget(
calendars: List<CalendarSource>,
fileCalendarName: String?,
): Long? {
val named = fileCalendarName?.let { name ->
calendars.firstOrNull { it.displayName.equals(name, ignoreCase = true) }
}
return (named ?: calendars.firstOrNull { it.isLocal } ?: calendars.firstOrNull())?.id
}

View File

@@ -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
@@ -111,6 +113,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
@@ -138,6 +141,7 @@ 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
@@ -439,6 +443,7 @@ fun MonthScreen(
showWeekNumbers = showWeekNumbers,
onRetry = jumpToToday,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
)
} else if (viewStyle == MonthViewStyle.Split) {
SplitMonthContent(
@@ -463,6 +468,7 @@ fun MonthScreen(
onSwipePrev = goPrev,
onRetry = jumpToToday,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
)
}
}
@@ -573,6 +579,7 @@ private fun MonthContent(
onSwipePrev: () -> Unit,
onRetry: () -> Unit,
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
) {
val slideSpec = rememberCalendarSlideSpec()
val fadeSpec = rememberCalendarFadeSpec()
@@ -599,6 +606,7 @@ private fun MonthContent(
state = s,
showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
)
}
}
@@ -617,6 +625,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
@@ -631,6 +640,7 @@ private fun ContinuousMonthContent(
listState = listState,
showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
)
} else {
ContinuousMonthGrid(
@@ -638,6 +648,7 @@ private fun ContinuousMonthContent(
listState = listState,
showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
)
}
}
@@ -736,7 +747,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
@@ -754,11 +766,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,
) {
@@ -779,6 +795,7 @@ internal fun MonthGrid(
inMonth = { it.month == month.month && it.year == month.year },
showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
selected = selected,
modifier = Modifier
.fillMaxWidth()
@@ -809,6 +826,7 @@ internal fun ContinuousMonthGrid(
listState: LazyListState,
showWeekNumbers: Boolean,
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
modifier: Modifier = Modifier,
) {
val monthCount = remember { continuousMonthCount() }
@@ -836,6 +854,7 @@ internal fun ContinuousMonthGrid(
today = state.today,
showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
)
}
}
@@ -855,6 +874,7 @@ private fun ContinuousMonthBlock(
today: LocalDate,
showWeekNumbers: Boolean,
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
) {
val rowCount = remember(month, weekStart) { weekRowsInMonth(month, weekStart) }
Column(
@@ -877,6 +897,7 @@ private fun ContinuousMonthBlock(
blankOutside = true,
showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
modifier = Modifier
.fillMaxWidth()
.height(CONTINUOUS_ROW_HEIGHT),
@@ -935,6 +956,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) }
@@ -942,10 +964,15 @@ internal fun DenseMonthGrid(
state = listState,
modifier = modifier
.fillMaxSize()
.padding(horizontal = 8.dp),
// 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 = 8.dp)
.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]
@@ -960,6 +987,7 @@ internal fun DenseMonthGrid(
inMonth = { true },
showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
labelMonthOnFirst = true,
modifier = Modifier
.fillMaxWidth()
@@ -1187,6 +1215,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 {
@@ -1287,6 +1318,7 @@ private fun SplitMonthExpanded(
showWeekNumbers: Boolean,
swipeModifier: Modifier,
onPickDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
onCollapse: () -> Unit,
) {
val slideSpec = rememberCalendarSlideSpec()
@@ -1309,6 +1341,7 @@ private fun SplitMonthExpanded(
state = s,
showWeekNumbers = showWeekNumbers,
onOpenDay = onPickDay,
onEventClick = onEventClick,
selected = sel,
)
}
@@ -1789,7 +1822,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) {
@@ -1837,6 +1870,7 @@ private fun MonthWeekRow(
inMonth: (LocalDate) -> Boolean,
showWeekNumbers: Boolean,
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
modifier: Modifier = Modifier,
blankOutside: Boolean = false,
labelMonthOnFirst: Boolean = false,
@@ -2135,11 +2169,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 {
@@ -2147,9 +2187,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)
},
)
}
}
@@ -2158,6 +2222,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<LayoutCoordinates?>,
band: Array<LayoutCoordinates?>,
): 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
@@ -2358,6 +2459,7 @@ private fun MonthBar(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = eventInk(fill),
textDecoration = declinedDecoration(event.isDeclined),
)
}
}

View File

@@ -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(

View File

@@ -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,

View File

@@ -302,13 +302,14 @@ private fun ReportProblemRow(position: Position) {
@Composable
private fun LanguageRow(position: Position) {
val context = LocalContext.current
val supported = remember { AppLanguage.supportedTags(context, R.xml.locales_config) }
// Setting a locale recreates the activity; mirror the choice locally so the
// row updates instantly even before the recreation lands.
var current by remember { mutableStateOf(AppLanguage.currentTag()) }
var current by remember { mutableStateOf(AppLanguage.currentTag(supported)) }
var showDialog by remember { mutableStateOf(false) }
// null = follow the system; the rest are BCP-47 tags from locales_config.xml.
val options = remember { listOf<String?>(null) + AppLanguage.supportedTags(context, R.xml.locales_config) }
val options = remember(supported) { listOf<String?>(null) + supported }
GroupedRow(
title = stringResource(R.string.settings_language),

View File

@@ -90,6 +90,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
@@ -129,6 +130,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
@@ -506,7 +508,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
@@ -602,7 +604,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))
@@ -662,6 +668,7 @@ private fun AllDayBar(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = eventInk(fill),
textDecoration = declinedDecoration(event.isDeclined),
)
}
}
@@ -708,6 +715,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 },
@@ -940,6 +948,7 @@ private fun EventBlock(
maxLines = titleMaxLines,
overflow = TextOverflow.Ellipsis,
color = eventInk(fill, alpha = 0.85f),
textDecoration = declinedDecoration(block.event.isDeclined),
)
}
if (showTime) {
@@ -977,7 +986,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(

View File

@@ -0,0 +1,115 @@
package de.jeanlucmakiola.calendula.widget
import android.app.AlarmManager
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import androidx.core.content.getSystemService
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver
import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
/**
* Holds the app's own wake-up for the next local midnight, so the home-screen
* widgets roll "today" over on the day boundary (#228).
*
* The widgets used to lean on `ACTION_DATE_CHANGED`, which is not an exempted
* implicit broadcast — a manifest-declared receiver has not been given it since
* Android 8, leaving only the throttled `updatePeriodMillis`.
*
* Exactly one alarm exists at a time and every firing re-arms the next, the same
* shape as [de.jeanlucmakiola.calendula.data.reminders.ReminderAlarmScheduler].
* Deliberately **inexact**: `setAndAllowWhileIdle` needs no permission and
* survives doze (plain `set` does not), a rollover a few minutes late is
* invisible on a sleeping screen, and exact alarms stay reserved for snooze.
*/
object WidgetRolloverScheduler {
/**
* Fire just *after* midnight: an alarm delivered a few milliseconds early
* would still read the old date and re-arm for an instant later.
*/
internal val ROLLOVER_SLACK = 5.seconds
/**
* Arm the next rollover, or cancel a pending one when no widget is placed.
* Idempotent, so every trigger (boot, app start, widget added/removed, the
* rollover itself, a clock or timezone change) can just call it.
*/
fun sync(context: Context) {
val appContext = context.applicationContext
val alarmManager = appContext.getSystemService<AlarmManager>() ?: return
val pendingIntent = rolloverPendingIntent(appContext)
if (!hasPlacedWidgets(appContext)) {
alarmManager.cancel(pendingIntent)
return
}
val triggerAt = nextRolloverAt(Clock.System.now(), TimeZone.currentSystemDefault())
alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, triggerAt.toEpochMilliseconds(), pendingIntent,
)
}
/**
* The instant just after the next local midnight following [now] in [zone].
*
* The *actual* start of day, not 00:00, so it holds where a DST jump means
* midnight never happens (Havana) and where a date-line move skips a whole
* local date (Apia, December 2011) — the loop walks on to the next real day.
*
* The mirror case — a zone rewinding *across* midnight, so the day starts
* twice — resolves to the earlier start and runs an hour ahead of the clock.
* No live tz entry does that (Brazil dropped DST in 2019) and
* `updatePeriodMillis` covers it, so it isn't worth state to detect.
*/
fun nextRolloverAt(now: Instant, zone: TimeZone): Instant {
val date = now.toLocalDateTime(zone).date
var days = 1
while (days <= MAX_LOOKAHEAD_DAYS) {
val candidate = date.plus(days, DateTimeUnit.DAY).atStartOfDayIn(zone) + ROLLOVER_SLACK
if (candidate > now) return candidate
days++
}
// Unreachable for any zone in the tz database. Deliberately an hour and
// not the slack: a 5-second retry would just hit this branch again and
// wake the device in a loop.
return now + 1.hours
}
private fun hasPlacedWidgets(context: Context): Boolean {
val manager = AppWidgetManager.getInstance(context) ?: return false
return PROVIDERS.any {
manager.getAppWidgetIds(ComponentName(context, it)).isNotEmpty()
}
}
private fun rolloverPendingIntent(context: Context): PendingIntent =
PendingIntent.getBroadcast(
context,
ROLLOVER_REQUEST_CODE,
Intent(context, WidgetUpdateReceiver::class.java)
.setAction(WidgetUpdateReceiver.ACTION_ROLLOVER),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
private val PROVIDERS = listOf(
MonthWidgetReceiver::class.java,
AgendaWidgetReceiver::class.java,
)
/** Fixed: there is only ever one rollover alarm, and re-arming must replace it. */
private const val ROLLOVER_REQUEST_CODE = 0x0DA1
/** A gap of more than a couple of days does not exist in any tz database entry. */
private const val MAX_LOOKAHEAD_DAYS = 3
}

View File

@@ -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

View File

@@ -12,19 +12,40 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
/**
* 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` is a free extra in the filter that nothing depends on — see
* [WidgetRolloverScheduler]. The backstops are `updatePeriodMillis` in the
* provider XML and the month widget's refresh button.
*
* 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
// Re-arm first, so the next day boundary is covered whatever the redraw
// does. Every handled action either dropped, consumed or invalidated it.
WidgetRolloverScheduler.sync(appContext)
// The host sends APPWIDGET_UPDATE after both of these anyway, so
// redrawing here would only repeat the work in a cold process, at the
// moment the device is most contended.
if (intent.action in REARM_ONLY_ACTIONS) return
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.
@@ -38,4 +59,23 @@ class WidgetUpdateReceiver : BroadcastReceiver() {
}
}
}
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,
)
internal val HANDLED_ACTIONS = REARM_ONLY_ACTIONS + setOf(
ACTION_ROLLOVER,
Intent.ACTION_PROVIDER_CHANGED,
Intent.ACTION_DATE_CHANGED,
Intent.ACTION_TIME_CHANGED,
Intent.ACTION_TIMEZONE_CHANGED,
)
}
}

View File

@@ -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),

View File

@@ -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)
}
}

View File

@@ -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,9 +324,31 @@ 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 ->
Box(
modifier = GlanceModifier
.width(colW)
.fillMaxHeight()
.clickable(openDayAction(context, date)),
) {}
}
}
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,
@@ -301,13 +361,37 @@ private fun WeekRow(
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 ->
// 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))
}
OverflowRow(week = week, colW = colW)
if (hidden.any { it > 0 }) OverflowRow(hidden = hidden, 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<Int> = 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
@@ -318,25 +402,14 @@ 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,
) {
Box(
modifier = GlanceModifier
.size(DAY_NUMBER_HEIGHT)
.then(if (isToday) GlanceModifier.cornerRadius(DAY_NUMBER_HEIGHT / 2).background(GlanceTheme.colors.primary) else GlanceModifier),
contentAlignment = Alignment.Center,
) {
Text(
text = date.day.toString(),
style = TextStyle(
val style = TextStyle(
color = when {
isToday -> GlanceTheme.colors.onPrimary
inMonth -> GlanceTheme.colors.onSurface
@@ -344,38 +417,78 @@ private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW:
},
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)
.cornerRadius(DAY_NUMBER_HEIGHT / 2)
.background(GlanceTheme.colors.primary),
contentAlignment = Alignment.Center,
) {
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<LaneCell> {
val cells = mutableListOf<LaneCell>()
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<Int>, 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) {
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 = "+$hidden",
text = "+$count",
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 9.sp),
modifier = GlanceModifier.padding(start = 3.dp),
modifier = GlanceModifier.width(colW).padding(start = 3.dp),
)
}
}
}
if (gap > 0) Spacer(GlanceModifier.width(colW * gap).height(LANE_HEIGHT))
}
}

View File

@@ -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)
}
}

View File

@@ -16,14 +16,14 @@
<string name="state_failure_no_calendars_action">فتح إعدادات تقويم النظام</string>
<string name="state_failure_provider">تعذّر قراءة التقويم.</string>
<string name="permission_rationale_title">شاهد جميع أحداثك، بشكل جميل</string>
<string name="permission_rationale_body">Calendula يحتاج للوصول إلى تقويمك لعرض و إدارة أحداثك. هذا كل ما يطلبه مقدمًا — ولا يخرج أي شيء من جهازك على الإطلاق.</string>
<string name="permission_rationale_body">Calendula يحتاج للوصول إلى تقويمك لعرض و إدارة أحداثك.</string>
<string name="permission_request_button">منح الوصول إلى التقويم</string>
<string name="permission_denied_title">رُفِض الوصول إلى التقويم</string>
<string name="permission_denied_body">Calendula لا يمكنه عرض الأحداث بدون الوصول إلى التقويم. يمكنك منحه مُجددًا في إعدادات النظام.</string>
<string name="permission_retry_button">أعِد المحاولة</string>
<string name="permission_open_settings_button">فتح إعدادات النظام</string>
<string name="permission_benefit_private_title">يبقى على جهازك</string>
<string name="permission_benefit_private_body">تقويماتك تتِم قراءتها محليًا ولا تُغادر الهاتف أبدًا.</string>
<string name="permission_benefit_private_title">الخصوصية حسب التصميم</string>
<string name="permission_benefit_private_body">لا يوجد إذن بالإنترنت، ولا تتبع عن بُعد — لا شيء يغادر هاتفك أبدًا.</string>
<string name="permission_benefit_sync_title">جميع تقويماتك، مع بعض</string>
<string name="permission_benefit_sync_body">جوجل، CalDAV، محلي — أي شيء تمت مزامنته مع الجهاز يظهر فورًا.</string>
<string name="permission_benefit_privacy_title">لا تتبع، أبدًا</string>
@@ -38,7 +38,7 @@
<string name="settings_theme_system">النظام</string>
<string name="settings_theme_light">فاتح</string>
<string name="settings_theme_dark">داكن</string>
<string name="month_open_menu">افتح القائمة</string>
<string name="month_open_menu">فتح القائمة</string>
<string name="month_more_actions">المزيد من الإجراءات</string>
<string name="event_detail_edit">تعديل</string>
<string name="event_detail_delete">حذف</string>
@@ -126,6 +126,7 @@
<string name="event_edit_recurrence_none">لا يتكرر</string>
<string name="import_reminder_prompt_body_none">تم استيراد هذا الحدث بدون أي تذكير.</string>
<plurals name="import_reminder_prompt_body_existing">
<item quantity="zero">تم استيراد هذا الحدث مع (%1$d) لا تذكيرات.</item>
<item quantity="one">تم استيراد هذا الحدث مع %1$d تذكير.</item>
<item quantity="two">تم استيراد هذا الحدث مع %1$d تذكيران.</item>
<item quantity="few">تم استيراد هذا الحدث مع %1$d تذكيرات.</item>
@@ -175,9 +176,9 @@
<string name="reminder_at_time">في وقت الحدث</string>
<string name="event_untitled">(بدون عنوان)</string>
<string name="reminder_channel_name">تذكيرات الحدث</string>
<string name="reminder_channel_description">الإشعارات في أوقات التذكير الخاصه بأحداثك</string>
<string name="reminder_channel_description">الإشعارات في أوقات التذكير بأحداثك</string>
<string name="reminder_onboarding_title">لا تفوّت أي حدث أبدًا</string>
<string name="reminder_onboarding_body">Android لا يعرض تذكيرات الأحداث من نفسه — بل يجب أن يقوم تطبيق تقويم بذلك. دع Calendula يتولى هذه المهمة.</string>
<string name="reminder_onboarding_body">أندرويد لا يعرض تذكيرات الأحداث من نفسه — بل يجب أن يقوم تطبيق تقويم بذلك. دع Calendula يتولى هذه المهمة.</string>
<string name="reminder_benefit_delivery_body">كل تذكير لأحداثك يصِل كإشعار، في الوقت المحدد تمامًا.</string>
<string name="reminder_benefit_duplicates_title">هل تستخدم تطبيق تقويم ثانٍ؟</string>
<string name="reminder_benefit_duplicates_body">إذا تطبيق آخر أيضًا ينشر تذكيرات، فستراهم مرتين — قم بإيقاف تشغيلهم هناك أو هنا.</string>
@@ -237,22 +238,22 @@
<string name="settings_time_format_24h">٢٤-ساعة (١٤:٠٠)</string>
<string name="agenda_range_day">اليوم</string>
<string name="settings_agenda_show_today">إظهار اليوم دائمًا</string>
<string name="settings_agenda_show_today_hint">اترك اليوم في أعلى الجدول و ودجته، حتى بعد عدم وجود أي شيء اليوم.</string>
<string name="settings_agenda_show_today_hint">اترك اليوم في أعلى الجدول و أداته المصغرة، حتى بعد عدم وجود أي شيء اليوم.</string>
<string name="agenda_range_this_month">هذا الشهر</string>
<string name="agenda_range_this_week">هذا الأسبوع</string>
<string name="settings_past_events_show">إظهار</string>
<string name="settings_past_events_hide">إخفاء</string>
<string name="settings_agenda_header">جدول</string>
<string name="settings_agenda_header">الجدول</string>
<string name="event_edit_timezone_device">المنطقة الزمنية للجهاز</string>
<string name="event_edit_timezone_search">البحث عن المناطق الزمنية</string>
<string name="event_edit_timezone_all">جميع المناطق الزمنية</string>
<string name="event_edit_timezone_none">لا منطقة زمنية تطابق \"%1$s\"</string>
<string name="event_edit_timezone_none">لا منطقة زمنية تطابق %1$s</string>
<string name="event_edit_timezone_local_time">%1$s توقيتك</string>
<string name="event_edit_timezone_recent">الأحدث</string>
<string name="event_edit_timezone_recent">الأخيرة</string>
<string name="event_edit_recurrence_incomplete">أدخل رقمًا من ١ إلى ٩٩٩</string>
<plurals name="duration_minutes">
<item quantity="zero">(%d) لا دقائق</item>
<item quantity="one">(%d) دقيقة واحده</item>
<item quantity="one">%d دقيقة واحده</item>
<item quantity="two">%d دقيقتان</item>
<item quantity="few">%d دقائق</item>
<item quantity="many">%d دقيقة</item>
@@ -260,7 +261,7 @@
</plurals>
<plurals name="duration_hours">
<item quantity="zero">(%d) لا ساعات</item>
<item quantity="one">(%d) ساعة واحده</item>
<item quantity="one">%d ساعة واحده</item>
<item quantity="two">%d ساعتان</item>
<item quantity="few">%d ساعات</item>
<item quantity="many">%d ساعة</item>
@@ -268,7 +269,7 @@
</plurals>
<plurals name="duration_days">
<item quantity="zero">(%d) لا أيام</item>
<item quantity="one">(%d) يوم واحد</item>
<item quantity="one">%d يوم واحد</item>
<item quantity="two">%d يومان</item>
<item quantity="few">%d أيام</item>
<item quantity="many">%d يوم</item>
@@ -276,7 +277,7 @@
</plurals>
<plurals name="duration_weeks">
<item quantity="zero">(%d) لا أسابيع</item>
<item quantity="one">(%d) أسبوع واحد</item>
<item quantity="one">%d أسبوع واحد</item>
<item quantity="two">%d أسبوعان</item>
<item quantity="few">%d أسابيع</item>
<item quantity="many">%d أسبوع</item>
@@ -285,13 +286,13 @@
<string name="agenda_span_starts">يبدأ %1$s</string>
<string name="agenda_span_ends">ينتهي %1$s</string>
<string name="today_jump_action">الانتقال إلى اليوم</string>
<string name="widget_refresh">تحديث</string>
<string name="widget_refresh">إعادة تحميل</string>
<string name="settings_app_name">اسم التطبيق</string>
<string name="settings_dim_completed">تعتيم الأحداث المكتملة</string>
<string name="settings_dim_completed_summary">تعتيم الأحداث التي انتهت بالفعل في عرض الشهر والأسبوع</string>
<string name="settings_today_toolbar">زر اليوم في شريط الأدوات</string>
<string name="settings_today_toolbar_summary">إظهار زر الانتقال إلى اليوم في شريط الأدوات بدلاً من زر عائم</string>
<string name="settings_app_name_summary">اعرض Calendula كـ \"Calendar\" في مشغّل التطبيقات الخاص بك. فقط الاسم في المشغّل يتغير؛ وقد ينتقل الرمز إلى مكان جديد بعد التبديل.</string>
<string name="settings_app_name_summary">اعرض Calendula كـ Calendar في مشغّل التطبيقات الخاص بك. فقط الاسم في المشغّل يتغير؛ وقد ينتقل الرمز إلى مكان جديد بعد التبديل.</string>
<string name="settings_past_events_dim">تعتيم</string>
<string name="settings_past_events">الأحداث السابقة</string>
<string name="settings_agenda_range">مدى الجدول</string>
@@ -314,7 +315,6 @@
<string name="settings_form_fields_hint">الخانات المعروضة افتراضيًا — كل شيء آخر موجود ضمن \"المزيد من الخانات\"</string>
<string name="settings_section_event_form">نموذج حدث جديد</string>
<string name="settings_quick_switch_header">زر التبديل السريع</string>
<string name="app_name">التقويم</string>
<string name="settings_theme_system_summary">الحالي %1$s</string>
<string name="settings_theme_hint">سواء التطبيق فاتحًا أو داكنًا. الاختيار يُطبق على الفور.</string>
<string name="settings_week_start_auto_summary">الحالي %1$s</string>
@@ -329,14 +329,14 @@
<string name="settings_widget_size_extra_large">كبير جدًا</string>
<plurals name="agenda_range_days">
<item quantity="zero">(%d) لا أيام</item>
<item quantity="one">(%d) يوم واحد</item>
<item quantity="one">%d يوم واحد</item>
<item quantity="two">%d يومان</item>
<item quantity="few">%d أيام</item>
<item quantity="many">%d يوم</item>
<item quantity="other">%d يوم</item>
</plurals>
<string name="settings_agenda_range_bar_hint">اعرض شريطًا في الجزء العلوي من الجدول يحدد التواريخ المعروضة، مع زر لتبديل النطاق للجلسة</string>
<string name="settings_agenda_range_bar">شريط النطاق</string>
<string name="settings_agenda_range_bar_hint">اعرض شريطًا في الجزء العلوي من الجدول يحدد التواريخ المعروضة، مع زر لتبديل المدى للجلسة</string>
<string name="settings_agenda_range_bar">شريط المدى</string>
<string name="settings_month_header">عرض الشهر</string>
<string name="month_style_continuous">تمرير الشهور</string>
<string name="month_style_dense">أسابيع سلسة</string>
@@ -383,7 +383,7 @@
<string name="event_edit_recurrence_next">التالي: %1$s</string>
<plurals name="reminder_minutes">
<item quantity="zero">(%d) لا دقائق قبل</item>
<item quantity="one">(%d) دقيقة واحده قبل</item>
<item quantity="one">%d دقيقة واحده قبل</item>
<item quantity="two">%d دقيقتان قبل</item>
<item quantity="few">%d دقائق قبل</item>
<item quantity="many">%d دقيقة قبل</item>
@@ -391,7 +391,7 @@
</plurals>
<plurals name="reminder_hours">
<item quantity="zero">(%d) لا ساعات قبل</item>
<item quantity="one">(%d) ساعة واحده قبل</item>
<item quantity="one">%d ساعة واحده قبل</item>
<item quantity="two">%d ساعتان قبل</item>
<item quantity="few">%d ساعات قبل</item>
<item quantity="many">%d ساعة قبل</item>
@@ -399,7 +399,7 @@
</plurals>
<plurals name="reminder_days">
<item quantity="zero">(%d) لا أيام قبل</item>
<item quantity="one">(%d) يوم واحد قبل</item>
<item quantity="one">%d يوم واحد قبل</item>
<item quantity="two">%d يومان قبل</item>
<item quantity="few">%d أيام قبل</item>
<item quantity="many">%d يوم قبل</item>
@@ -407,13 +407,13 @@
</plurals>
<plurals name="reminder_weeks">
<item quantity="zero">(%d) لا أسابيع قبل</item>
<item quantity="one">(%d) أسبوع واحد قبل</item>
<item quantity="one">%d أسبوع واحد قبل</item>
<item quantity="two">%d أسبوعان قبل</item>
<item quantity="few">%d أسابيع قبل</item>
<item quantity="many">%d أسبوع قبل</item>
<item quantity="other">%d أسبوع قبل</item>
</plurals>
<string name="reminder_benefit_reversible_body">يوجد المفتاح في الإعدادات، ضمن الإشعارات.</string>
<string name="reminder_benefit_reversible_body">يوجد مفتاح التبديل في الإعدادات، ضمن الإشعارات.</string>
<string name="reminder_custom_amount">القيمة</string>
<string name="settings_manage_calendars_hint">إنشاء تقويمات محلية؛ إدارة التقويمات المتزامنة</string>
<string name="settings_snooze_duration">مدة التأجيل</string>
@@ -424,7 +424,7 @@
<string name="settings_views_subtitle">العرض، التخطيط، الترتيب</string>
<string name="settings_event_form_subtitle">الخانات الافتراضية و السلوك</string>
<string name="settings_notifications_subtitle">التذكيرات و التسليم</string>
<string name="settings_special_dates_subtitle">أعياد ميلاد جهات الاتصال و احتفالات ذكرى</string>
<string name="settings_special_dates_subtitle">أعياد ميلاد جهات الاتصال و الذكرات السنوية</string>
<string name="settings_special_dates_type_birthday">أعياد الميلاد</string>
<string name="settings_special_dates_type_custom">تواريخ أخرى</string>
<string name="settings_license">الترخيص</string>
@@ -432,7 +432,7 @@
<string name="settings_special_dates_disable_confirm">إيقاف</string>
<string name="settings_special_dates_sync_now">زامن الآن</string>
<string name="settings_special_dates_never_synced">لم تتم المزامنة بعد</string>
<string name="settings_special_dates_paused_hint">لم يعد بإمكان Calendula قراءة جهات الاتصال الخاصة بك، لذلك لا يتم تحديث هذه التقويمات.</string>
<string name="settings_special_dates_paused_hint">لم يعد بإمكان Calendula قراءة جهات اتصالك، لذلك لا يتم تحديث هذه التقويمات.</string>
<string name="calendars_manage_in_app">الإدارة في التطبيق</string>
<string name="calendars_enable_all">تفعيل الكل</string>
<string name="calendars_disable_all">تعطيل الكل</string>
@@ -451,13 +451,13 @@
<string name="settings_special_dates_grant">منح الوصول</string>
<string name="calendars_backup_action">تصدير كملف .ics</string>
<string name="calendars_export_title">تصدير التقويمات</string>
<string name="calendars_export_hint">اختر التقويمات التي تريد تضمينها في .ics الملف.</string>
<string name="calendars_export_hint">اختر التقويمات التي تريد تضمينها في ملف .ics.</string>
<string name="calendars_export_action">تصدير</string>
<string name="calendars_restore_header">استعادة</string>
<string name="calendars_restore_action">استعادة من ملف .ics</string>
<string name="calendars_restore_hint">استيراد الأحداث من نسخة احتياطية أو تطبيق تقويم آخر.</string>
<string name="calendars_auto_backup">النسخ الاحتياطي التلقائي</string>
<string name="calendars_auto_backup_hint">قم بتصدير تقويماتك المحلية بشكل دوري إلى مجلد كـ .ics الملف.</string>
<string name="calendars_auto_backup_hint">قم بتصدير تقويماتك المحلية بشكل دوري إلى مجلد كـ ملف .ics.</string>
<string name="calendars_auto_backup_folder">مجلد النسخ الاحتياطي</string>
<string name="calendars_auto_backup_folder_unset">اضغط لاختيار مجلد</string>
<string name="calendars_auto_backup_every">كل %1$s</string>
@@ -472,7 +472,7 @@
<string name="calendars_backup_failed">تعذّر تصدير النسخة الاحتياطية.</string>
<plurals name="calendars_backup_done">
<item quantity="zero">تم تصدير (%d) لا أحداث.</item>
<item quantity="one">تم تصدير (%d) حدث واحد.</item>
<item quantity="one">تم تصدير %d حدث واحد.</item>
<item quantity="two">تم تصدير %d حدثان.</item>
<item quantity="few">تم تصدير %d أحداث.</item>
<item quantity="many">تم تصدير %d حدث.</item>
@@ -500,11 +500,11 @@
<string name="settings_qs_tile">إضافة مربع الإعدادات السريعة</string>
<string name="import_button">استيراد</string>
<string name="crash_dialog_dismiss">ليس الآن</string>
<string name="crash_report_copied">تم نسخ التقرير إلى الحافظة</string>
<string name="crash_report_open_failed">تعذّر فتح متتبع المشكلات. التقرير موجود في الحافظة الخاصة بك.</string>
<string name="crash_report_copied">تم نسخ التقرير إلى حافظتك</string>
<string name="crash_report_open_failed">تعذّر فتح متتبع المشكلات. التقرير موجود في حافظتك.</string>
<string name="crash_report_issue_title">تقرير العطل</string>
<string name="crash_report_clip_label">%1$s تقرير العطل</string>
<string name="crash_report_body_paste">_(كان التقرير طويلًا جدًا لهذا الرابط — الصقه من الحافظة هنا.)_</string>
<string name="crash_report_body_paste">_(كان التقرير طويلًا جدًا لهذا الرابط — الصقه من حافظتك هنا.)_</string>
<string name="special_dates_calendar_birthday">أعياد الميلاد</string>
<string name="special_dates_calendar_custom">التواريخ الخاصة</string>
<string name="event_edit_recurrence_next_none">هذه القاعدة لا تتكرر أبدًا</string>
@@ -515,18 +515,18 @@
<string name="month_split_expand">إظهار الشهر بأكمله</string>
<string name="settings_week_day_header">الأسبوع و اليوم</string>
<string name="settings_default_view_hint">طريقة العرض التي يفتحها Calendula عندما تقوم ببدءه.</string>
<string name="settings_week_start_hint">اليوم الذي يبدأ به كل أسبوع، في جميع طرق العرض والودجتز.</string>
<string name="settings_week_start_hint">اليوم الذي يبدأ به كل أسبوع، في جميع طُرق العرض والأدوات المصغّرة.</string>
<string name="settings_time_format_hint">كيف الأوقات تُكتب في جميع أنحاء التطبيق. تلقائيًا يتبع إعداد نظامك.</string>
<string name="settings_past_events_hint">ما الذي يفعله جدول بالأحداث التي انتهت بالفعل.</string>
<string name="calendars_visibility_a11y">إظهار \"%1$s\"</string>
<string name="calendars_visibility_notice_title">بعض التقويمات متوقفة</string>
<string name="calendars_visibility_notice_message">Calendula يعرض الآن التقويمات التي تم تشغيلها لهذا الجهاز، لذلك ما تراه وما يذكرك لم يعد بإمكانه أن يختلف. بعض تقويماتك متوقفة حاليًا — تم إيقافها هنا أو في تطبيق تقويم آخر. أعد تشغيل أي منها في الإعدادات ← التقويمات.</string>
<string name="calendars_visibility_notice_message">Calendula يعرض التقويمات التي تم تشغيلها لهذا الجهاز، وبعض من خاصتك متوقفة. لرؤية أحداثهم، قم بتشغيلهم ضمن الإعدادات ← التقويمات.</string>
<string name="calendar_picker_missing_title">تفتقد تقويمًا؟</string>
<string name="calendar_picker_missing_summary">قد يكون متوقفًا عن التشغيل، للقراءة فقط أو مملوءًا من جهات الاتصال الخاصة بك — يمكنك إدارة تقويماتك هنا.</string>
<string name="calendar_picker_missing_summary">قد يكون متوقفًا عن التشغيل، للقراءة فقط أو مملوءًا من جهات اتصالك — يمكنك إدارة تقويماتك هنا.</string>
<string name="calendars_state_read_only">للقراءة فقط</string>
<string name="calendars_state_not_synced">لم تتم المزامنة مع هذا الجهاز</string>
<string name="calendars_state_managed">تم ملؤه من جهات اتصالك</string>
<string name="calendars_managed_delete_locked">تم ملء هذا التقويم من جهات الاتصال الخاصة بك، لذلك سيقوم Calendula بإنشائه مرة أخرى في المزامنة التالية. قم بإيقاف تشغيل التواريخ الخاصة ضمن الإعدادات ← التواريخ الخاصة لحذفها.</string>
<string name="calendars_managed_delete_locked">تم ملء هذا التقويم من جهات اتصالك، لذلك سيقوم Calendula بإنشائه مرة أخرى في المزامنة التالية. قم بإيقاف تشغيل التواريخ الخاصة ضمن الإعدادات ← التواريخ الخاصة لحذفها.</string>
<string name="duration_custom_max">كحد أقصى %1$s</string>
<string name="settings_calendar_duration_inherits">الافتراضي (%1$s)</string>
<string name="settings_calendar_duration_use_default">استخدام المدة الافتراضية (%1$s)</string>
@@ -542,4 +542,167 @@
<string name="timeline_scale_regular">العادي</string>
<string name="timeline_scale_comfortable_summary">مجموعات أوسع، تمرير أكثر</string>
<string name="timeline_scale_custom_summary">الارتفاع الذي قمت بضغظت المخطط الزمني إليه</string>
<string name="event_edit_timezone_device_summary">يتابعك أينما كنت</string>
<string name="event_access_confidential_summary">مُعَلَّم كـ سري؛ ما يعنيه ذلك متروك لحساب التقويم</string>
<string name="settings_widget_size_summary">نص الحدث %1$d%%</string>
<string name="settings_calendar_durations_hint">امنح تقويم طوله الافتراضي خاص به — للمثال. ٨ ساعات لمناوبات العمل.</string>
<string name="settings_backup_subtitle">تصدير، استيراد، النسخ الاحتياطي التلقائي</string>
<string name="settings_views_all_header">كل طُرق العرض</string>
<string name="settings_calendar_durations_title">المدة لكل تقويم</string>
<string name="event_move_done">تم النقل إلى %1$s</string>
<string name="event_move_undo">تراجع</string>
<string name="event_move_undone">تم التراجع عن النقل</string>
<string name="event_move_failed">تعذّر نقل الحدث</string>
<string name="event_move_write_denied">Calendula يحتاج إلى صلاحية للكتابة لنقل الأحداث</string>
<string name="event_move_gone">لم يعد هذا الحدث موجودًا</string>
<string name="onboarding_step_counter">الخطوة %1$d من %2$d</string>
<string name="onboarding_backup_title">أحداثك تعيش فقط هنا</string>
<string name="onboarding_backup_body">لا شئ على هذا الهاتف تتم مزامنته لحساب، لذلك فقدانها سيؤدي لفقدان تقويمك. Calendula يمكنه كتابة نسخة احتياطية لك.</string>
<string name="onboarding_backup_benefit_folder_body">النسخ الاحتياطية هي ملفات .ics عادية — ضعها في مكان ما يزامن، أو على بطاقة SD.</string>
<string name="onboarding_backup_benefit_daily_title">مرة واحدة يوميًا، من تلقاء نفسه</string>
<string name="onboarding_backup_benefit_daily_body">Calendula يقم بتصدير تقويماتك المحلية في الخلفية. يمكنك تغيير مدى المرات في الإعدادات.</string>
<string name="onboarding_backup_enable_button">اختر مجلد وانسخ احتياطيًا</string>
<string name="onboarding_backup_skip_button">ليس الآن</string>
<string name="onboarding_view_title">ما الذي يجب أن يفتح أولاً؟</string>
<string name="onboarding_month_style_title">كيف يجب أن يبدو الشهر؟</string>
<string name="onboarding_view_continue_button">متابعة</string>
<string name="onboarding_back">رجوع</string>
<string name="onboarding_visibility_button">فهمت</string>
<string name="onboarding_done_title">تم إعدادك بالكامل</string>
<string name="onboarding_done_body">تقويماتك جاهزه. يمكن تغيير كل ما اخترته للتو لاحقًا في الإعدادات.</string>
<string name="onboarding_done_button">افتح تقويمي</string>
<plurals name="search_selected_count">
<item quantity="zero">تم تحديد (%d)</item>
<item quantity="one">تم تحديد %d</item>
<item quantity="two">تم تحديد %d</item>
<item quantity="few">تم تحديد %d</item>
<item quantity="many">تم تحديد %d</item>
<item quantity="other">تم تحديد %d</item>
</plurals>
<string name="search_in_descriptions">عُثِر في الأوصاف</string>
<string name="search_selection_close">إلغاء التحديد</string>
<string name="search_select_all">تحديد الكل</string>
<string name="search_delete_selected">حذف المُحدّد</string>
<plurals name="search_delete_title">
<item quantity="zero">حذف (%d) حدث؟</item>
<item quantity="one">حذف %d حدث واحد؟</item>
<item quantity="two">حذف %d حدثان؟</item>
<item quantity="few">حذف %d أحداث؟</item>
<item quantity="many">حذف %d حدث؟</item>
<item quantity="other">حذف %d حدث؟</item>
</plurals>
<plurals name="search_delete_done">
<item quantity="zero">تم حذف (%d) لا حدث</item>
<item quantity="one">تم حذف %d حدث واحد</item>
<item quantity="two">تم حذف %d حدثان</item>
<item quantity="few">تم حذف %d أحداث</item>
<item quantity="many">تم حذف %d حدث</item>
<item quantity="other">تم حذف %d حدث</item>
</plurals>
<string name="search_delete_partial">تم حذف %1$d، وتعذّر حذف %2$d</string>
<string name="search_delete_denied_partial">تم حذف %1$d، ثم تم سحب صلاحية للكتابة</string>
<plurals name="import_done_skipped">
<item quantity="zero">تم تخطي (%d) بالفعل في هذا التقويم.</item>
<item quantity="one">تم تخطي %d بالفعل في هذا التقويم.</item>
<item quantity="two">تم تخطي %d بالفعل في هذا التقويم.</item>
<item quantity="few">تم تخطي %d بالفعل في هذا التقويم.</item>
<item quantity="many">تم تخطي %d بالفعل في هذا التقويم.</item>
<item quantity="other">تم تخطي %d بالفعل في هذا التقويم.</item>
</plurals>
<plurals name="import_done_imported">
<item quantity="zero">تم استيراد (%d) لا أحداث.</item>
<item quantity="one">تم استيراد %d حدث واحد.</item>
<item quantity="two">تم استيراد %d حدثان.</item>
<item quantity="few">تم استيراد %d أحداث.</item>
<item quantity="many">تم استيراد %d حدث.</item>
<item quantity="other">تم استيراد %d حدث.</item>
</plurals>
<plurals name="import_action">
<item quantity="zero">استيراد (%d) لا أحداث</item>
<item quantity="one">استيراد %d حدث واحد</item>
<item quantity="two">استيراد %d حدثان</item>
<item quantity="few">استيراد %d أحداث</item>
<item quantity="many">استيراد %d حدث</item>
<item quantity="other">استيراد %d حدث</item>
</plurals>
<plurals name="import_event_count">
<item quantity="zero">(%d) لا أحداث في هذا الملف.</item>
<item quantity="one">%d حدث واحد في هذا الملف.</item>
<item quantity="two">%d حدثان في هذا الملف.</item>
<item quantity="few">%d أحداث في هذا الملف.</item>
<item quantity="many">%d حدث في هذا الملف.</item>
<item quantity="other">%d حدث في هذا الملف.</item>
</plurals>
<plurals name="import_title_count">
<item quantity="zero">يتم استيراد (%d) لا أحداث</item>
<item quantity="one">يتم استيراد %d حدث واحد</item>
<item quantity="two">يتم استيراد %d حدثان</item>
<item quantity="few">يتم استيراد %d أحداث</item>
<item quantity="many">يتم استيراد %d حدث</item>
<item quantity="other">يتم استيراد %d حدث</item>
</plurals>
<string name="event_move_recurring_title">نقل الحدث المتكرر</string>
<string name="event_edit_conflict_overwrite_hint">فقط الحقول التي عدلتها هي التي ستحل محل التغيير الخارجي</string>
<string name="reorder_drag_handle">اسحب لإعادة الترتيب</string>
<string name="settings_drawer_order_hint">اسحب لإعادة ترتيب طرق العرض المدرجة في قائمة التنقل.</string>
<string name="settings_drawer_order_header">قائمة التنقل</string>
<string name="settings_default_reminder_allday">أحداث يوم كامل</string>
<string name="settings_allday_reminder_time">وقت تذكير اليوم الكامل</string>
<string name="reminder_none">لا شئ</string>
<string name="settings_reliable_delivery_hint">أندرويد قد يؤخر التذكيرات لتوفير البطارية. استثنِ Calendula لكي تصل في الوقت المحدد.</string>
<string name="settings_calendar_reminders_title">التذكيرات لكل تقويم</string>
<string name="settings_license_value">MIT</string>
<string name="settings_special_dates_paused_title">متوقف</string>
<string name="settings_special_dates_calendar_hint">اضبط لون كل تقويم وإمكانية ظهوره في إعدادات التقويمات.</string>
<string name="calendars_visibility_hint">قم بإيقاف تقويم لإخفائه على هذا الجهاز — تختفي أحداثه من التطبيق ويتوقف عن تذكيرك. هذا هو مفتاح التبديل نفسه الذي تستخدمه تطبيقات التقويم الأخرى خاصتك، لذلك تقوم بإخفائه أيضًا. لا شيء يتم حذفه، ولا يتأثر أي جهاز آخر، ويمكنك إعادة تشغيله من هنا في أي وقت.</string>
<string name="settings_allday_reminder_fires_at">يتم الإشعار في %1$s</string>
<string name="settings_agenda_widget_range">مدى أداة الجدول</string>
<string name="settings_widget_size">حجم أداة الجدول</string>
<string name="settings_widgets_subtitle">أداة الجدول، عنصر الإعدادات السريعة</string>
<string name="settings_drag_to_reschedule">السحب لتعديل الموعد</string>
<string name="settings_drag_to_reschedule_summary">انقل حدثًا عن طريق سحبه إلى يوم أو وقت آخر</string>
<string name="event_move_blocked_series_end">لا يمكن نقل حدث بعد نهاية سلسلته</string>
<string name="crash_dialog_report">تقرير</string>
<string name="settings_quick_switch_hint">اختر أي طُرق العرض التي يتم التنقل بينها باستخدام الزر في أعلى اليمين، واسحب لإعادة ترتيبهم. تظل طُرق العرض التي تم إيقافها متاحة من قائمة التنقل.</string>
<string name="calendars_account_from_source">%1$s (%2$s)</string>
<string name="duration_hours_minutes">%1$s %2$s</string>
<string name="settings_widgets_hint">إعدادات لأدوات الشاشة الرئيسية وعنصر الإعدادات السريعة. أضف أداة عن طريق الضغط لفترة طويلة على شاشتك الرئيسية.</string>
<string name="import_warning_attendees">لم يتم استيراد قوائم الضيوف.</string>
<string name="settings_reliable_delivery_exempt">معفى من تحسين البطارية — تصل التذكيرات في الوقت المحدد.</string>
<string name="settings_reliable_delivery">توصيل موثوق</string>
<string name="settings_allday_reminder_time_hint">تنطلق التذكيرات لأحداث التي تستمر طوال اليوم عند %1$s</string>
<string name="agenda_range_override_hint">فقط للآن — يعيد التعيين للمدى المحفوظ خاصتك عند إعادة فتح Calendula.</string>
<string name="settings_past_events_sample_morning">مراجعة الفريق</string>
<string name="settings_past_events_sample_midday">غداء مع روبن</string>
<string name="settings_past_events_sample_evening">ممارسة الجوقة</string>
<string name="settings_special_dates_last_synced">آخر مزامنة %1$s</string>
<string name="settings_special_dates_disable_type_message">يؤدي هذا لحذف التقويم ”%1$s“ وأحداثه. سيتم فقدان أي تذكيرات أو ملاحظات أضفتها إليه.</string>
<string name="special_dates_default_title_birthday">عيد ميلاد {name} ({year})</string>
<string name="event_move_action">نقل…</string>
<string name="settings_font_specimen_heading">الخميس، 14 مايو</string>
<string name="settings_font_specimen_body">مراجعة الفريق في 10:00، ثم الغداء مع روبن في المقهى في ماركتبلاتز.</string>
<string name="settings_section_widgets">الأدوات المصغّرة والعناصر</string>
<string name="settings_widget_size_hint">مدى حجم النص الذي يعرضه جدول أداة الشاشة الرئيسية. أداة الشهر ليس لديه إعداد — تتناسب شبكته دائمًا نفسه للمساحة التي تمنحها إيّاه.</string>
<string name="settings_agenda_widget_range_hint">كم مدى يعرض جدول أداة الشاشة الرئيسية الأحداث.</string>
<string name="settings_agenda_range_hint">كم مدى يعرض شاشة الجدول الأحداث.</string>
<string name="app_name">Calendula</string>
<string name="font_atkinson_hyperlegible">Atkinson Hyperlegible</string>
<string name="settings_special_dates_show_year">إظهار السنة</string>
<string name="settings_special_dates_show_year_hint">تضمين {year} في العناوين عندما يكون معروفًا</string>
<string name="special_dates_default_title_custom">{name}</string>
<string name="special_dates_default_title_anniversary">ذكرى {name} السنوية ({year})</string>
<string name="special_dates_calendar_anniversary">الذكرات السنوية</string>
<string name="crash_dialog_message">أُغلق %1$s بشكل غير متوقع في المرة الأخيرة. يمكنك المساعدة في إصلاحه عن طريق إرسال هذا التقرير كمشكلة. سيبقى على جهازك حتى تختار مشاركته، ولا يتضمن أي بيانات شخصية أو محتوى تقويم — فقط التفاصيل الفنية أدناه.</string>
<string name="font_lora">Lora</string>
<string name="font_jetbrains_mono">JetBrains Mono</string>
<string name="onboarding_backup_benefit_folder_title">مجلد تختاره</string>
<string name="import_warning_timezone">منطقة زمنية غير معروفة ترجع إلى الخاصة بجهازك.</string>
<string name="settings_calendar_reminders_managed_hint">تعيين في تواريخ جهات الاتصال الخاصة</string>
<string name="settings_special_dates_disable_all_message">يؤدي هذا إلى حذف تقويمات تواريخ جهات الاتصال وأحداثها. سيتم فقدان أي تذكيرات أو ملاحظات أضفتها إليها.</string>
<string name="settings_special_dates_type_anniversary">الذكرات السنوية</string>
<string name="settings_special_dates_template_hint">استخدم {name} لجهة الاتصال و{year} للسنة (سنة الميلاد، أو سنة بدء الذكرى السنوية؛ مخفية عندما تكون غير معروفة).</string>
<string name="settings_special_dates_template">صيغة العنوان</string>
<string name="settings_calendar_reminders_hint">تجاوز الافتراضي لكل تقويم — بشكل منفصل للمحدد بوقت وأحداث طوال اليوم. يمكن لتقويم الاحتفاظ بالافتراضي، إسقاطه أو تعيين خاصّته.</string>
<string name="event_move_occurrence_only">سيؤدي نقل السلسلة بأكملها لتغيير الأيام التي تقع فيها، لذلك يمكن نقل هذا الحدث فقط.</string>
<string name="agenda_empty_title">أنت على اطلاع</string>
</resources>

View File

@@ -0,0 +1,474 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="backup_interval_units">
<item>Minutes</item>
<item>Hours</item>
<item>Days</item>
<item>Weeks</item>
</string-array>
<string name="app_tagline">Сучасны каляндар.</string>
<string name="state_loading">Загрузка…</string>
<string name="state_retry">Паўтарыць спробу</string>
<string name="state_failure_unknown">Узнікла нейкая праблема.</string>
<string name="state_failure_permission">Патрабуецца доступ да календара.</string>
<string name="state_failure_permission_action">Дазволіць доступ</string>
<string name="state_failure_no_calendars">Календары не сканфігураваныя.</string>
<string name="state_failure_no_calendars_action">Адкрыць сыстэмныя налады календароў</string>
<string name="state_failure_provider">Немагчыма прачытаць каляндар.</string>
<string name="permission_rationale_title">Прагляд усіх падзеяў у прыгожым выглядзе</string>
<string name="permission_rationale_body">Для паказу і кіраваньня падзеямі Calendula патрабуецца доступ да вашага календара.</string>
<string name="permission_request_button">Дазволіць доступ да календара</string>
<string name="permission_denied_title">Няма доступу да календара</string>
<string name="permission_denied_body">Calendula ня можа паказваць падзеі бяз доступу да календара.</string>
<string name="permission_open_settings_button">Адкрыць сыстэмныя налады</string>
<string name="permission_retry_button">Паўтарыць спробу</string>
<string name="permission_benefit_private_title">Распрацавана з акцэнтам на прыватнасьць</string>
<string name="permission_benefit_private_body">Бяз доступу да Інтэрнэту, без тэлемэтрыі — нічога і ніколі не пакідае ваш тэлефон.</string>
<string name="permission_benefit_sync_title">Усе вашы календары разам</string>
<string name="permission_benefit_sync_body">Google, CalDAV, лякальныя — паказваюцца ўсе, сынхранізаваныя з прыладай.</string>
<string name="permission_benefit_privacy_title">Ніякага адсочваньня, ніколі</string>
<string name="permission_benefit_privacy_body">Без тэлемэтрыі, без аналітыкі, без рэклямы.</string>
<string name="permission_privacy_footnote">Усё застаецца на прыладзе, доступ да Інтэрнэту не патрабуецца</string>
<string name="month_prev">Папярэдні месяц</string>
<string name="month_next">Наступны месяц</string>
<string name="month_today_action">Сёньня</string>
<string name="month_more_actions">Іншыя дзеяньні</string>
<string name="month_open_menu">Адкрыць мэню</string>
<string name="month_action_settings">Налады</string>
<string name="month_a11y_today_prefix">Сёньня</string>
<string name="week_today_action">Гэты тыдзень</string>
<string name="week_number_label">Тдз.</string>
<string name="day_today_action">Сёньня</string>
<string name="event_detail_back">Назад</string>
<string name="event_detail_edit">Праўка</string>
<string name="event_detail_delete">Выдаліць</string>
<string name="event_detail_share">Падзяліцца</string>
<string name="event_detail_duplicate">Дублікат</string>
<string name="event_share_chooser_title">Падзяліцца падзеяй</string>
<string name="event_share_failed">Не атрымалася падзяліцца гэтай падзеяй.</string>
<string name="event_delete_title">Выдаліць падзею?</string>
<string name="event_delete_body">Падзея выдаленая з вашага календара і з усіх прыладаў, зь якімі адбываецца сынхранізацыя.</string>
<string name="event_delete_recurring_title">Выдаліць пэрыядычную падзею</string>
<string name="event_delete_option_occurrence">Толькі гэтую падзею</string>
<string name="event_delete_option_following">Гэтую і ўсе наступныя падзеі</string>
<string name="event_delete_option_series">Усе падзеі ў пасьлядоўнасьці</string>
<string name="event_edit_recurring_title">Рэдагаваць пэрыядычную падзею</string>
<string name="event_delete_failed">Не атрымалася выдаліць падзею</string>
<string name="event_delete_write_denied">Для выдаленьня падзеяў Calendula патрабуецца дазвол на запіс</string>
<string name="event_move_action">Перанесці…</string>
<string name="event_move_recurring_title">Перанесці пэрыядычную падзею</string>
<string name="event_move_occurrence_only">Перанос усёй пасьлядоўнасьці зьменіць дні, на якія яна прыпадае, таму перанесці можна толькі гэтую падзею.</string>
<string name="event_move_done">Перанесена на %1$s</string>
<string name="event_move_undo">АдрабІць</string>
<string name="event_move_undone">Перанос скасаваны</string>
<string name="event_move_failed">Не атрымалася перанесьці падзею</string>
<string name="event_move_write_denied">Для пераносу падзеяў Calendula патрабуецца дазвол на запіс</string>
<string name="event_move_gone">Гэтай падзеі больш не існуе</string>
<string name="event_move_blocked_series_end">Нельга перанесьці падзею на дату пасьля заканчэньня яе пасьлядоўнасьці</string>
<string name="dialog_cancel">Адмена</string>
<string name="dialog_ok">ОК</string>
<string name="event_edit_new_title">Новая падзея</string>
<string name="event_edit_close">Закрыць</string>
<string name="event_edit_save">Захаваць</string>
<string name="event_edit_title_hint">Дадаць загаловак</string>
<string name="event_edit_managed_hint">Кіруецца «%1$s» — загаловак, дата і паўтор сынхранізуюцца з вашымі кантактамі. Напаміны, месцазнаходжаньне і нататкі даступныя вам для рэдагаваньня.</string>
<string name="event_edit_starts">Пачатак</string>
<string name="event_edit_ends">Заканчэньне</string>
<string name="event_edit_error_end_before_start">Заканчваецца раней, чым пачынаецца</string>
<string name="event_edit_error_no_calendar">Няма календара, у які дазволены запіс</string>
<string name="event_edit_save_failed">Не атрымалася захаваць падзею</string>
<string name="event_edit_write_denied">Для стварэньня падзеяў Calendula патрабуецца дазвол на запіс</string>
<string name="event_edit_more_fields">Дадатковыя палі</string>
<string name="event_edit_add">Дадаць</string>
<string name="event_edit_add_reminder">Дадаць напамін</string>
<string name="event_edit_remove_reminder">Выдаліць напамін</string>
<string name="event_edit_attendees">Наведвальнікі</string>
<string name="event_edit_add_guest">Дадаць наведвальніка</string>
<string name="event_edit_add_guest_hint">Дадаць наведвальніка праз электронную пошту…</string>
<string name="event_edit_add_guest_from_contacts">Дадаць з кантактаў</string>
<string name="event_edit_location_from_contacts">Выбраць адрас з кантактаў</string>
<string name="event_edit_remove_guest">Выдаліць наведвальніка</string>
<string name="event_edit_attendee_required">Неабходны</string>
<string name="event_edit_attendee_optional">Неабавязковы</string>
<string name="event_edit_attendees_note_synced">Calendula не рассылае запрашэньні. Наведвальнікі могуць атрымаць электронныя лісты з вашага ўліковага запісу календара пры сынхранізацыі.</string>
<string name="event_edit_attendees_note_local">Захавана на гэтай прыладзе. Ніхто не атрымаў абвесткі.</string>
<string name="event_edit_reminder_custom">Карыстальніцкі</string>
<string name="reminder_unit_minutes">хв.</string>
<string name="reminder_unit_hours">гадз.</string>
<string name="reminder_unit_days">сут.</string>
<string name="reminder_unit_weeks">тыд.</string>
<string name="event_edit_availability">Даступнасьць</string>
<string name="event_edit_visibility">Бачнасьць</string>
<string name="event_edit_timezone_device">Часавы пояс прылады</string>
<string name="event_edit_timezone_device_summary">Сочыць за вашым месцазнаходжаньнем</string>
<string name="event_edit_timezone_search">Пошук часавых паясоў</string>
<string name="event_edit_timezone_recent">Апошнія</string>
<string name="event_edit_timezone_all">Усе часавыя паясы</string>
<string name="event_edit_timezone_none">Няма часавага поясу, які адпавядае «%1$s»</string>
<string name="event_edit_timezone_local_time">%1$s паводле вашага часу</string>
<string name="event_edit_color">Колер</string>
<string name="event_edit_color_default">Колер календара</string>
<string name="event_edit_color_custom">Карыстальніцкі колер</string>
<string name="event_edit_color_reset">Скід</string>
<string name="event_edit_color_unsupported">Недаступна для гэтага календара</string>
<string name="event_edit_color_unsupported_hint">Для гэтага календара не апублікаваны набор колераў. Для падобных календароў у наладах можна задаць карыстальніцкія колеры.</string>
<string name="event_edit_color_sync_warning">Пры наступнай сынхранізацыі гэты каляндар можа страціць або перазапісаць колер.</string>
<string name="event_edit_conflict_title">Падзея зьмянілася ў іншым месцы</string>
<string name="event_edit_conflict_body">Падчас рэдагаваньня вамі гэтая падзея была зьменена ў выніку сынхранізацыі або іншай праграмай. Што адбудзецца з вашымі зьменамі?</string>
<string name="event_edit_conflict_overwrite">Захаваць мае змены</string>
<string name="event_edit_conflict_overwrite_hint">Толькі адрэдагаваныя вамі палі перазапішуць зьнешнія зьмены</string>
<string name="event_edit_conflict_discard">Адкінуць мае зьмены</string>
<string name="event_edit_conflict_discard_hint">Падзея застаецца такой, якой яна ёсьць зараз</string>
<string name="event_edit_gone_title">Падзея выдалена</string>
<string name="event_edit_gone_body">Гэтая падзея ўжо выдаленая, напрыклад на іншай прыладзе. Вашыя зьмены болей немагчыма захаваць.</string>
<string name="import_reminder_prompt_title">Ужыць напамін па змаўчаньні?</string>
<string name="import_reminder_prompt_body_none">Гэтая падзея была імпартаваная без напаміну.</string>
<plurals name="import_reminder_prompt_body_existing">
<item quantity="one">"Гэтая падзея была імпартаваная з наст. колькасьцю напамінаў: %1$d."</item>
<item quantity="few">"Гэтая падзея была імпартаваная з наст. колькасьцю напамінаў: %1$d."</item>
<item quantity="many">"Гэтая падзея была імпартаваная з наст. колькасьцю напамінаў: %1$d."</item>
<item quantity="other">"Гэтая падзея была імпартаваная з наст. колькасьцю напамінаў: %1$d."</item>
</plurals>
<string name="import_reminder_prompt_apply">Ужыць змаўчаньні</string>
<string name="import_reminder_prompt_keep">Пакінуць як ёсьць</string>
<string name="event_edit_recurrence_none">Не паўтараецца</string>
<string name="event_edit_recurrence_custom">Карыстальніцкі варыянт</string>
<string name="event_edit_recurrence_every">Кожн.</string>
<string name="recurrence_unit_days">сут.</string>
<string name="recurrence_unit_weeks">тыд.</string>
<string name="recurrence_unit_months">мес.</string>
<string name="recurrence_unit_years">год/гады</string>
<string name="event_edit_recurrence_incomplete">Увядзеце лік ад 1 да 999</string>
<string name="event_edit_recurrence_next">Наступ.: %1$s</string>
<string name="event_edit_recurrence_next_none">Гэтае правіла ніколі не паўтараецца</string>
<string name="event_edit_recurrence_ends">Заканчэньне</string>
<string name="event_edit_recurrence_end_never">Ніколі</string>
<string name="event_edit_recurrence_end_until">На дату</string>
<string name="event_edit_recurrence_end_count">Пасьля некалькіх разоў</string>
<string name="event_edit_recurrence_times">разы(оў)</string>
<string name="app_name">Calendula</string>
<string name="event_edit_error_recurrence_ends_before_start">Паўторы заканчваюцца да пачатку падзеі</string>
<string name="event_availability_busy">Заняты</string>
<string name="event_access_default">Па змаўчаньні</string>
<string name="event_access_public">Публічны</string>
<string name="event_detail_all_day">Цэлы дзень</string>
<string name="event_detail_calendar">Каляндар</string>
<string name="event_detail_calendar_unknown">Невядомы каляндар</string>
<string name="event_detail_location">Месцазнаходжаньне</string>
<string name="event_detail_description">Апісаньне</string>
<string name="event_detail_attendees">Удзельнікі</string>
<string name="event_detail_recurrence">Паўтор</string>
<string name="event_detail_recurring">Паўтаральная падзея</string>
<string name="recurrence_daily">Кожны дзень</string>
<string name="recurrence_weekly">Кожны тыдзень</string>
<string name="recurrence_monthly">Кожны месяц</string>
<string name="recurrence_yearly">Кожны год</string>
<string name="recurrence_every_n_days">Кожныя %1$d сут.</string>
<string name="recurrence_every_n_weeks">Кожныя %1$d тыд.</string>
<string name="recurrence_every_n_months">Кожныя %1$d мес.</string>
<string name="recurrence_every_n_years">Кожныя %1$d гады(оў)</string>
<string name="recurrence_on_days">%1$s у %2$s</string>
<string name="recurrence_with_until">з %1$s да %2$s</string>
<string name="recurrence_with_count">%1$s, %2$d разы(оў)</string>
<string name="event_detail_not_found">Гэтай падзеі больш не існуе.</string>
<string name="event_attendee_accepted">Прынята</string>
<string name="event_attendee_declined">Адхілена</string>
<string name="event_attendee_tentative">Пад пытаньнем</string>
<string name="event_attendee_needs_action">Няма адказу</string>
<string name="event_attendee_unknown"></string>
<string name="event_detail_reminders">Напаміны</string>
<string name="event_detail_timezone">Часавы пояс</string>
<string name="event_status_tentative">Пад пытаньнем</string>
<string name="event_status_cancelled">Скасавана</string>
<string name="event_availability_free">Свабодна</string>
<string name="event_access_private">Прыватна</string>
<string name="event_access_confidential">Канфідэнцыйна</string>
<string name="event_access_default_summary">Што звычайна робіць гэты каляндар</string>
<string name="event_access_public_summary">Поўныя зьвесткі могуць бачыць усе, хто мае доступ</string>
<string name="event_access_private_summary">Іншыя бачаць толькі тое, што вы занятыя</string>
<string name="event_access_confidential_summary">Пазначана як канфідэнцыйнае: што гэта значыць, залежыць ад уліковага запісу календара</string>
<string name="event_attendee_organizer">Арганізатар</string>
<string name="event_attendee_optional">Неабавязкова</string>
<string name="event_attendee_resource">Рэсурс</string>
<string name="event_detail_self_response">Ваш адказ: %1$s</string>
<string name="reminder_at_time">На момант падзеі</string>
<string name="reminder_default">Напамін па змаўчаньні</string>
<plurals name="reminder_minutes">
<item quantity="one">За %d хвіліну</item>
<item quantity="few">За %d хв.</item>
<item quantity="many">За %d хв.</item>
<item quantity="other">За %d хв.</item>
</plurals>
<plurals name="reminder_hours">
<item quantity="one">За %d гадзіну</item>
<item quantity="few">За %d гадз.</item>
<item quantity="many">За %d гадз.</item>
<item quantity="other">За %d гадз.</item>
</plurals>
<plurals name="reminder_days">
<item quantity="one">За %d суткі</item>
<item quantity="few">За %d сут.</item>
<item quantity="many">За %d сут.</item>
<item quantity="other">За %d сут.</item>
</plurals>
<plurals name="reminder_weeks">
<item quantity="one">За %d тыдзень</item>
<item quantity="few">За %d тыдз.</item>
<item quantity="many">За %d тыдз.</item>
<item quantity="other">За %d тыдз.</item>
</plurals>
<plurals name="duration_minutes">
<item quantity="one">%d хвіліна</item>
<item quantity="few">%d хв.</item>
<item quantity="many">%d хв.</item>
<item quantity="other">%d хв.</item>
</plurals>
<plurals name="duration_hours">
<item quantity="one">%d гадзіна</item>
<item quantity="few">%d гадз.</item>
<item quantity="many">%d гадз.</item>
<item quantity="other">%d гадз.</item>
</plurals>
<plurals name="duration_days">
<item quantity="one">%d суткі</item>
<item quantity="few">%d сут.</item>
<item quantity="many">%d сут.</item>
<item quantity="other">%d сут.</item>
</plurals>
<string name="duration_hours_minutes">%1$s гадз. %2$s хв.</string>
<string name="duration_custom_max">Макс. %1$s</string>
<plurals name="duration_weeks">
<item quantity="one">%d тыдзень</item>
<item quantity="few">%d тыдз.</item>
<item quantity="many">%d тыдз.</item>
<item quantity="other">%d тыдз.</item>
</plurals>
<string name="event_untitled">(без загалоўка)</string>
<string name="onboarding_step_counter">Крок %1$d з %2$d</string>
<string name="onboarding_backup_title">Вашы падзеі знаходзяцца толькі тут</string>
<string name="onboarding_backup_body">На гэтым тэлефоне не адбываецца сынхранізацыя зь якім-небудзь уліковым запісам, і страта азначае страту вашага календара. Calendula можа зрабіць рэзервую копію для вас.</string>
<string name="onboarding_backup_benefit_folder_title">Каталёг на ваш выбар</string>
<string name="onboarding_backup_benefit_folder_body">Рэзэрвовыя копіі — гэта звычайныя файлы .ics. Захоўвайце іх у месцы, дзе яны сынхранізуюцца, або на SD-карце.</string>
<string name="onboarding_backup_benefit_daily_title">Раз за суткі, аўтаматычна</string>
<string name="onboarding_backup_benefit_daily_body">Calendula робіць экспарт вашых лякальных календароў у фонавым рэжыме. Зьмяніць пэрыядычнасьць можна ў наладах.</string>
<string name="onboarding_backup_enable_button">Выбраць каталёг і зрабіць рэзэрвовую копію</string>
<string name="onboarding_backup_skip_button">Ня зараз</string>
<string name="onboarding_view_title">Што павінна адкрыцца першым?</string>
<string name="onboarding_month_style_title">Як мусіць выглядаць месяц?</string>
<string name="onboarding_view_continue_button">Далей</string>
<string name="onboarding_back">Назад</string>
<string name="onboarding_visibility_button">Зразумела</string>
<string name="onboarding_done_title">Усё зроблена</string>
<string name="onboarding_done_body">Вашы календары гатовыя. Любы выбар, зроблены зараз, можна пазьней зьмяніць у наладах.</string>
<string name="onboarding_done_button">Адкрыць мой каляндар</string>
<string name="reminder_channel_name">Напаміны пра падзеі</string>
<string name="reminder_channel_description">Абвесткі ў час напамінаў пра вашы падзеі</string>
<string name="reminder_onboarding_title">Не прапускаць ніводную падзею</string>
<string name="reminder_onboarding_body">Сыстэма Android сама не паказвае напаміны — гэта мусіць рабіць дадатак календара. Хай гэта робіць Calendula.</string>
<string name="reminder_benefit_delivery_title">Напаміны, дастаўленыя</string>
<string name="reminder_benefit_delivery_body">Кожны напамін пра вашы падзеі прыходзіць у выглядзе своечасовай абвесткі.</string>
<string name="reminder_benefit_duplicates_title">Карыстаецеся яшчэ адным дадаткам календара?</string>
<string name="reminder_benefit_duplicates_body">Калі іншы дадатак таксама выдае напаміны, вы будзеце бачыць іх двойчы — адключэце іх там або тут.</string>
<string name="reminder_benefit_reversible_title">Гэта можна зьмяніць у які заўгодна час</string>
<string name="reminder_benefit_reversible_body">Перамыкач знаходзіцца ў наладах, у разьдзеле «Абвесткі».</string>
<string name="reminder_onboarding_enable_button">Уключыць напаміны</string>
<string name="reminder_onboarding_skip_button">Ня зараз</string>
<string name="reminder_action_snooze">Адкласьці</string>
<string name="reminder_action_dismiss">Адхіліць</string>
<string name="reminder_day_tomorrow">Заўтра</string>
<string name="reminder_day_yesterday">Учора</string>
<string name="view_month">Месяц</string>
<string name="view_week">Тыдзень</string>
<string name="view_day">Дзень</string>
<string name="view_agenda">Расклад</string>
<string name="view_section">Выгляд</string>
<string name="drawer_jump_to_date">Пераход да даты</string>
<string name="agenda_today_action">Сёньня</string>
<string name="agenda_header_today">Сёньня</string>
<string name="agenda_header_tomorrow">Заўтра</string>
<string name="agenda_empty_title">Усё завершана</string>
<string name="agenda_no_more_today">Сёньня больш няма падзеяў</string>
<string name="agenda_span_starts">Пачатак: %1$s</string>
<string name="agenda_span_ends">Заканчэньне: %1$s</string>
<string name="today_jump_action">Перайсці на сёньня</string>
<string name="search_action">Пошук</string>
<string name="search_hint">Пошук падзеяў</string>
<string name="search_back">Назад</string>
<string name="search_clear">Ачысьціць</string>
<string name="search_idle_hint">Пошук падзеяў па загалоўках, месцазнаходжаньні або зацемках.</string>
<string name="search_empty">Няма падзей, якія адпавядаюць «%1$s».</string>
<plurals name="search_selected_count">
<item quantity="one">Вылучана: %d</item>
<item quantity="few">Вылучана: %d</item>
<item quantity="many">Вылучана: %d</item>
<item quantity="other">Вылучана: %d</item>
</plurals>
<string name="search_in_descriptions">Знойдзена ў апісаньнях</string>
<string name="search_selection_close">Зьняць вылучэньне</string>
<string name="search_select_all">Вылучыць усё</string>
<string name="search_delete_selected">Выдаліць вылучанае</string>
<plurals name="search_delete_title">
<item quantity="one">Выдаліць падзеі (%d)?</item>
<item quantity="few">Выдаліць падзеі (%d)?</item>
<item quantity="many">Выдаліць падзеі (%d)?</item>
<item quantity="other">Выдаліць падзеі (%d)?</item>
</plurals>
<plurals name="search_delete_done">
<item quantity="one">Выдаленая %d падзея</item>
<item quantity="few">Выдалена падзеяў: %d</item>
<item quantity="many">Выдалена падзеяў: %d</item>
<item quantity="other">Выдалена падзеяў: %d</item>
</plurals>
<string name="search_delete_partial">Выдалена %1$d, не атрымалася выдаліць %2$d</string>
<string name="search_delete_denied_partial">Выдалена %1$d, затым быў скасаваны дазвол на запіс</string>
<string name="widget_agenda_title">Наступнае</string>
<string name="widget_agenda_label">Расклад Calendula</string>
<string name="widget_month_label">Месяц Calendula</string>
<string name="widget_refresh">Абнавіць</string>
<string name="widget_new_event">Новая падзея</string>
<string name="widget_needs_permission">Адкрыйце Calendula, каб дазволіць доступ да календара</string>
<string name="widget_prev_month">Папярэдні месяц</string>
<string name="widget_next_month">Наступны месяц</string>
<string name="widget_today">Сёньня</string>
<string name="filter_title">Календары</string>
<string name="settings_title">Налады</string>
<string name="settings_back">Назад</string>
<string name="back">Назад</string>
<string name="settings_section_appearance">Афармленьне</string>
<string name="settings_theme">Тэма</string>
<string name="settings_theme_system">Сыстэмная</string>
<string name="settings_theme_light">Сьветлая</string>
<string name="settings_theme_dark">Цёмная</string>
<string name="settings_theme_hint">Сьветлы або цёмны выгляд дадатку. Выбар будзе ўжыты адразу ж.</string>
<string name="settings_theme_system_summary">Зараз: %1$s</string>
<string name="settings_default_view">Выгляд па змаўчаньні</string>
<string name="settings_dynamic_color">Дынамічны колер</string>
<string name="settings_dynamic_color_unavailable">Патрабуецца сыстэма Android вэрсіі 12 або навейшай</string>
<string name="settings_soften_colors">Гарманізацыя колераў календара</string>
<string name="settings_soften_colors_summary">Вытрымліваць адценьне і яркасьць кожнага календара, каб кожная падзея заставалася чытэльнай, а колеры спалучаліся. Адключыць, каб паказваць зыходныя колеры з крыніцы календара.</string>
<string name="settings_font_headings">Шрыфт загалоўкаў</string>
<string name="settings_font_body">Шрыфт тэксту</string>
<string name="settings_font_system">Сыстэмны</string>
<string name="font_atkinson_hyperlegible">Atkinson Hyperlegible</string>
<string name="font_lora">Lora</string>
<string name="font_jetbrains_mono">JetBrains Mono</string>
<string name="settings_font_choose_file">Выбраць файл…</string>
<string name="settings_font_custom_selected">Карыстальніцкі</string>
<string name="settings_font_import_failed">Не атрымалася прачытаць файлы шрыфту</string>
<string name="settings_font_specimen_heading">Чацьвер, 14 траўня</string>
<string name="settings_font_specimen_body">Камандны разгляд а 10:00, затым абед з Робінам у кавярні на Марктпляц.</string>
<string name="settings_week_start">Тыдзень пачынаецца з</string>
<string name="settings_week_start_auto">Аўтаматычна</string>
<string name="settings_week_start_auto_summary">Зараз: %1$s</string>
<string name="settings_week_numbers">Нумары тыдняў</string>
<string name="settings_week_numbers_summary">Паказваць нумары тыдняў календара ў рэжыме прагляду месяца</string>
<string name="settings_today_toolbar">Кнопка «Сёньня» на панэлі</string>
<string name="settings_today_toolbar_summary">Паказваць кнопку пераходу да сёньняшняга дня на панэлі прыладаў замест плаваючай кнопкі</string>
<string name="settings_app_name">Назва дадатку</string>
<string name="settings_app_name_summary">Паказаць Calendula як «Каляндар» на панэлі запуску. На панэлі запуску зьменіцца толькі назва. Пасьля пераключэньня можа зьмяніцца месцазнаходжаньне абразка.</string>
<string name="settings_time_format">Фармат часу</string>
<string name="settings_time_format_auto">Аўта</string>
<string name="settings_time_format_12h">12-гадз. (2:00 пасьля абеду)</string>
<string name="settings_time_format_24h">24-гадз. (14:00)</string>
<string name="settings_time_format_auto_summary">Сыстэмная налада: %1$s</string>
<string name="settings_hour_lines">Лініі гадзінаў</string>
<string name="settings_hour_lines_summary">Паказваць раздзяляльную лінію для кожнай гадзіны ў рэжыме прагляду тыдня і дня</string>
<string name="settings_timeline_scale">Вышыня гадзіны</string>
<string name="settings_timeline_scale_hint">Колькі прасторы па вэртыкалі даецца на гадзіну ў рэжыме прагляду тыдня і дня. Гэтая налада агульная для абодвух рэжымаў прагляду. Таксама можна двума пальцамі перамяшчаць лінію, каб задаць нейкую іншую вышыню для гадзінаў.</string>
<string name="timeline_scale_fit_day">Цэлыя суткі</string>
<string name="timeline_scale_fit_day_summary">На экране ўсе 24 гадзіны, прагортваць ня трэба</string>
<string name="timeline_scale_compact">Кампактна</string>
<string name="timeline_scale_compact_summary">На экране больш гадзінаў, блёкі меншыя</string>
<string name="timeline_scale_regular">Звычайна</string>
<string name="timeline_scale_regular_summary">Стандартны інтэрвал</string>
<string name="timeline_scale_comfortable">Камфортна</string>
<string name="timeline_scale_comfortable_summary">Большыя блёкі, больш прагорткі</string>
<string name="timeline_scale_custom">Карыстальніцкі варыянт</string>
<string name="timeline_scale_custom_summary">Вышыня ў выніку перамяшчэньня лініі пальцамі</string>
<string name="settings_dim_completed">Зацямняць завершаныя падзеі</string>
<string name="settings_dim_completed_summary">Зацямняць завершаныя падзеі ў рэжыме прагляду месяца і тыдня</string>
<string name="settings_drag_to_reschedule">Перацягваць, каб пераплянаваць</string>
<string name="settings_drag_to_reschedule_summary">Перанесьці падзею, перацягнуўшы яе на іншы дзень або час</string>
<string name="settings_past_events">Мінулыя падзеі</string>
<string name="settings_past_events_show">Паказваць</string>
<string name="settings_past_events_dim">Зацямняць</string>
<string name="settings_past_events_hide">Хаваць</string>
<string name="settings_past_events_sample_morning">Камандны разгляд</string>
<string name="settings_past_events_sample_midday">Абед з Робінам</string>
<string name="settings_past_events_sample_evening">Рэпетыцыя ў хоры</string>
<string name="settings_agenda_header">Расклад</string>
<string name="settings_agenda_range">Дыяпазон раскладу</string>
<string name="settings_agenda_range_hint">Наколькі далёка наперад паказваюцца падзеі на экране раскладу.</string>
<string name="settings_agenda_widget_range">Дыяпазон віджэта раскладу</string>
<string name="settings_agenda_widget_range_hint">Наколькі далёка наперад паказваюцца падзеі раскладу на галоўным экране віджэта.</string>
<string name="settings_widget_size">Памер віджэта раскладу</string>
<string name="settings_widget_size_hint">Наколькі вялікім выглядае галоўны экран, на які выводзіцца тэкст раскладу. Віджэт у рэжыме прагляду месяца ня мае падобнага парамэтра, ягоная сетка займае ўсю выдзеленую прастору.</string>
<string name="settings_widget_size_small">Дробны</string>
<string name="settings_widget_size_medium">Сярэдні</string>
<string name="settings_widget_size_large">Буйны</string>
<string name="settings_widget_size_extra_large">Вельмі буйны</string>
<string name="settings_widget_size_summary">Тэкст падзеі %1$d%%</string>
<string name="settings_agenda_show_today">Заўсёды паказваць сёньняшні дзень</string>
<string name="settings_agenda_show_today_hint">Сёньняшні дзень застаецца ў верхняй частцы раскладу і віджэта, нават калі падзеяў больш няма.</string>
<string name="settings_agenda_range_bar">Панэль дыяпазона</string>
<string name="settings_agenda_range_bar_hint">Паказваць уверсе панэль раскладу зь пералікам паказаных дат і кнопку для пераключэньня дыяпазону для сэсіі</string>
<string name="agenda_range_day">Сёньня</string>
<string name="agenda_range_this_week">Гэты тыдзень</string>
<string name="agenda_range_this_month">Гэты месяц</string>
<string name="agenda_range_week">Наступныя 7 сутак</string>
<string name="agenda_range_month">Наступныя 30 сутак</string>
<string name="agenda_range_custom">Свой варыянт…</string>
<string name="agenda_range_custom_hint">Суткі</string>
<string name="agenda_range_override_hint">Толькі цяпер. Пры паўторным адкрыцьці Calendula дыяпазон скінецца да захаванага значэньня.</string>
<string name="agenda_range_showing_label">Паказаныя ўсе будучыя падзеі для</string>
<plurals name="agenda_range_days">
<item quantity="one">%d суткі</item>
<item quantity="few">%d сут.</item>
<item quantity="many">%d сут.</item>
<item quantity="other">%d сут.</item>
</plurals>
<string name="settings_section_views">Рэжымы прагляду</string>
<string name="settings_special_dates_type_birthday">Дні нараджэньня</string>
<string name="settings_special_dates_type_anniversary">Юбілейныя даты</string>
<string name="settings_special_dates_type_custom">Іншыя даты</string>
<string name="settings_special_dates_template">Фармат загалоўка</string>
<string name="settings_special_dates_template_hint">Ужываць {name} для імя кантакту і {year} для года (год нараджэньня або год пачатку юбілейнай падзеі; схаваны, калі невядомы).</string>
<string name="settings_special_dates_reminders">Напаміны</string>
<string name="settings_special_dates_show_year">Паказаць год</string>
<string name="settings_special_dates_show_year_hint">Пазначаць {year} (год) у загалоўках, калі вядомы</string>
<string name="settings_special_dates_sync_now">Сынхранізаваць зараз</string>
<string name="settings_special_dates_never_synced">Пакуль не сынхранізавана</string>
<string name="settings_special_dates_last_synced">Апошняя сынхранізацыя %1$s</string>
<string name="settings_special_dates_calendar_hint">Задаць у наладах колер і бачнасьць кожнага календара.</string>
<string name="settings_calendar_reminders_managed_hint">Задаць асаблівыя даты кантактаў</string>
<string name="settings_special_dates_paused_title">Прыпынена</string>
<string name="settings_special_dates_paused_hint">Calendula больш ня можа чытаць вашы кантакты, таму гэтыя календары не абнаўляюцца.</string>
<string name="settings_special_dates_grant">Дазволіць доступ</string>
<string name="settings_special_dates_disable_title">Адключыць даты кантактаў?</string>
<string name="settings_special_dates_disable_all_message">Пры гэты выдаляцца календары з датамі кантактаў і адпаведныя падзеі. Даданыя да іх напаміны або заўвагі зьнікнуць.</string>
<string name="settings_special_dates_disable_type_message">Пры гэтым выдаліцца каляндар «%1$s» зь ягонымі падзеямі. Даданыя да іх напаміны або зацемкі зьнікнуць.</string>
<string name="settings_special_dates_disable_confirm">Адключыць</string>
<string name="dialog_save">Захаваць</string>
<string name="settings_section_about">Аб дадатку</string>
<string name="settings_license">Ліцэнзія</string>
<string name="settings_license_value">MIT</string>
<string name="settings_about_author">аўтар: Жан-Люк Макіёла</string>
<string name="settings_about_source">Зыходны код</string>
<string name="settings_about_privacy">Палітыка канфідэнцыйнасьці</string>
<string name="settings_about_support">Падтрымка распрацоўкі</string>
<string name="settings_about_version">Вэрсія %1$s</string>
<string name="settings_about_logo_desc">Абразок дадатку Calendula</string>
<string name="settings_report_problem">Паведаміць пра праблему</string>
<string name="settings_report_problem_hint">Адправіць справаздачу аб краху або адкрыць сыстэму адсочваньня праблемаў</string>
<string name="calendars_title">Календары</string>
<string name="calendars_local_header">Вашыя календары</string>
<string name="calendars_local_empty">Пакуль няма лякальных календароў. Стварыць, каб захоўваць падзеі толькі на гэтай прыладзе.</string>
<string name="calendars_add">Дадаць каляндар</string>
<string name="calendars_visibility_hint">Адключыць каляндар, каб схаваць яго на гэтай прыладзе — падзеі і напаміны зь яго зьнікнуць з дадатку. Гэты перамыкач выкарыстоўваюць іншыя дадаткі календара, таму яны таксама яго схаваюць. Нічога не выдаляецца, гэта не ўплывае на іншыя прылады, і вы можаце ўключыць яго зноў тут у які заўгодна час.</string>
<string name="calendars_visibility_a11y">Паказаць «%1$s»</string>
<string name="calendars_visibility_notice_title">Некаторыя календары адключаныя</string>
<string name="calendars_visibility_notice_message">Calendula паказвае календары, уключаныя на гэтай прыладзе, а некаторыя з вашых адключаны. Каб убачыць падзеі ў іх, уключэце іх у разьдзеле «Налады» → «Календары».</string>
<string name="calendar_picker_missing_title">Не хапае календара?</string>
<string name="calendar_picker_missing_summary">Магчыма ён адключаны, у рэжыме толькі чытаньня, або запоўнены з вашых кантактаў — кіруйце календарамі тут.</string>
<string name="calendars_state_read_only">Толькі чытаньне</string>
</resources>

View File

@@ -0,0 +1,335 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="backup_interval_units">
<item>Minutes</item>
<item>Hours</item>
<item>Days</item>
<item>Weeks</item>
</string-array>
<string name="app_name">Calendula</string>
<string name="app_tagline">Moderní kalendář.</string>
<string name="state_loading">Načítá se…</string>
<string name="state_retry">Opakovat</string>
<string name="state_failure_unknown">Něco se pokazilo.</string>
<string name="state_failure_permission">Je nutný přístup ke kalendáři.</string>
<string name="state_failure_permission_action">Udělit přístup</string>
<string name="state_failure_no_calendars">Nejsou konfigurovány žádné kalendáře.</string>
<string name="permission_request_button">Udělit přístup ke kalendáři</string>
<string name="permission_rationale_body">Calendula potřebuje přístup k vašemu kalendáři, aby mohla zobrazovat a spravovat vaše události.</string>
<string name="permission_benefit_sync_title">Všechny vaše kalendáře na jednom místě</string>
<string name="permission_benefit_sync_body">Google, CalDAV, lokální kalendáře — cokoliv synchronizovaného se zařízením.</string>
<string name="permission_benefit_private_title">Navrženo pro soukromí</string>
<string name="permission_benefit_private_body">Žádný přístup k internetu, žádná telemetrie — nikdy nedojde k úniku dat z vašeho telefonu.</string>
<string name="onboarding_step_counter">Krok %1$d z %2$d</string>
<string name="permission_rationale_title">Prohlížejte všechny události, hezky a přehledně</string>
<string name="permission_denied_title">Přístup ke kalendáři byl odepřen</string>
<string name="permission_denied_body">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.</string>
<string name="state_failure_no_calendars_action">Otevřít nastavení kalendáře systému</string>
<string name="permission_open_settings_button">Otevřít nastavení systému</string>
<string name="permission_retry_button">Zkusit to znovu</string>
<string name="state_failure_provider">Nepodařilo se načíst kalendář.</string>
<string name="permission_benefit_privacy_title">Žádné sledování, nikdy</string>
<string name="permission_benefit_privacy_body">Žádná telemetrie, žádné analytické nástroje, bez reklam.</string>
<string name="permission_privacy_footnote">Zůstává na vašem zařízení · bez přístupu k internetu</string>
<string name="month_prev">Předchozí měsíc</string>
<string name="month_next">Následující měsíc</string>
<string name="month_today_action">Dnes</string>
<string name="month_more_actions">Další akce</string>
<string name="month_open_menu">Otevřít nabídku</string>
<string name="month_action_settings">Nastavení</string>
<string name="month_a11y_today_prefix">Dnes</string>
<string name="week_today_action">Tento týden</string>
<string name="week_number_label">týd.</string>
<string name="day_today_action">Dnes</string>
<string name="event_detail_back">Zpět</string>
<string name="event_detail_edit">Upravit</string>
<string name="event_detail_delete">Odstranit</string>
<string name="event_detail_share">Sdílet</string>
<string name="event_detail_duplicate">Kopírovat</string>
<string name="event_share_chooser_title">Sdílet událost</string>
<string name="event_share_failed">Tuto událost nelze sdílet.</string>
<string name="event_delete_title">Odstranit událost?</string>
<string name="event_delete_body">Událost je odstraněna z kalendáře i ze všech synchronizovaných zařízení.</string>
<string name="event_delete_recurring_title">Odstranit opakující se událost</string>
<string name="event_delete_option_occurrence">Pouze tuto událost</string>
<string name="event_delete_option_following">Tuto a všechny následující události</string>
<string name="event_delete_option_series">Všechny události v sérii</string>
<string name="event_edit_recurring_title">Upravit opakující se událost</string>
<string name="event_delete_failed">Tuto událost nelze odstranit</string>
<string name="event_delete_write_denied">Calendula potřebuje oprávnění k zápisu, aby mohla odstraňovat události</string>
<string name="event_move_action">Přesunout…</string>
<string name="event_move_recurring_title">Přesunout opakující se událost</string>
<string name="event_move_occurrence_only">Přesunutí celé série by rozhodilo pořadí dnů, na které série připadá, přesunout lze tedy pouze tuto událost.</string>
<string name="event_move_done">Přesunuto do %1$s</string>
<string name="event_move_undo">Vrátit</string>
<string name="event_move_undone">Vráceno zpět</string>
<string name="event_move_failed">Událost nelze přesunout</string>
<string name="event_move_write_denied">Calendula k přesouvání události vyžaduje oprávnění k zápisu</string>
<string name="event_move_gone">Tato událost již neexistuje</string>
<string name="event_move_blocked_series_end">Událost nelze přesunout za konec dané série</string>
<string name="dialog_cancel">Zrušit</string>
<string name="dialog_ok">OK</string>
<string name="event_edit_new_title">Nová událost</string>
<string name="event_edit_close">Zavřít</string>
<string name="event_edit_save">Uložit</string>
<string name="event_edit_title_hint">Přidat název</string>
<string name="event_edit_managed_hint">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.</string>
<string name="event_edit_starts">Začátek</string>
<string name="event_edit_ends">Konec</string>
<string name="event_edit_error_end_before_start">Končí dříve, než začíná</string>
<string name="event_edit_error_no_calendar">Není k dispozici žádný kalendář, který by umožňoval zápis</string>
<string name="event_edit_save_failed">Nelze uložit událost</string>
<string name="event_edit_write_denied">Calendula pro vytváření událostí vyžaduje oprávnění k zápisu</string>
<string name="event_edit_more_fields">další pole</string>
<string name="event_edit_add">Přidat</string>
<string name="event_edit_add_reminder">Přidat upomínku</string>
<string name="event_edit_remove_reminder">Vymazat upomínku</string>
<string name="event_edit_attendees">Hosté</string>
<string name="event_edit_add_guest">Přidat hosta</string>
<string name="event_edit_add_guest_hint">Přidat hosta e-mailem…</string>
<string name="event_edit_add_guest_from_contacts">Přidat z kontaktů</string>
<string name="event_edit_location_from_contacts">Vybrat adresu z kontaktů</string>
<string name="event_edit_remove_guest">Odstranit hosta</string>
<string name="event_edit_attendee_required">Požadované</string>
<string name="event_edit_attendee_optional">Volitelné</string>
<string name="event_edit_attendees_note_synced">Calendula nezasílá pozvánky. Váš účet kalendáře může hosty zvát e-mailem při synchronizaci.</string>
<string name="event_edit_attendees_note_local">Uloženo v tomto zařízení. Nikomu nejsou odesílány notifikace.</string>
<string name="event_edit_reminder_custom">Vlastní</string>
<string name="reminder_unit_minutes">minuty</string>
<string name="reminder_unit_hours">hodiny</string>
<string name="reminder_unit_days">dny</string>
<string name="reminder_unit_weeks">týdny</string>
<string name="event_edit_availability">Dostupnost</string>
<string name="event_edit_visibility">Viditelnost</string>
<string name="event_edit_timezone_device">Časové pásmo zařízení</string>
<string name="event_edit_timezone_device_summary">Monitoruje vaši polohu, ať už jste kdekoliv</string>
<string name="event_edit_timezone_search">Vyhledat časové pásmo</string>
<string name="event_edit_timezone_recent">Nedávné</string>
<string name="event_edit_timezone_all">Všechna časová pásma</string>
<string name="event_edit_timezone_none">Žádné časové pásmo neodpovídá “%1$s”</string>
<string name="event_edit_timezone_local_time">%1$s vašeho času</string>
<string name="event_edit_color">Barva</string>
<string name="event_edit_color_default">Barva kalendáře</string>
<string name="event_edit_color_custom">Vlastní barva</string>
<string name="event_edit_color_reset">Znovu nastavit</string>
<string name="event_edit_color_unsupported">Pro tento kalendář není k dispozici</string>
<string name="event_edit_color_unsupported_hint">Tento kalendář nemá žádnou sadu barev. Vlastní barvy kalendářů můžete povolit v nastavení.</string>
<string name="event_edit_color_sync_warning">Tato barva může být během příští synchronizace kalendáře ztracena nebo přepsána.</string>
<string name="event_edit_conflict_title">Ke změně události došlo na jiném místě.</string>
<string name="event_edit_conflict_body">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?</string>
<string name="event_edit_conflict_overwrite">Uložit změny</string>
<string name="event_edit_conflict_overwrite_hint">Pouze u upravených polí dojde k přepsání vnějšího zdroje</string>
<string name="event_edit_conflict_discard">Zrušit změny</string>
<string name="event_edit_conflict_discard_hint">Událost zůstává beze změny</string>
<string name="event_edit_gone_title">Událost byla odstraněna</string>
<string name="event_edit_gone_body">Během úpravy došlo k odstranění události, například pomocí jiného zařízení. Vaše změny již nelze uložit.</string>
<string name="import_reminder_prompt_title">Chcete použít výchozí upomínku?</string>
<string name="import_reminder_prompt_body_none">Tato událost byla importována bez nastavené upomínky.</string>
<plurals name="import_reminder_prompt_body_existing">
<item quantity="one">Tato událost byla importována s %1$d upomínkou.</item>
<item quantity="few">Tato událost byla importována s %1$d upomínkami.</item>
<item quantity="many">Tato událost byla importována s %1$d upomínkami.</item>
<item quantity="other">Tato událost byla importována s %1$d upomínkami.</item>
</plurals>
<string name="import_reminder_prompt_apply">Nastavit výchozí</string>
<string name="import_reminder_prompt_keep">Nechat beze změny</string>
<string name="event_edit_recurrence_none">Neopakuje se</string>
<string name="event_edit_recurrence_custom">Vlastní</string>
<string name="event_edit_recurrence_every">Každý</string>
<string name="recurrence_unit_days">dny</string>
<string name="recurrence_unit_weeks">týdny</string>
<string name="recurrence_unit_months">měsíce</string>
<string name="recurrence_unit_years">roky</string>
<string name="event_edit_recurrence_incomplete">Zadejte číslo v rozmezí 1 až 999</string>
<string name="event_edit_recurrence_next">Následující: %1$s</string>
<string name="event_edit_recurrence_next_none">Toto pravidlo se nikdy neopakuje</string>
<string name="event_edit_recurrence_ends">Konec</string>
<string name="event_edit_recurrence_end_never">Nikdy</string>
<string name="event_edit_recurrence_end_until">K určitému datu</string>
<string name="event_edit_recurrence_end_count">Po určitém počtu opakování</string>
<string name="event_edit_recurrence_times">opakování</string>
<string name="event_edit_error_recurrence_ends_before_start">Opakování končí před začátkem události</string>
<string name="event_availability_busy">Zaneprázdněný</string>
<string name="event_access_default">Výchozí</string>
<string name="event_access_public">Veřejný</string>
<string name="event_detail_all_day">Celý den</string>
<string name="event_detail_calendar">Kalendář</string>
<string name="event_detail_calendar_unknown">Neznámý kalendář</string>
<string name="event_detail_location">Umístění</string>
<string name="event_detail_description">Popis</string>
<string name="event_detail_attendees">Účastníci</string>
<string name="event_detail_recurrence">Opakování</string>
<string name="event_detail_recurring">Opakující se událost</string>
<string name="recurrence_daily">Každý den</string>
<string name="recurrence_weekly">Každý týden</string>
<string name="recurrence_monthly">Každý měsíc</string>
<string name="recurrence_yearly">Každý rok</string>
<string name="recurrence_every_n_days">Každý %1$d. den</string>
<string name="recurrence_every_n_weeks">Každý %1$d. týden</string>
<string name="recurrence_every_n_months">Každý %1$d. měsíc</string>
<string name="recurrence_every_n_years">Každý %1$d. rok</string>
<string name="recurrence_on_days">%1$s v %2$s</string>
<string name="recurrence_with_until">%1$s do%2$s</string>
<string name="recurrence_with_count">%1$s, %2$d opakování</string>
<string name="event_detail_not_found">Tato událost již neexistuje.</string>
<string name="event_attendee_accepted">Přijato</string>
<string name="event_attendee_declined">Odmítnuto</string>
<string name="event_attendee_tentative">Předběžné</string>
<string name="event_attendee_needs_action">Bez odezvy</string>
<string name="event_attendee_unknown"></string>
<string name="event_detail_reminders">Upomínky</string>
<string name="event_detail_timezone">Časové pásmo</string>
<string name="event_status_tentative">Přeběžné</string>
<string name="event_status_cancelled">Zrušeno</string>
<string name="event_availability_free">Volné</string>
<string name="event_access_private">Soukromé</string>
<string name="event_access_confidential">Utajené</string>
<string name="event_access_default_summary">Běžná činnost kalendáře</string>
<string name="event_access_public_summary">Kdokoliv s možností přístupu uvidí veškeré podrobnosti</string>
<string name="event_access_private_summary">Ostatní uvidí pouze to, že jste zaneprázdněn</string>
<string name="event_access_confidential_summary">Označeno jako utajené; záleží na daném účtu kalendáře, jak s tímto štítkem naloží</string>
<string name="event_attendee_organizer">Pořadatel</string>
<string name="event_attendee_optional">Volitelné</string>
<string name="event_attendee_resource">Zdroj</string>
<string name="event_detail_self_response">Vaše odpověď: %1$s</string>
<string name="reminder_at_time">V době konání události</string>
<string name="reminder_default">Výchozí upomínka</string>
<plurals name="reminder_minutes">
<item quantity="one">%d minutu předtím</item>
<item quantity="few">%d minuty předtím</item>
<item quantity="many">%d minut předtím</item>
<item quantity="other">%d minut předtím</item>
</plurals>
<plurals name="reminder_hours">
<item quantity="one">%d hodinu předtím</item>
<item quantity="few">%d hodiny předtím</item>
<item quantity="many">%d hodin předtím</item>
<item quantity="other">%d hodin předtím</item>
</plurals>
<plurals name="reminder_days">
<item quantity="one">%d den předtím</item>
<item quantity="few">%d dny předtím</item>
<item quantity="many">%d dnů předtím</item>
<item quantity="other">%d dnů předtím</item>
</plurals>
<plurals name="reminder_weeks">
<item quantity="one">%d týden předtím</item>
<item quantity="few">%d týdny předtím</item>
<item quantity="many">%d týdnů předtím</item>
<item quantity="other">%d týdnů předtím</item>
</plurals>
<plurals name="duration_minutes">
<item quantity="one">%d minuta</item>
<item quantity="few">%d minuty</item>
<item quantity="many">%d minut</item>
<item quantity="other">%d minut</item>
</plurals>
<plurals name="duration_hours">
<item quantity="one">%d hodina</item>
<item quantity="few">%d hodiny</item>
<item quantity="many">%d hodin</item>
<item quantity="other">%d hodin</item>
</plurals>
<plurals name="duration_days">
<item quantity="one">%d den</item>
<item quantity="few">%d dny</item>
<item quantity="many">%d dnů</item>
<item quantity="other">%d dnů</item>
</plurals>
<string name="duration_hours_minutes">%1$s %2$s</string>
<string name="duration_custom_max">Nanejvýš %1$s</string>
<plurals name="duration_weeks">
<item quantity="one">%d týden</item>
<item quantity="few">%d týdny</item>
<item quantity="many">%d týdnů</item>
<item quantity="other">%d týdnů</item>
</plurals>
<string name="event_untitled">(Bez názvu)</string>
<string name="onboarding_backup_title">Vaši událost naleznete pouze zde</string>
<string name="onboarding_backup_body">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.</string>
<string name="onboarding_backup_benefit_folder_title">Složka, kterou si vyberete</string>
<string name="onboarding_backup_benefit_folder_body">Zálohy jsou obyčejné soubory v .ics formátu — uložte je někam, kde se synchronizují, nebo na SD kartu.</string>
<string name="onboarding_backup_benefit_daily_title">Jednou denně, automaticky</string>
<string name="onboarding_backup_benefit_daily_body">Calendula exportuje vaše místní kalendáře na pozadí. Frekvenci lze změnit v nastavení.</string>
<string name="onboarding_backup_enable_button">Vyberte složku a proveďte zálohu</string>
<string name="onboarding_backup_skip_button">Teď ne</string>
<string name="onboarding_view_title">Co by se mělo otevřít nejdříve?</string>
<string name="onboarding_month_style_title">Jak by mělo vypadat měsíční rozvržení?</string>
<string name="onboarding_view_continue_button">Pokračovat</string>
<string name="onboarding_back">Zpět</string>
<string name="onboarding_visibility_button">Jasně</string>
<string name="onboarding_done_title">Vše je připraveno</string>
<string name="onboarding_done_body">Vaše kalendáře jsou připraveny. Veškeré volby lze později změnit v nastavení.</string>
<string name="onboarding_done_button">Otevřít můj kalendář</string>
<string name="reminder_channel_name">Upomínky událostí</string>
<string name="reminder_channel_description">Oznámení v časech upomínek vašich událostí</string>
<string name="reminder_onboarding_title">Ať vám neunikne žádná událost</string>
<string name="reminder_onboarding_body">Android sám o sobě nezobrazuje upomínky událostí — to musí zajistit aplikace kalendáře. Nechte tuto práci na Calendule.</string>
<string name="reminder_benefit_delivery_title">Upomínky, doručené</string>
<string name="reminder_benefit_delivery_body">Upomínky k vašim událostem vám přijdou v danou dobu jako oznámení.</string>
<string name="reminder_benefit_duplicates_title">Používáte ještě nějakou jinou aplikaci s kalendářem?</string>
<string name="reminder_benefit_duplicates_body">Pokud i jiná aplikace odesílá upomínky, uvidíte je dvakrát — v jedné z aplikací je musíte vypnout.</string>
<string name="reminder_benefit_reversible_title">Lze kdykoliv změnit</string>
<string name="reminder_benefit_reversible_body">Tento přepínač naleznete v Nastavení, v části Oznámení.</string>
<string name="reminder_onboarding_enable_button">Zapnout upomínky</string>
<string name="reminder_onboarding_skip_button">Později</string>
<string name="reminder_action_snooze">Odložit</string>
<string name="reminder_action_dismiss">Zrušit</string>
<string name="reminder_day_tomorrow">Zítra</string>
<string name="reminder_day_yesterday">Včera</string>
<string name="view_month">Měsíc</string>
<string name="view_week">Týden</string>
<string name="view_day">Den</string>
<string name="view_agenda">Program</string>
<string name="view_section">Zobrazení</string>
<string name="drawer_jump_to_date">Přejít na datum</string>
<string name="agenda_today_action">Dnes</string>
<string name="agenda_header_today">Dnes</string>
<string name="agenda_header_tomorrow">Zítra</string>
<string name="agenda_empty_title">Teď už jsi v obraze</string>
<string name="agenda_no_more_today">Dnes bez dalších událostí</string>
<string name="agenda_span_starts">Začátek %1$s</string>
<string name="agenda_span_ends">Konec %1$s</string>
<string name="today_jump_action">Přejít na dnešek</string>
<string name="search_action">Hledat</string>
<string name="search_hint">Vyhledat události</string>
<string name="search_back">Zpět</string>
<string name="search_clear">Vymazat</string>
<string name="search_idle_hint">Prohledávejte své události podle názvu, místa konání nebo poznámek.</string>
<string name="search_empty">Žádná událost neodpovídá “%1$s”.</string>
<plurals name="search_selected_count">
<item quantity="one">%d označený</item>
<item quantity="few">%d označené</item>
<item quantity="many">%d označených</item>
<item quantity="other">%d označených</item>
</plurals>
<string name="search_in_descriptions">Nalezeno v popisech</string>
<string name="search_selection_close">Zrušit výběr</string>
<string name="search_select_all">Vybrat vše</string>
<string name="search_delete_selected">Odstranit vybrané</string>
<plurals name="search_delete_title">
<item quantity="one">Odstranit %d událost?</item>
<item quantity="few">Odstranit %d události?</item>
<item quantity="many">Odstranit %d událostí?</item>
<item quantity="other">Odstranit %d událostí?</item>
</plurals>
<plurals name="search_delete_done">
<item quantity="one">%d událost odstraněna</item>
<item quantity="few">%d události odstraněny</item>
<item quantity="many">%d událostí odstraněno</item>
<item quantity="other">%d událostí odstraněno</item>
</plurals>
<string name="search_delete_partial">%1$d odstraněno, u %2$d se nepodařilo</string>
<string name="search_delete_denied_partial">%1$d odstraněno, poté byl odepřen přístup pro zápis</string>
<string name="settings_special_dates_disable_all_message">Tímto se smažou kalendáře spojené s určitým kontaktem a všechny události v nich obsažené. Veškerá upomínky a poznámky, které jste do nich přidali, budou ztraceny.</string>
<string name="widget_agenda_title">Nadcházející</string>
<string name="widget_agenda_label">Události Calendula</string>
<string name="widget_month_label">Měsíc Calendula</string>
<string name="widget_refresh">Obnovit</string>
<string name="widget_new_event">Nová událost</string>
<string name="widget_needs_permission">Otevřete Calendulu a povolte přístup ke kalendáři</string>
<string name="widget_prev_month">Předchozí měsíc</string>
<string name="widget_next_month">Následující měsíc</string>
<string name="widget_today">Dnes</string>
<string name="filter_title">Kalendáře</string>
</resources>

View File

@@ -17,14 +17,14 @@
<string name="state_failure_no_calendars_action">Apri le impostazioni calendario di sistema</string>
<string name="state_failure_provider">Errore nella lettura del calendario.</string>
<string name="permission_rationale_title">Gestisci magnificamente tutti i tuoi eventi</string>
<string name="permission_rationale_body">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.</string>
<string name="permission_rationale_body">Calendula ha bisogno di accedere al tuo calendario per mostrare e gestire gli eventi.</string>
<string name="permission_request_button">Concedi l\'accesso al calendario</string>
<string name="permission_denied_title">Accesso al calendario negato</string>
<string name="permission_denied_body">Calendula non può mostrare gli eventi senza accesso al calendario. Puoi concederlo dalle impostazioni di sistema.</string>
<string name="permission_open_settings_button">Apri le impostazioni di sistema</string>
<string name="permission_retry_button">Riprova</string>
<string name="permission_benefit_private_title">Rimane sul tuo dispositivo</string>
<string name="permission_benefit_private_body">I tuoi calendari sono letti localmente e non lasciano mai il tuo dispositivo.</string>
<string name="permission_benefit_private_title">Privato by design</string>
<string name="permission_benefit_private_body">Nessun accesso a internet, nessun tracking: nulla lascia il tuo dispositivo.</string>
<string name="permission_benefit_sync_title">Tutti i tuoi calendari, insieme</string>
<string name="permission_benefit_sync_body">Google, CalDAV, locali - qualsiasi calendario sincronizzato sul dispositivo, semplicemente, compare.</string>
<string name="permission_benefit_privacy_title">Nessun tracking, per sempre</string>
@@ -301,8 +301,8 @@
<string name="settings_language_auto">Lingua di sistema</string>
<string name="settings_translate">Aiuta a tradurre</string>
<string name="settings_translate_hint">Aggiungi o migliora una traduzione su Weblate</string>
<string name="settings_appearance_subtitle">Tema, vista di default, inizio settimana</string>
<string name="settings_event_form_subtitle">Campi di default per nuovi eventi</string>
<string name="settings_appearance_subtitle">Tema, colori, font</string>
<string name="settings_event_form_subtitle">Campi di default e comportamento</string>
<string name="settings_section_about">A proposito</string>
<string name="settings_license">Licenza</string>
<string name="settings_license_value">MIT</string>
@@ -414,7 +414,7 @@
<string name="agenda_range_this_week">Questa settimana</string>
<string name="settings_reminders">Promemoria per gli eventi</string>
<string name="settings_default_reminder">Promemoria per eventi standard</string>
<string name="settings_notifications_subtitle">Promemoria evento</string>
<string name="settings_notifications_subtitle">Promemoria</string>
<string name="shortcut_new_event_short">Nuovo evento</string>
<string name="event_edit_managed_hint">Gestito da “%1$s” - titolo, data e ripetizioni sono sincronizzate dai tuoi contatti. Promemoria e note sono modificabili dall\'utente.</string>
<string name="settings_font_headings">Carattere del titolo</string>
@@ -423,13 +423,13 @@
<string name="settings_font_choose_file">Scegli file…</string>
<string name="settings_font_custom_selected">Font personalizzato</string>
<string name="settings_font_import_failed">Impossibile leggere il file come font</string>
<string name="settings_section_views">Viste</string>
<string name="settings_section_views">Vista</string>
<string name="settings_quick_switch_header">Pulsante di cambio rapido</string>
<string name="settings_quick_switch_hint">Scegli attraverso quali viste scorrere col pulsante in alto a destra, trascinale per riordinarle. Le viste disattivate rimangono raggiungibili dal menù di navigazione.</string>
<string name="settings_drawer_order_header">Menù di navigazione</string>
<string name="settings_drawer_order_hint">Trascina per riordinare le viste elencate nel menù di navigazione.</string>
<string name="reorder_drag_handle">Trascina per riordinare</string>
<string name="settings_views_subtitle">Ordine del pulsante di cambio rapido e del menù</string>
<string name="settings_views_subtitle">Vista di default, layout, ordine</string>
<string name="settings_special_dates_subtitle">Compleanni e anniversari dei contatti</string>
<string name="settings_section_special_dates">Date speciali dei contatti</string>
<string name="settings_special_dates_enable">Mostra date dei contatti</string>
@@ -478,8 +478,8 @@
<string name="reminder_day_yesterday">Ieri</string>
<string name="agenda_no_more_today">Nessun altro evento per oggi</string>
<string name="back">Indietro</string>
<string name="settings_soften_colors">Attenua i colori del calendario</string>
<string name="settings_soften_colors_summary">I colori dei calendari e degli eventi vengono attenuati per adattarsi al tema. Deseleziona per mostrare i colori naturali.</string>
<string name="settings_soften_colors">Armonizza i colori del calendario</string>
<string name="settings_soften_colors_summary">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.</string>
<string name="settings_week_numbers">Numeri di settimana</string>
<string name="settings_week_numbers_summary">Mostra i numeri di settimana nella vista Mese</string>
<string name="settings_agenda_show_today">Mostra sempre Oggi</string>
@@ -499,4 +499,151 @@
<item quantity="many">Importando %d eventi</item>
<item quantity="other">Importando %d eventi</item>
</plurals>
<string name="event_move_action">Sposta…</string>
<string name="event_move_recurring_title">Sposta un evento ricorrente</string>
<string name="event_move_done">Spostato al %1$s</string>
<string name="event_move_undo">Annulla ripianificazione</string>
<string name="event_move_undone">Ripianificazione annullata</string>
<string name="event_move_failed">Impossibile ripianificare l\'evento</string>
<string name="event_move_write_denied">Calendula ha bisogno dei permessi di scrittura per ripianificare gli eventi</string>
<string name="event_move_gone">Questo evento è stato eliminato.</string>
<string name="event_move_blocked_series_end">Non è possibile ripianificare un evento oltre la data di fine della sua serie</string>
<string name="event_edit_timezone_device">Fuso orario del dispositivo</string>
<string name="event_edit_timezone_device_summary">Segue il fuso orario in cui ti trovi</string>
<string name="event_edit_timezone_search">Seleziona fuso orario</string>
<string name="event_edit_timezone_recent">Recenti</string>
<string name="event_edit_timezone_all">Tutti i fusi orari</string>
<string name="event_edit_timezone_none">Nessun fuso corrisponde a \"%1$s\"</string>
<string name="event_edit_timezone_local_time">%1$s nel tuo fuso orario</string>
<string name="event_edit_recurrence_incomplete">Inserisci un numero tra 1 e 999</string>
<string name="event_edit_recurrence_next">Prossimo evento: %1$s</string>
<string name="event_edit_recurrence_next_none">Questa regola non si ripete</string>
<string name="event_access_default_summary">In base all\'impostazione del calendario</string>
<string name="event_access_public_summary">Chiunque acceda può vedere i dettagli completi</string>
<string name="event_access_private_summary">Gli altri vedranno solo che sei impegnato</string>
<string name="event_access_confidential_summary">Confidenziale; dipende dalle impostazioni dell\'account</string>
<string name="duration_hours_minutes">%1$s %2$s</string>
<string name="duration_custom_max">Al più %1$s</string>
<string name="onboarding_step_counter">Step %1$d di %2$d</string>
<string name="onboarding_backup_title">I tuoi eventi rimangono qui</string>
<string name="onboarding_backup_body">Nulla viene sincronizzato, perdere i dati presenti su questo dispositivo significa perdere i tuoi calendari. Calendula può fare un backup per te.</string>
<string name="onboarding_backup_benefit_folder_title">Una cartella a scelta</string>
<string name="onboarding_backup_benefit_folder_body">I backup sono file .ics. Mettili al sicuro sul cloud o su una scheda SD.</string>
<string name="onboarding_backup_benefit_daily_title">Giornaliero, in automatico</string>
<string name="onboarding_backup_benefit_daily_body">Calendula esporta i tuoi calendari locali in background. Selezione la frequenza nelle Impostazioni.</string>
<string name="onboarding_backup_enable_button">Seleziona la cartella ed esegui il backup</string>
<string name="onboarding_backup_skip_button">Non ora</string>
<string name="onboarding_view_title">Cosa viene aperto per primo?</string>
<string name="onboarding_month_style_title">Seleziona la vista Mese</string>
<string name="onboarding_view_continue_button">Continua</string>
<string name="onboarding_back">Indietro</string>
<string name="onboarding_visibility_button">Capito</string>
<string name="onboarding_done_title">Tutto fatto</string>
<string name="onboarding_done_body">I tuoi calendari sono pronti. Tutto ciò che hai scelto può essere modificato più tardi nelle Impostazioni.</string>
<string name="onboarding_done_button">Apri i miei calendari</string>
<string name="agenda_span_starts">Inizia alle %1$s</string>
<string name="agenda_span_ends">Finisce alle %1$s</string>
<string name="today_jump_action">Vai ad oggi</string>
<plurals name="search_selected_count">
<item quantity="one">%d selezionato</item>
<item quantity="many">%d selezionati</item>
<item quantity="other">%d selezionati</item>
</plurals>
<string name="search_in_descriptions">Trovato nelle descrizioni</string>
<string name="search_selection_close">Cancella selezione</string>
<string name="search_select_all">Seleziona tutti</string>
<string name="search_delete_selected">Elimina i selezionati</string>
<plurals name="search_delete_title">
<item quantity="one">Eliminare l\'evento?</item>
<item quantity="other">Eliminare l\'evento?</item>
</plurals>
<plurals name="search_delete_done">
<item quantity="one">Evento eliminato</item>
<item quantity="other">Evento eliminato</item>
</plurals>
<string name="search_delete_partial">%1$d eliminati, %2$d no</string>
<string name="search_delete_denied_partial">%1$d eliminati, poi permessi di scrittura negati</string>
<string name="event_move_occurrence_only">Ripianificare l\'intera serie cambia i giorni in cui cade, quindi puoi ripianificare solo questo evento.</string>
<string name="settings_theme_hint">Tema chiaro o scuro. La modifica viene applicata immediatamente.</string>
<string name="settings_theme_system_summary">Al momento %1$s</string>
<string name="settings_font_specimen_heading">Giovedì, 14 Maggio</string>
<string name="settings_font_specimen_body">Team review alle 10:00, poi pranzo con Ugo al bar a Piazza di Spagna.</string>
<string name="settings_week_start_auto_summary">Al momento %1$s</string>
<string name="settings_today_toolbar">Pulsante Oggi nella toolbar</string>
<string name="settings_today_toolbar_summary">Mostra il pulsante \"vai alla data\" nella toolbar invece che come pulsante flottante</string>
<string name="settings_app_name">Nome app</string>
<string name="settings_app_name_summary">Mostra Calendula come \"Calendario\" nel launcher. Il nome cambia solo nel launcher; l\'icona potrebbe spostarsi dopo la modifica.</string>
<string name="settings_time_format_auto_summary">Da sistema: %1$s</string>
<string name="settings_timeline_scale">Altezza ore</string>
<string name="settings_timeline_scale_hint">Spazio occupato verticalmente da un\'ora nella vista Giorno e Settimana. Puoi anche modificare l\'altezza come preverisci pizzicando la timeline.</string>
<string name="timeline_scale_fit_day">Occupa la giornata intera</string>
<string name="timeline_scale_fit_day_summary">Tutte le 24 ore in una schermata, senza scrolling</string>
<string name="timeline_scale_compact">Compatto</string>
<string name="timeline_scale_compact_summary">Più ore per schermata, blocchi più piccoli</string>
<string name="timeline_scale_regular">Regolare</string>
<string name="timeline_scale_regular_summary">La spaziatura standard</string>
<string name="timeline_scale_comfortable">Comodo</string>
<string name="timeline_scale_comfortable_summary">Blocchi più ampi, più scrolling</string>
<string name="timeline_scale_custom">Personalizza</string>
<string name="timeline_scale_custom_summary">L\'altezza a cui hai portato la timeline</string>
<string name="settings_drag_to_reschedule">Trascina per ripianificare</string>
<string name="settings_drag_to_reschedule_summary">Ripianifica un evento trascinandolo in un altro giorno o ora</string>
<string name="settings_past_events_sample_morning">Team review</string>
<string name="settings_past_events_sample_midday">Pranzo con Ugo</string>
<string name="settings_past_events_sample_evening">Allenamento di scacchi</string>
<string name="settings_widget_size">Dimensioni del widget Agenda</string>
<string name="settings_widget_size_hint">Dimensioni del testo del widget agenda. Il widget mensile non ha impostazioni, si adatta da solo alla dimensione data.</string>
<string name="settings_widget_size_small">Piccolo</string>
<string name="settings_widget_size_medium">Medio</string>
<string name="settings_widget_size_large">Grande</string>
<string name="settings_widget_size_extra_large">Molto grande</string>
<string name="settings_widget_size_summary">Carattere %1$d%%</string>
<string name="settings_month_header">Vista Mese</string>
<string name="settings_month_view_style">Stile vista Mese</string>
<string name="month_style_paged">Pagine</string>
<string name="month_style_paged_summary">Viene mostrato un singolo mese. Scorri a destra o a sinistra per cambiare mese.</string>
<string name="month_style_continuous">Scorrimento</string>
<string name="settings_past_events_hint">Ciò che fa l\'Agenda con eventi conclusi.</string>
<string name="settings_dynamic_color_summary">Deduci il colore dell\'app dallo sfondo.</string>
<string name="settings_about_privacy">Politica per la Privacy</string>
<string name="calendars_visibility_hint">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.</string>
<string name="calendars_visibility_a11y">Mostra \"%1$s\"</string>
<string name="calendars_visibility_notice_title">Alcuni calendari non sono attivi</string>
<string name="calendars_visibility_notice_message">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.</string>
<string name="calendar_picker_missing_title">Manca un calendario?</string>
<string name="calendar_picker_missing_summary">Può essere disattivato, in sola lettura o compilato in base ai tuoi contatti. Gestisci qui i tuoi calendari.</string>
<string name="calendars_state_read_only">Sola lettura</string>
<string name="calendars_state_not_synced">Non sincronizzato su questo dispositivo</string>
<string name="calendars_state_managed">Compilato dai tuoi contatti</string>
<string name="calendars_managed_delete_locked">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.</string>
<string name="calendars_account_from_source">%1$s (%2$s)</string>
<string name="month_style_continuous_summary">Ogni mese è sotto la sua testata, con un piccolo spazio a separarlo dal mese successivo.</string>
<string name="month_style_dense">Settimane continue</string>
<string name="month_style_dense_summary">Le settimane scorrono senza interruzione, ogni mese succede a quello precedente.</string>
<string name="month_style_split">Diviso</string>
<string name="month_style_split_summary">Una griglia compatta indica i giorni con eventi. Gli eventi del giorno selezionato sono elencati sotto.</string>
<string name="month_split_no_events">Nulla in programma</string>
<string name="month_split_expand">Mostra il mese completo</string>
<string name="month_split_collapse">Mostra gli eventi del giorno</string>
<string name="settings_event_duration">Durata standard</string>
<string name="settings_event_duration_hint">Quanto durano gli eventi se non modifichi l\'orario di fine. Non ha effetto sugli eventi giornalieri.</string>
<string name="settings_calendar_durations_title">Durata per calendario</string>
<string name="settings_calendar_durations_hint">Assegna a ciascun calendario la sua durata standard (e.g. 8 ore per il calendario dei turni lavorativi).</string>
<string name="settings_calendar_duration_inherits">Default (%1$s)</string>
<string name="settings_calendar_duration_use_default">Usa la durata standard (%1$s)</string>
<string name="settings_allday_reminder_fires_at">Promemoria alle %1$s</string>
<string name="settings_group_look">Aspetto e Comportamento</string>
<string name="settings_group_data">Dati</string>
<string name="settings_group_app">App</string>
<string name="settings_group_about">A proposito</string>
<string name="settings_widgets_subtitle">Widget Agenda e riquadro Impostazioni rapide</string>
<string name="settings_backup_subtitle">Export, Import, backup automatici</string>
<string name="settings_section_widgets">Widget &amp; Riquardi</string>
<string name="settings_widgets_hint">Impostazioni per i widget e il riquardo delle Impostazioni rapide. Aggiungi i widget tenendo premuto sulla home.</string>
<string name="settings_section_backup">Backup e ripristino</string>
<string name="settings_views_all_header">Tutte le viste</string>
<string name="settings_week_day_header">Giorno e settimana</string>
<string name="settings_default_view_hint">La vista di default all\'apertura dell\'app.</string>
<string name="settings_week_start_hint">Il giorno in cui inizia ogni settimana, in tutta l\'app e i suoi widget.</string>
<string name="settings_time_format_hint">Formato degli orari in tutta l\'app. Di default viene seguito il formato di sistema.</string>
</resources>

View File

@@ -0,0 +1,82 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="backup_interval_units">
<item>Minutes</item>
<item>Hours</item>
<item>Days</item>
<item>Weeks</item>
</string-array>
<string name="app_tagline">モダンなカレンダー。</string>
<string name="state_loading">読み込み中…</string>
<string name="state_retry">リトライ</string>
<string name="month_today_action">今日</string>
<string name="month_action_settings">設定</string>
<string name="event_detail_description">説明</string>
<string name="event_edit_add_reminder">リマインダーを追加</string>
<string name="event_edit_save">保存</string>
<string name="event_detail_all_day">終日</string>
<string name="view_month"></string>
<string name="view_week"></string>
<string name="view_day"></string>
<string name="settings_section_backup">バックアップと復元</string>
<string name="settings_special_dates_type_birthday">誕生日</string>
<string name="settings_special_dates_sync_now">今すぐ同期</string>
<string name="settings_special_dates_last_synced">最終同期 %1$s</string>
<string name="settings_section_notifications">通知</string>
<string name="drawer_jump_to_date">日付に移動</string>
<string name="state_failure_permission">カレンダーへのアクセス権限が必要です。</string>
<string name="permission_rationale_body">Calendulaで予定を表示・管理するには、カレンダーへのアクセス許可が必要です。</string>
<string name="permission_request_button">カレンダーへのアクセスを許可</string>
<string name="permission_denied_title">カレンダーへのアクセスが拒否されました</string>
<string name="permission_denied_body">Calendulaはカレンダーへのアクセス権がないと予定を表示できません。システム設定から再度許可をお願いします。</string>
<string name="permission_open_settings_button">システム設定を開く</string>
<string name="permission_retry_button">再試行</string>
<string name="permission_benefit_private_title">プライバシーを重視した設計</string>
<string name="permission_benefit_private_body">インターネットへのアクセス権限は不要で、テレメトリも送信しません。すべてのデータはスマートフォン内に保持されます。</string>
<string name="permission_benefit_sync_title">すべてのカレンダーをひとつに</string>
<string name="permission_benefit_sync_body">Google、CalDAV、ローカルなど、デバイスに同期されているすべてのカレンダーが自動的に表示されます。</string>
<string name="event_detail_delete">削除</string>
<string name="event_detail_share">共有</string>
<string name="event_detail_duplicate">複製</string>
<string name="event_delete_title">予定を削除しますか?</string>
<string name="event_delete_body">この予定はカレンダーだけでなく、同期しているすべてのデバイスからも削除されます。</string>
<string name="event_delete_recurring_title">定期的な予定を削除</string>
<string name="event_delete_option_occurrence">この予定のみ</string>
<string name="event_delete_option_following">この予定とそれ以降すべて</string>
<string name="event_edit_recurring_title">定期的な予定を編集</string>
<string name="event_delete_failed">予定を削除できませんでした</string>
<string name="recurrence_unit_years">毎年</string>
<string name="reminder_unit_weeks"></string>
<string name="recurrence_unit_weeks">毎週</string>
<string name="recurrence_unit_months">毎月</string>
<string name="reminder_unit_minutes"></string>
<string name="reminder_unit_hours">時間</string>
<string name="reminder_unit_days"></string>
<string name="recurrence_unit_days">毎日</string>
<string name="reminder_day_yesterday">前日</string>
<string name="settings_section_widgets">ウィジットとタイル</string>
<string name="settings_week_start">週の開始日</string>
<string name="settings_week_numbers">週番号</string>
<string name="settings_theme_light">ライト</string>
<string name="settings_theme_dark">ダーク</string>
<string name="settings_font_headings">見出しのフォント</string>
<string name="settings_font_body">本文のフォント</string>
<string name="settings_font_system">システムの標準</string>
<string name="settings_font_choose_file">ファイルを選択…</string>
<string name="settings_week_start_auto">自動</string>
<string name="settings_week_start_auto_summary">現在は %1$s</string>
<string name="settings_app_name">アプリの名前</string>
<string name="settings_section_language">言語</string>
<string name="settings_language">アプリの言語</string>
<string name="settings_language_auto">システムの既定</string>
<string name="settings_group_app">アプリ</string>
<string name="settings_appearance_subtitle">テーマ、カラー、フォント</string>
<string name="settings_about_privacy">プライバシーポリシー</string>
<string name="settings_about_support">開発を支援する</string>
<string name="settings_license">ライセンス</string>
<string name="settings_report_problem">問題を報告</string>
<string name="settings_title">設定</string>
<string name="settings_theme">テーマ</string>
<string name="settings_theme_system">システム</string>
<string name="settings_dynamic_color">ダイナミックカラー</string>
</resources>

View File

@@ -16,14 +16,14 @@
<string name="state_failure_no_calendars_action">Otwórz systemowe ustawienia kalendarza</string>
<string name="state_failure_provider">Nie udało się kalendarza.</string>
<string name="permission_rationale_title">Zobacz wszystkie swoje wydarzenia w pięknej oprawie</string>
<string name="permission_rationale_body">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.</string>
<string name="permission_rationale_body">Calendula potrzebuje dostępu do Twojego kalendarza, aby wyświetlać wydarzenia i nimi zarządzać.</string>
<string name="permission_request_button">Przyznaj dostęp do kalendarza</string>
<string name="permission_denied_title">Brak dostępu do kalendarza</string>
<string name="permission_denied_body">Calendula nie może wyświetlać wydarzeń bez dostępu do kalendarza. Możesz go ponownie przyznać w ustawieniach systemowych.</string>
<string name="permission_open_settings_button">Otwórz ustawienia systemowe</string>
<string name="permission_retry_button">Spróbuj ponownie</string>
<string name="permission_benefit_private_title">Pozostaje na Twoim urządzeniu</string>
<string name="permission_benefit_private_body">Twoje kalendarze są odczytywane lokalnie i nigdy nie opuszcza telefonu.</string>
<string name="permission_benefit_private_title">Prywatność z założenia</string>
<string name="permission_benefit_private_body">Brak uprawnień do internetu, brak telemetrii — nic nigdy nie opuszcza Twojego telefonu.</string>
<string name="permission_benefit_sync_title">Wszystkie Twoje kalendarze w jednym miejscu</string>
<string name="permission_benefit_sync_body">Google, CalDAV, lokalne — wszystko, co jest zsynchronizowane z urządzeniem, po prostu się pojawia.</string>
<string name="permission_benefit_privacy_title">Nigdy żadnego śledzenia</string>
@@ -333,7 +333,7 @@
<string name="settings_default_reminder">Domyślne przypomnienie</string>
<string name="settings_default_reminder_allday">Wydarzenia całodniowe</string>
<string name="settings_allday_reminder_time">Godzina przypomnienia o wydarzeniach całodniowych</string>
<string name="settings_allday_reminder_time_hint">Przypomnienia o wydarzeniach całodniowych zostaną pojawiają się o %1$s</string>
<string name="settings_allday_reminder_time_hint">Przypomnienia o wydarzeniach całodniowych pojawiają się o %1$s</string>
<string name="reminder_none">Brak</string>
<string name="reminder_use_default">Użyj domyślnego przypomnienia</string>
<string name="reminder_custom_amount">jednostek</string>
@@ -353,10 +353,10 @@
<string name="settings_language_auto">Domyślny systemu</string>
<string name="settings_translate">Pomóż w tłumaczeniu</string>
<string name="settings_translate_hint">Dodaj lub ulepsz tłumaczenie w Weblate</string>
<string name="settings_appearance_subtitle">Motyw, domyślny widok, pierwszy dzień tygodnia</string>
<string name="settings_views_subtitle">Układ widoku miesiąca, przycisk szybkiego przełączania, kolejność menu</string>
<string name="settings_event_form_subtitle">Domyślne pola dla nowych wydarzeń</string>
<string name="settings_notifications_subtitle">Przypomnienia o wydarzeniach</string>
<string name="settings_appearance_subtitle">Motyw, kolory, czcionki</string>
<string name="settings_views_subtitle">Domyślny widok, układ, kolejność</string>
<string name="settings_event_form_subtitle">Domyślne pola i zachowanie</string>
<string name="settings_notifications_subtitle">Przypomnienia i dostarczanie</string>
<string name="settings_special_dates_subtitle">Urodziny i rocznice kontaktów</string>
<string name="settings_section_special_dates">Szczególne daty kontaktów</string>
<string name="settings_special_dates_enable">Pokaż daty kontaktów</string>
@@ -509,8 +509,8 @@
<string name="reminder_day_tomorrow">Jutro</string>
<string name="reminder_day_yesterday">Wczoraj</string>
<string name="agenda_no_more_today">Brak zaplanowanych wydarzeń</string>
<string name="settings_soften_colors">Zredukuj intensywność kolorów kalendarza</string>
<string name="settings_soften_colors_summary">Dopasuj intensywność kolorów kalendarza i wydarzeń do motywu. Wyłącz tę opcję, aby wyświetlać oryginalne kolory ze źródła kalendarza.</string>
<string name="settings_soften_colors">Harmonizuj kolory kalendarza</string>
<string name="settings_soften_colors_summary">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.</string>
<string name="settings_agenda_show_today">Zawsze pokazuj dzisiejszy dzień</string>
<string name="settings_agenda_show_today_hint">Zachowaj dzisiejszy dzień na górze agendy i widżetu, nawet gdy nie ma już na dziś żadnych zadań.</string>
<string name="special_dates_calendar_birthday">Urodziny</string>
@@ -543,4 +543,128 @@
<string name="month_split_no_events">Brak planów</string>
<string name="month_split_expand">Pokaż cały miesiąc</string>
<string name="month_split_collapse">Pokaż wydarzenia dnia</string>
<string name="event_move_gone">To wydarzenie już nie istnieje</string>
<string name="event_move_action">Przenieś…</string>
<string name="event_move_recurring_title">Przenieś wydarzenie cykliczne</string>
<string name="event_move_occurrence_only">Przeniesienie całej serii zmieniłoby dni, w których wypadają wydarzenia, więc można przenieść tylko to wystąpienie.</string>
<string name="event_move_done">Przeniesiono na %1$s</string>
<string name="event_move_undo">Cofnij</string>
<string name="event_move_undone">Cofnięto przeniesienie</string>
<string name="event_move_failed">Nie udało się przenieść wydarzenia</string>
<string name="event_move_write_denied">Calendula wymaga uprawnień do zapisu, aby móc przenosić wydarzenia</string>
<string name="event_move_blocked_series_end">Nie można przenieść wydarzenia poza datę zakończenia jego serii</string>
<string name="event_edit_recurrence_next">Następne: %1$s</string>
<string name="event_edit_recurrence_next_none">Ta reguła nie ma powtórzeń</string>
<string name="event_access_default_summary">Standardowe ustawienie tego kalendarza</string>
<string name="event_access_public_summary">Każdy, kto ma dostęp, widzi pełne szczegóły</string>
<string name="event_access_private_summary">Inni widzą tylko to, że jesteś zajęty</string>
<string name="event_access_confidential_summary">Oznaczone jako poufne; co to oznacza, zależy od konta kalendarza</string>
<string name="duration_hours_minutes">%1$s %2$s</string>
<string name="duration_custom_max">Maksymalnie %1$s</string>
<string name="onboarding_step_counter">Krok %1$d z %2$d</string>
<string name="onboarding_backup_title">Twoje wydarzenia istnieją tylko tutaj</string>
<string name="onboarding_backup_body">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ą.</string>
<string name="onboarding_backup_benefit_folder_title">Wybrany przez Ciebie folder</string>
<string name="onboarding_backup_benefit_folder_body">Kopie zapasowe to zwykłe pliki .ics — umieść je w miejscu, które się synchronizuje, albo na karcie SD.</string>
<string name="onboarding_backup_benefit_daily_title">Raz dziennie, automatycznie</string>
<string name="onboarding_backup_benefit_daily_body">Calendula eksportuje Twoje lokalne kalendarze w tle. Możesz zmienić częstotliwość w Ustawieniach.</string>
<string name="onboarding_backup_enable_button">Wybierz folder i utwórz kopię zapasową</string>
<string name="onboarding_view_title">Co powinno się otworzyć jako pierwsze?</string>
<string name="onboarding_month_style_title">Jak powinien wyglądać miesiąc?</string>
<string name="onboarding_view_continue_button">Kontynuuj</string>
<string name="onboarding_back">Wstecz</string>
<string name="onboarding_visibility_button">Rozumiem</string>
<string name="onboarding_done_title">Wszystko gotowe</string>
<string name="onboarding_done_body">Twoje kalendarze są gotowe. Wszystko co właśnie wybrałeś możesz później zmienić w Ustawieniach.</string>
<string name="onboarding_done_button">Otwórz mój kalendarz</string>
<plurals name="search_selected_count">
<item quantity="one">%d wybrane</item>
<item quantity="few">%d wybrane</item>
<item quantity="many">%d wybranych</item>
<item quantity="other">%d wybranych</item>
</plurals>
<string name="search_in_descriptions">Znaleziono w opisach</string>
<string name="search_selection_close">Anuluj wybór</string>
<string name="search_select_all">Wybierz wszystkie</string>
<string name="search_delete_selected">Usuń wybrane</string>
<plurals name="search_delete_title">
<item quantity="one">Usunąć %d wydarzenie?</item>
<item quantity="few">Usunąć %d wydarzenia?</item>
<item quantity="many">Usunąć %d wydarzeń?</item>
<item quantity="other">Usunąć %d wydarzeń?</item>
</plurals>
<plurals name="search_delete_done">
<item quantity="one">Usunięto %d wydarzenie</item>
<item quantity="few">Usunięto %d wydarzenia</item>
<item quantity="many">Usunięto %d wydarzeń</item>
<item quantity="other">Usunięto %d wydarzeń</item>
</plurals>
<string name="search_delete_partial">%1$d usunięto, %2$d nie udało się</string>
<string name="search_delete_denied_partial">%1$d usunięto, następnie utracono uprawnienia do zapisu</string>
<string name="settings_theme_hint">Czy aplikacja ma być jasna, czy ciemna. Wybór zostanie zastosowany natychmiast.</string>
<string name="settings_theme_system_summary">Obecnie %1$s</string>
<string name="settings_font_specimen_heading">czwartek, 14 maja</string>
<string name="settings_font_specimen_body">Przegląd zespołu o 10:00, a potem lunch z Robinem w kawiarni na rynku.</string>
<string name="settings_week_start_auto_summary">Obecnie %1$s</string>
<string name="settings_time_format_auto_summary">Zgodnie z systemem: %1$s</string>
<string name="settings_timeline_scale">Wysokość godzin</string>
<string name="settings_timeline_scale_hint">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.</string>
<string name="timeline_scale_fit_day">Cały dzień na ekranie</string>
<string name="timeline_scale_fit_day_summary">Cała doba na jednym ekranie, bez przewijania</string>
<string name="timeline_scale_compact">Kompaktowa</string>
<string name="timeline_scale_compact_summary">Więcej godzin na ekranie, mniejsze bloki</string>
<string name="timeline_scale_regular">Standardowa</string>
<string name="timeline_scale_regular_summary">Standardowy rozmiar</string>
<string name="timeline_scale_comfortable">Wygodna</string>
<string name="timeline_scale_comfortable_summary">Większe bloki, więcej przewijania</string>
<string name="timeline_scale_custom">Własna</string>
<string name="timeline_scale_custom_summary">Wysokość, którą wybrałeś gestem „uszczypnięcia” osi czasu</string>
<string name="settings_drag_to_reschedule">Przeciągnij, aby zmienić termin</string>
<string name="settings_drag_to_reschedule_summary">Przenieś wydarzenie, przeciągając je na inny dzień lub godzinę</string>
<string name="settings_past_events_sample_morning">Przegląd zespołu</string>
<string name="settings_past_events_sample_midday">Lunch z Robin</string>
<string name="settings_past_events_sample_evening">Próba chóru</string>
<string name="settings_widget_size">Rozmiar widżetu agendy</string>
<string name="settings_widget_size_hint">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.</string>
<string name="settings_widget_size_small">Mały</string>
<string name="settings_widget_size_medium">Średni</string>
<string name="settings_widget_size_large">Duży</string>
<string name="settings_widget_size_extra_large">Bardzo duży</string>
<string name="settings_widget_size_summary">Tekst wydarzenia %1$d%%</string>
<string name="settings_event_duration">Domyślny czas trwania</string>
<string name="settings_event_duration_hint">Czas trwania nowego wydarzenia do momentu zmiany godziny jego zakończenia. Nie dotyczy to wydarzeń całodniowych.</string>
<string name="settings_calendar_durations_title">Czas trwania dla poszczególnych kalendarzy</string>
<string name="settings_calendar_durations_hint">Przypisz kalendarzowi własną domyślną długość — np. 8 godzin dla zmian w pracy.</string>
<string name="settings_calendar_duration_inherits">Domyślny (%1$s)</string>
<string name="settings_calendar_duration_use_default">Używaj domyślnego czasu trwania (%1$s)</string>
<string name="settings_allday_reminder_fires_at">Powiadamia o %1$s</string>
<string name="settings_group_look">Wygląd i zachowanie</string>
<string name="settings_group_data">Dane</string>
<string name="settings_group_app">Aplikacja</string>
<string name="settings_group_about">O aplikacji</string>
<string name="settings_widgets_subtitle">Widżet agendy, kafelek Szybkich ustawień</string>
<string name="settings_backup_subtitle">Eksport, import, automatyczna kopia zapasowa</string>
<string name="settings_section_widgets">Widżety i kafelki</string>
<string name="settings_widgets_hint">Ustawienia widżetów ekranu głównego i kafelka Szybkich ustawień. Aby dodać widżet, naciśnij i przytrzymaj na ekranie domowym.</string>
<string name="settings_section_backup">Kopia zapasowa i przywracanie</string>
<string name="settings_views_all_header">Wszystkie widoki</string>
<string name="settings_week_day_header">Tydzień i dzień</string>
<string name="settings_default_view_hint">Widok, który pojawia się po uruchomieniu aplikacji Calendula.</string>
<string name="settings_week_start_hint">Dzień, od którego zaczyna się każdy tydzień we wszystkich widokach i widżetach.</string>
<string name="settings_time_format_hint">Format godziny w aplikacji. Opcja automatyczna dostosowuje się do ustawień systemowych.</string>
<string name="settings_past_events_hint">Co agenda robi z wydarzeniami, które już się zakończyły.</string>
<string name="settings_dynamic_color_summary">Dopasuj kolory aplikacji do swojej tapety.</string>
<string name="settings_about_privacy">Polityka prywatności</string>
<string name="calendars_visibility_hint">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.</string>
<string name="calendars_visibility_a11y">Pokaż \"%1$s\"</string>
<string name="calendars_visibility_notice_title">Niektóre kalendarze są wyłączone</string>
<string name="calendars_visibility_notice_message">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.</string>
<string name="calendar_picker_missing_title">Brakuje jakiegoś kalendarza?</string>
<string name="calendar_picker_missing_summary">Może być wyłączony, tylko do odczytu lub wypełniony na podstawie Twoich kontaktów — zarządzaj swoimi kalendarzami tutaj.</string>
<string name="calendars_state_read_only">Tylko do odczytu</string>
<string name="calendars_state_not_synced">Niezsynchronizowany z tym urządzeniem</string>
<string name="calendars_state_managed">Wypełniony na podstawie Twoich kontaktów</string>
<string name="calendars_managed_delete_locked">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.</string>
<string name="calendars_account_from_source">%1$s (%2$s)</string>
<string name="onboarding_backup_skip_button">Nie teraz</string>
</resources>

View File

@@ -207,4 +207,445 @@
<item quantity="other">%d minutos</item>
</plurals>
<string name="settings_special_dates_grant">Conceder acesso</string>
<string name="event_move_action">Mover…</string>
<string name="event_move_recurring_title">Mover evento recorrente</string>
<string name="event_move_occurrence_only">Mover a série inteira mudaria os dias em que o evento cai, então somente este evento pode se mover.</string>
<string name="event_move_done">Movido para %1$s</string>
<string name="event_move_undo">Desfazer</string>
<string name="event_move_undone">Mover desfeito</string>
<string name="event_move_failed">Não foi possível mover o evento</string>
<string name="event_move_write_denied">O Calendula precisa de permissão de escrita para mover eventos</string>
<string name="event_move_gone">Este evento não existe mais</string>
<string name="event_move_blocked_series_end">Impossível mover um evento após o fim de sua série</string>
<string name="event_status_tentative">Talvez</string>
<plurals name="duration_hours">
<item quantity="one">%d hora</item>
<item quantity="many">%d horas</item>
<item quantity="other">%d horas</item>
</plurals>
<plurals name="duration_days">
<item quantity="one">%d dia</item>
<item quantity="many">%d dias</item>
<item quantity="other">%d dias</item>
</plurals>
<string name="duration_hours_minutes">%1$s %2$s</string>
<string name="duration_custom_max">No máximo %1$s</string>
<plurals name="duration_weeks">
<item quantity="one">%d semana</item>
<item quantity="many">%d semanas</item>
<item quantity="other">%d semanas</item>
</plurals>
<string name="event_untitled">(Sem título)</string>
<string name="onboarding_step_counter">Passo %1$d de %2$d</string>
<string name="onboarding_backup_title">Seus eventos vivem somente aqui</string>
<string name="onboarding_backup_body">Nada neste aparelho sincroniza para uma conta, então perdê-lo levaria seu calendário junto. O Calendula pode criar um backup para você.</string>
<string name="onboarding_backup_benefit_folder_title">Uma pasta à sua escolha</string>
<string name="onboarding_backup_benefit_folder_body">Os backups são arquivos .ics — coloque-os em um lugar para sincronizar, ou um cartão SD.</string>
<string name="onboarding_backup_benefit_daily_title">Uma vez por dia, sozinho</string>
<string name="onboarding_backup_benefit_daily_body">O Calendula exporta seus calendários locais em segundo plano. Mude a frequência em Ajustes.</string>
<string name="onboarding_backup_enable_button">Escolher pasta e fazer backup</string>
<string name="onboarding_backup_skip_button">Agora não</string>
<string name="onboarding_view_title">O que deve abrir primeiro?</string>
<string name="onboarding_month_style_title">Como o Mês deve se parecer?</string>
<string name="onboarding_view_continue_button">Continuar</string>
<string name="onboarding_back">Voltar</string>
<string name="onboarding_visibility_button">Entendi</string>
<string name="onboarding_done_title">Tudo pronto</string>
<string name="onboarding_done_body">Seus calendários estão prontos. Todas as suas escolhas podem ser alteradas depois em Ajustes.</string>
<string name="onboarding_done_button">Abrir meu calendário</string>
<string name="reminder_channel_name">Lembretes de evento</string>
<string name="reminder_channel_description">Notificações na hora dos lembretes dos seus eventos</string>
<string name="reminder_onboarding_title">Nunca perca um evento</string>
<string name="reminder_onboarding_body">O Android não mostra lembretes sozinho — um app de calendário deve fazer isso. Deixe o Calendula cuidar disso.</string>
<string name="reminder_benefit_delivery_title">Lembretes, entregues</string>
<string name="reminder_benefit_delivery_body">Todos os seus lembretes de evento chegam em notificação, na hora certa.</string>
<string name="reminder_benefit_duplicates_title">Usando outro app de calendário?</string>
<string name="reminder_benefit_duplicates_body">Se outro app também postar lembretes, você verá eles em dobro — desligue aqui ou lá.</string>
<string name="reminder_benefit_reversible_title">Mude a qualquer hora</string>
<string name="reminder_benefit_reversible_body">O botão está em Ajustes, dentro de Notificações.</string>
<string name="reminder_onboarding_enable_button">Ativar lembretes</string>
<string name="reminder_onboarding_skip_button">Agora não</string>
<string name="reminder_action_snooze">Soneca</string>
<string name="reminder_action_dismiss">Dispensar</string>
<string name="reminder_day_tomorrow">Amanhã</string>
<string name="reminder_day_yesterday">Ontem</string>
<string name="view_month">Mês</string>
<string name="view_week">Semana</string>
<string name="view_day">Dia</string>
<string name="view_agenda">Agenda</string>
<string name="view_section">Vista</string>
<string name="drawer_jump_to_date">Ir para data</string>
<string name="agenda_today_action">Hoje</string>
<string name="agenda_header_today">Hoje</string>
<string name="agenda_header_tomorrow">Amanhã</string>
<string name="agenda_empty_title">Tudo em dia</string>
<string name="agenda_no_more_today">Sem mais eventos hoje</string>
<string name="agenda_span_starts">Começa às %1$s</string>
<string name="agenda_span_ends">Termina às %1$s</string>
<string name="today_jump_action">Ir para Hoje</string>
<string name="search_action">Buscar</string>
<string name="search_hint">Buscar eventos</string>
<string name="search_back">Voltar</string>
<string name="search_clear">Limpar</string>
<string name="search_idle_hint">Busque seus eventos por título, local ou notas.</string>
<string name="search_empty">Nenhum evento corresponde a \"%1$s\".</string>
<plurals name="search_selected_count">
<item quantity="one">%d selecionado</item>
<item quantity="many">%d selecionados</item>
<item quantity="other">%d selecionados</item>
</plurals>
<string name="search_in_descriptions">Encontrado em descrições</string>
<string name="search_selection_close">Cancelar seleção</string>
<string name="search_select_all">Selecionar tudo</string>
<string name="search_delete_selected">Deletar selecionados</string>
<plurals name="search_delete_title">
<item quantity="one">Deletar %d evento?</item>
<item quantity="many">Deletar %d eventos?</item>
<item quantity="other">Deletar %d eventos?</item>
</plurals>
<plurals name="search_delete_done">
<item quantity="one">%d evento deletado</item>
<item quantity="many">%d eventos deletados</item>
<item quantity="other">%d eventos deletados</item>
</plurals>
<string name="search_delete_partial">%1$d deletado, %2$d não pôde ser deletado</string>
<string name="search_delete_denied_partial">%1$d deletado, depois permissão de escrita removida</string>
<string name="widget_agenda_title">A seguir</string>
<string name="widget_agenda_label">Agenda do Calendula</string>
<string name="widget_month_label">Mês do Calendula</string>
<string name="widget_refresh">Atualizar</string>
<string name="widget_new_event">Novo evento</string>
<string name="widget_needs_permission">Abra o Calendula para conceder acesso ao calendário</string>
<string name="widget_prev_month">Mês passado</string>
<string name="widget_next_month">Mês que vem</string>
<string name="widget_today">Hoje</string>
<string name="filter_title">Calendários</string>
<string name="settings_title">Ajustes</string>
<string name="settings_back">Voltar</string>
<string name="back">Voltar</string>
<string name="settings_section_appearance">Aparência</string>
<string name="settings_theme">Tema</string>
<string name="settings_theme_system">Sistema</string>
<string name="settings_theme_light">Claro</string>
<string name="settings_theme_dark">Escuro</string>
<string name="settings_theme_hint">Se o app é claro ou escuro. Aplica imediatamente.</string>
<string name="settings_theme_system_summary">No momento %1$s</string>
<string name="settings_default_view">Vista padrão</string>
<string name="settings_dynamic_color">Cor dinâmica</string>
<string name="settings_dynamic_color_unavailable">Requer Android 12 ou superior</string>
<string name="settings_soften_colors">Harmonizar cores do calendário</string>
<string name="settings_soften_colors_summary">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.</string>
<string name="settings_font_headings">Fonte dos cabeçalhos</string>
<string name="settings_font_body">Fonte do corpo</string>
<string name="settings_font_system">Padrão do sistema</string>
<string name="font_atkinson_hyperlegible">Atkinson Hiperlegível</string>
<string name="font_lora">Lora</string>
<string name="font_jetbrains_mono">JetBrains Mono</string>
<string name="settings_font_choose_file">Escolher arquivo…</string>
<string name="settings_font_custom_selected">Fonte personalizada</string>
<string name="settings_font_import_failed">Não pôde ler arquivo como fonte</string>
<string name="settings_font_specimen_heading">Quinta-feira, 14 de maio</string>
<string name="settings_font_specimen_body">Reunião de equipe às 10:00, depois almoço com Robin no café em Markplatz.</string>
<string name="settings_week_start">A semana inicia em</string>
<string name="settings_week_start_auto">Automático</string>
<string name="settings_week_start_auto_summary">No momento %1$s</string>
<string name="settings_week_numbers">Números das semanas</string>
<string name="settings_week_numbers_summary">Mostrar números das semanas na vista do mês</string>
<string name="settings_today_toolbar">Botão \"Hoje\" na barra de ferramentas</string>
<string name="settings_today_toolbar_summary">Mostrar um botão \"Hoje\" na barra de ferramentas em vez de um botão flutuante</string>
<string name="settings_app_name">Nome do app</string>
<string name="settings_app_name_summary">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.</string>
<string name="settings_time_format">Formato de hora</string>
<string name="settings_time_format_auto">Automático</string>
<string name="settings_time_format_12h">12 horas (2:00 PM)</string>
<string name="settings_time_format_24h">24 horas (14:00)</string>
<string name="settings_time_format_auto_summary">Seguir o sistema: %1$s</string>
<string name="settings_hour_lines">Linhas de hora</string>
<string name="settings_hour_lines_summary">Mostrar um separador a cada hora nas vistas de semana e dia</string>
<string name="settings_timeline_scale">Altura da hora</string>
<string name="settings_timeline_scale_hint">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.</string>
<string name="timeline_scale_fit_day">Encaixar o dia todo</string>
<string name="timeline_scale_fit_day_summary">24 horas na tela, sem rolagem</string>
<string name="timeline_scale_compact">Compacto</string>
<string name="timeline_scale_compact_summary">Mais horas por tela, blocos menores</string>
<string name="timeline_scale_regular">Regular</string>
<string name="timeline_scale_regular_summary">Espaçamento padrão</string>
<string name="timeline_scale_comfortable">Confortável</string>
<string name="timeline_scale_comfortable_summary">Blocos maiores, mais rolagem</string>
<string name="timeline_scale_custom">Personalizado</string>
<string name="timeline_scale_custom_summary">A altura em que você pinçou a linha do tempo</string>
<string name="settings_dim_completed">Esmaecer eventos passados</string>
<string name="settings_dim_completed_summary">Esmaecer eventos que já terminaram nas vistas do mês e semana</string>
<string name="settings_drag_to_reschedule">Arraste para reagendar</string>
<string name="settings_drag_to_reschedule_summary">Mova um evento arrastando para outro dia ou hora</string>
<string name="settings_past_events">Eventos passados</string>
<string name="settings_past_events_show">Mostrar</string>
<string name="settings_past_events_dim">Esmaecer</string>
<string name="settings_past_events_hide">Ocultar</string>
<string name="settings_past_events_sample_morning">Revisão de equipe</string>
<string name="settings_past_events_sample_midday">Almoço com Robin</string>
<string name="settings_past_events_sample_evening">Treino do coral</string>
<string name="settings_agenda_header">Agenda</string>
<string name="settings_agenda_range">Amplitude da agenda</string>
<string name="settings_agenda_range_hint">O quanto à frente a tela de Agenda lista eventos.</string>
<string name="settings_agenda_widget_range">Amplitude do widget de Agenda</string>
<string name="settings_agenda_widget_range_hint">O quanto à frente o widget de Agenda lista eventos.</string>
<string name="settings_widget_size">Tamanho do widget de Agenda</string>
<string name="settings_widget_size_hint">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.</string>
<string name="settings_widget_size_small">Pequeno</string>
<string name="settings_widget_size_medium">Médio</string>
<string name="settings_widget_size_large">Grande</string>
<string name="settings_widget_size_extra_large">Extra grande</string>
<string name="settings_widget_size_summary">Texto do evento %1$d%%</string>
<string name="settings_agenda_show_today">Sempre mostrar Hoje</string>
<string name="settings_agenda_show_today_hint">Mantenha o dia de hoje no topo da agenda e no widget, mesmo que não tenha mais nada hoje.</string>
<string name="settings_agenda_range_bar">Barra de amplitude</string>
<string name="settings_agenda_range_bar_hint">Mostrar uma barra no topo da agenda nomeando as datas mostradas, com um botão para mudar a amplitude para a sessão</string>
<string name="agenda_range_day">Hoje</string>
<string name="agenda_range_this_week">Essa semana</string>
<string name="agenda_range_this_month">Esse mês</string>
<string name="agenda_range_week">Próximos 7 dias</string>
<string name="agenda_range_month">Próximos 30 dias</string>
<string name="agenda_range_custom">Personalizado…</string>
<string name="agenda_range_custom_hint">Dias</string>
<string name="agenda_range_override_hint">Só por agora — reajusta para sua amplitude salva ao reabrir o Calendula.</string>
<string name="agenda_range_showing_label">Mostrando eventos a seguir para</string>
<plurals name="agenda_range_days">
<item quantity="one">%d dia</item>
<item quantity="many">%d dias</item>
<item quantity="other">%d dias</item>
</plurals>
<string name="settings_section_views">Vistas</string>
<string name="settings_month_header">Vista do mês</string>
<string name="settings_month_view_style">Estilo de vista do mês</string>
<string name="month_style_paged">Páginas</string>
<string name="month_style_paged_summary">Um mês ocupa a tela. Deslize para os lados para mudar o mês.</string>
<string name="month_style_continuous">Meses contínuos</string>
<string name="month_style_continuous_summary">Cada mês tem o próprio cabeçalho, com um espaço separando-o do próximo.</string>
<string name="month_style_dense">Semanas integradas</string>
<string name="month_style_dense_summary">As semanas seguem sem separação, cada mês terminando no início do outro sem distinção entre eles.</string>
<string name="month_style_split">Dividido</string>
<string name="month_style_split_summary">Uma grade compacta marca os dias com eventos, e o dia clicado é listado abaixo.</string>
<string name="month_split_no_events">Nada agendado</string>
<string name="month_split_expand">Mostrar o mês inteiro</string>
<string name="month_split_collapse">Mostrar eventos do dia</string>
<string name="settings_quick_switch_header">Botão de troca rápida</string>
<string name="settings_quick_switch_hint">Escolha por quais vistas o botão superior direito circula, e arraste para reordenar. Vistas desativadas continuam acessíveis pelo menu de navegação.</string>
<string name="settings_drawer_order_header">Menu de navegação</string>
<string name="settings_drawer_order_hint">Arraste para reordenar vistas listadas no menu de navegação.</string>
<string name="reorder_drag_handle">Arraste para reordenar</string>
<string name="settings_section_event_form">Novo formulário de evento</string>
<string name="settings_form_fields_hint">Campos padrão — o restante está dentro de \"Mais campos\"</string>
<string name="settings_autofocus_title">Focar no título em novo evento</string>
<string name="settings_autofocus_title_hint">Ao iniciar um novo evento, colocar o cursor no campo de título e abrir o teclado.</string>
<string name="settings_event_duration">Duração padrão</string>
<string name="settings_event_duration_hint">Quanto tempo um novo evento dura até você mudar o tempo de encerramento. Eventos de dia inteiro não são afetados.</string>
<string name="settings_calendar_durations_title">Duração por calendário</string>
<string name="settings_calendar_durations_hint">Dar a um calendário a sua própria duração padrão — ex.: 8 horas para trabalho.</string>
<string name="settings_calendar_duration_inherits">Padrão (%1$s)</string>
<string name="settings_calendar_duration_use_default">Usar duração padrão (%1$s)</string>
<string name="settings_color_unsupported">Permitir cores em calendários não suportados</string>
<string name="settings_color_unsupported_hint">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.</string>
<string name="settings_section_notifications">Notificações</string>
<string name="settings_reminders">Lembretes de evento</string>
<string name="settings_reminders_hint">Vendo lembretes dobrados? Outro calendário está postando também — desligue em um dos dois.</string>
<string name="settings_default_reminder">Lembrete padrão</string>
<string name="settings_default_reminder_allday">Eventos de dia todo</string>
<string name="settings_allday_reminder_time">Tempo de lembrete de dia todo</string>
<string name="settings_allday_reminder_time_hint">Lembretes para eventos de dia todo disparam às %1$s</string>
<string name="settings_allday_reminder_fires_at">Notifica às %1$s</string>
<string name="reminder_none">Nenhum</string>
<string name="reminder_use_default">Usar lembrete padrão</string>
<string name="reminder_custom_amount">Quantidade</string>
<string name="reminder_custom_set">Ajustar</string>
<string name="settings_calendar_reminders_title">Lembretes por calendário</string>
<string name="settings_calendar_reminders_hint">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.</string>
<string name="settings_calendar_reminder_inherits">Padrão (%1$s)</string>
<string name="settings_reliable_delivery">Entrega confiável</string>
<string name="settings_reliable_delivery_hint">O Android pode atrasar lembretes para economizar bateria. Autorize o Calendula para que possam chegar na hora.</string>
<string name="settings_reliable_delivery_exempt">Isentar de otimização de bateria — lembretes chegam na hora.</string>
<string name="settings_snooze_duration">Duração de soneca</string>
<string name="settings_section_calendars">Calendários</string>
<string name="settings_manage_calendars">Gerenciar calendários</string>
<string name="settings_manage_calendars_hint">Crie calendários locais; gerencie os sincronizados</string>
<string name="settings_section_language">Idioma</string>
<string name="settings_language">Idioma do app</string>
<string name="settings_language_auto">Padrão do sistema</string>
<string name="settings_translate">Ajude a traduzir</string>
<string name="settings_translate_hint">Adicione ou melhore um idioma no Weblate</string>
<string name="settings_group_look">Aparência e comportamento</string>
<string name="settings_group_data">Dados</string>
<string name="settings_group_app">App</string>
<string name="settings_group_about">Sobre</string>
<string name="settings_appearance_subtitle">Tema, cores, fontes</string>
<string name="settings_views_subtitle">Vista, layout e ordem padrão</string>
<string name="settings_event_form_subtitle">Campos e comportamento padrão</string>
<string name="settings_notifications_subtitle">Lembretes e entrega</string>
<string name="settings_special_dates_subtitle">Aniversários e datas de contato</string>
<string name="settings_widgets_subtitle">Widget de Agenda, bloco de Ajustes Rápidos</string>
<string name="settings_backup_subtitle">Exportar, importar, backup automático</string>
<string name="settings_section_widgets">WIdgets e blocos</string>
<string name="settings_widgets_hint">Ajustes para widgets da tela inicial e bloco de Ajustes Rápidos. Adicione um widget pressionando e segurando a tela inicial.</string>
<string name="settings_section_backup">Backup e restaurar</string>
<string name="settings_views_all_header">Todas as vistas</string>
<string name="settings_week_day_header">Semana e dia</string>
<string name="settings_default_view_hint">A vista que o Calendula abre ao iniciar.</string>
<string name="settings_week_start_hint">O dia em que cada semana inicia, em todas as vistas e widgets.</string>
<string name="settings_time_format_hint">Como os horários são escritos pelo app. \"Automático\" segue a configuração do sistema.</string>
<string name="settings_past_events_hint">O que a agenda faz com eventos que já terminaram.</string>
<string name="settings_dynamic_color_summary">Use as cores do app de acordo com seu papel de parede.</string>
<string name="settings_section_special_dates">Datas especiais de contatos</string>
<string name="settings_special_dates_enable">Mostrar datas de contatos</string>
<string name="settings_special_dates_enable_hint">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.</string>
<string name="settings_special_dates_type_birthday">Aniversários</string>
<string name="settings_special_dates_type_anniversary">Aniversários de relacionamento</string>
<string name="settings_special_dates_type_custom">Outras datas</string>
<string name="settings_special_dates_template">Formato do título</string>
<string name="settings_special_dates_template_hint">Use {nome} para o contato e {ano} para o ano (de nascimento, ou de início do relacionamento; oculto quando não informado).</string>
<string name="settings_special_dates_reminders">Lembretes</string>
<string name="settings_special_dates_show_year">Mostrar ano</string>
<string name="settings_special_dates_show_year_hint">Incluir {ano} nos títulos caso informado</string>
<string name="settings_special_dates_sync_now">Sincronizar agora</string>
<string name="settings_special_dates_never_synced">Não sincronizado ainda</string>
<string name="settings_special_dates_last_synced">Sincronizado por último %1$s</string>
<string name="settings_special_dates_calendar_hint">Ajustar cada cor e visibilidade de calendário nos ajustes de Calendários.</string>
<string name="settings_calendar_reminders_managed_hint">Ajustar em datas especiais de Contatos</string>
<string name="settings_special_dates_paused_title">Pausado</string>
<string name="settings_special_dates_paused_hint">O Calendula não consegue mais ler seus contatos, então estes calendários não estão atualizando.</string>
<string name="settings_special_dates_disable_title">Desligar datas de contatos?</string>
<string name="settings_special_dates_disable_all_message">Isto deleta calendários de datas de contatos e seus eventos. Qualquer lembrete ou nota adicionada serão perdidos.</string>
<string name="settings_special_dates_disable_type_message">Isto deleta o calendário \"%1$s\" e seus eventos. Qualquer lembrete ou nota adicionados serão perdidos.</string>
<string name="settings_special_dates_disable_confirm">Desativar</string>
<string name="dialog_save">Salvar</string>
<string name="settings_section_about">Sobre</string>
<string name="settings_license">Licença</string>
<string name="settings_license_value">MIT</string>
<string name="settings_about_author">por Jean-Luc Makiola</string>
<string name="settings_about_source">Código-fonte</string>
<string name="settings_about_privacy">Política de privacidade</string>
<string name="settings_about_support">Apoie o desenvolvimento</string>
<string name="settings_about_version">Versão %1$s</string>
<string name="settings_about_logo_desc">Ícone de app do Calendula</string>
<string name="settings_report_problem">Reportar um problema</string>
<string name="settings_report_problem_hint">Envie um reporte de falha ou abra o tracker de problemas</string>
<string name="calendars_title">Calendários</string>
<string name="calendars_local_header">Seus calendários</string>
<string name="calendars_local_empty">Nenhum calendário local ainda. Crie um para manter eventos somente neste dispositivo.</string>
<string name="calendars_add">Adicionar calendário</string>
<string name="calendars_visibility_hint">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.</string>
<string name="calendars_visibility_a11y">Mostrar \"%1$s\"</string>
<string name="calendars_visibility_notice_title">Alguns calendários estão desativados</string>
<string name="calendars_visibility_notice_message">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.</string>
<string name="calendar_picker_missing_title">Sentindo falta de um calendário?</string>
<string name="calendar_picker_missing_summary">Pode estar desativado, somente-leitura, ou preenchido dos seus contatos — gerencie seus calendários aqui.</string>
<string name="calendars_state_read_only">Somente leitura</string>
<string name="calendars_state_not_synced">Não sincronizado neste dispositivo</string>
<string name="calendars_state_managed">Preenchido dos seus contatos</string>
<string name="calendars_managed_delete_locked">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.</string>
<string name="calendars_synced_header">Calendários sincronizados</string>
<string name="calendars_synced_hint">Estes são de contas no seu dispositivo. Crie e edite-os em seus próprios apps.</string>
<string name="calendars_manage_in_app">Gerenciar no app</string>
<string name="calendars_account_from_source">%1$s (%2$s)</string>
<string name="calendars_account_menu_a11y">Mais opções para %1$s</string>
<string name="calendars_enable_all">Habilitar todos</string>
<string name="calendars_disable_all">Desabilitar todos</string>
<string name="calendars_add_account">Adicionar conta</string>
<string name="calendars_new_title">Novo calendário</string>
<string name="calendars_edit_title">Editar calendário</string>
<string name="calendars_name_label">Nome</string>
<string name="calendars_color_label">Cor</string>
<string name="calendars_description_hint">Adicionar descrição</string>
<string name="calendars_delete_confirm_title">Deletar calendário?</string>
<string name="calendars_delete_confirm_message">\"%1$s\" e todos os seus eventos serão permanentemente removidos deste dispositivo.</string>
<string name="calendars_write_error">Não foi possível salvar a alteração.</string>
<string name="calendars_backup_header">Backup</string>
<string name="calendars_backup_hint">Calendários locais não são sincronizados, então exporte-os para um arquivo .ics para manter uma cópia.</string>
<string name="calendars_backup_action">Exportar como arquivo .ics</string>
<string name="calendars_export_title">Exportar calendários</string>
<string name="calendars_export_hint">Escolha quais calendários incluir no arquivo .ics.</string>
<string name="calendars_export_action">Exportar</string>
<string name="calendars_restore_header">Restaurar</string>
<string name="calendars_restore_action">Restaurar de um arquivo .ics</string>
<string name="calendars_restore_hint">Importar eventos de um backup ou outro app de calendário.</string>
<string name="calendars_auto_backup">Backup automático</string>
<string name="calendars_auto_backup_hint">Exportar periodicamente seu calendário local para uma pasta como um arquivo .ics.</string>
<string name="calendars_auto_backup_folder">Pasta de backup</string>
<string name="calendars_auto_backup_folder_unset">Toque para escolher uma pasta</string>
<string name="calendars_auto_backup_interval">Intervalo</string>
<string name="calendars_auto_backup_every">A cada %1$s</string>
<string name="calendars_auto_backup_interval_min">Mínimo de 30 minutos.</string>
<string name="calendars_auto_backup_status_never">Nenhum backup automático ainda</string>
<string name="calendars_auto_backup_status_ok">Último backup: %1$s</string>
<string name="calendars_auto_backup_status_failed">Último backup falho: %1$s</string>
<string name="backup_channel_name">Backup</string>
<string name="backup_channel_description">Avisa se os backups automáticos falharem repetidamente.</string>
<string name="backup_failed_title">Backup automático falhou</string>
<string name="backup_failed_text">O Calendula não pôde salvar o arquivo de backup. Verifique a pasta de backup em Ajustes.</string>
<string name="calendars_backup_failed">Não foi possível exportar o backup.</string>
<plurals name="calendars_backup_done">
<item quantity="one">Exportou %d evento.</item>
<item quantity="many">Exportou %d eventos.</item>
<item quantity="other">Exportou %d eventos.</item>
</plurals>
<string name="import_title">Importar eventos</string>
<string name="import_target_header">Adicionar ao calendário</string>
<string name="import_empty">Nenhum evento encontrado neste arquivo.</string>
<string name="import_failed">Não foi possível ler este arquivo.</string>
<string name="import_no_calendar">Nenhum calendário salvável para importar. Crie um calendário local primeiro.</string>
<string name="import_done_title">Importação concluída</string>
<string name="import_done_dedup_note">Eventos já no calendário foram pulados.</string>
<string name="import_done_added_label">Adicionado</string>
<string name="import_done_skipped_label">Duplicatas</string>
<string name="import_close">Fechar</string>
<string name="import_warning_recurrence">Algumas ocorrências alteradas de eventos recorrentes foram puladas.</string>
<string name="import_warning_no_start">Um evento sem horário de início foi pulado.</string>
<string name="import_warning_attendees">Listas de convidados não foram importadas.</string>
<string name="import_warning_timezone">Um fuso-horário desconhecido foi alterado para o do seu dispositivo.</string>
<string name="import_button">Importar</string>
<plurals name="import_title_count">
<item quantity="one">Importando %d evento</item>
<item quantity="many">Importando %d eventos</item>
<item quantity="other">Importando %d eventos</item>
</plurals>
<plurals name="import_event_count">
<item quantity="one">%d evento neste arquivo.</item>
<item quantity="many">%d eventos neste arquivo.</item>
<item quantity="other">%d eventos neste arquivo.</item>
</plurals>
<plurals name="import_action">
<item quantity="one">Importar %d evento</item>
<item quantity="many">Importar %d eventos</item>
<item quantity="other">Importar %d eventos</item>
</plurals>
<plurals name="import_done_imported">
<item quantity="one">Importou %d evento.</item>
<item quantity="many">Importou %d eventos.</item>
<item quantity="other">Importou %d eventos.</item>
</plurals>
<plurals name="import_done_skipped">
<item quantity="one">Pulou %d já neste calendário.</item>
<item quantity="many">Pulou %d já neste calendário.</item>
<item quantity="other">Pulou %d já neste calendário.</item>
</plurals>
<string name="shortcut_new_event_short">Novo evento</string>
<string name="shortcut_new_event_long">Criar novo evento</string>
<string name="qs_tile_new_event_label">Novo evento</string>
<string name="settings_qs_tile">Adicionar bloco Ajustes Rápidos</string>
<string name="settings_qs_tile_hint">Adicionar bloo \"Novo evento\" no painel Ajustes Rápidos.</string>
<string name="crash_dialog_title">%1$s quebrou</string>
<string name="crash_dialog_message">%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.</string>
<string name="crash_dialog_report">Reportar</string>
<string name="crash_dialog_dismiss">Agora não</string>
<string name="crash_report_issue_title">Reportar quebra</string>
<string name="crash_report_clip_label">%1$s relatório de quebra</string>
<string name="crash_report_copied">Relatório copiado para a área de transferência</string>
<string name="crash_report_open_failed">Não foi possível abrir o tracker de problemas. O relatório está na área de transferência.</string>
<string name="crash_report_body_template">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</string>
<string name="crash_report_body_paste">_(O reporte era muito longo para este link — cole da área de transferência aqui.)_</string>
<string name="special_dates_calendar_birthday">Aniversários</string>
<string name="special_dates_calendar_anniversary">Aniversários de relacionamento</string>
<string name="special_dates_calendar_custom">Datas especiais</string>
<string name="special_dates_default_title_birthday">Aniversário de {nome} ({ano})</string>
<string name="special_dates_default_title_anniversary">Aniversário de relacionamento de {nome} ({ano})</string>
<string name="special_dates_default_title_custom">{nome}</string>
</resources>

View File

@@ -17,14 +17,14 @@
<string name="state_failure_no_calendars_action">Перейти в системные настройки календаря</string>
<string name="state_failure_provider">Не удалось просмотреть календарь.</string>
<string name="permission_rationale_title">Следите за всеми своими событиями. С красотой</string>
<string name="permission_rationale_body">Calendula требуется доступ к вашему календарю, чтобы показывать и управлять вашими событиями. Это всё, что приложение требует с самого начала и никакая информация не покидает ваш девайс.</string>
<string name="permission_rationale_body">Calendula требуется доступ к вашему календарю, чтобы показывать и управлять вашими событиями. Это всё, что приложение требует с самого начала и никакая информация не покидает ваше устройство.</string>
<string name="permission_request_button">Дать доступ к календарю</string>
<string name="permission_denied_title">В доступе к календарю отказано</string>
<string name="permission_denied_body">Calendula не может показать события без доступа к календарю. Предоставьте его снова в настройках телефона.</string>
<string name="permission_open_settings_button">Открыть настройки телефона</string>
<string name="permission_retry_button">Попытайтесь снова</string>
<string name="permission_benefit_private_title">Остаётся на вашем устройстве</string>
<string name="permission_benefit_private_body">Ваши календари просматриваются локально и информация никогда не покидает устройство.</string>
<string name="permission_benefit_private_title">Приватность по умолчанию</string>
<string name="permission_benefit_private_body">Нет доступа к интернету, нету телеметрии — ничто не покидает ваше устройство.</string>
<string name="permission_benefit_sync_title">Все ваши календари. Вместе</string>
<string name="permission_benefit_sync_body">Google, CalDAV, локальный календарь всё синхронизированное с устройством просто подключается.</string>
<string name="permission_benefit_privacy_title">Никакого отслеживания. Никогда</string>
@@ -62,4 +62,118 @@
<string name="event_edit_close">Закрыть</string>
<string name="event_edit_save">Сохранить</string>
<string name="event_edit_title_hint">Добавить имя</string>
<string name="event_move_action">Перенести…</string>
<string name="event_move_recurring_title">Перенести повторяющиеся события</string>
<string name="event_move_done">Перенесено на %1$s</string>
<string name="event_move_undo">Отменить</string>
<string name="event_move_occurrence_only">Перемещение всей серии изменит дни на которые она попадёт, поэтому можно переместить только это событие.</string>
<string name="event_move_undone">Перемещение отменено</string>
<string name="event_move_failed">Не удалось переместить событие</string>
<string name="event_move_write_denied">Приложению Calendula требуется доступ на запись, чтобы перемещать события</string>
<string name="event_move_gone">Это событие больше не существует</string>
<string name="event_move_blocked_series_end">Нельзя переместить событие дальше за пределы его серии</string>
<string name="event_edit_managed_hint">Управляется «%1$s» — название, дата и повторение синхронизируются с ваших контактов. Напоминания, местоположения и заметки могут быть редактированы вами.</string>
<string name="event_edit_starts">Начинается</string>
<string name="event_edit_ends">Заканчивается</string>
<string name="event_edit_error_end_before_start">Заканчивается раньше, чем начинается</string>
<string name="event_edit_error_no_calendar">Нет календарей с правами на запись</string>
<string name="event_edit_save_failed">Не удалось сохранить событие</string>
<string name="event_edit_write_denied">Приложению Calendula требуется доступ на запись, чтобы создавать события</string>
<string name="event_edit_more_fields">Больше полей</string>
<string name="event_edit_add">Добавить</string>
<string name="event_edit_add_reminder">Добавить напоминание</string>
<string name="event_edit_remove_reminder">Удалить напоминание</string>
<string name="event_edit_attendees">Гости</string>
<string name="event_edit_add_guest">Добавить гостя</string>
<string name="event_edit_add_guest_hint">Добавить гостя по почте…</string>
<string name="event_edit_add_guest_from_contacts">Добавить из контактов</string>
<string name="event_edit_location_from_contacts">Выбрать адрес из контактов</string>
<string name="event_edit_remove_guest">Удалить гостя</string>
<string name="event_edit_attendee_required">Обязательный</string>
<string name="event_edit_attendee_optional">Необязательный</string>
<string name="event_edit_attendees_note_synced">Calendula не отправляет приглашения. Ваша учётная запись календаря может отправить письма гостям при синхронизации.</string>
<string name="event_edit_attendees_note_local">Хранится на этом устройстве. Никто не получит уведомление.</string>
<string name="event_edit_reminder_custom">Другое</string>
<string name="reminder_unit_minutes">минуты</string>
<string name="reminder_unit_hours">часы</string>
<string name="reminder_unit_days">дни</string>
<string name="reminder_unit_weeks">недели</string>
<string name="event_edit_availability">Занятость</string>
<string name="event_edit_visibility">Видимость</string>
<string name="event_edit_timezone_device">Часовой пояс устройства</string>
<string name="event_edit_timezone_device_summary">Следует за вами, где бы вы ни были</string>
<string name="event_edit_timezone_search">Поиск часовых поясов</string>
<string name="event_edit_timezone_recent">Недавние</string>
<string name="event_edit_timezone_all">Все часовые пояса</string>
<string name="event_edit_timezone_none">Часовые пояса, соответсвующие «%1$s», не найдены</string>
<string name="event_edit_timezone_local_time">%1$s по вашему времени</string>
<string name="event_edit_color">Цвет</string>
<string name="event_edit_color_default">Цвет календаря</string>
<string name="event_edit_color_custom">Свой цвет</string>
<string name="event_edit_color_reset">Сбросить</string>
<string name="event_edit_color_unsupported">Недоступно для этого календаря</string>
<string name="event_edit_color_unsupported_hint">Этот календарь не публикует набор цветов. Вы можете разрешить пользовательские цвета для таких календарей в Настройках.</string>
<string name="event_edit_color_sync_warning">Этот календарь может потерять или перезаписать цвет при следующей синхронизации.</string>
<string name="event_edit_conflict_title">Событие изменено в другом месте</string>
<string name="event_edit_conflict_body">Пока вы редактировали, это событие было изменено — синхронизацией или другим приложением. Что вы хотите сделать с вашими изменениями?</string>
<string name="event_edit_conflict_overwrite">Сохранить мои изменения</string>
<string name="event_edit_conflict_overwrite_hint">Только изменённые вами поля, перезаписывают внешние изменения</string>
<string name="event_edit_conflict_discard">Отменить мои изменения</string>
<string name="event_edit_conflict_discard_hint">Событие остаётся таким, как сейчас</string>
<string name="event_edit_gone_title">Событие удалено</string>
<string name="event_edit_gone_body">Это событие было удалено, пока вы его редактировали, например на другом устройстве. Ваши изменения больше не могут быть сохранены.</string>
<string name="import_reminder_prompt_title">Применить ваше стандартное напоминание?</string>
<string name="import_reminder_prompt_body_none">Это событие было импортировано без каких-либо напоминаний.</string>
<plurals name="import_reminder_prompt_body_existing">
<item quantity="one">Это событие было импортировано с %1$d напоминанием.</item>
<item quantity="few">Это событие было импортировано с %1$d напоминаниями.</item>
<item quantity="many">Это событие было импортировано с %1$d напоминаниями.</item>
<item quantity="other">Это событие было импортировано с %1$d напоминаниями.</item>
</plurals>
<string name="import_reminder_prompt_apply">Применить стандартное</string>
<string name="import_reminder_prompt_keep">Оставить как есть</string>
<string name="event_edit_recurrence_none">Не повторяется</string>
<string name="event_edit_recurrence_custom">Свой</string>
<string name="event_edit_recurrence_every">Каждый</string>
<string name="recurrence_unit_days">дни</string>
<string name="recurrence_unit_weeks">недели</string>
<string name="recurrence_unit_months">месяцы</string>
<string name="recurrence_unit_years">года</string>
<string name="event_edit_recurrence_incomplete">Введите число от 1 до 999</string>
<string name="event_edit_recurrence_next_none">Это правило никогда не повторяется</string>
<string name="event_edit_recurrence_ends">Заканчивается</string>
<string name="event_edit_recurrence_end_never">Никогда</string>
<string name="event_edit_recurrence_end_until">До</string>
<string name="event_edit_recurrence_end_count">После определённого количества</string>
<string name="event_availability_busy">Занят</string>
<string name="event_access_default">Стандартный</string>
<string name="event_access_public">Публичный</string>
<string name="event_detail_all_day">Весь день</string>
<string name="event_detail_calendar">Календарь</string>
<string name="event_detail_calendar_unknown">Неизвестный календарь</string>
<string name="event_detail_location">Местоположение</string>
<string name="event_detail_description">Описание</string>
<string name="event_detail_recurring">Повторяющееся событие</string>
<string name="recurrence_daily">Каждый день</string>
<string name="recurrence_weekly">Каждую неделю</string>
<string name="recurrence_monthly">Каждый месяц</string>
<string name="recurrence_yearly">Каждый год</string>
<string name="recurrence_every_n_days">Каждые %1$d дней</string>
<string name="recurrence_every_n_weeks">Каждые %1$d недель</string>
<string name="recurrence_every_n_months">Каждые %1$d месяцев</string>
<string name="recurrence_every_n_years">Каждые %1$d лет</string>
<string name="event_detail_not_found">Это событие больше не существует.</string>
<string name="event_attendee_accepted">Принято</string>
<string name="event_attendee_declined">Отклонено</string>
<string name="event_attendee_needs_action">Без ответа</string>
<string name="event_detail_reminders">Напоминания</string>
<string name="event_detail_timezone">Временная зона</string>
<string name="event_status_cancelled">Отменён</string>
<string name="event_availability_free">Бесплатно</string>
<string name="event_access_private">Приватно</string>
<string name="event_access_confidential">Конфиденциально</string>
<string name="event_access_public_summary">Любой с доступом может видеть все детали</string>
<string name="event_access_private_summary">Другие видят только, что вы заняты</string>
<string name="event_detail_self_response">Ваш ответ: %1$s</string>
<string name="reminder_at_time">Во время начала события</string>
</resources>

View File

@@ -0,0 +1,190 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="backup_interval_units">
<item>Minutes</item>
<item>Hours</item>
<item>Days</item>
<item>Weeks</item>
</string-array>
<string name="app_name">Calendula</string>
<string name="app_tagline">Moderný kalendár.</string>
<string name="state_loading">Načítava sa…</string>
<string name="state_retry">Opakovať</string>
<string name="state_failure_unknown">Niečo sa pokazilo.</string>
<string name="state_failure_permission">Je potrebný prístup ku kalendáru.</string>
<string name="state_failure_permission_action">Udeliť prístup</string>
<string name="state_failure_no_calendars">Nie sú nakonfigurované žiadne kalendáre.</string>
<string name="state_failure_no_calendars_action">Otvoriť systémové nastavenie kalendára</string>
<string name="state_failure_provider">Nepodarilo sa načítať kalendár.</string>
<string name="permission_rationale_title">Prehľadávajte všetky svoje udalosti v krásnom prevedení</string>
<string name="permission_rationale_body">Calendula potrebuje prístup k vášmu kalendáru, aby mohla zobraziť a spravovať vaše udalosti.</string>
<string name="permission_request_button">Udeliť prístup k kalendáru</string>
<string name="permission_denied_title">Prístup ku kalendáru bol zamietnutý</string>
<string name="permission_denied_body">Calendula nemôže zobraziť udalosti bez prístupu ku kalendáru. Môžete ho znova udeliť v nastavení systému.</string>
<string name="permission_open_settings_button">Otvoriť systémové nastavenia</string>
<string name="permission_retry_button">Skúsiť znova</string>
<string name="permission_benefit_private_title">Súkromné už od začiatku</string>
<string name="permission_benefit_private_body">Žiadny prístup k internetu, žiadna telemetria - nikdy nič z vášho telefónu neunikne.</string>
<string name="permission_benefit_sync_title">Všetky vaše kalendáre pohromade</string>
<string name="permission_benefit_sync_body">Google, CalDAV, lokálne kalendáre - čokoľvek synchronizované zo zariadením sa proste objaví.</string>
<string name="permission_benefit_privacy_title">Nikdy žiadne sledovanie</string>
<string name="permission_benefit_privacy_body">Žiadna telemetria, žiadne analytické nástroje, bez reklám.</string>
<string name="permission_privacy_footnote">Ostáva na vašom zariadení · bez prístupu k internetu</string>
<string name="month_prev">Predchádzajúci mesiac</string>
<string name="month_next">Nasledujúci mesiac</string>
<string name="month_today_action">Dnes</string>
<string name="month_more_actions">Ďalšie možnosti</string>
<string name="month_open_menu">Otvoriť menu</string>
<string name="month_action_settings">Nastavenia</string>
<string name="month_a11y_today_prefix">Dnes</string>
<string name="week_today_action">Tento víkend</string>
<string name="week_number_label">týžd.</string>
<string name="day_today_action">Dnes</string>
<string name="event_detail_back">Späť</string>
<string name="event_detail_edit">Upraviť</string>
<string name="event_detail_delete">Vymazať</string>
<string name="event_detail_share">Zdielať</string>
<string name="event_detail_duplicate">Duplikovať</string>
<string name="event_share_chooser_title">Zdielať udalosť</string>
<string name="event_share_failed">Túto udalosť nieje možné zdieľať.</string>
<string name="event_delete_title">Odstrániť udalosť?</string>
<string name="event_delete_body">Udalosť je odstránená z vášho kalendára aj zo všetkých synchronizovaných zariadení.</string>
<string name="event_delete_recurring_title">Odstrániť opakujúcu sa udalosť</string>
<string name="event_delete_option_occurrence">Iba túto udalosť</string>
<string name="event_delete_option_following">Túto aj všetky nasledujúce udalosti</string>
<string name="event_delete_option_series">Všetky udalosti v sérii</string>
<string name="event_edit_recurring_title">Upraviť opakujúcu sa udalosť</string>
<string name="event_delete_failed">Túto udalosť nebolo možné odstrániť</string>
<string name="event_delete_write_denied">Calendula potrebuje povolenie k zápisu, aby mohla odstrániť udalosti</string>
<string name="event_move_action">Presunúť…</string>
<string name="event_move_recurring_title">Presunúť opakujúcu sa udalosť</string>
<string name="event_move_occurrence_only">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ť.</string>
<string name="event_move_done">Presunuté na %1$s</string>
<string name="event_move_undo">Vrátiť späť</string>
<string name="event_move_undone">Navrátené naspäť</string>
<string name="event_move_failed">Nemožno presunúť udalosť</string>
<string name="event_move_write_denied">Calendula potrebuje povolenie k zápisu, aby mohla presúvať udalosti</string>
<string name="event_move_gone">Táto udalosť už neexistuje</string>
<string name="event_move_blocked_series_end">Nie je možné presunúť udalosť za koniec danej série</string>
<string name="dialog_cancel">Zrušiť</string>
<string name="dialog_ok">OK</string>
<string name="event_edit_new_title">Nová udalosť</string>
<string name="event_edit_close">Zavrieť</string>
<string name="event_edit_save">Uložiť</string>
<string name="event_edit_title_hint">Pridať názov</string>
<string name="event_edit_managed_hint">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ť.</string>
<string name="event_edit_starts">Začiatok</string>
<string name="event_edit_ends">Koniec</string>
<string name="event_edit_error_end_before_start">Končí pred začiatkom</string>
<string name="event_edit_error_no_calendar">Není dostupný kalendár, ktorý je možno upravovať</string>
<string name="event_edit_save_failed">Nemožno udalosť uložiť</string>
<string name="event_edit_write_denied">Calendula potrebuje povolenie k zápisu, aby mohla vytvárať udalosti</string>
<string name="event_edit_more_fields">Viac poli</string>
<string name="event_edit_add">Pridať</string>
<string name="event_edit_add_reminder">Pridať pripomienku</string>
<string name="event_edit_remove_reminder">Odstrániť pripomienku</string>
<string name="event_edit_attendees">Hostia</string>
<string name="event_edit_add_guest">Pridať hosťa</string>
<string name="event_edit_add_guest_hint">Pridať hosťa e-mailom…</string>
<string name="event_edit_add_guest_from_contacts">Pridať z kontaktov</string>
<string name="event_edit_location_from_contacts">Vybrať adresu z kontaktov</string>
<string name="event_edit_remove_guest">Odstrániť hosťa</string>
<string name="event_edit_attendee_required">Nutné</string>
<string name="event_edit_attendee_optional">Voliteľné</string>
<string name="event_edit_attendees_note_synced">Calendula neposiela pozvánky. Váš kalendárny účet ich môže poslať keď sa synchronizuje.</string>
<string name="event_edit_attendees_note_local">Uložené na tomto zariadení. Nikomu nebude poslaná notifikácia.</string>
<string name="event_edit_reminder_custom">Vlastné</string>
<string name="reminder_unit_minutes">minúty</string>
<string name="reminder_unit_hours">hodiny</string>
<string name="reminder_unit_days">dni</string>
<string name="reminder_unit_weeks">týždne</string>
<string name="event_edit_availability">Dostupnosť</string>
<string name="event_edit_visibility">Viditeľnosť</string>
<string name="event_edit_timezone_device">Časové pásmo zariadenia</string>
<string name="event_edit_timezone_device_summary">Sleduje vás, nech ste kdekoľvek</string>
<string name="event_edit_timezone_search">Vyhľadať časové pásmo</string>
<string name="event_edit_timezone_recent">Nedávne</string>
<string name="event_edit_timezone_all">Všetky časové pásma</string>
<string name="event_edit_timezone_none">Žiadne časové pásmo nezodpovedá “%1$s”</string>
<string name="event_edit_timezone_local_time">%1$s vášho času</string>
<string name="event_edit_color">Farba</string>
<string name="event_edit_color_default">Farba kalendáru</string>
<string name="event_edit_color_custom">Vlastná farba</string>
<string name="event_edit_color_reset">Obnoviť</string>
<string name="event_edit_color_unsupported">Není k dispozícii pre tento kalendár</string>
<string name="event_edit_color_unsupported_hint">Tento kalendár nemá vlastnú sadu farieb. Pre takýto kalendár môžte povoliť vlastné farby v nastaveniach.</string>
<string name="event_edit_color_sync_warning">Tento kalendár môže vymazať alebo prepísať farbu pri ďalšej synchronizácii.</string>
<string name="event_edit_conflict_title">Udalosť bola zmenená niekde inde</string>
<string name="event_edit_conflict_body">Počas úprav sa táto udalosť zmenila — synchronizáciou alebo inou aplikáciou. Čo sa má stať s vašimi zmenami?</string>
<string name="event_edit_conflict_overwrite">Uložiť zmeny</string>
<string name="event_edit_conflict_overwrite_hint">Iba u upravených polí sa prepíše zmena z iného zdroja</string>
<string name="event_edit_conflict_discard">Zrušiť zmeny</string>
<string name="event_edit_conflict_discard_hint">Udalosť ostáva bez zmeny</string>
<string name="event_edit_gone_title">Udalosť odstránená</string>
<string name="event_edit_gone_body">Počas úprav bola táto udalosť odstránená, napríklad na inom zariadení. Vaše zmeny už nie je možné uložiť.</string>
<string name="import_reminder_prompt_title">Chcete použiť predvolenú pripomienku?</string>
<string name="import_reminder_prompt_body_none">Táto udalosť bola importovaná bez pripomienky.</string>
<plurals name="import_reminder_prompt_body_existing">
<item quantity="one">Táto udalosť bola importovaná s %1$d pripomienkou.</item>
<item quantity="few">Táto udalosť bola importovaná s %1$d pripomienkami.</item>
<item quantity="many">Táto udalosť bola importovaná s %1$d pripomienkami.</item>
<item quantity="other">Táto udalosť bola importovaná s %1$d pripomienkami.</item>
</plurals>
<string name="import_reminder_prompt_apply">Nastaviť predvolené</string>
<string name="import_reminder_prompt_keep">Nechať bez zmeny</string>
<string name="event_edit_recurrence_none">Neopakuje sa</string>
<string name="event_edit_recurrence_custom">Vlastné</string>
<string name="event_edit_recurrence_every">Každý</string>
<string name="recurrence_unit_days">dni</string>
<string name="recurrence_unit_weeks">týždne</string>
<string name="recurrence_unit_months">mesiace</string>
<string name="recurrence_unit_years">roky</string>
<string name="event_edit_recurrence_incomplete">Zadajte číslo od 1 do 999</string>
<string name="event_edit_recurrence_next">Nasledujúce: %1$s</string>
<string name="event_edit_recurrence_next_none">Toto pravidlo sa nikdy neopakuje</string>
<string name="event_edit_recurrence_ends">Koniec</string>
<string name="event_edit_recurrence_end_never">Nikdy</string>
<string name="event_edit_recurrence_end_until">K určitému dátumu</string>
<string name="event_edit_recurrence_end_count">Po určitom počtu opakovaní</string>
<string name="event_edit_recurrence_times">opakovaní</string>
<string name="event_edit_error_recurrence_ends_before_start">Opakovanie končí pred začiatkom udalosti</string>
<string name="event_availability_busy">Zaneprázdnený</string>
<string name="event_access_default">Predvolený</string>
<string name="event_access_public">Verejný</string>
<string name="event_detail_all_day">Celý deň</string>
<string name="event_detail_calendar">Kalendár</string>
<string name="event_detail_calendar_unknown">Neznámy kalendár</string>
<string name="event_detail_location">Poloha</string>
<string name="event_detail_description">Popis</string>
<string name="event_detail_attendees">Účastníci</string>
<string name="event_detail_recurrence">Opakovanie</string>
<string name="event_detail_recurring">Opakovaná udalosť</string>
<string name="recurrence_daily">Každý deň</string>
<string name="recurrence_weekly">Každý týždeň</string>
<string name="recurrence_monthly">Každý mesiac</string>
<string name="recurrence_yearly">Každý rok</string>
<string name="recurrence_every_n_days">Každý %1$d. deň</string>
<string name="recurrence_every_n_weeks">Každý %1$d. týždeň</string>
<string name="recurrence_every_n_months">Každý %1$d. mesiac</string>
<string name="recurrence_every_n_years">Každý %1$d. rok</string>
<string name="recurrence_on_days">%1$s v %2$s</string>
<string name="recurrence_with_until">%1$s do%2$s</string>
<string name="recurrence_with_count">%1$s, %2$d opakovaní</string>
<string name="event_detail_not_found">Táto udalosť už neexistuje.</string>
<string name="event_attendee_accepted">Akceptované</string>
<string name="event_attendee_declined">Zamietnuté</string>
<string name="event_attendee_tentative">Predbežne</string>
<string name="event_attendee_needs_action">Bez odozvy</string>
<string name="event_attendee_unknown"></string>
<string name="event_detail_reminders">Pripomienky</string>
<string name="event_detail_timezone">Časové pásmo</string>
<string name="event_status_tentative">Predbežne</string>
<string name="event_status_cancelled">Zrušené</string>
<string name="event_availability_free">Voľno</string>
<string name="event_access_private">Súkromné</string>
<string name="event_access_confidential">Tajné</string>
<string name="event_access_default_summary">Bežná činnosť kalendáru</string>
<string name="event_access_public_summary">Kdekoľvek s prístupom vidí všetky detaily</string>
<string name="event_access_private_summary">Ostatný vidia iba že ste zaneprázdnený</string>
<string name="event_access_confidential_summary">Označené ako utajené; záleží na kalendárnom účtu čo toto znamená</string>
</resources>

View File

@@ -6,34 +6,33 @@
<item>Days</item>
<item>Weeks</item>
</string-array>
<string name="app_name">Calendula</string>
<string name="app_tagline">一个现代化的日历应用</string>
<string name="app_tagline">现代化的日历应用</string>
<string name="state_loading">加载中…</string>
<string name="state_retry">推迟</string>
<string name="state_failure_unknown">错误!</string>
<string name="state_failure_permission">需要日历权限</string>
<string name="state_retry">重试</string>
<string name="state_failure_unknown">出错了。</string>
<string name="state_failure_permission">需要日历访问权限</string>
<string name="state_failure_permission_action">授予访问权限</string>
<string name="state_failure_no_calendars">无已设置的日历</string>
<string name="state_failure_no_calendars">未配置任何日历</string>
<string name="state_failure_no_calendars_action">打开系统日历设置</string>
<string name="state_failure_provider">无法读取日历</string>
<string name="permission_rationale_title">优雅地查看你的所有活动</string>
<string name="permission_rationale_body">Calendula需要获取权限来访问您的日历展示和管理您的活动。所有权限均会在使用前申请——不必担心隐私泄露</string>
<string name="permission_request_button">授予日历权限</string>
<string name="permission_denied_title">日历权限不可用</string>
<string name="permission_denied_body">Calendula 无法显示没有日历访问权限的事件。可以在系统设置里再次授予它</string>
<string name="state_failure_provider">无法读取日历</string>
<string name="permission_rationale_title">优雅地查看您所有的事件</string>
<string name="permission_rationale_body">Calendula 需要访问您的日历,以展示和管理您的事件</string>
<string name="permission_request_button">授予日历访问权限</string>
<string name="permission_denied_title">日历访问被拒绝</string>
<string name="permission_denied_body">Calendula 无法没有日历访问权限的情况下显示事件。可以在系统设置中重新授予权限</string>
<string name="permission_open_settings_button">打开系统设置</string>
<string name="permission_retry_button">重试</string>
<string name="permission_benefit_private_title">保留在设备</string>
<string name="permission_benefit_private_body">您的日历数据将会在本地读取,而不会被上传</string>
<string name="permission_benefit_sync_title">所有日历</string>
<string name="permission_benefit_sync_body">Google、CalDAV、本地设备——任何同步到设备的东西都会直接显示出来</string>
<string name="permission_benefit_privacy_title">无追踪</string>
<string name="permission_benefit_privacy_body">零遥测零分析,没有广告。</string>
<string name="permission_privacy_footnote">数据保留在本地设备 ·无网络权限</string>
<string name="month_prev"></string>
<string name="month_next"></string>
<string name="permission_benefit_private_title">从设计上保护隐私</string>
<string name="permission_benefit_private_body">没有网络权限,没有遥测——任何数据都不会离开您的手机</string>
<string name="permission_benefit_sync_title">所有日历汇聚一处</string>
<string name="permission_benefit_sync_body">Google、CalDAV、本地日历——只要同步到设备就会自动显示</string>
<string name="permission_benefit_privacy_title">无追踪</string>
<string name="permission_benefit_privacy_body">零遥测零分析、无广告。</string>
<string name="permission_privacy_footnote">数据保留在您的设备 · 无网络权限</string>
<string name="month_prev">上月</string>
<string name="month_next">下月</string>
<string name="month_today_action">今天</string>
<string name="month_more_actions">更多选项</string>
<string name="month_more_actions">更多操作</string>
<string name="month_open_menu">打开菜单</string>
<string name="month_action_settings">设置</string>
<string name="month_a11y_today_prefix">今天</string>
@@ -43,80 +42,572 @@
<string name="event_detail_edit">编辑</string>
<string name="event_detail_delete">删除</string>
<string name="event_detail_share">分享</string>
<string name="event_share_chooser_title">分享活动</string>
<string name="event_share_failed">无法分享该活动</string>
<string name="event_delete_title">删除活动</string>
<string name="event_delete_body">活动将从的日历以及所有同步设备中移除。</string>
<string name="event_delete_recurring_title">删除重复活动</string>
<string name="event_delete_option_occurrence">该活动</string>
<string name="event_delete_option_following">当前及接下来的所有活动</string>
<string name="event_delete_option_series">系列的所有活动</string>
<string name="event_edit_recurring_title">编辑重复活动</string>
<string name="event_delete_failed">无法删除该活动</string>
<string name="event_delete_write_denied">Calendula需要权限读取或删除活动</string>
<string name="event_share_chooser_title">分享事件</string>
<string name="event_share_failed">无法分享此事件</string>
<string name="event_delete_title">删除事件</string>
<string name="event_delete_body">事件将从的日历以及所有同步设备中移除。</string>
<string name="event_delete_recurring_title">删除重复事件</string>
<string name="event_delete_option_occurrence">此事件</string>
<string name="event_delete_option_following">此事件及其后的所有事件</string>
<string name="event_delete_option_series">整个系列的所有事件</string>
<string name="event_edit_recurring_title">编辑重复事件</string>
<string name="event_delete_failed">无法删除事件</string>
<string name="event_delete_write_denied">Calendula 需要写入权限才能删除事件</string>
<string name="dialog_cancel">取消</string>
<string name="dialog_ok"></string>
<string name="event_edit_new_title">活动</string>
<string name="dialog_ok"></string>
<string name="event_edit_new_title">事件</string>
<string name="event_edit_close">关闭</string>
<string name="event_edit_save">保存</string>
<string name="event_edit_title_hint">加标题</string>
<string name="event_edit_managed_hint">由“%1$s”管理——标题、日期和重复内容会与你的联系人保持同步。提醒、位置和笔记由你自行编辑。</string>
<string name="event_edit_title_hint">加标题</string>
<string name="event_edit_managed_hint">由“%1$s”管理——标题、日期和重复会从您的联系人同步。提醒、位置和备注由您编辑。</string>
<string name="event_edit_starts">开始</string>
<string name="event_edit_ends">结束</string>
<string name="event_edit_error_end_before_start">报错:结束时间早于始时间</string>
<string name="event_edit_error_no_calendar">可写入的日历</string>
<string name="event_edit_save_failed">无法保存该活动</string>
<string name="event_edit_write_denied">Calendula需要写入权限来创建活动</string>
<string name="event_edit_more_fields">其他领域</string>
<string name="event_edit_add">新增</string>
<string name="event_edit_add_reminder">新建提醒</string>
<string name="event_edit_error_end_before_start">结束时间早于始时间</string>
<string name="event_edit_error_no_calendar">没有可写入的日历</string>
<string name="event_edit_save_failed">无法保存事件</string>
<string name="event_edit_write_denied">Calendula 需要写入权限才能创建事件</string>
<string name="event_edit_more_fields">更多字段</string>
<string name="event_edit_add">添加</string>
<string name="event_edit_add_reminder">添加提醒</string>
<string name="event_edit_remove_reminder">移除提醒</string>
<string name="event_edit_attendees">访问</string>
<string name="event_edit_add_guest">新增访问</string>
<string name="event_edit_add_guest_hint">通过 Email 新增一个访问者…</string>
<string name="event_edit_add_guest_from_contacts">从联系人添加访问者</string>
<string name="event_edit_location_from_contacts">从联系人中添加地址</string>
<string name="event_edit_remove_guest">移除访问</string>
<string name="event_edit_attendee_required"></string>
<string name="event_edit_attendee_optional">可选</string>
<string name="event_edit_attendees_note_synced">Calendula 不发送邀请函。你的日历户同步时可能会给访客发邮件</string>
<string name="event_edit_attendees_note_local">保存在本设备,其他访客不会被提醒</string>
<string name="event_edit_attendees">参与</string>
<string name="event_edit_add_guest">添加参与</string>
<string name="event_edit_add_guest_hint">通过电子邮件添加参与者…</string>
<string name="event_edit_add_guest_from_contacts">从联系人添加</string>
<string name="event_edit_location_from_contacts">从联系人选取地址</string>
<string name="event_edit_remove_guest">移除参与</string>
<string name="event_edit_attendee_required"></string>
<string name="event_edit_attendee_optional">可选</string>
<string name="event_edit_attendees_note_synced">Calendula 不发送邀请。您的日历户同步时可能会通过邮件通知参与者</string>
<string name="event_edit_attendees_note_local">存储于本设备,不会通知任何人</string>
<string name="event_edit_reminder_custom">自定义</string>
<string name="reminder_unit_minutes">分钟</string>
<string name="reminder_unit_hours">小时</string>
<string name="reminder_unit_days"></string>
<string name="reminder_unit_weeks"></string>
<string name="event_edit_availability">空闲</string>
<string name="event_edit_visibility">隐私设置</string>
<string name="event_edit_color"></string>
<string name="event_edit_availability">忙闲状态</string>
<string name="event_edit_visibility">可见性</string>
<string name="event_edit_color"></string>
<string name="event_edit_color_default">日历颜色</string>
<string name="event_edit_color_custom">自定义颜色</string>
<string name="event_edit_color_reset">重置</string>
<string name="event_edit_color_unsupported">日历不可用</string>
<string name="event_edit_color_unsupported_hint">此日历未设置任何颜色集。您可以在“设置”中为此类日历启用自定义颜色。</string>
<string name="event_edit_color_sync_warning">日历在下一次同步时可能会删除或覆盖该颜色。</string>
<string name="event_edit_conflict_title">其他设备的活动已更改</string>
<string name="event_edit_conflict_body">编辑时,事件已同步或其他应用修改。您的修改要怎么处理?</string>
<string name="event_edit_color_unsupported">日历不支持</string>
<string name="event_edit_color_unsupported_hint">此日历未提供颜色集。您可以在“设置”中为此类日历允许自定义颜色。</string>
<string name="event_edit_color_sync_warning">日历次同步时可能会丢弃或覆盖该颜色。</string>
<string name="event_edit_conflict_title">事件已在别处更改</string>
<string name="event_edit_conflict_body">您编辑时,事件已通过同步或其他应用修改。您的更改应如何处理?</string>
<string name="event_edit_conflict_overwrite">保存我的更改</string>
<string name="event_edit_conflict_overwrite_hint">仅您编辑的字段会覆盖外部更改</string>
<string name="event_edit_conflict_overwrite_hint">覆盖您编辑的字段</string>
<string name="event_edit_conflict_discard">丢弃我的更改</string>
<string name="event_edit_conflict_discard_hint">该活动保持现状</string>
<string name="event_edit_gone_title">活动被删除</string>
<string name="event_edit_gone_body">这个事件在您编辑期间已被删除了(比如在另一设备上)。您的修改现在无法保存。</string>
<string name="event_edit_recurrence_none">确认无需重复</string>
<string name="event_edit_conflict_discard_hint">事件保持当前状态</string>
<string name="event_edit_gone_title">事件已被删除</string>
<string name="event_edit_gone_body">事件在您编辑期间已被删除(例如在另一设备上)。您的更改无法保存。</string>
<string name="event_edit_recurrence_none">重复</string>
<string name="event_edit_recurrence_custom">自定义</string>
<string name="event_edit_recurrence_every"></string>
<string name="recurrence_unit_days"></string>
<string name="recurrence_unit_weeks"></string>
<string name="recurrence_unit_months"></string>
<string name="recurrence_unit_years"></string>
<string name="event_edit_recurrence_ends">结束时间</string>
<string name="event_edit_recurrence_end_never"></string>
<string name="event_edit_recurrence_ends">结束</string>
<string name="event_edit_recurrence_end_never"></string>
<string name="event_edit_recurrence_end_until">于指定日期</string>
<string name="event_edit_recurrence_end_count">重复几次</string>
<string name="event_edit_recurrence_end_count">在指定次数</string>
<string name="event_edit_recurrence_times"></string>
<string name="event_edit_error_recurrence_ends_before_start">报错:结束日期早于开始日期</string>
<string name="event_availability_busy">占用中</string>
<string name="event_edit_error_recurrence_ends_before_start">重复结束日期早于事件开始时间</string>
<string name="event_availability_busy">忙碌</string>
<string name="event_access_default">默认</string>
<string name="event_access_public">公开</string>
<string name="event_detail_duplicate">复制</string>
<string name="event_move_action">移动…</string>
<string name="event_move_recurring_title">删除重复活动</string>
<string name="event_move_occurrence_only">移动整个系列会改变其发生日期,因此只能移动此事件。</string>
<string name="event_move_done">已移动到 %1$s</string>
<string name="event_move_undo">撤销</string>
<string name="event_move_undone">已撤销移动</string>
<string name="event_move_failed">无法保存该活动</string>
<string name="event_move_write_denied">Calendula需要权限读取或删除活动</string>
<string name="event_move_gone">该事件已不存在</string>
<string name="event_move_blocked_series_end">无法将事件移至其系列结束日期之后</string>
<string name="event_edit_timezone_device">设备时区</string>
<string name="event_edit_timezone_device_summary">跟随您所在位置</string>
<string name="event_edit_timezone_search">搜索时区</string>
<string name="event_edit_timezone_recent">最近使用</string>
<string name="event_edit_timezone_all">所有时区</string>
<string name="event_edit_timezone_none">没有与“%1$s”匹配的时区</string>
<string name="event_edit_timezone_local_time">%1$s 您的本地时间</string>
<string name="import_reminder_prompt_title">应用默认提醒?</string>
<string name="import_reminder_prompt_body_none">此事件导入时没有提醒。</string>
<plurals name="import_reminder_prompt_body_existing">
<item quantity="other">此事件导入时包含 %1$d 个提醒。</item>
</plurals>
<string name="app_name">Calendula</string>
<string name="week_number_label"></string>
<string name="import_reminder_prompt_apply">应用默认</string>
<string name="import_reminder_prompt_keep">保留原样</string>
<string name="event_edit_recurrence_incomplete">请输入 1 到 999 之间的数字</string>
<string name="event_edit_recurrence_next">下一次:%1$s</string>
<string name="event_edit_recurrence_next_none">此规则永不重复</string>
<string name="event_detail_all_day">全天</string>
<string name="event_detail_calendar">Calendula</string>
<string name="event_detail_calendar_unknown">未知日历</string>
<string name="event_detail_location">地点</string>
<string name="event_detail_description">描述</string>
<string name="event_detail_attendees">参与者</string>
<string name="event_detail_recurrence">重复</string>
<string name="event_detail_recurring">重复事件</string>
<string name="recurrence_daily">每天</string>
<string name="recurrence_weekly">每周</string>
<string name="recurrence_monthly">每月</string>
<string name="recurrence_yearly">每年</string>
<string name="recurrence_every_n_days">每 %1$d 天</string>
<string name="recurrence_every_n_weeks">每 %1$d 周</string>
<string name="recurrence_every_n_months">每 %1$d 月</string>
<string name="recurrence_every_n_years">每 %1$d 年</string>
<string name="recurrence_on_days">%1$s%2$s</string>
<string name="recurrence_with_until">%1$s直到 %2$s</string>
<string name="recurrence_with_count">%1$s共 %2$d 次</string>
<string name="event_detail_not_found">此事件已不存在。</string>
<string name="event_attendee_accepted">已接受</string>
<string name="event_attendee_declined">已拒绝</string>
<string name="event_attendee_tentative">待定</string>
<string name="event_attendee_needs_action">未回复</string>
<string name="event_attendee_unknown"></string>
<string name="event_detail_reminders">提醒</string>
<string name="event_detail_timezone">时区</string>
<string name="event_status_tentative">待定</string>
<string name="event_status_cancelled">取消</string>
<string name="event_availability_free">空闲</string>
<string name="event_access_private">私密</string>
<string name="event_access_confidential">机密</string>
<string name="event_access_default_summary">按日历默认设置</string>
<string name="event_access_public_summary">有权限者可见全部详情</string>
<string name="event_access_private_summary">他人仅看到您忙碌</string>
<string name="event_access_confidential_summary">标记为机密,具体含义由日历帐户决定</string>
<string name="event_attendee_organizer">组织者</string>
<string name="event_attendee_optional">可选项</string>
<string name="event_attendee_resource">资源</string>
<string name="event_detail_self_response">您的回复:%1$s</string>
<string name="reminder_at_time">事件开始时</string>
<string name="reminder_default">默认提醒</string>
<plurals name="reminder_minutes">
<item quantity="other">提前 %d 分钟</item>
</plurals>
<plurals name="reminder_hours">
<item quantity="other">提前 %d 小时</item>
</plurals>
<plurals name="reminder_days">
<item quantity="other">提前 %d 天</item>
</plurals>
<plurals name="reminder_weeks">
<item quantity="other">提前 %d 周</item>
</plurals>
<plurals name="duration_minutes">
<item quantity="other">%d分钟</item>
</plurals>
<plurals name="duration_hours">
<item quantity="other">%d 小时</item>
</plurals>
<plurals name="duration_days">
<item quantity="other">%d 天</item>
</plurals>
<string name="duration_hours_minutes">%1$s %2$s</string>
<string name="duration_custom_max">最多 %1$s</string>
<plurals name="duration_weeks">
<item quantity="other">%d 周</item>
</plurals>
<string name="event_untitled">(无标题)</string>
<string name="onboarding_step_counter">第 %1$d 步,共 %2$d 步</string>
<string name="onboarding_backup_title">您的事件仅保存在这里</string>
<string name="onboarding_backup_body">本手机上的事件不会同步到任何帐户丢失手机将丢失日历。Calendula 可以为您创建备份。</string>
<string name="onboarding_backup_benefit_folder_title">您选择的文件夹</string>
<string name="onboarding_backup_benefit_folder_body">备份为纯 .ics 文件——可存放在同步文件夹或 SD 卡中。</string>
<string name="onboarding_backup_benefit_daily_title">每天自动备份</string>
<string name="onboarding_backup_benefit_daily_body">Calendula 会在后台导出您的本地日历。您可以在“设置”中更改频率。</string>
<string name="onboarding_backup_enable_button">选择文件夹并备份</string>
<string name="onboarding_backup_skip_button">暂不</string>
<string name="onboarding_view_title">默认打开哪个视图?</string>
<string name="onboarding_month_style_title">月视图样式?</string>
<string name="onboarding_view_continue_button">继续</string>
<string name="onboarding_back">返回</string>
<string name="onboarding_visibility_button">知道了</string>
<string name="onboarding_done_title">一切就绪</string>
<string name="onboarding_done_body">您的日历已就绪。所有选择均可在“设置”中更改。</string>
<string name="onboarding_done_button">打开我的日历</string>
<string name="reminder_channel_name">事件提醒</string>
<string name="reminder_channel_description">按事件提醒时间推送的通知</string>
<string name="reminder_onboarding_title">不错过任何事件</string>
<string name="reminder_onboarding_body">Android 本身不会显示事件提醒——需要日历应用来承担。让 Calendula 接管这项工作吧。</string>
<string name="reminder_benefit_delivery_title">准时送达提醒</string>
<string name="reminder_benefit_delivery_body">您事件中的每个提醒都会按时以通知形式送达。</string>
<string name="reminder_benefit_duplicates_title">使用第二个日历应用?</string>
<string name="reminder_benefit_duplicates_body">如果另一个应用也发送提醒,您会看到两次——请在其中一个中关闭。</string>
<string name="reminder_benefit_reversible_title">随时更改</string>
<string name="reminder_benefit_reversible_body">开关位于“设置”中的“通知”下。</string>
<string name="reminder_onboarding_enable_button">开启提醒</string>
<string name="reminder_onboarding_skip_button">暂不</string>
<string name="reminder_action_snooze">稍后提醒</string>
<string name="reminder_action_dismiss">关闭</string>
<string name="reminder_day_tomorrow">明天</string>
<string name="reminder_day_yesterday">昨天</string>
<string name="view_month"></string>
<string name="view_week"></string>
<string name="view_day"></string>
<string name="view_agenda">日程</string>
<string name="view_section">视图</string>
<string name="drawer_jump_to_date">跳转到日期</string>
<string name="agenda_today_action">今天</string>
<string name="agenda_header_today">今天</string>
<string name="agenda_header_tomorrow">明天</string>
<string name="agenda_empty_title">暂无待办</string>
<string name="agenda_no_more_today">今天没有更多事件</string>
<string name="agenda_span_starts">开始 %1$s</string>
<string name="agenda_span_ends">结束 %1$s</string>
<string name="today_jump_action">去今天</string>
<string name="search_action">搜索</string>
<string name="search_hint">搜索事件</string>
<string name="search_back">返回</string>
<string name="search_clear">清除</string>
<string name="search_idle_hint">按标题、地点或备注搜索您的事件。</string>
<string name="search_empty">没有与“%1$s”匹配的事件。</string>
<plurals name="search_selected_count">
<item quantity="other">已选 %d 个</item>
</plurals>
<string name="search_in_descriptions">在描述中找到</string>
<string name="search_selection_close">取消选择</string>
<string name="search_select_all">全选</string>
<string name="search_delete_selected">删除所选</string>
<plurals name="search_delete_title">
<item quantity="other">删除 %d个活动</item>
</plurals>
<plurals name="search_delete_done">
<item quantity="other">删除了%d个活动</item>
</plurals>
<string name="search_delete_partial">已删除 %1$d 个,%2$d 个无法删除</string>
<string name="search_delete_denied_partial">已删除 %1$d 个,之后写入权限被撤</string>
<string name="widget_agenda_title">即将开始</string>
<string name="widget_agenda_label">Calendula 日程</string>
<string name="widget_month_label">Calendula 月视图</string>
<string name="widget_refresh">刷新</string>
<string name="widget_new_event">新活动</string>
<string name="widget_needs_permission">打开 Calendula 授予日历访问权限</string>
<string name="widget_prev_month">上个月</string>
<string name="widget_next_month">下个月</string>
<string name="widget_today">今天</string>
<string name="filter_title">日历</string>
<string name="settings_title">设置</string>
<string name="settings_back">返回</string>
<string name="back">返回</string>
<string name="settings_section_appearance">外观</string>
<string name="settings_theme">主题</string>
<string name="settings_theme_system">跟随系统</string>
<string name="settings_theme_light">浅色</string>
<string name="settings_theme_dark">深色</string>
<string name="settings_theme_hint">应用使用浅色或深色主题。选择后立即生效。</string>
<string name="settings_theme_system_summary">当前为 %1$s</string>
<string name="settings_default_view">默认视图</string>
<string name="settings_dynamic_color">动态颜色</string>
<string name="settings_dynamic_color_unavailable">需要 Android 12 或更高版本</string>
<string name="settings_soften_colors">协调日历颜色</string>
<string name="settings_soften_colors_summary">保留每个日历的色调但均衡亮度,使所有事件清晰可读且色彩协调。关闭则显示日历源的原始颜色。</string>
<string name="settings_font_headings">标题字体</string>
<string name="settings_font_body">正文字体</string>
<string name="settings_font_system">系统默认</string>
<string name="font_atkinson_hyperlegible">Atkinson Hyperlegible</string>
<string name="font_lora">Lora</string>
<string name="font_jetbrains_mono">JetBrains Mono</string>
<string name="settings_font_choose_file">选择文件…</string>
<string name="settings_font_custom_selected">自定义字体</string>
<string name="settings_font_import_failed">无法将文件识别为字体</string>
<string name="settings_font_specimen_heading">2026年8月28日 星期四</string>
<string name="settings_font_specimen_body">团队评审 10:00然后与 Robin 在集市广场咖啡馆共进午餐。</string>
<string name="settings_week_start">每周起始日</string>
<string name="settings_week_start_auto">自动</string>
<string name="settings_week_start_auto_summary">当前为 %1$s</string>
<string name="settings_week_numbers">周数</string>
<string name="settings_week_numbers_summary">在月视图中显示日历周数</string>
<string name="settings_today_toolbar">工具栏中的“今天”按钮</string>
<string name="settings_today_toolbar_summary">在工具栏中显示“跳至今天”按钮,而非浮动按钮</string>
<string name="settings_app_name">应用名称</string>
<string name="settings_app_name_summary">在启动器中显示为“日历”。仅启动器名称改变;切换后图标可能移动到新位置。</string>
<string name="settings_time_format">时间格式</string>
<string name="settings_time_format_auto">自动</string>
<string name="settings_time_format_12h">12 小时制(下午 2:00</string>
<string name="settings_time_format_24h">24 小时制14:00</string>
<string name="settings_time_format_auto_summary">跟随系统:%1$s</string>
<string name="settings_hour_lines">整点分隔线</string>
<string name="settings_hour_lines_summary">在周视图和日视图中显示每个整点的分隔线</string>
<string name="settings_timeline_scale">小时高度</string>
<string name="settings_timeline_scale_hint">周视图和日视图中每小时占用的垂直空间。两者共享此设置。您也可以用双指捏合时间线设置任意高度。</string>
<string name="timeline_scale_fit_day">适应全天</string>
<string name="timeline_scale_fit_day_summary">24 小时一屏显示,无需滚动</string>
<string name="timeline_scale_compact">紧凑</string>
<string name="timeline_scale_compact_summary">每屏显示更多小时,区块更小</string>
<string name="timeline_scale_regular">常规</string>
<string name="timeline_scale_regular_summary">标准间距</string>
<string name="timeline_scale_comfortable">舒适</string>
<string name="timeline_scale_comfortable_summary">区块更宽敞,需更多滚动</string>
<string name="timeline_scale_custom">自定义</string>
<string name="timeline_scale_custom_summary">您捏合时间线所设定的高度</string>
<string name="settings_dim_completed">变暗已完成事件</string>
<string name="settings_dim_completed_summary">在月视图和周视图中淡化已结束的事件</string>
<string name="settings_drag_to_reschedule">拖拽重新安排</string>
<string name="settings_drag_to_reschedule_summary">通过拖拽事件到另一天或时间来移动它</string>
<string name="settings_past_events">过去事件</string>
<string name="settings_past_events_show">显示</string>
<string name="settings_past_events_dim">变暗</string>
<string name="settings_past_events_hide">隐藏</string>
<string name="settings_past_events_sample_morning">团队评审</string>
<string name="settings_past_events_sample_midday">与 Robin 午餐</string>
<string name="settings_past_events_sample_evening">合唱团练习</string>
<string name="settings_agenda_header">日程</string>
<string name="settings_agenda_range">日程范围</string>
<string name="settings_agenda_range_hint">“日程”视图列出未来事件的时间跨度。</string>
<string name="settings_agenda_widget_range">日程小部件范围</string>
<string name="settings_agenda_widget_range_hint">主屏幕日程小部件列出未来事件的时间跨度。</string>
<string name="settings_widget_size">日程小部件大小</string>
<string name="settings_widget_size_hint">主屏幕日程小部件绘制文字的大小。月视图小部件无此设置——它的网格始终适应您给定的空间。</string>
<string name="settings_widget_size_small"></string>
<string name="settings_widget_size_medium"></string>
<string name="settings_widget_size_large"></string>
<string name="settings_widget_size_extra_large">特大</string>
<string name="settings_widget_size_summary">事件文字 %1$d%%</string>
<string name="settings_agenda_show_today">始终显示今天</string>
<string name="settings_agenda_show_today_hint">即使今天没有剩余事件,也将今天保持在日程及其小部件顶部。</string>
<string name="settings_agenda_range_bar">范围栏</string>
<string name="settings_agenda_range_bar_hint">在日程顶部显示一个条,标明显示的日期范围,并提供一个按钮用于临时切换范围</string>
<string name="agenda_range_day">今天</string>
<string name="agenda_range_this_week">本周</string>
<string name="agenda_range_this_month">本月</string>
<string name="agenda_range_week">未来 7 天</string>
<string name="agenda_range_month">未来 30 天</string>
<string name="agenda_range_custom">自定义…</string>
<string name="agenda_range_custom_hint">天数</string>
<string name="agenda_range_override_hint">仅本次生效——重新打开 Calendula 时恢复为已保存的范围。</string>
<string name="agenda_range_showing_label">显示所有未来事件</string>
<plurals name="agenda_range_days">
<item quantity="other">%d 天</item>
</plurals>
<string name="settings_section_views">视图</string>
<string name="settings_month_header">月视图</string>
<string name="settings_month_view_style">月视图样式</string>
<string name="month_style_paged">分页</string>
<string name="month_style_paged_summary">一屏显示一个月,左右滑动切换月份。</string>
<string name="month_style_continuous">滚动月份</string>
<string name="month_style_continuous_summary">每个月在其标题下,与下个月之间略有间距。</string>
<string name="month_style_dense">无缝周</string>
<string name="month_style_dense_summary">各周连续排列,月份之间无间隙。</string>
<string name="month_style_split">拆分</string>
<string name="month_style_split_summary">紧凑网格标记有事件的日子,点击的日期在下方列出。</string>
<string name="month_split_no_events">无安排</string>
<string name="month_split_expand">显示整月</string>
<string name="month_split_collapse">显示当天事件</string>
<string name="settings_quick_switch_header">快速切换按钮</string>
<string name="settings_quick_switch_hint">选择右上角按钮循环切换的视图,并拖动排序。关闭的视图仍可从导航菜单访问。</string>
<string name="settings_drawer_order_header">导航菜单</string>
<string name="settings_drawer_order_hint">拖动排序导航菜单中的视图。</string>
<string name="reorder_drag_handle">拖动以排序</string>
<string name="settings_section_event_form">新建事件表单</string>
<string name="settings_form_fields_hint">默认显示的字段——其他字段位于“更多字段”下</string>
<string name="settings_autofocus_title">新建事件时聚焦标题</string>
<string name="settings_autofocus_title_hint">创建新事件时,光标自动置于标题字段并弹出键盘。</string>
<string name="settings_event_duration">默认时长</string>
<string name="settings_event_duration_hint">新事件在您更改结束时间之前的默认时长。全天事件不受影响。</string>
<string name="settings_calendar_durations_title">按日历设置时长</string>
<string name="settings_calendar_durations_hint">为某个日历单独设置默认时长——例如工作班次设为 8 小时。</string>
<string name="settings_calendar_duration_inherits">默认(%1$s</string>
<string name="settings_calendar_duration_use_default">使用默认时长(%1$s</string>
<string name="settings_color_unsupported">允许在不支持的日历上使用颜色</string>
<string name="settings_color_unsupported_hint">某些日历(例如某些 CalDAV未发布颜色集自定义事件颜色可能会在下一次同步时被丢弃或覆盖。这是那些日历的限制并非 Calendula 可修复的问题。</string>
<string name="settings_section_notifications">通知</string>
<string name="settings_reminders">事件提醒</string>
<string name="settings_reminders_hint">看到重复提醒?另一个日历应用也在发送——请关闭其中一个。</string>
<string name="settings_default_reminder">默认提醒</string>
<string name="settings_default_reminder_allday">全天事件</string>
<string name="settings_allday_reminder_time">全天事件提醒时间</string>
<string name="settings_allday_reminder_time_hint">全天事件的提醒在 %1$s 触发</string>
<string name="settings_allday_reminder_fires_at">在 %1$s 通知</string>
<string name="reminder_none"></string>
<string name="reminder_use_default">使用默认提醒</string>
<string name="reminder_custom_amount">数量</string>
<string name="reminder_custom_set">设置</string>
<string name="settings_calendar_reminders_title">按日历设置提醒</string>
<string name="settings_calendar_reminders_hint">为每个日历覆盖默认提醒——分别针对定时事件和全天事件。日历可保持默认、不使用提醒,或设置自己的提醒。</string>
<string name="settings_calendar_reminder_inherits">默认(%1$s</string>
<string name="settings_reliable_delivery">可靠送达</string>
<string name="settings_reliable_delivery_hint">Android 可能为省电延迟提醒。豁免 Calendula 以使其准时送达。</string>
<string name="settings_reliable_delivery_exempt">豁免电池优化——提醒准时送达。</string>
<string name="settings_snooze_duration">贪睡时长</string>
<string name="settings_section_calendars">日历</string>
<string name="settings_manage_calendars">管理日历</string>
<string name="settings_manage_calendars_hint">创建本地日历;管理同步日历</string>
<string name="settings_section_language">语言</string>
<string name="settings_language">应用语言</string>
<string name="settings_language_auto">系统默认</string>
<string name="settings_translate">帮助翻译</string>
<string name="settings_translate_hint">在 Weblate 上添加或改进语言</string>
<string name="settings_group_look">外观与行为</string>
<string name="settings_group_data">数据</string>
<string name="settings_group_app">应用</string>
<string name="settings_group_about">关于</string>
<string name="settings_appearance_subtitle">主题、颜色、字体</string>
<string name="settings_views_subtitle">默认视图、布局、顺序</string>
<string name="settings_event_form_subtitle">默认字段和行为</string>
<string name="settings_notifications_subtitle">提醒与送达</string>
<string name="settings_special_dates_subtitle">联系人生日和纪念日</string>
<string name="settings_widgets_subtitle">日程小部件、快速设置磁贴</string>
<string name="settings_backup_subtitle">导出、导入、自动备份</string>
<string name="settings_section_widgets">小部件与磁贴</string>
<string name="settings_widgets_hint">主屏幕小部件和快速设置磁贴的设置。长按主屏幕即可添加小部件。</string>
<string name="settings_section_backup">备份与恢复</string>
<string name="settings_views_all_header">所有视图</string>
<string name="settings_week_day_header">周与日</string>
<string name="settings_default_view_hint">启动 Calendula 时默认打开的视图。</string>
<string name="settings_week_start_hint">所有视图和小部件中每周开始的日子。</string>
<string name="settings_time_format_hint">整个应用的时间显示格式。自动跟随系统设置。</string>
<string name="settings_past_events_hint">日程对已结束事件的处理方式。</string>
<string name="settings_dynamic_color_summary">从壁纸中提取应用颜色。</string>
<string name="settings_section_special_dates">联系人特殊日期</string>
<string name="settings_special_dates_enable">显示联系人日期</string>
<string name="settings_special_dates_enable_hint">将联系人生日和其他日期镜像到本地日历。仅读取本设备上的联系人——不会上传任何内容,也不会修改您的联系人。</string>
<string name="settings_special_dates_type_birthday">生日</string>
<string name="settings_special_dates_type_anniversary">纪念日</string>
<string name="settings_special_dates_type_custom">其他日期</string>
<string name="settings_special_dates_template">标题格式</string>
<string name="settings_special_dates_template_hint">使用 {name} 表示联系人,{year} 表示年份(出生年份或纪念日开始年份;未知时隐藏)。</string>
<string name="settings_special_dates_reminders">提醒</string>
<string name="settings_special_dates_show_year">显示年份</string>
<string name="settings_special_dates_show_year_hint">在标题中包含 {year}(当已知时)</string>
<string name="settings_special_dates_sync_now">立即同步</string>
<string name="settings_special_dates_never_synced">尚未同步</string>
<string name="settings_special_dates_last_synced">上次同步:%1$s</string>
<string name="settings_special_dates_calendar_hint">在“日历”设置中分别设置每个日历的颜色和可见性。</string>
<string name="settings_calendar_reminders_managed_hint">在“联系人特殊日期”中设置</string>
<string name="settings_special_dates_paused_title">已暂停</string>
<string name="settings_special_dates_paused_hint">Calendula 无法读取您的联系人,因此这些日历未更新。</string>
<string name="settings_special_dates_grant">授予访问权限</string>
<string name="settings_special_dates_disable_title">关闭联系人日期?</string>
<string name="settings_special_dates_disable_all_message">这将删除联系人日期日历及其事件。您添加的任何提醒或备注将丢失。</string>
<string name="settings_special_dates_disable_type_message">这将删除“%1$s”日历及其事件。您添加的任何提醒或备注将丢失。</string>
<string name="settings_special_dates_disable_confirm">关闭</string>
<string name="dialog_save">保存</string>
<string name="settings_section_about">关于</string>
<string name="settings_license">许可协议</string>
<string name="settings_license_value">MIT</string>
<string name="settings_about_author">作者 Jean-Luc Makiola</string>
<string name="settings_about_source">源代码</string>
<string name="settings_about_privacy">隐私政策</string>
<string name="settings_about_support">支持开发</string>
<string name="settings_about_version">版本 %1$s</string>
<string name="settings_about_logo_desc">Calendula 应用图标</string>
<string name="settings_report_problem">报告问题</string>
<string name="settings_report_problem_hint">发送崩溃报告或打开问题追踪器</string>
<string name="calendars_title">日历</string>
<string name="calendars_local_header">您的日历</string>
<string name="calendars_local_empty">尚未创建本地日历。创建一个以仅在本设备保存事件。</string>
<string name="calendars_add">添加日历</string>
<string name="calendars_visibility_hint">关闭日历可隐藏其在设备上的显示——事件会从应用中消失,同时停止提醒。此开关与其他日历应用共用,因此它们也会隐藏。不会删除任何内容,不会影响其他设备,您随时可在此重新开启。</string>
<string name="calendars_visibility_a11y">显示“%1$s”</string>
<string name="calendars_visibility_notice_title">部分日历已关闭</string>
<string name="calendars_visibility_notice_message">Calendula 显示本设备已开启的日历,但您有一些是关闭的。要查看事件,请在“设置”→“日历”中将其开启。</string>
<string name="calendar_picker_missing_title">找不到日历?</string>
<string name="calendar_picker_missing_summary">可能已关闭、只读,或由联系人填充——在此管理您的日历。</string>
<string name="calendars_state_read_only">只读</string>
<string name="calendars_state_not_synced">未同步到本设备</string>
<string name="calendars_state_managed">由联系人填充</string>
<string name="calendars_managed_delete_locked">此日历由联系人填充Calendula 下次同步时会重新创建。要删除它,请在“设置”→“特殊日期”中关闭相关功能。</string>
<string name="calendars_synced_header">同步日历</string>
<string name="calendars_synced_hint">来自设备上的帐户。请在对应的应用中创建和编辑。</string>
<string name="calendars_manage_in_app">在应用中管理</string>
<string name="calendars_account_from_source">%1$s%2$s</string>
<string name="calendars_account_menu_a11y">%1$s 的更多选项</string>
<string name="calendars_enable_all">启用全部</string>
<string name="calendars_disable_all">禁用全部</string>
<string name="calendars_add_account">添加帐户</string>
<string name="calendars_new_title">新建日历</string>
<string name="calendars_edit_title">编辑日历</string>
<string name="calendars_name_label">名称</string>
<string name="calendars_color_label">色彩</string>
<string name="calendars_description_hint">添加描述</string>
<string name="calendars_delete_confirm_title">删除日历?</string>
<string name="calendars_delete_confirm_message">“%1$s”及其所有事件将从本设备永久删除。</string>
<string name="calendars_write_error">无法保存更改。</string>
<string name="calendars_backup_header">返回</string>
<string name="calendars_backup_hint">本地日历不会同步到任何地方,请导出为 .ics 文件以保留副本。</string>
<string name="calendars_backup_action">导出为 .ics 文件</string>
<string name="calendars_export_title">导出日历</string>
<string name="calendars_export_hint">选择要包含在 .ics 文件中的日历。</string>
<string name="calendars_export_action">导出</string>
<string name="calendars_restore_header">恢复</string>
<string name="calendars_restore_action">从 .ics 文件恢复</string>
<string name="calendars_restore_hint">从备份或其他日历应用导入事件。</string>
<string name="calendars_auto_backup">自动备份</string>
<string name="calendars_auto_backup_hint">定期将本地日历导出为 .ics 文件保存到文件夹。</string>
<string name="calendars_auto_backup_folder">备份文件夹</string>
<string name="calendars_auto_backup_folder_unset">点击选择文件夹</string>
<string name="calendars_auto_backup_interval">间隔</string>
<string name="calendars_auto_backup_every">每 %1$s</string>
<string name="calendars_auto_backup_interval_min">最短 30 分钟。</string>
<string name="calendars_auto_backup_status_never">尚未自动备份</string>
<string name="calendars_auto_backup_status_ok">上次备份:%1$s</string>
<string name="calendars_auto_backup_status_failed">上次备份失败:%1$s</string>
<string name="backup_channel_name">备份</string>
<string name="backup_channel_description">自动备份反复失败时发出警告。</string>
<string name="backup_failed_title">自动备份失败</string>
<string name="backup_failed_text">Calendula 无法写入备份文件。请在“设置”中检查备份文件夹。</string>
<string name="calendars_backup_failed">无法导出备份。</string>
<plurals name="calendars_backup_done">
<item quantity="other">已导出 %d 个事件。</item>
</plurals>
<string name="import_title">导入事件</string>
<string name="import_target_header">添加至日历</string>
<string name="import_empty">此文件中未找到事件。</string>
<string name="import_failed">无法读取此文件。</string>
<string name="import_no_calendar">没有可写入的日历用于导入。请先创建本地日历。</string>
<string name="import_done_title">导入完成</string>
<string name="import_done_dedup_note">已跳过日历中已存在的事件。</string>
<string name="import_done_added_label">已添加</string>
<string name="import_done_skipped_label">重复项</string>
<string name="import_close">关闭</string>
<string name="import_warning_recurrence">已跳过重复事件中部分已修改的发生项。</string>
<string name="import_warning_no_start">已跳过无开始时间的事件。</string>
<string name="import_warning_attendees">未导入参与者列表。</string>
<string name="import_warning_timezone">未知时区已回退为设备时区。</string>
<string name="import_button">导入</string>
<plurals name="import_title_count">
<item quantity="other">正在导入 %d 个事件</item>
</plurals>
<plurals name="import_event_count">
<item quantity="other">此文件中包含 %d 个事件。</item>
</plurals>
<plurals name="import_action">
<item quantity="other">导入 %d 个事件</item>
</plurals>
<plurals name="import_done_imported">
<item quantity="other">已导入 %d 个事件。</item>
</plurals>
<plurals name="import_done_skipped">
<item quantity="other">已跳过 %d 个(已存在于该日历)。</item>
</plurals>
<string name="shortcut_new_event_short">新事件</string>
<string name="shortcut_new_event_long">创建新事件</string>
<string name="qs_tile_new_event_label">新事件</string>
<string name="settings_qs_tile">添加快速设置磁贴</string>
<string name="settings_qs_tile_hint">在快速设置面板中添加“新事件”磁贴。</string>
<string name="crash_dialog_title">%1$s 已崩溃</string>
<string name="crash_dialog_message">%1$s 上次意外关闭。您可以将此报告作为问题发送以帮助修复。报告在您选择共享之前仅保留在设备上,不含任何个人数据或日历内容——仅包含以下技术细节。</string>
<string name="crash_dialog_report">报告</string>
<string name="crash_dialog_dismiss">暂不</string>
<string name="crash_report_issue_title">崩溃报告</string>
<string name="crash_report_clip_label">%1$s 崩溃报告</string>
<string name="crash_report_copied">报告已复制到剪贴板</string>
<string name="crash_report_open_failed">无法打开问题追踪器。报告已在剪贴板中。</string>
<string name="crash_report_body_template">感谢您报告 %1$s 的崩溃。请补充您记得的操作内容,然后提交。\n\n### 发生了什么\n\n\n### 崩溃报告\n%2$s\n</string>
<string name="crash_report_body_paste">_报告太长无法通过此链接附带——请从剪贴板粘贴。_</string>
<string name="special_dates_calendar_birthday">生日</string>
<string name="special_dates_calendar_anniversary">纪念日</string>
<string name="special_dates_calendar_custom">特殊日期</string>
<string name="special_dates_default_title_birthday">{name} 的生日({year}</string>
<string name="special_dates_default_title_anniversary">{name} 的纪念日({year}</string>
<string name="special_dates_default_title_custom">{name}</string>
</resources>

View File

@@ -693,11 +693,15 @@
<string name="import_done_dedup_note">Events already in the calendar were skipped.</string>
<string name="import_done_added_label">Added</string>
<string name="import_done_skipped_label">Duplicates</string>
<string name="import_done_failed_label">Not added</string>
<string name="import_done_failed_note">Some events couldn\'t be added and were left out.</string>
<string name="import_close">Close</string>
<string name="import_warning_recurrence">Some changed occurrences of recurring events were skipped.</string>
<string name="import_warning_no_start">An event without a start time was skipped.</string>
<string name="import_warning_attendees">Guest lists weren\'t imported.</string>
<string name="import_warning_timezone">An unknown time zone fell back to your device\'s.</string>
<string name="import_warning_tasks">Tasks in this file were added as events.</string>
<string name="import_warning_recurrence_repaired">A faulty repeat rule was repaired.</string>
<string name="import_button">Import</string>
<plurals name="import_title_count">
<item quantity="one">Importing %d event</item>
@@ -719,6 +723,10 @@
<item quantity="one">Skipped %d already in this calendar.</item>
<item quantity="other">Skipped %d already in this calendar.</item>
</plurals>
<plurals name="import_done_failed">
<item quantity="one">%d event couldn\'t be added.</item>
<item quantity="other">%d events couldn\'t be added.</item>
</plurals>
<!-- Launcher long-press shortcuts -->
<string name="shortcut_new_event_short">New event</string>
<string name="shortcut_new_event_long">Create a new event</string>

View File

@@ -10,11 +10,14 @@
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
<locale android:name="en" />
<locale android:name="ar" />
<locale android:name="cs" />
<locale android:name="de" />
<locale android:name="es" />
<locale android:name="fr" />
<locale android:name="it" />
<locale android:name="ja" />
<locale android:name="pl" />
<locale android:name="pt-BR" />
<locale android:name="ru" />
<locale android:name="zh-CN" />
</locale-config>

View File

@@ -143,6 +143,47 @@ 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 `a winter-anchored offset drifts one hour on a summer occurrence`() {
// Known limitation: one fixed MINUTES per series can't track DST. An

View File

@@ -801,6 +801,62 @@ 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 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 +881,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,

View File

@@ -128,7 +128,8 @@ class EventWriteMapperTest {
original: EventForm,
updated: EventForm,
series: Long = seriesStart,
): Map<String, Any?> = buildEventUpdateValues(original, updated, series, berlin)
exdate: String? = null,
): Map<String, Any?> = buildEventUpdateValues(original, updated, series, exdate, berlin)
/** The instant [local] names in [zoneId], as the provider would store it. */
private fun instantAt(local: String, zoneId: String): Long =
@@ -558,6 +559,239 @@ class EventWriteMapperTest {
assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(1)
}
// --- EXDATE follows the series when its times change (Codeberg #248) ---
/** A weekly series whose displayed occurrence runs 15 July 2026, 09:0010: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 `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 `an exdate form we do not write is left untouched`() {
// A sync adapter may store a TZID-parameterised or floating stamp. A stale
// stamp excludes nothing; a mangled one could exclude the wrong occurrence.
val original = julySeries()
val values = update(
original,
original.atHour(10),
instantAt("2026-07-01T09:00", "Europe/Berlin"),
"TZID=Europe/Berlin;20260722T090000",
)
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,
),
).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),
).isNull()
assertThat(exdateAfter(null, 0L, isAllDay = false)).isNull()
}
@Test
fun `all-day exclusions split on their UTC date`() {
assertThat(
exdateAfter("20260708,20260722", instantAt("2026-07-15T00:00", "UTC"), isAllDay = true),
).isEqualTo("20260722")
}
@Test
fun `an unreadable exclusion carries nothing across a split`() {
assertThat(
exdateAfter(
"20260722T080000Z,TZID=Europe/Berlin;20260729T100000",
instantAt("2026-07-15T08:00", "UTC"),
isAllDay = false,
),
).isNull()
}
@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,
),
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 +980,168 @@ 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",
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,
),
).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),
).isTrue()
assertThat(
exdateContains("20260611", 1_781_136_000_000L, isAllDay = true),
).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)).isFalse()
assertThat(exdateContains("", 1_781_164_800_000L, isAllDay = false)).isFalse()
// A neighbouring occurrence must not match — the guard is per-instant.
assertThat(
exdateContains("20260610T080000Z", 1_781_164_800_000L, isAllDay = false),
).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",
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")
}
}

View File

@@ -23,6 +23,7 @@ internal class FakeCalendarDataSource : CalendarDataSource {
var searchResult: (String) -> List<SearchCandidate> = { _ -> emptyList() }
var eventDetailResult: (Long) -> EventDetail? = { null }
var eventColorPaletteResult: (Long) -> List<EventColorOption> = { emptyList() }
var publishedEventColorsResult: (Long) -> List<EventColorOption> = { emptyList() }
var exportableEventsResult: List<IcsEvent> = emptyList()
/** The [calendarIds] the last [exportableEvents] call received (null = all). */
var lastExportableEventsCalendarIds: Set<Long>? = null
@@ -81,7 +82,12 @@ internal class FakeCalendarDataSource : CalendarDataSource {
eventDetailResult(eventId)
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
eventColorPaletteResult(calendarId)
override fun exportableEvents(calendarIds: Set<Long>?): List<IcsEvent> {
override fun publishedEventColors(calendarId: Long): List<EventColorOption> =
publishedEventColorsResult(calendarId)
override fun exportableEvents(
calendarIds: Set<Long>?,
allDayReminderTimeMinutes: Int,
): List<IcsEvent> {
lastExportableEventsCalendarIds = calendarIds
return exportableEventsResult
}
@@ -91,8 +97,21 @@ internal class FakeCalendarDataSource : CalendarDataSource {
/** (event, targetCalendarId) pairs passed to [insertImportedEvent]. */
val importedEvents = mutableListOf<Pair<ParsedIcsEvent, Long>>()
override fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long {
/** Thrown instead of [writeError] for events whose summary is in this set. */
val failingImportSummaries = mutableSetOf<String>()
/** The [colorPalette] the last [insertImportedEvent] call received. */
var lastImportPalette: List<EventColorOption> = emptyList()
override fun insertImportedEvent(
event: ParsedIcsEvent,
calendarId: Long,
allDayReminderTimeMinutes: Int,
colorPalette: List<EventColorOption>,
): Long {
lastImportPalette = colorPalette
writeError?.let { throw it }
if (event.summary in failingImportSummaries) error("rejected: ${event.summary}")
importedEvents += event to calendarId
return nextInsertId
}

View File

@@ -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,98 @@ 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 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()
}
}

View File

@@ -0,0 +1,119 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.provider.CalendarContract
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import org.junit.jupiter.api.Test
import kotlin.time.Instant
class ImportedEventValuesTest {
private val dayStart = Instant.parse("2026-08-19T00:00:00Z")
private fun allDay(
rrule: String? = null,
exDates: List<String> = emptyList(),
color: Int? = null,
days: Int = 1,
) = ParsedIcsEvent(
uid = "u@x",
summary = "Anna Birthday",
start = dayStart,
end = Instant.fromEpochMilliseconds(dayStart.toEpochMilliseconds() + days * 86_400_000L),
isAllDay = true,
zoneId = "UTC",
recurrenceRule = rrule,
exDates = exDates,
color = color,
)
private fun values(event: ParsedIcsEvent, palette: List<EventColorOption> = emptyList()) =
buildImportedEventValues(event, calendarId = 7L, uid = "u@x", palette = palette)
@Test
fun `a one-off carries DTEND and no recurrence`() {
val v = values(allDay())
assertThat(v[CalendarContract.Events.DTEND])
.isEqualTo(dayStart.toEpochMilliseconds() + 86_400_000L)
assertThat(v).doesNotContainKey(CalendarContract.Events.DURATION)
assertThat(v[CalendarContract.Events.ALL_DAY]).isEqualTo(1)
assertThat(v[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("UTC")
}
@Test
fun `a recurring row carries DURATION instead of DTEND`() {
val v = values(allDay(rrule = "FREQ=YEARLY;INTERVAL=1"))
assertThat(v).doesNotContainKey(CalendarContract.Events.DTEND)
assertThat(v[CalendarContract.Events.RRULE]).isEqualTo("FREQ=YEARLY;INTERVAL=1")
assertThat(v[CalendarContract.Events.DURATION]).isEqualTo("P1D")
}
@Test
fun `an all-day series is never zero days long`() {
// A degenerate span would expand into no instances at all — the series
// would simply not exist (Codeberg #225).
val v = values(allDay(rrule = "FREQ=YEARLY;INTERVAL=1", days = 0))
assertThat(v[CalendarContract.Events.DURATION]).isEqualTo("P1D")
}
@Test
fun `EXDATEs ride along with the recurrence`() {
val v = values(allDay(rrule = "FREQ=DAILY", exDates = listOf("20260820", "20260822")))
assertThat(v[CalendarContract.Events.EXDATE]).isEqualTo("20260820,20260822")
}
@Test
fun `EXDATEs are not written without a recurrence to exclude from`() {
val v = values(allDay(exDates = listOf("20260820")))
assertThat(v).doesNotContainKey(CalendarContract.Events.EXDATE)
}
@Test
fun `an imported colour is written raw when the account publishes no palette`() {
val v = values(allDay(color = -2818048))
assertThat(v[CalendarContract.Events.EVENT_COLOR]).isEqualTo(-2818048)
assertThat(v[CalendarContract.Events.EVENT_COLOR_KEY]).isNull()
}
@Test
fun `an imported colour snaps to the nearest published palette key`() {
val palette = listOf(
EventColorOption(key = "1", argb = 0xFF0000FF.toInt()), // blue
EventColorOption(key = "2", argb = 0xFFFF4500.toInt()), // orangered
EventColorOption(key = "3", argb = 0xFF008000.toInt()), // green
)
// Tomato — visually an orangered, nothing like the blue or the green.
val v = values(allDay(color = 0xFFFF6347.toInt()), palette)
assertThat(v[CalendarContract.Events.EVENT_COLOR_KEY]).isEqualTo("2")
// A raw colour alongside a key is what a palette calendar rejects.
assertThat(v).doesNotContainKey(CalendarContract.Events.EVENT_COLOR)
}
@Test
fun `an event with no colour touches neither colour column`() {
val v = values(allDay())
assertThat(v).doesNotContainKey(CalendarContract.Events.EVENT_COLOR)
assertThat(v).doesNotContainKey(CalendarContract.Events.EVENT_COLOR_KEY)
}
@Test
fun `a timed event keeps its own zone and a seconds duration`() {
val start = Instant.parse("2026-08-19T08:00:00Z")
val event = ParsedIcsEvent(
uid = "u@x",
summary = "Standup",
start = start,
end = Instant.parse("2026-08-19T08:15:00Z"),
isAllDay = false,
zoneId = "Europe/Berlin",
recurrenceRule = "FREQ=WEEKLY",
)
val v = values(event)
assertThat(v[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin")
assertThat(v[CalendarContract.Events.DURATION]).isEqualTo("P900S")
assertThat(v[CalendarContract.Events.ALL_DAY]).isEqualTo(0)
}
}

View File

@@ -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()
}
}
}

View File

@@ -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()
}
}

View File

@@ -0,0 +1,586 @@
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 `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()
}
}

View File

@@ -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")
}
}

View File

@@ -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()
}
}

View File

@@ -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()
}
}

View File

@@ -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<EventDetailUiState>()
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()
}
}

View File

@@ -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()
}
}

View File

@@ -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 612 — sits wholly inside it. */
private fun rowOfJuly6(events: List<EventInstance>) =
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()
}
}

View File

@@ -0,0 +1,164 @@
package de.jeanlucmakiola.calendula.widget
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)
}
}

View File

@@ -0,0 +1,64 @@
BEGIN:VCALENDAR
PRODID:-//Fossify//NONSGML Event Calendar//EN
VERSION:2.0
BEGIN:VEVENT
SUMMARY:Anna Schmidt
UID:101
X-FOSSIFY-CATEGORY-COLOR:-1155931
CATEGORIES:Birthdays
LAST-MODIFIED:20250615T150640Z
TRANSP:TRANSPARENT
DTSTART;VALUE=DATE:19900412
DTEND;VALUE=DATE:19900412
X-FOSSIFY-MISSING-YEAR:0
DTSTAMP:20260819T120000Z
CLASS:PUBLIC
STATUS:CONFIRMED
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=4
BEGIN:VALARM
DESCRIPTION:Reminder
ACTION:DISPLAY
TRIGGER:P0DT9H0M0S
END:VALARM
END:VEVENT
BEGIN:VEVENT
SUMMARY:Opa
UID:102
X-FOSSIFY-CATEGORY-COLOR:-1155931
CATEGORIES:Birthdays
LAST-MODIFIED:20250615T150640Z
TRANSP:TRANSPARENT
DTSTART;VALUE=DATE:19700603
DTEND;VALUE=DATE:19700603
X-FOSSIFY-MISSING-YEAR:1
DTSTAMP:20260819T120000Z
CLASS:PUBLIC
STATUS:CONFIRMED
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=6
BEGIN:VALARM
DESCRIPTION:Reminder
ACTION:DISPLAY
TRIGGER:P0DT9H0M0S
END:VALARM
END:VEVENT
BEGIN:VEVENT
SUMMARY:Hochzeitstag
UID:103
X-FOSSIFY-CATEGORY-COLOR:-1155931
CATEGORIES:Anniversaries
LAST-MODIFIED:20250615T150640Z
TRANSP:TRANSPARENT
DTSTART;VALUE=DATE:20050917
DTEND;VALUE=DATE:20050917
X-FOSSIFY-MISSING-YEAR:0
DTSTAMP:20260819T120000Z
CLASS:PUBLIC
STATUS:CONFIRMED
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=9
BEGIN:VALARM
DESCRIPTION:Reminder
ACTION:DISPLAY
TRIGGER:P0DT9H0M0S
END:VALARM
END:VEVENT
END:VCALENDAR

View File

@@ -0,0 +1,25 @@
BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
PRODID:Fossify Calendar Holiday Generator
METHOD:PUBLISH
X-PUBLISHED-TTL:PT1H
BEGIN:VEVENT
UID:fossify_c10f9ad245dc2e57fb3652abee6ed8a06744f0bd
SUMMARY:Neujahr
DTSTAMP:20260526T132315Z
DTSTART;VALUE=DATE:19800101
DTEND;VALUE=DATE:19800102
STATUS:CONFIRMED
RRULE:FREQ=YEARLY
END:VEVENT
BEGIN:VEVENT
UID:fossify_8a60ccf65b65265f768dd33a0b7aefc7620e3ee2
SUMMARY:Heilige Drei Könige
DTSTAMP:20260526T132315Z
DTSTART;VALUE=DATE:19800106
DTEND;VALUE=DATE:19800107
STATUS:CONFIRMED
RRULE:FREQ=YEARLY
END:VEVENT
END:VCALENDAR

View File

@@ -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:

View File

@@ -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. |

View File

@@ -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

View File

@@ -0,0 +1,8 @@
إصلاحات
• النقر على حدث في عرض الشهر يفتح الحدث نفسه بدل يومه.
• التعديل يظهر فور إعادة فتح الحدث.
• لم تعد أسهم أداة الشهر تتوقف بعد بضع نقرات: تحديث ضخم كان يُسقط مضيف الأدوات في المشغّل.
• «اليوم» في الأسابيع المتصلة يصل إلى الأسبوع الحالي تماماً، وعادت مسافة الهامش على يسار عرضي اليوم والأسبوع.
تغييرات
• الحدث الذي رفضته يظهر مشطوباً في كل مكان ولم يعد يُذكّرك به.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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ê.

View File

@@ -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.

View File

@@ -0,0 +1,8 @@
Исправлено
• Нажатие на событие в виде месяца открывает событие, а не его день.
• Изменение видно сразу при повторном открытии события.
• Стрелки виджета месяца больше не отмирают после нескольких нажатий: слишком большое обновление роняло хост виджетов лаунчера.
• «Сегодня» в непрерывных неделях попадает точно на текущую неделю, а у видов дня и недели снова есть отступ справа.
Изменения
• Отклонённое событие везде зачёркнуто и больше не напоминает о себе.

View File

@@ -0,0 +1,8 @@
修复
• 在月视图中点按事件现在会打开该事件本身,而不是它所在的那一天。
• 编辑后再次打开事件,改动会立即显示。
• 月视图小部件的箭头不再点几下就失灵:过大的更新会拖垮启动器的小部件宿主。
• 连续周视图中的“今天”会精确定位到本周,日视图和周视图右侧也重新留出了间距。
变更
• 已拒绝的事件在各处均以删除线标记,并且不再提醒。

View File

@@ -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]