Compare commits

...

46 Commits

Author SHA1 Message Date
8536774522 feat: surface Simplified Chinese; complete 2.14.0 changelog
All checks were successful
Translations / check (pull_request) Successful in 28s
CI / ci (pull_request) Successful in 5m40s
Add zh-CN to locales_config.xml so the community Simplified Chinese
translation (values-zh-rCN, already committed via Weblate, ~27%) is
selectable in the in-app language picker and Android's per-app-language
settings. Untranslated strings fall back to English.

Fill in the 2.14.0 changelog, which only documented #37: add the three
feature PRs that also landed on this branch — .ics restore + per-calendar
export (#32), Month week numbers (#25), edit-screen title wrapping (#33) —
plus a note for the new Chinese translation, the missing [#N] link refs,
and the re-synced fastlane en-US changelog.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 09:23:57 +02:00
988ac009b5 Merge pull request 'Wrap long event titles in the edit screen (#33)' (!65) from feat/wrap-long-titles into release/v2.14.0
All checks were successful
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Successful in 6m22s
Reviewed-on: #65
2026-07-06 20:31:46 +00:00
494e486998 Merge remote-tracking branch 'origin/release/v2.14.0' into feat/wrap-long-titles
All checks were successful
CI / ci (pull_request) Successful in 10m31s
2026-07-06 22:28:42 +02:00
3c73028c80 Wrap long event titles in the edit screen (#33)
The edit-screen title field was single-line, so long titles scrolled off
one line instead of wrapping. Make it multi-line so it wraps and grows
vertically, matching the detail screen and Google Calendar.

A title is still one logical line: strip any newline the IME's Enter key
or a paste would introduce in setTitle, so no line break reaches the
provider's TITLE column.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 22:28:31 +02:00
2be1be19fe Merge pull request 'feat: week-of-year numbers in Month view (#25)' (!64) from feat/month-week-numbers into release/v2.14.0
All checks were successful
Translations / check (pull_request) Successful in 7s
CI / ci (pull_request) Successful in 10m43s
Reviewed-on: #64
2026-07-06 20:26:13 +00:00
1f050c2be9 feat: make Month week-number a full-height cell like the day cells
All checks were successful
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Successful in 6m12s
Per on-device review, render the week number as a full-height tonal pill
mirroring the day cells' geometry (secondaryContainer tint, same rounded
shape and gap), with the number centred — so the gutter reads as part of the
grid rather than a floating chip. This diverges from the Week view's small
header chip, so revert the shared-badge extraction: restore WeekScreen's
private badge and drop ui/common/WeekNumberBadge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 22:24:11 +02:00
8b22e1b2af refactor: share WeekNumberBadge; use it in Month gutter, centered
Extract the Week view's calendar-week badge into a shared ui/common
component and reuse it for the Month grid's week-number gutter, so the two
views show week numbers in the exact same format. The gutter now centres the
badge vertically in each row (was pinned to the day-number band) and is
widened to seat the badge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 22:11:02 +02:00
a514b8b506 feat: show calendar-week numbers in Month view (#25)
Add an opt-in left gutter to the Month grid showing the ISO calendar-week
number, gated by a new "Week numbers" display setting (default off). The
number is computed on each row's first day — the same basis as the Week
view's badge — so the two views agree, and rendered as a low-emphasis
onSurfaceVariant label so it recedes across all six rows rather than
competing with the event bars. The weekday header reserves a matching
gutter so the day columns stay aligned.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 22:02:44 +02:00
60fcc6b64c Merge pull request 'feat: restore events from .ics + per-calendar export selector (#32)' (!62) from feat/ics-restore into release/v2.14.0
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 9m54s
Reviewed-on: #62
2026-07-06 19:45:02 +00:00
d6bc660983 Merge remote-tracking branch 'origin/release/v2.14.0' into feat/ics-restore
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 9m46s
2026-07-06 21:39:27 +02:00
b449fff77c release: v2.14.0 — Day view on date-header tap
All checks were successful
CI / ci (pull_request) Successful in 10m12s
Bump versionName to 2.14.0 (versionCode 21400) and cut the changelog for
the day-view-on-date-header-tap feature (#37). Merging this to main
triggers the release pipeline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 21:34:40 +02:00
f0cc35f2ce test: cover the export calendar-id plumbing
The fake now records the calendarIds it receives; two repository tests
assert exportEvents forwards a chosen subset and defaults to null (all
eligible calendars), closing the coverage gap for the per-calendar
export selector.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 20:34:35 +02:00
2ce79942c4 fix: broaden restore availability and stabilize the export picker
- Restore is import, not export: offer it whenever any writable, non-
  managed calendar exists (local or synced), not only when there is a
  local calendar to back up. Previously the row lived inside the
  export-gated block and vanished for users with only a writable synced
  calendar, despite import supporting that target.
- Export-picker selection now uses rememberSaveable and is no longer
  keyed on the observer-driven calendars list, so a background provider
  re-emit (sync/recolor) can't silently reset the user's de-selections,
  and the choice survives rotation.
- Shared calendar picker: restore the displayName fallback for a synced
  calendar whose account name and type are both blank (was grouping them
  under an empty header).
- Drop imports left dead by the CalendarPickerGroups extraction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 20:34:35 +02:00
df426bb8df fix: scope import ViewModel per-uri so a second import re-parses
ImportScreen has no nav backstack, so an unkeyed hiltViewModel() resolved
to the Activity's ViewModelStore and was retained across imports. Its
one-shot `load` guard then showed the *previous* file's parsed state on
the next import — trivially reachable now that the in-app Restore button
lets you export→restore or restore twice in one session (worst case: the
picker still holds file A, so tapping Import writes A's events after you
picked B). Keying the VM by the file uri hands each distinct file a fresh
VM (fresh Loading state); the same uri (rotation) reuses it and holds the
result.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 20:34:15 +02:00
31f51554f9 feat: open Day view when tapping a date header in Week/Agenda
Tapping a date header in the Week (day-of-week column) and Agenda
(sticky section) views now drills into that date in Day view, mirroring
the Agenda widget's header behaviour. Both reuse the existing onOpenDay
callback (pendingDayIso + drillToDay) that Month already used, so the
back stack lands on Day with the tapped view as its parent.

Month already navigated on any cell tap (the transparent tap layer sits
above the day number), so no change was needed there — all four views
now behave consistently.

Closes #37

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 19:51:49 +02:00
e967007bdc fix: default import target to the first local calendar
The pre-selected target was calendars.first() (raw provider order), which
could land on a synced calendar mid-list while the picker shows local
calendars first. Default to the first local calendar so the checkmark lines
up with the top row; fall back to the first calendar when none are local.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 19:41:51 +02:00
9f7427e72f feat: pin import action to the top bar, fold count into title
Move the multi-event import's confirm button into the app-bar actions so
it's reachable without scrolling past a long calendar list, and put the
count in the title ('Importing 5 events') instead of a separate 'N events
in this file' line. Hoists the selected target calendar to the screen so
the top-bar action can read it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 19:39:54 +02:00
79d9e0eaa0 feat: in-app restore always uses the full import flow
Route by entry point, not just event count. Opening a .ics from outside
still sends a single event straight to the prefilled create form (add one
event, e.g. a ticket). The in-app 'Restore from .ics' button passes
forceMany so even a single-event backup goes through the calendar picker +
summary — its intent is 'restore a backup', not 'add this event'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 19:29:04 +02:00
a98dd654a6 polish: explain skipped duplicates on the import-complete screen
The import de-dups by UID against the target calendar (idempotent restore),
so re-importing events already present shows a low 'Added' count. Add a note
under the title when any were skipped so the outcome doesn't read as broken.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 19:07:56 +02:00
a138e179dd feat: per-calendar export selector
Tapping Export with more than one exportable calendar now opens a picker
to choose which local calendars to include (all selected by default); a
single calendar exports straight through as before. Threads an optional
calendarIds filter through exportEvents/exportableEvents (null = all
eligible), so the auto-backup path is unaffected. The backup section is
now gated on there being at least one exportable (non-managed) calendar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:46:24 +02:00
94e3887345 fix: exclude managed special-dates calendars from import targets
Symmetric with the export change: the contact-derived, editor-locked
special-dates mirror calendars aren't a valid import destination, so drop
them from the target-calendar picker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:39:54 +02:00
f440d385fa fix: exclude managed special-dates calendars from .ics export
The contact special-dates mirror calendars (birthdays/anniversaries) are
derived from contacts and re-materialise from the contact sync, so backing
them up only duplicates events on restore. Skip managed calendars in
exportableEvents — covers both the manual export and the auto-backup, which
share this path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:38:21 +02:00
8fb4767888 feat: spice up the import-complete screen
Replace the plain centered text list with an M3 Expressive success state:
a tonal check badge that springs in, the headline, and big-number tonal
stat tiles for added / duplicate-skipped counts, with a full-width Done
button. Stat tiles carry the full-sentence plurals as accessibility
labels so TalkBack still reads 'Imported N events'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:32:20 +02:00
993d74502f chore: stop tracking local floret-kit scratch dir (added by mistake)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:25:20 +02:00
4a11c951ae refactor: one shared calendar picker with settings-page category headers
Extract CalendarPickerGroups into ui/common: the calendar-manager screen's
grouped-card system (device chip for local calendars, the owning app's
launcher icon per synced account, colour chip + check per calendar) as a
single reusable picker. Use it in both the event editor and the .ics import
screen so all 'which calendar' lists match.

Moves LeadingAvatar/SourceLogo/curatedSourcePackage out of CalendarsScreen
into common as the shared source of truth. Drops the redundant 'Add to
calendar' caption from the import picker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:23:39 +02:00
f25f308326 fix: import target picker uses the standard grouped-list format
The 'Add to calendar' picker rendered bare OptionCards with no calendar
colour and no account grouping. Reuse the same account-grouped GroupedRow
layout as the event editor's calendar picker — coloured chip per calendar,
account sub-headers, a check on the selected row — so it matches the rest
of the app.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:11:45 +02:00
bff683a403 feat: restore events from .ics file in backup section (#32)
Add a 'Restore from .ics file' row to the Calendars backup section, next
to Export. It opens a SAF document picker and routes the picked Uri into
the existing import flow (parse, dedup by UID, target-calendar picker,
summary) via CalendarHost's importUri — the same path an externally
opened .ics already takes, so no new import machinery is needed.

Closes #32.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 17:55:35 +02:00
bb7954d026 Remove superpowers planning docs; ignore CLAUDE.md
All checks were successful
Release — F-Droid repo + Gitea release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea release / release (push) Successful in 7m3s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 17:43:22 +02:00
d9f4239729 Merge pull request 'release: v2.13.1 — recurring-event open fix, 24h time picker, INSERT intent' (!60) from release/v2.13.1 into main
Some checks failed
Release — F-Droid repo + Gitea release / detect (push) Successful in 4s
Release — F-Droid repo + Gitea release / release (push) Has been cancelled
Reviewed-on: #60
2026-07-06 15:36:42 +00:00
cf3b897305 release: v2.13.1 — recurring-event open fix, 24h time picker, INSERT intent
All checks were successful
CI / ci (pull_request) Successful in 8m32s
Patch release bundling the fixes for #34 (pre-1970 recurring events could
not be opened) and #27 (time-picker dial ignored the 24h setting), plus
#30 (create events from external ACTION_INSERT launches). Bumps
versionName to 2.13.1 (versionCode 21301) and cuts the changelog.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 17:25:17 +02:00
87a78ca924 Merge branches 'fix/recurring-event-open-and-24h-picker' and 'feat/insert-intent' into release/v2.13.1 2026-07-06 17:23:47 +02:00
13debac340 Merge pull request 'Translations update from Weblate' (!59) from weblate-bot/calendula:weblate-calendula-strings into main
All checks were successful
Release — F-Droid repo + Gitea release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea release / release (push) Has been skipped
Reviewed-on: #59
2026-07-06 15:21:52 +00:00
76846420a2 fix(detail): clamp a backwards DTEND instead of dropping the event (#34)
toEventDetailCore returned null when a present DTEND preceded DTSTART, the
only remaining false-drop that surfaces as the generic "Something went
wrong." error screen — the same un-openable trap as the pre-1970 DTSTART
bug, and worse because the user can't even open the malformed event to fix
it. Clamp the end to DTSTART (a zero-length event) instead, matching how
SearchMapper already coerces its end. After this the detail mapper drops a
row only when DTSTART is genuinely absent (unrenderable).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 17:11:51 +02:00
1a3e4f501f fix(edit): time picker dial honours the app 24h/12h setting (#27)
The event-form (and Settings) time picker seeded is24Hour from the
system TIME_12_24 override / device locale, ignoring the app's own
TimeFormatPref. So with the app set to 24h under an English locale the
dial still showed AM/PM, while every time label (which reads
LocalUse24HourFormat) showed 24h.

Seed the picker from LocalUse24HourFormat — the app-wide clock
convention already resolved once at the root from TimeFormatPref — so the
dial matches the labels. Drops the now-unused deviceUses24HourClock
helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 16:05:43 +02:00
974d65f619 fix(detail): open events whose series starts before 1970 (#34)
DTSTART is stored as UTC epoch millis, so a recurring series anchored
before 1970-01-01 (common for yearly birthdays/anniversaries synced over
CalDAV) has a legitimately negative DTSTART. The detail and search
mappers dropped any row with dtstart < 0, and since the detail query
reads the series-master DTSTART (the ancient anchor), every occurrence of
such a series became un-openable — surfacing as the generic
"Something went wrong." error screen — and the events vanished from
search too.

Relax the guard to reject only an *absent* DTSTART (isNull), which is the
malformed case it was meant to catch; negative epoch millis flow through
correctly (Instant/formatting and the all-day reminder decode are all
Long-based). Add regression tests for both mappers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 16:05:33 +02:00
Ulisse Perusin
721ca0d7e0 Translated using Weblate (Italian)
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 9m40s
Currently translated at 97.3% (399 of 410 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/it/
2026-07-05 08:52:25 +00:00
Ulisse Perusin
2556eba84b Translated using Weblate (Italian)
All checks were successful
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Successful in 4m2s
Currently translated at 94.1% (386 of 410 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/it/
2026-07-05 07:49:35 +00:00
zh-cn
fc1ff97d22 Translated using Weblate (Chinese (Simplified Han script))
All checks were successful
Translations / check (pull_request) Successful in 27s
CI / ci (pull_request) Successful in 4m3s
Currently translated at 29.0% (119 of 410 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/zh_Hans/
2026-07-05 04:20:20 +00:00
Weblate
b7f3d421ef Update translation files
All checks were successful
Translations / check (pull_request) Successful in 28s
CI / ci (pull_request) Successful in 10m7s
Updated by "Remove blank strings" add-on in Weblate.

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/
2026-07-04 04:10:19 +00:00
zh-cn
2722b3427a Added translation using Weblate (Chinese (Simplified Han script)) 2026-07-04 04:10:19 +00:00
Weblate
e28c293d27 Translated using Weblate (Spanish)
Currently translated at 100.0% (410 of 410 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/es/
2026-07-04 04:10:19 +00:00
iker Contreras
5cb2a836ab Translated using Weblate (Spanish)
Currently translated at 100.0% (410 of 410 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/es/
2026-07-04 04:10:18 +00:00
Weblate
b5978e7a61 Translated using Weblate (German)
Currently translated at 84.3% (346 of 410 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/de/
2026-07-04 04:10:18 +00:00
c4a048adee Translated using Weblate (German)
Currently translated at 84.3% (346 of 410 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/de/
2026-07-04 04:10:18 +00:00
Weblate
2746ca5784 Translated using Weblate (German)
Currently translated at 84.3% (346 of 410 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/de/
2026-07-04 04:10:18 +00:00
fc9bd33992 Translated using Weblate (German)
Currently translated at 84.3% (346 of 410 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/de/
2026-07-04 04:10:18 +00:00
45 changed files with 1326 additions and 6601 deletions

3
.gitignore vendored
View File

@@ -55,3 +55,6 @@ Thumbs.db
# KSP
.ksp/
# Claude Code
/CLAUDE.md

View File

@@ -5,6 +5,63 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.14.0] — 2026-07-06
### Added
- Restore events from a backup file. The backup section of Settings can now read
events back **in** from an `.ics` file, not just write one out: pick a file,
choose which calendar to import into, and Calendula adds the events — skipping
any that are already there and telling you how many it skipped. Export gained a
per-calendar selector at the same time, so you can back up just the calendars
you pick instead of everything at once ([#32]).
- Week numbers in Month view. A new **Week numbers** setting (off by default)
adds a slim gutter down the left of the Month grid showing the calendar-week
number for each row, sized to match the day cells. Handy if you plan or refer
to dates by week number ([#25]).
- Tap a date header to open that day. In Week and Agenda view, tapping a date
header now opens that date in Day view — the same drill-in that Month view and
the agenda widget already offered, so every view behaves the same way. It makes
jumping to a specific day quicker: switch to Week, swipe to the week you want,
then tap the date to open it. Thanks to @ptab for the suggestion ([#37]).
- An early Simplified Chinese translation. Calendula has started speaking
Simplified Chinese, contributed as a community translation through
[Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/).
It is still an early effort, so many parts of the app show in English until it
fills out — you can already pick it under Settings → Language or in Android's
per-app language settings. Thanks to
[zh-cn](https://weblate.dev.jeanlucmakiola.de/user/zh-cn/) for getting it
started; help finishing it is very welcome.
### Changed
- Long event titles wrap in the edit screen. When editing an event, a long title
now wraps onto multiple lines instead of being clipped to a single line, so you
can see and edit the whole thing ([#33]).
## [2.13.1] — 2026-07-06
### Added
- Create events from other apps and widgets. Calendula now registers the
standard "insert event" intent (`ACTION_INSERT` on the calendar events type),
so other apps and home-screen widgets — such as the Todo Agenda widget — can
hand off to Calendula to create a new event. It opens the new-event form
prefilled with whatever they passed (title, start/end time, all-day, location,
description, recurrence), and picks your last-used or first writable calendar.
Thanks to @dschuermann for the suggestion ([#30]).
### Fixed
- Some recurring events could not be opened. Events in a series that started
before 1970 — for example yearly birthdays or anniversaries synced over CalDAV
— showed "Something went wrong" instead of opening, because their stored start
time is a negative value that was wrongly treated as invalid. They now open
normally and appear in search again. A related case (an event whose stored end
precedes its start) is now kept and openable instead of failing the same way.
Thanks to @dschuermann for the report ([#34]).
- The time picker now follows your 24-hour setting. With Calendula set to
24-hour time, the clock dial for choosing an event's start and end time still
showed AM/PM instead of a 24-hour dial; it now matches your setting (and the
same fix applies to the all-day reminder time in Settings). Thanks to
@abrossimow for the report ([#27]).
## [2.13.0] — 2026-07-03
### Added
@@ -809,4 +866,11 @@ automatically, with zero telemetry and no internet permission.
[#19]: https://codeberg.org/jlmakiola/calendula/issues/19
[#20]: https://codeberg.org/jlmakiola/calendula/issues/20
[#24]: https://codeberg.org/jlmakiola/calendula/issues/24
[#25]: https://codeberg.org/jlmakiola/calendula/issues/25
[#27]: https://codeberg.org/jlmakiola/calendula/issues/27
[#29]: https://codeberg.org/jlmakiola/calendula/issues/29
[#30]: https://codeberg.org/jlmakiola/calendula/issues/30
[#32]: https://codeberg.org/jlmakiola/calendula/issues/32
[#33]: https://codeberg.org/jlmakiola/calendula/issues/33
[#34]: https://codeberg.org/jlmakiola/calendula/issues/34
[#37]: https://codeberg.org/jlmakiola/calendula/issues/37

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 = 21300
versionName = "2.13.0"
versionCode = 21400
versionName = "2.14.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

View File

@@ -71,9 +71,11 @@ interface CalendarDataSource {
/**
* 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]).
* 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.
*/
fun exportableEvents(): List<IcsEvent>
fun exportableEvents(calendarIds: Set<Long>? = null): List<IcsEvent>
/**
* The non-empty `Events.UID_2445` values present in [calendarId] — used to
@@ -611,12 +613,19 @@ class AndroidCalendarDataSource @Inject constructor(
?: emptyList()
}
override fun exportableEvents(): List<IcsEvent> {
override fun exportableEvents(calendarIds: Set<Long>?): List<IcsEvent> {
// Only the local calendars the app owns and can write — synced calendars
// already have a backup (their server). Map id → display name for the
// already have a backup (their server). Exclude the managed special-dates
// mirror calendars: their events are derived from contacts, not authored
// here, and re-materialise from the contact sync — backing them up would
// just duplicate them on restore. A non-null [calendarIds] narrows the
// export to the user's chosen subset. Map id → display name for the
// X-CALENDULA-CALENDAR tag a restore uses to fan back out.
val names = calendars()
.filter { it.isLocal && it.canModifyContents }
.filter {
it.isLocal && it.canModifyContents && !it.isManaged &&
(calendarIds == null || it.id in calendarIds)
}
.associate { it.id to it.displayName }
if (names.isEmpty()) return emptyList()

View File

@@ -41,8 +41,9 @@ interface CalendarRepository {
/**
* Every event of the writable local calendars, ready to serialise into a
* whole-calendar `.ics` backup (see [CalendarDataSource.exportableEvents]).
* [calendarIds] narrows the export to a chosen subset; `null` exports all.
*/
suspend fun exportEvents(): List<IcsEvent>
suspend fun exportEvents(calendarIds: Set<Long>? = null): List<IcsEvent>
/**
* Bulk-import parsed `.ics` [events] into [targetCalendarId]. Events whose

View File

@@ -117,7 +117,8 @@ class CalendarRepositoryImpl @Inject constructor(
override suspend fun deleteCalendar(id: Long) =
withContext(io) { dataSource.deleteCalendar(id) }
override suspend fun exportEvents() = withContext(io) { dataSource.exportableEvents() }
override suspend fun exportEvents(calendarIds: Set<Long>?) =
withContext(io) { dataSource.exportableEvents(calendarIds) }
override suspend fun importEvents(
targetCalendarId: Long,

View File

@@ -23,26 +23,29 @@ internal fun ColumnReader.toEventDetailCore(
attendees: List<Attendee>,
reminders: List<Reminder>,
): EventDetail? {
val begin = getLong(EventDetailProjection.IDX_DTSTART)
if (begin < 0L) {
Log.w(TAG, "Dropping event with negative dtstart=$begin")
// DTSTART is epoch millis in UTC, so a series anchored before 1970 (common
// for yearly birthdays/anniversaries synced over CalDAV) is legitimately
// negative — only an *absent* DTSTART marks a malformed row worth dropping.
// Dropping negatives made every occurrence of such a series un-openable
// (the detail loads the ancient series-master DTSTART), see issue #34.
if (isNull(EventDetailProjection.IDX_DTSTART)) {
Log.w(TAG, "Dropping event with missing dtstart")
return null
}
val begin = getLong(EventDetailProjection.IDX_DTSTART)
// Recurring events store DURATION instead of DTEND, so the series row's
// DTEND is null. Keep the event (end == begin); callers that opened a
// specific occurrence supply the real per-occurrence times from
// CalendarContract.Instances. Only a present-but-backwards DTEND is malformed.
// CalendarContract.Instances. A present-but-backwards DTEND is malformed,
// but dropping the row would make the event un-openable — the same trap as
// the pre-1970 DTSTART bug above (issue #34): it would surface as the
// generic error screen with no way to open the event and fix it. Clamp to a
// zero-length event instead (matching SearchMapper's coerceAtLeast).
val end = if (isNull(EventDetailProjection.IDX_DTEND)) {
begin
} else {
val rawEnd = getLong(EventDetailProjection.IDX_DTEND)
if (rawEnd < begin) {
Log.w(TAG, "Dropping event with dtend=$rawEnd < dtstart=$begin")
return null
}
rawEnd
getLong(EventDetailProjection.IDX_DTEND).coerceAtLeast(begin)
}
// Kept raw (no untitled fallback): the detail screen substitutes its own

View File

@@ -10,8 +10,11 @@ import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
* of DTEND — reconstruct the end the same way the `.ics` export does.
*/
internal fun ColumnReader.toSearchResult(): EventInstance? {
// A pre-1970 series anchor is a legitimately negative epoch-millis DTSTART
// (see EventDetailMapper / issue #34); drop only a genuinely absent one, so
// long-running birthdays/anniversaries still surface in search.
if (isNull(SearchProjection.IDX_DTSTART)) return null
val dtStart = getLong(SearchProjection.IDX_DTSTART)
if (dtStart < 0L) return null
val end = when {
!isNull(SearchProjection.IDX_DTEND) -> getLong(SearchProjection.IDX_DTEND)
else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION))

View File

@@ -200,6 +200,19 @@ class SettingsPrefs @Inject constructor(
store.edit { it[DIM_COMPLETED_EVENTS_KEY] = enabled }
}
/**
* Whether the Month grid shows the calendar-week (ISO) number in a left
* gutter (#25). Defaults to OFF — users opt in, since it narrows the day
* cells slightly. The Week view shows its number unconditionally.
*/
val showWeekNumbers: Flow<Boolean> = store.data.map { prefs ->
prefs[SHOW_WEEK_NUMBERS_KEY] ?: false
}
suspend fun setShowWeekNumbers(enabled: Boolean) {
store.edit { it[SHOW_WEEK_NUMBERS_KEY] = enabled }
}
/**
* How far ahead the in-app Agenda screen shows events (v2.11). Defaults to
* [AgendaRange.Month] — a month of upcoming events. Independent of the
@@ -659,6 +672,7 @@ class SettingsPrefs @Inject constructor(
internal val SHOW_HOUR_LINES_KEY = booleanPreferencesKey("show_hour_lines")
internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display")
internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events")
internal val SHOW_WEEK_NUMBERS_KEY = booleanPreferencesKey("show_week_numbers")
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views")
internal val DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order")

View File

@@ -172,9 +172,15 @@ fun CalendarHost(
// picker (many). A plain conditional overlay (no slide) — it's transient.
var importUri by remember { mutableStateOf<android.net.Uri?>(null) }
var importForm by remember { mutableStateOf<EventForm?>(null) }
// A restore (in-app "Restore from .ics" button) always runs the full import
// flow — picker + summary — even for a single-event file, because the intent
// is "restore a backup", not "add this one event". An externally opened .ics
// keeps routing a single event straight into the prefilled create form.
var importForceMany by remember { mutableStateOf(false) }
LaunchedEffect(requestedImportUri) {
if (requestedImportUri != null) {
importUri = requestedImportUri
importForceMany = false
onImportConsumed()
}
}
@@ -284,6 +290,7 @@ fun CalendarHost(
CalendarView.Week -> WeekScreen(
selectedView = currentView,
onSelectView = onSelectView,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
@@ -315,6 +322,7 @@ fun CalendarHost(
CalendarView.Agenda -> AgendaScreen(
selectedView = currentView,
onSelectView = onSelectView,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
@@ -412,7 +420,10 @@ fun CalendarHost(
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
) {
CalendarsScreen(onBack = { showCalendars = false })
CalendarsScreen(
onBack = { showCalendars = false },
onImport = { importUri = it; importForceMany = true },
)
}
// Import flow for an opened/received .ics file. A single event routes
@@ -420,6 +431,7 @@ fun CalendarHost(
importUri?.let { uri ->
ImportScreen(
uri = uri,
forceMany = importForceMany,
onClose = { importUri = null },
onOpenSingle = { form ->
importUri = null

View File

@@ -93,6 +93,7 @@ private val zone = TimeZone.currentSystemDefault()
fun AgendaScreen(
selectedView: CalendarView,
onSelectView: (CalendarView) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
onOpenSettings: () -> Unit,
onOpenSearch: () -> Unit,
@@ -191,6 +192,7 @@ fun AgendaScreen(
pastDisplay = pastDisplay,
onRetry = viewModel::goToToday,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
@@ -289,6 +291,7 @@ private fun AgendaContent(
pastDisplay: PastEventDisplay,
onRetry: () -> Unit,
onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
) {
when (state) {
@@ -318,6 +321,7 @@ private fun AgendaContent(
dimPast = pastDisplay == PastEventDisplay.DIM,
now = now,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
modifier = modifier,
)
}
@@ -333,6 +337,7 @@ private fun AgendaList(
dimPast: Boolean,
now: Instant,
onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
@@ -342,7 +347,7 @@ private fun AgendaList(
) {
days.forEach { day ->
stickyHeader(key = "header-${day.date}") {
AgendaDayHeader(date = day.date, today = today)
AgendaDayHeader(date = day.date, today = today, onOpenDay = onOpenDay)
}
itemsIndexed(
items = day.events,
@@ -362,10 +367,16 @@ private fun AgendaList(
}
@Composable
private fun AgendaDayHeader(date: LocalDate, today: LocalDate) {
private fun AgendaDayHeader(
date: LocalDate,
today: LocalDate,
onOpenDay: (LocalDate) -> Unit,
) {
Surface(
color = MaterialTheme.colorScheme.surface,
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.clickable { onOpenDay(date) },
) {
Text(
text = agendaDayLabel(date, today),

View File

@@ -8,7 +8,6 @@ import android.text.format.DateUtils
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
@@ -24,7 +23,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
@@ -37,6 +35,7 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.FileDownload
import androidx.compose.material.icons.filled.FileUpload
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.PhoneAndroid
@@ -45,6 +44,7 @@ import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -66,6 +66,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -73,10 +74,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringArrayResource
@@ -85,7 +83,6 @@ import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.core.graphics.drawable.toBitmap
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import androidx.hilt.navigation.compose.hiltViewModel
@@ -96,6 +93,11 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.FullScreenPicker
import de.jeanlucmakiola.calendula.ui.common.positionOf
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
import de.jeanlucmakiola.calendula.ui.common.SourceLogo
import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage
import de.jeanlucmakiola.calendula.ui.common.DialogAmountField
import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
@@ -112,6 +114,16 @@ import java.time.LocalDate
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */
private const val NEW_CALENDAR_ID = Long.MIN_VALUE
// SAF mime filter for the restore picker. `.ics` files reach us under several
// mimes depending on the source app (our own export uses text/calendar; others
// hand them out as octet-stream or text/plain), so accept the common set rather
// than hide valid backups behind an over-tight filter.
private val RESTORE_MIME_TYPES = arrayOf(
"text/calendar",
"application/octet-stream",
"text/plain",
)
/**
* Calendar manager (reached from Settings). Lists the app's own device-only
* calendars with create / rename / recolor / delete (via a full-screen editor),
@@ -122,6 +134,7 @@ private const val NEW_CALENDAR_ID = Long.MIN_VALUE
@Composable
fun CalendarsScreen(
onBack: () -> Unit,
onImport: (android.net.Uri) -> Unit,
viewModel: CalendarsViewModel = hiltViewModel(),
) {
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
@@ -168,6 +181,7 @@ fun CalendarsScreen(
onConsumeError = viewModel::consumeError,
backupResult = backupResult,
onExportBackup = viewModel::exportBackup,
onImport = onImport,
onConsumeBackupResult = viewModel::consumeBackupResult,
autoBackup = autoBackup,
onSetAutoBackupEnabled = viewModel::setAutoBackupEnabled,
@@ -190,7 +204,8 @@ private fun CalendarsList(
error: Boolean,
onConsumeError: () -> Unit,
backupResult: BackupResult?,
onExportBackup: (android.net.Uri) -> Unit,
onExportBackup: (android.net.Uri, Set<Long>?) -> Unit,
onImport: (android.net.Uri) -> Unit,
onConsumeBackupResult: () -> Unit,
autoBackup: AutoBackupUiState,
onSetAutoBackupEnabled: (Boolean) -> Unit,
@@ -218,10 +233,19 @@ private fun CalendarsList(
}
// SAF "create document" target for the backup file. The picked Uri is handed
// to the VM to stream the .ics into.
// to the VM to stream the .ics into. This launcher exports everything
// eligible (null); the per-calendar selector owns its own launcher.
val createBackup = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("text/calendar"),
) { uri -> uri?.let(onExportBackup) }
) { uri -> uri?.let { onExportBackup(it, null) } }
var showExportPicker by rememberSaveable { mutableStateOf(false) }
// SAF "open document" picker for restoring events from a .ics file. The
// picked Uri is handed up to the host, which runs it through the same import
// flow as an externally opened .ics (parse, dedup by UID, target picker).
val openBackup = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(),
) { uri -> uri?.let(onImport) }
// SAF folder picker for the automatic-backup destination; the VM persists the
// write grant so background runs can keep writing to it.
@@ -301,8 +325,13 @@ private fun CalendarsList(
}
// Backup — local calendars have no sync, so a .ics export is their only
// safety net. Offered only when there is something to back up.
if (local.isNotEmpty()) {
// safety net. Offered only when there is something exportable: the user's
// own local calendars (managed special-dates mirrors don't count).
val exportable = local.filter { it.canModifyContents && !it.isManaged }
// Restore/import can target any writable, non-managed calendar (local or
// synced), so its availability is broader than export's.
val canImport = (local + synced).any { it.canModifyContents && !it.isManaged }
if (exportable.isNotEmpty()) {
Spacer(Modifier.height(16.dp))
SectionHeader(stringResource(R.string.calendars_backup_header))
HintText(stringResource(R.string.calendars_backup_hint))
@@ -313,7 +342,22 @@ private fun CalendarsList(
position = Position.Top,
leading = { LeadingAvatar(Icons.Default.FileDownload) },
onClick = {
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
// With more than one exportable calendar, let the user choose
// which to include; a single one exports straight away.
if (exportable.size == 1) {
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
} else {
showExportPicker = true
}
},
)
GroupedRow(
title = stringResource(R.string.calendars_restore_action),
summary = stringResource(R.string.calendars_restore_hint),
position = Position.Middle,
leading = { LeadingAvatar(Icons.Default.FileUpload) },
onClick = {
runCatching { openBackup.launch(RESTORE_MIME_TYPES) }
},
)
GroupedRow(
@@ -342,6 +386,19 @@ private fun CalendarsList(
)
HintText(backupStatusText(autoBackup.status))
}
} else if (canImport) {
// Nothing to back up (no writable local calendar), but events can
// still be restored into a writable calendar — offer restore on its
// own so it isn't hidden behind export eligibility.
Spacer(Modifier.height(16.dp))
SectionHeader(stringResource(R.string.calendars_restore_header))
HintText(stringResource(R.string.calendars_restore_hint))
GroupedRow(
title = stringResource(R.string.calendars_restore_action),
position = Position.Alone,
leading = { LeadingAvatar(Icons.Default.FileUpload) },
onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
)
}
Spacer(Modifier.height(16.dp))
@@ -408,6 +465,86 @@ private fun CalendarsList(
onDismiss = { showInterval = false },
)
}
if (showExportPicker) {
ExportCalendarPicker(
calendars = local.filter { it.canModifyContents && !it.isManaged },
onExport = onExportBackup,
onDismiss = { showExportPicker = false },
)
}
}
/**
* Choose which local calendars to include in a one-time `.ics` export. Defaults
* to all selected; the Export action opens the SAF save dialog and hands back
* the picked file with the chosen calendar ids.
*/
@Composable
private fun ExportCalendarPicker(
calendars: List<CalendarSource>,
onExport: (android.net.Uri, Set<Long>?) -> Unit,
onDismiss: () -> Unit,
) {
// Seed once with everything selected and hold it across recomposition and
// rotation. NOT keyed on [calendars]: the list is observer-driven, so keying
// it would silently reset the user's de-selections whenever the provider
// re-emits (a background sync, a recolor). Ids that later vanish are harmless
// — the data layer intersects the chosen set with the eligible calendars.
var selected by rememberSaveable(
stateSaver = listSaver(
save = { it.toList() },
restore = { it.toSet() },
),
) {
mutableStateOf(calendars.map { it.id }.toSet())
}
val createBackup = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("text/calendar"),
) { uri ->
if (uri != null) {
onExport(uri, selected)
onDismiss()
}
}
FullScreenPicker(
title = stringResource(R.string.calendars_export_title),
onDismiss = onDismiss,
) {
HintText(stringResource(R.string.calendars_export_hint))
calendars.forEachIndexed { index, calendar ->
val isSelected = calendar.id in selected
GroupedRow(
title = calendar.displayName,
summary = calendar.description,
position = positionOf(index, calendars.size),
leading = { CalendarColorChip(calendar.color) },
trailing = {
Checkbox(
checked = isSelected,
onCheckedChange = { checked ->
selected = if (checked) selected + calendar.id else selected - calendar.id
},
)
},
onClick = {
selected = if (isSelected) selected - calendar.id else selected + calendar.id
},
)
}
Button(
onClick = {
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
},
enabled = selected.isNotEmpty(),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp),
) {
Text(stringResource(R.string.calendars_export_action))
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@@ -741,66 +878,6 @@ private fun CalendarGroupMenu(
}
}
/**
* The source app's launcher icon (Google Calendar, DAVx5, Nextcloud, …) as a
* round 40dp chip, so each synced account is recognisable at a glance. We load
* whatever app owns the account from [PackageManager] rather than bundling brand
* logos — always accurate, nothing to license. Falls back to a neutral cloud
* chip when no installed app resolves for the account.
*/
@Composable
private fun SourceLogo(accountType: String) {
val context = LocalContext.current
val logo = remember(accountType) { sourceAppLogo(context, accountType) }
if (logo != null) {
Image(
bitmap = logo,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(40.dp)
.clip(CircleShape),
)
} else {
LeadingAvatar(Icons.Default.Cloud)
}
}
/** The launcher icon of the app backing [accountType], preferring the human-facing app. */
private fun sourceAppLogo(context: Context, accountType: String): ImageBitmap? {
val pm = context.packageManager
val candidates = buildList {
curatedSourcePackage(accountType)?.let { add(it) }
AccountManager.get(context).authenticatorTypes
.firstOrNull { it.type.equals(accountType, ignoreCase = true) }
?.packageName
?.let { add(it) }
}
for (pkg in candidates) {
val bitmap = runCatching { pm.getApplicationIcon(pkg).toBitmap() }.getOrNull()
if (bitmap != null) return bitmap.asImageBitmap()
}
return null
}
/** Neutral circular chip carrying an arbitrary icon (e.g. the local-device mark). */
@Composable
private fun LeadingAvatar(icon: ImageVector) {
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
contentAlignment = Alignment.Center,
) {
Icon(
icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(22.dp),
)
}
}
@Composable
private fun SectionHeader(text: String) {
@@ -935,9 +1012,3 @@ private fun sourceAppIntent(context: Context, accountType: String): Intent {
return Intent(Settings.ACTION_SYNC_SETTINGS)
}
/** Preferred app for account types whose authenticator isn't the app to open. */
private fun curatedSourcePackage(accountType: String): String? = when {
accountType.equals("com.google", ignoreCase = true) -> "com.google.android.calendar"
else -> null
}

View File

@@ -107,11 +107,11 @@ class CalendarsViewModel @Inject constructor(
* document [uri] as one `VCALENDAR`. Result (event count, or failure) lands
* in [backupResult] for a one-shot message.
*/
fun exportBackup(uri: Uri) {
fun exportBackup(uri: Uri, calendarIds: Set<Long>? = null) {
viewModelScope.launch {
_backupResult.value = try {
val count = withContext(io) {
val events = repository.exportEvents()
val events = repository.exportEvents(calendarIds)
icsExporter.writeDocument(
uri = uri,
content = IcsWriter().writeCalendar(events, Clock.System.now()),

View File

@@ -0,0 +1,180 @@
package de.jeanlucmakiola.calendula.ui.common
import android.accounts.AccountManager
import android.content.Context
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.graphics.drawable.toBitmap
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.CalendarSource
/**
* The app's single "which calendar" selection list, shared by the event editor
* and the .ics import screen. Renders the same grouped-card system as the
* calendar-manager screen: a category header per source — the device chip for
* the app's own calendars, the owning app's launcher icon for each synced
* account — with the calendars beneath it as a connected card, a colour chip on
* each and a check on the selected one. Emits into the caller's [ColumnScope]
* (a scrolling column), so the caller owns the surrounding chrome.
*/
@Composable
fun ColumnScope.CalendarPickerGroups(
calendars: List<CalendarSource>,
selectedId: Long?,
onSelect: (Long) -> Unit,
) {
val local = remember(calendars) { calendars.filter { it.isLocal } }
val syncedGroups = remember(calendars) {
calendars.filterNot { it.isLocal }
.groupBy { it.accountName.ifBlank { it.accountType }.ifBlank { it.displayName } }
.toList()
}
if (local.isNotEmpty()) {
CalendarPickerGroup(
title = stringResource(R.string.calendars_local_header),
leading = { LeadingAvatar(Icons.Default.PhoneAndroid) },
calendars = local,
selectedId = selectedId,
onSelect = onSelect,
)
}
syncedGroups.forEachIndexed { index, (account, cals) ->
if (local.isNotEmpty() || index > 0) Spacer(Modifier.height(16.dp))
CalendarPickerGroup(
title = account,
leading = { SourceLogo(cals.first().accountType) },
calendars = cals,
selectedId = selectedId,
onSelect = onSelect,
)
}
}
/** One account's category header (avatar + name) atop its selectable calendars. */
@Composable
private fun CalendarPickerGroup(
title: String,
leading: @Composable () -> Unit,
calendars: List<CalendarSource>,
selectedId: Long?,
onSelect: (Long) -> Unit,
) {
GroupedRow(
title = title,
position = Position.Top,
leading = leading,
)
calendars.forEachIndexed { index, calendar ->
val isSelected = calendar.id == selectedId
GroupedRow(
title = calendar.displayName,
position = if (index == calendars.lastIndex) Position.Bottom else Position.Middle,
selected = isSelected,
leading = { CalendarColorChip(calendar.color) },
trailing = if (isSelected) {
{
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
} else {
null
},
onClick = { onSelect(calendar.id) },
)
}
}
/**
* The source app's launcher icon (Google Calendar, DAVx5, Nextcloud, …) as a
* round 40dp chip, so each synced account is recognisable at a glance. We load
* whatever app owns the account from [android.content.pm.PackageManager] rather
* than bundling brand logos — always accurate, nothing to license. Falls back to
* a neutral cloud chip when no installed app resolves for the account.
*/
@Composable
fun SourceLogo(accountType: String) {
val context = LocalContext.current
val logo = remember(accountType) { sourceAppLogo(context, accountType) }
if (logo != null) {
Image(
bitmap = logo,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(40.dp)
.clip(CircleShape),
)
} else {
LeadingAvatar(Icons.Default.Cloud)
}
}
/** Neutral circular chip carrying an arbitrary icon (e.g. the local-device mark). */
@Composable
fun LeadingAvatar(icon: ImageVector) {
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
contentAlignment = Alignment.Center,
) {
Icon(
icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(22.dp),
)
}
}
/** The launcher icon of the app backing [accountType], preferring the human-facing app. */
private fun sourceAppLogo(context: Context, accountType: String): ImageBitmap? {
val pm = context.packageManager
val candidates = buildList {
curatedSourcePackage(accountType)?.let { add(it) }
AccountManager.get(context).authenticatorTypes
.firstOrNull { it.type.equals(accountType, ignoreCase = true) }
?.packageName
?.let { add(it) }
}
for (pkg in candidates) {
val bitmap = runCatching { pm.getApplicationIcon(pkg).toBitmap() }.getOrNull()
if (bitmap != null) return bitmap.asImageBitmap()
}
return null
}
/** Preferred app for account types whose authenticator isn't the app to open. */
internal fun curatedSourcePackage(accountType: String): String? = when {
accountType.equals("com.google", ignoreCase = true) -> "com.google.android.calendar"
else -> null
}

View File

@@ -4,14 +4,9 @@ import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import android.content.Context
import android.content.res.Resources
import android.provider.Settings
import android.text.format.DateFormat
import androidx.compose.material3.TimePicker
import androidx.compose.material3.rememberTimePickerState
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import de.jeanlucmakiola.calendula.R
import kotlinx.datetime.LocalTime
@@ -27,10 +22,13 @@ fun TimePickerAlert(
onConfirm: (LocalTime) -> Unit,
onDismiss: () -> Unit,
) {
// Honour the app's own time-format preference (the value every time label
// reads), not the system/locale clock — otherwise an explicit 24h setting
// still showed an AM/PM dial (issue #27).
val state = rememberTimePickerState(
initialHour = initial.hour,
initialMinute = initial.minute,
is24Hour = deviceUses24HourClock(LocalContext.current),
is24Hour = LocalUse24HourFormat.current,
)
AlertDialog(
onDismissRequest = onDismiss,
@@ -45,24 +43,3 @@ fun TimePickerAlert(
text = { TimePicker(state = state) },
)
}
/**
* Whether the clock should read 24-hour, matching the rest of the device.
*
* [DateFormat.is24HourFormat] resolves a "locale default" system setting against
* the *app's* context locale — and this app applies a per-app language
* (AppCompatDelegate), so an English UI on a German-region phone would wrongly
* read 12-hour while the system clock shows 24-hour. So we honour an explicit
* system 12/24 override, and otherwise fall back to the **device** locale
* (Resources.getSystem), not the app's.
*/
private fun deviceUses24HourClock(context: Context): Boolean =
when (Settings.System.getString(context.contentResolver, Settings.System.TIME_12_24)) {
"24" -> true
"12" -> false
// 'a' is the AM/PM marker; a best-fit pattern without it is 24-hour.
else -> {
val deviceLocale = Resources.getSystem().configuration.locales[0]
!DateFormat.getBestDateTimePattern(deviceLocale, "jm").contains('a')
}
}

View File

@@ -37,7 +37,6 @@ import androidx.compose.material.icons.automirrored.filled.Notes
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Contacts
import androidx.compose.material.icons.filled.EventAvailable
@@ -121,7 +120,7 @@ import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
import de.jeanlucmakiola.calendula.ui.common.DialogAmountField
@@ -520,13 +519,17 @@ private fun EventEditContent(
.padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 40.dp),
) {
// Title: borderless headline, mirroring the detail screen's title +
// accent bar instead of a boxed Material text field.
// accent bar instead of a boxed Material text field. Multi-line so long
// titles wrap instead of scrolling off one line (#33); newlines are
// stripped in setTitle, so this stays a single logical line — the Enter
// key just has no effect.
InlineField(
value = form.title,
onValueChange = viewModel::setTitle,
placeholder = stringResource(R.string.event_edit_title_hint),
textStyle = MaterialTheme.typography.headlineMedium
.copy(fontWeight = FontWeight.SemiBold),
singleLine = false,
enabled = !locked,
modifier = Modifier
.fillMaxWidth()
@@ -1975,50 +1978,14 @@ private fun CalendarPicker(
onSelect: (Long) -> Unit,
onDismiss: () -> Unit,
) {
// Group by owning account (name, else type, else the calendar's own name),
// preserving the provider's order within and across groups — the same
// grouping [groupByAccount] applies for the drawer filter.
val groups = remember(calendars) {
calendars
.groupBy {
it.accountName.takeIf(String::isNotBlank)
?: it.accountType.takeIf(String::isNotBlank)
?: it.displayName
}
.toList()
}
FullScreenPicker(
title = stringResource(R.string.event_detail_calendar),
onDismiss = onDismiss,
) {
groups.forEach { (account, cals) ->
Text(
text = account,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp),
)
cals.forEachIndexed { index, calendar ->
val isSelected = calendar.id == selectedId
GroupedRow(
title = calendar.displayName,
position = positionOf(index, cals.size),
selected = isSelected,
leading = { CalendarColorChip(calendar.color) },
trailing = if (isSelected) {
{
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
} else {
null
},
onClick = { onSelect(calendar.id) },
)
}
}
CalendarPickerGroups(
calendars = calendars,
selectedId = selectedId,
onSelect = onSelect,
)
}
}

View File

@@ -336,7 +336,11 @@ class EventEditViewModel @Inject constructor(
_revealed.value = _revealed.value + field
}
fun setTitle(value: String) = update { it.copy(title = value) }
// The title field wraps (multi-line) so long titles stay visible (#33), but
// a title is one logical line: drop any newline the IME's Enter key or a
// paste would introduce, so it never reaches the provider's TITLE column.
fun setTitle(value: String) =
update { it.copy(title = value.replace("\n", "").replace("\r", "")) }
fun setLocation(value: String) = update { it.copy(location = value) }
fun setDescription(value: String) = update { it.copy(description = value) }
fun setAllDay(value: Boolean) {

View File

@@ -1,15 +1,27 @@
package de.jeanlucmakiola.calendula.ui.imports
import android.net.Uri
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
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.material3.Button
import androidx.compose.material3.CircularProgressIndicator
@@ -18,6 +30,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
@@ -30,6 +43,12 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@@ -38,14 +57,16 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning
import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.OptionCard
/**
* Handles an opened/received `.ics` file. A single event is handed straight to
* the prefilled create form via [onOpenSingle]; several events show a target-
* calendar picker and import in bulk (dedup by UID), then a result summary.
* Empty/failed files show a short message and close.
* Empty/failed files show a short message and close. [forceMany] keeps a
* single-event file on the bulk path — used by the in-app restore, whose intent
* is "restore a backup" rather than "add this one event".
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -53,9 +74,16 @@ fun ImportScreen(
uri: Uri,
onClose: () -> Unit,
onOpenSingle: (EventForm) -> Unit,
viewModel: ImportViewModel = hiltViewModel(),
forceMany: Boolean = false,
// Key the VM by the file uri. This screen has no nav backstack, so an
// unkeyed hiltViewModel() resolves to the Activity's store and is retained
// across imports — its one-shot `load` guard would then show the *previous*
// file's parsed state on the next import (a second restore, export→restore,
// etc.). Keying per uri hands each distinct file a fresh VM (fresh Loading
// state), while the same uri (rotation) reuses it and holds the result.
viewModel: ImportViewModel = hiltViewModel(key = uri.toString()),
) {
LaunchedEffect(uri) { viewModel.load(uri) }
LaunchedEffect(uri) { viewModel.load(uri, forceMany) }
val state by viewModel.state.collectAsStateWithLifecycle()
// A single event isn't shown here — it opens the create form for review.
@@ -63,18 +91,55 @@ fun ImportScreen(
(state as? ImportUiState.Single)?.let { onOpenSingle(it.form); onClose() }
}
// 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.
val many = state as? ImportUiState.Many
val defaultTarget = many?.calendars?.let { cals ->
(cals.firstOrNull { it.isLocal } ?: cals.firstOrNull())?.id
}
var selected by rememberSaveable(defaultTarget) { mutableStateOf(defaultTarget) }
Scaffold(
modifier = Modifier
.predictiveBack(onBack = onClose)
.fillMaxSize(),
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.import_title)) },
title = {
Text(
if (many != null) {
pluralStringResource(
R.plurals.import_title_count,
many.events.size,
many.events.size,
)
} else {
stringResource(R.string.import_title)
},
)
},
navigationIcon = {
IconButton(onClick = onClose) {
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.event_edit_close))
}
},
actions = {
// Only meaningful in the multi-event picker with a writable
// target; every other state has nothing to confirm here.
if (many != null && many.calendars.isNotEmpty()) {
Button(
onClick = { selected?.let(viewModel::import) },
enabled = selected != null,
modifier = Modifier.padding(end = 12.dp),
) {
Text(stringResource(R.string.import_button))
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
@@ -90,7 +155,7 @@ fun ImportScreen(
ImportUiState.Empty -> CenteredMessage(stringResource(R.string.import_empty), onClose)
ImportUiState.Failed -> CenteredMessage(stringResource(R.string.import_failed), onClose)
is ImportUiState.Many -> ManyContent(s, onImport = viewModel::import)
is ImportUiState.Many -> ManyContent(s, selected, onSelect = { selected = it })
is ImportUiState.Done -> DoneContent(s, onClose)
}
}
@@ -98,84 +163,160 @@ fun ImportScreen(
}
@Composable
private fun ManyContent(state: ImportUiState.Many, onImport: (Long) -> Unit) {
private fun ManyContent(state: ImportUiState.Many, selected: Long?, onSelect: (Long) -> Unit) {
// No writable calendar to import into — tell the user honestly.
if (state.calendars.isEmpty()) {
CenteredMessage(stringResource(R.string.import_no_calendar), onClose = null)
return
}
var selected by rememberSaveable { mutableStateOf(state.calendars.first().id) }
Column(
Modifier.fillMaxSize().verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
.padding(top = 8.dp, bottom = 24.dp),
) {
Text(
pluralStringResource(R.plurals.import_event_count, state.events.size, state.events.size),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(vertical = 8.dp),
CalendarPickerGroups(
calendars = state.calendars,
selectedId = selected,
onSelect = onSelect,
)
Text(
stringResource(R.string.import_target_header),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)
state.calendars.forEach { calendar ->
OptionCard(
label = calendar.displayName,
onClick = { selected = calendar.id },
selected = calendar.id == selected,
icon = null,
)
}
state.warnings.forEach { WarningText(it) }
Button(
onClick = { onImport(selected) },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
) {
Text(pluralStringResource(R.plurals.import_action, state.events.size, state.events.size))
if (state.warnings.isNotEmpty()) {
Column(
Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
state.warnings.forEach { WarningText(it) }
}
}
}
}
@Composable
private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
// A little expressive pop on the success badge — springs in on first show.
val badgeScale = remember { Animatable(0.7f) }
LaunchedEffect(Unit) {
badgeScale.animateTo(
targetValue = 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow,
),
)
}
Column(
Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(Modifier.weight(1f))
Box(
Modifier
.size(112.dp)
.graphicsLayer {
scaleX = badgeScale.value
scaleY = badgeScale.value
}
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(56.dp),
)
}
Spacer(Modifier.height(24.dp))
Text(
stringResource(R.string.import_done_title),
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(top = 24.dp),
)
Text(
pluralStringResource(
R.plurals.import_done_imported,
state.summary.imported,
state.summary.imported,
),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
if (state.summary.skippedDuplicate > 0) {
Spacer(Modifier.height(8.dp))
Text(
pluralStringResource(
R.plurals.import_done_skipped,
state.summary.skippedDuplicate,
state.summary.skippedDuplicate,
),
stringResource(R.string.import_done_dedup_note),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
Button(onClick = onClose, modifier = Modifier.padding(top = 12.dp)) {
Spacer(Modifier.height(24.dp))
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
ImportStatCard(
count = state.summary.imported,
label = stringResource(R.string.import_done_added_label),
contentDescription = pluralStringResource(
R.plurals.import_done_imported,
state.summary.imported,
state.summary.imported,
),
container = MaterialTheme.colorScheme.secondaryContainer,
onContainer = MaterialTheme.colorScheme.onSecondaryContainer,
)
if (state.summary.skippedDuplicate > 0) {
ImportStatCard(
count = state.summary.skippedDuplicate,
label = stringResource(R.string.import_done_skipped_label),
contentDescription = pluralStringResource(
R.plurals.import_done_skipped,
state.summary.skippedDuplicate,
state.summary.skippedDuplicate,
),
container = MaterialTheme.colorScheme.surfaceContainerHighest,
onContainer = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(Modifier.weight(1f))
Button(
onClick = onClose,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.import_close))
}
}
}
/** A big-number tonal tile summarising one import outcome (added / skipped). */
@Composable
private fun RowScope.ImportStatCard(
count: Int,
label: String,
contentDescription: String,
container: Color,
onContainer: Color,
) {
Surface(
modifier = Modifier
.weight(1f)
.clearAndSetSemantics { this.contentDescription = contentDescription },
shape = RoundedCornerShape(24.dp),
color = container,
) {
Column(
Modifier.padding(vertical = 20.dp, horizontal = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
count.toString(),
style = MaterialTheme.typography.displaySmall,
color = onContainer,
)
Text(
label,
style = MaterialTheme.typography.labelLarge,
color = onContainer.copy(alpha = 0.85f),
)
}
}
}
@Composable
private fun WarningText(warning: IcsParseWarning) {
val text = when (warning) {

View File

@@ -66,8 +66,13 @@ class ImportViewModel @Inject constructor(
val state: StateFlow<ImportUiState> = _state.asStateFlow()
private var started = false
/** Read + parse [uri] once; subsequent calls (recomposition) are ignored. */
fun load(uri: Uri) {
/**
* Read + parse [uri] once; subsequent calls (recomposition) are ignored.
* When [forceMany] is set (an in-app restore), a single-event file still goes
* through the bulk picker + summary rather than the prefilled create form —
* a restore is "bring back a backup", not "add this one event".
*/
fun load(uri: Uri, forceMany: Boolean = false) {
if (started) return
started = true
viewModelScope.launch {
@@ -77,19 +82,21 @@ class ImportViewModel @Inject constructor(
_state.value = when {
parsed == null -> ImportUiState.Failed
parsed.events.isEmpty() -> ImportUiState.Empty
parsed.events.size == 1 -> ImportUiState.Single(
parsed.events.size == 1 && !forceMany -> ImportUiState.Single(
form = parsed.events.single().toEventForm(TimeZone.currentSystemDefault()),
warnings = parsed.warnings,
)
else -> {
// A disabled calendar is removed from the app, so it can't be
// an import target — exclude it alongside the read-only ones.
// Managed special-dates calendars are contact-derived and
// editor-locked, so they're not a valid destination either.
val disabled = prefs.disabledCalendarIds.first()
ImportUiState.Many(
events = parsed.events,
warnings = parsed.warnings,
calendars = repository.calendars().first()
.filter { it.canModifyContents && it.id !in disabled },
.filter { it.canModifyContents && !it.isManaged && it.id !in disabled },
)
}
}

View File

@@ -108,6 +108,7 @@ fun MonthScreen(
val month by viewModel.month.collectAsStateWithLifecycle()
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle()
val showWeekNumbers by viewModel.showWeekNumbers.collectAsStateWithLifecycle()
// The instant before which an event counts as completed, or null when dimming
// is off. derivedStateOf keeps the per-minute "now" from recomposing the
// screen while the setting is off (it stays null regardless of the tick).
@@ -211,11 +212,12 @@ fun MonthScreen(
.padding(innerPadding)
.fillMaxSize(),
) {
WeekdayHeader(weekStart = weekStart)
WeekdayHeader(weekStart = weekStart, showWeekNumbers = showWeekNumbers)
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
MonthContent(
state = state,
slideDir = slideDir,
showWeekNumbers = showWeekNumbers,
onSwipeNext = goNext,
onSwipePrev = goPrev,
onRetry = jumpToToday,
@@ -231,6 +233,7 @@ fun MonthScreen(
private fun MonthContent(
state: MonthUiState,
slideDir: Int,
showWeekNumbers: Boolean,
onSwipeNext: () -> Unit,
onSwipePrev: () -> Unit,
onRetry: () -> Unit,
@@ -276,6 +279,7 @@ private fun MonthContent(
is MonthUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
is MonthUiState.Success -> MonthGrid(
state = s,
showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay,
)
}
@@ -325,7 +329,7 @@ private fun MonthTopBar(
}
@Composable
private fun WeekdayHeader(weekStart: DayOfWeek) {
private fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) {
val locale = currentLocale()
val days = remember(weekStart, locale) {
(0 until 7).map { offset ->
@@ -337,6 +341,8 @@ private fun WeekdayHeader(weekStart: DayOfWeek) {
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp),
) {
// Reserve the gutter so the weekday labels stay over their day columns.
if (showWeekNumbers) Spacer(Modifier.width(WEEK_NUMBER_GUTTER))
days.forEach { dow ->
val isWeekend = dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY
val javaDow = java.time.DayOfWeek.of(dow.ordinal + 1)
@@ -354,6 +360,9 @@ private fun WeekdayHeader(weekStart: DayOfWeek) {
private val EVENT_ROW_HEIGHT = 20.dp
private val DAY_NUMBER_HEIGHT = 22.dp
/** Width of the optional left calendar-week gutter (#25); narrow, since it only
* seats a one- or two-digit week number in a full-height tonal pill. */
private val WEEK_NUMBER_GUTTER = 40.dp
private val DAY_NUMBER_GAP = 4.dp
private val CELL_TOP_PADDING = 6.dp
private val CELL_GAP = 2.dp
@@ -363,6 +372,7 @@ private const val MAX_EVENT_ROWS = 3
@Composable
private fun MonthGrid(
state: MonthUiState.Success,
showWeekNumbers: Boolean,
onOpenDay: (LocalDate) -> Unit,
) {
Column(
@@ -376,6 +386,7 @@ private fun MonthGrid(
week = week,
today = state.today,
month = state.month,
showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay,
modifier = Modifier
.fillMaxWidth()
@@ -397,6 +408,7 @@ private fun MonthWeekRow(
week: MonthWeek,
today: LocalDate,
month: YearMonth,
showWeekNumbers: Boolean,
onOpenDay: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
) {
@@ -404,135 +416,182 @@ private fun MonthWeekRow(
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS)
BoxWithConstraints(modifier) {
val colW = maxWidth / 7
// Per-day background pills — same surfaceContainer rounded surface the
// week/day views use, so the three views share one visual language.
// Spanning bars draw on top of these, bridging cells, so they still read
// as one continuous event.
Row(Modifier.matchParentSize()) {
week.days.forEach { d ->
val inMonth = d.month == month.month && d.year == month.year
Box(
Modifier
.weight(1f)
.fillMaxHeight()
.padding(horizontal = CELL_GAP, vertical = 1.dp)
.background(
color = if (inMonth) MaterialTheme.colorScheme.surfaceContainer
else MaterialTheme.colorScheme.surfaceContainerLow,
shape = CELL_SHAPE,
),
)
}
}
Column(Modifier.fillMaxSize().padding(top = CELL_TOP_PADDING)) {
Row(Modifier.fillMaxWidth()) {
week.days.forEach { d ->
DayNumberCell(
date = d,
isToday = d == today,
inMonth = d.month == month.month && d.year == month.year,
modifier = Modifier.weight(1f),
)
}
}
// Breathing room between the day number (and today's circle) and the
// first event row.
Spacer(Modifier.height(DAY_NUMBER_GAP))
Box(
Row(modifier) {
// Optional calendar-week gutter, sized so the seven day columns below
// divide the remaining width — the absolute bar offsets stay correct
// because they're measured inside the grid box, not the whole row.
if (showWeekNumbers) {
WeekNumberGutter(
weekStart = week.days.first(),
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.clipToBounds(),
) {
// Spanning bars on their shared lanes.
week.spans.filter { it.lane < shownLanes }.forEach { span ->
val cols = span.endCol - span.startCol + 1
MonthBar(
event = span.event,
dark = dark,
continuesLeft = span.continuesLeft,
continuesRight = span.continuesRight,
modifier = Modifier
.offset(
x = colW * span.startCol,
y = EVENT_ROW_HEIGHT * span.lane,
)
.width(colW * cols)
.height(EVENT_ROW_HEIGHT)
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
.width(WEEK_NUMBER_GUTTER)
.fillMaxHeight(),
)
}
BoxWithConstraints(
Modifier
.weight(1f)
.fillMaxHeight(),
) {
val colW = maxWidth / 7
// Per-day background pills — same surfaceContainer rounded surface the
// week/day views use, so the three views share one visual language.
// Spanning bars draw on top of these, bridging cells, so they still read
// as one continuous event.
Row(Modifier.matchParentSize()) {
week.days.forEach { d ->
val inMonth = d.month == month.month && d.year == month.year
Box(
Modifier
.weight(1f)
.fillMaxHeight()
.padding(horizontal = CELL_GAP, vertical = 1.dp)
.background(
color = if (inMonth) MaterialTheme.colorScheme.surfaceContainer
else MaterialTheme.colorScheme.surfaceContainerLow,
shape = CELL_SHAPE,
),
)
}
// Single-day timed pills + overflow, per column. Pills fill the
// lane slots no bar occupies on THIS day (top-most first), so a
// bar-free day isn't pushed down by a multi-day event that only
// sits on other days of the week.
week.days.forEachIndexed { col, d ->
val timed = week.timedByDay[d].orEmpty()
val occupied = week.spans
.filter { it.lane < shownLanes && col in it.startCol..it.endCol }
.map { it.lane }
.toSet()
val freeSlots = (0 until MAX_EVENT_ROWS).filter { it !in occupied }
val pillsShown = timed.take(freeSlots.size)
pillsShown.forEachIndexed { i, ev ->
}
Column(Modifier.fillMaxSize().padding(top = CELL_TOP_PADDING)) {
Row(Modifier.fillMaxWidth()) {
week.days.forEach { d ->
DayNumberCell(
date = d,
isToday = d == today,
inMonth = d.month == month.month && d.year == month.year,
modifier = Modifier.weight(1f),
)
}
}
// Breathing room between the day number (and today's circle) and the
// first event row.
Spacer(Modifier.height(DAY_NUMBER_GAP))
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.clipToBounds(),
) {
// Spanning bars on their shared lanes.
week.spans.filter { it.lane < shownLanes }.forEach { span ->
val cols = span.endCol - span.startCol + 1
MonthBar(
event = ev,
event = span.event,
dark = dark,
continuesLeft = false,
continuesRight = false,
continuesLeft = span.continuesLeft,
continuesRight = span.continuesRight,
modifier = Modifier
.offset(
x = colW * col,
y = EVENT_ROW_HEIGHT * freeSlots[i],
x = colW * span.startCol,
y = EVENT_ROW_HEIGHT * span.lane,
)
.width(colW)
.width(colW * cols)
.height(EVENT_ROW_HEIGHT)
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
)
}
val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size
if (hidden > 0) {
val hiddenColors = buildList {
week.spans
.filter { it.lane >= shownLanes && col in it.startCol..it.endCol }
.forEach { add(it.event.color) }
timed.drop(pillsShown.size).forEach { add(it.color) }
}.distinct().take(3)
OverflowDots(
colors = hiddenColors,
extra = hidden - hiddenColors.size,
dark = dark,
modifier = Modifier
.offset(x = colW * col, y = EVENT_ROW_HEIGHT * MAX_EVENT_ROWS)
.width(colW)
.padding(horizontal = 3.dp),
)
// Single-day timed pills + overflow, per column. Pills fill the
// lane slots no bar occupies on THIS day (top-most first), so a
// bar-free day isn't pushed down by a multi-day event that only
// sits on other days of the week.
week.days.forEachIndexed { col, d ->
val timed = week.timedByDay[d].orEmpty()
val occupied = week.spans
.filter { it.lane < shownLanes && col in it.startCol..it.endCol }
.map { it.lane }
.toSet()
val freeSlots = (0 until MAX_EVENT_ROWS).filter { it !in occupied }
val pillsShown = timed.take(freeSlots.size)
pillsShown.forEachIndexed { i, ev ->
MonthBar(
event = ev,
dark = dark,
continuesLeft = false,
continuesRight = false,
modifier = Modifier
.offset(
x = colW * col,
y = EVENT_ROW_HEIGHT * freeSlots[i],
)
.width(colW)
.height(EVENT_ROW_HEIGHT)
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
)
}
val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size
if (hidden > 0) {
val hiddenColors = buildList {
week.spans
.filter { it.lane >= shownLanes && col in it.startCol..it.endCol }
.forEach { add(it.event.color) }
timed.drop(pillsShown.size).forEach { add(it.color) }
}.distinct().take(3)
OverflowDots(
colors = hiddenColors,
extra = hidden - hiddenColors.size,
dark = dark,
modifier = Modifier
.offset(x = colW * col, y = EVENT_ROW_HEIGHT * MAX_EVENT_ROWS)
.width(colW)
.padding(horizontal = 3.dp),
)
}
}
}
}
}
// Tap layer: in month view a tap on any day opens that day. Padded and
// clipped to the background pill so the ripple matches it.
Row(Modifier.matchParentSize()) {
week.days.forEach { d ->
Box(
Modifier
.weight(1f)
.fillMaxHeight()
.padding(horizontal = CELL_GAP, vertical = 1.dp)
.clip(CELL_SHAPE)
.clickable { onOpenDay(d) },
)
// Tap layer: in month view a tap on any day opens that day. Padded and
// clipped to the background pill so the ripple matches it.
Row(Modifier.matchParentSize()) {
week.days.forEach { d ->
Box(
Modifier
.weight(1f)
.fillMaxHeight()
.padding(horizontal = CELL_GAP, vertical = 1.dp)
.clip(CELL_SHAPE)
.clickable { onOpenDay(d) },
)
}
}
}
}
}
/**
* Left-gutter calendar-week cell (#25): a full-height tonal pill mirroring the
* day cells' geometry, set apart by the secondaryContainer tint (matching the
* Week view's badge), with the ISO week number centred like a day number. The
* week is computed on the row's first day — the same basis as the Week view — so
* the two agree.
*/
@Composable
private fun WeekNumberGutter(weekStart: LocalDate, modifier: Modifier = Modifier) {
val weekNumber = remember(weekStart) {
java.time.LocalDate.of(weekStart.year, weekStart.month.ordinal + 1, weekStart.day)
.get(java.time.temporal.IsoFields.WEEK_OF_WEEK_BASED_YEAR)
}
val label = stringResource(R.string.week_number_label)
Box(
modifier = modifier
.padding(horizontal = CELL_GAP, vertical = 1.dp)
.background(MaterialTheme.colorScheme.secondaryContainer, CELL_SHAPE)
.semantics { contentDescription = "$label $weekNumber" },
contentAlignment = Alignment.Center,
) {
Text(
text = weekNumber.toString(),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
}
@Composable
private fun DayNumberCell(
date: LocalDate,

View File

@@ -67,6 +67,14 @@ class MonthViewModel @Inject constructor(
initialValue = false,
)
/** Whether to show the calendar-week number gutter (#25; display only). */
val showWeekNumbers: StateFlow<Boolean> = settingsPrefs.showWeekNumbers
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = false,
)
private val todayDate: LocalDate
get() = Clock.System.now().toLocalDateTime(zone).date

View File

@@ -613,6 +613,18 @@ private fun AppearanceScreen(
position = Position.Middle,
onClick = { showWeekStart = true },
)
GroupedRow(
title = stringResource(R.string.settings_week_numbers),
summary = stringResource(R.string.settings_week_numbers_summary),
position = Position.Middle,
trailing = {
Switch(
checked = state.showWeekNumbers,
onCheckedChange = viewModel::setShowWeekNumbers,
)
},
onClick = { viewModel.setShowWeekNumbers(!state.showWeekNumbers) },
)
GroupedRow(
title = stringResource(R.string.settings_time_format),
summary = timeFormatLabel(state.timeFormat),

View File

@@ -33,6 +33,8 @@ data class SettingsUiState(
val pastEventDisplay: PastEventDisplay = PastEventDisplay.SHOW,
/** Whether the month/week grids fade events that have already finished. */
val dimCompletedEvents: Boolean = false,
/** Whether the Month grid shows calendar-week numbers in a left gutter (#25). */
val showWeekNumbers: Boolean = false,
/** How far ahead the in-app Agenda screen shows events (v2.11). */
val agendaScreenRange: AgendaRange = AgendaRange.Month,
/** How far ahead the agenda widget shows events (v2.11). */

View File

@@ -116,9 +116,15 @@ class SettingsViewModel @Inject constructor(
prefs.agendaScreenRange,
prefs.agendaWidgetRange,
prefs.timeFormat,
prefs.showHourLines,
) { view, screenRange, widgetRange, timeFormat, showHourLines ->
ViewSettings(view, screenRange, widgetRange, timeFormat, showHourLines)
// Two grid-display toggles folded into one flow so they fit this
// group — the outer combine is already at its five-arg limit.
combine(prefs.showHourLines, prefs.showWeekNumbers, ::Pair),
) { view, screenRange, widgetRange, timeFormat, gridToggles ->
ViewSettings(
view, screenRange, widgetRange, timeFormat,
showHourLines = gridToggles.first,
showWeekNumbers = gridToggles.second,
)
},
combine(
prefs.agendaShowRangeBar,
@@ -140,6 +146,7 @@ class SettingsViewModel @Inject constructor(
agendaWidgetRange = views.agendaWidgetRange,
timeFormat = views.timeFormat,
showHourLines = views.showHourLines,
showWeekNumbers = views.showWeekNumbers,
agendaShowRangeBar = misc.showRangeBar,
autofocusEventTitle = misc.autofocusEventTitle,
pastEventDisplay = misc.pastEventDisplay,
@@ -212,6 +219,7 @@ class SettingsViewModel @Inject constructor(
val agendaWidgetRange: AgendaRange,
val timeFormat: TimeFormatPref,
val showHourLines: Boolean,
val showWeekNumbers: Boolean,
)
private data class MiscSettings(
@@ -398,6 +406,10 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setShowHourLines(enabled) }
}
fun setShowWeekNumbers(enabled: Boolean) {
viewModelScope.launch { prefs.setShowWeekNumbers(enabled) }
}
fun setPastEventDisplay(mode: PastEventDisplay) {
viewModelScope.launch {
prefs.setPastEventDisplay(mode)

View File

@@ -128,6 +128,7 @@ private fun WeekUiState.Success.allDayStripHeight(): Dp {
fun WeekScreen(
selectedView: CalendarView,
onSelectView: (CalendarView) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
onOpenSettings: () -> Unit,
onOpenSearch: () -> Unit,
@@ -249,6 +250,7 @@ fun WeekScreen(
onSwipePrev = goPrev,
onRetry = jumpToToday,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
modifier = Modifier
.padding(innerPadding)
@@ -268,6 +270,7 @@ private fun WeekContent(
onSwipePrev: () -> Unit,
onRetry: () -> Unit,
onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
modifier: Modifier = Modifier,
) {
@@ -342,6 +345,7 @@ private fun WeekContent(
scrollState = scrollState,
allDayHeight = allDayHeight,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
onCreateAt = onCreateAt,
)
}
@@ -355,6 +359,7 @@ private fun WeekSuccess(
scrollState: ScrollState,
allDayHeight: Dp,
onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
) {
Column(modifier = Modifier.fillMaxSize()) {
@@ -363,7 +368,7 @@ private fun WeekSuccess(
.fillMaxWidth()
.background(topSectionColor),
) {
WeekDayHeader(days = state.days, today = state.today)
WeekDayHeader(days = state.days, today = state.today, onOpenDay = onOpenDay)
AllDayStrip(state = state, height = allDayHeight, onEventClick = onEventClick)
}
// Breathing room between the (colour-shifting) top section and the
@@ -427,7 +432,11 @@ private fun WeekTopBar(
}
@Composable
private fun WeekDayHeader(days: List<LocalDate>, today: LocalDate) {
private fun WeekDayHeader(
days: List<LocalDate>,
today: LocalDate,
onOpenDay: (LocalDate) -> Unit,
) {
val locale = currentLocale()
val weekStart = days.first()
val weekNumber = remember(weekStart) {
@@ -453,7 +462,10 @@ private fun WeekDayHeader(days: List<LocalDate>, today: LocalDate) {
val javaDow = java.time.DayOfWeek.of(date.dayOfWeek.ordinal + 1)
val isToday = date == today
Column(
modifier = Modifier.weight(1f),
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(12.dp))
.clickable { onOpenDay(date) },
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(

View File

@@ -12,7 +12,7 @@
<string name="state_failure_provider">Kalender konnte nicht gelesen werden.</string>
<!-- Permission-Flow (F1) -->
<string name="permission_rationale_title">Alle Termine, schön im Blick</string>
<string name="permission_rationale_body">Calendula braucht Zugriff auf deinen Kalender, um deine Termine zu zeigen und zu verwalten. Mehr verlangt die App nie.</string>
<string name="permission_rationale_body">Calendula braucht Zugriff auf deinen Kalender, um deine Termine zu zeigen und zu verwalten. Mehr braucht sie nicht um zu funktionieren — und nichts verlässt dein Gerät.</string>
<string name="permission_request_button">Kalender-Zugriff erlauben</string>
<string name="permission_denied_title">Kalender-Zugriff abgelehnt</string>
<string name="permission_denied_body">Ohne Kalender-Zugriff kann Calendula keine Termine anzeigen. Du kannst den Zugriff in den System-Einstellungen wieder erlauben.</string>
@@ -309,7 +309,7 @@
<string name="settings_language">App-Sprache</string>
<string name="settings_language_auto">Systemstandard</string>
<!-- Hub category subtitles -->
<string name="settings_appearance_subtitle">Design, dynamische Farben, Wochenstart</string>
<string name="settings_appearance_subtitle">Design, Standartansicht, Wochenstart</string>
<string name="settings_event_form_subtitle">Standardfelder für neue Termine</string>
<string name="settings_notifications_subtitle">Termin-Erinnerungen</string>
<string name="settings_section_about">Über</string>
@@ -387,4 +387,27 @@
<string name="crash_report_open_failed">Der Issue-Tracker konnte nicht geöffnet werden. Der Bericht ist in deiner Zwischenablage.</string>
<string name="crash_report_body_template">Danke, dass du einen Absturz in Calendula meldest. Bitte ergänze, was du gerade getan hast, und sende dann ab.\n\n### Was ist passiert\n\n\n### Absturzbericht\n%1$s\n</string>
<string name="crash_report_body_paste">_(Der Bericht war zu lang für diesen Link — füge ihn aus deiner Zwischenablage hier ein.)_</string>
<string name="event_edit_managed_hint">Verwaltet durch “%1$s” — der Titel, das Datum und Wiederholungen werden synchron mit deinen Kontakten gehalten. Erinnerungen, Ort und Beschreibung kannst du frei bearbeiten.</string>
<plurals name="duration_days">
<item quantity="one">%d Tag</item>
<item quantity="other">%d Tage</item>
</plurals>
<plurals name="duration_weeks">
<item quantity="one">Woche</item>
<item quantity="other">Wochen</item>
</plurals>
<string name="settings_default_view">Standartansicht</string>
<string name="settings_font_headings">Überschriften Schriftart</string>
<string name="settings_font_system">Systemstandard</string>
<string name="font_jetbrains_mono">JetBrains Mono</string>
<string name="settings_font_choose_file">Datei auswählen…</string>
<string name="settings_font_import_failed">Die Datei konnte nicht als Schriftart gelesen werden</string>
<string name="settings_dim_completed">Vergangene Ereignisse abdunkeln</string>
<string name="settings_dim_completed_summary">Vergangene Termine in der Monats- und Wochenansicht abdunkeln</string>
<string name="settings_past_events">Vergangene Termine</string>
<string name="settings_past_events_show">Zeigen</string>
<string name="settings_past_events_dim">Abdunkeln</string>
<string name="settings_past_events_hide">Ausblenden</string>
<string name="settings_agenda_header">Agenda</string>
<string name="settings_special_dates_reminders">Erinnerungen</string>
</resources>

View File

@@ -17,7 +17,7 @@
<string name="state_failure_no_calendars_action">Abrir los ajustes del calendario del sistema</string>
<string name="state_failure_provider">No se pudo leer el calendario.</string>
<string name="permission_rationale_title">Mira todos sus eventos, a la perfección</string>
<string name="permission_rationale_body">Calendula necesita acceso a tu calendario para mostrarte y administrar eventos. Ese es el único permiso que pedirá.</string>
<string name="permission_rationale_body">Calendula necesita acceso a tu calendario para mostrarte y administrar eventos. Ese es el único permiso que pedirá para comenzar a funcionar. Y nada de información sale de tu dispositivo.</string>
<string name="permission_request_button">Conceder el acceso al calendario</string>
<string name="permission_denied_title">Acceso a calendario denegado</string>
<string name="permission_denied_body">Calendula no puede mostrar eventos sin acceso al calendario. Puedes concederlo en ajustes del sistema.</string>
@@ -287,7 +287,7 @@
<item quantity="many">%d dias</item>
<item quantity="other">%d dias</item>
</plurals>
<string name="settings_section_event_form">Nueva formulario de evento</string>
<string name="settings_section_event_form">Formulario de nuevo evento</string>
<string name="settings_form_fields_hint">Campos mostrados por defecto — todo lo demás se encuentra bajo \"mas campos\"</string>
<string name="settings_autofocus_title">Enfocar titulo en nuevos eventos</string>
<string name="settings_autofocus_title_hint">Cuando comienzas un nuevo evento, coloca el cursor en el titulo y abre el teclado inmediatamente.</string>
@@ -418,4 +418,52 @@
<string name="recurrence_with_count">%1$s, %2$d veces</string>
<string name="recurrence_with_until">%1$s hasta %2$s</string>
<string name="recurrence_on_days">%1$s los %2$s</string>
<string name="event_edit_managed_hint">Administrado por “%1$s” — el titulo, fecha y recurrencia se mantienen sincronizados con la inflación de tus contactos. Puedes editar los recordatorios, ubicacion y notas.</string>
<string name="settings_font_headings">Fuente de los títulos</string>
<string name="settings_font_body">Fuente del cuerpo del texto</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">Escoger desde archivo…</string>
<string name="settings_font_custom_selected">Fuente personalizada</string>
<string name="settings_font_import_failed">No se pude leer el archivo como fuente</string>
<string name="settings_section_views">Vistas</string>
<string name="settings_quick_switch_header">Botón de cambio rápido</string>
<string name="settings_quick_switch_hint">Escoge que vistas muestra el botón superior derecho y arrastralas para reordenalas. Las vistas deshabitadas permanecen accesibles desde el menú de navegación.</string>
<string name="settings_drawer_order_header">Menú de navegación</string>
<string name="settings_drawer_order_hint">Arrastra para cambiar el orden de las vistas que aparecen en el menú de navegación.</string>
<string name="reorder_drag_handle">Arrastra para reordenar</string>
<string name="settings_views_subtitle">Orden del botón de cambio rápido y menú</string>
<string name="settings_special_dates_subtitle">Aniversarios y cumpleaños de los contactos</string>
<string name="settings_section_special_dates">Fechas especiales de contactos</string>
<string name="settings_special_dates_enable">Mostrar fechas de contactos</string>
<string name="settings_special_dates_enable_hint">Refleja los cumpleaños y otras fechas de tus contactos en calendarios locales. Solo puede leer los contactos de este dispositivo — nada es compartido ni editado.</string>
<string name="settings_special_dates_type_birthday">Cumpleaños</string>
<string name="settings_special_dates_type_anniversary">Aniversarios</string>
<string name="settings_special_dates_type_custom">Otras fechas</string>
<string name="settings_special_dates_template">Formato de titulo</string>
<string name="settings_special_dates_template_hint">Usa {name} para el contacto y {year} para el año (el año de nacimiento u origen del aniversario; oculto cuando es desconocido).</string>
<string name="settings_special_dates_reminders">Recordatorios</string>
<string name="settings_special_dates_show_year">Mostrar año</string>
<string name="settings_special_dates_show_year_hint">Incluye {year} en títulos cuando se conoce el año</string>
<string name="settings_special_dates_sync_now">Sincronizar ahora</string>
<string name="settings_special_dates_never_synced">No se ha sincronizado todavía</string>
<string name="settings_special_dates_last_synced">Sincronizado por ultima vez %1$s</string>
<string name="settings_special_dates_calendar_hint">Cambia el color y visibilidad de cada calendario en ajustes de calendarios.</string>
<string name="settings_calendar_reminders_managed_hint">Establecido en Fechas especiales de contactos</string>
<string name="settings_special_dates_paused_hint">Calendula ya no puede leer tus contactos, por lo que estos calendarios ya no se estan actualizando.</string>
<string name="settings_special_dates_disable_title">¿Desactivar las fechas de contactos?</string>
<string name="settings_special_dates_disable_all_message">Esto eliminara los calendarios de fechas de contactos y sus eventos. Se perderán todos los recordatorios o notas agregadas.</string>
<string name="settings_special_dates_paused_title">Pausado</string>
<string name="settings_special_dates_disable_type_message">Esto eliminara el calendario “%1$s” y sus eventos. Se perderán todos los recordatorios o notas agregadas.</string>
<string name="settings_special_dates_disable_confirm">Desactivar</string>
<string name="special_dates_calendar_birthday">Cumpleaños</string>
<string name="special_dates_calendar_anniversary">Aniversarios</string>
<string name="special_dates_calendar_custom">Fechas especiales</string>
<string name="special_dates_default_title_birthday">Cumpleaños de {name} ({year})</string>
<string name="special_dates_default_title_anniversary">Aniversario de {name} ({year})</string>
<string name="special_dates_default_title_custom">{name}</string>
<string name="settings_special_dates_grant">Permitir acceso</string>
<string name="dialog_save">Guardar</string>
<string name="settings_font_system">Igual que el sistema</string>
</resources>

View File

@@ -418,4 +418,42 @@
<string name="settings_default_reminder">Promemoria standard</string>
<string name="settings_notifications_subtitle">Promemoria evento</string>
<string name="shortcut_new_event_short">Nuovo evento</string>
<string name="event_edit_managed_hint">Gestito da “%1$s” - titolo, data e ripetizioni sono sincronizzate dai tuoi contatti. Promemoria e note sono modificabili dall\'utente</string>
<string name="settings_font_headings">Carattere dell\'intestazione</string>
<string name="settings_font_body">Carattere del corpo</string>
<string name="settings_font_system">Predefinito di sistema</string>
<string name="settings_font_choose_file">Scegli file…</string>
<string name="settings_font_custom_selected">Carattere personalizzato</string>
<string name="settings_font_import_failed">Impossibile leggere il file come font</string>
<string name="settings_section_views">Viste</string>
<string name="settings_quick_switch_header">Pulsante di cambio rapido</string>
<string name="settings_quick_switch_hint">Scegli attraverso quali viste scorrere mediante il pulsante in alto a destra, trascinale per riordinarle. Le viste disattivate rimangono raggiungibili dal menù di navigazione.</string>
<string name="settings_drawer_order_header">Menù di navigazione</string>
<string name="settings_drawer_order_hint">Trascina per riordinare le viste elencate nel menù di navigazione.</string>
<string name="reorder_drag_handle">Trascina per riordinare</string>
<string name="settings_views_subtitle">Ordine del pulsante di cambio rapido e del menù</string>
<string name="settings_special_dates_subtitle">Compleanni e anniversari dei contatti</string>
<string name="settings_section_special_dates">Date speciali dei contatti</string>
<string name="settings_special_dates_enable">Mostra date dei contatti</string>
<string name="settings_special_dates_enable_hint">Riporta compleanni ed altre date dei tuoi contatti nei calendari locali. Legge i contatti solo su questo dispositivo - niente viene caricato in rete ed i tuoi contatti non vengono mai modificati.</string>
<string name="settings_special_dates_type_birthday">Compleanni</string>
<string name="settings_special_dates_type_anniversary">Anniversari</string>
<string name="settings_special_dates_type_custom">Altre date</string>
<string name="settings_special_dates_template">Formato del titolo</string>
<string name="settings_special_dates_template_hint">Usa {name} per il contatto e {year} per l\'anno (l\'anno di nascita, o l\'anno di inizio di un anniversario; nascosto se sconosciuto).</string>
<string name="settings_special_dates_reminders">Promemoria</string>
<string name="settings_special_dates_show_year">Mostra anno</string>
<string name="settings_special_dates_show_year_hint">Include {year} nei titoli quando è noto</string>
<string name="settings_special_dates_sync_now">Sincronizza adesso</string>
<string name="settings_special_dates_never_synced">Ancora non sincronizzato</string>
<string name="settings_special_dates_last_synced">%1$s è un tempo relativo, per esempio \"5 minuti fa\"</string>
<string name="settings_special_dates_calendar_hint">Imposta colore e visibilità di ogni calendario nelle impostazioni dei calendari.</string>
<string name="settings_special_dates_paused_title">In pausa</string>
<string name="settings_special_dates_paused_hint">Calendula non può più leggere i tuoi contatti, quindi questi calendari non vengono aggiornati.</string>
<string name="settings_special_dates_grant">Concedi accesso</string>
<string name="settings_special_dates_disable_title">Disattivare date dei contatti?</string>
<string name="settings_special_dates_disable_all_message">Questo cancella i calendari delle date dei contatti ed i loro eventi. Ogni promemoria o note che erano stati aggiunti saranno persi.</string>
<string name="settings_special_dates_disable_type_message">Questo cancella il calendario “%1$s” ed i suoi eventi. Ogni promemoria o note che erano stati aggiunti saranno persi.</string>
<string name="settings_special_dates_disable_confirm">Spegni</string>
<string name="dialog_save">Salva</string>
</resources>

View File

@@ -0,0 +1,122 @@
<?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">一个现代化的日历应用</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="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_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="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">通过 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_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_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="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_end_until">于指定日期</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_access_default">默认</string>
<string name="event_access_public">公开</string>
</resources>

View File

@@ -287,6 +287,8 @@
<string name="settings_font_import_failed">Couldn\'t read that file as a font</string>
<string name="settings_week_start">Week starts on</string>
<string name="settings_week_start_auto">Automatic</string>
<string name="settings_week_numbers">Week numbers</string>
<string name="settings_week_numbers_summary">Show calendar-week numbers in month view</string>
<string name="settings_time_format">Time format</string>
<string name="settings_time_format_auto">Automatic</string>
<string name="settings_time_format_12h">12-hour (2:00 PM)</string>
@@ -428,6 +430,12 @@
<string name="calendars_backup_header">Backup</string>
<string name="calendars_backup_hint">Local calendars aren\'t synced anywhere, so export them to an .ics file to keep a copy.</string>
<string name="calendars_backup_action">Export as .ics file</string>
<string name="calendars_export_title">Export calendars</string>
<string name="calendars_export_hint">Choose which calendars to include in the .ics file.</string>
<string name="calendars_export_action">Export</string>
<string name="calendars_restore_header">Restore</string>
<string name="calendars_restore_action">Restore from .ics file</string>
<string name="calendars_restore_hint">Import events from a backup or another calendar app.</string>
<string name="calendars_auto_backup">Automatic backup</string>
<string name="calendars_auto_backup_hint">Periodically export your local calendars to a folder as an .ics file.</string>
<string name="calendars_auto_backup_folder">Backup folder</string>
@@ -460,11 +468,19 @@
<string name="import_failed">Couldn\'t read this file.</string>
<string name="import_no_calendar">No writable calendar to import into. Create a local calendar first.</string>
<string name="import_done_title">Import complete</string>
<string name="import_done_dedup_note">Events already in the calendar were skipped.</string>
<string name="import_done_added_label">Added</string>
<string name="import_done_skipped_label">Duplicates</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_button">Import</string>
<plurals name="import_title_count">
<item quantity="one">Importing %d event</item>
<item quantity="other">Importing %d events</item>
</plurals>
<plurals name="import_event_count">
<item quantity="one">%d event in this file.</item>
<item quantity="other">%d events in this file.</item>

View File

@@ -12,4 +12,5 @@
<locale android:name="de" />
<locale android:name="es" />
<locale android:name="it" />
<locale android:name="zh-CN" />
</locale-config>

View File

@@ -536,6 +536,30 @@ class CalendarRepositoryImplTest {
assertThat(fake.importedEvents.map { it.second }).containsExactly(3L, 3L)
}
@Test
fun `exportEvents forwards the chosen calendar-id subset to the data source`(
@TempDir tempDir: Path,
) = runTest {
val fake = FakeCalendarDataSource()
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
repo.exportEvents(calendarIds = setOf(1L, 4L))
assertThat(fake.lastExportableEventsCalendarIds).containsExactly(1L, 4L)
}
@Test
fun `exportEvents defaults to null so every eligible calendar is exported`(
@TempDir tempDir: Path,
) = runTest {
val fake = FakeCalendarDataSource()
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
repo.exportEvents()
assertThat(fake.lastExportableEventsCalendarIds).isNull()
}
private fun parsedEvent(uid: String?) = de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent(
uid = uid,
summary = "E",

View File

@@ -22,8 +22,8 @@ class EventDetailMapperTest {
eventColor: Any? = null,
eventColorKey: String? = null,
calendarColor: Int = 0xFFAABBCC.toInt(),
dtstart: Long = 1_000_000_000L,
dtend: Long = 1_000_003_600L,
dtstart: Long? = 1_000_000_000L,
dtend: Long? = 1_000_003_600L,
allDay: Int = 0,
location: String? = "Berlin",
calendarId: Long = 7L,
@@ -120,9 +120,31 @@ class EventDetailMapperTest {
}
@Test
fun `dtend before dtstart drops detail`() {
fun `dtend before dtstart is clamped to a zero-length event, not dropped`() {
// A backwards DTEND is malformed, but dropping it would make the event
// un-openable (the "Something went wrong" trap, same as issue #34); keep
// it as a zero-length event so the user can still open and fix it.
val detail = detailReader(dtstart = 2000L, dtend = 1000L).toDetail()
assertThat(detail).isNull()
assertThat(detail).isNotNull()
assertThat(detail!!.instance.start.toEpochMilliseconds()).isEqualTo(2000L)
assertThat(detail.instance.end.toEpochMilliseconds()).isEqualTo(2000L)
}
@Test
fun `pre-1970 negative dtstart is kept, not dropped (issue #34)`() {
// A yearly birthday/anniversary anchored before the epoch has a
// legitimately negative UTC epoch-millis DTSTART; recurring rows carry
// no DTEND (they use DURATION), so it stays end == begin.
val begin = -157_766_400_000L // 1965-01-01T00:00:00Z
val detail = detailReader(dtstart = begin, dtend = null).toDetail()
assertThat(detail).isNotNull()
assertThat(detail!!.instance.start.toEpochMilliseconds()).isEqualTo(begin)
assertThat(detail.instance.end.toEpochMilliseconds()).isEqualTo(begin)
}
@Test
fun `absent dtstart drops detail`() {
assertThat(detailReader(dtstart = null).toDetail()).isNull()
}
@Test

View File

@@ -23,6 +23,8 @@ internal class FakeCalendarDataSource : CalendarDataSource {
var eventDetailResult: (Long) -> EventDetail? = { null }
var eventColorPaletteResult: (Long) -> List<EventColorOption> = { emptyList() }
var exportableEventsResult: List<IcsEvent> = emptyList()
/** The [calendarIds] the last [exportableEvents] call received (null = all). */
var lastExportableEventsCalendarIds: Set<Long>? = null
/** UIDs the target calendar already holds, for import dedup. */
var existingUidsResult: Set<String> = emptySet()
/** Set to make the next write call throw. */
@@ -59,7 +61,10 @@ internal class FakeCalendarDataSource : CalendarDataSource {
override fun eventDetail(eventId: Long): EventDetail? = eventDetailResult(eventId)
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
eventColorPaletteResult(calendarId)
override fun exportableEvents(): List<IcsEvent> = exportableEventsResult
override fun exportableEvents(calendarIds: Set<Long>?): List<IcsEvent> {
lastExportableEventsCalendarIds = calendarIds
return exportableEventsResult
}
override fun existingUids(calendarId: Long): Set<String> = existingUidsResult

View File

@@ -0,0 +1,45 @@
package de.jeanlucmakiola.calendula.data.calendar
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class SearchMapperTest {
private fun searchReader(
id: Long = 1L,
calendarId: Long = 7L,
title: String? = "Birthday",
dtstart: Long? = 1_000_000_000L,
dtend: Long? = null,
duration: String? = "P1D",
allDay: Int = 1,
eventColor: Any? = null,
calendarColor: Int = 0xFFAABBCC.toInt(),
location: String? = null,
): MapColumnReader = MapColumnReader(
SearchProjection.IDX_ID to id,
SearchProjection.IDX_CALENDAR_ID to calendarId,
SearchProjection.IDX_TITLE to title,
SearchProjection.IDX_DTSTART to dtstart,
SearchProjection.IDX_DTEND to dtend,
SearchProjection.IDX_DURATION to duration,
SearchProjection.IDX_ALL_DAY to allDay,
SearchProjection.IDX_EVENT_COLOR to eventColor,
SearchProjection.IDX_CALENDAR_COLOR to calendarColor,
SearchProjection.IDX_LOCATION to location,
)
@Test
fun `pre-1970 negative dtstart still surfaces in search (issue #34)`() {
val begin = -157_766_400_000L // 1965-01-01T00:00:00Z
val result = searchReader(dtstart = begin, dtend = null, duration = "P1D").toSearchResult()
assertThat(result).isNotNull()
assertThat(result!!.start.toEpochMilliseconds()).isEqualTo(begin)
assertThat(result.title).isEqualTo("Birthday")
}
@Test
fun `absent dtstart drops the search hit`() {
assertThat(searchReader(dtstart = null).toSearchResult()).isNull()
}
}

View File

@@ -95,6 +95,14 @@ class SettingsPrefsTest {
assertThat(prefs.showHourLines.first()).isTrue()
}
@Test
fun `week numbers default off and round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.showWeekNumbers.first()).isFalse()
prefs.setShowWeekNumbers(true)
assertThat(prefs.showWeekNumbers.first()).isTrue()
}
@Test
fun `autofocus event title defaults on and round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))

File diff suppressed because it is too large Load Diff

View File

@@ -1,204 +0,0 @@
# Calendula - Plan 03: Write Support (Milestone 2 / v2.0)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Calendula kann Events anlegen, bearbeiten und löschen — direkt über
`CalendarContract`-Writes, ohne eigene DB. Der V1-Spec dient als Leitplanke,
nicht als Gesetz: Ausgeliefert wird in vier Slices (v1.1 → v2.0), jeder Slice
ist für sich releasebar und lässt `./gradlew lint test assembleDebug` grün.
**Architecture:** Writes laufen durch dieselbe Schichtung wie Reads:
`ui/``CalendarRepository` (Interface) → `CalendarDataSource`
`ContentResolver.insert/update/delete`. Kein neuer Layer, keine Transaktions-
Abstraktion — der Provider notified nach jedem Write selbst, der bestehende
`ContentObserver`-Tick aktualisiert alle Views automatisch (F3 gilt unverändert).
Domain bleibt pure Kotlin.
**Leitentscheidungen (Abweichungen / Präzisierungen ggü. Spec §2 "V2"):**
1. **Permission-Strategie:** `WRITE_CALENDAR` kommt ins Manifest. Das Onboarding
fragt READ+WRITE zusammen an (eine System-Dialog-Gruppe), zwingend bleibt
nur READ — wer Write ablehnt, nutzt die App weiter read-only.
v1.0-Upgrader (haben nur READ) bekommen den WRITE-Request kontextuell beim
ersten Schreib-Versuch. Onboarding-Footnote verliert die "Nur Lesezugriff"-
Behauptung (wäre mit Manifest-Eintrag gelogen).
2. **Read-only-Kalender respektieren:** `Calendars.CALENDAR_ACCESS_LEVEL` wird
mitgelesen (`canModifyContents` = Level ≥ `CAL_ACCESS_CONTRIBUTOR`).
Edit/Delete-Actions erscheinen gar nicht erst für WebCal-Subscriptions,
Geburtstags- und andere read-only-Kalender.
3. **Recurring Events:** Löschen bietet "Nur dieser Termin" (Exception-Insert
via `Events.CONTENT_EXCEPTION_URI` mit `STATUS_CANCELED` +
`ORIGINAL_INSTANCE_TIME`) vs. "Ganze Serie" (Delete der Events-Row).
Bearbeiten startet mit "ganze Serie"; Occurrence-Edit (Exception mit neuen
Werten) folgt erst, wenn das Serien-Edit stabil ist.
4. **Kein RRULE-Editor in v1.2:** Create startet ohne Wiederholungs-UI
(einmalige Events). Ein einfacher Recurrence-Picker (täglich/wöchentlich/
monatlich/jährlich + Ende) kommt mit v1.3/v2.0.
5. **Conflict UX (Spec V2 "event modified externally during edit"):** kein
Locking. Beim Speichern wird gegen die beim Laden gemerkte Row verglichen
(Dirty-Check auf den editierten Feldern); bei externem Konflikt Dialog
"Überschreiben / Verwerfen". Mehr ist YAGNI.
---
## Slices
| Slice | Inhalt | Status |
|---|---|---|
| v1.1 | Write-Fundament: `WRITE_CALENDAR`, `canModifyContents`, Delete (Serie + einzelnes Vorkommen) | ausgeliefert (v1.1.0, 2026-06-11) |
| v1.2 | Create: Event-Formular (Titel, Kalender, ganztägig, Start/Ende, Ort, Beschreibung), FAB, Default-Kalender-Pref | ausgeliefert (v1.2.0, 2026-06-11) |
| v1.3 | Edit: Formular wiederverwendet, Serien-Edit, Reminder-Edit, einfacher Recurrence-Picker | ausgeliefert (v1.3.0, 2026-06-11) |
| v2.0 | Konflikt-Dialog, Polish-Pass (Store-Copy, Screenshots), Release | ausgeliefert (v2.0.0, 2026-06-11) |
## v1.1 — Write-Fundament + Delete
**Build/Manifest:**
- [x] `AndroidManifest.xml`: `WRITE_CALENDAR` ergänzen
**Data layer:**
- [x] `Projections.kt`: `CALENDAR_ACCESS_LEVEL` in `CalendarProjection`
- [x] `Models.kt`: `CalendarSource.canModifyContents: Boolean` (Default `false`).
Kein neuer `FailureReason` — Delete-Fehler sind ein Snackbar-Fall, kein
Full-Screen-Failure
- [x] `CalendarMapper.kt`: Access-Level → `canModifyContents`
- [x] `CalendarDataSource`: `deleteEvent(eventId)`, `deleteOccurrence(eventId, beginMillis)`
— Impl in `AndroidCalendarDataSource` (`delete` auf Events-URI bzw.
Exception-Insert), `WriteFailedException` bei 0 rows / null-Uri
- [x] `CalendarRepository(+Impl)`: beide Methoden durchreichen, auf `io`
**UI:**
- [x] `EventDetailUiState.Success.canModify` (Kalender-Lookup im ViewModel)
- [x] `EventDetailViewModel`: `delete(mode)` mit eigenem One-Shot-State
(Idle/Deleting/Deleted/Failed); `SecurityException` → kontextueller
WRITE-Request statt Failure-Screen
- [x] `EventDetailScreen`: Edit/Delete nur wenn `canModify`; Delete →
Confirm-Dialog (recurring: "Nur dieser Termin" / "Ganze Serie"),
Erfolg → zurück, Fehler → Snackbar
- [x] Onboarding (`PermissionScreen`): `RequestMultiplePermissions` READ+WRITE,
Gate bleibt READ; Copy-Anpassung (Footnote, Rationale-Body) DE+EN
**Tests:**
- [x] `FakeCalendarDataSource`: Write-Ops aufnehmen
- [x] `CalendarRepositoryImplTest`: delete-Pfade (Erfolg, Fehler)
- [x] `CalendarMapperTest`: Access-Level-Mapping
## v1.2 — Create
- [x] `EventForm`-Domain-Modell + Validierung (`problems()`: EndBeforeStart,
NoCalendar; leerer Titel und Instant-Events erlaubt)
- [x] `EventEditScreen` (ein Formular, ab v1.3 auch für Edit), M3-Date/Time-Picker
- [x] FAB-Stack auf allen drei Hauptansichten (`CalendarFabColumn`: "+" immer,
Heute-Pill darüber), vorbelegt mit dem sichtbaren Tag
- [x] Kalender-Vorauswahl: explizit > zuletzt benutzt
(`CalendarPrefs.lastUsedCalendarId` statt Settings-Eintrag) > erster
beschreibbarer; Picker bietet nur beschreibbare Kalender an
- [x] `insertEvent(form): Long` im DataSource; `EventWriteMapper` (JVM-testbar)
normalisiert all-day auf UTC-Mitternächte mit exklusivem DTEND
## v1.3 — Edit
**Domain:**
- [x] `EventForm.rrule` (roher RRULE-Wert, null = einmalig); komplexe Regeln
(ordinales BYDAY wie "2TH", BYMONTHDAY etc.) bleiben verbatim erhalten,
solange der Picker sie nicht ersetzt
- [x] `SimpleRecurrence` (FREQ + INTERVAL + UNTIL/COUNT + wöchentliches
BYDAY — Review-Feedback: "jede Woche Mo+Fr" muss gehen) mit
`parseSimpleRecurrence`/`toRRule` (Recurrence.kt, JVM-getestet)
- [x] `EventDetail.toEditForm(begin, end, zone)` — Prefill inkl. all-day-
Rückrechnung (exklusives DTEND → letzter abgedeckter Tag)
- [x] Validierung: `RecurrenceEndsBeforeStart` (UNTIL vor erstem Tag hieße
null Vorkommen — Event würde unsichtbar)
**Data layer:**
- [x] `buildEventUpdateValues(original, updated, seriesDtStart, zone)`
Dirty-Check, nur geänderte Spalten; Zeitfelder als Einheit:
einmalig → DTSTART/DTEND (RRULE/DURATION genullt), wiederkehrend →
Serien-DTSTART verschiebt sich um das User-Delta, DURATION statt DTEND
- [x] `CalendarDataSource.updateEvent(eventId, original, updated)` — Events-Row-
Update + Reminder-Diff nach Minuten (unberührte Rows behalten ihre Methode)
- [x] `insertEvent` versteht RRULE (RRULE+DURATION statt DTEND — Provider-Invariante)
- [x] `CalendarRepository(+Impl).updateEvent` durchgereicht, auf `io`
- [x] `EventDetailMapper`: Titel bleibt roh (kein "(Ohne Titel)"-Fallback mehr —
der Detail-Screen ersetzt selbst lokalisiert, das Formular braucht den Rohwert)
**UI:**
- [x] `EventEditViewModel.openForEdit` (lädt Detail, merkt Original für
Dirty-Check; unverändertes Formular speichert als No-Op); Felder mit
Werten werden unabhängig vom Settings-Default eingeblendet
- [x] `EventEditScreen`: `editKey`-Parameter, Kalender im Edit-Modus fixiert,
Repeat-Karte + `RecurrencePickerDialog` (Presets per Tap, Custom-Schritt
mit Intervall/Einheit + Wochentags-Toggles bei "Wochen" (Wochenstart
nach Locale, Start-Wochentag vorausgewählt) + Ende nie/Datum/Anzahl,
OptionCard-Stil)
- [x] Recurrence-Humanizer nach `ui/common/RecurrenceText.kt` (Detail + Formular)
- [x] `EventDetailScreen`: Edit-Action (nur `canModify`, kontextueller
WRITE-Request wie Delete); Save schließt Formular **und** Detail (die
getappte Occurrence existiert danach evtl. nicht mehr)
- [x] **Occurrence-Edit (aus v2.0 vorgezogen, Review-Feedback):** Die
Scope-Frage kommt **beim Speichern** (Google-Modell, Review-Feedback):
ein dirty wiederkehrender Termin parkt in `SaveUiState.AwaitingScope`,
der Dialog bietet "Nur dieser Termin / Dieser und alle folgenden /
Ganze Serie"; bei geänderter Wiederholungsregel entfällt "nur dieser"
(eine Exception-Row trägt keine eigene Regel). "Nur dieser" schreibt
eine Modified-Occurrence-Exception (`CONTENT_EXCEPTION_URI`, alle
Formularwerte, leere Optionals als explizite NULLs weil der Provider
die Serien-Row klont), Reminder werden gegen die tatsächlichen
Provider-Rows abgeglichen. "Dieser und folgende" = Serien-Split:
neues Event mit den Formularwerten (insert zuerst — schlägt es fehl,
bleibt das Original unberührt), dann Original-RRULE per UNTIL gekappt;
ab der ersten Occurrence = normales Serien-Update. Ein mitgenommenes
COUNT zählt in der neuen Serie neu (kein Rest-COUNT-Rechnen wie AOSP)
- [x] **Delete dreistufig (Review-Feedback):** "Nur dieser Termin" /
"Dieser und alle folgenden" (RRULE-Truncation via `rruleTruncatedAt`)
/ "Alle Termine der Serie"; ab der ersten Occurrence = ganze Serie
löschen
- [x] **Split-Duplikat-Bugfix (On-Device-Review):** Nach dem Serien-Split
blieb die getappte Occurrence doppelt sichtbar. Root cause (per
adb-Probe verifiziert): der Provider regeneriert die Instances eines
Events nur aus den **Values des Updates selbst** — ein RRULE-only-
Update lässt die alten Instances stehen, und ein Teilset (nur DTSTART)
erzeugt kaputte Nulllängen-Instanzen. Truncation-Updates schicken
deshalb das komplette Zeit-Set (DTSTART/DURATION/RRULE/ALL_DAY/
EVENT_TIMEZONE) zusammen (`truncateSeries`), wie AOSPs
EditEventHelper. Zusätzlich (Robustheit, Google-Modell): Cutoff =
Ende des Vortags in der Event-Zeitzone (`previousLocalDayEndUtcMillis`)
statt Occurrence1s, und der Recurrence-Picker rendert UNTIL als
lokales Tagesende in UTC (`toRRule(zone)`) statt pauschal `T235959Z`
(sonst kann bei UTC+x ein Extra-Tag hineinrutschen)
- [x] `CalendarHost`: Edit-Overlay mit Held-Key-Pattern
- [x] `EventFormField.Recurrence` (Formular, "Mehr Felder", Settings-Default)
- [x] Strings DE+EN
**Tests:**
- [x] `RecurrenceTest` (Parse/Render/Roundtrip, Ablehnung komplexer Regeln)
- [x] `EventFormTest`: Prefill (timed/all-day), `populatedFields`, UNTIL-Validierung
- [x] `EventWriteMapperTest`: Duration-Format, Dirty-Check-Pfade (Text-only,
Zeit einmalig/wiederkehrend, Recurrence an/aus, Reminder-only)
- [x] `CalendarRepositoryImplTest` + `FakeCalendarDataSource`: update-Pfade
- [x] `EventDetailMapperTest`: roher Titel
Bewusst nicht in v1.3 (→ v2.0): Konflikt-Dialog, Kalender-Wechsel beim
Bearbeiten (Sync-Adapter-Minenfeld, sperren auch alle Stock-Apps).
## v2.0 — Abschluss (Scope-Recut 2026-06-11, nach v1.4)
- ~~Quick-Add-Sheet (Titel + Zeit, Rest Defaults)~~ — **gestrichen**: das
Formular öffnet bereits vorbefüllt (sichtbarer Tag, zuletzt benutzter
Kalender, optionale Felder versteckt); der Sheet spart nur einen
Screen-Übergang und kostet eine zweite Create-Surface. Nur bei
Praxis-Feedback wieder aufnehmen
- ~~Occurrence-Edit (Exception mit geänderten Werten)~~ — schon in v1.3
ausgeliefert (vorgezogen)
- [x] Konflikt-Dialog beim Speichern (Leitentscheidung 5): `EditSnapshot`
(Formular + rohe Row-Zeiten) wird beim Laden gemerkt und vor dem
Schreiben gegen einen frischen Read verglichen; Abweichung parkt den
Save in `AwaitingConflict` (Überschreiben/Verwerfen/Abbrechen,
OptionCard-Stil), gelöschtes Event → `Gone`-Dialog. "Überschreiben"
schreibt weiterhin nur dirty Felder
- Kalender-Wechsel beim Bearbeiten → v3-Backlog (copy+delete-Modell)
- [x] Polish: F-Droid-Description + README auf Write-Support + Reminder
aktualisiert (DE+EN)
- [x] F-Droid-Screenshots (de-DE + en-US, je 6: Woche/Monat/Tag/Detail/
Formular/Onboarding) — mit Demo-Kalendern auf dem Gerät aufgenommen
- [x] Changelog, Release-Tag v2.0.0 (ausgeliefert 2026-06-11 — Milestone 2
damit abgeschlossen)

View File

@@ -1,119 +0,0 @@
# Calendula - Plan 04: Reminder Notifications (v1.4)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Calendula stellt Erinnerungen selbst als Notification zu (Etar-Modell).
Der Provider plant die Alarme und broadcastet
`android.intent.action.EVENT_REMINDER` — die sichtbare Notification postet er
**nicht**, das muss eine Kalender-App tun. Für Nutzer, deren einzige
Kalender-App Calendula ist, ist das essenziell, kein Nice-to-have.
`./gradlew lint test assembleDebug` bleibt grün; Release erst nach
On-Device-Review.
**Architecture:** Eigenes kleines Datenmodul `data/reminders/` neben
`data/calendar/` — der Receiver braucht weder Repository noch Flows. Schichtung
wie gehabt: `EventReminderReceiver` (Hilt-EntryPoint) →
`ReminderAlertStore` (Interface, Android-Impl auf `CalendarAlerts`) →
`ReminderNotifier` (NotificationManager). Domain bleibt pure Kotlin
(`ReminderAlert`-Modell, JVM-testbare Textformatierung).
**Recherche-Befunde (AOSP `CalendarAlarmManager` + Etar, 2026-06-11):**
1. Der Provider legt `CalendarAlerts`-Rows **nur für `METHOD_ALERT`-Reminder**
an (AOSP-Query: `AND method=1`). Der im Roadmap-Eintrag geforderte
METHOD-Filter (E-Mail überspringen) passiert also schon upstream — wir
filtern nicht doppelt. Calendula schreibt eigene Reminder ohnehin als
`METHOD_ALERT`.
2. Der Broadcast ist implizit (Action + `content://com.android.calendar/…`-URI,
Extra `alarmTime`). Etars Manifest-Receiver ist `exported="true"` mit
`<data android:scheme="content"/>` — das übernehmen wir (plus Host,
enger gefasst). Den URI-Inhalt werten wir nicht aus; wir queryen selbst
"fällig & noch SCHEDULED".
3. Etar postet aus dem Zustand `SCHEDULED FIRED` und verwaltet Dismiss über
eigene Services. Wir vereinfachen: nur `STATE_SCHEDULED AND alarmTime <= now`
posten, danach best-effort auf `FIRED` setzen (braucht `WRITE_CALENDAR`;
`SecurityException` wird geschluckt). Weggewischte Notifications kommen so
nie wieder, ohne deleteIntent-Maschinerie: FIRED-Rows fassen wir nicht an.
Re-Broadcasts ohne Write-Recht ersetzen still (Tag pro Alert +
`setOnlyAlertOnce`).
**Leitentscheidungen:**
1. **Kein eigenes Alarm-Scheduling** (kein `SCHEDULE_EXACT_ALARM`, kein
`BOOT_COMPLETED`, kein WorkManager): Zustellung hängt am Provider-Broadcast.
Etars Zusatz-Maschinerie (eigener AlarmScheduler) kommt erst, wenn sich
Zuverlässigkeit auf echten Geräten als Problem zeigt (Roadmap: bewusst
verschoben, ebenso Snooze-/Dismiss-Actions und Battery-Exemption).
2. **Toggle default ON, Onboarding-Schritt danach:** Nach dem Kalender-Grant
folgt ein zweiter Onboarding-Screen (gleiche Shell wie der Permission-
Screen): erklärt Reminder, warnt vor Duplikaten (zweite Kalender-App mit
aktiven Notifications), fragt `POST_NOTIFICATIONS` an (nur API 33+ zeigt
einen Dialog; minSdk 29). "Später" schaltet den Toggle aus. Der Schritt
erscheint genau einmal (`reminder_onboarding_done`-Pref) — auch für
v1.0v1.3-Upgrader, die das Feature so entdecken.
3. **Settings-Spiegel:** Abschnitt "Erinnerungen" mit demselben Toggle +
Duplikat-Hinweis. Einschalten fordert `POST_NOTIFICATIONS` kontextuell an,
wenn sie fehlt.
4. **Tap öffnet das Event-Detail:** Notification-Intent trägt
eventId/begin/end; `MainActivity` wird `singleTop`, reicht den Key als
Compose-State an `CalendarHost` durch (gleiches LongArray-Key-Muster wie
der Detail-Overlay selbst).
5. **Ein Kanal, einfache Inhalte:** Kanal "Erinnerungen"
(`IMPORTANCE_HIGH`), pro Alert eine Notification (Tag = Alert-Id):
Titel = Eventtitel (Fallback "(Ohne Titel)"), Text = Zeitspanne
(ganztägig: Datum, UTC gelesen) + Ort. Kein Grouping/Summary, kein
Vollbild-Alarm.
---
## Tasks
**Manifest / Resourcen:**
- [x] `POST_NOTIFICATIONS` ins Manifest; Receiver `.reminders.EventReminderReceiver`
`exported="true"`, Intent-Filter `EVENT_REMINDER` + `data scheme=content
host=com.android.calendar`; `MainActivity``launchMode="singleTop"`
- [x] Monochromes Notification-Icon `drawable/ic_notification.xml`
- [x] Strings DE+EN: Kanal, Onboarding-Copy, Settings-Abschnitt + Hinweis
**Prefs:**
- [x] `SettingsPrefs.remindersEnabled` (default **true**) + Setter;
`reminderOnboardingDone` (default false) + Setter; `SettingsPrefsTest`
**Data layer (`data/reminders/`):**
- [x] `ReminderAlert`-Modell (in `data/reminders/`, nicht domain — Alerts
erreichen nie einen Screen): alertId, eventId, begin/end als Millis,
title, location, isAllDay
- [x] `ReminderAlertStore` (Interface) + `AndroidReminderAlertStore`:
`dueAlerts(nowMillis)` = `CalendarAlerts` mit
`STATE_SCHEDULED AND ALARM_TIME <= now`;
`markFired(ids, nowMillis)` setzt STATE/RECEIVED_TIME/NOTIFY_TIME,
`SecurityException` → Log (Write-Recht optional)
- [x] `ReminderNotifier`: Kanal lazy anlegen, eine Notification pro Alert
(Tag = alertId, `setOnlyAlertOnce`, autoCancel, `when` = begin,
Category EVENT), Content-PendingIntent auf `MainActivity` mit
eventId/begin/end
- [x] Zeitspannen-Text als pure Funktion (JVM-testbar) + Test
**Receiver:**
- [x] `EventReminderReceiver` (`@AndroidEntryPoint`): Action prüfen,
`goAsync()`; raus, wenn Pref aus, READ_CALENDAR fehlt oder
Notifications systemseitig geblockt; sonst posten → `markFired`
**UI:**
- [x] Onboarding-Shell aus `PermissionScreen` extrahieren
(`OnboardingScaffold` + BenefitRow, intern wiederverwendet)
- [x] `NotificationOnboardingScreen` + ViewModel: Benefit-Rows (verpasst
nichts / Duplikat-Warnung), Primär-Button fordert `POST_NOTIFICATIONS`
(API 33+) und lässt den Toggle an, "Später" schaltet ihn aus; beide
setzen `reminder_onboarding_done`
- [x] `RootScreen`: Kalender-Gate → Reminder-Schritt (einmalig) → `CalendarHost`
- [x] `CalendarHost`: externer Detail-Key (Notification-Tap) wird wie ein
Event-Tap konsumiert; `MainActivity` parst Intent (onCreate +
onNewIntent) in Compose-State
- [x] Settings: Abschnitt "Benachrichtigungen" — Toggle (mit kontextuellem
Permission-Request beim Einschalten) + Duplikat-Hinweistext
**Abschluss:**
- [x] `./gradlew lint test assembleDebug` grün
- [x] CHANGELOG (`[Unreleased]`), ROADMAP-Status; **kein** Tag/Release vor
On-Device-Review

View File

@@ -1,150 +0,0 @@
# Calendula - Plan 05: ICS Export (v2.7, Branch 1 von 2)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Die Schreib-Hälfte des `.ics`-Themas. Calendula serialisiert eigene
Events nach RFC 5545 — als Einzel-Event (Share-Sheet) und als
Ganz-Kalender-Backup (SAF-Datei). Damit existiert für gerätelokale Kalender
(`ACCOUNT_TYPE_LOCAL`) zum ersten Mal ein Backup; ohne Sync ist ein verlorenes
Gerät sonst Totalverlust. Diese Branch baut **nur Export**; Import
(Parser, Restore, Open-into-form) folgt in Branch 2 (`feat/ics-import`),
beide landen zusammen in **einem** Release v2.7.0 — es gibt also keine
Zwischenversion, die UIDs schreibt, ohne sie je zu lesen.
`./gradlew lint test assembleDebug` bleibt grün; Release erst nach
On-Device-Review (gemeinsam mit Branch 2).
**Architecture:** Neue reine-Kotlin-Engine `domain/ics/` — kein
`CalendarContract`, keine Android-Deps, voll JVM-testbar. Kern ist
`IcsWriter` (nimmt eine Liste eigener Event-Modelle + Kalender-Metadaten,
gibt einen `VCALENDAR`-String zurück). Keine ICS-Library: wir bleiben auf
`kotlinx-datetime` (kein `java.time`-Desugaring) und hand-rollen wie schon
bei RRULE in `Recurrence.kt`. Das Schreiben in eine SAF-Datei / das
Share-Intent liegt in einer dünnen Android-Schicht
(`data/ics/IcsExporter` o. ä.), die Provider-Reads → Domain-Modelle →
`IcsWriter``OutputStream` verdrahtet.
**Recherche-Befunde (Codebase, 2026-06-18):**
1. **Keine ICS-Library, kein `java.time`-Desugaring** — Stack ist
`kotlinx-datetime` + `kotlin.time.Instant`. RRULE wird bereits in
`domain/Recurrence.kt` von Hand geparst/gerendert (inkl. der sorgfältigen
`UNTIL`/DST-Korrektur). `IcsWriter` reiht sich in genau diese Kultur ein und
nutzt `SimpleRecurrence.toRRule()` direkt.
2. **Kein UID-Handling.** `Events.UID_2445` wird heute nirgends gelesen oder
geschrieben. Für idempotenten Restore in Branch 2 muss Import auf UID
matchen — ohne UID verdoppelt ein erneuter Import alles. Darum schreibt
**diese** Branch bereits UID bei jedem Insert (Vorarbeit; ohne Import noch
unsichtbar, zahlt sich erst in Branch 2 aus — beide im selben Release).
3. **Zeitzonen werden gespeichert, aber nie vom Nutzer gewählt.** Lesen/Anzeige:
`EVENT_TIMEZONE` landet in `EventDetail.eventTimezone`, das Detail zeigt ein
Fremdzonen-Label nur bei Abweichung vom Gerät (`foreignTimeZoneLabel`).
Schreiben (`EventWriteMapper.toWriteTimes`): all-day → UTC-Mitternachten,
`EVENT_TIMEZONE="UTC"`; getimt → `EVENT_TIMEZONE = zone.id`, und alle Caller
übergeben `currentSystemDefault()` (kein Zonen-Feld im Formular). Jedes selbst
erstellte Event trägt also die Gerätezone zum Erstellzeitpunkt; eingesynkte
Events behalten ihre Originalzone.
**Leitentscheidungen:**
1. **Zeitzonen-Regel beim Schreiben (fallbasiert):**
- **All-day** → `DTSTART;VALUE=DATE:YYYYMMDD`, `DTEND` exklusiv
(Tag-danach). Keine Zone — trivial korrekt.
- **Getimt, nicht wiederkehrend** → UTC-Instant `…T…Z`. Ein Instant ist ein
Instant; die Anzeige rechnet beim Import wieder in die Gerätezone. Verlustfrei.
- **Getimt, wiederkehrend** → `DTSTART;TZID=<EVENT_TIMEZONE>:<lokale Wandzeit>`.
Eine Serie muss an der **Wandzeit** verankert sein, sonst driftet ein
„wöchentlich 9 Uhr" über die nächste DST-Grenze um eine Stunde. Die Zone
liegt bereits in `EVENT_TIMEZONE` vor; die lokale Wandzeit ist eine
`kotlinx-datetime`-Konversion (Instant → LocalDateTime in der Zone).
- **`VTIMEZONE`-Blöcke werden bewusst NICHT emittiert.** Beim eigenen
Round-Trip löst Branch-2-Import `TZID` gegen die OS-tz-Datenbank auf
(`kotlinx-datetime`/`java.time` kennen jede IANA-Id). Technisch nicht
RFC-konform; einziger Preis sind strenge Fremd-Parser ohne tz-DB — als
bekannte Lücke dokumentiert (Skip-and-report-Territorium des Imports),
kein Blocker. Voll-`VTIMEZONE` ist „später, falls nötig".
2. **UID bei jedem Insert.** `insertEvent` schreibt fortan `Events.UID_2445`
(z. B. `<random-uuid>@calendula`). Bestehende Events ohne UID exportieren
wir mit einer **deterministischen, stabilen** Fallback-UID, abgeleitet aus
`event-id + DTSTART` (`<id>-<dtstart>@calendula`), damit derselbe Bestand
über mehrere Backups dieselbe UID behält und Branch-2-Restore nicht
verdoppelt. Bestehende Rows werden **nicht** rückwirkend gestempelt
(kein Migrations-Sweep über fremde Kalender).
3. **Manueller Export, kein Background.** Backup via
`ACTION_CREATE_DOCUMENT` (SAF, MIME `text/calendar`, Default-Name
`calendula-backup-<datum>.ics`); Einzel-Event-Share via `ACTION_SEND` mit
einem `FileProvider`-Cache-File (`text/calendar`). Kein WorkManager, kein
geplantes/automatisches Backup (passt zum „kein Hintergrunddienst"-Ethos;
Auto-Backup bleibt explizit Roadmap-`later`).
4. **Backup-Layout: eine kombinierte `VCALENDAR`-Datei** über alle
gerätelokalen (beschreibbaren) Kalender. Pro Event ein `VEVENT`; die
Kalender-Zugehörigkeit reist als `X-WR-CALNAME` / `CATEGORIES` o. ä. mit,
damit Branch-2-Restore wieder auffächern oder per Ziel-Picker einsortieren
kann. Eine Datei ist einfacher zu teilen/abzulegen als n Dateien.
*Offen, vor dem Backup-Task zu fixieren:* exaktes Property fürs
Kalender-Mapping (`X-WR-CALNAME` pro `VCALENDAR` erlaubt nur einen Namen;
für mehrere Kalender in einer Datei brauchen wir ein Pro-`VEVENT`-Property
wie `X-CALENDULA-CALENDAR` oder `CATEGORIES`).
5. **Feldumfang = was Calendula modelliert.** `IcsWriter` serialisiert genau
die gelesenen Felder: `SUMMARY`, `DTSTART`/`DTEND` (Regel #1),
`LOCATION`, `DESCRIPTION`, `RRULE` (über `toRRule`), `VALARM` aus den
Remindern (DISPLAY, `TRIGGER` = `-PT<min>M`), `STATUS`
(CONFIRMED/TENTATIVE/CANCELLED), `TRANSP` (Free→TRANSPARENT/Busy→OPAQUE),
`UID`, `DTSTAMP`. Felder ohne sauberes Modell (Attendees, RECURRENCE-ID-
Ausnahmen) bleiben **vorerst weg** — Export erzeugt nichts, was Import in
Branch 2 nicht auch wieder lesen kann.
6. **Korrekte RFC-5545-Mechanik:** Zeilen-Folding bei >75 Oktett (CRLF +
Space-Fortsetzung), Text-Escaping (`\` `;` `,` `\n`), CRLF-Zeilenenden,
`PRODID`/`VERSION:2.0`-Header. Eine reine, einzeln getestete Hilfsschicht
(`IcsLine`/`fold`/`escapeText`), nicht ad hoc im Writer verstreut.
---
## Tasks
**Domain-Engine (`domain/ics/`, reine Kotlin, JVM-Tests):**
- [x] `IcsText`: `escapeText`, `foldLine` (75-Oktett, CRLF+Space) + Test
(`IcsTextTest`). Wert-Helfer (Instant→`…T…Z`, Wandzeit→`TZID`,
LocalDate→`VALUE=DATE`) leben als private Helfer in `IcsWriter`.
- [x] `IcsEvent`-Eingabemodell (reine Kotlin: summary, start/end als Instant
+ isAllDay + zoneId, recurrenceRule?, location, description,
reminderMinutes, status, availability, uid, calendarName) — entkoppelt
vom Provider-Modell
- [x] `IcsWriter.writeCalendar(events, dtStamp)` → String: Header, pro Event
`VEVENT` nach Entscheidung #5, Zeitzonen-Regel #1, `VALARM`; JVM-Test
`IcsWriterTest` (all-day, getimt, wiederkehrend+TZID, unbekannte Zone,
Reminder, Escaping)
- [x] UID-Ableitung `deriveIcsUid` (`uid ?: "<eventId>-<dtstartMillis>@calendula"`)
+ Stabilitätstest
**Provider → Domain (`data/calendar/IcsExportMapper.kt`):**
- [x] Mapper Provider-Row → `IcsEvent` (`ColumnReader.toIcsEvent`) inkl.
DURATION→DTEND-Rekonstruktion (`parseRfc2445DurationMillis`),
`EventExportProjection`; Datasource-Methode `exportableEvents()` +
Repository `exportEvents()`; Test `IcsExportMapperTest`
- [x] `insertEvent` schreibt `Events.UID_2445` (`UUID@calendula`) bei jedem
Create
**Android-Export-Schicht:**
- [x] `data/ics/IcsExporter`: `writeDocument(uri)` (SAF) + `stageShareFile`
(FileProvider-Cache) als UTF-8
- [x] Einzel-Event-Share: Share-Action im Event-Detail → `IcsWriter` für ein
Event (one-off) → Cache-File über `FileProvider``ACTION_SEND`
- [x] Ganz-Kalender-Backup: „Export as .ics file" in Settings → Calendars →
`ACTION_CREATE_DOCUMENT` → in den URI streamen; Ergebnis-Snackbar
(Plural „Exported N events")
- [x] `FileProvider` + `file_paths.xml` im Manifest (Cache-Dir für Shares)
- [x] Strings DE+EN: Share-Label/Chooser/Fehler, Backup-Sektion/Aktion/
Fehler + Plural, dateierter Default-Name
**Abschluss:**
- [ ] `./gradlew lint test assembleDebug` grün ← **nächster Schritt (Test)**
- [x] CHANGELOG (`[Unreleased]`) ergänzt
- [ ] On-Device-Review; **kein** Tag/Release vor Review und vor Merge von
Branch 2 (`feat/ics-import`)
**Offene Detail-Calls (vor Review klären, nicht-blockierend):**
- Kalender→Event-Mapping nutzt das per-`VEVENT`-Property `X-CALENDULA-CALENDAR`
(statt `X-WR-CALNAME`), damit eine kombinierte Datei mehrere Kalender trägt.
- Backup = **eine** kombinierte `VCALENDAR`-Datei über alle lokalen Kalender.
- EXDATE / `RECURRENCE-ID`-Ausnahmen werden beim Export ausgelassen
(`ORIGINAL_ID IS NULL`) — dokumentierter v1-Grenzfall, Import lässt sie auch aus.

View File

@@ -1,122 +0,0 @@
# Calendula - Plan 06: ICS Import (v2.7, Branch 2 von 2)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Die Lese-Hälfte des `.ics`-Themas, aufgesetzt auf den Writer aus
Branch 1 (`feat/ics-export`, gemerged in `release/v2.7.0`). Calendula parst
RFC-5545-Dateien und führt sie über zwei Wege ein: eine einzelne `VEVENT` öffnet
das vorausgefüllte Erstellen-Formular (Review vor dem Speichern), eine Datei mit
vielen Events geht in einen Bulk-Import mit Ziel-Kalender-Auswahl und
Ergebnis-Report. Damit schließt sich der Backup→Restore-Kreis für lokale
Kalender. Beide Branches landen in **einem** Release v2.7.0.
`./gradlew lint test assembleDebug` bleibt grün; Release erst nach
On-Device-Review.
**Architecture:** `IcsParser` lebt rein in `domain/ics/` neben `IcsWriter`
kein Android, JVM-testbar, symmetrisch zum Writer. Ausgabe ist ein
`IcsParseResult` (`events: List<ParsedIcsEvent>` + `warnings: List<String>`).
`ParsedIcsEvent` ist die Parse-Variante von `IcsEvent` (gleiche Felder, aber
`uid: String?` — eine eingelesene `VEVENT` kann ohne UID kommen). Zwei Adapter:
`ParsedIcsEvent.toEventForm(zone)` (Einzel-Öffnen → `EventForm`) und eine
Repository-Methode `importEvents(targetCalendarId, events)``ImportSummary`
(Bulk). Datei-IO (`Uri` lesen, Intent) liegt in `data/ics/` bzw. der
Activity/Compose-Schicht; Routing 1-vs-viele entscheidet anhand der geparsten
Event-Anzahl.
**Liberal-in/strict-out (Leitprinzip):** unbekannte Properties, fremde
`VTIMEZONE`-Blöcke und `RECURRENCE-ID`-Ausnahmen werden **übersprungen und im
Report vermerkt**, nie still verschluckt; ein einzelnes kaputtes `VEVENT` lässt
den Rest der Datei durch.
**Leitentscheidungen:**
1. **Parse-Mechanik (Umkehr von `IcsText`):** zuerst **Unfolding** (CRLF +
Space/Tab → wegfalten), dann pro Zeile `NAME[;params]:value` zerlegen,
TEXT-Werte **unescapen** (`\\` `\;` `\,` `\n`). Eine reine, einzeln getestete
Schicht (`IcsLineParser`), nicht ad hoc im Walker.
2. **Datum/Zeit-Parsing (Umkehr der Writer-Zeitzonenregel):**
- `VALUE=DATE` (`YYYYMMDD`) → all-day, Instant auf UTC-Mitternacht (wie der
Provider all-day speichert), exklusives `DTEND` bleibt exklusiv.
- `…T…Z` → UTC-Instant.
- `…T…` mit `TZID=<zone>` → lokale Wandzeit in der Zone, aufgelöst gegen die
**OS-tz-Datenbank** (`TimeZone.of`); unbekannte/fehlende `TZID`
Gerätezone als Fallback (+ Warnung).
- Kein `VTIMEZONE`-Parsing — `TZID` wird gegen die OS-DB aufgelöst (s. Branch-1
Entscheidung); ein `VTIMEZONE`-Block wird übersprungen (Warnung nur, wenn
seine `TZID` nicht in der OS-DB ist).
3. **Routing 1-vs-viele:** genau **eine** `VEVENT` → vorausgefülltes
Erstellen-Formular (`ParsedIcsEvent.toEventForm`, `calendarId=null`
Formular wählt wie gehabt den zuletzt genutzten Kalender vor). **Mehr als
eine** → Bulk-Import-Screen (Ziel-Kalender-Picker, nur beschreibbare). Leere
Datei → freundlicher „nichts gefunden"-Hinweis.
4. **UID-Dedup beim Bulk-Import:** vor dem Insert die vorhandenen
`Events.UID_2445` des Ziel-Kalenders lesen; eine eingelesene UID, die schon
existiert, wird **übersprungen** (gezählt als „bereits vorhanden"). v1:
skip-not-update — kein Überschreiben, das hält den Restore idempotent und
verlustfrei. Events ohne UID bekommen beim Insert eine frische
(`UUID@calendula`, wie `insertEvent`).
5. **Empfang via Intent:** Manifest-`ACTION_VIEW`/`SEND` mit MIME `text/calendar`
(+ `.ics`-Pfadmuster für `file`/`content`-Schemes). `MainActivity`
(`singleTop`, wie beim Reminder-Tap) liest den `Uri`, parst, und reicht das
Ergebnis als Compose-State an `CalendarHost` (gleiches Key-Muster wie der
Notification-Deep-Link).
6. **Reminder/Status/Transp zurück:** `VALARM` `TRIGGER` (negatives `-PT…`,
`PT0…`) → Lead-Minuten; `STATUS`/`TRANSP``EventStatus`/`Availability`.
`DURATION`-statt-`DTEND` über `parseRfc2445DurationMillis` (existiert aus
Branch 1). Attendees werden **nicht** importiert (kein Modell; Warnung wenn
vorhanden).
**Recherche-Befunde (Codebase, 2026-06-18 — aus Branch 1):**
- `IcsText.escapeText`/`foldLine` + `IcsWriter` existieren; Parser spiegelt sie.
- `parseRfc2445DurationMillis` (in `IcsExportMapper.kt`) parst die
Provider-`DURATION`-Formen inkl. des nicht-standardkonformen `P<n>S`.
- `EventForm` (Domain): Zeiten als `LocalDateTime` in Gerätezone, `calendarId`
nullable; das Formular wählt bei `null` den zuletzt genutzten Kalender vor.
- `insertEvent` schreibt bereits `Events.UID_2445`; für den Import muss eine
**vorgegebene** UID durchgereicht werden (Insert-Variante / Parameter).
---
## Tasks
**Parser-Engine (`domain/ics/`, reine Kotlin, JVM-Tests):**
- [x] `IcsText`: `unescapeText` + `unfold(lines)` ergänzen (+ Test)
- [x] `IcsLineParser`: `NAME;PARAM=v;PARAM=v:VALUE` → (name, params-map, value);
Param-Werte ggf. in Quotes; Test (Kantenfälle: Doppelpunkt im Wert,
gequotete Params)
- [x] `ParsedIcsEvent` (wie `IcsEvent`, aber `uid: String?`) + Datum/Zeit-Parser
(`VALUE=DATE` / `…Z` / `TZID` → Instant + isAllDay + zoneId)
- [x] `IcsParser.parse(text)``IcsParseResult(events, warnings)`: VCALENDAR/
VEVENT-Walk, skip-and-report für `RECURRENCE-ID`/unbekannte `VTIMEZONE`/
Attendees; ein defektes VEVENT killt nicht den Rest. **Round-trip-Test**
gegen `IcsWriter`-Ausgabe (all-day, getimt, wiederkehrend+TZID, Reminder)
+ Fremd-Quirks (gefaltete Zeilen, fehlende UID, `PT…`-Trigger)
**Datenschicht (`data/calendar/` + `data/ics/`):**
- [x] `ParsedIcsEvent.toEventForm(zone)` (Einzel-Öffnen → `EventForm`); Test
- [x] Datasource: `existingUids(calendarId)` (Query `Events.UID_2445`) +
`insertImported(event, calendarId)` (Insert mit vorgegebener/erzeugter UID)
- [x] Repository `importEvents(targetCalendarId, events)``ImportSummary`
(imported / skippedDuplicate / skippedUnsupported); UID-Dedup; Test mit
Fake-Datasource
- [x] `IcsImporter` (`data/ics/`): `Uri` → Text lesen (UTF-8, `contentResolver`)
**Intent + Routing:**
- [x] Manifest: `ACTION_VIEW`/`ACTION_SEND`, MIME `text/calendar`, `.ics`-
Pfadmuster (`file`/`content`); `MainActivity` parst eingehenden `Uri`
- [x] Routing in `CalendarHost`: 1 Event → Erstellen-Formular vorausgefüllt;
>1 → Bulk-Import-Screen; 0 → Hinweis
**UI:**
- [x] Bulk-Import-Screen: Ziel-Kalender-Picker (OptionCard, nur beschreibbare),
Anzahl/Vorschau, Import-Button → `ImportSummary` als Ergebnis
- [x] Einzel-Öffnen: `EventEditScreen` mit vorausgefülltem Formular (neuer
Prefill-Pfad im `EventEditViewModel`, ohne `eventId`)
- [x] Strings DE+EN: Import-Titel, Ziel-Auswahl, Ergebnis-Plurals
(importiert / übersprungen-vorhanden / übersprungen-nicht-unterstützt),
leere-Datei-Hinweis
**Abschluss:**
- [x] `./gradlew lint test assembleDebug` grün
- [ ] CHANGELOG done; ROADMAP/STATE pending; v2.7 cut **erst** wenn beide
Branches gemerged sind und On-Device-Review durch ist

View File

@@ -1,406 +0,0 @@
# Calendula - V1 Design Spec
**Date:** 2026-06-08
**Status:** Draft for review
**Author:** Jean-Luc Makiola (with Claude)
## 1. Motivation & Goal
Eine Android-native, Open-Source-Kalender-App im Material 3 Expressive Design.
Schließt die Lücke, dass es derzeit keinen optisch zeitgemäßen Kalender abseits
von Google Calendar gibt - speziell mit dem 2025er Expressive-Design.
**Was die App NICHT macht:**
- Eigene CalDAV/iCal-Synchronisation - das übernimmt der Android `CalendarContract`
bzw. Drittsoftware wie DAVx5
- Eigene lokale Event-DB - alle Daten leben im `CalendarContract`
**Was die App macht:**
- Schöne, moderne UI über Android-Bordmittel
- Liest aus `CalendarContract` (alle Quellen: Nextcloud/CalDAV via DAVx5,
Google, lokal, WebCal-Subscriptions)
- V1 ist read-only; Schreibrechte kommen in V2
## 2. Scope - V1 MVP (Variante "B")
### In-Scope
- 3 Hauptansichten: Monat, Woche, Tag
- Event-Detail-Sheet (read-only Detailansicht)
- Multi-Kalender-Toggle (Sichtbarkeit pro Kalender)
- Heute-Button (Jump-to-Date gestrichen, siehe Out-of-Scope)
- Settings-Screen (Theme, Dynamic Color, Wochenstart, Sprache)
- Permission-Flow für `READ_CALENDAR`
- Empty-States und Error-Recovery
- DE + EN Lokalisierung
- Tests + CI ab Tag 1
### Out-of-Scope (V2+)
- Jump-to-Date / Datum-Picker (aus V1-Scope gestrichen)
- Event-Create/Edit/Delete (V2)
- Home-Screen-Widget
- Volltextsuche
- Quick-Add
- Notifications/Reminders (System macht das schon, nicht doppeln)
- Tablet-/Foldable-spezifische Layouts
- iOS (Kotlin-Native ist explizit Android-only)
## 3. Tech Stack
| Layer | Wahl | Begründung |
|---|---|---|
| Sprache | Kotlin 2.0+ | Android-Native-Standard |
| UI | Jetpack Compose + Material3 Expressive (1.5+) | Echter M3 Expressive Support |
| Min SDK | 29 (Android 10) | Modern, keine Compat-Pfade |
| Target SDK | 36 (Android 16) | Aktuell, wie HouseHoldKeaper CI |
| DI | Hilt | Industriestandard |
| Persistenz Prefs | DataStore (Preferences) | Theme, Wochenstart, Filter-State |
| Persistenz Daten | keine | Source of Truth bleibt `CalendarContract` |
| Datum/Zeit | `kotlinx.datetime` (Domain), `java.time` an Provider-Grenze | Saubere API |
| Navigation | Compose Navigation, Single-Activity | Standard |
| Lokalisierung | Android Resources (`strings.xml`) + Plurals | DE + EN ab V1 |
| Tests | JUnit5, Truth, Turbine, Compose UI Test | JVM-first, Instrumented nur für ContentResolver-Integration |
| Build | Gradle Kotlin DSL + Version Catalog | Lesbar, typsicher |
| CI | Gitea Workflows (adaptiert von HouseHoldKeaper) | Gleiche Konvention wie restliche Projekte |
### Permissions
- `READ_CALENDAR` (einzige Runtime-Permission)
- `android.permission.QUERY_ALL_PACKAGES` **nicht** nötig (Maps-Intent geht ohne)
### App-Identifier
- **App-Name:** Calendula (vom lateinischen *kalendae* - "der erste Tag des Monats", Wortwurzel von "Kalender"; gleichzeitig der Name der Ringelblume)
- **Package:** `de.jeanlucmakiola.calendula`
- Convention identisch zu `HouseHoldKeaper`: `de.jeanlucmakiola.<app_name>`
## 4. Architektur
### Modul-Struktur
Single Gradle module `:app` für V1. Feature-Split (`:core`, `:feature-*`) erst
wenn nötig - YAGNI.
### Package-Layout
```
de.jeanlucmakiola.calendula/
├── CalendulaApp.kt + MainActivity.kt
├── data/ ContentResolver-Wrapper, Repositories
├── domain/ Pure-Kotlin Models (Event, CalendarSource)
└── ui/
├── theme/ M3 Expressive Theme, Dynamic Color
├── month/ Monatsansicht (Composable + ViewModel + State)
├── week/ Wochenansicht
├── day/ Tagesansicht
├── detail/ Event-Detail-Sheet
├── filter/ Kalender-Filter-Sheet
├── settings/ Settings-Screen
├── permission/ Permission-Request-Screen
└── common/ Geteilte Composables (LoadingScreen-Helper, FailureScreen-Helper)
```
### Layer-Verantwortlichkeiten
- **data/**: Nur Layer der Android-Klassen kennt (`ContentResolver`, `Cursor`,
`CalendarContract`). Mapped auf Domain-Modelle.
- **domain/**: Pure Kotlin. Keine Android-Imports.
- **ui/**: Compose-Code, ViewModels. Hängt von domain ab, niemals direkt an data.
## 5. Datenfluss & Domain-Modell
### Domain-Modelle (pure Kotlin)
```kotlin
data class CalendarSource(
val id: Long,
val displayName: String,
val accountName: String, // z.B. "jlmak@nextcloud.example.com"
val accountType: String, // z.B. "at.bitfire.davdroid" (DAVx5), "com.google" (Google), "LOCAL"
val color: Int,
val isVisibleInSystem: Boolean, // CalendarContract.Calendars.VISIBLE
)
data class EventInstance(
val instanceId: Long, // Instances._ID (eindeutig pro Vorkommen)
val eventId: Long, // Events._ID (gleich für alle Vorkommen)
val calendarId: Long,
val title: String,
val start: Instant,
val end: Instant,
val isAllDay: Boolean,
val color: Int, // Effektiv: Event.color ?: Calendar.color
val location: String?,
)
data class EventDetail(
val instance: EventInstance,
val description: String?,
val organizer: String?,
val attendees: List<Attendee>,
val rrule: String?, // Read-only: nur "wiederkehrt wöchentlich" anzeigen
)
data class Attendee(val name: String, val email: String?, val status: AttendeeStatus)
enum class AttendeeStatus { Accepted, Declined, Tentative, NeedsAction, Unknown }
```
### Repository
```kotlin
interface CalendarRepository {
fun calendars(): Flow<List<CalendarSource>>
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
suspend fun eventDetail(eventId: Long): EventDetail
}
```
**Implementation-Details:**
- Hält `ContentObserver` auf `CalendarContract.CONTENT_URI` registriert
- Bei Observer-Trigger: re-query, emit neuer Wert via SharedFlow
- Queries laufen auf `Dispatchers.IO`
- `instances(range)` nutzt `CalendarContract.Instances.CONTENT_BY_DAY_URI`
oder `CONTENT_URI` mit Time-Range - Recurrence-Expansion macht der Provider
- App-eigene "ausgeblendete Kalender-IDs" leben in DataStore als `Set<Long>`,
kombinieren via `combine()` mit Calendar-Flow
### ViewModel-Pattern
Ein ViewModel pro Top-Level-Screen. State immer als sealed interface
(siehe Section 7 - Loading/Failure/Success).
ViewModel kennt aktuelle "Cursor"-Position (welcher Monat/Woche/Tag) und
fragt Repository nur für sichtbaren Range an - kein "alle Events der Welt".
## 6. Screens & Menüs
### Hauptscreens
**S1 - Monatsansicht**
- Zeigt einen Monat im Überblick
- Pro Tag erkennbar: hat-Events / keine-Events, ggf. Andeutung über Anzahl/Farbe
- Navigation: vorwärts/zurück zwischen Monaten
- Tap auf Tag → Tagesansicht für diesen Tag
- Heute deutlich markiert
**S2 - Wochenansicht**
- Zeigt eine Woche mit Zeitschiene
- Events auf ihrer Uhrzeit, Calendar-Farbe
- Overlap-Events: nebeneinander aufgelöst
- All-Day-Events extra dargestellt
- Navigation: vorwärts/zurück zwischen Wochen
- Tap Event → Event-Detail-Sheet
**S3 - Tagesansicht**
- Eine Spalte, mehr Detail pro Event als Wochenansicht
- All-Day-Events extra
- Navigation: vorwärts/zurück zwischen Tagen
- Tap Event → Event-Detail-Sheet
**S4 - Event-Detail-Sheet (Bottom-Sheet, ModalBottomSheet)**
- Pflicht-Inhalte: Titel, Start/Ende oder "Ganztägig", Kalender-Zugehörigkeit
- Konditional: Ort (Tap → Maps-Intent), Beschreibung, Teilnehmer, RRULE-Hinweis
- Dismissable via Drag oder Back-Geste
### Menüs
**M1 - View-Switcher**
- Wechsel zwischen Monat / Woche / Tag
- Immer erreichbar von allen Hauptansichten
- State persistent (zuletzt aktive Ansicht)
**M2 - Heute**
- Schnell zurück zu "heute" (Drawer-Eintrag, ausgeliefert in v0.5)
- ~~Springe zu beliebigem Datum via Datum-Picker~~ — **gestrichen**, siehe Out-of-Scope
- Erreichbar von allen Hauptansichten
**M3 - Kalender-Filter (Bottom-Sheet)**
- Sichtbare Kalender ein-/ausblenden
- Gruppiert pro Account (Nextcloud / Local / Google / …)
- Pro Eintrag: Name, Calendar-Farbe
- Persistiert in DataStore
- Erreichbar von allen Hauptansichten
**M4 - Settings**
- Theme: System / Light / Dark
- Dynamic Color: an/aus (auto disabled wenn API < 31)
- Wochenstart: Auto (aus Locale) / Mo / So
- Sprache: Auto / DE / EN
- About: Version, Lizenz, Link zum Quellcode (Gitea)
### Spezial-Flows
**F1 - Erst-Start / Permission-Flow**
- Beim ersten App-Start: `READ_CALENDAR`-Request
- Erklärungs-Text: "Wir lesen nur deinen Gerätekalender - keine Daten verlassen das Gerät"
- Bei Denial: friendlicher Recovery-Screen mit Re-Request-Button + Link zu System-Settings
**F2 - Empty-State (keine Kalender / keine Events)**
- Keine Kalender konfiguriert: Hinweis "Füge in DAVx5 oder System-Settings einen Kalender hinzu" mit Intent-Link zu System-Calendar-Settings
- Kalender da, aber aktuelle Ansicht leer: dezent, kein nerviger Placeholder
**F3 - Reaktion auf externe Änderungen**
- DAVx5/System-Calendar ändert sich → App aktualisiert sich automatisch via ContentObserver
- Kein manueller Pull-to-Refresh
## 7. UI-State-Modell: Loading / Failure / Success
**Pflicht-Pattern für jeden Screen.** Keine Ausnahmen.
### ViewModel-State
```kotlin
sealed interface MonthUiState {
data object Loading : MonthUiState
data class Failure(val reason: FailureReason) : MonthUiState
data class Success(
val month: YearMonth,
val eventsPerDay: Map<LocalDate, List<EventInstance>>,
val visibleCalendars: List<CalendarSource>,
) : MonthUiState
}
enum class FailureReason {
PermissionRevoked, // → Re-Request-Screen
NoCalendarsConfigured, // → Empty-State mit Intent zu System-Settings
ProviderUnavailable, // → Retry-Screen
EventNotFound, // → nur für Event-Detail-Sheet
Unknown, // → Fallback
}
```
### Composable-Dispatch
```kotlin
@Composable
fun MonthScreen(viewModel: MonthViewModel) {
val state by viewModel.state.collectAsStateWithLifecycle()
when (val s = state) {
is MonthUiState.Loading -> MonthLoadingScreen()
is MonthUiState.Failure -> MonthFailureScreen(s.reason, onRetry = viewModel::retry)
is MonthUiState.Success -> MonthSuccessScreen(s, ...)
}
}
```
### Pflicht-Composables pro Screen
| Screen | Loading | Failure-Varianten | Success |
|---|---|---|---|
| Monat | Skelett-Grid (Shimmer) | Permission, NoCalendars, Provider | Grid mit Events |
| Woche | Skelett-Schiene | Permission, NoCalendars, Provider | Schiene mit Events |
| Tag | Skelett-Schiene | Permission, NoCalendars, Provider | Schiene mit Events |
| Event-Detail | kompakter Skelett-Sheet | EventNotFound, Provider | Voll-Detail |
| Kalender-Filter | Skelett-Liste | Provider | Liste |
| Settings | sofort (DataStore ist instant) | - | direkt |
**Regeln:**
- Loading ist ein bewusst gestalteter Screen (Skeleton + Shimmer), kein loser Spinner
- Failure ist ein eigener Screen mit Erklärung + Recovery-Action, kein Toast
- Beim UI-Design später: alle drei Varianten pro Screen skizzieren, nie nur Success
- Tests: pro Screen mindestens `renders_loading`, `renders_failure`, `renders_success`
## 8. Error Handling & Edge Cases
### Philosophie
Calendar-Apps dürfen niemals an leeren/malformen Daten crashen. Defensive
Validierung im Repository, kaputte Instanzen still droppen + via `Log.w` loggen.
### Konkrete Fehlerfälle
| Fall | Verhalten |
|---|---|
| ContentResolver-Query wirft (Permission revoked zur Laufzeit) | State → `Failure(PermissionRevoked)`, UI zeigt Re-Request |
| Calendar `displayName` null | Fallback "(Unbenannter Kalender)" |
| Event `title` null/leer | Fallback "(Ohne Titel)" |
| `dtend < dtstart` | Event droppen, Warn-Log |
| `dtstart` vor Unix-Epoch | Event droppen, Warn-Log |
| Maps-Intent fehlt | SnackBar "Keine Karten-App installiert" |
| DataStore-IO-Fehler | Defaults verwenden, weiter, Warn-Log |
### Edge Cases im UI
- **All-Day-Events über mehrere Tage:** in Wochen-/Tagesansicht über mehrere
Tage gespannter All-Day-Strip
- **Events über Mitternacht:** in Wochen-/Tagesansicht am Folgetag fortsetzen
- **Instant Events (start == end):** Mindesthöhe rendern für Tap-Target
- **Viele Events an einem Tag:** in Monatsansicht "+N more" statt Overflow
- **Timezones:** alle Berechnungen in Geräte-Local-TZ, außer all-day = floating
## 9. i18n & Accessibility
### i18n
- `res/values/strings.xml` = englische Master-Strings
- `res/values-de/strings.xml` = deutsche Übersetzungen
- Alle Strings extrahiert, auch Plurals (`<plurals>` für "1 Event" / "N Events")
- Wochentags-/Monatsnamen via `java.time.format.DateTimeFormatter` mit aktiver
Locale (kein Hardcoding)
- Sprach-Override aus Settings via `AppCompatDelegate.setApplicationLocales`
### Accessibility (V1-Minimum)
- Alle interaktiven Elemente: `contentDescription`, Tap-Target ≥ 48dp
- Event-Items: semantisches Label "Titel, Start-Zeit, Dauer, Kalender X"
- Calendar-Color **immer** mit Label/Form kombiniert (nie nur-Farbe-Information)
- Dynamic-Text-Size respektiert (keine fixen sp-Werte für Text)
- Hoher-Kontrast: M3-Theme reagiert automatisch
- TalkBack-Smoke-Tests im UI-Test-Plan
## 10. Testing
Best Practices, kein Diskussionsbedarf:
- **Unit-Tests:** JUnit5 + Truth + Turbine. Repository, ViewModels, Date/Time-Helpers,
ContentResolver-Wrapper (mit Mock-Cursor)
- **UI-Tests:** Compose UI Test pro Screen, mindestens `renders_loading` /
`renders_failure` / `renders_success` + 1-2 Interaktions-Tests
- **Coverage-Ziel:** pragmatisch ~70% lines, 100% für Repository + Date-Logik.
Kein Coverage-Gate in CI, aber Pflicht-Run
- **Instrumented-Tests:** nur für ContentResolver-Integration (echte
CalendarContract-Queries auf Emulator)
## 11. CI/CD
Adaption der `HouseHoldKeaper`-Pipeline, nur Flutter-Steps durch Gradle ersetzt.
### `.gitea/workflows/ci.yaml` (push + PR)
- Setup Java 17 + Android SDK 36
- `./gradlew lint`
- `./gradlew test`
- `./gradlew assembleDebug`
- Trivy Filesystem-Scan (HIGH/CRITICAL, `continue-on-error` wie HHK)
### `.gitea/workflows/release.yaml` (auf Git-Tags)
- Alles aus `ci.yaml`
- Version aus Git-Tag in `app/build.gradle.kts`:
- `versionName = "${tag#v}"`
- `versionCode = MAJOR*10000 + MINOR*100 + PATCH` (HHK-Konvention)
- Keystore aus Gitea Secrets (`KEYSTORE_BASE64`, `KEY_PASSWORD`, `KEY_ALIAS`)
- `./gradlew assembleRelease`
- F-Droid-Pipeline 1:1 wie HHK: Hetzner-Sync, `fdroid update -c`, Re-Upload
### Repo-Konventionen
- `CHANGELOG.md` wird beim Taggen gepflegt (patch/minor/major)
- `fdroid-metadata/de.jeanlucmakiola.calendula/` Verzeichnis-Struktur
- `LICENSE` = MIT, Jean-Luc Makiola, 2026
- `.planning/` mit `PROJECT.md`, `REQUIREMENTS.md`, `ROADMAP.md`, `STATE.md`
## 12. Design-Decisions (gelöst)
### Theme-Seed-Color (Fallback wenn kein Dynamic Color verfügbar)
**`0xFF5C6B7A`** - desaturiertes Schiefer-Blaugrau.
- Bewusst anders als HouseHoldKeaper's Sage (`0xFF7A9A6D`), damit beide Apps unterscheidbar sind
- Mid-Saturation → M3 Expressive Dynamic Color generiert daraus eine ausgewogene Palette
- Cool aber nicht kalt → passt zu "modern functional"
- Funktioniert in Light- und Dark-Theme
### App-Icon (Adaptive Launcher)
**Statische "1" auf M3-Expressive-Squircle.**
- **Foreground:** Stilisierte Ziffer "1" (bold), zentriert auf einem Squircle
- **Background:** Seed-Color `0xFF5C6B7A` (slate)
- **Bedeutung:** Die "1" referenziert *kalendae* (der erste Tag des Monats) - Wortwurzel sowohl von "Kalender" als auch "Calendula". Die App heisst Calendula, aber das Icon zeigt klar: dies ist ein Kalender.
- Adaptive-Icon-Spec: Foreground 432dp x 432dp Safe-Zone in 108dp Tile, Background fest
- Vektor-basiert (kein PNG), in `res/drawable/ic_launcher_*.xml` als VectorDrawable
### Konkretes UI-Layout pro Screen
**Bewusst offen** - wird in eigener UI-Design-Iteration nach Spec-Approval entworfen
(Mockups pro Screen, alle drei States, vor Implementation).
## 13. Nächste Schritte nach Spec-Approval
1. Implementation-Plan via `writing-plans`-Skill aus diesem Spec ableiten
2. Initiales Gradle-Projekt-Scaffolding
3. Tooling: Lint-Config, Detekt o.ä., CI-Workflows initial
4. Iterative UI-Design-Phase (Mockups pro Screen, alle drei States,
bevor implementiert wird)
5. Feature-by-Feature-Implementation gegen den Plan

View File

@@ -0,0 +1,16 @@
### Added
- Create events from other apps and widgets. Calendula now registers the
standard "insert event" intent, so other apps and home-screen widgets (such as
the Todo Agenda widget) can hand off to Calendula to create a new event — it
opens the new-event form prefilled with whatever they passed (title, time,
location, description). Thanks to @dschuermann for the suggestion ([#30]).
### Fixed
- Some recurring events could not be opened. Events in a series that started
before 1970 — for example yearly birthdays or anniversaries synced over CalDAV
— showed "Something went wrong" instead of opening. They now open normally, and
show up in search again. Thanks to @dschuermann for the report ([#34]).
- The time picker now follows your 24-hour setting. With Calendula set to
24-hour time, the dial for choosing an event's start and end time still showed
AM/PM; it now matches your setting. Thanks to @abrossimow for the report
([#27]).

View File

@@ -0,0 +1,30 @@
### Added
- Restore events from a backup file. The backup section of Settings can now read
events back **in** from an `.ics` file, not just write one out: pick a file,
choose which calendar to import into, and Calendula adds the events — skipping
any that are already there and telling you how many it skipped. Export gained a
per-calendar selector at the same time, so you can back up just the calendars
you pick instead of everything at once ([#32]).
- Week numbers in Month view. A new **Week numbers** setting (off by default)
adds a slim gutter down the left of the Month grid showing the calendar-week
number for each row, sized to match the day cells. Handy if you plan or refer
to dates by week number ([#25]).
- Tap a date header to open that day. In Week and Agenda view, tapping a date
header now opens that date in Day view — the same drill-in that Month view and
the agenda widget already offered, so every view behaves the same way. It makes
jumping to a specific day quicker: switch to Week, swipe to the week you want,
then tap the date to open it. Thanks to @ptab for the suggestion ([#37]).
- An early Simplified Chinese translation. Calendula has started speaking
Simplified Chinese, contributed as a community translation through
[Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/).
It is still an early effort, so many parts of the app show in English until it
fills out — you can already pick it under Settings → Language or in Android's
per-app language settings. Thanks to
[zh-cn](https://weblate.dev.jeanlucmakiola.de/user/zh-cn/) for getting it
started; help finishing it is very welcome.
### Changed
- Long event titles wrap in the edit screen. When editing an event, a long title
now wraps onto multiple lines instead of being clipped to a single line, so you
can see and edit the whole thing ([#33]).