Compare commits

...

55 Commits

Author SHA1 Message Date
dd3988c5bf Merge remote-tracking branch 'origin/main' into release/v2.20.0
# Conflicts:
#	CHANGELOG.md
#	app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt
#	app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt
2026-09-02 15:34:17 +02:00
Jean-Luc Makiola
03287ae25d Tidy the app bar at larger display and font scales (#165) (#261)
### Part A — the title shortens instead of wrapping

`CalendarTitleButton` measures the full title against the width the bar hands
the title slot (`BoxWithConstraints` + `rememberTextMeasurer`) and falls back to
a shorter form when it does not fit. One pass, so no flicker, and it adapts on
its own to locale, font scale and to the pill being absent after #150.

- Month and week fall back to the three-letter month ("Sep", "Mär").
- Day drops the weekday instead ("Wed, 2 Sep 2027" → "2 Sep 2027") — there is no
  shorter month form left there, `EEEdMMM` is already abbreviated.
- Either way the title now clamps to one line and ellipsises rather than growing
  the bar to two lines.

The choice itself is a pure `titleFor(title, shortTitle, titleWidth,
availableWidth)`, so it is unit-tested rather than only reachable through a
composable. `formatCalendarTitle` gained no new behaviour, so the widget header
is untouched.

### Part B — the trailing edge, not the whole rhythm

`AppBarSpacing` holds the shared values: the side inset (also consumed by the
month grid, so the pill lines up with the content under it), the title's start
inset, and the two trailing insets — one measured to a container's background,
one to an icon button's glyph.

**Deviation from the issue:** the issue asks for one edge inset at *both* ends
and one gap between adjacent controls. Only the trailing edge and the title
inset are shared here. M3 derives both the title's position and the gap between
two adjacent action icons from the icon buttons' width, so putting the leading
edge and the inter-icon gaps on the same rhythm meant narrowing the icon buttons
from 48.dp to 32.dp — that reads cramped and shrinks the touch targets. The
leading edge and the icon gaps stay on M3's defaults. The title's 8.dp start
inset stays real padding rather than a negative offset, so the menu button's hit
box is intact.

The agenda range bar picks its end inset from whether the view switcher is
showing: aligned to the pill's background when it is, to the search icon's glyph
once #150 hides it.

Closes #165

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/261
2026-09-02 15:25:38 +02:00
Jean-Luc Makiola
b3fe98907e Release v2.19.4 (#259)
Some checks failed
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 13m53s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Failing after 1m24s
Release v2.19.4.

**Fixed**
- A deleted occurrence stays deleted through a series re-timing, and through backup/restore ([#225]).
- "Only this event" saves the edit on calendars that never synced, and on a row that doesn't recur it edits or deletes the event itself instead of filing an exception against nothing.
- A reminder left on the account's default is no longer read as a lead time of Calendula's own.
- The widgets turn the page at midnight; the re-arm backstop for the inexact rollover alarm is back, throttled to once per 15 minutes on calendar changes.
- An event running past midnight can be dragged by either half ([#253]).
- One event the provider refuses no longer costs the rest of the import, and an import cut short reports honestly how far it got.
- EXDATE zone prefixes are read in the bare `<zone>;` form the provider and DAVx5 actually write, not only the iCalendar `TZID=` spelling; a list holding a stamp we can't parse is kept whole rather than re-emitted stripped of its zone; RDATE travels with RRULE when the recurrence set is rewritten.

**Added**
- Belarusian, Hungarian and Slovak, from community translators on Weblate.

**Changed**
- Arabic, Polish and Simplified Chinese are complete; Czech grew, and Russian is now at about half.

Bumps `versionName` to 2.19.4, so merging this triggers the release pipeline.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/259
2026-09-02 12:44:40 +02:00
Jean-Luc Makiola
b19493e925 Translations update from Weblate (#260)
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/260
2026-09-02 12:36:21 +02:00
Ábel Nagy
19b4cf06b2 Translated using Weblate (Hungarian)
Currently translated at 10.9% (63 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/hu/
2026-09-02 10:35:42 +00:00
Artur
f9a5618b84 Translated using Weblate (Russian)
Currently translated at 41.9% (241 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ru/
2026-09-02 10:35:42 +00:00
Jean-Luc Makiola
822a9799db Translations update from Weblate (#258)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 4s
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/258
2026-09-02 12:31:45 +02:00
Weblate
eb4fd3108e 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-09-01 21:43:02 +00:00
Ábel Nagy
4c86c261ba Added translation using Weblate (Hungarian) 2026-09-01 21:43:01 +00:00
Julie
dc5c9db68a Translated using Weblate (Slovak)
Currently translated at 38.3% (220 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/sk/
2026-09-01 18:14:18 +00:00
Weblate
befcad586e Translated using Weblate (Slovak)
Currently translated at 38.3% (220 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/sk/
2026-09-01 18:14:18 +00:00
Dmitri Gabinski
9f950cba9f Translated using Weblate (Belarusian)
Currently translated at 89.7% (515 of 574 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/be/
2026-09-01 18:14:18 +00: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
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
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
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
7e8119be02 Hide the quick-switch button below two views (#150) (#221)
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/221
2026-08-18 17:14:43 +02:00
Jean-Luc Makiola
7e843aa740 Drop the ellipsis in week and day event chips too (#164) (#222)
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/222
2026-08-18 17:14:12 +02:00
9775e0a652 Drop the ellipsis in week and day event chips too (#164)
#183 only covered the month grid, but a week column is just as narrow. The
all-day chips and the drag copy take the same treatment; the timed blocks
only when they are showing a single line, since wrapping needs softWrap on
and clipping with it on breaks at the last whole word.

The rule now lives in one helper instead of being copied per chip.
2026-08-18 15:57:17 +02:00
2cf7d590bd Hide the quick-switch button below two views (#150)
The pill only appears with something to switch between, so one enabled view
hides it just like zero. The floor that blocked disabling is gone from both
the settings screen and the view model.
2026-08-18 15:52:39 +02:00
Jean-Luc Makiola
8c76cbdf5e Show as much of a month event title as fits (#164) (#183)
Month-view event titles no longer truncate with "…". `MonthBar` — the single
chip renderer for every month style and for the drag's floating copy — now uses
`TextOverflow.Clip` with `softWrap = false`, so a title runs to the chip's edge
and clips mid-glyph instead of spending two characters' width on an ellipsis.

`softWrap = false` is load-bearing: `Clip` on its own still breaks a `maxLines = 1`
line at the last whole word, so "Team standup meeting" would render as "Team" —
less title than the ellipsis showed, not more.

Deviation from the issue: right-to-left layouts keep the ellipsis. With
`softWrap` off Compose lays the line out at its full intrinsic width and clips to
the node's left edge, which in RTL is the *end* of the string — an Arabic title
would have lost its beginning. `Ellipsis` truncates at the logical end in both
directions, so RTL keeps it.

The Glance month widget needs nothing: its `Text` has no overflow parameter, and
a RemoteViews `TextView` with `maxLines = 1` and no ellipsize already clips.

Closes #164

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/183
2026-08-13 15:26:31 +02:00
95 changed files with 8213 additions and 551 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,113 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Changed
- Month-view event titles no longer end in an ellipsis. The "…" took the width
of a couple of characters and told you nothing you couldn't already see, so
the title now simply runs to the edge of its chip — a few more letters per
cell, which is often the difference between two events you can tell apart and
two you can't ([#164]).
- The calendar titles now shorten instead of being cut off. When the full month
name doesn't fit the top bar, the month and week views fall back to its
three-letter form and the day view drops the weekday, rather than trailing off
mid-word at large font sizes. The view switcher, the title and the agenda's
range bar also line up with the grid underneath them ([#165]).
## [2.19.4] — 2026-09-01
### Fixed
- **A deleted occurrence no longer comes back when the series is re-timed.**
Delete a single occurrence of a repeating event, then change the time of the
whole series, and the occurrence you had removed reappeared. Doing it the
other way round — "This and all following events" — lost every removal in the
part of the series being changed. Removals now travel with the event: they
keep their place in the series across a time change, a move to a different
day, a timezone change and a switch to or from an all-day event, and they
carry over when a series is split. Repeating events on your device's own
calendars are affected, including the birthday and anniversary calendars
Calendula creates from your contacts ([#248]).
- **Home-screen widgets now turn the page at midnight.** Both the month and the
agenda widget kept highlighting yesterday as "today" — and the agenda kept
greying out the wrong events as already past — until you paged the month back
and forth or removed and re-added the widget. Calendula now wakes itself at the
day boundary and redraws, and re-arms after a reboot, a clock change or a
flight into another timezone. Paging the month widget forward and back also
stops quietly pinning it to that month, so it follows the date again instead of
being stranded on the month you happened to be looking at ([#228]).
- **"Only this event" now actually saves your edit.** On some calendars —
including Google ones that still show as on-device, and any local calendar —
editing a single occurrence of a repeating event did nothing at all: the scope
dialog closed, the edit screen stayed put, and saving again just repeated it.
Android can only attach a single-occurrence change to its series once the
calendar has been synced at least once, so on those calendars the change had
nowhere to go. Calendula now removes that one occurrence from the series and
saves the edit as its own event instead, which is what you see either way. A
save that does fail also says so for longer, rather than flashing past
([#234]).
- **A failed import no longer costs you the whole file.** One event the calendar
refuses used to abort the entire import with nothing on screen but "couldn't
read this file". Events are now added one at a time: anything rejected is
counted and shown, and the rest still arrive. A faulty repeat rule in the file
is repaired instead of being handed on ([#225]).
- **All-day reminders survive a backup and an import.** They fire at the hour you
chose, not at midnight UTC — the same rule the app already applied to all-day
events you create yourself — and an export no longer leaves them out of the
file entirely. An imported yearly birthday fires at the right hour too, even
though the file dates it back to the year of birth ([#225]).
- **Birthdays and anniversaries imported from Fossify Calendar now show up.**
Fossify writes the ones it mirrors from your contacts as events that start and
end on the same day, which the calendar read as lasting no time at all: they
imported without complaint and then appeared nowhere. Any all-day event that
ends where it starts, or carries no end at all, is now a one-day event
([#225]).
- **A backup no longer brings back occurrences you had deleted.** Remove a single
occurrence of a repeating event, back up, restore — and it was there again,
because the removals never made it into the file. They travel with the series
now, so a restored calendar looks the way you left it ([#225]).
- **A reminder left on your account's default no longer went off as the event
started.** An event whose reminder says "Default" is the account's to time, not
Calendula's — it was reading that setting as a lead time of its own and firing a
minute after the event began. It now leaves such a reminder alone, and says so
on the event.
- **"Only this event" on an event that doesn't repeat now edits or deletes it.**
Deleting used to write an exclusion onto a one-off event instead — which left
the event there and lost its end time — and editing filed the change away as an
exception to nothing, so it vanished.
- **An import that the calendar cuts short now tells you how far it got.** Losing
the calendar or the permission part way through a big file reported the whole
import as failed, so importing again added everything twice.
- **An event that runs past midnight can be dragged by either half.** Its second
half couldn't be picked up at all, and the first could only be moved later. Both
halves now move, and both are previewed while you hold either one, so the event
reads as moving rather than growing. The blocks are cut square at midnight
instead of rounded off, a month row draws an event crossing into the next week
as one bar, and a move across a daylight-saving change no longer lands an hour
out ([#253]).
### Added
- **More of an imported `.ics` survives the trip**: tasks come across as events
rather than being dropped in silence, deleted occurrences of a repeating event
stay deleted, a calendar named like the one the file came from is preselected,
and event colours are carried over — matched to the closest colour your
calendar's account offers, so a migrated calendar still reads at a glance
([#225]).
- **Belarusian, Hungarian and Slovak**, all three contributed as community
translations through
[Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/).
Belarusian covers about nine tenths of the app, Slovak about a third and
Hungarian is just under way, so untranslated parts still show in English until
they fill out. Pick any of them under Settings → Language or in Android's
per-app language settings. Thanks to Dmitri Gabinski (Belarusian), Ábel Nagy
(Hungarian) and Julie (Slovak); help finishing them is very welcome.
### Changed
- **Arabic, Polish and Simplified Chinese are now complete**, all three carried
the rest of the way this cycle — Simplified Chinese from a fifth of the app to
all of it. Czech and Russian grew too — Russian to about half — and still have
room. Thanks to Sup99999 (Arabic), Bazyli Cyran (Polish), JeanWenzel
(Simplified Chinese), James Trivette (Czech), and SpiritOTHawk and Artur
(Russian).
## [2.19.3] — 2026-08-22
### Added
@@ -1465,9 +1572,16 @@ 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
[#164]: https://codeberg.org/jlmakiola/calendula/issues/164
[#165]: https://codeberg.org/jlmakiola/calendula/issues/165
[#180]: https://codeberg.org/jlmakiola/calendula/issues/180
[#187]: https://codeberg.org/jlmakiola/calendula/issues/187
[#191]: https://codeberg.org/jlmakiola/calendula/issues/191
[#192]: https://codeberg.org/jlmakiola/calendula/issues/192
[#196]: https://codeberg.org/jlmakiola/calendula/issues/196
[#214]: https://codeberg.org/jlmakiola/calendula/issues/214
[#225]: https://codeberg.org/jlmakiola/calendula/issues/225
[#228]: https://codeberg.org/jlmakiola/calendula/issues/228
[#234]: https://codeberg.org/jlmakiola/calendula/issues/234
[#248]: https://codeberg.org/jlmakiola/calendula/issues/248
[#253]: https://codeberg.org/jlmakiola/calendula/issues/253

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 = 21903
versionName = "2.19.3"
versionCode = 21904
versionName = "2.19.4"
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

@@ -31,9 +31,13 @@ private const val MILLIS_PER_MINUTE = 60_000L
* event (a whole-day multiple; sub-day remainders are dropped), so it fires at
* [timeOfDayMinutes] (minutes from local midnight) in [zone]. The result may be
* **negative** — e.g. "at time of event" at 09:00 CEST encodes to 420, meaning
* the provider fires *after* DTSTART; this is valid and must not be clamped.
* A negative [semanticMinutes] is the "provider default" sentinel and passes
* through unchanged.
* the provider fires *after* DTSTART; this is valid and must not be clamped. The
* one negative it never is is [MINUTES_DEFAULT]: the sentinel and the encoding
* share a value space, so an encoding that lands on it is nudged one minute past
* it rather than being read back as "use the account default". That sentinel is
* also the one [semanticMinutes] that passes through unencoded — a lead time may
* itself be negative (a foreign row firing the day *after* the event), and
* waving those through would change when they fire.
*/
internal fun toProviderAllDayMinutes(
semanticMinutes: Int,
@@ -41,12 +45,13 @@ internal fun toProviderAllDayMinutes(
zone: ZoneId,
timeOfDayMinutes: Int,
): Int {
if (semanticMinutes < 0) return semanticMinutes
if (semanticMinutes == MINUTES_DEFAULT) return semanticMinutes
val utcMidnight = startDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
val fire = startDate.minusDays((semanticMinutes / MINUTES_PER_DAY).toLong())
.atTime(LocalTime.of(timeOfDayMinutes / 60, timeOfDayMinutes % 60))
.atZone(zone).toInstant().toEpochMilli()
return ((utcMidnight - fire) / MILLIS_PER_MINUTE).toInt()
val raw = ((utcMidnight - fire) / MILLIS_PER_MINUTE).toInt()
return if (raw == MINUTES_DEFAULT) MINUTES_DEFAULT - 1 else raw
}
/**
@@ -70,15 +75,52 @@ internal fun nextYearlyOccurrence(month: Int, day: Int, today: LocalDate): Local
return LocalDate.of(today.year, month, day)
}
/**
* The date an **imported** all-day event's reminder offset is sampled at.
*
* A one-off fires once, on its own date. A series' `DTSTART` is only an anchor
* and may be ancient — Fossify writes a year-less contact birthday at 1970, a
* year whose rules predate DST across most of Europe, and a real birth year is
* usually older still — so sampling there skews every modern occurrence by the
* offset delta (the same trap [nextYearlyOccurrence] exists for). A yearly
* series is therefore sampled at its next occurrence of the anchor's month/day,
* which shares the season; any other series at [today], which its upcoming
* occurrences are near.
*
* The decode side ([fromProviderAllDayMinutes]) only asks which local *day* the
* encoded instant falls on, so it still reads the lead time back correctly from
* the anchor's own date.
*/
internal fun importedAllDayReminderDate(
startDate: LocalDate,
recurrenceRule: String?,
today: LocalDate,
): LocalDate = when {
recurrenceRule.isNullOrBlank() -> startDate
recurrenceRule.contains("FREQ=YEARLY", ignoreCase = true) ->
nextYearlyOccurrence(startDate.monthValue, startDate.dayOfMonth, today)
startDate.isBefore(today) -> today
else -> startDate
}
/**
* Recover the semantic whole-day lead time from a raw all-day reminder
* [rawMinutes] — the inverse of [toProviderAllDayMinutes], for the form and the
* detail screen. Delegates to [allDayLeadDays], so the day displayed is the day
* the reminder actually fires on.
*
* [MINUTES_DEFAULT] passes through unchanged, the mirror of the encode side. A
* real encoding is often negative, so only that exact value may be treated as the
* sentinel — decoding it would land it on the start date and turn "use the
* account default" into a concrete at-start alarm.
*/
internal fun fromProviderAllDayMinutes(
rawMinutes: Int,
startDate: LocalDate,
zone: ZoneId,
timeOfDayMinutes: Int,
): Int = allDayLeadDays(rawMinutes, startDate, zone, timeOfDayMinutes).toInt() * MINUTES_PER_DAY
): Int = if (rawMinutes == MINUTES_DEFAULT) {
rawMinutes
} else {
allDayLeadDays(rawMinutes, startDate, zone, timeOfDayMinutes).toInt() * MINUTES_PER_DAY
}

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,14 +253,21 @@ interface CalendarDataSource {
): Long
/**
* Change a single occurrence of a recurring event by inserting a
* modified-occurrence exception at [beginMillis] (the occurrence's
* `Instances.BEGIN`) carrying [form]'s values; returns the exception
* row's `Events._ID`. [allDayReminderTimeMinutes]: see [insertEvent].
* Change a single occurrence of a recurring event at [beginMillis] (the
* occurrence's `Instances.BEGIN`) to [form]'s values; returns the
* `Events._ID` of the row now holding them.
*
* A series with a `_sync_id` gets a modified-occurrence exception. One
* without gets the occurrence excluded from the parent via EXDATE plus a
* standalone event carrying the edits — an exception cannot link to its
* parent there (Codeberg #234, the same constraint as [deleteOccurrence]).
* A row that turns out not to recur at all is edited whole, which is what
* [original] is for. [allDayReminderTimeMinutes]: see [insertEvent].
*/
fun updateOccurrence(
eventId: Long,
beginMillis: Long,
original: EventForm,
form: EventForm,
allDayReminderTimeMinutes: Int,
): Long
@@ -721,7 +749,10 @@ class AndroidCalendarDataSource @Inject constructor(
}
}
override fun eventColorPalette(calendarId: Long): List<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 +770,13 @@ class AndroidCalendarDataSource @Inject constructor(
c.mapAll { EventColorOption(key = it.getString(0).orEmpty(), argb = it.getInt(1)) }
}
?.filter { it.key.isNotEmpty() }
?.curatedForPicker()
?: emptyList()
}
override fun exportableEvents(calendarIds: Set<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 +811,7 @@ class AndroidCalendarDataSource @Inject constructor(
reader.toIcsEvent(
reminderMinutes = queryReminders(eventId).map { it.minutes },
calendarName = names[calendarId],
allDayReminderTimeMinutes = allDayReminderTimeMinutes,
)
}
} ?: emptyList()
@@ -786,76 +820,83 @@ class AndroidCalendarDataSource @Inject constructor(
override fun existingUids(calendarId: Long): Set<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) {
// The event row is already in. A reminder that won't attach costs
// that one alarm, not the event: throwing here would have the import
// count an event it did create as failed, and the retry would then
// skip it as a duplicate.
val attached = try {
resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder)
} catch (e: Exception) {
Log.w(TAG, "Reminder insert threw for imported event $eventId", e)
null
}
if (attached == null) {
Log.w(TAG, "Failed to attach reminder ($minutes min) to imported event $eventId")
}
}
return eventId
}
/** Provider DURATION for an imported recurring row: whole days / seconds. */
private fun importDuration(startMillis: Long, endMillis: Long, isAllDay: Boolean): String {
val span = (endMillis - startMillis).coerceAtLeast(0)
return if (isAllDay) "P${span / 86_400_000L}D" else "P${span / 1_000L}S"
}
private fun EventStatus.toProviderStatus(): Int = when (this) {
EventStatus.Confirmed -> CalendarContract.Events.STATUS_CONFIRMED
EventStatus.Tentative -> CalendarContract.Events.STATUS_TENTATIVE
EventStatus.Cancelled -> CalendarContract.Events.STATUS_CANCELED
}
/** The account a calendar belongs to, for scoping a `Colors` lookup. */
private fun calendarAccount(calendarId: Long): CalendarAccount? = resolver.query(
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarId),
@@ -976,10 +1017,12 @@ class AndroidCalendarDataSource @Inject constructor(
updated: EventForm,
allDayReminderTimeMinutes: Int,
) {
val row = querySeriesRow(eventId)
val values = buildEventUpdateValues(
original = original,
updated = updated,
seriesDtStartMillis = querySeriesRow(eventId).dtStartMillis,
seriesDtStartMillis = row.dtStartMillis,
seriesExdate = row.exdate,
zone = ZoneId.systemDefault(),
)
if (values.isNotEmpty()) {
@@ -1191,9 +1234,22 @@ class AndroidCalendarDataSource @Inject constructor(
override fun updateOccurrence(
eventId: Long,
beginMillis: Long,
original: EventForm,
form: EventForm,
allDayReminderTimeMinutes: Int,
): Long {
val row = querySeriesRow(eventId)
// Not a series, so there is no occurrence to single out: the row *is* the
// event, and an exception with nothing to be an exception to would leave
// an orphan holding the edit (the same test deleteOccurrence makes).
if (!row.recurs) {
updateEvent(eventId, original, form, allDayReminderTimeMinutes)
return eventId
}
// EXDATE needs no _sync_id; a modified exception does.
if (row.syncId == null) {
return detachOccurrence(eventId, beginMillis, row, form, allDayReminderTimeMinutes)
}
// The provider clones the series row and applies these values on top.
val values = buildOccurrenceExceptionValues(
form = form,
@@ -1212,6 +1268,90 @@ class AndroidCalendarDataSource @Inject constructor(
return exceptionId
}
/**
* "Edit only this event" on a series with **no `_sync_id`**: drop the
* occurrence from the parent with EXDATE and insert the edited values as a
* standalone event on the same calendar.
*
* A modified exception attaches to its parent only through `ORIGINAL_SYNC_ID`,
* exactly like the cancelled one [deleteOccurrence] documents; with no
* `_sync_id` the link never forms and the edit is lost (Codeberg #234).
* EXDATE plus a standalone row needs no link — what a detached instance
* degrades to without a `RECURRENCE-ID` to carry it.
*
* The detached row keeps no stored link back to its series, so: it no longer
* travels with it ([moveEvent] copies the master and its `ORIGINAL_ID`
* children, and this is neither); its EXDATE hole is an absolute instant, so
* re-timing the whole series brings the occurrence back beside the copy (a
* #47 delete resurrects the same way); and it is built from the form, not
* cloned, so `ORGANIZER`, `STATUS` and the attendee rows [reconcileAttendees]
* preserves are dropped — the same limitation as [moveEvent].
*
* Insert first, so a failure leaves the series untouched
* ([updateEventFromOccurrence]'s discipline); roll the new row back if the
* EXDATE update then fails, since it would be a visible duplicate. The
* reverse order risks the worse outcome — an excluded occurrence with no
* replacement, i.e. an edit that quietly deletes.
*/
private fun detachOccurrence(
eventId: Long,
beginMillis: Long,
row: SeriesRow,
form: EventForm,
allDayReminderTimeMinutes: Int,
): Long {
// Already detached (or deleted) from a stale screen still pointing at the
// parent: the EXDATE merge would fold the repeat away and still report a
// changed row, quietly leaving a *second* standalone copy.
if (exdateContains(row.exdate, beginMillis, row.allDay != 0, row.timezone)) {
throw NoSuchEventException(eventId)
}
// Reminders, guests and colour come along like any new event, and so does
// a fresh UID — the detached row is a separate event now, and sharing the
// parent's would collide with it in .ics restore dedup.
val detachedId = insertEvent(form.toDetachedOccurrence(), allDayReminderTimeMinutes)
val values = buildOccurrenceExdateValues(
existingExdate = row.exdate,
occurrenceMillis = beginMillis,
dtStartMillis = row.dtStartMillis,
rrule = row.rrule,
rdate = row.rdate,
duration = row.duration,
timezone = row.timezone,
allDay = row.allDay,
)
// Rows touched, not occurrences excluded — 1 whenever the series row still
// exists. It catches the row disappearing under us, not an EXDATE the
// provider's expansion fails to match.
val updatedRows = try {
resolver.update(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
values.toContentValues(), null, null,
)
} catch (t: Throwable) {
rollBackDetached(detachedId)
throw t
}
if (updatedRows == 0) {
rollBackDetached(detachedId)
throw WriteFailedException(
"exdate occurrence for edit, event id=$eventId begin=$beginMillis",
)
}
return detachedId
}
/**
* Undo the standalone row [detachOccurrence] inserted before its EXDATE
* update failed. Best effort: the caller is already throwing, and the worst
* case is the duplicate we were avoiding — never a lost occurrence.
*/
private fun rollBackDetached(detachedId: Long) {
runCatching { deleteEvent(detachedId) }.onFailure {
Log.w(TAG, "Failed to roll back detached occurrence $detachedId", it)
}
}
override fun updateEventFromOccurrence(
eventId: Long,
beginMillis: Long,
@@ -1228,10 +1368,71 @@ class AndroidCalendarDataSource @Inject constructor(
}
// Insert the new series first: if it fails, the original is untouched.
val newEventId = insertEvent(updated, allDayReminderTimeMinutes)
carrySplitExdate(newEventId, row, beginMillis, original, updated)
truncateSeries(eventId, row, beginMillis)
return newEventId
}
/**
* Carry the [parent]'s exclusions for occurrences past [beginMillis] onto the
* series [newEventId] that now owns them, re-timed by the shift the split
* applied ([shiftedExdate]/[exdateAfter]). [insertEvent] builds the new row
* from the form, which knows nothing about them, so without this every
* occurrence the user had deleted from the tail of the series comes back.
*
* The whole time/recurrence set rides along with EXDATE for the reason
* [buildOccurrenceExdateValues] documents: on its own the provider does not
* read an EXDATE write as a recurrence change, and leaves the instances it
* expanded on insert standing.
*
* A failure rolls the new series back and throws, so the split fails whole
* rather than landing with the exclusions quietly dropped — the parent is
* still untruncated at this point, so the event is left exactly as it was.
*/
private fun carrySplitExdate(
newEventId: Long,
parent: SeriesRow,
beginMillis: Long,
original: EventForm,
updated: EventForm,
) {
// Dropping the recurrence in the same save leaves a one-off tail, and an
// exclusion means nothing on a row that doesn't recur.
if (updated.rrule.isNullOrBlank()) return
val carried = shiftedExdate(
existingExdate = exdateAfter(
parent.exdate,
beginMillis,
original.isAllDay,
parent.timezone,
),
original = original,
updated = updated,
zone = ZoneId.systemDefault(),
) ?: return
val row = querySeriesRow(newEventId)
val values = ContentValues().apply {
put(CalendarContract.Events.EXDATE, carried)
put(CalendarContract.Events.DTSTART, row.dtStartMillis)
put(CalendarContract.Events.RRULE, row.rrule)
put(CalendarContract.Events.DURATION, row.duration)
put(CalendarContract.Events.EVENT_TIMEZONE, row.timezone)
put(CalendarContract.Events.ALL_DAY, row.allDay)
}
try {
val rows = resolver.update(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, newEventId),
values, null, null,
)
if (rows == 0) {
throw WriteFailedException("carry exdate onto split series id=$newEventId")
}
} catch (t: Throwable) {
runCatching { deleteEvent(newEventId) }
throw t
}
}
override fun deleteEventFromOccurrence(eventId: Long, beginMillis: Long) {
val row = querySeriesRow(eventId)
// From the first occurrence on = the whole series; also the fallback
@@ -1284,6 +1485,7 @@ class AndroidCalendarDataSource @Inject constructor(
CalendarContract.Events.ALL_DAY,
CalendarContract.Events._SYNC_ID,
CalendarContract.Events.EXDATE,
CalendarContract.Events.RDATE,
),
null, null, null,
)?.use { c ->
@@ -1296,6 +1498,7 @@ class AndroidCalendarDataSource @Inject constructor(
allDay = c.getInt(4),
syncId = c.getString(5),
exdate = c.getString(6),
rdate = c.getString(7),
)
} else {
null
@@ -1311,7 +1514,16 @@ class AndroidCalendarDataSource @Inject constructor(
/** Null on a local calendar (and before a synced event's first push). */
val syncId: String? = null,
val exdate: String? = null,
val rdate: String? = null,
) {
/**
* Whether this row expands into more than itself. RDATE counts as much as
* RRULE: a sync adapter may list the occurrences instead of naming a rule,
* and the provider expands either — as does the read side, which marks
* such a row recurring too.
*/
val recurs: Boolean get() = !rrule.isNullOrBlank() || !rdate.isNullOrBlank()
/** UNTIL cutoff for ending the series before the occurrence at [beginMillis]. */
fun truncationCutoff(beginMillis: Long): Long = previousLocalDayEndUtcMillis(
beginMillis = beginMillis,
@@ -1502,6 +1714,12 @@ class AndroidCalendarDataSource @Inject constructor(
override fun deleteOccurrence(eventId: Long, beginMillis: Long) {
val row = querySeriesRow(eventId)
// Not a series, so the row *is* the event: an exclusion would put a
// recurrence set on a one-off, an exception would leave an orphan.
if (!row.recurs) {
deleteEvent(eventId)
return
}
if (row.syncId == null) {
// No _sync_id — a local calendar, or a synced event not pushed yet.
// A cancelled exception can only attach to its parent through
@@ -1515,6 +1733,7 @@ class AndroidCalendarDataSource @Inject constructor(
occurrenceMillis = beginMillis,
dtStartMillis = row.dtStartMillis,
rrule = row.rrule,
rdate = row.rdate,
duration = row.duration,
timezone = row.timezone,
allDay = row.allDay,

View File

@@ -88,9 +88,15 @@ interface CalendarRepository {
/**
* Change a single occurrence of a recurring event (exception row with the
* form's values); returns the exception's `Events._ID`.
* form's values); returns the exception's `Events._ID`. [original] is what
* the row holds now, for the case where it turns out not to recur.
*/
suspend fun updateOccurrence(eventId: Long, beginMillis: Long, form: EventForm): Long
suspend fun updateOccurrence(
eventId: Long,
beginMillis: Long,
original: EventForm,
form: EventForm,
): Long
/**
* Change a recurring event from [beginMillis] onwards (series split);

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,60 @@ 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
for (event in events) {
var failed = 0
var notAttempted = 0
for ((index, event) in events.withIndex()) {
// A known UID means the event is already in this calendar — skip,
// keeping a restore idempotent (no overwrite this pass).
if (event.uid != null && event.uid in existing) {
skipped++
} else {
dataSource.insertImportedEvent(event, targetCalendarId)
continue
}
try {
dataSource.insertImportedEvent(event, targetCalendarId, allDayMinutes, palette)
imported++
} catch (e: CancellationException) {
throw e
} catch (e: SecurityException) {
// Not this event's fault: the permission or the target calendar is
// gone, so every remaining insert would fail the same way.
Log.w(TAG, "Import stopped: the calendar is no longer writable", e)
// A known UID would have been skipped whatever happened, so it is
// no part of what a retry still has to import.
val remaining = events.drop(index)
val duplicates = remaining.count { it.uid?.let { uid -> uid in existing } == true }
skipped += duplicates
notAttempted = remaining.size - duplicates
break
} catch (e: Exception) {
// One event the provider won't take must not cost the user the
// rest of the file — foreign exports do carry rows it rejects
// outright (a malformed RRULE throws straight out of insert).
failed++
Log.w(TAG, "Skipped an unimportable event", e)
}
}
IcsImportSummary(imported = imported, skippedDuplicate = skipped)
IcsImportSummary(
imported = imported,
skippedDuplicate = skipped,
failed = failed,
notAttempted = notAttempted,
)
}
override suspend fun createEvent(form: EventForm): Long = withContext(io) {
@@ -262,9 +298,12 @@ class CalendarRepositoryImpl @Inject constructor(
override suspend fun updateOccurrence(
eventId: Long,
beginMillis: Long,
original: EventForm,
form: EventForm,
): Long = withContext(io) {
dataSource.updateOccurrence(eventId, beginMillis, form, allDayReminderTimeMinutes())
dataSource.updateOccurrence(
eventId, beginMillis, original, form, allDayReminderTimeMinutes(),
)
}
override suspend fun updateEventFromOccurrence(
@@ -288,6 +327,10 @@ class CalendarRepositoryImpl @Inject constructor(
) = withContext(io) {
dataSource.deleteEventFromOccurrence(eventId, beginMillis)
}
private companion object {
const val TAG = "CalendarRepository"
}
}
private fun <T> Flow<Unit>.reQuery(block: suspend () -> T): Flow<T> = flow {

View File

@@ -3,14 +3,23 @@ package de.jeanlucmakiola.calendula.data.calendar
import android.provider.CalendarContract
import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import de.jeanlucmakiola.calendula.domain.nearestTo
import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toJavaLocalDateTime
import java.time.Duration
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.time.format.ResolverStyle
import java.time.LocalDate as JavaLocalDate
import java.time.LocalDateTime as JavaLocalDateTime
import java.time.LocalTime as JavaLocalTime
/** Provider-ready DTSTART / DTEND / EVENT_TIMEZONE for an event write. */
internal data class EventWriteTimes(
@@ -134,18 +143,21 @@ internal fun buildEventInsertValues(
*
* Time fields travel together (the provider validates them as a unit):
* - unchanged times, all-day flag and rrule → no time columns at all;
* - non-recurring result → DTSTART/DTEND, DURATION and RRULE cleared;
* - non-recurring result → DTSTART/DTEND, DURATION, RRULE and EXDATE cleared;
* - recurring result → the *series* DTSTART moves by the same **wall-clock**
* shift the user applied to the displayed occurrence and is re-resolved in the
* event's zone ([seriesDtStartMillis] is the row's current DTSTART), DURATION
* replaces DTEND, RRULE is written. This keeps past occurrences intact when
* someone edits a later occurrence's time, and keeps the anchor's time-of-day
* stable across a DST boundary or a zone change between the two.
* replaces DTEND, RRULE is written, and the row's exclusions
* ([seriesExdate], the current EXDATE) travel with the anchor. This keeps past
* occurrences intact when someone edits a later occurrence's time, and keeps
* the anchor's time-of-day stable across a DST boundary or a zone change
* between the two.
*/
internal fun buildEventUpdateValues(
original: EventForm,
updated: EventForm,
seriesDtStartMillis: Long,
seriesExdate: String?,
zone: ZoneId,
): Map<String, Any?> = buildMap {
if (updated.title.trim() != original.title.trim()) {
@@ -185,6 +197,10 @@ internal fun buildEventUpdateValues(
put(CalendarContract.Events.DTEND, newTimes.dtEndMillis)
put(CalendarContract.Events.RRULE, null)
put(CalendarContract.Events.DURATION, null)
// The exclusions named occurrences of a series that no longer exists.
// Left behind they are dormant rather than harmless: adding a recurrence
// back later would punch the old holes into the new one.
put(CalendarContract.Events.EXDATE, null)
} else {
// Move the series anchor by the *wall-clock* shift the user applied to the
// displayed occurrence, then re-resolve it in the event's (possibly new)
@@ -206,6 +222,14 @@ internal fun buildEventUpdateValues(
put(CalendarContract.Events.DTEND, null)
put(CalendarContract.Events.RRULE, updated.rrule)
put(CalendarContract.Events.DURATION, newTimes.toRfc2445Duration(updated.isAllDay))
// An EXDATE stamp is an absolute instant, so it excludes an occurrence
// only while the series keeps generating one at exactly that instant. The
// anchor has just moved, so every occurrence regenerates elsewhere and a
// stamp left behind matches none of them — the occurrence the user deleted
// comes back. Move the stamps the same way the anchor moved.
shiftedExdate(seriesExdate, original, updated, zone)
?.takeIf { it != seriesExdate }
?.let { put(CalendarContract.Events.EXDATE, it) }
}
}
@@ -243,6 +267,21 @@ internal fun buildOccurrenceExceptionValues(
putAll(eventColorColumns(form.colorKey, form.color))
}
/**
* The form as a **detached occurrence**: the same edited values, with the
* series rule dropped so [buildEventInsertValues] writes a standalone one-off
* row (DTSTART + DTEND, no RRULE/DURATION) at the occurrence's own times.
*
* The "edit only this event" shape for a series with **no `_sync_id`**, where an
* exception row can't attach to its parent at all (Codeberg #234).
*
* The exception path gets the rule dropped for free — the provider clears the
* RRULE it cloned when an exception carries DTSTART + DURATION
* ([buildOccurrenceExceptionValues]). Here nothing is cloned, so it is stripped
* by hand; leaving it on would insert a second *series* overlapping the first.
*/
internal fun EventForm.toDetachedOccurrence(): EventForm = copy(rrule = null)
/**
* Raw provider snapshot of a master/one-off Events row, enough to re-insert it
* verbatim on another calendar (a calendar move is copy+delete — `CALENDAR_ID`
@@ -406,52 +445,336 @@ internal fun buildOccurrenceCancelValues(
* first occurrence. Passing DTSTART + DURATION + RRULE + zone together is what
* re-expands it correctly. All observed on a Pixel; see the #47 notes.
*
* [rdate] travels with [rrule]: a sync adapter may list a series' occurrences
* instead of naming a rule, and rewriting the recurrence set without it would
* leave the provider nothing but DTSTART to re-expand from — the collapse this
* values map exists to avoid.
*
* EXDATE is a comma-separated list, so an existing one is appended to (a repeat
* of the same occurrence is folded away). All-day series take the `VALUE=DATE`
* form (`yyyyMMdd`), timed ones the UTC date-time form (`yyyyMMddTHHmmssZ`).
* of the same occurrence is folded away — [mergedExdate]).
*/
internal fun buildOccurrenceExdateValues(
existingExdate: String?,
occurrenceMillis: Long,
dtStartMillis: Long,
rrule: String?,
rdate: String?,
duration: String?,
timezone: String?,
allDay: Int,
): Map<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(",")
return mapOf(
CalendarContract.Events.EXDATE to merged,
): Map<String, Any?> = mapOf(
CalendarContract.Events.EXDATE to
mergedExdate(existingExdate, occurrenceMillis, allDay != 0, timezone),
CalendarContract.Events.DTSTART to dtStartMillis,
CalendarContract.Events.RRULE to rrule,
CalendarContract.Events.RDATE to rdate,
CalendarContract.Events.DURATION to duration,
CalendarContract.Events.EVENT_TIMEZONE to timezone,
CalendarContract.Events.ALL_DAY to allDay,
)
/**
* [existingExdate] with the occurrence at [occurrenceMillis] excluded as well.
*
* Existing stamps are canonicalised first — all-day series take the `VALUE=DATE`
* form (`yyyyMMdd`), timed ones the UTC date-time form (`yyyyMMddTHHmmssZ`) — so
* appending to a list a sync adapter wrote in the other accepted form folds the
* repeat away instead of leaving the same day excluded twice in two spellings.
*
* A stamp in no form Calendula reads makes the whole list untouchable: it is kept
* exactly as it is, behind the parameter prefix that tells a reader how to
* resolve it, and the new exclusion joins it in the list's own form.
* Canonicalising the readable stamps around it would leave absolute stamps under
* a prefix claiming a zone for all of them — which is how the unreadable one
* quietly changes instant.
*/
private fun mergedExdate(
existingExdate: String?,
occurrenceMillis: Long,
isAllDay: Boolean,
timezone: String?,
): String {
val listZone = exdateListZone(existingExdate, timezone)
val stamps = exdateStamps(existingExdate)
val canonical = stamps.map { canonicalExdateStamp(it, isAllDay, listZone) }
if (canonical.all { it != null }) {
val stamp = formatExdateStamp(occurrenceMillis, isAllDay)
return (canonical.filterNotNull() + stamp).distinct().joinToString(",")
}
val prefix = existingExdate?.exdateParameterPrefix().orEmpty()
val stamp = if (isAllDay || prefix.isEmpty()) {
formatExdateStamp(occurrenceMillis, isAllDay)
} else {
Instant.ofEpochMilli(occurrenceMillis).atZone(listZone).format(FLOATING_EXDATE)
}
return prefix + (stamps + stamp).distinct().joinToString(",")
}
/**
* Whether [existingExdate] already excludes the occurrence at [occurrenceMillis]
* — i.e. it has already been dropped from the series, deleted or detached.
*
* Guards the detach path against running twice from a stale screen: the EXDATE
* merge folds the repeat away silently and the update still reports one row
* changed, so a second save would leave a second standalone copy.
*/
internal fun exdateContains(
existingExdate: String?,
occurrenceMillis: Long,
isAllDay: Boolean,
timezone: String?,
): Boolean {
val stamp = formatExdateStamp(occurrenceMillis, isAllDay)
val listZone = exdateListZone(existingExdate, timezone)
// Canonicalised rather than compared verbatim: a sync adapter may spell the
// same exclusion differently to Calendula, and a guard that misses it lets
// the detach run a second time and leave a second standalone copy.
return exdateStamps(existingExdate)
.any { canonicalExdateStamp(it, isAllDay, listZone) == stamp }
}
/**
* [existingExdate] with every stamp re-timed by the same **wall-clock** shift the
* series anchor takes from [original] to [updated] — the exclusions' half of the
* anchor move [buildEventUpdateValues] performs, and what carries them onto the
* new series when "this and following" splits one.
*
* A stamp is an absolute instant, so it keeps naming its occurrence only if it
* moves exactly as the occurrence does: shifted in wall clock and re-resolved in
* the event's (possibly new) zone. A millisecond delta would instead bake in the
* offset that happened to apply at the edited occurrence — an hour off for any
* exclusion on the far side of a DST boundary. All-day-ness is read from
* [original] and written from [updated], so the `VALUE=DATE` and date-time forms
* convert into each other when the event switches.
*
* A zoned list is read in the zone its prefix names ([exdateListZone]). Null
* when there is nothing to carry: no stamps, or a stamp in a form Calendula
* cannot read at all. Those are left exactly as they are rather than guessed at —
* a stale stamp excludes nothing, but a mangled one could exclude the wrong
* occurrence.
*/
internal fun shiftedExdate(
existingExdate: String?,
original: EventForm,
updated: EventForm,
zone: ZoneId,
): String? {
val stamps = exdateStamps(existingExdate)
if (stamps.isEmpty()) return null
val fromZone = original.writeZone(zone)
val toZone = updated.writeZone(zone)
val listZone = exdateListZone(existingExdate, fromZone.id)
val wallClockShift = Duration.between(original.anchorLocal(), updated.anchorLocal())
return stamps
.map { stamp ->
val local = parseExdateStamp(stamp, original.isAllDay, fromZone, listZone)
?: return null
formatExdateStamp(local.plus(wallClockShift), updated.isAllDay, toZone)
}
.distinct()
.joinToString(",")
}
/**
* The stamps of [existingExdate] naming occurrences after [beginMillis] — the ones
* that belong to the *new* series once "this and following" splits a recurring
* event there. The parent keeps the full list; its stamps past the split point are
* simply inert once it stops generating those occurrences.
*
* The occurrence at [beginMillis] itself is never carried. It is the one the user
* is editing, so it exists by definition, and honouring a stale exclusion for it
* would swallow the edit whole.
*
* The stamps that qualify come back canonicalised, since the zone prefix that
* told the reader how to resolve them does not survive the filter.
*
* Null when nothing qualifies, or when a stamp can't be read (see [shiftedExdate]).
*/
internal fun exdateAfter(
existingExdate: String?,
beginMillis: Long,
isAllDay: Boolean,
timezone: String?,
): String? {
val stamps = exdateStamps(existingExdate)
if (stamps.isEmpty()) return null
val listZone = exdateListZone(existingExdate, timezone)
return stamps
.mapNotNull { stamp ->
val utc = parseExdateStamp(stamp, isAllDay, ZoneOffset.UTC, listZone) ?: return null
utc.takeIf { it.toInstant(ZoneOffset.UTC).toEpochMilli() > beginMillis }
}
.map { formatExdateStamp(it, isAllDay, ZoneOffset.UTC) }
.takeIf { it.isNotEmpty() }
?.joinToString(",")
}
/**
* The individual stamps of an EXDATE column value; it is a comma-separated list,
* optionally behind the parameter prefix AOSP's `RecurrenceSet` puts in front of
* a zoned one ([exdateListZone] reads that half).
*/
private fun exdateStamps(exdate: String?): List<String> = exdate
?.withoutExdateParameters()
?.split(',')
?.map { it.trim() }
?.filter { it.isNotEmpty() }
.orEmpty()
/**
* An EXDATE column value with the parameter section its stamps sit behind taken
* off.
*/
internal fun String.withoutExdateParameters(): String = substring(exdateParameterPrefix().length)
/**
* The parameter section an EXDATE list's stamps sit behind, its `;` included, or
* empty when it has none. Only a `;` before the first stamp separator is one —
* anything later belongs to a stamp, and cutting there would drop every stamp in
* front of it.
*/
private fun String.exdateParameterPrefix(): String {
val semicolon = indexOf(';')
val comma = indexOf(',')
return if (semicolon >= 0 && (comma < 0 || semicolon < comma)) {
substring(0, semicolon + 1)
} else {
""
}
}
/**
* The zone the floating stamps of [exdate] are wall clock in: the one its
* parameter prefix names, or [fallback] — the event's own `EVENT_TIMEZONE` —
* when there is no prefix, or the device's tz database doesn't know the name it
* uses (Exchange spells zones its own way). The series' own zone is the right
* guess, since a zoned exclusion list is written in it. UTC last, for a row that
* carries no zone either. Stamps already written as UTC instants ignore all this.
*
* Both spellings of the prefix are read. AOSP's `RecurrenceSet` stores the TZID
* parameter's *value* alone (`Europe/Berlin;<stamps>`), and so does DAVx5; the
* iCalendar `TZID=<zone>;` some writers keep is the other one. Only matching the
* second would miss every list the provider itself wrote.
*/
internal fun exdateListZone(exdate: String?, fallback: String?): ZoneId =
exdate?.exdateZoneName()?.toZoneIdOrNull()
?: fallback?.toZoneIdOrNull()
?: ZoneOffset.UTC
/** The zone id an EXDATE list's parameter prefix names, with or without `TZID=`. */
private fun String.exdateZoneName(): String {
val prefix = exdateParameterPrefix().dropLast(1)
return if (prefix.startsWith(TZID_PREFIX, ignoreCase = true)) {
prefix.drop(TZID_PREFIX.length)
} else {
prefix
}
}
private fun String.toZoneIdOrNull(): ZoneId? = runCatching { ZoneId.of(this) }.getOrNull()
/** The optional `TZID=` a zoned EXDATE list's zone id may sit behind. */
private const val TZID_PREFIX = "TZID="
/**
* One EXDATE entry for the occurrence starting at [occurrenceMillis]. Both forms
* 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 Calendula cannot read, so a caller can
* tell "not ours, leave it alone" from a value it may safely re-time.
*
* A timed exclusion is read both as the UTC instant Calendula writes and as a
* floating stamp, which [listZone] then resolves. An all-day one is read both
* bare (`yyyyMMdd`, what Calendula writes) and padded to a midnight stamp, in
* either spelling: sync adapters are inconsistent about which they store — the
* same forms [exportExDates] reshapes on the way out — and refusing one would
* drop every exclusion of a foreign all-day series the moment it is re-timed.
*/
private fun parseExdateStamp(
stamp: String,
isAllDay: Boolean,
zone: ZoneId,
listZone: ZoneId,
): JavaLocalDateTime? =
if (isAllDay) {
parseAllDayExdate(stamp)?.atStartOfDay()
} else {
parseTimedExdate(stamp, listZone)?.withZoneSameInstant(zone)?.toLocalDateTime()
}
/**
* A timed EXDATE stamp as the instant it names: the trailing `Z` form resolved in
* UTC, or the floating one resolved in [listZone].
*/
private fun parseTimedExdate(stamp: String, listZone: ZoneId): ZonedDateTime? =
runCatching { JavaLocalDateTime.parse(stamp, TIMED_EXDATE).atZone(ZoneOffset.UTC) }.getOrNull()
?: runCatching {
JavaLocalDateTime.parse(stamp, FLOATING_EXDATE).atZone(listZone)
}.getOrNull()
/**
* The calendar day an all-day EXDATE stamp names, bare or padded to a midnight
* stamp. A padded stamp naming any other time of day is refused rather than
* rounded: it is not a form an all-day series is written in, so it could be
* naming something else entirely. Midnight is midnight in any zone, so the
* padding's own spelling — trailing `Z` or floating — makes no difference.
*/
private fun parseAllDayExdate(stamp: String): JavaLocalDate? {
runCatching { JavaLocalDate.parse(stamp, ALL_DAY_EXDATE) }.getOrNull()?.let { return it }
val padded = parsePaddedExdate(stamp) ?: return null
return padded.toLocalDate().takeIf { padded.toLocalTime() == JavaLocalTime.MIDNIGHT }
}
/** An `uuuuMMddTHHmmss` stamp, with or without the trailing `Z`, as its own wall clock. */
private fun parsePaddedExdate(stamp: String): JavaLocalDateTime? =
runCatching { JavaLocalDateTime.parse(stamp, TIMED_EXDATE) }.getOrNull()
?: runCatching { JavaLocalDateTime.parse(stamp, FLOATING_EXDATE) }.getOrNull()
/**
* [stamp] in the form Calendula writes, so two spellings of the same exclusion
* compare equal. Null for a stamp in no form it reads.
*/
internal fun canonicalExdateStamp(
stamp: String,
isAllDay: Boolean,
listZone: ZoneId,
): String? = parseExdateStamp(stamp, isAllDay, ZoneOffset.UTC, listZone)
?.let { formatExdateStamp(it, isAllDay, ZoneOffset.UTC) }
/** `VALUE=DATE` EXDATE form, for an all-day series. */
private val ALL_DAY_EXDATE: DateTimeFormatter =
DateTimeFormatter.ofPattern("uuuuMMdd").withResolverStyle(ResolverStyle.STRICT)
/** UTC date-time EXDATE form, for a timed series. */
private val TIMED_EXDATE: DateTimeFormatter =
DateTimeFormatter.ofPattern("uuuuMMdd'T'HHmmss'Z'").withResolverStyle(ResolverStyle.STRICT)
/** The same form without the `Z`, wall clock in the list's zone rather than UTC. */
private val FLOATING_EXDATE: DateTimeFormatter =
DateTimeFormatter.ofPattern("uuuuMMdd'T'HHmmss").withResolverStyle(ResolverStyle.STRICT)
/**
* The `EVENT_COLOR` / `EVENT_COLOR_KEY` columns for a colour selection. A
* [colorKey] writes the key alone (the provider derives `EVENT_COLOR` from the
@@ -499,3 +822,73 @@ internal fun AccessLevel.toProviderValue(): Int = when (this) {
AccessLevel.Private -> CalendarContract.Events.ACCESS_PRIVATE
AccessLevel.Public -> CalendarContract.Events.ACCESS_PUBLIC
}
/**
* The `Events` row for a `.ics` event being imported into [calendarId].
*
* A recurring row carries RRULE + DURATION (and any EXDATE) with no DTEND; a
* one-off carries DTEND. All-day rows live at UTC midnights, exactly as the
* parser hands them over.
*
* [palette] is the target account's published event colours. A calendar that
* publishes one rejects a raw `EVENT_COLOR`, so an imported colour — which is
* an arbitrary ARGB from a foreign app — is snapped to the nearest key it does
* accept; accounts with no palette take the raw value (see [eventColorColumns]).
*/
internal fun buildImportedEventValues(
event: ParsedIcsEvent,
calendarId: Long,
uid: String,
palette: List<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,77 @@ internal fun ColumnReader.toIcsEvent(
summary = getString(EventExportProjection.IDX_TITLE).orEmpty(),
start = dtStart.toKotlinInstantFromEpochMillis(),
end = end.toKotlinInstantFromEpochMillis(),
isAllDay = getInt(EventExportProjection.IDX_ALL_DAY) != 0,
isAllDay = isAllDay,
zoneId = getString(EventExportProjection.IDX_EVENT_TIMEZONE)?.takeIf { it.isNotBlank() }
?: "UTC",
recurrenceRule = rrule,
exDates = if (rrule == null) {
emptyList()
} else {
exportExDates(
getString(EventExportProjection.IDX_EXDATE),
isAllDay,
getString(EventExportProjection.IDX_EVENT_TIMEZONE),
)
},
location = getString(EventExportProjection.IDX_LOCATION),
description = getString(EventExportProjection.IDX_DESCRIPTION),
reminderMinutes = reminderMinutes,
reminderMinutes = if (isAllDay) {
val startDate = Instant.ofEpochMilli(dtStart).atZone(ZoneOffset.UTC).toLocalDate()
reminderMinutes.map {
fromProviderAllDayMinutes(it, startDate, zone, allDayReminderTimeMinutes)
}
} else {
reminderMinutes
},
status = status,
availability = mapAvailability(getInt(EventExportProjection.IDX_AVAILABILITY)),
calendarName = calendarName,
)
}
/**
* The row's `EXDATE` as the writer wants it: one stamp per entry, empties
* dropped.
*
* An all-day exclusion names a calendar day, and sync adapters are inconsistent
* about whether they write it as a bare `yyyyMMdd` or pad it to a midnight
* stamp; the time part is dropped so the exported value is the day either way.
* Calendula's own writes ([buildOccurrenceExdateValues]) are already bare.
*
* A zoned exclusion arrives in AOSP's `RecurrenceSet` form, `<zone>;<stamps>`,
* where the prefix names the zone the whole list is wall clock in. [IcsWriter]
* emits one bare `EXDATE:` line, and a parameter belongs before that colon, not
* inside the value — passed on as-is it would make the line unparseable. The
* prefix is therefore taken off and its stamps resolved to UTC instants
* ([canonicalExdateStamp], the same reading the write path does), the form every
* reader accepts.
*
* Which zone that resolution uses is [exdateListZone]'s call, the same one the
* write path makes: the `TZID=` when this device's tz database knows the name,
* else the row's own [eventTimezone]. A stamp is never emitted floating just
* because the prefix named a zone we couldn't place — against a `DTSTART;TZID=…`
* line every reader would resolve it in its own zone and the exclusion would stop
* matching its occurrence.
*/
internal fun exportExDates(
exdate: String?,
isAllDay: Boolean,
eventTimezone: String? = null,
): List<String> {
if (exdate.isNullOrBlank()) return emptyList()
val zone = exdateListZone(exdate, eventTimezone)
return exdate
.withoutExdateParameters()
.split(',')
.map { it.trim() }
.filter { it.isNotEmpty() }
.map { stamp ->
if (isAllDay) {
stamp.substringBefore('T')
} else {
canonicalExdateStamp(stamp, isAllDay = false, listZone = zone) ?: stamp
}
}
.distinct()
}

View File

@@ -121,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(
@@ -137,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,
@@ -153,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
}
/**

View File

@@ -0,0 +1,24 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.provider.CalendarContract
/**
* "Fire at whatever lead time this account defaults to" — a sentinel in the
* `Reminders.MINUTES` column, not an offset, so nothing may do arithmetic on it.
* `CalendarContract` never exposes what an account resolves it to, so Calendula
* neither fires such a row ([withoutProviderDefaults]) nor decodes it
* ([fromProviderAllDayMinutes]); the detail screen names it instead.
*
* It is the **only** negative that means this. A real all-day offset is often
* negative too — the firing time of day is encoded into it — so a `< 0` test
* would silence reminders that do fire.
*/
internal const val MINUTES_DEFAULT = CalendarContract.Reminders.MINUTES_DEFAULT
/**
* Raw provider offsets with the account-default rows dropped, so the pure
* planner ([de.jeanlucmakiola.calendula.domain.reminders.planReminders]) only
* ever sees offsets it can resolve.
*/
internal fun Map<Long, List<Int>>.withoutProviderDefaults(): Map<Long, List<Int>> =
mapValues { (_, minutes) -> minutes.filter { it != MINUTES_DEFAULT } }

View File

@@ -7,6 +7,7 @@ import android.util.Log
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
import de.jeanlucmakiola.calendula.data.calendar.withoutProviderDefaults
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.ReminderStatePrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
@@ -90,7 +91,8 @@ class ReminderScanner @Inject constructor(
val occurrences = source.occurrences(now - PAST_WINDOW_MILLIS, now + lookahead)
val planned = planReminders(
instances = occurrences,
minutesByEvent = source.reminderMinutes(occurrences.map { it.eventId }),
minutesByEvent = source.reminderMinutes(occurrences.map { it.eventId })
.withoutProviderDefaults(),
zone = ZoneId.systemDefault(),
allDayTimeMinutes = settingsPrefs.allDayReminderTimeMinutes.first(),
)

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

@@ -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,28 @@ 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.
*
* [notAttempted] counts what a retry still has to import, because the permission
* or the target calendar went away mid-file and every remaining insert would have
* failed the same way. Events past that point whose UID is already in the
* calendar count under [skippedDuplicate] instead — they were never going to be
* added either way. It travels in the summary rather than as an exception so the
* counts of what *did* land survive: an import is only idempotent for events that
* carry a UID, so a retry told "nothing happened" would duplicate the rest.
*/
data class IcsImportSummary(
val imported: Int,
val skippedDuplicate: Int,
val failed: Int = 0,
val notAttempted: Int = 0,
)
/** The non-standard property most exporters name the whole calendar with. */
private const val CALENDAR_NAME_PROPERTY = "X-WR-CALNAME"
/**
* Hand-rolled RFC 5545 reader, the inverse of [IcsWriter]. Pure and
@@ -62,6 +136,9 @@ data class IcsImportSummary(val imported: Int, val skippedDuplicate: Int)
* (`RECURRENCE-ID`, attendees, unresolved `TZID`) are reported as [warnings]
* rather than silently dropped. `VTIMEZONE` blocks are skipped — a `TZID` is
* resolved against the OS tz database instead ([deviceZone] is the fallback).
*
* `VTODO` components are read as all-day/timed events: Calendula models no
* tasks, and dropping them would silently lose half of some exports.
*/
class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault()) {
@@ -69,42 +146,57 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
val lines = unfoldLines(text)
val events = mutableListOf<ParsedIcsEvent>()
val warnings = mutableSetOf<IcsParseWarning>()
var calendarName: String? = null
// Scanned up front, not in document order: an X-WR-CALNAME after the
// first VEVENT still names the calendar. The cheap prefix test first,
// so a file without one doesn't pay a full parse to find nothing.
val calendarName = lines.asSequence()
.filter { it.startsWith(CALENDAR_NAME_PROPERTY, ignoreCase = true) }
.mapNotNull(::parseContentLine)
.firstOrNull { it.name == CALENDAR_NAME_PROPERTY }
?.let { unescapeText(it.value).trim().ifEmpty { null } }
var i = 0
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 +204,11 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
var status = EventStatus.Confirmed
var availability = Availability.Busy
var calendarName = fileCalendarName
var category: String? = null
var eventColor: Int? = null
var calendarColor: Int? = null
val reminders = mutableListOf<Int>()
val exDateLines = mutableListOf<IcsContentLine>()
var skipAsOverride = false
var i = 0
@@ -129,9 +225,11 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
"UID" -> uid = line.value.trim().ifEmpty { null }
"SUMMARY" -> summary = unescapeText(line.value)
"DTSTART" -> dtStart = parseIcsDateTime(line, warnings)
"DUE" -> if (isTask) due = parseIcsDateTime(line, warnings)
"DTEND" -> dtEnd = parseIcsDateTime(line, warnings)
"DURATION" -> duration = line.value.trim()
"RRULE" -> rrule = line.value.trim().ifEmpty { null }
"EXDATE" -> exDateLines.add(line)
"LOCATION" -> location = unescapeText(line.value).ifEmpty { null }
"DESCRIPTION" -> description = unescapeText(line.value).ifEmpty { null }
"STATUS" -> status = mapIcsStatus(line.value)
@@ -142,6 +240,19 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
"ATTENDEE" -> warnings.add(IcsParseWarning.AttendeesIgnored)
"X-CALENDULA-CALENDAR" ->
calendarName = unescapeText(line.value).trim().ifEmpty { calendarName }
// Fossify names the source calendar per event; it never writes
// an X-WR-CALNAME, so this is the only calendar label its files
// carry. Elsewhere CATEGORIES is a plain tag list, so it only
// stands in where nothing named the calendar outright.
"CATEGORIES" -> category = singleCategory(line.value) ?: category
// Per-event colour, most specific encoding first (see parseIcsColorValue).
"X-FOSSIFY-EVENT-COLOR", "X-SMT-EVENT-COLOR" ->
eventColor = parseIcsColorValue(line.value) ?: eventColor
"COLOR" -> eventColor = eventColor ?: parseIcsColorValue(line.value)
// The *calendar's* colour. Kept as a fallback so a migration that
// folds several source calendars into one keeps them apart visually.
"X-FOSSIFY-CATEGORY-COLOR", "X-SMT-CATEGORY-COLOR", "CATEGORY_COLOR" ->
calendarColor = parseIcsColorValue(line.value) ?: calendarColor
}
i++
}
@@ -150,37 +261,125 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
warnings.add(IcsParseWarning.ModifiedOccurrenceSkipped)
return null
}
// Most producers give a VTODO only a DUE, which is then the moment the
// task sits at. One that carries a DTSTART too means the span between
// them, so DUE stands in for the end it has no DTEND for. Resolved after
// the sweep rather than inside it: property order isn't guaranteed, and
// a DUE ahead of the DTSTART would otherwise be taken for the start.
if (isTask && due != null) {
if (dtStart == null) dtStart = due else if (dtEnd == null) dtEnd = due
}
val start = dtStart ?: run {
warnings.add(IcsParseWarning.EventWithoutStartSkipped)
return null
}
val end = dtEnd
?: duration?.let {
start.copy(
instant = Instant.fromEpochMilliseconds(
start.instant.toEpochMilliseconds() + parseRfc2445DurationMillis(it),
),
)
}
?: start
val recurrence = sanitizeRrule(rrule)
if (recurrence.repaired) warnings.add(IcsParseWarning.RecurrenceRuleRepaired)
val end = resolveEnd(start, dtEnd, duration)
return ParsedIcsEvent(
uid = uid,
summary = summary,
start = start.instant,
end = end.instant,
end = end,
isAllDay = start.isAllDay,
zoneId = start.zoneId,
recurrenceRule = rrule,
recurrenceRule = recurrence.rule,
location = location,
description = description,
reminderMinutes = reminders.distinct(),
status = status,
availability = availability,
calendarName = calendarName,
calendarName = calendarName ?: category,
color = eventColor ?: calendarColor,
exDates = if (recurrence.rule == null) {
emptyList()
} else {
normalizeExDates(exDateLines, start)
},
isTask = isTask,
)
}
/** A VALARM's lead time in minutes before start, or null if not a usable relative trigger. */
/**
* The event's end instant.
*
* All-day events get two RFC 5545 §3.6.1 rules applied that a literal read
* would miss: a `DATE`-valued `DTSTART` with no `DTEND` lasts one day, and —
* since the provider expands a zero-length all-day series into no instances
* at all — an all-day event may never end at or before it starts.
*/
private fun resolveEnd(
start: IcsDateTime,
dtEnd: IcsDateTime?,
duration: String?,
): Instant {
val explicit = dtEnd?.instant ?: duration?.let {
Instant.fromEpochMilliseconds(
start.instant.toEpochMilliseconds() + parseRfc2445DurationMillis(it),
)
}
if (!start.isAllDay) return explicit ?: start.instant
return explicit?.takeIf { it > start.instant } ?: start.instant.plusDays(1)
}
/**
* `EXDATE` values in the shape `Events.EXDATE` wants: `yyyyMMdd` for an
* all-day series, a UTC `yyyyMMddTHHmmssZ` stamp otherwise.
*
* Fossify stores its excluded occurrences as bare day codes and writes them
* out that way even for timed series, where the property is then ambiguous.
* Such a value is resolved against the series' own time of day, which is
* what it meant — its repetitions never move within the day.
*
* An all-day exclusion names a calendar day, so a zone-qualified DATE-TIME
* still means the day it spells out; resolving it to an instant first and
* reading *that* back in UTC would roll a midnight east of UTC onto the day
* before and exclude an occurrence that doesn't exist.
*/
private fun normalizeExDates(lines: List<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 +388,7 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
// Absolute (DATE-TIME) triggers can't be expressed as a lead time.
if (trigger.params["VALUE"].equals("DATE-TIME", true)) return null
val millis = parseRfc2445DurationMillis(trigger.value)
// Negative = before start (the normal case) → positive lead minutes.
return (-millis / 60_000L).toInt().coerceAtLeast(0)
return (-millis / 60_000L).toInt()
}
private fun parseIcsDateTime(line: IcsContentLine, warnings: MutableSet<IcsParseWarning>): IcsDateTime? {
@@ -218,6 +416,34 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
fun IcsContentLine.isBegin(component: String) =
name == "BEGIN" && value.trim().equals(component, true)
fun Instant.plusDays(days: Int): Instant =
Instant.fromEpochMilliseconds(toEpochMilliseconds() + days * DAY_MILLIS)
fun dayCode(date: LocalDate): String =
"%04d%02d%02d".format(date.year, date.month.number, date.day)
fun utcStamp(instant: Instant): String = with(instant.toLocalDateTime(TimeZone.UTC)) {
"%04d%02d%02dT%02d%02d%02dZ".format(year, month.number, day, hour, minute, second)
}
/**
* A single-valued `CATEGORIES`, which is what the Fossify family writes
* for the source calendar's name. A multi-item list is a tag list and
* names no calendar, so it is ignored.
*/
fun singleCategory(raw: String): String? {
var escaped = false
for (c in raw) {
when {
escaped -> escaped = false
c == '\\' -> escaped = true
c == ',' -> return null
}
}
return unescapeText(raw).trim()
.takeIf { it.isNotEmpty() && !it.equals("null", true) }
}
/** Index of the matching `END:<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

@@ -63,6 +63,12 @@ private const val MINUTES_PER_DAY = 1_440
* for *which day* it means ([allDayLeadDays]) and take the hour from
* [allDayTimeMinutes], recomposed against each occurrence's own date in [zone].
* Duplicate offsets in [minutesByEvent] collapse.
*
* Every offset given is planned. The provider's "use the account default"
* sentinel names a lead time no reader can resolve, and is translated out on the
* way in
* ([de.jeanlucmakiola.calendula.data.calendar.withoutProviderDefaults]) — taken
* as an offset it would arm an alarm a minute after the event began.
*/
fun planReminders(
instances: List<ReminderEventInstance>,
@@ -70,7 +76,9 @@ fun planReminders(
zone: ZoneId,
allDayTimeMinutes: Int,
): List<PlannedReminder> = instances.flatMap { instance ->
minutesByEvent[instance.eventId].orEmpty().distinct().map { minutes ->
minutesByEvent[instance.eventId].orEmpty()
.distinct()
.map { minutes ->
PlannedReminder(
instance = instance,
minutes = minutes,

View File

@@ -319,9 +319,10 @@ fun CalendarHost(
EventMoveScope(
movableCalendarIds = movableCalendarIds,
dragEnabled = dragToReschedule,
move = reschedule::move,
move = { reschedule.move(it) },
inFlight = reschedule.inFlight,
undoStarted = reschedule.undoStarted,
abandoned = reschedule.abandoned,
edit = onEditEvent,
)
}

View File

@@ -43,6 +43,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -51,6 +52,8 @@ import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.floret.identity.animateItemMotion
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
@@ -135,6 +138,7 @@ fun AgendaScreen(
AgendaTopBar(
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
quickSwitchViews = quickSwitchViews,
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
showTodayButton = todayInToolbar,
@@ -159,13 +163,24 @@ fun AgendaScreen(
// One bar at the top: the "showing …" header on the left and the
// session range switcher on the right (one settings toggle).
successState?.takeIf { it.showRangeBar }?.let { s ->
// end lines the selector up with whatever ends the top bar:
// the view switcher's background, or — once #150 hides it —
// the search icon's glyph.
val selectorEnd = if (quickSwitchViews.size >= QuickSwitchConfig.MIN_CYCLE) {
AppBarSpacing.Inset
} else {
AppBarSpacing.IconTrailingInset
}
Row(
verticalAlignment = Alignment.CenterVertically,
// end aligns the selector's right edge with the top-bar view
// switcher (its 8.dp margin + the app bar's 4.dp inset).
modifier = Modifier
.fillMaxWidth()
.padding(start = 28.dp, end = 12.dp, top = 8.dp, bottom = 8.dp),
.padding(
start = RANGE_BAR_TEXT_INSET,
end = selectorEnd,
top = 8.dp,
bottom = 8.dp,
),
) {
AgendaRangeBanner(
range = s.range,
@@ -245,6 +260,9 @@ private fun AgendaRangePill(
}
}
/** Optical start inset for the range bar's text, set against the title above it. */
private val RANGE_BAR_TEXT_INSET = 28.dp
/**
* A header naming the concrete window currently shown under a "showing …" label,
* e.g. "27 Jun 2026" / "27 Jun 3 Jul 2026" / "June 2026". The range's name
@@ -409,6 +427,7 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) {
private fun AgendaTopBar(
selectedView: CalendarView,
onCycleView: () -> Unit,
quickSwitchViews: List<CalendarView>,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
showTodayButton: Boolean,
@@ -417,9 +436,14 @@ private fun AgendaTopBar(
) {
TopAppBar(
title = {
// A plain label rather than a CalendarTitleButton, so it takes that
// one's inset and one-line clamp itself.
Text(
text = stringResource(R.string.view_agenda),
style = MaterialTheme.typography.titleLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = AppBarSpacing.TitleInset),
)
},
navigationIcon = {
@@ -440,8 +464,8 @@ private fun AgendaTopBar(
}
ViewSwitcherPill(
current = selectedView,
cycle = quickSwitchViews,
onCycle = onCycleView,
modifier = Modifier.padding(end = 8.dp),
)
},
colors = TopAppBarDefaults.topAppBarColors(

View File

@@ -0,0 +1,40 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.ui.unit.dp
/**
* Trailing-edge and title spacing shared by the four calendar app bars (#165).
*
* Only the trailing edge and the title's start are shared; the leading edge and
* the gaps between the action icons stay on M3's defaults, which derive both
* from the icon buttons' width.
*/
object AppBarSpacing {
/** Side inset shared by the bars' trailing edge and the month grid. */
val Inset = 8.dp
/**
* Start inset on the title, taking it clear of the navigation icon M3 places
* it flush against. Real padding rather than a negative offset, which would
* swallow taps meant for the menu button.
*/
val TitleInset = 8.dp
/** M3's own padding around the actions row. */
internal val BarPadding = 4.dp
private val IconButtonSize = 48.dp
private val IconSize = 24.dp
/**
* End padding for a container-backed control that ends the bar, measured to
* its background. Coerced because [androidx.compose.foundation.layout.padding]
* throws on a negative value.
*/
val ContainerTrailingInset = (Inset - BarPadding).coerceAtLeast(0.dp)
/** Screen edge to the glyph of an icon button that ends the bar. */
val IconTrailingInset = BarPadding + (IconButtonSize - IconSize) / 2
}

View File

@@ -1,6 +1,7 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
@@ -12,6 +13,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -19,6 +21,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import kotlinx.datetime.LocalDate
@@ -31,10 +35,9 @@ import kotlinx.datetime.LocalDate
* [currentDate] seeds the picker with whatever the bar is currently naming (the
* visible day, week start or month anchor).
*
* The row keeps its own 8.dp inset rather than shifting back onto the app bar's
* start alignment: M3 places the title flush against the navigation icon's
* trailing edge, and the title is hit-tested on top of it, so a negative offset
* would swallow taps meant for the menu button.
* [shortTitle] replaces [title] when the full one does not fit the width the app
* bar hands the title slot (#165). Either way the line clamps to one and
* ellipsises, so pass a [shortTitle] wherever there is something left to drop.
*/
@Composable
fun CalendarTitleButton(
@@ -42,6 +45,7 @@ fun CalendarTitleButton(
currentDate: LocalDate,
onJumpToDate: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
shortTitle: String = title,
) {
var showDatePicker by rememberSaveable { mutableStateOf(false) }
@@ -53,14 +57,27 @@ fun CalendarTitleButton(
onClickLabel = stringResource(R.string.drawer_jump_to_date),
role = Role.Button,
) { showDatePicker = true }
.padding(horizontal = 8.dp),
.padding(horizontal = AppBarSpacing.TitleInset),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.weight(1f, fill = false),
val style = MaterialTheme.typography.titleLarge
BoxWithConstraints(modifier = Modifier.weight(1f, fill = false)) {
val measurer = rememberTextMeasurer()
val shown = remember(title, shortTitle, style, constraints.maxWidth, measurer) {
titleFor(
title = title,
shortTitle = shortTitle,
titleWidth = measurer.measure(title, style).size.width,
availableWidth = constraints.maxWidth,
)
}
Text(
text = shown,
style = style,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Icon(
imageVector = Icons.Default.ArrowDropDown,
contentDescription = null,
@@ -79,3 +96,11 @@ fun CalendarTitleButton(
)
}
}
/** Falls back to [shortTitle] when [title] is wider than [availableWidth] (#165). */
internal fun titleFor(
title: String,
shortTitle: String,
titleWidth: Int,
availableWidth: Int,
): String = if (titleWidth <= availableWidth) title else shortTitle

View File

@@ -57,7 +57,8 @@ fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): Calend
* implemented view — the settings screen reorders the whole set — while [cycle]
* is the subset the pill actually steps through, in [order]. The navigation
* drawer keeps its own separate order and always lists every view, so a view
* disabled here stays reachable there.
* disabled here stays reachable there — including when [cycle] is emptied and
* the pill disappears altogether.
*/
data class QuickSwitchConfig(
val order: List<CalendarView>,
@@ -67,14 +68,15 @@ data class QuickSwitchConfig(
val cycle: List<CalendarView> get() = order.filter { it in enabled }
companion object {
/**
* Fewest views that keep the switch meaningful. A single target is not a
* switch, so below this the pill is hidden rather than special-cased (#150);
* the drawer still reaches every view.
*/
const val MIN_CYCLE = 2
/** All views, in default order, all enabled. */
val Default = QuickSwitchConfig(IMPLEMENTED_VIEWS, IMPLEMENTED_VIEWS.toSet())
/**
* Fewest views that keep the switch meaningful — a "switch" needs at
* least two targets, so the settings screen blocks disabling below this.
*/
const val MIN_ENABLED = 2
}
}

View File

@@ -0,0 +1,35 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.unit.dp
/** The corner radius every event chip keeps on each edge the event doesn't cross. */
val EVENT_CHIP_CORNER = 4.dp
/** An event chip that is cut on no edge — an all-day bar, a floating copy. */
val EventChipShape = RoundedCornerShape(EVENT_CHIP_CORNER)
/**
* A timed block's corners: square on whichever edge the event runs past, so a
* cut edge reads as "this carries on" rather than as the event's own end. The
* vertical counterpart of [monthBarShape].
*/
fun timedBlockShape(continuesBefore: Boolean, continuesAfter: Boolean): RoundedCornerShape =
RoundedCornerShape(
topStart = if (continuesBefore) 0.dp else EVENT_CHIP_CORNER,
topEnd = if (continuesBefore) 0.dp else EVENT_CHIP_CORNER,
bottomStart = if (continuesAfter) 0.dp else EVENT_CHIP_CORNER,
bottomEnd = if (continuesAfter) 0.dp else EVENT_CHIP_CORNER,
)
/**
* A month/all-day bar's corners: square on whichever side the event runs past
* the row it is drawn in.
*/
fun monthBarShape(continuesLeft: Boolean, continuesRight: Boolean): RoundedCornerShape =
RoundedCornerShape(
topStart = if (continuesLeft) 0.dp else EVENT_CHIP_CORNER,
bottomStart = if (continuesLeft) 0.dp else EVENT_CHIP_CORNER,
topEnd = if (continuesRight) 0.dp else EVENT_CHIP_CORNER,
bottomEnd = if (continuesRight) 0.dp else EVENT_CHIP_CORNER,
)

View File

@@ -35,8 +35,12 @@ class EventMoveScope(
* accidental drags the setting exists to stop.
*/
val dragEnabled: Boolean,
/** False when the drop was refused outright, so nothing will be written. */
val move: (MoveRequest) -> Boolean,
/**
* Hand a drop to the writer. Returns nothing on purpose: a drop is answered
* before its write resolves, so [abandoned] — not a return value — is what
* tells a held copy that nothing landed.
*/
val move: (MoveRequest) -> Unit,
/**
* True while a dropped event is being written, including the time its scope
* dialog is up. A flow rather than a value so this scope stays the same
@@ -45,6 +49,12 @@ class EventMoveScope(
val inFlight: StateFlow<Boolean>,
/** Ticks when an undo write begins — see `RescheduleViewModel.undoStarted`. */
val undoStarted: StateFlow<Int>,
/**
* Ticks when a drop ends with nothing landing — see
* `RescheduleViewModel.abandoned`. [move] answers before the write is
* resolved, so this is what tells a held copy to stop waiting.
*/
val abandoned: StateFlow<Int>,
/** Open an event in the edit form — the pointer-free route to the same change. */
val edit: (EventInstance) -> Unit,
) {
@@ -57,6 +67,8 @@ private val NEVER_IN_FLIGHT = MutableStateFlow(false)
private val NEVER_UNDONE = MutableStateFlow(0)
private val NEVER_ABANDONED = MutableStateFlow(0)
/**
* Whether a dropped event is still being written — false wherever moving is off.
* The drag overlays hold a landed block on its target for this window.
@@ -67,6 +79,17 @@ fun moveInFlight(): Boolean {
return flow.collectAsStateWithLifecycle().value
}
/**
* How many drops have ended with nothing landing. Read as a plain count so a
* drag overlay can capture it at the moment of the drop and tell, later, whether
* *its own* drop was one of them.
*/
@Composable
fun abandonedMoves(): Int {
val flow = LocalEventMove.current?.abandoned ?: NEVER_ABANDONED
return flow.collectAsStateWithLifecycle().value
}
/**
* Runs [onUndo] when an undo write begins, and never for one that began before
* this composable came on screen.

View File

@@ -0,0 +1,39 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.LayoutDirection
/** How an event chip's title should overflow: what to pass to `Text`. */
data class EventTitleOverflow(val overflow: TextOverflow, val softWrap: Boolean)
/**
* Overflow for an event chip's title (#164). Chips are narrow enough that the
* "…" costs a couple of readable characters, so the title runs to the chip's
* edge and clips mid-glyph instead.
*
* Two cases keep the ellipsis:
*
* - **[rtl].** With `softWrap` off Compose lays the line out at its full
* intrinsic width and clips to the node's left edge, which in RTL is the *end*
* of the string — an Arabic title would lose its beginning. The ellipsis
* truncates at the logical end in both directions.
* - **More than one line** ([singleLine] false). Wrapping needs `softWrap` on,
* and clipping with it on breaks the last line at the last whole word — less
* title than the ellipsis showed, not more.
*/
fun eventTitleOverflowFor(rtl: Boolean, singleLine: Boolean): EventTitleOverflow =
if (rtl || !singleLine) {
EventTitleOverflow(TextOverflow.Ellipsis, softWrap = true)
} else {
EventTitleOverflow(TextOverflow.Clip, softWrap = false)
}
/** [eventTitleOverflowFor] against the current layout direction. */
@Composable
fun eventTitleOverflow(singleLine: Boolean = true): EventTitleOverflow =
eventTitleOverflowFor(
rtl = LocalLayoutDirection.current == LayoutDirection.Rtl,
singleLine = singleLine,
)

View File

@@ -5,6 +5,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.calendar.MINUTES_DEFAULT
import de.jeanlucmakiola.floret.reminders.ReminderUnit
/** Common reminder lead times offered as quick picks in the form and settings. */
@@ -20,15 +21,20 @@ fun reminderUnitLabel(unit: ReminderUnit): Int = when (unit) {
/**
* Humanise a reminder lead time (minutes before the event start) into one
* line: "Default reminder" (negative = the provider default), "At time of
* event" (0), "10 minutes before", "1 hour before", … Shared by the detail
* screen, the event form and the default-reminder settings so the wording
* never drifts.
* line: "Default reminder" ([MINUTES_DEFAULT]), "At time of event" (0),
* "10 minutes before", "1 hour before", … Shared by the detail screen, the event
* form and the default-reminder settings so the wording never drifts.
*
* Only the sentinel itself is the account default. Any other negative is a real
* lead time firing *after* the start — rare, but a foreign all-day row decodes to
* one — and naming it "Default reminder" would tell the user an alarm that does
* go off is not ours.
*/
@Composable
fun reminderLeadTimeLabel(minutes: Int): String = when {
minutes < 0 -> stringResource(R.string.reminder_default)
minutes == MINUTES_DEFAULT -> stringResource(R.string.reminder_default)
minutes == 0 -> stringResource(R.string.reminder_at_time)
minutes < 0 -> stringResource(R.string.reminder_after, durationLabel(-minutes))
minutes % 10_080 == 0 ->
pluralStringResource(R.plurals.reminder_weeks, minutes / 10_080, minutes / 10_080)
minutes % 1_440 == 0 ->

View File

@@ -140,6 +140,17 @@ class RescheduleViewModel @Inject constructor(
*/
val inFlight: StateFlow<Boolean> = _inFlight.asStateFlow()
private val _abandoned = MutableStateFlow(0)
/**
* Ticks when a drop ends with nothing on the target day: refused outright,
* refused before the write, written and failed, or its scope dialog
* dismissed. [move] answers before any of that is known, so without this
* signal the view that drew the drop held its copy out for the full settle
* timeout waiting for a grid that was never going to draw it (#253).
*/
val abandoned: StateFlow<Int> = _abandoned.asStateFlow()
private val _undoStarted = MutableStateFlow(0)
/**
@@ -189,15 +200,23 @@ class RescheduleViewModel @Inject constructor(
/**
* Take a drop, unless one is already being written. False means nothing will
* be written and the caller must let its held copy go now — waiting on
* [inFlight] would strand it until the settle timeout instead.
* be written; [abandoned] ticks for that refusal too, so a held copy is let
* go by the one signal rather than by every caller remembering to.
*
* The Boolean is diagnostic — the tests read it, [EventMoveScope] does not
* hand it on. A caller that gated on it would be back to releasing its copy
* from two places, and it answers before the write is resolved anyway.
*/
fun move(request: MoveRequest): Boolean {
if (busy || _scopePrompt.value != null) return false
if (busy || _scopePrompt.value != null) {
_abandoned.value += 1
return false
}
busy = true
viewModelScope.launch {
val prepared = prepare(request)
if (prepared == null) {
_abandoned.value += 1
busy = false
return@launch
}
@@ -231,6 +250,7 @@ class RescheduleViewModel @Inject constructor(
pending = null
busy = false
_scopePrompt.value = null
_abandoned.value += 1
}
/**
@@ -238,6 +258,12 @@ class RescheduleViewModel @Inject constructor(
* inverse write reports back, so the one chip changes what it says rather
* than closing and reopening. False when another write is already running —
* the chip hides its action for that window, so this is the backstop.
*
* [abandoned] ticks once an inverse that *started* fails to land: [undoStarted]
* has sent the chip back on its journey by then and nothing else will seat it.
* A refusal ticks nothing — it launched no journey of its own, and the counter
* is global, so a tick here would release some other drop's held copy before
* the grid has drawn it.
*/
fun undo(undo: MoveUndo): Boolean {
if (busy) return false
@@ -256,6 +282,9 @@ class RescheduleViewModel @Inject constructor(
} catch (e: Exception) {
MoveOutcome.Failed
}
// Read off the outcome, the way [write] does — no second copy of the
// same fact to drift from it.
if (_outcome.value !is MoveOutcome.Undone) _abandoned.value += 1
busy = false
}
return true
@@ -340,6 +369,7 @@ class RescheduleViewModel @Inject constructor(
val request = prepared.request
if (endsBeforeItStarts(prepared, scope)) {
_outcome.value = MoveOutcome.BlockedSeriesEnd
_abandoned.value += 1
return
}
_outcome.value = try {
@@ -351,6 +381,7 @@ class RescheduleViewModel @Inject constructor(
repository.updateOccurrence(
request.eventId,
request.beginMillis,
prepared.original,
prepared.updated,
)
@@ -375,6 +406,9 @@ class RescheduleViewModel @Inject constructor(
} catch (e: Exception) {
MoveOutcome.Failed
}
// Nothing landed, so the copy the view is holding has nothing to fade
// into — let it go now rather than at the end of the settle timeout.
if (_outcome.value !is MoveOutcome.Moved) _abandoned.value += 1
}
/**

View File

@@ -25,6 +25,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInRoot
@@ -44,7 +45,11 @@ import de.jeanlucmakiola.calendula.ui.week.TimedBlock
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.floret.locale.currentLocale
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.StateFlow
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.plus
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
@@ -66,17 +71,148 @@ private val AUTO_SCROLL_STEP = 16.dp
data class TimelineDrag(
val event: EventInstance,
val date: LocalDate,
/**
* Where the dragged *block's* top edge now sits, in minutes from [date]'s
* midnight. The gesture's own quantity — what snaps, what the haptics tick
* on, and what the drop is expressed in. Not the event's start: for the tail
* of an event that began the day before, the two are a clip offset apart.
*/
val startMin: Int,
val endMin: Int,
val topLeftInRoot: Offset,
val sizePx: IntSize,
/** The event's new start, from [date]'s midnight — negative if it began earlier. */
val eventStartMin: Int,
val eventSpanMin: Int,
/** The event as the grid would draw it, one piece per day column it covers. */
val pieces: List<TimelineDragPiece>,
/**
* True when [pieces] is only [edgeDragSlice]'s stand-in — the event has left
* every column this timeline shows. A hint under the finger, not where the
* drop lands, so it is dropped once the gesture ends ([finish]).
*/
val edgeOnly: Boolean = false,
) {
/** What the snap haptics key off: one tick per changed slot, not per frame. */
val slot: Pair<LocalDate, Int> get() = date to startMin
}
/** Where a finished drag asks its event to go. */
data class TimelineDrop(val event: EventInstance, val date: LocalDate, val startMin: Int)
/**
* One day column's share of a dragged event, ready to draw. Both halves of an
* event crossing midnight are previewed, whichever half the finger picked up:
* showing only the held one made the event read as growing rather than moving,
* since the day beside it still stood at the block's old extent (#253).
*/
data class TimelineDragPiece(
val topLeftInRoot: Offset,
val sizePx: IntSize,
val continuesBefore: Boolean,
val continuesAfter: Boolean,
)
/**
* How a run of [spanMin] minutes beginning [eventStartMin] minutes into a day —
* negative when it began on an earlier one — falls across day columns, as
* offsets from that day. Every slice but the first and last is a whole day, and
* the flags say which edges are cuts rather than the event's own ends.
*
* [within] bounds the day offsets that have a column to draw in, so a very long
* event doesn't slice days no one can see.
*/
internal fun dragSlices(
eventStartMin: Int,
spanMin: Int,
within: IntRange? = null,
): List<DragSlice> {
val span = spanMin.coerceAtLeast(0)
val end = eventStartMin + span
val days = dragDayRange(eventStartMin, spanMin)
val first = days.first
val last = days.last
val from = maxOf(first, within?.first ?: first)
val to = minOf(last, within?.last ?: last)
if (from > to) return emptyList()
return (from..to).map { day ->
val dayStart = day * MINUTES_PER_DAY
val sliceStart = maxOf(eventStartMin - dayStart, 0)
val sliceEnd = minOf(end - dayStart, MINUTES_PER_DAY)
DragSlice(
dayOffset = day,
startMin = sliceStart,
spanMin = sliceEnd - sliceStart,
continuesBefore = day > first,
continuesAfter = day < last,
)
}
}
/**
* The one piece to draw when a drag has carried the whole event clear of the
* columns this timeline shows, so [dragSlices] has nothing to hand back — where a
* short clipped tail lands at its [dragFloorMin]: the event then belongs to the
* day before, which Day view has no column for, and the copy would blink out from
* under the finger mid-gesture. Pinned to the top of the nearest visible column
* at minimum height instead, cut at that edge, so it reads as having slid off the
* top. Null whenever a real slice exists. The drop is unaffected either way — it
* reads [TimelineDrag.startMin], not the pieces.
*/
internal fun edgeDragSlice(eventStartMin: Int, spanMin: Int, within: IntRange): DragSlice? {
if (dragDayRange(eventStartMin, spanMin).last >= within.first) return null
return DragSlice(
dayOffset = within.first,
startMin = 0,
spanMin = 0,
continuesBefore = true,
continuesAfter = false,
)
}
/**
* The day offsets a run of [spanMin] minutes beginning [eventStartMin] covers,
* before any clipping to what a timeline shows. One rule, so [dragSlices] and
* [edgeDragSlice] can't drift apart on where an event's days begin and end.
*/
private fun dragDayRange(eventStartMin: Int, spanMin: Int): IntRange {
val end = eventStartMin + spanMin.coerceAtLeast(0)
val first = eventStartMin.floorDiv(MINUTES_PER_DAY)
// A zero-length event still has a block, on the one day it sits in.
return first..maxOf((end - 1).floorDiv(MINUTES_PER_DAY), first)
}
/**
* How far above its day's midnight a block's top edge may be dragged. Zero for
* an ordinary block, whose top edge is the event's own start. A tail clipped at
* midnight has to go lower: its top edge *is* midnight and stays there however
* much earlier the event moves, so pinning it at zero made dragging the second
* half earlier a no-op (#253). It may rise until the event's end reaches one
* snap step into this day — or by a single step, for a tail too short to afford
* even that, which then leaves the day for the one before it.
*/
internal fun dragFloorMin(clipOffsetMin: Int, eventSpanMin: Int): Int =
if (clipOffsetMin > 0) {
minOf(-DRAG_SNAP_MINUTES, DRAG_SNAP_MINUTES + clipOffsetMin - eventSpanMin)
} else {
0
}
/** One day's share of a dragged event, before it is placed on screen. */
internal data class DragSlice(
val dayOffset: Int,
val startMin: Int,
val spanMin: Int,
val continuesBefore: Boolean,
val continuesAfter: Boolean,
)
/**
* Where a finished drag asks its event to go. [date]/[startMin] are where the
* dragged *block's* top edge landed, which is the event's own start only for a
* block that isn't clipped — see [clipOffsetMin] and [startInstant].
*/
data class TimelineDrop(
val event: EventInstance,
val date: LocalDate,
val startMin: Int,
/** Minutes from the event's start to the dragged block's top edge. */
val clipOffsetMin: Int = 0,
)
/**
* The timeline's live geometry, republished on every layout. Plain fields rather
@@ -134,12 +270,14 @@ class TimelineDragController {
private set
/**
* Where the settled drop came from, and which event row it belongs to. The
* instance id alone can't identify the source block for the length of the
* write: the provider regenerates `Instances` rows, so a re-read carrying the
* *old* time can arrive under a new instance id.
* The event row and start instant the settled drop came from. The instance id
* alone can't identify the source for the length of the write: the provider
* regenerates `Instances` rows, so a re-read carrying the *old* time can
* arrive under a new instance id. Matching the start instant rather than a
* single day and slot keeps *both* halves of an event that crosses midnight
* ghosted until the write lands (#253).
*/
private var settledOrigin: Triple<Long, LocalDate, Int>? by mutableStateOf(null)
private var settledOrigin: Pair<Long, Instant>? by mutableStateOf(null)
/** Whether a finger is on a block right now — [settling] is not dragging. */
var isDragging: Boolean by mutableStateOf(false)
@@ -153,18 +291,42 @@ class TimelineDragController {
var settledOnGrid: Boolean by mutableStateOf(false)
private set
/**
* The abandoned-move counter, read at the drop so the mark can't be a
* composition behind the tick it is compared against.
*/
var abandonedTicks: StateFlow<Int>? = null
/** What [abandonedTicks] stood at when the settling drop was let go. */
var settledAbandonedMark: Int = 0
private set
private var source: TimedBlock? = null
private var grab = Offset.Zero
private var pointer = Offset.Zero
/**
* How far the picked-up block's top edge sits past the event's own start —
* nonzero only for a block clipped at midnight, which is the tail of an
* event that began the day before (#253). The gesture tracks the block the
* finger is on; this is what turns where it lands back into an event start.
*/
private var clipOffsetMin = 0
/**
* The slot the block already occupied when it was picked up — not the same
* as its start, since the target snaps to the grid (09:07 lifts to 09:00).
*/
private var originSlot: Pair<LocalDate, Int>? = null
fun begin(block: TimedBlock, pointerInRoot: Offset, blockInRoot: Offset) {
fun begin(
block: TimedBlock,
clipOffsetMin: Int,
pointerInRoot: Offset,
blockInRoot: Offset,
) {
source = block
this.clipOffsetMin = clipOffsetMin
settling = null
isDragging = true
liftedInstanceId = block.event.instanceId
@@ -182,6 +344,7 @@ class TimelineDragController {
fun cancel() {
source = null
clipOffsetMin = 0
isDragging = false
liftedInstanceId = null
originSlot = null
@@ -192,33 +355,42 @@ class TimelineDragController {
}
/**
* Whether [block] is the one whose copy is in flight, and so must stay a
* ghost: the instance the finger picked up, or once dropped whatever now
* sits in the slot it left.
* Whether [block] is part of the event whose copy is in flight, and so must
* stay a ghost: the instance the finger picked up, or once dropped whatever
* the re-read now carries at the time it came from — both halves of it, for
* an event that crosses midnight (#253).
*/
fun ghosts(block: TimedBlock, date: LocalDate): Boolean {
fun ghosts(block: TimedBlock): Boolean {
if (liftedInstanceId == null) return false
if (block.event.instanceId == liftedInstanceId) return true
val (eventId, originDate, originMin) = settledOrigin ?: return false
return block.event.eventId == eventId &&
date == originDate &&
block.startMin == originMin
val (eventId, start) = settledOrigin ?: return false
return block.event.eventId == eventId && block.event.start == start
}
/**
* What a day column now holds, so a settled drop can tell when the grid has
* caught up with it. Matched on the landing slot plus either the event row
* or its title, since a single-occurrence move writes a new `eventId`.
* or its title, since a single-occurrence move writes a new `eventId`. A
* blank title matches nothing: every untitled event has one (#253).
*/
fun noteGrid(date: LocalDate, blocks: List<TimedBlock>) {
val landed = settling ?: return
if (settledOnGrid || landed.date != date) return
if (settledOnGrid) return
// Where this day would draw it, which for a drop clipped at midnight is
// the day's own start rather than the edge the finger held (#253) — and
// which for a tail dragged clear of its day is the day before it.
val offset = (date.toEpochDays() - landed.date.toEpochDays()).toInt()
val slice = dragSlices(landed.eventStartMin, landed.eventSpanMin, offset..offset)
.firstOrNull() ?: return
settledOnGrid = blocks.any { block ->
block.startMin == landed.startMin &&
block.startMin == slice.startMin &&
(
block.event.eventId == landed.event.eventId ||
(
landed.event.title.isNotBlank() &&
block.event.title == landed.event.title
)
)
}
}
@@ -230,12 +402,18 @@ class TimelineDragController {
fun finish(): TimelineDrop? {
val landed = drag
val origin = originSlot
val clipOffset = clipOffsetMin
cancel()
if (landed == null || landed.slot == origin) return null
settling = landed
// An edge piece is a hint for the finger, not a preview of the landing
// slot: the event belongs to a day this timeline has no column for, so
// [noteGrid] can never seat it and it would sit at the top of today for
// the whole settle timeout, long after the event moved off-screen.
settling = if (landed.edgeOnly) landed.copy(pieces = emptyList()) else landed
liftedInstanceId = landed.event.instanceId
settledOrigin = origin?.let { (date, min) -> Triple(landed.event.eventId, date, min) }
return TimelineDrop(landed.event, landed.date, landed.startMin)
settledOrigin = landed.event.eventId to landed.event.start
settledAbandonedMark = abandonedTicks?.value ?: 0
return TimelineDrop(landed.event, landed.date, landed.startMin, clipOffset)
}
/**
@@ -271,28 +449,47 @@ class TimelineDragController {
val origin = grid.positionInRoot()
val rawMinutes = (pointer.y - grab.y - origin.y) / hourPx * 60f
val snapped = (rawMinutes / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES
// Clamp the start into the target day; a tail past midnight is fine.
val startMin = snapped.coerceIn(0, MINUTES_PER_DAY - DRAG_SNAP_MINUTES)
// The event's own length, not the block's: TimedBlock.endMin is clipped
// at midnight, which would draw a 22:0002:00 event as a two-hour copy.
val span = (block.event.end - block.event.start).inWholeMinutes.toInt().coerceAtLeast(0)
// The column the finger is over on screen, and the day that column shows.
val eventSpan = (block.event.end - block.event.start).inWholeMinutes.toInt()
.coerceAtLeast(0)
val floor = dragFloorMin(clipOffsetMin, eventSpan)
val startMin = snapped.coerceIn(floor, MINUTES_PER_DAY - DRAG_SNAP_MINUTES)
val eventStartMin = startMin - clipOffsetMin
val column = ((pointer.x - origin.x) / columnPx).toInt().coerceIn(0, days.lastIndex)
val dayIndex = if (geometry.isRtl) days.lastIndex - column else column
val height = maxOf(span / 60f * hourPx, MIN_EVENT_FRACTION * hourPx)
val visible = -dayIndex..days.lastIndex - dayIndex
val onGrid = dragSlices(eventStartMin, eventSpan, within = visible)
val slices = onGrid.ifEmpty {
listOfNotNull(edgeDragSlice(eventStartMin, eventSpan, visible))
}
drag = TimelineDrag(
event = block.event,
date = days[dayIndex],
startMin = startMin,
endMin = startMin + span,
eventStartMin = eventStartMin,
eventSpanMin = eventSpan,
// Bounded to the columns this timeline actually shows: a day it
// doesn't has no piece to draw. The write is unaffected.
pieces = slices
.map { slice ->
val day = dayIndex + slice.dayOffset
val col = if (geometry.isRtl) days.lastIndex - day else day
TimelineDragPiece(
topLeftInRoot = Offset(
x = origin.x + column * columnPx,
y = origin.y + startMin / 60f * hourPx,
x = origin.x + col * columnPx,
y = origin.y + slice.startMin / 60f * hourPx,
),
sizePx = IntSize(
(columnPx - geometry.columnGapPx).roundToInt(),
height.roundToInt(),
maxOf(
slice.spanMin / 60f * hourPx,
MIN_EVENT_FRACTION * hourPx,
).roundToInt(),
),
continuesBefore = slice.continuesBefore,
continuesAfter = slice.continuesAfter,
)
},
edgeOnly = onGrid.isEmpty(),
)
}
@@ -334,24 +531,52 @@ fun rememberTimelineDragController(): TimelineDragController {
val density = LocalDensity.current
controller.geometry.edgePx = with(density) { AUTO_SCROLL_EDGE.toPx() }
controller.geometry.stepPx = with(density) { AUTO_SCROLL_STEP.toPx() }
controller.abandonedTicks = LocalEventMove.current?.abandoned
return controller
}
/**
* True when [block] actually begins on [day] rather than being the tail of an
* event that started earlier. A clipped block's top edge is midnight, not the
* event's start, so dragging it would move the event to a time it never had.
* How far this block's top edge sits past its event's start, in minutes — zero
* for a block that begins on [day], and the part already run for the tail of an
* event that started earlier, whose top edge is midnight rather than the event's
* start. Dragging such a tail moves the event by where *its own* top edge lands,
* so the offset has to come back off at the drop (#253).
*/
fun TimedBlock.beginsOn(day: LocalDate, zone: TimeZone): Boolean =
event.start.toLocalDateTime(zone).date == day
fun TimedBlock.clipOffsetMinutes(day: LocalDate, zone: TimeZone): Int {
val start = event.start.toLocalDateTime(zone)
val days = (day.toEpochDays() - start.date.toEpochDays()).toInt()
val minutes = days * MINUTES_PER_DAY + startMin - start.time.toSecondOfDay() / 60
return minutes.coerceAtLeast(0)
}
/**
* The instant a drop asks for, read in the zone the timeline is drawn in. A drop
* into a spring-forward gap has no instant of its own; `toInstant` resolves it
* forward by the missing hour, and the block visibly settles there.
* True when the event began before [day] — this block is its tail, cut at
* midnight rather than starting where the event does.
*/
fun TimelineDrop.startInstant(zone: TimeZone): Instant =
LocalDateTime(date, LocalTime(startMin / 60, startMin % 60)).toInstant(zone)
fun TimedBlock.continuesBefore(day: LocalDate, zone: TimeZone): Boolean =
event.start < day.atStartOfDayIn(zone)
/** True when the event runs past [day]'s midnight, so this block is cut there. */
fun TimedBlock.continuesAfter(day: LocalDate, zone: TimeZone): Boolean =
event.end > day.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone)
/**
* The instant a drop asks for — the *event's* new start, so the tail of an
* overnight event drops back onto the day its own start belongs to. Read in the
* zone the timeline is drawn in. A drop into a spring-forward gap has no instant
* of its own; `toInstant` resolves it forward by the missing hour, and the block
* visibly settles there.
*/
fun TimelineDrop.startInstant(zone: TimeZone): Instant {
// Wall clock throughout, and past this day at either end: a block's top edge
// can be dragged above its midnight and a tail's below the next one, so the
// start is resolved as a day plus a minute of it rather than as an offset
// from an instant — which would shift by an hour across a DST boundary.
val fromMidnight = startMin - clipOffsetMin
val day = date.plus(fromMidnight.floorDiv(MINUTES_PER_DAY), DateTimeUnit.DAY)
val minute = fromMidnight.mod(MINUTES_PER_DAY)
return LocalDateTime(day, LocalTime(minute / 60, minute % 60)).toInstant(zone)
}
/**
* A beat after the grid draws the drop, so its own block is under way before the
@@ -384,7 +609,6 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
val reduceMotion = rememberReduceMotion()
val density = LocalDensity.current
val moveInFlight = moveInFlight()
// Both live here rather than beside the controller: they read [drag], which
@@ -397,9 +621,14 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
// recurring drop's scope dialog is up — and then until the grid draws the
// drop itself.
var handingOver by remember(controller.settling) { mutableStateOf(false) }
LaunchedEffect(controller.settling, moveInFlight, controller.settledOnGrid) {
val abandoned = abandonedMoves()
LaunchedEffect(controller.settling, moveInFlight, controller.settledOnGrid, abandoned) {
if (controller.settling == null || moveInFlight) return@LaunchedEffect
// Past the mark this drop was let go on, it was abandoned: nothing will
// land and there is nothing to wait for.
if (abandoned == controller.settledAbandonedMark) {
delay(if (controller.settledOnGrid) SETTLE_GRACE_MILLIS else SETTLE_TIMEOUT_MILLIS)
}
handingOver = true
controller.handOver()
delay(SETTLE_FADE_MILLIS.toLong())
@@ -428,49 +657,86 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
)
val fill = eventFill(drag.event.color, dark, soften)
val title = drag.event.title.ifBlank { stringResource(R.string.event_untitled) }
// An end past midnight wraps rather than saturating, so a four-hour
// event dragged to 22:00 reads "22:0002:00" and not "22:0024:00".
val endMin = if (drag.endMin > MINUTES_PER_DAY) {
drag.endMin % MINUTES_PER_DAY
} else {
drag.endMin
}
val label = "${formatMinuteOfDay(drag.startMin, use24Hour, locale)}" +
// Read off the event rather than the held block, so grabbing either half
// of an event that crosses midnight names the same hours. An end past
// midnight wraps rather than saturating, so a four-hour event dragged to
// 22:00 reads "22:0002:00" and not "22:0024:00".
val startMin = drag.eventStartMin.mod(MINUTES_PER_DAY)
val rawEnd = startMin + drag.eventSpanMin
val endMin = if (rawEnd > MINUTES_PER_DAY) rawEnd % MINUTES_PER_DAY else rawEnd
val label = "${formatMinuteOfDay(startMin, use24Hour, locale)}" +
formatMinuteOfDay(endMin, use24Hour, locale)
// The tallest piece carries the range: on the smallest it would be
// clipped away, which is exactly the case when a short tail is held.
val labelled = drag.pieces.indices.maxByOrNull { drag.pieces[it].sizePx.height }
drag.pieces.forEachIndexed { index, piece ->
DragCopy(
topLeftInRoot = piece.topLeftInRoot,
overlayOrigin = origin,
sizePx = piece.sizePx,
fill = fill,
shape = timedBlockShape(piece.continuesBefore, piece.continuesAfter),
lift = lift,
alpha = copyAlpha,
title = title,
// Once for the whole event: repeated, it would name it per day.
label = label.takeIf { index == labelled },
)
}
}
}
/** One piece of a dragged block, floating over the calendar. */
@Composable
private fun DragCopy(
topLeftInRoot: Offset,
overlayOrigin: Offset,
sizePx: IntSize,
fill: Color,
shape: RoundedCornerShape,
lift: Float,
alpha: Float,
title: String,
label: String?,
) {
val density = LocalDensity.current
Box(
modifier = Modifier
// Absolute: these are root coordinates, and the direction-aware
// offset would mirror them across the screen in an RTL layout.
.absoluteOffset {
IntOffset(
(drag.topLeftInRoot.x - origin.x).roundToInt(),
(drag.topLeftInRoot.y - origin.y).roundToInt(),
(topLeftInRoot.x - overlayOrigin.x).roundToInt(),
(topLeftInRoot.y - overlayOrigin.y).roundToInt(),
)
}
.size(
width = with(density) { drag.sizePx.width.toDp() },
height = with(density) { drag.sizePx.height.toDp() },
width = with(density) { sizePx.width.toDp() },
height = with(density) { sizePx.height.toDp() },
)
.padding(horizontal = 1.dp)
.graphicsLayer {
scaleX = 1f + 0.02f * lift
scaleY = 1f + 0.02f * lift
shadowElevation = 8.dp.toPx() * lift
alpha = copyAlpha
shape = RoundedCornerShape(4.dp)
this.alpha = alpha
this.shape = shape
clip = false
}
.background(fill, RoundedCornerShape(4.dp))
.background(fill, shape)
.padding(horizontal = 4.dp, vertical = 2.dp),
) {
Column {
val titleOverflow = eventTitleOverflow()
Text(
text = title,
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
overflow = titleOverflow.overflow,
softWrap = titleOverflow.softWrap,
color = eventInk(fill, alpha = 0.85f),
)
if (label != null) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,

View File

@@ -1,26 +1,39 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
/**
* Top-bar pill that shows the current view and cycles to the next one on tap
* (spec M1: Month → Week → Day → Month, restricted to [IMPLEMENTED_VIEWS]).
*
* Renders nothing when [cycle] holds fewer than [QuickSwitchConfig.MIN_CYCLE]
* views (#150), so it drops straight into an app bar's `actions` slot without a
* wrapping condition — the same way [TodayAction] handles being turned off.
*
* [trailingInset] lands the pill's background on the bar's own rhythm; pass
* `0.dp` outside a top app bar, where M3's actions padding is not there to
* cancel (#165).
*/
@Composable
fun ViewSwitcherPill(
current: CalendarView,
cycle: List<CalendarView>,
onCycle: () -> Unit,
modifier: Modifier = Modifier,
trailingInset: Dp = AppBarSpacing.ContainerTrailingInset,
) {
if (cycle.size < QuickSwitchConfig.MIN_CYCLE) return
FilledTonalButton(
onClick = onCycle,
shape = MaterialTheme.shapes.large,
modifier = modifier,
modifier = modifier.padding(end = trailingInset),
) {
Text(stringResource(current.labelRes))
}

View File

@@ -65,7 +65,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
@@ -91,9 +90,13 @@ import de.jeanlucmakiola.calendula.ui.common.NowLine
import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
import de.jeanlucmakiola.calendula.ui.common.beginsOn
import de.jeanlucmakiola.calendula.ui.common.clipOffsetMinutes
import de.jeanlucmakiola.calendula.ui.common.continuesAfter
import de.jeanlucmakiola.calendula.ui.common.continuesBefore
import de.jeanlucmakiola.calendula.ui.common.timedBlockShape
import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
import de.jeanlucmakiola.calendula.ui.common.eventTitleOverflow
import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
import de.jeanlucmakiola.calendula.ui.common.startInstant
@@ -104,6 +107,7 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.ui.common.declinedDecoration
@@ -245,6 +249,7 @@ fun DayScreen(
currentYear = currentYear,
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
quickSwitchViews = quickSwitchViews,
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
onJumpToDate = jumpToDate,
@@ -351,7 +356,7 @@ private fun DayContent(
onEventClick = onEventClick,
onCreateAt = onCreateAt,
onDrop = { drop ->
val took = move?.move(
move?.move(
MoveRequest(
eventId = drop.event.eventId,
beginMillis = drop.event.start.toEpochMilliseconds(),
@@ -359,9 +364,6 @@ private fun DayContent(
target = MoveTarget.Start(drop.startInstant(zone)),
),
)
// Refused, so nothing will land: let the copy go now
// rather than hold it out for a settle that never comes.
if (took != true) dragController.release()
},
)
}
@@ -413,6 +415,7 @@ private fun DayTopBar(
currentYear: Int,
selectedView: CalendarView,
onCycleView: () -> Unit,
quickSwitchViews: List<CalendarView>,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
onJumpToDate: (LocalDate) -> Unit,
@@ -421,12 +424,17 @@ private fun DayTopBar(
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
) {
val locale = currentLocale()
val (title, shortTitle) = remember(date, locale, currentYear) {
formatDayTitle(date, locale, currentYear) to
formatDayTitle(date, locale, currentYear, abbreviated = true)
}
TopAppBar(
title = {
CalendarTitleButton(
title = formatDayTitle(date, locale, currentYear),
title = title,
currentDate = date,
onJumpToDate = onJumpToDate,
shortTitle = shortTitle,
)
},
navigationIcon = {
@@ -447,8 +455,8 @@ private fun DayTopBar(
}
ViewSwitcherPill(
current = selectedView,
cycle = quickSwitchViews,
onCycle = onCycleView,
modifier = Modifier.padding(end = 8.dp),
)
},
colors = TopAppBarDefaults.topAppBarColors(
@@ -519,17 +527,19 @@ private fun AllDayBar(
val fill = eventFill(event.color, dark, soften)
Box(
modifier = modifier
.background(fill, RoundedCornerShape(4.dp))
.background(fill, EventChipShape)
.clickable(onClick = onClick)
.padding(horizontal = 6.dp, vertical = 2.dp)
.semantics { contentDescription = title },
contentAlignment = Alignment.CenterStart,
) {
val titleOverflow = eventTitleOverflow()
Text(
text = title,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
overflow = titleOverflow.overflow,
softWrap = titleOverflow.softWrap,
color = eventInk(fill),
textDecoration = declinedDecoration(event.isDeclined),
)
@@ -744,24 +754,30 @@ private fun EventBlock(
val fill = eventFill(block.event.color, dark, soften)
val zone = remember { TimeZone.currentSystemDefault() }
val moveAction = eventMoveAction(block.event)
// A block clipped at the top continues from the previous day: its top edge
// is midnight, not the event's start, so dragging it would invent a time.
val draggable = eventDragAllowed(block.event) && block.beginsOn(date, zone)
val draggable = eventDragAllowed(block.event)
// The drop takes this offset back off, so a tail clipped at midnight lands
// where the event's own start belongs (#253).
val clipOffset = remember(block, date, zone) { block.clipOffsetMinutes(date, zone) }
val shape = remember(block, date, zone) {
timedBlockShape(block.continuesBefore(date, zone), block.continuesAfter(date, zone))
}
val dragModifier = rememberEventDragSource(
enabled = draggable,
key = block.event.instanceId,
onPickUp = { pointer, blockRoot, _ -> dragController.begin(block, pointer, blockRoot) },
onPickUp = { pointer, blockRoot, _ ->
dragController.begin(block, clipOffset, pointer, blockRoot)
},
onMove = dragController::move,
onDrop = { dragController.finish()?.let(onDrop) },
onCancel = dragController::cancel,
)
val lifted = draggable && dragController.ghosts(block, date)
val lifted = draggable && dragController.ghosts(block)
val ghost = ghostAlpha(lifted)
Box(
modifier = modifier
// The source stays put as a ghost while its floating copy travels.
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
.background(fill, RoundedCornerShape(4.dp))
.background(fill, shape)
.clickable(onClick = onClick)
// After clickable, so it is the inner node and wins the main pass;
// the tap still works, since a drag consumes the up.
@@ -774,11 +790,13 @@ private fun EventBlock(
) {
Column {
if (showTitle) {
val titleOverflow = eventTitleOverflow(singleLine = showTime)
Text(
text = title,
style = MaterialTheme.typography.labelMedium,
maxLines = if (showTime) 1 else 2,
overflow = TextOverflow.Ellipsis,
overflow = titleOverflow.overflow,
softWrap = titleOverflow.softWrap,
color = eventInk(fill, alpha = 0.85f),
textDecoration = declinedDecoration(block.event.isDeclined),
)
@@ -822,10 +840,16 @@ private fun DayLoading() {
private fun minToHm(min: Int, is24Hour: Boolean, locale: Locale): String =
formatMinuteOfDay(min, is24Hour, locale)
private fun formatDayTitle(date: LocalDate, locale: Locale, currentYear: Int): String =
/** [abbreviated] drops the weekday, leaving the date itself (#165). */
private fun formatDayTitle(
date: LocalDate,
locale: Locale,
currentYear: Int,
abbreviated: Boolean = false,
): String =
formatCalendarTitle(
date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day),
locale = locale,
currentYear = currentYear,
skeleton = "EEEdMMM",
skeleton = if (abbreviated) "dMMM" else "EEEdMMM",
)

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
@@ -734,7 +737,12 @@ class EventEditViewModel @Inject constructor(
} else {
when (scope) {
RecurringWriteScope.ThisEvent ->
repository.updateOccurrence(target.eventId, target.beginMillis, form)
repository.updateOccurrence(
target.eventId,
target.beginMillis,
target.original,
form,
)
RecurringWriteScope.ThisAndFollowing ->
repository.updateEventFromOccurrence(
eventId = target.eventId,
@@ -751,7 +759,15 @@ class EventEditViewModel @Inject constructor(
throw e
} catch (e: SecurityException) {
SaveUiState.NeedsPermission
} catch (e: NoSuchEventException) {
// The event or occurrence is already gone: the same answer the
// pre-check gives, and better than a bare "couldn't save".
SaveUiState.Gone
} catch (e: Exception) {
// The user only gets a generic snackbar, so without this a failed
// write leaves nothing to report (Codeberg #234). Scope and event
// id only — never the form's content.
Log.w(TAG, "Save failed (scope=$scope, eventId=${target?.eventId})", e)
SaveUiState.Failed
}
}

View File

@@ -23,6 +23,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.PriorityHigh
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -93,15 +94,11 @@ fun ImportScreen(
}
// Hoisted target calendar so the always-visible top-bar Import action can
// read it without the user scrolling to a bottom button. Defaults to the
// first *local* calendar — the first row the picker shows ("Your calendars"
// group leads) — so the pre-selection lines up with the top of the list;
// falls back to the first calendar if there are no local ones. Re-defaults
// when the "many" list first arrives (keyed on it), then holds the pick.
// read it without the user scrolling to a bottom button (see
// [defaultImportTarget] for the choice). Re-defaults when the "many" list
// first arrives (keyed on it), then holds the pick.
val many = state as? ImportUiState.Many
val defaultTarget = many?.calendars?.let { cals ->
(cals.firstOrNull { it.isLocal } ?: cals.firstOrNull())?.id
}
val defaultTarget = many?.let { defaultImportTarget(it.calendars, it.fileCalendarName) }
var selected by rememberSaveable(defaultTarget) { mutableStateOf(defaultTarget) }
Scaffold(
@@ -223,6 +220,10 @@ private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
)
}
// Part of the file never reached the calendar, so the screen doesn't get to
// read as a plain success.
val stopped = state.summary.notAttempted > 0
Column(
Modifier.fillMaxSize().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
@@ -236,22 +237,43 @@ private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
scaleY = badgeScale.value
}
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
.background(
if (stopped) {
MaterialTheme.colorScheme.errorContainer
} else {
MaterialTheme.colorScheme.primaryContainer
},
),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.Check,
if (stopped) Icons.Default.PriorityHigh else Icons.Default.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
tint = if (stopped) {
MaterialTheme.colorScheme.onErrorContainer
} else {
MaterialTheme.colorScheme.onPrimaryContainer
},
modifier = Modifier.size(56.dp),
)
}
Spacer(Modifier.height(24.dp))
Text(
stringResource(R.string.import_done_title),
stringResource(
if (stopped) R.string.import_done_stopped_title else R.string.import_done_title,
),
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface,
)
if (state.summary.failed > 0) {
Spacer(Modifier.height(8.dp))
Text(
stringResource(R.string.import_done_failed_note),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
if (state.summary.skippedDuplicate > 0) {
Spacer(Modifier.height(8.dp))
Text(
@@ -261,6 +283,21 @@ private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
textAlign = TextAlign.Center,
)
}
// The one note that isn't about individual events: the counts below cover
// the part of the file that was tried, so say how much never was.
if (stopped) {
Spacer(Modifier.height(8.dp))
Text(
pluralStringResource(
R.plurals.import_done_stopped,
state.summary.notAttempted,
state.summary.notAttempted,
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
)
}
Spacer(Modifier.height(24.dp))
Row(
Modifier.fillMaxWidth(),
@@ -290,6 +327,19 @@ private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
onContainer = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (state.summary.failed > 0) {
ImportStatCard(
count = state.summary.failed,
label = stringResource(R.string.import_done_failed_label),
contentDescription = pluralStringResource(
R.plurals.import_done_failed,
state.summary.failed,
state.summary.failed,
),
container = MaterialTheme.colorScheme.errorContainer,
onContainer = MaterialTheme.colorScheme.onErrorContainer,
)
}
}
Spacer(Modifier.weight(1f))
Button(
@@ -301,7 +351,7 @@ private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
}
}
/** A big-number tonal tile summarising one import outcome (added / skipped). */
/** A big-number tonal tile summarising one import outcome (added / skipped / failed). */
@Composable
private fun RowScope.ImportStatCard(
count: Int,
@@ -343,6 +393,9 @@ private fun WarningText(warning: IcsParseWarning) {
IcsParseWarning.EventWithoutStartSkipped -> stringResource(R.string.import_warning_no_start)
IcsParseWarning.AttendeesIgnored -> stringResource(R.string.import_warning_attendees)
IcsParseWarning.UnknownTimezone -> stringResource(R.string.import_warning_timezone)
IcsParseWarning.TasksImportedAsEvents -> stringResource(R.string.import_warning_tasks)
IcsParseWarning.RecurrenceRuleRepaired ->
stringResource(R.string.import_warning_recurrence_repaired)
}
Text(
text = text,

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

@@ -13,6 +13,8 @@ import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.unit.IntSize
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
import kotlinx.coroutines.flow.StateFlow
import kotlinx.datetime.LocalDate
import kotlin.math.abs
@@ -33,6 +35,8 @@ class MonthRowGeometry(
val isRtl: Boolean,
/** What this row currently draws in [lane] of column `col`, or null. */
val chipAt: (col: Int, lane: Int) -> EventInstance?,
/** The column the chip in [lane] of column `col` is drawn from. */
val chipStart: (col: Int, lane: Int) -> Int,
) {
/** Root top-left of the chip seated in [lane] of column [col]. */
fun seat(col: Int, lane: Int): Offset? {
@@ -47,21 +51,43 @@ class MonthRowGeometry(
* the day isn't in this week, or the chip went into the day's "+N" overflow.
*/
fun seatOf(event: EventInstance, date: LocalDate): Offset? {
val col = days.indexOf(date).takeIf { it >= 0 } ?: return null
val lane = (0 until laneCount).firstOrNull { lane ->
chipAt(col, lane)?.let { isSameEvent(it, event) } == true
} ?: return null
val (col, lane) = chipSeat(days, laneCount, chipAt, chipStart, event, date) ?: return null
return seat(col, lane)
}
}
/**
* The column and lane a row draws [event]'s chip in, given it covers [date] —
* null when the row seats no such chip. The column is the *left end* of the bar
* within this row, not [date]'s own: a bar spanning several days is drawn once,
* from its first column, so an event dropped onto the second day it covers is
* seated a column (or more) to the left of where it landed (#253).
*/
internal fun chipSeat(
days: List<LocalDate>,
laneCount: Int,
chipAt: (col: Int, lane: Int) -> EventInstance?,
chipStart: (col: Int, lane: Int) -> Int,
event: EventInstance,
date: LocalDate,
): Pair<Int, Int>? {
val col = days.indexOf(date).takeIf { it >= 0 } ?: return null
val lane = (0 until laneCount).firstOrNull { lane ->
chipAt(col, lane)?.let { isSameEvent(it, event) } == true
} ?: return null
return chipStart(col, lane) to lane
}
/**
* Whether [chip] is the moved event [moved] as the grid now holds it. Neither id
* alone will do: re-read instances get new instance ids, and a single-occurrence
* move writes a new event id — the title survives both.
* move writes a new event id — the title survives both. A *blank* title is no
* evidence at all, though: every untitled event carries one, so matching on it
* made any two of them the same event (#253).
*/
private fun isSameEvent(chip: EventInstance, moved: EventInstance): Boolean =
chip.eventId == moved.eventId || chip.title == moved.title
internal fun isSameEvent(chip: EventInstance, moved: EventInstance): Boolean =
chip.eventId == moved.eventId ||
(moved.title.isNotBlank() && chip.title == moved.title)
/** A chip in flight, in root coordinates so it can be drawn in an overlay. */
data class MonthChipDrag(
@@ -134,6 +160,16 @@ class MonthDragController {
*/
private var undoable: MonthChipDrag? = null
/**
* The abandoned-move counter, read at the drop so the mark can't be a
* composition behind the tick it is compared against.
*/
var abandonedTicks: StateFlow<Int>? = null
/** What [abandonedTicks] stood at when the settling chip was let go. */
var settledAbandonedMark: Int = 0
private set
private var event: EventInstance? = null
private var grabDate: LocalDate? = null
private var grab = Offset.Zero
@@ -222,6 +258,7 @@ class MonthDragController {
liftedInstanceId = last.event.instanceId
settledGhost = true
settledInRoot = null
settledAbandonedMark = abandonedTicks?.value ?: 0
return true
}
@@ -242,6 +279,7 @@ class MonthDragController {
liftedInstanceId = landed.event.instanceId
settledGhost = true
undoable = settling
settledAbandonedMark = abandonedTicks?.value ?: 0
return MonthChipDrop(landed.event, landed.grabDate, target)
}
@@ -298,6 +336,10 @@ class MonthDragController {
}
@Composable
fun rememberMonthDragController(): MonthDragController = remember { MonthDragController() }
fun rememberMonthDragController(): MonthDragController {
val controller = remember { MonthDragController() }
controller.abandonedTicks = LocalEventMove.current?.abandoned
return controller
}
val LocalMonthDrag = compositionLocalOf<MonthDragController?> { null }

View File

@@ -91,10 +91,12 @@ import androidx.compose.ui.geometry.Offset
import kotlin.math.roundToInt
import de.jeanlucmakiola.calendula.ui.common.rememberDragSurface
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
import de.jeanlucmakiola.calendula.ui.common.eventTitleOverflow
import de.jeanlucmakiola.calendula.ui.common.MoveTarget
import de.jeanlucmakiola.calendula.ui.common.MoveRequest
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
import de.jeanlucmakiola.calendula.ui.common.moveInFlight
import de.jeanlucmakiola.calendula.ui.common.abandonedMoves
import de.jeanlucmakiola.calendula.ui.common.OnUndoStarted
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
import de.jeanlucmakiola.calendula.ui.common.DragSnapHaptics
@@ -133,6 +135,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaEmptyDayRow
import de.jeanlucmakiola.calendula.ui.agenda.AgendaEventRow
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing
import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.TodayAction
@@ -145,6 +148,8 @@ import de.jeanlucmakiola.calendula.ui.common.declinedDecoration
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
import de.jeanlucmakiola.calendula.ui.common.monthBarShape
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
@@ -295,10 +300,14 @@ fun MonthScreen(
// carries the year instead of repeating it two lines further down. Dense has
// no header to defer to, so it keeps the full title.
val locale = currentLocale()
val topBarTitle = if (viewStyle == MonthViewStyle.Continuous) {
titleMonth.year.toString()
val (topBarTitle, topBarShortTitle) = remember(viewStyle, titleMonth, locale, today.year) {
if (viewStyle == MonthViewStyle.Continuous) {
val year = titleMonth.year.toString()
year to year
} else {
formatMonthTitle(titleMonth, locale, currentYear = today.year)
formatMonthTitle(titleMonth, locale, currentYear = today.year) to
formatMonthTitle(titleMonth, locale, currentYear = today.year, abbreviated = true)
}
}
// Slide direction for the grid transition: +1 = next, -1 = prev, 0 = jump (no slide).
@@ -387,9 +396,11 @@ fun MonthScreen(
topBar = {
MonthTopBar(
title = topBarTitle,
shortTitle = topBarShortTitle,
titleDate = LocalDate(titleMonth.year, titleMonth.month, 1),
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
quickSwitchViews = quickSwitchViews,
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
onJumpToDate = jumpToDate,
@@ -505,11 +516,14 @@ private fun MonthDragOverlay(controller: MonthDragController) {
val glide = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
val glideSpec = MaterialTheme.motionScheme.fastSpatialSpec<Offset>()
val seat = controller.settledInRoot
LaunchedEffect(controller.settling, moveInFlight, seat) {
val abandoned = abandonedMoves()
LaunchedEffect(controller.settling, moveInFlight, seat, abandoned) {
val landed = controller.settling
if (landed == null || moveInFlight) return@LaunchedEffect
if (seat == null) {
delay(MONTH_SETTLE_TIMEOUT_MILLIS)
// Past the mark this drop was let go on, it was abandoned and no
// seat is ever coming.
if (abandoned == controller.settledAbandonedMark) delay(MONTH_SETTLE_TIMEOUT_MILLIS)
} else {
if (!gliding) glide.snapTo(landed.topLeftInRoot)
gliding = true
@@ -563,7 +577,7 @@ private fun MonthDragOverlay(controller: MonthDragController) {
scaleY = 1f + 0.04f * lift
shadowElevation = 8.dp.toPx() * lift
alpha = copyAlpha
shape = RoundedCornerShape(4.dp)
shape = EventChipShape
clip = false
},
)
@@ -658,9 +672,11 @@ private fun ContinuousMonthContent(
@Composable
private fun MonthTopBar(
title: String,
shortTitle: String,
titleDate: LocalDate,
selectedView: CalendarView,
onCycleView: () -> Unit,
quickSwitchViews: List<CalendarView>,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
onJumpToDate: (LocalDate) -> Unit,
@@ -674,6 +690,7 @@ private fun MonthTopBar(
title = title,
currentDate = titleDate,
onJumpToDate = onJumpToDate,
shortTitle = shortTitle,
)
},
navigationIcon = {
@@ -694,8 +711,8 @@ private fun MonthTopBar(
}
ViewSwitcherPill(
current = selectedView,
cycle = quickSwitchViews,
onCycle = onCycleView,
modifier = Modifier.padding(end = 8.dp),
)
},
scrollBehavior = scrollBehavior,
@@ -713,7 +730,7 @@ internal fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp),
.padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp),
) {
// Reserve the gutter so the weekday labels stay over their day columns.
if (showWeekNumbers) Spacer(Modifier.width(WEEK_NUMBER_GUTTER))
@@ -781,10 +798,10 @@ internal fun MonthGrid(
Column(
modifier = Modifier
.fillMaxSize()
// Match the weekday header's 8dp inset so day cells sit under their
// Match the weekday header's inset so day cells sit under their
// labels, and so the week-number gutter's centre lines up with the
// top bar's hamburger (4dp bar inset + 24dp half icon button).
.padding(horizontal = 8.dp, vertical = 4.dp),
.padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
val month = state.month
@@ -880,7 +897,7 @@ private fun ContinuousMonthBlock(
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp)
.padding(horizontal = AppBarSpacing.Inset)
.padding(bottom = CONTINUOUS_MONTH_GAP),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
@@ -968,7 +985,7 @@ internal fun DenseMonthGrid(
// 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(horizontal = AppBarSpacing.Inset)
.padding(top = DENSE_HEADER_GAP),
verticalArrangement = Arrangement.spacedBy(2.dp),
// Bottom inset clears the FAB stack so the last row stays tappable.
@@ -1435,7 +1452,7 @@ internal fun SplitMonthGrid(
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp),
.padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
state.weeks.forEach { week ->
@@ -1800,7 +1817,7 @@ private fun ContinuousMonthSkeleton(dense: Boolean) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 8.dp)
.padding(horizontal = AppBarSpacing.Inset)
.clipToBounds(),
) {
if (!dense) {
@@ -1919,6 +1936,7 @@ private fun MonthWeekRow(
laneCount = MAX_EVENT_ROWS,
isRtl = isRtl,
chipAt = { col, lane -> week.chipAt(col, lane, MAX_EVENT_ROWS) },
chipStart = { col, lane -> week.chipStartCol(col, lane) },
),
)
}
@@ -2310,7 +2328,7 @@ private fun monthChipDragModifier(
// How many columns the finger crossed — grabbing the middle of a
// multi-day bar shifts by what it travelled, not to where it landed.
val delta = (drop.targetDate.toEpochDays() - drop.grabDate.toEpochDays()).toInt()
val took = moveScope?.move(
moveScope?.move(
MoveRequest(
eventId = drop.event.eventId,
beginMillis = drop.event.start.toEpochMilliseconds(),
@@ -2318,9 +2336,6 @@ private fun monthChipDragModifier(
target = MoveTarget.ByDays(delta),
),
)
// Refused, so nothing will land: let the copy go now rather than
// hold the chip out for a settle that can never arrive.
if (took != true) controller?.release()
}
},
onCancel = { controller?.cancel() },
@@ -2436,12 +2451,7 @@ private fun MonthBar(
val lifted = monthDrag?.liftedInstanceId == event.instanceId ||
monthDrag?.isSettledChip(event, days) == true
val ghost = ghostAlpha(lifted)
val shape = RoundedCornerShape(
topStart = if (continuesLeft) 0.dp else 4.dp,
bottomStart = if (continuesLeft) 0.dp else 4.dp,
topEnd = if (continuesRight) 0.dp else 4.dp,
bottomEnd = if (continuesRight) 0.dp else 4.dp,
)
val shape = monthBarShape(continuesLeft, continuesRight)
Box(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
@@ -2453,11 +2463,13 @@ private fun MonthBar(
},
contentAlignment = Alignment.CenterStart,
) {
val titleOverflow = eventTitleOverflow()
Text(
text = title,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
overflow = titleOverflow.overflow,
softWrap = titleOverflow.softWrap,
color = eventInk(fill),
textDecoration = declinedDecoration(event.isDeclined),
)
@@ -2515,7 +2527,7 @@ private fun MonthGridLoading() {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 8.dp, vertical = 4.dp),
.padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
repeat(6) {
@@ -2541,10 +2553,16 @@ private fun MonthGridLoading() {
}
}
private fun formatMonthTitle(ym: YearMonth, locale: Locale, currentYear: Int): String =
/** [abbreviated] swaps the full month name for its three-letter form (#165). */
private fun formatMonthTitle(
ym: YearMonth,
locale: Locale,
currentYear: Int,
abbreviated: Boolean = false,
): String =
formatCalendarTitle(
date = java.time.LocalDate.of(ym.year, ym.month.ordinal + 1, 1),
locale = locale,
currentYear = currentYear,
skeleton = "LLLL",
skeleton = if (abbreviated) "LLL" else "LLLL",
)

View File

@@ -84,6 +84,16 @@ fun MonthWeek.chipAt(col: Int, lane: Int, laneCap: Int): EventInstance? {
return timedByDay[days[col]].orEmpty().take(free.size).getOrNull(index)
}
/**
* The column the chip drawn in lane [lane] of column [col] begins in. A bar is
* drawn once, from its own first column, so a chip seated by [chipAt] on the
* second day it covers belongs a column (or more) to the left (#253). Answers
* [col] for a single-day chip, and for an empty slot no one should be asking
* about.
*/
fun MonthWeek.chipStartCol(col: Int, lane: Int): Int =
spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }?.startCol ?: col
/**
* The events on [day] that [laneEvents] had no lane left for — its exact
* complement, in the same bars-then-pills order. Returned as events rather than

View File

@@ -12,6 +12,7 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.ui.week.coversDay
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
import de.jeanlucmakiola.calendula.ui.week.spansMultipleDays
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
@@ -417,8 +418,11 @@ internal fun layoutCalendarWeek(
zone: TimeZone,
): MonthWeek {
val weekEvents = instances.filter { ev -> days.any { ev.coversDay(it, zone) } }
// A bar is decided by the *event*, not by how much of it this row holds: one
// crossing midnight into the next week row covers a single day in each, and
// counting per row drew it as two unrelated pills (#253).
val (bars, singles) = weekEvents.partition { ev ->
ev.isAllDay || days.count { ev.coversDay(it, zone) } > 1
ev.isAllDay || ev.spansMultipleDays(zone)
}
val spans = layoutAllDay(bars, days, zone).map { s ->
MonthSpan(

View File

@@ -591,15 +591,15 @@ class SettingsViewModel @Inject constructor(
/**
* Enable or disable [view] in the quick-switch cycle, via an atomic
* read-modify-write so a concurrent reorder can't clobber it. The
* MIN_ENABLED floor is re-checked inside the transform, because the screen's
* own guard reads an async-echoed snapshot.
* read-modify-write so a concurrent reorder can't clobber it. Any number of
* views may be turned off; below two the pill hides itself (#150).
*/
fun setQuickSwitchViewEnabled(view: CalendarView, enabled: Boolean) {
viewModelScope.launch {
prefs.updateQuickSwitch { config ->
val next = if (enabled) config.enabled + view else config.enabled - view
if (next.size < QuickSwitchConfig.MIN_ENABLED) config else config.copy(enabled = next)
config.copy(
enabled = if (enabled) config.enabled + view else config.enabled - view,
)
}
}
}

View File

@@ -32,7 +32,6 @@ import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.calendula.ui.common.descriptionRes
@@ -55,8 +54,8 @@ import java.time.format.TextStyle as JavaTextStyle
* it belongs to, plus the two cross-view ordering lists (#24, #69).
*
* The quick-switch cycle and the drawer list are independent orders — a view
* off in the cycle is still reachable from the drawer. The cycle needs at least
* [QuickSwitchConfig.MIN_ENABLED] targets.
* off in the cycle is still reachable from the drawer, including when the cycle
* is emptied and the pill disappears (#150).
*/
@Composable
internal fun ViewsScreen(
@@ -221,8 +220,6 @@ internal fun ViewsScreen(
SectionHeader(stringResource(R.string.settings_quick_switch_header))
SettingsHint(stringResource(R.string.settings_quick_switch_hint))
Spacer(Modifier.height(8.dp))
// Turning a view off is blocked once only the minimum remain enabled.
val canDisable = config.enabled.size > QuickSwitchConfig.MIN_ENABLED
ReorderableColumn(
items = config.order,
keyOf = { it },
@@ -238,8 +235,6 @@ internal fun ViewsScreen(
trailing = {
Switch(
checked = checked,
// Keep the last two on: with fewer, the pill can't switch.
enabled = !checked || canDisable,
onCheckedChange = { on -> viewModel.setQuickSwitchViewEnabled(view, on) },
)
},

View File

@@ -72,7 +72,6 @@ import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
@@ -100,14 +99,19 @@ import de.jeanlucmakiola.calendula.ui.common.MoveTarget
import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
import de.jeanlucmakiola.calendula.ui.common.beginsOn
import de.jeanlucmakiola.calendula.ui.common.clipOffsetMinutes
import de.jeanlucmakiola.calendula.ui.common.continuesAfter
import de.jeanlucmakiola.calendula.ui.common.continuesBefore
import de.jeanlucmakiola.calendula.ui.common.timedBlockShape
import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
import de.jeanlucmakiola.calendula.ui.common.eventTitleOverflow
import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
import de.jeanlucmakiola.calendula.ui.common.startInstant
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.ui.common.NowLine
@@ -268,6 +272,7 @@ fun WeekScreen(
currentYear = currentYear,
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
quickSwitchViews = quickSwitchViews,
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
onJumpToDate = jumpToDate,
@@ -387,7 +392,7 @@ private fun WeekContent(
onOpenDay = onOpenDay,
onCreateAt = onCreateAt,
onDrop = { drop ->
val took = move?.move(
move?.move(
MoveRequest(
eventId = drop.event.eventId,
beginMillis = drop.event.start.toEpochMilliseconds(),
@@ -395,9 +400,6 @@ private fun WeekContent(
target = MoveTarget.Start(drop.startInstant(zone)),
),
)
// Refused, so nothing will land: let the copy go now
// rather than hold it out for a settle that never comes.
if (took != true) dragController.release()
},
)
}
@@ -448,6 +450,7 @@ private fun WeekTopBar(
currentYear: Int,
selectedView: CalendarView,
onCycleView: () -> Unit,
quickSwitchViews: List<CalendarView>,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
onJumpToDate: (LocalDate) -> Unit,
@@ -456,12 +459,17 @@ private fun WeekTopBar(
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
) {
val locale = currentLocale()
val (title, shortTitle) = remember(weekStart, locale, currentYear) {
formatWeekTitle(weekStart, locale, currentYear) to
formatWeekTitle(weekStart, locale, currentYear, abbreviated = true)
}
TopAppBar(
title = {
CalendarTitleButton(
title = formatWeekTitle(weekStart, locale, currentYear),
title = title,
currentDate = weekStart,
onJumpToDate = onJumpToDate,
shortTitle = shortTitle,
)
},
navigationIcon = {
@@ -482,8 +490,8 @@ private fun WeekTopBar(
}
ViewSwitcherPill(
current = selectedView,
cycle = quickSwitchViews,
onCycle = onCycleView,
modifier = Modifier.padding(end = 8.dp),
)
},
// Match the static top section exactly: plain surface, lifting to
@@ -656,17 +664,19 @@ private fun AllDayBar(
val fill = eventFill(event.color, dark, soften)
Box(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
.background(fill, RoundedCornerShape(4.dp))
.background(fill, EventChipShape)
.clickable(onClick = onClick)
.padding(horizontal = 6.dp, vertical = 2.dp)
.semantics { contentDescription = title },
contentAlignment = Alignment.CenterStart,
) {
val titleOverflow = eventTitleOverflow()
Text(
text = title,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
overflow = titleOverflow.overflow,
softWrap = titleOverflow.softWrap,
color = eventInk(fill),
textDecoration = declinedDecoration(event.isDeclined),
)
@@ -912,24 +922,30 @@ private fun EventBlock(
val fill = eventFill(block.event.color, dark, soften)
val zone = remember { TimeZone.currentSystemDefault() }
val moveAction = eventMoveAction(block.event)
// A block clipped at the top continues from the previous day: its top edge
// is midnight, not the event's start, so dragging it would invent a time.
val draggable = eventDragAllowed(block.event) && block.beginsOn(date, zone)
val draggable = eventDragAllowed(block.event)
// The drop takes this offset back off, so a tail clipped at midnight lands
// where the event's own start belongs (#253).
val clipOffset = remember(block, date, zone) { block.clipOffsetMinutes(date, zone) }
val shape = remember(block, date, zone) {
timedBlockShape(block.continuesBefore(date, zone), block.continuesAfter(date, zone))
}
val dragModifier = rememberEventDragSource(
enabled = draggable,
key = block.event.instanceId,
onPickUp = { pointer, blockRoot, _ -> dragController.begin(block, pointer, blockRoot) },
onPickUp = { pointer, blockRoot, _ ->
dragController.begin(block, clipOffset, pointer, blockRoot)
},
onMove = dragController::move,
onDrop = { dragController.finish()?.let(onDrop) },
onCancel = dragController::cancel,
)
val lifted = draggable && dragController.ghosts(block, date)
val lifted = draggable && dragController.ghosts(block)
val ghost = ghostAlpha(lifted)
Box(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
// The source stays put as a ghost while its floating copy travels.
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
.background(fill, RoundedCornerShape(4.dp))
.background(fill, shape)
.clickable(onClick = onClick)
// After clickable, so it is the inner node and wins the main pass;
// the tap still works, since a drag consumes the up.
@@ -942,11 +958,13 @@ private fun EventBlock(
) {
Column {
if (showTitle) {
val titleOverflow = eventTitleOverflow(singleLine = titleMaxLines == 1)
Text(
text = title,
style = MaterialTheme.typography.labelMedium,
maxLines = titleMaxLines,
overflow = TextOverflow.Ellipsis,
overflow = titleOverflow.overflow,
softWrap = titleOverflow.softWrap,
color = eventInk(fill, alpha = 0.85f),
textDecoration = declinedDecoration(block.event.isDeclined),
)
@@ -1026,13 +1044,18 @@ private fun minToHm(min: Int, is24Hour: Boolean, locale: java.util.Locale): Stri
* "December" with no year would be actively misleading while the reader is
* looking straight at January dates.
*/
private fun formatWeekTitle(weekStart: LocalDate, locale: Locale, currentYear: Int): String {
private fun formatWeekTitle(
weekStart: LocalDate,
locale: Locale,
currentYear: Int,
abbreviated: Boolean = false,
): String {
val weekEnd = weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY)
return formatCalendarTitle(
date = java.time.LocalDate.of(weekStart.year, weekStart.month.ordinal + 1, 1),
locale = locale,
currentYear = currentYear,
skeleton = "LLLL",
skeleton = if (abbreviated) "LLL" else "LLLL",
forceYear = weekEnd.year != weekStart.year,
)
}

View File

@@ -206,6 +206,17 @@ internal fun EventInstance.coversDay(day: LocalDate, zone: TimeZone): Boolean {
return start < dayEnd && end > dayStart
}
/**
* True if this event touches more than one calendar day in [zone] — an all-day
* event covering a range, or a timed one crossing midnight. Occurrences are
* contiguous, so covering the day after the first is the whole question.
*/
internal fun EventInstance.spansMultipleDays(zone: TimeZone): Boolean {
val anchor = if (isAllDay) TimeZone.UTC else zone
val secondDay = start.toLocalDateTime(anchor).date.plus(1, DateTimeUnit.DAY)
return coversDay(secondDay, zone)
}
/**
* Clip [events] to a single [day] and assign lanes so overlapping events render
* side-by-side. Lane count is computed per overlap-cluster (a maximal run of

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

@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.widget
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.SystemClock
import androidx.glance.appwidget.updateAll
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
@@ -10,32 +11,110 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicLong
/**
* Redraws both home-screen widgets when their data goes stale. Triggered by:
* Redraws both home-screen widgets when their data goes stale, and keeps the
* midnight rollover alarm armed. Triggered by:
* - `PROVIDER_CHANGED` from the calendar provider — fires on any data change,
* so it covers both the app's own writes and external sync.
* - `DATE_CHANGED` / `TIME_SET` / `TIMEZONE_CHANGED` — so "today" highlighting
* and the upcoming window roll over at midnight / on a clock change.
* - [ACTION_ROLLOVER], the app's own alarm from [WidgetRolloverScheduler] —
* the day boundary, so "today" highlighting and the agenda's past-event
* dimming move on (#228).
* - `TIME_SET` / `TIMEZONE_CHANGED` — the day boundary moved, so redraw *and*
* re-arm.
* - `BOOT_COMPLETED` / `MY_PACKAGE_REPLACED` — both wipe pending alarms; the
* latter is also what arms installs upgrading into the fix.
*
* Both widgets also carry an `updatePeriodMillis` backstop in their provider
* XML, and the month widget's refresh button forces an immediate redraw.
* `DATE_CHANGED` rides along as a free extra the system may or may not send —
* see [WidgetRolloverScheduler]. The backstops are `updatePeriodMillis` in the
* provider XML and the month widget's refresh button.
*
* Every action that dropped, consumed or invalidated the alarm re-arms it, and
* `PROVIDER_CHANGED` re-arms at most once every [DATA_REARM_INTERVAL_MILLIS]
* ([dataRearmDue]). The alarm is inexact, so the system may drop it outright in
* a restricted standby bucket; nothing in ordinary daily use would then notice,
* and the widget sits on yesterday's date. A calendar change is the one signal
* that keeps arriving — but it arrives on *every* write (each drag, each event
* of a thousand-event import), which is what the interval is for.
*
* Exported for the system broadcasts; an extra redraw from another app is
* harmless.
*/
class WidgetUpdateReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val pending = goAsync()
// Exported, so anything can reach it with an explicit intent. Nothing
// here crosses a trust boundary, but narrowing to the actions we asked
// for keeps a stray broadcast from costing two wide provider reads.
if (intent.action !in HANDLED_ACTIONS) return
val appContext = context.applicationContext
val rearm = intent.action in REARM_ACTIONS ||
(intent.action == Intent.ACTION_PROVIDER_CHANGED && dataRearmDue())
// The host sends APPWIDGET_UPDATE after the re-arm-only ones anyway, so
// redrawing there would only repeat the work in a cold process, at the
// moment the device is most contended.
val redraw = intent.action !in REARM_ONLY_ACTIONS
val pending = goAsync()
// Calendar data may have changed (sync / our own write) — drop the cached
// month window so the widgets reload fresh. Month paging does NOT call
// this, so arrow taps stay instant.
invalidateMonthWidgetCache()
if (redraw) invalidateMonthWidgetCache()
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
try {
// Re-armed first, so the next day boundary is covered whatever
// the redraw does — and off the receiving thread, since it is a
// handful of binder calls (`CalendulaApp` moves it for the same
// reason).
if (rearm) WidgetRolloverScheduler.sync(appContext)
if (redraw) {
AgendaWidget().updateAll(appContext)
MonthWidget().updateAll(appContext)
}
} finally {
pending.finish()
}
}
}
companion object {
/** The app's own midnight wake-up; see [WidgetRolloverScheduler]. */
const val ACTION_ROLLOVER = "de.jeanlucmakiola.calendula.widget.ROLLOVER"
/** Both wipe pending alarms, and the host redraws the widgets itself after them. */
private val REARM_ONLY_ACTIONS = setOf(
Intent.ACTION_BOOT_COMPLETED,
Intent.ACTION_MY_PACKAGE_REPLACED,
)
/**
* The actions that leave no alarm standing: the two above wipe it, the
* rollover consumed itself, and a clock, zone or day change moved the
* boundary the pending one was set for.
*/
internal val REARM_ACTIONS = REARM_ONLY_ACTIONS + setOf(
ACTION_ROLLOVER,
Intent.ACTION_TIME_CHANGED,
Intent.ACTION_TIMEZONE_CHANGED,
Intent.ACTION_DATE_CHANGED,
)
internal val HANDLED_ACTIONS = REARM_ACTIONS + Intent.ACTION_PROVIDER_CHANGED
/** How rarely a calendar change may re-arm; see the class doc. */
private const val DATA_REARM_INTERVAL_MILLIS = 15 * 60_000L
/** Uptime of the last data-change re-arm, 0 for none in this process. */
private val lastDataRearmMillis = AtomicLong(0L)
/**
* Whether a calendar change may re-arm now — the backstop for an alarm
* the system dropped, throttled so a burst of writes costs one pass.
*/
private fun dataRearmDue(): Boolean {
val now = SystemClock.elapsedRealtime()
val last = lastDataRearmMillis.get()
val due = last == 0L || now - last >= DATA_REARM_INTERVAL_MILLIS
return due && lastDataRearmMillis.compareAndSet(last, now)
}
}
}

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

@@ -152,7 +152,17 @@ class ShiftMonthAction : ActionCallback {
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().update(context.applicationContext, glanceId)
}

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

@@ -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>
@@ -176,7 +176,7 @@
<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">أندرويد لا يعرض تذكيرات الأحداث من نفسه — بل يجب أن يقوم تطبيق تقويم بذلك. دع Calendula يتولى هذه المهمة.</string>
<string name="reminder_benefit_delivery_body">كل تذكير لأحداثك يصِل كإشعار، في الوقت المحدد تمامًا.</string>
@@ -238,7 +238,7 @@
<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>
@@ -249,7 +249,7 @@
<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_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>
@@ -286,7 +286,7 @@
<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>
@@ -335,8 +335,8 @@
<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>
@@ -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>
@@ -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="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>
@@ -656,26 +656,53 @@
<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_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="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="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="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,587 @@
<?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>
<string name="settings_month_header">Месяц</string>
<string name="settings_month_view_style">Стыль прагляду месяца</string>
<string name="month_style_paged">Старонкі</string>
<string name="month_style_paged_summary">Адзін месяц на ўвесь экран. Каб пабачыць іншы месяц, праводзьце пальцам улева або ўправа.</string>
<string name="month_style_continuous">Месяцы з прагорткай</string>
<string name="month_style_continuous_summary">Кожны месяц мае ўласны загаловак, яны разьдзяляюцца невялікімі прамежкамі.</string>
<string name="month_style_dense">Тыдні з плынным пераходам</string>
<string name="month_style_dense_summary">Тыдні йдуць без перапынку, кожны месяц перацякае ў наступны без прагалу паміж імі.</string>
<string name="month_style_split">Разьдзельны</string>
<string name="month_style_split_summary">Кампактная сетка пазначае дні з падзеямі, а дзень, на які вы націскаеце, адлюстроўваецца ўнізе.</string>
<string name="month_split_no_events">Нічога не заплянавана</string>
<string name="month_split_expand">Паказваць месяц цалкам</string>
<string name="month_split_collapse">Паказваць падзеі дня</string>
<string name="settings_quick_switch_header">Кнопка хуткага пераключэньня</string>
<string name="settings_quick_switch_hint">Выбраць рэжымы прагляду для цыклічнага пераключэньня кнопкай у верхнім правым куце. Перацягваць для зьмены парадку. Адключаныя рэжымы прагляду застаюцца даступнымі праз мэню навігацыі</string>
<string name="settings_drawer_order_header">Мэню навігацыі</string>
<string name="settings_drawer_order_hint">Перацягваць для зьмены парадку рэжымаў прагляду, пералічаных у мэню навігацыі.</string>
<string name="reorder_drag_handle">Перацягваць для зьмены парадку</string>
<string name="settings_section_event_form">Шаблён новай падзеі</string>
<string name="settings_form_fields_hint">Палі, якія паказваюцца па змаўчаньні; усе астатнія знаходзіцца ў раздзеле «Дадатковыя палі»</string>
<string name="settings_autofocus_title">Фокус на загалоўку новай падзеі</string>
<string name="settings_autofocus_title_hint">Пры старце новай падзеі курсор зьмяшчаецца ў полі загалоўка, адразу ж адкрываецца клявіятура.</string>
<string name="settings_event_duration">Працягласьць па змаўчаньні</string>
<string name="settings_event_duration_hint">Колькі часу працягваецца новая падзея, пакуль ня будзе зьменены час заканчэньня. На падзеі на цэлы дзень гэта не ўплывае.</string>
<string name="settings_calendar_durations_title">Працягласьць для кожнага календара</string>
<string name="settings_calendar_durations_hint">Задаць календару ўласную працягласьць па змаўчаньні — напр. 8 гадз. для працоўных зьменаў.</string>
<string name="settings_calendar_duration_inherits">Па змаўчаньні (%1$s)</string>
<string name="settings_calendar_duration_use_default">Ужываць працягласьць па змаўчаньні (%1$s)</string>
<string name="settings_color_unsupported">Дазволіць колеры ў непадтрыманых календарох</string>
<string name="settings_color_unsupported_hint">У некаторых календарох (напрыклад, у некаторых CalDAV) няма апублікаванага набору колераў; карыстальніцкі колер падзеі можа быць выдалены або перазапісаны пры наступнай сынхранізацыі. Гэтае абмежаваньне падобных календароў Calendula ня ў стане выправіць.</string>
<string name="settings_section_notifications">Абвесткі</string>
<string name="settings_reminders">Напаміны пра падзеі</string>
<string name="settings_reminders_hint">Напаміны зьяўляюцца двойчы? Іх таксама публікуе іншы дадатак календара — адключэце іх у адным з двух.</string>
<string name="settings_default_reminder">Напамін па змаўчаньні</string>
<string name="settings_default_reminder_allday">Падзеі на цэлы дзень</string>
<string name="settings_allday_reminder_time">Час напаміну аб падзеях на цэлы дзень</string>
<string name="settings_allday_reminder_time_hint">Напаміны аб падзеях на цэлы дзень зьяўляюцца ў %1$s</string>
<string name="settings_allday_reminder_fires_at">Час абвесткі: %1$s</string>
<string name="reminder_none">Няма</string>
<string name="reminder_use_default">Выкарыстоўваць напамін па змаўчаньні</string>
<string name="reminder_custom_amount">Велічыня</string>
<string name="reminder_custom_set">Задаць</string>
<string name="settings_calendar_reminders_title">Напаміны для асобных календароў</string>
<string name="settings_calendar_reminders_hint">Перавызначыць наладу па змаўчаньні для кожнага календара — асобна для падзеяў з пэўным часам і на цэлы дзень. Каляндар можа захаваць наладу па змаўчаньні, выдаліць яе або задаць сваю ўласную.</string>
<string name="settings_calendar_reminder_inherits">Па змаўчаньні (%1$s)</string>
<string name="settings_reliable_delivery">Надзейная дастаўка</string>
<string name="settings_reliable_delivery_hint">Сыстэма Android можа затрымліваць напаміны ў мэтах ашчаднага расходу зараду акумулятара. Зрабеце выключэньне для Calendula, каб яны зьяўляліся своечасова.</string>
<string name="settings_reliable_delivery_exempt">Выключэньне з рэжыму аптымізацыі акумулятара — напаміны прыходзяць своечасова.</string>
<string name="settings_snooze_duration">Працягласць адкладу</string>
<string name="settings_section_calendars">Календары</string>
<string name="settings_manage_calendars">Кіраваньне календарамі</string>
<string name="settings_manage_calendars_hint">Стварэньне лякальных календароў; кіраваньне сынхранізаванымі</string>
<string name="settings_section_language">Мова</string>
<string name="settings_language">Мова дадатку</string>
<string name="settings_language_auto">Сыстэмная</string>
<string name="settings_translate">Дапамагчы зь перакладам</string>
<string name="settings_translate_hint">Дадаць мову або палепшыць пераклад у Weblate</string>
<string name="settings_group_look">Зьнешні выгляд і паводзіны</string>
<string name="settings_group_data">Даныя</string>
<string name="settings_group_app">Дадатак</string>
<string name="settings_group_about">Аб дадатку</string>
<string name="settings_appearance_subtitle">Тэма, колеры, шрыфты</string>
<string name="settings_views_subtitle">Выгляд, макет, парадак па змаўчаньні</string>
<string name="settings_event_form_subtitle">Палі і паводзіны па змаўчаньні</string>
<string name="settings_notifications_subtitle">Напаміны і дастаўка</string>
<string name="settings_special_dates_subtitle">Дні нараджэньня і юбілейныя даты кантактаў</string>
<string name="settings_widgets_subtitle">Віджэт раскладу, плітка хуткіх налад</string>
<string name="settings_backup_subtitle">Экспарт, імпарт, аўтаматычнае рэзэрвовае капіяваньне</string>
<string name="settings_section_widgets">Віджэты і пліткі</string>
<string name="settings_widgets_hint">Парамэтры віджэтаў на галоўным экране і пліткі хуткіх наладаў. Дадаць віджэты на галоўны экран праз доўгае націсканьне.</string>
<string name="settings_section_backup">Рэзервовае капіяваньне і аднаўленьне</string>
<string name="settings_views_all_header">Усе рэжымы прагляду</string>
<string name="settings_week_day_header">Тыдзень і дзень</string>
<string name="settings_default_view_hint">Рэжым прагляду пры запуску Calendula.</string>
<string name="settings_week_start_hint">Дзень пачатку тыдня для ўсіх рэжымаў прагляду і віджэтаў.</string>
<string name="settings_time_format_hint">Як запісваецца час у дадатку. Аўтаматычна — значыць у адпаведнасьці з наладамі вашай сыстэмы.</string>
<string name="settings_past_events_hint">Што адбываецца з завершанымі падзеямі распарадку.</string>
<string name="settings_dynamic_color_summary">Колеры для дадатку са шпалераў.</string>
<string name="settings_section_special_dates">Асаблівыя даты кантактаў</string>
<string name="settings_special_dates_enable">Паказваць даты кантактаў</string>
<string name="settings_special_dates_enable_hint">Адлюстроўваць дні нараджэньня і іншыя даты вашых кантактаў у лякальных календарох. Чытаюцца толькі кантакты на гэтай прыладзе — нічога не запампоўваецца, вашы кантакты ніколі не зьмяняюцца.</string>
<string name="calendars_state_not_synced">Не сынхранізавана з гэтай прыладай</string>
<string name="calendars_state_managed">Запоўнена з вашых кантактаў</string>
<string name="calendars_managed_delete_locked">Гэты каляндар запоўнены данымі з вашых кантактаў, таму Calendula створыць яго зноў пры наступнай сынхранізацыі. Каб выдаліць яго, адключэце асаблівыя даты ў меню «Налады» → «Асаблівыя даты».</string>
<string name="calendars_synced_header">Сынхранізаваныя календары</string>
<string name="calendars_synced_hint">Яны паступаюць з уліковых запісаў на вашай прыладзе. Стварайце і рэдагуйце іх у асобным дадатку.</string>
<string name="calendars_manage_in_app">Кіраваць у дадатку</string>
<string name="calendars_account_from_source">%1$s (%2$s)</string>
<string name="calendars_account_menu_a11y">Дадатковыя парамэтры для %1$s</string>
<string name="calendars_enable_all">Уключыць усе</string>
<string name="calendars_disable_all">Адключыць усе</string>
<string name="calendars_add_account">Дадаць ул. запіс</string>
<string name="calendars_new_title">Новы каляндар</string>
<string name="calendars_edit_title">Рэдагаваць каляндар</string>
<string name="calendars_name_label">Назва</string>
<string name="calendars_color_label">Колер</string>
<string name="calendars_description_hint">Дадаць апісаньне</string>
<string name="calendars_delete_confirm_title">Выдаліць каляндар?</string>
<string name="calendars_delete_confirm_message">Каляндар «%1$s» зь ягонымі падзеямі назаўжды выдалены з гэтай прылады.</string>
<string name="calendars_write_error">Не атрымалася захаваць зьмены.</string>
<string name="calendars_backup_header">Рэзэрвовае капіяваньне</string>
<string name="calendars_backup_hint">Лякальныя календары ні з чым не сынхранізуюцца, таму экспартуйце іх у файл .ics, каб захаваць копію.</string>
<string name="calendars_backup_action">Экспарт у файл .ics</string>
<string name="calendars_export_title">Экспартаваць календары</string>
<string name="calendars_export_hint">Выбраць календары для запісу ў файл .ics.</string>
<string name="calendars_export_action">Экспарт</string>
<string name="calendars_restore_header">Аднавіць</string>
<string name="calendars_restore_action">Аднавіць з файла .ics</string>
<string name="calendars_restore_hint">Імпартаваць падзеі з рэзэрвовай копіі або іншага дадатку календара.</string>
<string name="calendars_auto_backup">Аўтаматычнае рэзэрвовае капіяваньне</string>
<string name="calendars_auto_backup_hint">Пэрыядычны экспарт лякальных календароў у каталёг у выглядзе файла .ics.</string>
<string name="calendars_auto_backup_folder">Каталёг рэзэрвовага капіяваньня</string>
<string name="calendars_auto_backup_folder_unset">Націсьніце, каб выбраць каталёг</string>
</resources>

View File

@@ -43,25 +43,25 @@
<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">Vymazat</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">Smazat opakující se událost</string>
<string name="event_delete_recurring_title">Odstranit opakující se událost</string>
<string name="event_delete_option_occurrence">Pouze tuto událost</string>
<string name="event_delete_option_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 mazat události</string>
<string name="event_delete_write_denied">Calendula potřebuje oprávnění k zápisu, aby mohla odstraňovat události</string>
<string name="event_move_action">Přesunout…</string>
<string name="event_move_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">Zpět</string>
<string name="event_move_undo">Vrátit</string>
<string name="event_move_undone">Vráceno zpět</string>
<string name="event_move_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>
@@ -111,7 +111,7 @@
<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">Obnovit</string>
<string name="event_edit_color_reset">Znovu nastavit</string>
<string name="event_edit_color_unsupported">Pro tento kalendář není k dispozici</string>
<string name="event_edit_color_unsupported_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>
@@ -121,8 +121,8 @@
<string name="event_edit_conflict_overwrite_hint">Pouze u upravených polí dojde k přepsání vnějšího zdroje</string>
<string name="event_edit_conflict_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 smazána</string>
<string name="event_edit_gone_body">Během úpravy došlo ke smazání události, například pomocí jiného zařízení. Vaše změny již nelze uložit.</string>
<string name="event_edit_gone_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">
@@ -250,4 +250,86 @@
<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

@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="backup_interval_units">
<item>Minutes</item>
<item>Hours</item>
<item>Days</item>
<item>Weeks</item>
</string-array>
<string name="app_name">Calendula</string>
<string name="app_tagline">Egy modern naptár.</string>
<string name="state_loading">Töltés…</string>
<string name="state_retry">Újra</string>
<string name="state_failure_unknown">Hiba történt.</string>
<string name="state_failure_permission">Naptár hozzáférés szükséges.</string>
<string name="state_failure_permission_action">Hozzáférés engedélyezése</string>
<string name="state_failure_no_calendars">Nincs naptár beállítva.</string>
<string name="state_failure_no_calendars_action">Rendszernaptár beállítások megnyitása</string>
<string name="state_failure_provider">A naptárat nem lehetett beolvasni.</string>
<string name="permission_rationale_title">Lásd az eseményeid, szépen</string>
<string name="permission_rationale_body">A Calendula alkalmazásnak hozzáférésre van szüksége a naptáradhoz az események megjelenítéséhez és kezeléséhez.</string>
<string name="permission_request_button">Naptár hozzáférés engedélyezése</string>
<string name="permission_denied_title">Naptár hozzáférés megtagadva</string>
<string name="permission_denied_body">A Calendula nem tud eseményeket megjeleníteni naptár-hozzáférés nélkül. Ezt a jogosultságot a rendszerbeállításokban adhatja meg újra.</string>
<string name="permission_open_settings_button">Rendszerbeállítások megnyitása</string>
<string name="permission_retry_button">Próbáld újra</string>
<string name="permission_benefit_private_title">Eleve privát</string>
<string name="permission_benefit_private_body">Nem kell internetelérés, nincs telemetria — semmi sem hagyja el a telefonod.</string>
<string name="permission_benefit_sync_title">Az összes naptárad, egy helyen</string>
<string name="permission_benefit_sync_body">Google, CalDAV, helyi — minden, ami szinkronizálva van az eszközzel, egyszerűen megjelenik.</string>
<string name="permission_benefit_privacy_body">Nulla telemetria, nulla analitika, nulla hirdetés.</string>
<string name="permission_privacy_footnote">Az eszközödön marad · nem kell internetelérés</string>
<string name="month_prev">Előző hónap</string>
<string name="month_next">Következő hónap</string>
<string name="month_today_action">Ma</string>
<string name="month_more_actions">További műveletek</string>
<string name="month_open_menu">Menü megnyitása</string>
<string name="month_action_settings">Beállítások</string>
<string name="month_a11y_today_prefix">Ma</string>
<string name="week_today_action">Ez a hét</string>
<string name="week_number_label">Hét</string>
<string name="day_today_action">Ma</string>
<string name="event_detail_back">Vissza</string>
<string name="event_detail_edit">Szerkeszt</string>
<string name="event_detail_delete">Töröl</string>
<string name="event_detail_share">Megoszt</string>
<string name="event_detail_duplicate">Másolat</string>
<string name="event_share_chooser_title">Esemény megosztása</string>
<string name="event_share_failed">Ezt az eseményt nem lehet megosztani.</string>
<string name="event_delete_title">Esemény törlése?</string>
<string name="event_delete_body">Az esemény törlődik a naptárból és minden olyan eszközről, amelyhez szinkronizálódik.</string>
<string name="event_delete_recurring_title">Ismétlődő esemény törlése</string>
<string name="event_delete_option_occurrence">Csak ezt az eseményt</string>
<string name="event_delete_option_following">Ezt és az összes további eseményt</string>
<string name="event_delete_option_series">A sorozat összes eseményét</string>
<string name="event_edit_recurring_title">Ismétlődő esemény szerkesztése</string>
<string name="event_delete_failed">Nem sikerült törölni az eseményt</string>
<string name="event_delete_write_denied">A Calendula írási hozzáférést igényel az események törléséhez</string>
<string name="event_move_action">Áthelyez…</string>
<string name="event_move_recurring_title">Ismétlődő esemény áthelyezése</string>
<string name="event_move_occurrence_only">A teljes sorozat áthelyezése megváltoztatná, hogy mely napokra esik, így csak ez az esemény helyezhető át.</string>
<string name="event_move_done">Áthelyezve ide: %1$s</string>
<string name="event_move_undo">Visszavon</string>
<string name="event_move_undone">Visszavont áthelyezése</string>
<string name="event_detail_calendar">Naptár</string>
</resources>

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

@@ -133,4 +133,215 @@
<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>
<string name="event_edit_recurrence_next">Следующий: %1$s</string>
<string name="event_edit_recurrence_times">раз</string>
<string name="event_detail_attendees">Участники</string>
<string name="event_detail_recurrence">Повторяется</string>
<string name="event_edit_error_recurrence_ends_before_start">Повторы заканчиваются до начала мероприятия</string>
<string name="event_attendee_unknown"></string>
<string name="event_access_default_summary">Стандартная настройка календаря</string>
<string name="event_access_confidential_summary">Помечено как личное; что это означает, зависит от учетной записи календаря</string>
<string name="event_attendee_organizer">Организатор</string>
<string name="event_attendee_optional">Необязательно</string>
<string name="reminder_default">Уведомление по умолчанию</string>
<plurals name="reminder_minutes">
<item quantity="one">%d минута до</item>
<item quantity="few">%d минуты до</item>
<item quantity="many">%d минут до</item>
<item quantity="other">%d минут до</item>
</plurals>
<plurals name="reminder_hours">
<item quantity="one">%d час до</item>
<item quantity="few">%d часа до</item>
<item quantity="many">%d часов до</item>
<item quantity="other">%d часов до</item>
</plurals>
<plurals name="reminder_days">
<item quantity="one">%d день до</item>
<item quantity="few">%d дня до</item>
<item quantity="many">%d дней до</item>
<item quantity="other">%d дней до</item>
</plurals>
<plurals name="reminder_weeks">
<item quantity="one">%d неделя до</item>
<item quantity="few">%d недели до</item>
<item quantity="many">%d недель до</item>
<item quantity="other">%d недель до</item>
</plurals>
<plurals name="duration_minutes">
<item quantity="one">%d минута</item>
<item quantity="few">%d минуты</item>
<item quantity="many">%d минут</item>
<item quantity="other">%d минут</item>
</plurals>
<plurals name="duration_hours">
<item quantity="one">%d час</item>
<item quantity="few">%d часа</item>
<item quantity="many">%d часов</item>
<item quantity="other">%d часов</item>
</plurals>
<plurals name="duration_days">
<item quantity="one">%d день</item>
<item quantity="few">%d дня</item>
<item quantity="many">%d дней</item>
<item quantity="other">%d дней</item>
</plurals>
<string name="duration_hours_minutes">%1$s %2$s</string>
<string name="duration_custom_max">Максимум %1$s</string>
<plurals name="duration_weeks">
<item quantity="one">%d неделя</item>
<item quantity="few">%d недели</item>
<item quantity="many">%d недель</item>
<item quantity="other">%d недель</item>
</plurals>
<string name="event_untitled">(Без названия)</string>
<string name="onboarding_step_counter">Шаг %1$d из %2$d</string>
<string name="onboarding_backup_body">На этом телефоне ничего не синхронизируется с учетной записью, поэтому, потеряв его, вы потеряете и свой календарь. Calendula может создать для вас резервную копию.</string>
<string name="onboarding_backup_benefit_folder_title">Папка на выбор</string>
<string name="onboarding_backup_benefit_folder_body">Резервные копии сохраняются как .ics, поэтому сохраните их в синхронизируемом месте или на SD-карте.</string>
<string name="onboarding_backup_benefit_daily_title">Раз в день</string>
<string name="onboarding_backup_benefit_daily_body">Calendula экспортирует ваши локальные калдендари в фоновом режиме. Выставите как часто в Настройках.</string>
<string name="onboarding_backup_enable_button">Выберите папку и создайте резервную копию</string>
<string name="onboarding_backup_skip_button">Не сейчас</string>
<string name="onboarding_view_title">Что открыть первым?</string>
<string name="onboarding_month_style_title">Вид месяца?</string>
<string name="onboarding_view_continue_button">Продолжить</string>
<string name="onboarding_back">Назад</string>
<string name="onboarding_visibility_button">Хорошо</string>
<string name="onboarding_done_title">Всё готово</string>
<string name="onboarding_done_body">Ваши календари готовы. Все выбранные настройки можно изменить позже.</string>
<string name="onboarding_done_button">Открыть мой календарь</string>
<string name="reminder_benefit_duplicates_title">Используете иное приложение календаря?</string>
<string name="reminder_benefit_duplicates_body">Если несколько приложений отвечают за уведомления, вы увидите дубликаты — отключите их в одном месте.</string>
<string name="reminder_onboarding_enable_button">Включить напоминания</string>
<string name="reminder_onboarding_skip_button">Не сейчас</string>
<string name="reminder_action_snooze">Отложить</string>
<string name="reminder_action_dismiss">Отключить</string>
<string name="reminder_day_tomorrow">Завтра</string>
<string name="reminder_day_yesterday">Вчера</string>
<string name="view_month">Месяц</string>
<string name="view_week">Неделя</string>
<string name="view_day">День</string>
<string name="view_agenda">Расписание</string>
<string name="view_section">Вид</string>
<string name="drawer_jump_to_date">Выбрать дату</string>
<string name="agenda_today_action">Сегодня</string>
<string name="agenda_header_today">Сегодня</string>
<string name="agenda_header_tomorrow">Завтра</string>
<string name="agenda_empty_title">Вы везде успели</string>
<string name="agenda_no_more_today">На сегодня — всё</string>
<string name="agenda_span_starts">Начало в %1$s</string>
<string name="agenda_span_ends">Конец в %1$s</string>
<string name="today_jump_action">Сегодня</string>
<string name="search_action">Поиск</string>
<string name="search_hint">Поиск мероприятий</string>
<string name="search_back">Назад</string>
<string name="search_clear">Очистить</string>
<string name="search_idle_hint">Поиск по названию, локации или описанию.</string>
<string name="search_empty">Не найдено по запросу \"%1$s\".</string>
<plurals name="search_selected_count">
<item quantity="one">%d выбран</item>
<item quantity="few">%d выбрано</item>
<item quantity="many">%d выбрано</item>
<item quantity="other">%d выбрано</item>
</plurals>
<string name="search_in_descriptions">Найдено в описаниях</string>
<string name="search_select_all">Выбрать всё</string>
<string name="search_delete_selected">Удалить выбранное</string>
<string name="search_selection_close">Отменить выбранное</string>
<plurals name="search_delete_title">
<item quantity="one">Удалить %d мероприятие?</item>
<item quantity="few">Удалить %d мероприятия?</item>
<item quantity="many">Удалить %d мероприятий?</item>
<item quantity="other">Удалить %d мероприятий?</item>
</plurals>
<plurals name="search_delete_done">
<item quantity="one">%d мероприятие удалено</item>
<item quantity="few">%d мероприятия удалено</item>
<item quantity="many">%d мероприятий удалено</item>
<item quantity="other">%d мероприятий удалено</item>
</plurals>
<string name="search_delete_partial">%1$d удалено, %2$d не вышло</string>
<string name="widget_agenda_title">Следующее</string>
<string name="widget_agenda_label">Расписание от Calendula</string>
<string name="widget_month_label">Месяц от Calendula</string>
<string name="widget_refresh">Обновить</string>
<string name="widget_new_event">Новое мероприятие</string>
<string name="widget_needs_permission">Откройте Calendula, чтобы дать доступ к календарю</string>
<string name="widget_prev_month">Предыдущий месяц</string>
<string name="widget_next_month">Следующий месяц</string>
<string name="widget_today">Сегодня</string>
<string name="filter_title">Календари</string>
<string name="settings_title">Настройки</string>
<string name="settings_back">Назад</string>
<string name="back">Назад</string>
<string name="settings_section_appearance">Внешний вид</string>
<string name="settings_theme">Тема</string>
<string name="settings_theme_system">По умолчанию системы</string>
<string name="settings_theme_light">Светлая</string>
<string name="settings_theme_dark">Тёмная</string>
<string name="settings_theme_hint">Моментально меняет веншний вид приложения: светлый или тёмный</string>
<string name="settings_theme_system_summary">Выбрано: %1$s</string>
<string name="settings_default_view">Вид по умолчанию</string>
<string name="settings_dynamic_color">Динамические цвета</string>
<string name="settings_dynamic_color_unavailable">Требуется Android 12 или более поздняя версия</string>
<string name="settings_soften_colors">Гармонизировать цвета календаря</string>
<string name="settings_soften_colors_summary">Изменяет яркость цветов не меняя оттенок, чтобы создать единую цветовую палитру. Отключите для показа стандартных цветов подключённого календаря.</string>
<string name="settings_font_headings">Шрифт заголовков</string>
<string name="settings_font_body">Основной шрифт</string>
<string name="settings_font_system">По умолчанию</string>
<string name="font_atkinson_hyperlegible">Atkinson Hyperlegible</string>
<string name="font_lora">Lora</string>
<string name="font_jetbrains_mono">JetBrains Mono</string>
<string name="settings_font_choose_file">Выбрать файл…</string>
<string name="settings_font_custom_selected">Пользовательский шрифт</string>
<string name="settings_font_import_failed">Не удалось прочитать этот файл как шрифт</string>
<string name="settings_font_specimen_heading">Четверг, 14 мая</string>
<string name="settings_font_specimen_body">Рабочая встреча в 10:00, затем обед с Александром в кафе на улице Пушкина.</string>
<string name="settings_week_start">Неделя начинается в</string>
<string name="settings_week_start_auto">Автоматически</string>
<string name="settings_week_start_auto_summary">Выбрано: %1$s</string>
</resources>

View File

@@ -0,0 +1,266 @@
<?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>
<plurals name="duration_minutes">
<item quantity="one">%d minúta</item>
<item quantity="few">%d minúty</item>
<item quantity="many">%d minút</item>
<item quantity="other">%d minút</item>
</plurals>
<string name="event_attendee_organizer">Usporiadateľ</string>
<string name="event_attendee_optional">Voliteľné</string>
<string name="event_attendee_resource">Zdroj</string>
<string name="event_detail_self_response">Vaša odpoveď: %1$s</string>
<string name="reminder_at_time">V čas udalosti</string>
<string name="reminder_default">Predvolená pripomienka</string>
<plurals name="reminder_minutes">
<item quantity="one">%d minútu predtým</item>
<item quantity="few">%d minúty predtým</item>
<item quantity="many">%d minút predtým</item>
<item quantity="other">%d minút predtým</item>
</plurals>
<plurals name="reminder_hours">
<item quantity="one">%d hodinu predtým</item>
<item quantity="few">%d hodiny predtým</item>
<item quantity="many">%d hodín predtým</item>
<item quantity="other">%d hodín predtým</item>
</plurals>
<plurals name="reminder_days">
<item quantity="one">%d deň predtým</item>
<item quantity="few">%d dní predtým</item>
<item quantity="many">%d dní predtým</item>
<item quantity="other">%d dní predtým</item>
</plurals>
<plurals name="reminder_weeks">
<item quantity="one">%d týždeň predtým</item>
<item quantity="few">%d týždne predtým</item>
<item quantity="many">%d týždňov predtým</item>
<item quantity="other">%d týždňov predtým</item>
</plurals>
<plurals name="duration_hours">
<item quantity="one">%d hodina</item>
<item quantity="few">%d hodiny</item>
<item quantity="many">%d hodín</item>
<item quantity="other">%d hodín</item>
</plurals>
<plurals name="duration_days">
<item quantity="one">%d deň</item>
<item quantity="few">%d dni</item>
<item quantity="many">%d dní</item>
<item quantity="other">%d dní</item>
</plurals>
<string name="duration_hours_minutes">%1$s %2$s</string>
<string name="duration_custom_max">Najviac %1$s</string>
<plurals name="duration_weeks">
<item quantity="one">%d týždeň</item>
<item quantity="few">%d týždne</item>
<item quantity="many">%d týždňov</item>
<item quantity="other">%d týždňov</item>
</plurals>
<string name="event_untitled">(Bez názvu)</string>
<string name="onboarding_step_counter">Krok %1$d z %2$d</string>
<string name="onboarding_backup_title">Vaše udalosti sú iba tu</string>
<string name="onboarding_backup_body">Nič na vašom telefóne sa nesynchronizuje z účtom. Ak ho stratíte, prídete aj o svoj kalendár. Calendula môže vytvoriť lokálnu zálohu.</string>
<string name="onboarding_backup_benefit_folder_title">Priečinok, ktorý si vyberiete</string>
<string name="onboarding_backup_benefit_folder_body">Zálohy sú obyčajné .ics súbory uložte je niekam kde sa môžu synchronizovať alebo na SD kartu.</string>
<string name="onboarding_backup_benefit_daily_title">Automaticky jeden krát za deň</string>
<string name="onboarding_backup_benefit_daily_body">Calendula exportuje vaše lokálne kalendáre na pozadí. V nastaveniach môžete zmeniť ako často.</string>
<string name="onboarding_backup_enable_button">Vyberte priečinok a spravte zálohu</string>
<string name="onboarding_backup_skip_button">Nie teraz</string>
<string name="onboarding_view_title">Čo by sa malo otvoriť ako prvé?</string>
<string name="onboarding_month_style_title">Ako by mal vyzerať rozloženie kalendára?</string>
<string name="onboarding_view_continue_button">Pokračovať</string>
<string name="onboarding_back">Späť</string>
<string name="onboarding_visibility_button">Jasné</string>
<string name="onboarding_done_title">Všetko je pripravené</string>
<string name="onboarding_done_body">Vaše kalendáre sú pripravené. Všetky voľby môžete neskôr zmeniť v nastaveniach.</string>
<string name="onboarding_done_button">Otvoriť môj kalendár</string>
<string name="reminder_channel_name">Pripomienky udalostí</string>
<string name="reminder_channel_description">Notifikácie v časoch pripomienok vašich udalostí</string>
</resources>

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

@@ -231,6 +231,8 @@
<string name="event_detail_self_response">Your response: %1$s</string>
<string name="reminder_at_time">At time of event</string>
<string name="reminder_default">Default reminder</string>
<!-- A reminder that fires after the event starts, e.g. "2 hours after". -->
<string name="reminder_after">%1$s after</string>
<plurals name="reminder_minutes">
<item quantity="one">%d minute before</item>
<item quantity="other">%d minutes before</item>
@@ -496,7 +498,7 @@
<string name="month_split_expand">Show the whole month</string>
<string name="month_split_collapse">Show the day\'s events</string>
<string name="settings_quick_switch_header">Quick-switch button</string>
<string name="settings_quick_switch_hint">Choose which views the top-right button cycles through, and drag to reorder them. Turned-off views stay reachable from the navigation menu.</string>
<string name="settings_quick_switch_hint">Choose which views the top-right button cycles through, and drag to reorder them. With fewer than two views turned on the button is hidden. Turned-off views stay reachable from the navigation menu.</string>
<string name="settings_drawer_order_header">Navigation menu</string>
<string name="settings_drawer_order_hint">Drag to reorder the views listed in the navigation menu.</string>
<string name="reorder_drag_handle">Drag to reorder</string>
@@ -693,11 +695,16 @@
<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_done_stopped_title">Import stopped</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>
@@ -711,6 +718,10 @@
<item quantity="one">Import %d event</item>
<item quantity="other">Import %d events</item>
</plurals>
<plurals name="import_done_stopped">
<item quantity="one">The calendar stopped accepting events part way through. %d event from the file was never added.</item>
<item quantity="other">The calendar stopped accepting events part way through. %d events from the file were never added.</item>
</plurals>
<plurals name="import_done_imported">
<item quantity="one">Imported %d event.</item>
<item quantity="other">Imported %d events.</item>
@@ -719,6 +730,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,14 +10,17 @@
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
<locale android:name="en" />
<locale android:name="ar" />
<locale android:name="be" />
<locale android:name="cs" />
<locale android:name="de" />
<locale android:name="es" />
<locale android:name="fr" />
<locale android:name="hu" />
<locale android:name="it" />
<locale android:name="ja" />
<locale android:name="pl" />
<locale android:name="pt-BR" />
<locale android:name="ru" />
<locale android:name="sk" />
<locale android:name="zh-CN" />
</locale-config>

View File

@@ -143,6 +143,76 @@ class AllDayReminderEncodingTest {
assertThat(fireFromNext).isEqualTo(LocalTime.of(9, 0)) // correct
}
@Test
fun `an imported yearly series samples at its next occurrence, not its anchor`() {
// Fossify anchors a year-less contact birthday at 1970, when Berlin had
// no DST; sampling there fires a modern June occurrence at 10:00 instead
// of the 09:00 the user picked (Codeberg #225).
val anchor = LocalDate.of(1970, 6, 3)
val today = LocalDate.of(2026, 6, 1)
val sampled = importedAllDayReminderDate(anchor, "FREQ=YEARLY;BYMONTH=6", today)
assertThat(sampled).isEqualTo(LocalDate.of(2026, 6, 3))
val raw = toProviderAllDayMinutes(0, sampled, berlin, nineAm)
val fire = actualFire(raw, LocalDate.of(2027, 6, 3))
.let(java.time.Instant::ofEpochMilli).atZone(berlin).toLocalTime()
assertThat(fire).isEqualTo(LocalTime.of(9, 0))
}
@Test
fun `the lead time still reads back from the anchor's own date`() {
// The offset is sampled at the next occurrence but decoded against
// DTSTART, so the two must still agree on the whole-day lead time.
val anchor = LocalDate.of(1970, 6, 3)
val sampled = importedAllDayReminderDate(anchor, "FREQ=YEARLY", LocalDate.of(2026, 6, 1))
for (semantic in listOf(0, 1_440, 2_880)) {
val raw = toProviderAllDayMinutes(semantic, sampled, berlin, nineAm)
assertThat(fromProviderAllDayMinutes(raw, anchor, berlin, nineAm)).isEqualTo(semantic)
}
}
@Test
fun `a one-off import samples at its own date, a stale series at today`() {
val past = LocalDate.of(1970, 6, 3)
val today = LocalDate.of(2026, 6, 1)
val future = LocalDate.of(2027, 3, 4)
assertThat(importedAllDayReminderDate(past, null, today)).isEqualTo(past)
assertThat(importedAllDayReminderDate(past, "FREQ=WEEKLY;BYDAY=MO", today)).isEqualTo(today)
assertThat(importedAllDayReminderDate(future, "FREQ=WEEKLY", today)).isEqualTo(future)
}
@Test
fun `the provider default sentinel survives a round trip untouched`() {
// -1 is Reminders.MINUTES_DEFAULT, not an offset. Decoded it landed on
// the start date and came back as 0 — a concrete at-start alarm on the
// detail screen and in an exported file.
assertThat(toProviderAllDayMinutes(-1, winter, berlin, nineAm)).isEqualTo(-1)
assertThat(fromProviderAllDayMinutes(-1, winter, berlin, nineAm)).isEqualTo(-1)
}
@Test
fun `a real encoding that is negative is still decoded`() {
// "At time of event" encodes negative — the sentinel check must be the
// exact value, not any negative one.
val raw = toProviderAllDayMinutes(0, winter, berlin, nineAm)
assertThat(raw).isLessThan(0)
assertThat(fromProviderAllDayMinutes(raw, winter, berlin, nineAm)).isEqualTo(0)
}
@Test
fun `an encoding that would land on the sentinel is nudged off it`() {
// 01:01 Berlin in winter (CET, +1) is 00:01 UTC — one minute past the
// event's UTC-midnight DTSTART, i.e. exactly -1. Left there it would read
// back as "use the account default" and the real reminder would be gone
// from the screen and from an export.
val raw = toProviderAllDayMinutes(0, winter, berlin, 61)
assertThat(raw).isEqualTo(-2)
assertThat(fromProviderAllDayMinutes(raw, winter, berlin, 61)).isEqualTo(0)
}
@Test
fun `a winter-anchored offset drifts one hour on a summer occurrence`() {
// Known limitation: one fixed MINUTES per series can't track DST. An

View File

@@ -22,6 +22,7 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import kotlin.time.Instant
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
@@ -642,7 +643,12 @@ class CalendarRepositoryImplTest {
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
)
val id = repo.updateOccurrence(eventId = 42L, beginMillis = 1_000L, form = form)
val id = repo.updateOccurrence(
eventId = 42L,
beginMillis = 1_000L,
original = form,
form = form,
)
assertThat(id).isEqualTo(88L)
assertThat(fake.updatedOccurrences).containsExactly(Triple(42L, 1_000L, form))
@@ -801,6 +807,113 @@ class CalendarRepositoryImplTest {
assertThat(fake.importedEvents.map { it.second }).containsExactly(3L, 3L)
}
@Test
fun `importEvents carries on past an event the provider rejects`(
@TempDir tempDir: Path,
) = runTest {
// A foreign export can hold a row the provider throws on outright; that
// must cost the user one event, not the whole file (Codeberg #225).
val fake = FakeCalendarDataSource().apply { failingImportSummaries += "bad" }
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
val events = listOf(
parsedEvent("a@x", summary = "good"),
parsedEvent("b@x", summary = "bad"),
parsedEvent("c@x", summary = "good"),
)
val summary = repo.importEvents(targetCalendarId = 3L, events = events)
assertThat(summary.imported).isEqualTo(2)
assertThat(summary.failed).isEqualTo(1)
assertThat(fake.importedEvents.map { it.first.uid }).containsExactly("a@x", "c@x")
}
@Test
fun `importEvents stops at a permission failure and reports what landed`(
@TempDir tempDir: Path,
) = runTest {
// The permission or the target calendar is gone, so every remaining insert
// would fail the same way. Counted per event, the screen reported "N events
// couldn't be added" for a file it never had a chance at; thrown, it
// reported that nothing landed — and since only events carrying a UID dedup,
// the retry that invites duplicated everything that already had.
val fake = FakeCalendarDataSource().apply {
importsBeforeError = 2
writeError = SecurityException("WRITE_CALENDAR revoked")
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
val summary = repo.importEvents(
targetCalendarId = 3L,
events = List(5) { parsedEvent("e$it@x") },
)
assertThat(summary.imported).isEqualTo(2)
// The one that threw and the two never reached.
assertThat(summary.notAttempted).isEqualTo(3)
assertThat(summary.failed).isEqualTo(0)
}
@Test
fun `a stopped import does not count known duplicates as work left to do`(
@TempDir tempDir: Path,
) = runTest {
// The remainder is not all work: a UID already in the calendar would have
// been skipped whatever happened, so counting it against a retry
// overstates how much of the file is missing.
val fake = FakeCalendarDataSource().apply {
existingUidsResult = setOf("e3@x", "e4@x")
importsBeforeError = 1
writeError = SecurityException("WRITE_CALENDAR revoked")
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
val summary = repo.importEvents(
targetCalendarId = 3L,
events = List(5) { parsedEvent("e$it@x") },
)
assertThat(summary.imported).isEqualTo(1)
// e1 threw and e2 was never reached; e3 and e4 were duplicates either way.
assertThat(summary.notAttempted).isEqualTo(2)
assertThat(summary.skippedDuplicate).isEqualTo(2)
}
@Test
fun `importEvents looks the target palette up once, not per event`(
@TempDir tempDir: Path,
) = runTest {
var lookups = 0
val fake = FakeCalendarDataSource().apply {
publishedEventColorsResult = { lookups++; emptyList() }
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
repo.importEvents(3L, List(5) { parsedEvent("e$it@x") })
assertThat(lookups).isEqualTo(1)
}
@Test
fun `importEvents matches colours against the uncurated palette`(
@TempDir tempDir: Path,
) = runTest {
// Curation is a picker concession — it folds look-alikes and drops the
// neutrals from an oversized palette — but every published key is one
// the calendar accepts, so an imported colour matches against all of
// them (Codeberg #225).
val published = listOf(EventColorOption("1", 0xFF808080.toInt()))
val fake = FakeCalendarDataSource().apply {
publishedEventColorsResult = { published }
eventColorPaletteResult = { emptyList() }
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
repo.importEvents(3L, listOf(parsedEvent("a@x")))
assertThat(fake.lastImportPalette).isEqualTo(published)
}
@Test
fun `exportEvents forwards the chosen calendar-id subset to the data source`(
@TempDir tempDir: Path,
@@ -825,9 +938,10 @@ class CalendarRepositoryImplTest {
assertThat(fake.lastExportableEventsCalendarIds).isNull()
}
private fun parsedEvent(uid: String?) = de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent(
private fun parsedEvent(uid: String?, summary: String = "E") =
de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent(
uid = uid,
summary = "E",
summary = summary,
start = Instant.fromEpochMilliseconds(1_000_000_000L),
end = Instant.fromEpochMilliseconds(1_000_003_600L),
isAllDay = false,

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 =
@@ -497,6 +498,7 @@ class EventWriteMapperTest {
occurrenceMillis = 1_784_102_400_000L,
dtStartMillis = 1_783_929_600_000L,
rrule = "FREQ=DAILY;COUNT=5",
rdate = null,
duration = "PT1H",
timezone = "Europe/Berlin",
allDay = 0,
@@ -520,6 +522,7 @@ class EventWriteMapperTest {
occurrenceMillis = 1_784_102_400_000L,
dtStartMillis = 1_783_929_600_000L,
rrule = "FREQ=DAILY;COUNT=5",
rdate = null,
duration = "PT1H",
timezone = "Europe/Berlin",
allDay = 0,
@@ -535,6 +538,7 @@ class EventWriteMapperTest {
occurrenceMillis = 1_784_102_400_000L,
dtStartMillis = 1_783_929_600_000L,
rrule = "FREQ=DAILY;COUNT=5",
rdate = null,
duration = "PT1H",
timezone = "Europe/Berlin",
allDay = 0,
@@ -542,6 +546,27 @@ class EventWriteMapperTest {
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260715T080000Z")
}
@Test
fun `an all-day drop folds away a padded repeat of the same day`() {
// Compared verbatim, the two spellings of 15 July are different strings:
// the day ends up excluded twice, and exdateContains — the guard against
// detaching the same occurrence twice — misses it entirely.
val occurrence = 1_784_073_600_000L // 2026-07-15T00:00:00Z
val values = buildOccurrenceExdateValues(
existingExdate = "20260715T000000Z",
occurrenceMillis = occurrence,
dtStartMillis = 1_783_900_800_000L,
rrule = "FREQ=WEEKLY",
rdate = null,
duration = "P1D",
timezone = "UTC",
allDay = 1,
)
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260715")
assertThat(exdateContains("20260715T000000Z", occurrence, isAllDay = true, timezone = null))
.isTrue()
}
@Test
fun `all-day exdate drop uses the date-only form`() {
// An all-day DTSTART sits at UTC midnight, so the date reads off UTC.
@@ -550,6 +575,7 @@ class EventWriteMapperTest {
occurrenceMillis = 1_784_073_600_000L, // 2026-07-15T00:00:00Z
dtStartMillis = 1_783_900_800_000L,
rrule = "FREQ=YEARLY",
rdate = null,
duration = "P1D",
timezone = "UTC",
allDay = 1,
@@ -558,6 +584,439 @@ class EventWriteMapperTest {
assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(1)
}
@Test
fun `an exdate list zoned the way the provider writes it is read in that zone`() {
// AOSP's RecurrenceSet keeps the TZID parameter's *value*, not the
// parameter: `Europe/Berlin;<stamps>`, no `TZID=`. Matching only the
// iCalendar spelling read every list the provider itself wrote as if its
// floating stamps were UTC — hours off for any zone but UTC.
assertThat(exdateListZone("Europe/Berlin;20260708T100000", "UTC"))
.isEqualTo(ZoneId.of("Europe/Berlin"))
assertThat(exdateListZone("TZID=Europe/Berlin;20260708T100000", "UTC"))
.isEqualTo(ZoneId.of("Europe/Berlin"))
// No prefix, or one this device can't place: the row's own zone.
assertThat(exdateListZone("20260708T100000", "Europe/Berlin"))
.isEqualTo(ZoneId.of("Europe/Berlin"))
assertThat(exdateListZone("W. Europe Standard Time;20260708T100000", "Europe/Berlin"))
.isEqualTo(ZoneId.of("Europe/Berlin"))
}
@Test
fun `a bare-zoned exclusion list splits on the instants its prefix names`() {
assertThat(
exdateAfter(
"Europe/Berlin;20260708T100000,20260729T100000",
instantAt("2026-07-15T08:00", "UTC"),
isAllDay = false,
timezone = null,
),
).isEqualTo("20260729T080000Z")
}
@Test
fun `an exdate drop keeps a list it cannot read whole, prefix and all`() {
// Stripping the prefix off a list and re-emitting the stamps it can't
// parse leaves them floating: "09:00 in Berlin" silently becomes 09:00 in
// whatever zone the next reader picks.
val values = buildOccurrenceExdateValues(
existingExdate = "Europe/Berlin;20260722T090000",
occurrenceMillis = 1_784_073_600_000L, // 2026-07-15T00:00:00Z
dtStartMillis = 1_783_900_800_000L,
rrule = "FREQ=WEEKLY",
rdate = null,
duration = "P1D",
timezone = "Europe/Berlin",
allDay = 1,
)
assertThat(values[CalendarContract.Events.EXDATE])
.isEqualTo("Europe/Berlin;20260722T090000,20260715")
}
@Test
fun `a new exclusion joins an unreadable timed list in that list's own form`() {
val values = buildOccurrenceExdateValues(
existingExdate = "Europe/Berlin;sometime next Tuesday",
occurrenceMillis = 1_784_102_400_000L, // 2026-07-15T08:00:00Z == 10:00 Berlin
dtStartMillis = 1_783_929_600_000L,
rrule = "FREQ=DAILY",
rdate = null,
duration = "PT1H",
timezone = "Europe/Berlin",
allDay = 0,
)
assertThat(values[CalendarContract.Events.EXDATE])
.isEqualTo("Europe/Berlin;sometime next Tuesday,20260715T100000")
}
@Test
fun `an exdate drop rewrites RDATE along with the rule`() {
// A series may be a list of dates with no rule at all. Left out of the
// rewrite, the provider has only DTSTART to re-expand from and collapses
// the series to its first occurrence.
val values = buildOccurrenceExdateValues(
existingExdate = null,
occurrenceMillis = 1_784_102_400_000L,
dtStartMillis = 1_783_929_600_000L,
rrule = null,
rdate = "20260716T080000Z,20260722T080000Z",
duration = "PT1H",
timezone = "Europe/Berlin",
allDay = 0,
)
assertThat(values[CalendarContract.Events.RDATE])
.isEqualTo("20260716T080000Z,20260722T080000Z")
assertThat(values[CalendarContract.Events.RRULE]).isNull()
}
// --- EXDATE follows the series when its times change (Codeberg #248) ---
/** A weekly series whose displayed occurrence runs 15 July 2026, 09: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 `an all-day exclusion a sync adapter padded still moves with the series`() {
// The bug: only the bare yyyyMMdd form parsed, so a padded midnight stamp
// bailed the whole list and buildEventUpdateValues wrote no EXDATE at all
// — the deleted occurrence came back the moment the series was re-timed.
val series = instantAt("2026-07-01T00:00", "UTC")
val original = form(
isAllDay = true,
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
).copy(rrule = "FREQ=WEEKLY")
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
)
val values = update(original, moved, series, "20260722T000000Z")
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260724")
}
@Test
fun `an all-day exclusion padded without a Z is read the same way`() {
// DAVx5 and AOSP's own RecurrenceSet write a floating midnight for a
// date value. Only the trailing-Z spelling parsed, so the floating one
// bailed the whole list and the deleted occurrence came back.
val series = instantAt("2026-07-01T00:00", "UTC")
val original = form(
isAllDay = true,
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
).copy(rrule = "FREQ=WEEKLY")
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
)
val values = update(original, moved, series, "20260722T000000")
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260724")
}
@Test
fun `a padded all-day stamp naming any other time is still left alone`() {
// Not a form an all-day series is written in, so it could be naming
// something else entirely — guessing could exclude the wrong occurrence.
val series = instantAt("2026-07-01T00:00", "UTC")
val original = form(
isAllDay = true,
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
).copy(rrule = "FREQ=WEEKLY")
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)),
)
val values = update(original, moved, series, "20260722T090000Z")
assertThat(values).doesNotContainKey(CalendarContract.Events.EXDATE)
}
@Test
fun `switching a series to all-day rewrites its exclusions as dates`() {
// The two forms aren't interchangeable: a date-time stamp on an all-day
// series matches no occurrence, so the exclusion would be lost.
val series = instantAt("2026-07-01T09:00", "Europe/Berlin")
val original = julySeries()
val values = update(original, original.copy(isAllDay = true), series, "20260722T070000Z")
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260722")
}
@Test
fun `switching a series back to timed rewrites its exclusions as instants`() {
val series = instantAt("2026-07-01T00:00", "UTC")
val original = form(
isAllDay = true,
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)),
).copy(rrule = "FREQ=WEEKLY")
val timed = original.copy(
isAllDay = false,
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
)
// The excluded day gains the new 09:00 Berlin time-of-day == 07:00Z.
assertThat(update(original, timed, series, "20260722")[CalendarContract.Events.EXDATE])
.isEqualTo("20260722T070000Z")
}
@Test
fun `a text-only edit leaves the exclusions alone`() {
val original = julySeries()
val values = update(original, original.copy(title = "Renamed"), exdate = "20260722T070000Z")
assertThat(values).doesNotContainKey(CalendarContract.Events.EXDATE)
}
@Test
fun `changing only the rule rewrites no exclusions`() {
// The times are untouched, so every surviving occurrence keeps its instant
// and the stamps still name the right ones.
val original = julySeries()
val values = update(original, original.copy(rrule = "FREQ=DAILY"), exdate = "20260722T070000Z")
assertThat(values[CalendarContract.Events.RRULE]).isEqualTo("FREQ=DAILY")
assertThat(values).doesNotContainKey(CalendarContract.Events.EXDATE)
}
@Test
fun `a zoned exdate list is read in the zone its prefix names`() {
// AOSP's RecurrenceSet form, TZID=<zone>;<floating stamps>. Read as UTC the
// stamps name the wrong instants; skipped, every exclusion of the series is
// dropped the moment it is re-timed.
val original = julySeries()
val values = update(
original,
original.atHour(10),
instantAt("2026-07-01T09:00", "Europe/Berlin"),
"TZID=Europe/Berlin;20260722T090000",
)
// 22 July 09:00 Berlin (CEST, +2) == 07:00Z; the +1h edit puts it at 08:00Z.
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260722T080000Z")
}
@Test
fun `a zoned list whose zone this device cannot place falls back to the event's`() {
// Exchange spells zones its own way, so ZoneId.of refuses the name. The
// series' own zone is what such a list is written in anyway.
val original = julySeries()
val values = update(
original,
original.atHour(10),
instantAt("2026-07-01T09:00", "Europe/Berlin"),
"TZID=W. Europe Standard Time;20260722T090000",
)
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260722T080000Z")
}
@Test
fun `an exdate form we do not write is left untouched`() {
// A stale stamp excludes nothing; a mangled one could exclude the wrong
// occurrence, so anything unreadable is left exactly as it stands.
val original = julySeries()
val values = update(
original,
original.atHour(10),
instantAt("2026-07-01T09:00", "Europe/Berlin"),
"sometime next Tuesday",
)
assertThat(values).doesNotContainKey(CalendarContract.Events.EXDATE)
}
@Test
fun `removing the recurrence clears the exclusions with it`() {
// Dormant, not harmless: adding a recurrence back later would punch the old
// holes into the new one.
val original = julySeries()
val values = update(original, original.copy(rrule = null), exdate = "20260722T070000Z")
assertThat(values).containsEntry(CalendarContract.Events.EXDATE, null)
}
@Test
fun `undoing a series move lands the exclusions back where they started`() {
val series = instantAt("2026-01-07T09:00", "Europe/Berlin")
val original = julySeries()
val moved = original.copy(
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(14, 30)),
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(15, 30)),
)
val exdate = "20260722T070000Z"
val forward = update(original, moved, series, exdate)
val movedExdate = forward[CalendarContract.Events.EXDATE] as String
assertThat(movedExdate).isNotEqualTo(exdate)
val back = update(
moved,
original,
forward[CalendarContract.Events.DTSTART] as Long,
movedExdate,
)
assertThat(back[CalendarContract.Events.EXDATE]).isEqualTo(exdate)
}
@Test
fun `a series with no exclusions writes no exdate column`() {
val series = instantAt("2026-07-01T09:00", "Europe/Berlin")
val original = julySeries()
assertThat(update(original, original.atHour(10), series, exdate = null))
.doesNotContainKey(CalendarContract.Events.EXDATE)
}
// --- exdateAfter (the exclusions a "this and following" split inherits) ---
@Test
fun `a split carries only the exclusions past the split point`() {
// The occurrence at the split point is the one being edited, so it exists
// by definition — a stale exclusion for it would swallow the edit whole.
assertThat(
exdateAfter(
existingExdate = "20260708T080000Z,20260715T080000Z,20260722T080000Z",
beginMillis = instantAt("2026-07-15T08:00", "UTC"),
isAllDay = false,
timezone = null,
),
).isEqualTo("20260722T080000Z")
}
@Test
fun `a split carries nothing when every exclusion is behind it`() {
assertThat(
exdateAfter(
"20260708T080000Z",
instantAt("2026-07-15T08:00", "UTC"),
isAllDay = false,
timezone = null,
),
).isNull()
assertThat(exdateAfter(null, 0L, isAllDay = false, timezone = null)).isNull()
}
@Test
fun `all-day exclusions split on their UTC date`() {
assertThat(
exdateAfter(
"20260708,20260722",
instantAt("2026-07-15T00:00", "UTC"),
isAllDay = true,
timezone = null,
),
).isEqualTo("20260722")
}
@Test
fun `an unreadable exclusion carries nothing across a split`() {
assertThat(
exdateAfter(
"20260722T080000Z,sometime next Tuesday",
instantAt("2026-07-15T08:00", "UTC"),
isAllDay = false,
timezone = null,
),
).isNull()
}
@Test
fun `a zoned exclusion list splits on the instants its prefix names`() {
// Canonicalised on the way out: the prefix that explained how to read the
// stamps doesn't survive the filter, so the survivors can't stay floating.
assertThat(
exdateAfter(
"TZID=Europe/Berlin;20260708T100000,20260729T100000",
instantAt("2026-07-15T08:00", "UTC"),
isAllDay = false,
timezone = null,
),
).isEqualTo("20260729T080000Z")
}
@Test
fun `split exclusions move by the same shift as the new series start`() {
// What the split path composes: filter to the tail, then re-time it by the
// shift the user applied to the occurrence they split at.
val original = julySeries()
val carried = shiftedExdate(
existingExdate = exdateAfter(
"20260716T070000Z",
instantAt("2026-07-15T07:00", "UTC"),
isAllDay = false,
timezone = null,
),
original = original,
updated = original.atHour(11),
zone = berlin,
)
// 16 July 09:00 Berlin, pushed two hours, is 11:00 Berlin == 09:00Z.
assertThat(carried).isEqualTo("20260716T090000Z")
}
@Test
fun `nothing to shift yields no exdate`() {
assertThat(shiftedExdate(null, julySeries(), julySeries().atHour(10), berlin)).isNull()
assertThat(shiftedExdate(" ", julySeries(), julySeries().atHour(10), berlin)).isNull()
}
// --- per-event colour ---
@Test
@@ -746,4 +1205,173 @@ class EventWriteMapperTest {
val values = buildCopiedExceptionValues(exceptionSnapshot(duration = "P900S", dtEndMillis = null))
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P900S")
}
@Test
fun `a detached all-day occurrence matches its date-only exdate stamp`() {
val parent = buildOccurrenceExdateValues(
existingExdate = null,
occurrenceMillis = 1_781_136_000_000L, // 2026-06-11T00:00:00Z
dtStartMillis = 1_749_600_000_000L,
rrule = "FREQ=YEARLY",
rdate = null,
duration = "P1D",
timezone = "UTC",
allDay = 1,
)
val detached = form(
isAllDay = true,
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
).copy(rrule = "FREQ=YEARLY").toDetachedOccurrence()
val inserted = buildEventInsertValues(
form = detached,
uid = "uid@calendula",
times = detached.toWriteTimes(berlin),
)
assertThat(parent[CalendarContract.Events.EXDATE]).isEqualTo("20260611")
assertThat(inserted[CalendarContract.Events.DTSTART]).isEqualTo(1_781_136_000_000L)
}
@Test
fun `a detached all-day occurrence stays on UTC midnights`() {
val edited = form(
isAllDay = true,
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
).copy(title = "Birthday", rrule = "FREQ=YEARLY")
val detached = edited.toDetachedOccurrence()
val values = buildEventInsertValues(
form = detached,
uid = "uid@calendula",
times = detached.toWriteTimes(berlin),
)
assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(1)
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("UTC")
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_136_000_000L)
// Exclusive DTEND — the next UTC midnight.
assertThat(values[CalendarContract.Events.DTEND]).isEqualTo(1_781_222_400_000L)
assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE)
}
@Test
fun `a detached occurrence carries every edited field onto the new row`() {
// Built from the form, not cloned from the parent: a field dropped here
// is an edit silently lost.
val edited = form(timezone = "America/New_York").copy(
title = " Standup ",
location = "Room 2",
description = "notes",
reminders = listOf(10),
availability = Availability.Free,
accessLevel = AccessLevel.Private,
rrule = "FREQ=DAILY",
)
val detached = edited.toDetachedOccurrence()
val values = buildEventInsertValues(
form = detached,
uid = "uid@calendula",
times = detached.toWriteTimes(berlin),
)
assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Standup")
assertThat(values[CalendarContract.Events.EVENT_LOCATION]).isEqualTo("Room 2")
assertThat(values[CalendarContract.Events.DESCRIPTION]).isEqualTo("notes")
assertThat(values[CalendarContract.Events.AVAILABILITY])
.isEqualTo(CalendarContract.Events.AVAILABILITY_FREE)
assertThat(values[CalendarContract.Events.ACCESS_LEVEL])
.isEqualTo(CalendarContract.Events.ACCESS_PRIVATE)
// The pinned zone survives — never re-anchored to the device.
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("America/New_York")
assertThat(values[CalendarContract.Events.UID_2445]).isEqualTo("uid@calendula")
// Reminders aren't columns; the insert path seeds them from the form.
assertThat(detached.reminders).containsExactly(10)
}
@Test
fun `a detached occurrence drops the series rule and becomes a one-off row`() {
val edited = form().copy(title = "Moved", rrule = "FREQ=WEEKLY;BYDAY=TH")
val detached = edited.toDetachedOccurrence()
val values = buildEventInsertValues(
form = detached,
uid = "uid@calendula",
times = detached.toWriteTimes(berlin),
)
assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Moved")
// A surviving rule would insert a second *series* overlapping the first
// (Codeberg #234's stray duplicate).
assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE)
assertThat(values).doesNotContainKey(CalendarContract.Events.DURATION)
// A one-off row carries DTEND rather than a duration.
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L)
assertThat(values[CalendarContract.Events.DTEND]).isEqualTo(1_781_170_200_000L)
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin")
}
@Test
fun `an exclusion is found anywhere in a multi-entry exdate list`() {
// Whitespace after a comma is legal in the stored column.
assertThat(
exdateContains(
"20260604T080000Z, 20260611T080000Z,20260618T080000Z",
1_781_164_800_000L,
isAllDay = false,
timezone = null,
),
).isTrue()
}
@Test
fun `an occurrence already excluded is recognised, timed and all-day`() {
// Detaching twice would leave a second standalone copy.
assertThat(
exdateContains("20260611T080000Z", 1_781_164_800_000L, isAllDay = false, timezone = null),
).isTrue()
assertThat(
exdateContains("20260611", 1_781_136_000_000L, isAllDay = true, timezone = null),
).isTrue()
}
@Test
fun `an occurrence not in the exdate list is not mistaken for an excluded one`() {
assertThat(exdateContains(null, 1_781_164_800_000L, isAllDay = false, timezone = null))
.isFalse()
assertThat(exdateContains("", 1_781_164_800_000L, isAllDay = false, timezone = null))
.isFalse()
// A neighbouring occurrence must not match — the guard is per-instant.
assertThat(
exdateContains("20260610T080000Z", 1_781_164_800_000L, isAllDay = false, timezone = null),
).isFalse()
}
@Test
fun `the detached row lands exactly where the parent's exdate removes it`() {
// The two halves must agree on the instant, or the user sees the
// occurrence twice or not at all.
val edited = form().copy(title = "Renamed", rrule = "FREQ=WEEKLY")
val occurrenceMillis = 1_781_164_800_000L
val parent = buildOccurrenceExdateValues(
existingExdate = null,
occurrenceMillis = occurrenceMillis,
dtStartMillis = 1_780_560_000_000L,
rrule = "FREQ=WEEKLY",
rdate = null,
duration = "P5400S",
timezone = "Europe/Berlin",
allDay = 0,
)
val detached = edited.toDetachedOccurrence()
val inserted = buildEventInsertValues(
form = detached,
uid = "uid@calendula",
times = detached.toWriteTimes(berlin),
)
assertThat(parent[CalendarContract.Events.EXDATE]).isEqualTo("20260611T080000Z")
assertThat(inserted[CalendarContract.Events.DTSTART]).isEqualTo(occurrenceMillis)
// The parent keeps its own anchor and rule — only this occurrence leaves.
assertThat(parent[CalendarContract.Events.DTSTART]).isEqualTo(1_780_560_000_000L)
assertThat(parent[CalendarContract.Events.RRULE]).isEqualTo("FREQ=WEEKLY")
}
}

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,25 @@ internal class FakeCalendarDataSource : CalendarDataSource {
/** (event, targetCalendarId) pairs passed to [insertImportedEvent]. */
val importedEvents = mutableListOf<Pair<ParsedIcsEvent, Long>>()
override fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long {
writeError?.let { throw it }
/** Thrown instead of [writeError] for events whose summary is in this set. */
val failingImportSummaries = mutableSetOf<String>()
/** Imports let through before [writeError] applies, for part-way failures. */
var importsBeforeError: Int = 0
private var imports = 0
/** The [colorPalette] the last [insertImportedEvent] call received. */
var lastImportPalette: List<EventColorOption> = emptyList()
override fun insertImportedEvent(
event: ParsedIcsEvent,
calendarId: Long,
allDayReminderTimeMinutes: Int,
colorPalette: List<EventColorOption>,
): Long {
lastImportPalette = colorPalette
if (imports++ >= importsBeforeError) writeError?.let { throw it }
if (event.summary in failingImportSummaries) error("rejected: ${event.summary}")
importedEvents += event to calendarId
return nextInsertId
}
@@ -173,6 +196,7 @@ internal class FakeCalendarDataSource : CalendarDataSource {
override fun updateOccurrence(
eventId: Long,
beginMillis: Long,
original: EventForm,
form: EventForm,
allDayReminderTimeMinutes: Int,
): Long {

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,224 @@ class IcsExportMapperTest {
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
)
assertThat(reader.toIcsEvent(emptyList(), null).isAllDay).isTrue()
assertThat(reader.toIcsEvent(emptyList(), null, NINE_AM).isAllDay).isTrue()
}
@Test
fun `an all-day reminder is exported as its whole-day lead time`() {
// The raw offset encodes the firing time and is normally negative; left
// raw it would be written as a trigger after the event and dropped, so
// the backup would come back with no reminder at all.
val reader = MapColumnReader(
EventExportProjection.IDX_ID to 1L,
EventExportProjection.IDX_TITLE to "Anna Birthday",
EventExportProjection.IDX_DTSTART to 1_782_864_000_000L, // 2026-07-01 UTC, CEST
EventExportProjection.IDX_DTEND to 1_782_950_400_000L,
EventExportProjection.IDX_ALL_DAY to 1,
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
)
val onTheDay = reader.toIcsEvent(listOf(-420), null, NINE_AM, BERLIN)
val dayBefore = reader.toIcsEvent(listOf(1_020), null, NINE_AM, BERLIN)
assertThat(onTheDay.reminderMinutes).containsExactly(0)
assertThat(dayBefore.reminderMinutes).containsExactly(1_440)
}
private companion object {
const val NINE_AM = 9 * 60
val BERLIN: ZoneId = ZoneId.of("Europe/Berlin")
}
@Test
fun `a recurring row carries its EXDATE exclusions`() {
val reader = MapColumnReader(
EventExportProjection.IDX_ID to 7L,
EventExportProjection.IDX_TITLE to "Weekly",
EventExportProjection.IDX_DTSTART to 1_000_000L,
EventExportProjection.IDX_DURATION to "P3600S",
EventExportProjection.IDX_ALL_DAY to 0,
EventExportProjection.IDX_RRULE to "FREQ=WEEKLY",
EventExportProjection.IDX_EXDATE to "20260901T080000Z,20260908T080000Z",
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
)
val event = reader.toIcsEvent(
reminderMinutes = emptyList(),
calendarName = null,
allDayReminderTimeMinutes = NINE_AM,
)
assertThat(event.exDates)
.containsExactly("20260901T080000Z", "20260908T080000Z").inOrder()
}
@Test
fun `an all-day EXDATE keeps only the day, however the adapter padded it`() {
val reader = MapColumnReader(
EventExportProjection.IDX_ID to 8L,
EventExportProjection.IDX_TITLE to "Holiday",
EventExportProjection.IDX_DTSTART to 0L,
EventExportProjection.IDX_DURATION to "P1D",
EventExportProjection.IDX_ALL_DAY to 1,
EventExportProjection.IDX_RRULE to "FREQ=YEARLY",
EventExportProjection.IDX_EXDATE to "20260901,20270901T000000Z",
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
)
val event = reader.toIcsEvent(
reminderMinutes = emptyList(),
calendarName = null,
allDayReminderTimeMinutes = NINE_AM,
)
assertThat(event.exDates).containsExactly("20260901", "20270901").inOrder()
}
@Test
fun `a TZID-prefixed EXDATE is resolved to UTC, not handed on verbatim`() {
// The provider stores a zoned exclusion as AOSP RecurrenceSet does. Passed
// through, the writer would emit `EXDATE:TZID=Europe/Berlin;2026...` — the
// parameter belongs before the colon, so the line is unreadable.
val reader = MapColumnReader(
EventExportProjection.IDX_ID to 11L,
EventExportProjection.IDX_TITLE to "Weekly",
EventExportProjection.IDX_DTSTART to 1_000_000L,
EventExportProjection.IDX_DURATION to "P3600S",
EventExportProjection.IDX_ALL_DAY to 0,
EventExportProjection.IDX_RRULE to "FREQ=WEEKLY",
EventExportProjection.IDX_EXDATE to
"TZID=Europe/Berlin;20260722T090000,20260729T090000",
EventExportProjection.IDX_EVENT_TIMEZONE to "Europe/Berlin",
)
val event = reader.toIcsEvent(
reminderMinutes = emptyList(),
calendarName = null,
allDayReminderTimeMinutes = NINE_AM,
)
// 09:00 Berlin in July is CEST (+2) == 07:00Z.
assertThat(event.exDates)
.containsExactly("20260722T070000Z", "20260729T070000Z").inOrder()
}
@Test
fun `a zoned EXDATE in the provider's own bare form resolves in that zone`() {
// AOSP's RecurrenceSet stores the TZID parameter's value alone. Read as
// if it were unzoned, 09:00 Berlin exported as 09:00Z — two hours out,
// so the exclusion no longer matched its occurrence in the file.
val reader = MapColumnReader(
EventExportProjection.IDX_ID to 14L,
EventExportProjection.IDX_TITLE to "Weekly",
EventExportProjection.IDX_DTSTART to 1_000_000L,
EventExportProjection.IDX_DURATION to "P3600S",
EventExportProjection.IDX_ALL_DAY to 0,
EventExportProjection.IDX_RRULE to "FREQ=WEEKLY",
EventExportProjection.IDX_EXDATE to "Europe/Berlin;20260722T090000",
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
)
val event = reader.toIcsEvent(
reminderMinutes = emptyList(),
calendarName = null,
allDayReminderTimeMinutes = NINE_AM,
)
assertThat(event.exDates).containsExactly("20260722T070000Z")
}
@Test
fun `a TZID this device cannot place falls back to the row's own zone`() {
// Exchange spells zones its own way, so ZoneId.of refuses the name.
// Stripping the prefix and emitting the stamp as it stands would export a
// floating time against a `DTSTART;TZID=…` line — every reader in another
// zone resolves it to another instant and the exclusion stops matching.
val reader = MapColumnReader(
EventExportProjection.IDX_ID to 13L,
EventExportProjection.IDX_TITLE to "Weekly",
EventExportProjection.IDX_DTSTART to 1_000_000L,
EventExportProjection.IDX_DURATION to "P3600S",
EventExportProjection.IDX_ALL_DAY to 0,
EventExportProjection.IDX_RRULE to "FREQ=WEEKLY",
EventExportProjection.IDX_EXDATE to
"TZID=W. Europe Standard Time;20260722T090000",
EventExportProjection.IDX_EVENT_TIMEZONE to "Europe/Berlin",
)
val event = reader.toIcsEvent(
reminderMinutes = emptyList(),
calendarName = null,
allDayReminderTimeMinutes = NINE_AM,
)
assertThat(event.exDates).containsExactly("20260722T070000Z")
}
@Test
fun `an all-day EXDATE loses a TZID prefix along with its time`() {
val reader = MapColumnReader(
EventExportProjection.IDX_ID to 12L,
EventExportProjection.IDX_TITLE to "Holiday",
EventExportProjection.IDX_DTSTART to 0L,
EventExportProjection.IDX_DURATION to "P1D",
EventExportProjection.IDX_ALL_DAY to 1,
EventExportProjection.IDX_RRULE to "FREQ=YEARLY",
EventExportProjection.IDX_EXDATE to "TZID=Europe/Berlin;20260901T000000",
EventExportProjection.IDX_EVENT_TIMEZONE to "Europe/Berlin",
)
val event = reader.toIcsEvent(
reminderMinutes = emptyList(),
calendarName = null,
allDayReminderTimeMinutes = NINE_AM,
)
assertThat(event.exDates).containsExactly("20260901")
}
@Test
fun `a default-lead all-day reminder is not decoded into an at-start alarm`() {
// MINUTES_DEFAULT (-1) says "use the account default", not "0 minutes
// before". Decoded, it landed on the start date and became a real alarm
// that IcsWriter no longer dropped.
val reader = MapColumnReader(
EventExportProjection.IDX_ID to 13L,
EventExportProjection.IDX_TITLE to "Birthday",
EventExportProjection.IDX_DTSTART to 0L,
EventExportProjection.IDX_DURATION to "P1D",
EventExportProjection.IDX_ALL_DAY to 1,
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
)
val event = reader.toIcsEvent(
reminderMinutes = listOf(-1),
calendarName = null,
allDayReminderTimeMinutes = NINE_AM,
zone = BERLIN,
)
assertThat(event.reminderMinutes).containsExactly(-1)
}
@Test
fun `a one-off row exports no exclusions even if the column is set`() {
val reader = MapColumnReader(
EventExportProjection.IDX_ID to 9L,
EventExportProjection.IDX_TITLE to "Standup",
EventExportProjection.IDX_DTSTART to 1_000_000L,
EventExportProjection.IDX_DTEND to 1_900_000L,
EventExportProjection.IDX_ALL_DAY to 0,
EventExportProjection.IDX_EXDATE to "20260901T080000Z",
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
)
val event = reader.toIcsEvent(
reminderMinutes = emptyList(),
calendarName = null,
allDayReminderTimeMinutes = NINE_AM,
)
assertThat(event.exDates).isEmpty()
}
}

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

@@ -0,0 +1,33 @@
package de.jeanlucmakiola.calendula.data.calendar
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
/**
* The provider's "use the account default" sentinel is translated out here, so
* the pure planner only ever sees offsets it can resolve (#75).
*/
class ProviderReminderMinutesTest {
@Test
fun `the account-default sentinel is dropped`() {
// Taken as an offset it armed an alarm a minute *after* the event began,
// while the detail screen said "Default reminder" for the same row.
assertThat(mapOf(1L to listOf(-1)).withoutProviderDefaults())
.containsExactly(1L, emptyList<Int>())
}
@Test
fun `a real reminder beside a default one survives`() {
assertThat(mapOf(1L to listOf(-1, 10)).withoutProviderDefaults())
.containsExactly(1L, listOf(10))
}
@Test
fun `an all-day encoding that is negative is not mistaken for the sentinel`() {
// An all-day offset carries the firing time of day, so it is routinely
// negative; a `< 0` test would silence reminders that do fire.
assertThat(mapOf(1L to listOf(-420, -2_000)).withoutProviderDefaults())
.containsExactly(1L, listOf(-420, -2_000))
}
}

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,607 @@
package de.jeanlucmakiola.calendula.domain.ics
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.TimeZone
import org.junit.jupiter.api.Test
import kotlin.time.Instant
/**
* Reading the Simple Calendar / Fossify export dialect (Codeberg #225).
*
* Every fixture is shaped as `IcsExporter.writeEvent` emits it — property order,
* the `X-FOSSIFY-*` extensions and the `P0DT1H5M0S` duration spelling included.
*
* Their all-day `DTEND` is *usually* RFC-correct and must not be "corrected":
* `Event.endTS` anchors a UI-created all-day event at noon of its last day
* (`EventActivity.getStartEndTimes`), or at the exclusive midnight for a
* CalDAV-sourced one, and the exporter's `+ TWELVE_HOURS` rounds either to the
* following midnight. Shifting those by a day would corrupt them.
*
* The exception — and the whole of #225 — is events mirrored from Contacts.
* `MainActivity` builds those with `startTS = endTS = timestamp`, so `+ 12h`
* lands back on the starting day and they export zero length.
*/
class IcsFossifyImportTest {
private val parser = IcsParser(TimeZone.of("Europe/Berlin"))
private fun fossify(vararg body: String) = (
listOf(
"BEGIN:VCALENDAR",
"PRODID:-//Fossify//NONSGML Event Calendar//EN",
"VERSION:2.0",
) + body + listOf("END:VCALENDAR")
).joinToString("\r\n")
private fun days(event: ParsedIcsEvent): Long =
(event.end - event.start).inWholeMilliseconds / 86_400_000L
@Test
fun `an all-day event keeps the exact span the file gives it`() {
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Anna Birthday",
"UID:abc123",
"X-FOSSIFY-CATEGORY-COLOR:-1155931",
"DTSTART;VALUE=DATE:19900412",
"DTEND;VALUE=DATE:19900413",
"X-FOSSIFY-MISSING-YEAR:0",
"RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=4",
"END:VEVENT",
),
)
val event = result.events.single()
assertThat(event.isAllDay).isTrue()
assertThat(days(event)).isEqualTo(1)
}
@Test
fun `contact-mirrored birthdays export zero length and must not vanish`() {
// The reported failure (#225). Fossify mirrors Contacts birthdays and
// anniversaries with startTS == endTS, so the exporter's +12h rounds
// back to the starting day: DTSTART == DTEND. Read literally these are
// yearly series of zero-length occurrences, which the provider expands
// into nothing at all — the birthdays import "successfully" and are
// then nowhere to be seen.
val text = checkNotNull(
javaClass.classLoader?.getResourceAsStream("ics/fossify-contact-birthdays.ics"),
).use { it.readBytes().toString(Charsets.UTF_8) }
val result = parser.parse(text)
assertThat(result.events).hasSize(3)
result.events.forEach {
assertThat(it.isAllDay).isTrue()
assertThat(days(it)).isEqualTo(1)
assertThat(it.recurrenceRule).startsWith("FREQ=YEARLY")
// Their all-day reminder encoding: "on the day", not midnight UTC.
assertThat(it.semanticReminderMinutes()).containsExactly(0)
}
}
@Test
fun `a real Fossify holiday file imports unchanged`() {
// Shipped inside Fossify Calendar (assets/holidays/AT/public.ics). Its
// PRODID names Fossify, so any producer-sniffing shortcut would corrupt
// it — the file is plain, conformant iCalendar.
val text = checkNotNull(
javaClass.classLoader?.getResourceAsStream("ics/fossify-holidays-at.ics"),
).use { it.readBytes().toString(Charsets.UTF_8) }
val result = parser.parse(text)
assertThat(result.events.map { it.summary })
.containsExactly("Neujahr", "Heilige Drei Könige").inOrder()
assertThat(result.events.map { days(it) }).containsExactly(1L, 1L)
assertThat(result.events.map { it.recurrenceRule }).containsExactly("FREQ=YEARLY", "FREQ=YEARLY")
}
@Test
fun `an all-day event with no DTEND lasts one day`() {
// RFC 5545 3.6.1, and the span the provider needs to expand a series.
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Holiday",
"DTSTART;VALUE=DATE:20260819",
"END:VEVENT",
),
)
assertThat(days(result.events.single())).isEqualTo(1)
}
@Test
fun `a zero-length all-day event is widened rather than left to vanish`() {
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Holiday",
"DTSTART;VALUE=DATE:20260819",
"DTEND;VALUE=DATE:20260819",
"END:VEVENT",
),
)
assertThat(days(result.events.single())).isEqualTo(1)
}
@Test
fun `a malformed repeat rule is repaired rather than passed to the provider`() {
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Standup",
"DTSTART:20260819T080000Z",
"DTEND:20260819T081500Z",
"RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=",
"END:VEVENT",
),
)
assertThat(result.events.single().recurrenceRule).isEqualTo("FREQ=WEEKLY;INTERVAL=1")
assertThat(result.warnings).contains(IcsParseWarning.RecurrenceRuleRepaired)
}
@Test
fun `a task is imported as an event and reported`() {
val result = parser.parse(
fossify(
"BEGIN:VTODO",
"SUMMARY:Pay rent",
"DTSTART;VALUE=DATE:20260901",
"END:VTODO",
),
)
val task = result.events.single()
assertThat(task.isTask).isTrue()
assertThat(task.summary).isEqualTo("Pay rent")
assertThat(days(task)).isEqualTo(1)
assertThat(result.warnings).contains(IcsParseWarning.TasksImportedAsEvents)
}
@Test
fun `a task dated only by DUE still imports`() {
val result = parser.parse(
fossify(
"BEGIN:VTODO",
"SUMMARY:File taxes",
"DUE;VALUE=DATE:20260901",
"END:VTODO",
),
)
assertThat(result.events.single().summary).isEqualTo("File taxes")
}
@Test
fun `a task with both DTSTART and DUE spans between them`() {
val result = parser.parse(
fossify(
"BEGIN:VTODO",
"SUMMARY:Write report",
"DTSTART:20260901T090000Z",
"DUE:20260901T170000Z",
"END:VTODO",
),
)
val task = result.events.single()
assertThat(task.start).isEqualTo(Instant.parse("2026-09-01T09:00:00Z"))
assertThat(task.end).isEqualTo(Instant.parse("2026-09-01T17:00:00Z"))
}
@Test
fun `a DUE ahead of the DTSTART is still the end`() {
// Property order isn't guaranteed; DUE must not be taken for the start.
val result = parser.parse(
fossify(
"BEGIN:VTODO",
"SUMMARY:Write report",
"DUE:20260901T170000Z",
"DTSTART:20260901T090000Z",
"END:VTODO",
),
)
val task = result.events.single()
assertThat(task.start).isEqualTo(Instant.parse("2026-09-01T09:00:00Z"))
assertThat(task.end).isEqualTo(Instant.parse("2026-09-01T17:00:00Z"))
}
@Test
fun `the per-event colour wins over the calendar colour`() {
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Holiday",
"X-FOSSIFY-CATEGORY-COLOR:-1155931",
"COLOR:tomato",
"X-FOSSIFY-EVENT-COLOR:-2818048",
"DTSTART:20260819T080000Z",
"DTEND:20260819T090000Z",
"END:VEVENT",
),
)
assertThat(result.events.single().color).isEqualTo(-2818048)
}
@Test
fun `the calendar colour stands in when the event has none of its own`() {
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Holiday",
"X-FOSSIFY-CATEGORY-COLOR:-1155931",
"DTSTART:20260819T080000Z",
"DTEND:20260819T090000Z",
"END:VEVENT",
),
)
assertThat(result.events.single().color).isEqualTo(-1155931)
}
@Test
fun `the legacy Simple Calendar colour spellings are read too`() {
val result = parser.parse(
listOf(
"BEGIN:VCALENDAR",
"PRODID:-//Simple Mobile Tools//NONSGML Event Calendar//EN",
"BEGIN:VEVENT",
"SUMMARY:Holiday",
"X-SMT-CATEGORY-COLOR:-1155931",
"DTSTART:20260819T080000Z",
"DTEND:20260819T090000Z",
"END:VEVENT",
"END:VCALENDAR",
).joinToString("\r\n"),
)
assertThat(result.events.single().color).isEqualTo(-1155931)
}
@Test
fun `the literal null Fossify writes for a missing calendar is not a colour`() {
// Both properties interpolate a nullable calendar straight into the value.
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Holiday",
"X-FOSSIFY-CATEGORY-COLOR:null",
"CATEGORIES:null",
"DTSTART:20260819T080000Z",
"DTEND:20260819T090000Z",
"END:VEVENT",
),
)
val event = result.events.single()
assertThat(event.color).isNull()
assertThat(event.calendarName).isNull()
}
@Test
fun `CATEGORIES names the source calendar`() {
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Anna Birthday",
"CATEGORIES:Birthdays",
"DTSTART;VALUE=DATE:20260819",
"DTEND;VALUE=DATE:20260820",
"END:VEVENT",
),
)
assertThat(result.events.single().calendarName).isEqualTo("Birthdays")
}
@Test
fun `CATEGORIES does not outrank a property that names the calendar outright`() {
// Elsewhere CATEGORIES is a tag list, so it only stands in where nothing
// else named the calendar — X-WR-CALNAME and our own label win.
val result = IcsParser().parse(
listOf(
"BEGIN:VCALENDAR",
"X-WR-CALNAME:Work",
"BEGIN:VEVENT",
"SUMMARY:Standup",
"CATEGORIES:Holiday",
"DTSTART:20260819T080000Z",
"DTEND:20260819T090000Z",
"END:VEVENT",
"END:VCALENDAR",
).joinToString("\r\n"),
)
assertThat(result.events.single().calendarName).isEqualTo("Work")
}
@Test
fun `X-WR-CALNAME names the calendar wherever in the file it sits`() {
// Why the property is scanned up front instead of in document order: an
// exporter that writes it at the tail still names the calendar every
// event in the file came from.
val result = IcsParser().parse(
listOf(
"BEGIN:VCALENDAR",
"BEGIN:VEVENT",
"SUMMARY:Standup",
"DTSTART:20260819T080000Z",
"DTEND:20260819T090000Z",
"END:VEVENT",
"X-WR-CALNAME:Work",
"END:VCALENDAR",
).joinToString("\r\n"),
)
assertThat(result.events.single().calendarName).isEqualTo("Work")
}
@Test
fun `a multi-valued CATEGORIES is a tag list, not a calendar name`() {
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Standup",
"CATEGORIES:Meeting,Personal",
"DTSTART:20260819T080000Z",
"DTEND:20260819T090000Z",
"END:VEVENT",
),
)
assertThat(result.events.single().calendarName).isNull()
}
@Test
fun `a reminder written as a positive trigger is not read as a lead time`() {
// The exporter flips the sign for reminders stored below -1
// (`sign = if (reminder.minutes < -1) "" else "-"`), so a trigger can
// point after the start. On an all-day event that means a time of day.
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Anna Birthday",
"DTSTART;VALUE=DATE:20260819",
"DTEND;VALUE=DATE:20260820",
"BEGIN:VALARM",
"ACTION:DISPLAY",
"TRIGGER:P0DT9H0M0S",
"END:VALARM",
"END:VEVENT",
),
)
val event = result.events.single()
assertThat(event.reminderMinutes).containsExactly(-540)
assertThat(event.semanticReminderMinutes()).containsExactly(0)
}
@Test
fun `an all-day reminder whose firing time is after noon keeps its full day`() {
// The raw offset is `days * 1440 - timeOfDay`, so an 18:00 notification
// time puts "1 day before" only six hours ahead of the UTC midnight.
// Rounding to the nearest day would read that as "on the day".
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Anna Birthday",
"DTSTART;VALUE=DATE:20260819",
"DTEND;VALUE=DATE:20260820",
"BEGIN:VALARM",
"ACTION:DISPLAY",
"TRIGGER:-P0DT6H0M0S",
"END:VALARM",
"END:VEVENT",
),
)
assertThat(result.events.single().semanticReminderMinutes()).containsExactly(1440)
}
@Test
fun `an all-day reminder rounds to whole days before`() {
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Anna Birthday",
"DTSTART;VALUE=DATE:20260819",
"DTEND;VALUE=DATE:20260820",
"BEGIN:VALARM",
"ACTION:DISPLAY",
// 15h before midnight == "1 day before at 09:00".
"TRIGGER:-P0DT15H0M0S",
"END:VALARM",
"END:VEVENT",
),
)
assertThat(result.events.single().semanticReminderMinutes()).containsExactly(1440)
}
@Test
fun `a timed reminder keeps its exact lead time`() {
// Their P0DT1H5M0S duration spelling, from Parser.getDurationCode.
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Standup",
"DTSTART:20260819T080000Z",
"DTEND:20260819T081500Z",
"BEGIN:VALARM",
"ACTION:DISPLAY",
"TRIGGER:-P0DT0H10M0S",
"END:VALARM",
"END:VEVENT",
),
)
assertThat(result.events.single().semanticReminderMinutes()).containsExactly(10)
}
@Test
fun `a timed trigger that fires after the start is dropped, not clamped`() {
// A legal RFC 5545 follow-up alarm. Modelling it as a lead time of zero
// would invent a notification at the start the file never asked for.
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Standup",
"DTSTART:20260819T080000Z",
"DTEND:20260819T081500Z",
"BEGIN:VALARM",
"ACTION:DISPLAY",
"TRIGGER:PT30M",
"END:VALARM",
"END:VEVENT",
),
)
assertThat(result.events.single().semanticReminderMinutes()).isEmpty()
}
@Test
fun `an all-day series' EXDATE day codes carry over`() {
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Standup",
"DTSTART;VALUE=DATE:20260819",
"DTEND;VALUE=DATE:20260820",
"RRULE:FREQ=DAILY;INTERVAL=1",
"EXDATE:20260820",
"EXDATE:20260822",
"END:VEVENT",
),
)
assertThat(result.events.single().exDates).containsExactly("20260820", "20260822").inOrder()
}
@Test
fun `an all-day EXDATE keeps the day it spells out, whatever zone it carries`() {
// A midnight east of UTC resolves to an instant on the previous day;
// reading that back in UTC would exclude a day that isn't an occurrence
// and leave the one the user deleted in place.
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Standup",
"DTSTART;VALUE=DATE:20260819",
"DTEND;VALUE=DATE:20260820",
"RRULE:FREQ=DAILY",
"EXDATE;TZID=Europe/Berlin:20260821T000000",
"EXDATE;VALUE=DATE:20260823",
"END:VEVENT",
),
)
assertThat(result.events.single().exDates)
.containsExactly("20260821", "20260823").inOrder()
}
@Test
fun `a timed series' bare EXDATE day code is resolved against the series time`() {
// They store excluded occurrences as day codes and write them out that
// way even for a timed series, where the property alone is ambiguous.
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Gym",
"DTSTART:20260819T170000Z",
"DTEND:20260819T180000Z",
"RRULE:FREQ=DAILY;INTERVAL=1",
"EXDATE:20260820",
"END:VEVENT",
),
)
assertThat(result.events.single().exDates).containsExactly("20260820T170000Z")
}
@Test
fun `a floating EXDATE is read in the series' zone, not the device's`() {
// RFC 5545 ties EXDATE to DTSTART's form: with no Z and no TZID the
// value is New York wall time. Reading it in the device's Berlin zone
// would land on an instant no occurrence has, and the excluded
// occurrence would come back.
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Standup",
"DTSTART;TZID=America/New_York:20260819T100000",
"DTEND;TZID=America/New_York:20260819T103000",
"RRULE:FREQ=WEEKLY",
"EXDATE:20260826T100000",
"END:VEVENT",
),
)
assertThat(result.events.single().exDates).containsExactly("20260826T140000Z")
}
@Test
fun `EXDATE is dropped along with an unusable recurrence rule`() {
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Gym",
"DTSTART:20260819T170000Z",
"DTEND:20260819T180000Z",
"RRULE:INTERVAL=1",
"EXDATE:20260820",
"END:VEVENT",
),
)
val event = result.events.single()
assertThat(event.recurrenceRule).isNull()
assertThat(event.exDates).isEmpty()
}
@Test
fun `a whole export imports every component`() {
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Anna Birthday",
"UID:abc123",
"X-FOSSIFY-CATEGORY-COLOR:-1155931",
"CATEGORIES:Birthdays",
"LAST-MODIFIED:20260101T120000Z",
"TRANSP:TRANSPARENT",
"DTSTART;VALUE=DATE:19900412",
"DTEND;VALUE=DATE:19900413",
"X-FOSSIFY-MISSING-YEAR:0",
"DTSTAMP:20260819T100000Z",
"CLASS:PUBLIC",
"STATUS:CONFIRMED",
"RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=4",
"END:VEVENT",
"BEGIN:VEVENT",
"SUMMARY:Standup",
"UID:def456",
"DTSTART:20260819T080000Z",
"DTEND:20260819T081500Z",
"RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=TU",
"END:VEVENT",
"BEGIN:VTODO",
"SUMMARY:Pay rent",
"UID:ghi789",
"DTSTART;VALUE=DATE:20260901",
"END:VTODO",
),
)
assertThat(result.events.map { it.summary })
.containsExactly("Anna Birthday", "Standup", "Pay rent").inOrder()
// Nothing may reach the provider as a zero-length all-day row.
assertThat(result.events.none { it.isAllDay && days(it) == 0L }).isTrue()
}
}

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,28 @@
package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class AppBarSpacingTest {
@Test
fun `the pill's background ends one inset from the screen edge`() {
// The padding is applied inside M3's actions row, so it carries that
// row's own inset on top of whatever the pill asks for (#165).
val rendered = AppBarSpacing.BarPadding + AppBarSpacing.ContainerTrailingInset
assertThat(rendered).isEqualTo(AppBarSpacing.Inset)
}
@Test
fun `trailing insets stay non-negative`() {
// Modifier.padding throws on a negative value.
assertThat(AppBarSpacing.ContainerTrailingInset.value).isAtLeast(0f)
assertThat(AppBarSpacing.IconTrailingInset.value).isAtLeast(0f)
}
@Test
fun `an icon button's glyph ends further in than a container's edge`() {
assertThat(AppBarSpacing.IconTrailingInset.value)
.isGreaterThan(AppBarSpacing.Inset.value)
}
}

View File

@@ -0,0 +1,31 @@
package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class CalendarTitleButtonTest {
@Test
fun `keeps the full title when it fits`() {
assertThat(titleFor("September", "Sep", titleWidth = 200, availableWidth = 300))
.isEqualTo("September")
}
@Test
fun `keeps the full title when it fills the slot exactly`() {
assertThat(titleFor("September", "Sep", titleWidth = 300, availableWidth = 300))
.isEqualTo("September")
}
@Test
fun `falls back to the short title when the full one overflows`() {
assertThat(titleFor("September", "Sep", titleWidth = 400, availableWidth = 300))
.isEqualTo("Sep")
}
@Test
fun `a caller with nothing to shorten still gets its own title back`() {
assertThat(titleFor("Wed, 2 Sep", "Wed, 2 Sep", titleWidth = 400, availableWidth = 300))
.isEqualTo("Wed, 2 Sep")
}
}

View File

@@ -0,0 +1,40 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.ui.text.style.TextOverflow
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class EventTitleOverflowTest {
@Test
fun `a single LTR line clips, so the title runs to the chip's edge`() {
val result = eventTitleOverflowFor(rtl = false, singleLine = true)
assertThat(result.overflow).isEqualTo(TextOverflow.Clip)
// Load-bearing: with softWrap on, a clipped line breaks at the last
// whole word and shows less title than the ellipsis did (#164).
assertThat(result.softWrap).isFalse()
}
@Test
fun `RTL keeps the ellipsis, which truncates at the logical end`() {
// Clipping with softWrap off cuts at the node's left edge, which in RTL
// is the end of the string — the title would lose its beginning.
val result = eventTitleOverflowFor(rtl = true, singleLine = true)
assertThat(result.overflow).isEqualTo(TextOverflow.Ellipsis)
assertThat(result.softWrap).isTrue()
}
@Test
fun `a multi-line block keeps the ellipsis, since wrapping needs softWrap`() {
val result = eventTitleOverflowFor(rtl = false, singleLine = false)
assertThat(result.overflow).isEqualTo(TextOverflow.Ellipsis)
assertThat(result.softWrap).isTrue()
}
@Test
fun `multi-line in RTL keeps the ellipsis too`() {
val result = eventTitleOverflowFor(rtl = true, singleLine = false)
assertThat(result.overflow).isEqualTo(TextOverflow.Ellipsis)
assertThat(result.softWrap).isTrue()
}
}

View File

@@ -286,9 +286,53 @@ class RescheduleViewModelTest {
vm.move(toWednesday())
advanceUntilIdle()
val abandoned = vm.abandoned.value
assertThat(vm.undo(undo)).isFalse()
advanceUntilIdle()
assertThat(fake.updatedEvents).hasSize(1)
// Nothing ticks: the refusal started no journey of its own, and the
// counter is global — a tick here would release the copy the drop that
// *is* in flight is holding, before the grid has drawn it.
assertThat(vm.abandoned.value).isEqualTo(abandoned)
}
@Test
fun `an undo that fails lets the held chip go`(@TempDir tempDir: Path) = runTest(dispatcher) {
// undoStarted has already sent the chip back on its journey by the time
// the inverse write is attempted. Without a tick here nothing ever seats
// it, and the overlay waits out its whole settle timeout for a chip that
// is never coming.
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
val vm = viewModel(tempDir, fake)
vm.move(oneHourLater())
advanceUntilIdle()
val undo = (vm.outcome.value as MoveOutcome.Moved).undo!!
val abandoned = vm.abandoned.value
fake.writeError = SecurityException("permission revoked")
assertThat(vm.undo(undo)).isTrue()
advanceUntilIdle()
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.WriteDenied)
assertThat(vm.abandoned.value).isEqualTo(abandoned + 1)
}
@Test
fun `an undo that lands leaves the abandoned count alone`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
val vm = viewModel(tempDir, fake)
vm.move(oneHourLater())
advanceUntilIdle()
val undo = (vm.outcome.value as MoveOutcome.Moved).undo!!
val abandoned = vm.abandoned.value
vm.undo(undo)
advanceUntilIdle()
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.Undone)
assertThat(vm.abandoned.value).isEqualTo(abandoned)
}
@Test
@@ -494,4 +538,61 @@ class RescheduleViewModelTest {
assertThat(fake.updatedEvents).isEmpty()
assertThat(fake.updatedOccurrences).isEmpty()
}
@Test
fun `a drop that lands nothing says so, since move() already answered yes`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
val vm = viewModel(tempDir, fake)
// Refused in prepare: nowhere to move to.
assertThat(vm.move(
MoveRequest(
eventId = 42L,
beginMillis = beginMillis,
endMillis = endMillis,
target = MoveTarget.Start(Instant.fromEpochMilliseconds(beginMillis)),
),
)).isTrue()
advanceUntilIdle()
assertThat(vm.abandoned.value).isEqualTo(1)
// Refused by the provider, after the write was attempted.
fake.writeError = SecurityException("revoked")
vm.move(oneHourLater())
advanceUntilIdle()
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.WriteDenied)
assertThat(vm.abandoned.value).isEqualTo(2)
}
@Test
fun `a scope dialog dismissed leaves nothing waiting on the write`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
eventDetailResult = { detail(rrule = "FREQ=WEEKLY") }
}
val vm = viewModel(tempDir, fake)
vm.move(oneHourLater())
advanceUntilIdle()
assertThat(vm.abandoned.value).isEqualTo(0)
vm.cancelScope()
advanceUntilIdle()
assertThat(vm.abandoned.value).isEqualTo(1)
}
@Test
fun `a written move never counts as abandoned`(@TempDir tempDir: Path) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
val vm = viewModel(tempDir, fake)
vm.move(oneHourLater())
advanceUntilIdle()
assertThat(vm.outcome.value).isInstanceOf(MoveOutcome.Moved::class.java)
assertThat(vm.abandoned.value).isEqualTo(0)
}
}

View File

@@ -0,0 +1,209 @@
package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY
import de.jeanlucmakiola.calendula.ui.week.layoutDay
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atTime
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.ui.unit.dp
import org.junit.jupiter.api.Test
/**
* Which edges a timed block is cut on, and how much of a dragged piece its day
* can show (#253).
*/
class TimedBlockShapeTest {
private val zone = TimeZone.UTC
private val day1 = LocalDate(2026, 9, 5)
private val day2 = day1.plus(1, DateTimeUnit.DAY)
private val day3 = day2.plus(1, DateTimeUnit.DAY)
private fun event(from: LocalDateTimeIsh, to: LocalDateTimeIsh) = EventInstance(
instanceId = 1L, eventId = 1L, calendarId = 1L, title = "E",
start = from.date.atTime(from.hour, 0).toInstant(zone),
end = to.date.atTime(to.hour, 0).toInstant(zone),
isAllDay = false, color = 0xFF112233.toInt(), location = null,
)
data class LocalDateTimeIsh(val date: LocalDate, val hour: Int)
private fun at(date: LocalDate, hour: Int) = LocalDateTimeIsh(date, hour)
@Test
fun `a same-day block is cut on neither edge`() {
val ev = event(at(day1, 9), at(day1, 10))
val block = layoutDay(listOf(ev), day1, zone).single()
assertThat(block.continuesBefore(day1, zone)).isFalse()
assertThat(block.continuesAfter(day1, zone)).isFalse()
}
@Test
fun `an overnight event is cut at the foot of its first day and the head of its second`() {
val ev = event(at(day1, 20), at(day2, 8))
val head = layoutDay(listOf(ev), day1, zone).single()
assertThat(head.continuesBefore(day1, zone)).isFalse()
assertThat(head.continuesAfter(day1, zone)).isTrue()
val tail = layoutDay(listOf(ev), day2, zone).single()
assertThat(tail.continuesBefore(day2, zone)).isTrue()
assertThat(tail.continuesAfter(day2, zone)).isFalse()
}
@Test
fun `a day wholly inside a long event is cut on both edges`() {
val ev = event(at(day1, 20), at(day3, 8))
val middle = layoutDay(listOf(ev), day2, zone).single()
assertThat(middle.continuesBefore(day2, zone)).isTrue()
assertThat(middle.continuesAfter(day2, zone)).isTrue()
}
@Test
fun `an event ending exactly at midnight is not cut`() {
val ev = event(at(day1, 20), at(day2, 0))
val block = layoutDay(listOf(ev), day1, zone).single()
assertThat(block.continuesAfter(day1, zone)).isFalse()
}
@Test
fun `only the cut edges lose their corners`() {
val square = 0.dp
assertThat(timedBlockShape(continuesBefore = true, continuesAfter = false).topStart)
.isEqualTo(CornerSize(square))
assertThat(timedBlockShape(continuesBefore = true, continuesAfter = false).bottomStart)
.isEqualTo(CornerSize(EVENT_CHIP_CORNER))
assertThat(timedBlockShape(continuesBefore = false, continuesAfter = true).topStart)
.isEqualTo(CornerSize(EVENT_CHIP_CORNER))
assertThat(timedBlockShape(continuesBefore = false, continuesAfter = true).bottomEnd)
.isEqualTo(CornerSize(square))
}
private val fourHours = 4 * 60
@Test
fun `an event that fits its day is drawn as one uncut piece`() {
val pieces = dragSlices(eventStartMin = 10 * 60, spanMin = fourHours)
assertThat(pieces).hasSize(1)
assertThat(pieces.single().dayOffset).isEqualTo(0)
assertThat(pieces.single().spanMin).isEqualTo(fourHours)
assertThat(pieces.single().continuesBefore).isFalse()
assertThat(pieces.single().continuesAfter).isFalse()
}
@Test
fun `dragging the start half re-splits both days as it moves`() {
// Held at 22:00: two hours on this day, two on the next.
assertThat(dragSlices(22 * 60, fourHours).map { it.spanMin })
.containsExactly(2 * 60, 2 * 60).inOrder()
// Carried up to 21:00 the first day takes three, so the second day's end
// comes up by exactly the hour the first day gained.
assertThat(dragSlices(21 * 60, fourHours).map { it.spanMin })
.containsExactly(3 * 60, 60).inOrder()
}
@Test
fun `dragging the end half splits the same way, one day back`() {
// The tail's top edge is at midnight and the event began two hours
// earlier, so it starts at -120 relative to the day the finger is on.
val pieces = dragSlices(eventStartMin = -2 * 60, spanMin = fourHours)
assertThat(pieces.map { it.dayOffset }).containsExactly(-1, 0).inOrder()
assertThat(pieces.map { it.spanMin }).containsExactly(2 * 60, 2 * 60).inOrder()
// The piece on the earlier day ends at midnight; the held one starts there.
assertThat(pieces.first().startMin).isEqualTo(22 * 60)
assertThat(pieces.first().continuesAfter).isTrue()
assertThat(pieces.last().startMin).isEqualTo(0)
assertThat(pieces.last().continuesBefore).isTrue()
}
@Test
fun `the earlier half shrinks away as the end half is pulled down its own day`() {
// Tail top dragged from midnight down to 01:00: one hour left behind.
assertThat(dragSlices(-2 * 60 + 60, fourHours).map { it.spanMin })
.containsExactly(60, 3 * 60).inOrder()
// At 02:00 the event no longer crosses midnight at all.
val whole = dragSlices(0, fourHours)
assertThat(whole).hasSize(1)
assertThat(whole.single().continuesBefore).isFalse()
}
@Test
fun `a day wholly inside a long event is cut on both sides`() {
val pieces = dragSlices(eventStartMin = 22 * 60, spanMin = 30 * 60)
assertThat(pieces.map { it.dayOffset }).containsExactly(0, 1, 2).inOrder()
assertThat(pieces[1].spanMin).isEqualTo(MINUTES_PER_DAY)
assertThat(pieces[1].continuesBefore).isTrue()
assertThat(pieces[1].continuesAfter).isTrue()
}
@Test
fun `an event ending exactly at midnight takes no piece of the next day`() {
val pieces = dragSlices(eventStartMin = 22 * 60, spanMin = 2 * 60)
assertThat(pieces).hasSize(1)
assertThat(pieces.single().continuesAfter).isFalse()
}
@Test
fun `a zero-length event still has a piece to draw`() {
val pieces = dragSlices(eventStartMin = 9 * 60, spanMin = 0)
assertThat(pieces).hasSize(1)
assertThat(pieces.single().dayOffset).isEqualTo(0)
assertThat(pieces.single().startMin).isEqualTo(9 * 60)
assertThat(pieces.single().spanMin).isEqualTo(0)
}
@Test
fun `only the days there are columns for are sliced`() {
// A week's worth of event, previewed on a timeline showing three days
// from the one the finger is on.
val pieces = dragSlices(0, 7 * MINUTES_PER_DAY, within = 0..2)
assertThat(pieces.map { it.dayOffset }).containsExactly(0, 1, 2).inOrder()
assertThat(pieces.last().continuesAfter).isTrue()
}
@Test
fun `an event outside the columns shown is sliced into nothing`() {
assertThat(dragSlices(-3 * MINUTES_PER_DAY, 60, within = 0..2)).isEmpty()
}
// --- the copy never blinks out from under the finger (#253) ---
@Test
fun `a short tail dragged to its floor still has a piece to draw`() {
// 23:50-00:05: on the tail's day the clip offset is 10 and the floor is
// one snap step above midnight, which puts the whole event on the day
// before. Day view has no column for that day, so dragSlices hands back
// nothing and the floating copy used to vanish mid-gesture.
val span = 15
val floor = dragFloorMin(clipOffsetMin = 10, eventSpanMin = span)
val eventStartMin = floor - 10
val visible = 0..0
assertThat(dragSlices(eventStartMin, span, visible)).isEmpty()
val edge = edgeDragSlice(eventStartMin, span, visible)
assertThat(edge).isNotNull()
assertThat(edge!!.dayOffset).isEqualTo(0)
assertThat(edge.startMin).isEqualTo(0)
assertThat(edge.continuesBefore).isTrue()
}
@Test
fun `an event a visible column does hold gets no edge piece`() {
assertThat(edgeDragSlice(9 * 60, 60, 0..0)).isNull()
}
}

View File

@@ -0,0 +1,156 @@
package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
import de.jeanlucmakiola.calendula.ui.week.layoutDay
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atTime
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import org.junit.jupiter.api.Test
/**
* What a timeline drop asks for, for the head and the tail of an event that
* crosses midnight (#253). The tail's top edge is midnight rather than the
* event's start, so the drop has to take that offset back off.
*/
class TimelineDropTest {
private val zone = TimeZone.UTC
private val sat = LocalDate(2026, 9, 5)
private val sun = sat.plus(1, DateTimeUnit.DAY)
// Sat 5 Sep 20:00 to Sun 6 Sep 08:00.
private val overnight = EventInstance(
instanceId = 1L,
eventId = 1L,
calendarId = 1L,
title = "Night shift",
start = sat.atTime(20, 0).toInstant(zone),
end = sun.atTime(8, 0).toInstant(zone),
isAllDay = false,
color = 0xFF112233.toInt(),
location = null,
)
private fun blockOn(day: LocalDate): TimedBlock =
layoutDay(listOf(overnight), day, zone).single()
@Test
fun `the head block carries no clip offset`() {
val head = blockOn(sat)
assertThat(head.startMin).isEqualTo(20 * 60)
assertThat(head.clipOffsetMinutes(sat, zone)).isEqualTo(0)
}
@Test
fun `the tail block is offset by the part that already ran`() {
val tail = blockOn(sun)
assertThat(tail.startMin).isEqualTo(0)
// 20:00 to midnight is four hours of the event already gone.
assertThat(tail.clipOffsetMinutes(sun, zone)).isEqualTo(4 * 60)
}
@Test
fun `dropping the head asks for exactly where it landed`() {
val target = LocalDate(2026, 9, 2)
val drop = TimelineDrop(overnight, target, 20 * 60, clipOffsetMin = 0)
assertThat(drop.startInstant(zone)).isEqualTo(target.atTime(20, 0).toInstant(zone))
}
@Test
fun `dropping the tail moves the event by the days the tail travelled`() {
val tail = blockOn(sun)
val offset = tail.clipOffsetMinutes(sun, zone)
// Dragged three days back, same height in the column: Sun 6 -> Thu 3.
val drop = TimelineDrop(overnight, LocalDate(2026, 9, 3), 0, offset)
// The event began Sat 5 Sep 20:00, so it now begins Wed 2 Sep 20:00 —
// the whole event moved three days, and no time of day was invented.
assertThat(drop.startInstant(zone))
.isEqualTo(LocalDate(2026, 9, 2).atTime(20, 0).toInstant(zone))
}
@Test
fun `dragging the tail down the column re-times the event across midnight`() {
val offset = blockOn(sun).clipOffsetMinutes(sun, zone)
// Tail top pulled from 00:00 to 03:00 on its own day.
val drop = TimelineDrop(overnight, sun, 3 * 60, offset)
assertThat(drop.startInstant(zone)).isEqualTo(sat.atTime(23, 0).toInstant(zone))
}
@Test
fun `pulling the tail above its own midnight moves the event earlier`() {
val offset = blockOn(sun).clipOffsetMinutes(sun, zone)
// The tail's top edge dragged an hour above the midnight it rests on.
val drop = TimelineDrop(overnight, sun, -60, offset)
// Event was Sat 20:00 to Sun 08:00; it now begins an hour earlier.
assertThat(drop.startInstant(zone)).isEqualTo(sat.atTime(19, 0).toInstant(zone))
}
@Test
fun `a top edge dragged past the next midnight resolves onto the day after`() {
val drop = TimelineDrop(overnight, sat, 25 * 60, clipOffsetMin = 0)
assertThat(drop.startInstant(zone)).isEqualTo(sun.atTime(1, 0).toInstant(zone))
}
@Test
fun `a short tail can still be dragged a step earlier`() {
// 23:50 to 00:05: five minutes of tail, less than one snap step, which
// a floor pinned at midnight left with nowhere to go (#253).
assertThat(dragFloorMin(clipOffsetMin = 10, eventSpanMin = 15))
.isEqualTo(-DRAG_SNAP_MINUTES)
}
@Test
fun `a long tail may rise until its event ends just inside the day`() {
// Sat 20:00 to Sun 08:00, held by the Sunday half.
assertThat(dragFloorMin(clipOffsetMin = 4 * 60, eventSpanMin = 12 * 60))
.isEqualTo(DRAG_SNAP_MINUTES - 8 * 60)
}
@Test
fun `an unclipped block cannot be dragged above its own midnight`() {
assertThat(dragFloorMin(clipOffsetMin = 0, eventSpanMin = 60)).isEqualTo(0)
}
@Test
fun `a drop across a DST boundary keeps the time of day it was let go at`() {
// Europe/Berlin springs forward at 02:00 on 29 March 2026, between the
// event's start and the midnight its tail is cut at.
val berlin = TimeZone.of("Europe/Berlin")
val sun = LocalDate(2026, 3, 29)
val overnightIntoDst = EventInstance(
instanceId = 3L, eventId = 3L, calendarId = 1L, title = "Night shift",
start = LocalDate(2026, 3, 28).atTime(22, 0).toInstant(berlin),
end = sun.atTime(6, 0).toInstant(berlin),
isAllDay = false, color = 0xFF112233.toInt(), location = null,
)
val tail = layoutDay(listOf(overnightIntoDst), sun, berlin).single()
val offset = tail.clipOffsetMinutes(sun, berlin)
// Dropped back onto its own slot a week earlier, clear of the boundary.
val drop = TimelineDrop(overnightIntoDst, LocalDate(2026, 3, 22), 0, offset)
assertThat(drop.startInstant(berlin))
.isEqualTo(LocalDate(2026, 3, 21).atTime(22, 0).toInstant(berlin))
}
@Test
fun `a block that is not clipped is unaffected wherever it sits`() {
val plain = EventInstance(
instanceId = 2L, eventId = 2L, calendarId = 1L, title = "Standup",
start = sat.atTime(9, 0).toInstant(zone),
end = sat.atTime(10, 0).toInstant(zone),
isAllDay = false, color = 0xFF112233.toInt(), location = null,
)
val block = layoutDay(listOf(plain), sat, zone).single()
assertThat(block.clipOffsetMinutes(sat, zone)).isEqualTo(0)
}
}

View File

@@ -120,4 +120,30 @@ class ViewBackStackTest {
)
assertThat(config.cycle).containsExactly(CalendarView.Agenda, CalendarView.Month).inOrder()
}
@Test
fun `a cycle can be emptied down to no views at all`() {
// #150: the settings screen no longer holds a floor, so every view may go.
val config = QuickSwitchConfig(order = IMPLEMENTED_VIEWS, enabled = emptySet())
assertThat(config.cycle).isEmpty()
assertThat(config.cycle.size).isLessThan(QuickSwitchConfig.MIN_CYCLE)
}
@Test
fun `one enabled view is below the cycle minimum, so the pill hides`() {
// A single target is not a switch — it hides rather than becoming a
// jump-to-one-view button, which would be dead once you were there.
val config = QuickSwitchConfig(order = IMPLEMENTED_VIEWS, enabled = setOf(CalendarView.Day))
assertThat(config.cycle).containsExactly(CalendarView.Day)
assertThat(config.cycle.size).isLessThan(QuickSwitchConfig.MIN_CYCLE)
}
@Test
fun `two enabled views are enough to show the pill`() {
val config = QuickSwitchConfig(
order = IMPLEMENTED_VIEWS,
enabled = setOf(CalendarView.Day, CalendarView.Month),
)
assertThat(config.cycle.size).isAtLeast(QuickSwitchConfig.MIN_CYCLE)
}
}

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,120 @@
package de.jeanlucmakiola.calendula.ui.month
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.atTime
import kotlinx.datetime.plus
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import org.junit.jupiter.api.Test
/**
* Where a week row draws a moved chip — what the settling copy glides onto
* (#68, #253).
*/
class ChipSeatTest {
private val monday = LocalDate(2026, 6, 8)
private val days = (0..6).map { monday.plus(it, DateTimeUnit.DAY) }
private fun event(id: Long, title: String = "E") = EventInstance(
instanceId = id,
eventId = id,
calendarId = 1L,
title = title,
start = monday.atTime(9, 0).toInstant(TimeZone.UTC),
end = monday.atTime(10, 0).toInstant(TimeZone.UTC),
isAllDay = false,
color = 0xFF112233.toInt(),
location = null,
)
/**
* A grid holding [seated] in lane 0 as one bar across [cols], and nothing
* else — the seating a [MonthWeek] gives a span.
*/
private fun row(seated: EventInstance, cols: IntRange) = MonthWeek(
days = days,
spans = listOf(
MonthSpan(
event = seated,
startCol = cols.first,
endCol = cols.last,
lane = 0,
continuesLeft = false,
continuesRight = false,
),
),
timedByDay = emptyMap(),
countByDay = emptyMap(),
)
/** A grid holding one single-day pill per day of [cols], all of [seated]. */
private fun pills(seated: EventInstance, cols: IntRange) = MonthWeek(
days = days,
spans = emptyList(),
timedByDay = cols.associate { days[it] to listOf(seated) },
countByDay = emptyMap(),
)
private fun seat(week: MonthWeek, event: EventInstance, date: LocalDate) = chipSeat(
days = days,
laneCount = 3,
chipAt = { col, lane -> week.chipAt(col, lane, 3) },
chipStart = { col, lane -> week.chipStartCol(col, lane) },
event = event,
date = date,
)
@Test
fun `a single-day chip is seated in its own column`() {
val ev = event(1L)
assertThat(seat(row(ev, 3..3), ev, days[3])).isEqualTo(3 to 0)
}
@Test
fun `a bar is seated from its first column, not the day it was dropped on`() {
val ev = event(1L)
// Covers WedThu; dropped on the Thursday it now runs into.
assertThat(seat(row(ev, 2..3), ev, days[3])).isEqualTo(2 to 0)
}
@Test
fun `a bar carried in from the previous week is seated at column zero`() {
val ev = event(1L)
assertThat(seat(row(ev, 0..4), ev, days[4])).isEqualTo(0 to 0)
}
@Test
fun `a row that does not hold the event seats nothing`() {
val ev = event(1L)
assertThat(seat(row(ev, 2..3), ev, days[5])).isNull()
assertThat(seat(row(ev, 2..3), ev, LocalDate(2026, 7, 1))).isNull()
}
@Test
fun `a re-read chip is matched by title once its event id has changed`() {
// A single-occurrence move writes a new event id; the title survives.
val moved = event(1L, title = "Standup")
val reRead = event(9L, title = "Standup")
assertThat(seat(row(reRead, 3..3), moved, days[3])).isEqualTo(3 to 0)
}
@Test
fun `two untitled events are not the same event`() {
val moved = event(1L, title = "")
val other = event(2L, title = "")
assertThat(isSameEvent(other, moved)).isFalse()
assertThat(seat(row(other, 3..3), moved, days[3])).isNull()
}
@Test
fun `a daily series seats each occurrence in its own column`() {
// Every column holds an occurrence of the same event id, so a walk left
// by identity would run to the start of the row (#253).
val ev = event(1L, title = "Standup")
assertThat(seat(pills(ev, 0..6), ev, days[4])).isEqualTo(4 to 0)
}
}

View File

@@ -44,6 +44,19 @@ class MonthLayoutTest {
location = null,
)
/** A timed event running from [date] 20:00 into the next day at [endHour]. */
private fun overnight(date: LocalDate, endHour: Int = 8) = EventInstance(
instanceId = 7L,
eventId = 7L,
calendarId = 1L,
title = "Night shift",
start = at(date, 20),
end = at(date.plus(1, DateTimeUnit.DAY), endHour),
isAllDay = false,
color = 0xFF112233.toInt(),
location = null,
)
/** All-day events live at UTC midnights with an *exclusive* end. */
private fun allDay(
from: LocalDate,
@@ -162,6 +175,43 @@ class MonthLayoutTest {
assertThat(week.timedByDay[LocalDate(2026, 6, 11)]).isEmpty()
}
@Test
fun `an event crossing midnight is one bar, not two pills, on both sides of a week seam`() {
// Sun 14 Jun 20:00 to Mon 15 Jun 08:00 — the seam of a Monday-anchored
// grid: one day in this row, one in the next. Counting per row made each
// a standalone pill with nothing saying they were one event (#253).
val overnight = overnight(LocalDate(2026, 6, 14))
val head = layoutCalendarWeek(weekOf8th, listOf(overnight), zone)
assertThat(head.timedByDay.values.flatten()).isEmpty()
val headSpan = head.spans.single()
assertThat(headSpan.startCol).isEqualTo(6)
assertThat(headSpan.endCol).isEqualTo(6)
assertThat(headSpan.continuesLeft).isFalse()
assertThat(headSpan.continuesRight).isTrue()
val tailRow = layoutCalendarWeek(
weekOf8th.map { it.plus(7, DateTimeUnit.DAY) },
listOf(overnight),
zone,
)
assertThat(tailRow.timedByDay.values.flatten()).isEmpty()
val tailSpan = tailRow.spans.single()
assertThat(tailSpan.startCol).isEqualTo(0)
assertThat(tailSpan.endCol).isEqualTo(0)
assertThat(tailSpan.continuesLeft).isTrue()
assertThat(tailSpan.continuesRight).isFalse()
}
@Test
fun `an event ending exactly at midnight stays a single-day pill`() {
val untilMidnight = overnight(LocalDate(2026, 6, 10), endHour = 0)
val week = layoutCalendarWeek(weekOf8th, listOf(untilMidnight), zone)
assertThat(week.spans).isEmpty()
assertThat(week.timedByDay[LocalDate(2026, 6, 10)]).hasSize(1)
assertThat(week.timedByDay[LocalDate(2026, 6, 11)]).isEmpty()
}
@Test
fun `countByDay totals bars and pills on each date`() {
val span = allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12), id = 1L)

View File

@@ -0,0 +1,189 @@
package de.jeanlucmakiola.calendula.widget
import android.content.Intent
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import org.junit.jupiter.api.Test
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant
/**
* The rollover alarm is what makes the widgets stop highlighting yesterday
* (#228), so the "when is the next local midnight" arithmetic is the one piece
* worth pinning down — especially where midnight is not 00:00.
*/
class WidgetRolloverSchedulerTest {
private val berlin = TimeZone.of("Europe/Berlin")
private fun at(local: String, zone: TimeZone): Instant =
LocalDateTime.parse(local).toInstant(zone)
private fun nextRollover(local: String, zone: TimeZone = berlin): Instant =
WidgetRolloverScheduler.nextRolloverAt(at(local, zone), zone)
// --- the ordinary day ----------------------------------------------------
@Test
fun `midday rolls over at the coming midnight`() {
val next = nextRollover("2026-08-27T12:00:00")
assertThat(next).isEqualTo(at("2026-08-28T00:00:05", berlin))
}
@Test
fun `a second before midnight still targets tonight, not tomorrow night`() {
val next = nextRollover("2026-08-27T23:59:59")
assertThat(next).isEqualTo(at("2026-08-28T00:00:05", berlin))
}
@Test
fun `at midnight exactly the target is the next day, never the current instant`() {
// Re-arming after a firing must move a whole day on, or the widget wakes
// itself in a tight loop.
val now = at("2026-08-28T00:00:00", berlin)
val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
assertThat(next).isEqualTo(at("2026-08-29T00:00:05", berlin))
assertThat(next - now).isGreaterThan(Duration.ZERO)
}
@Test
fun `re-arming from the slack instant itself moves a full day on`() {
// What actually happens in practice: the receiver runs at midnight + slack.
val now = at("2026-08-28T00:00:05", berlin)
assertThat(WidgetRolloverScheduler.nextRolloverAt(now, berlin))
.isEqualTo(at("2026-08-29T00:00:05", berlin))
}
@Test
fun `the result is always in the future for every minute of a day`() {
val zone = berlin
// Spans Berlin's 2024 spring-forward: the likeliest source of a target
// in the past, i.e. an alarm that fires immediately, forever.
var probe = LocalDateTime.parse("2024-03-29T00:00:00").toInstant(zone)
val end = LocalDateTime.parse("2024-04-01T00:00:00").toInstant(zone)
while (probe < end) {
assertThat(WidgetRolloverScheduler.nextRolloverAt(probe, zone)).isGreaterThan(probe)
probe += 1.minutes
}
}
// --- daylight saving -----------------------------------------------------
@Test
fun `spring forward keeps the rollover one day away, not one hour short`() {
// Berlin skipped 02:00-03:00 on 31 March 2024, so that day was 23h long.
// A rollover computed as "now + 24h" would land at 01:00 on 1 April.
val now = at("2024-03-30T12:00:00", berlin)
val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
assertThat(next).isEqualTo(at("2024-03-31T00:00:05", berlin))
assertThat(next - now).isLessThan(24.hours)
}
@Test
fun `fall back does not overshoot into the repeated hour`() {
// Berlin repeated 02:00-03:00 on 27 October 2024: a 25h day, so
// "now + 24h" would land at 23:00 on the 26th and never roll over.
val now = at("2024-10-26T12:00:00", berlin)
val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
assertThat(next).isEqualTo(at("2024-10-27T00:00:05", berlin))
assertThat(next.toLocalDateTime(berlin).date).isEqualTo(LocalDate.parse("2024-10-27"))
}
@Test
fun `a zone that repeats midnight takes the first start of day`() {
// Sao Paulo used to end DST by moving 00:00 back to 23:00, so the day
// began twice. Pinned as the accepted trade: the widget runs an hour
// ahead until the next redraw. No live zone does this since 2019.
val saoPaulo = TimeZone.of("America/Sao_Paulo")
val next = WidgetRolloverScheduler.nextRolloverAt(
at("2018-02-16T12:00:00", saoPaulo), saoPaulo,
)
val local = next.toLocalDateTime(saoPaulo)
assertThat(local.date).isEqualTo(LocalDate.parse("2018-02-17"))
assertThat(local.hour).isEqualTo(0)
}
@Test
fun `a zone where midnight does not exist rolls over at the real start of day`() {
// Cuba starts DST at 00:00, so 11 March 2018 began at 01:00 in Havana.
// Targeting a literal 00:00 there would arm an instant on the wrong day.
val havana = TimeZone.of("America/Havana")
val next = WidgetRolloverScheduler.nextRolloverAt(at("2018-03-10T12:00:00", havana), havana)
val local = next.toLocalDateTime(havana)
assertThat(local.date).isEqualTo(LocalDate.parse("2018-03-11"))
assertThat(local.hour).isEqualTo(1)
assertThat(local.minute).isEqualTo(0)
// And it is genuinely the first instant of that date, not a guess.
assertThat(next).isEqualTo(
LocalDate.parse("2018-03-11").atStartOfDayIn(havana) +
WidgetRolloverScheduler.ROLLOVER_SLACK,
)
}
// --- timezone changes ----------------------------------------------------
@Test
fun `the same instant rolls over at different times in different zones`() {
// TIMEZONE_CHANGED must re-arm to the new local midnight: the arithmetic
// follows the zone, not a cached offset.
val instant = at("2026-08-27T12:00:00", berlin)
val tokyo = TimeZone.of("Asia/Tokyo")
val berlinNext = WidgetRolloverScheduler.nextRolloverAt(instant, berlin)
val tokyoNext = WidgetRolloverScheduler.nextRolloverAt(instant, tokyo)
assertThat(tokyoNext).isNotEqualTo(berlinNext)
assertThat(tokyoNext).isLessThan(berlinNext)
assertThat(tokyoNext.toLocalDateTime(tokyo).hour).isEqualTo(0)
}
@Test
fun `a half-hour offset zone still lands on its own midnight`() {
val kathmandu = TimeZone.of("Asia/Kathmandu")
val next = WidgetRolloverScheduler.nextRolloverAt(
at("2026-08-27T12:00:00", kathmandu), kathmandu,
)
val local = next.toLocalDateTime(kathmandu)
assertThat(local.date.toString()).isEqualTo("2026-08-28")
assertThat(local.hour).isEqualTo(0)
}
// --- the wiring ----------------------------------------------------------
@Test
fun `the receiver actually handles the action the alarm is sent with`() {
// The single point where the whole fix would die silently: the alarm
// fires, the receiver drops it on the action guard, nothing redraws.
assertThat(WidgetUpdateReceiver.HANDLED_ACTIONS)
.contains(WidgetUpdateReceiver.ACTION_ROLLOVER)
}
@Test
fun `a data change is handled, and re-arms only on the throttle`() {
// PROVIDER_CHANGED fires on every calendar write — each event of a bulk
// import, each drop of a drag — so it is not an unconditional re-arm.
// It is not dropped either: the alarm is inexact and the system may lose
// it, and this is the only signal that keeps arriving to notice.
assertThat(WidgetUpdateReceiver.HANDLED_ACTIONS)
.containsAtLeast(Intent.ACTION_PROVIDER_CHANGED, Intent.ACTION_DATE_CHANGED)
assertThat(WidgetUpdateReceiver.REARM_ACTIONS)
.doesNotContain(Intent.ACTION_PROVIDER_CHANGED)
}
@Test
fun `everything that loses the alarm re-arms it`() {
assertThat(WidgetUpdateReceiver.REARM_ACTIONS).containsAtLeast(
WidgetUpdateReceiver.ACTION_ROLLOVER,
Intent.ACTION_BOOT_COMPLETED,
Intent.ACTION_MY_PACKAGE_REPLACED,
Intent.ACTION_TIME_CHANGED,
Intent.ACTION_TIMEZONE_CHANGED,
Intent.ACTION_DATE_CHANGED,
)
}
}

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,10 @@
Fixed
• An occurrence you deleted from a repeating event stays deleted — through a re-timing, a backup and a restore.
• "Only this event" now saves your edit on calendars that have never synced, and deleting one from an event that doesn't repeat deletes the event.
• A reminder left on your account's default no longer goes off as the event starts.
• The widgets turn the page at midnight instead of leaving yesterday as "today".
• An event running past midnight can be dragged by either half.
• One event the calendar refuses no longer costs you the whole import, and an import cut short tells you how far it got.
Added
• Belarusian, Hungarian and Slovak, from community translators on Weblate.

View File

@@ -5,16 +5,16 @@ 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]