49 Commits

Author SHA1 Message Date
bdedf47972 release: cut v1.2.1 — event-form polish
All checks were successful
Build and Release to F-Droid / ci (push) Successful in 2m5s
CI / ci (push) Successful in 7m59s
Build and Release to F-Droid / build-and-deploy (push) Successful in 8m36s
Version bumped to 1.2.1 / 10. No code changes beyond the version — 1.2.1 is
the reviewed-and-approved form polish: card design system, optional fields
with settings defaults, reworked reminders, OptionCard dialogs app-wide,
expressive theme on standard springs, direction-aware today jump, IME fix.
CHANGELOG [1.2.1] carries the details.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:41:11 +02:00
a69be3da43 feat(edit): form redesign, optional fields, OptionCard dialogs, expressive motion
All checks were successful
CI / ci (push) Successful in 5m56s
Post-v1.2.0 design iteration on the event form, reviewed slice by slice
on-device:

- Form rebuilt on the detail screen's card system: tonal EditCards with
  gutter icons (centred on the first row, top-aligned for multiline),
  borderless inline fields (placeholders at half opacity), calendar-coloured
  title accent, no dividers, bare top bar
- Optional sections (location, description, reminders, availability,
  visibility) with per-user defaults in Settings ("New event form" toggles);
  hidden ones unfold via a "More fields" picker dialog
- Reminders: stacked rows + full-width borderless add; two-step picker
  (one-tap presets, then custom amount + minutes/hours/days/weeks dropdown);
  written as METHOD_ALERT Reminders rows. Availability busy/free segmented
  toggle; visibility selector with per-level icons
- OptionCard (ui/common) is now the app-wide selection-dialog standard;
  calendar picker, visibility, more-fields, reminder presets and the
  recurring-delete chooser all use it — radio-row dialogs removed
- MaterialExpressiveTheme with MotionScheme.standard() (expressive bounce
  felt overdone); FAB stack + field reveals animate on theme springs;
  jump-to-today slides toward today's actual direction
- IME: adjustResize + imePadding so the keyboard never pans the form
- Tests: form-field prefs round-trips, availability/access provider
  mappings; DE+EN strings throughout

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:14:30 +02:00
779fa1d480 release: cut v1.2.0 — event creation
All checks were successful
CI / ci (push) Successful in 7m47s
Build and Release to F-Droid / ci (push) Successful in 2m5s
Build and Release to F-Droid / build-and-deploy (push) Successful in 8m34s
Version bumped to 1.2.0 / 9. No code changes beyond the version — 1.2.0 is
the create slice: event form, "+" FAB on every view, last-used-calendar
preselect, provider-correct all-day storage. CHANGELOG [1.2.0] carries the
details; ROADMAP/STATE mark slice v1.2 shipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:27:17 +02:00
c59a071b82 feat(write): event creation — form screen, FAB, last-used calendar (v1.2)
Second slice of milestone 2 (write support):

- EventForm domain model + problems() validation (end-before-start,
  no-calendar; blank titles and instant events stay legal)
- Full-screen EventEditScreen: title, all-day switch, M3 date/time pickers
  (moving the start preserves the duration), calendar picker limited to
  writable calendars, location, description. Save validates, requests the
  WRITE upgrade contextually, and closes on success
- Calendar preselection: explicit pick > last-used (CalendarPrefs) > first
  writable calendar
- insertEvent in the data source; EventWriteMapper (JVM-tested) normalises
  all-day events to UTC midnights with exclusive DTEND, timed events to the
  device zone
- CalendarFabColumn shared by month/week/day: persistent "+" FAB anchored on
  the visible day, jump-to-today pill stacked above it
- Tests: EventForm validation, write-time mapping (incl. DST-safe epoch
  check), repository createEvent delegation/error propagation

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:27:08 +02:00
285bfd90a7 release: cut v1.1.0 — event delete (write foundation)
All checks were successful
CI / ci (push) Successful in 7m28s
Build and Release to F-Droid / ci (push) Successful in 2m1s
Build and Release to F-Droid / build-and-deploy (push) Successful in 8m17s
Version bumped to 1.1.0 / 8. No code changes beyond the version — 1.1.0 is
the write-foundation slice: WRITE_CALENDAR, read-only-calendar detection,
and event delete (whole series or single occurrence). CHANGELOG [1.1.0]
carries the details; ROADMAP/STATE mark slice v1.1 shipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:55:56 +02:00
9529f19c60 feat(write): event delete + WRITE_CALENDAR foundation (v1.1)
First slice of milestone 2 (write support), per the new plan in
docs/superpowers/plans/2026-06-11-03-write-support.md:

- Delete from the event detail screen with confirmation; recurring events
  choose "only this event" (cancelled exception via CONTENT_EXCEPTION_URI,
  series survives) or "all events in the series" (Events-row delete)
- WRITE_CALENDAR in the manifest; onboarding requests read+write in one
  system dialog but only read gates the app — declining write keeps it
  usable read-only. v1.0 installs get a contextual write request on their
  first delete
- CALENDAR_ACCESS_LEVEL is read into CalendarSource.canModifyContents;
  read-only calendars (WebCal, birthdays, …) show no write actions. The
  no-op placeholder Edit button is removed until edit ships (v1.3)
- Onboarding copy drops the now-false "read-only" claim (DE+EN)
- Tests: repository delete delegation/error propagation, access-level
  mapping; FakeCalendarDataSource grows write ops

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:55:15 +02:00
0013c9f3b1 ci: cut redundant per-run work (cache fix companion, emulator skip, daemon reuse)
All checks were successful
CI / ci (push) Successful in 14m34s
- skip setup-android's default packages (pulled the ~300 MB emulator every run)
- drop unused platforms;android-36 and the dead jq install step
- cache /opt/android-sdk and ~/.gradle (release.yaml had no cache at all)
- drop --no-daemon so lint/test/assemble reuse one warm daemon per job
- Trivy scan only on main (advisory-only; was ~25s tax on every branch push)
- concurrency group cancels superseded runs; drop duplicate pull_request trigger

Companion to the act_runner fix on the CI host: job containers now join the
runner's network so the actions/cache server is reachable (saves previously
failed with reserveCache timeouts, so no cache was ever stored).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:50:01 +02:00
bd6ad4ae5f Merge pull request 'feat: full event read (v0.6.0) + onboarding redesign, cut v1.0.0' (#2) from feat/full-event-read-v0.6.0 into main
Some checks failed
CI / ci (push) Has been cancelled
2026-06-11 07:28:16 +00:00
3697a58e5b release: cut v1.0.0 — first public release
Some checks failed
CI / ci (push) Successful in 13m23s
CI / ci (pull_request) Has been cancelled
Build and Release to F-Droid / ci (push) Has been cancelled
Build and Release to F-Droid / build-and-deploy (push) Has been cancelled
Version bumped to 1.0.0 / 7. No code changes beyond the version — 1.0.0 is the
accumulated v0.1 → v0.6 work (all V1 screens, full event read, filter, settings,
onboarding polish) declared release-ready. CHANGELOG [1.0.0] summarises the
shipped feature set; ROADMAP/STATE mark V1 complete.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 09:24:47 +02:00
e290c92d78 docs: fold onboarding redesign into 0.6.0 changelog
Some checks failed
Build and Release to F-Droid / ci (push) Has been cancelled
Build and Release to F-Droid / build-and-deploy (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 09:23:42 +02:00
9c4ebbc65a feat(permission): redesign first-run grant-access screen (M3 Expressive)
The onboarding screen is the first thing a new user sees; it was a bare
centred title + body + button. Rebuild it as a proper Material 3 Expressive
welcome:

- Branded hero reconstructing the launcher mark (slate squircle + foreground
  vector); the denied state adds a lock badge over the corner
- App-name eyebrow, a benefit-led headline, and three trust rows (stays on
  device / every calendar together / no tracking) with tonal icon chips
- Full-width filled CTA with a trailing arrow, pinned in a Scaffold bottom bar
  clear of the navigation bar; scrollable body for short screens
- "Read-only · no internet permission" footnote — accurate: the app declares
  only READ_CALENDAR
- Denied/recovery state reuses the same shell with Open-settings (primary) and
  Try-again (text) actions
- 8dp spacing scale, edge-to-edge insets handled via Scaffold

Built with the newly installed material-3 skill's token/component guidance.
Resolves the pre-1.0 polish backlog item.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 09:17:54 +02:00
c0d413ba11 docs: backlog the initial grant-access screen redesign (pre-1.0 polish)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 09:08:21 +02:00
dca0245a42 refactor(detail): show availability only when Free, pin it by the title
The Busy availability value is the default for nearly every event, so a
"Busy" chip on every detail screen was noise. Show the pill only for Free
(the noteworthy case) and move it to the top-right of the title row instead
of the under-title chip strip. That strip now carries only status/access and
hides entirely when there's nothing noteworthy. Drops the now-unused
event_availability_busy string from both locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 09:08:21 +02:00
024512959f feat(detail): full event read — surface every readable field (v0.6.0)
Round out the read-only model so the detail view shows everything
CalendarContract actually stores, ahead of write support.

Data layer:
- New domain types: Reminder, EventStatus, Availability, AccessLevel,
  AttendeeRelationship, AttendeeType; EventDetail gains reminders, status,
  availability, accessLevel, eventTimezone, selfStatus and Attendee gains
  relationship + type (all defaulted so existing callers compile)
- EventDetailProjection reads STATUS / AVAILABILITY / ACCESS_LEVEL /
  EVENT_TIMEZONE / SELF_ATTENDEE_STATUS; AttendeeProjection reads
  RELATIONSHIP + TYPE; new ReminderProjection queries CalendarContract.Reminders
- Mappers translate each provider integer code, guarding STATUS's null-vs-0
  ambiguity (0 == TENTATIVE) so an absent status reads as Confirmed
- Mapper unit tests cover every new column's codes

Detail UI:
- Status / availability / access chips under the title; cancelled also strikes
  the title through
- Reminders card with humanised lead times (plurals, DE + EN)
- Foreign-timezone card, shown only for timed events in a non-device zone
- Attendee role badges + the user's own "Your response: …" line
- http(s) URLs in the description are now tappable

URL field cut: CalendarContract exposes no Events.URL column (only the
CUSTOM_APP_URI app deep-link), so URLs are surfaced by linkifying the
description instead. Recorded in ROADMAP/CHANGELOG.

Version bumped to 0.6.0 / 6.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 08:56:40 +02:00
e78da3d7c1 docs: add v0.6 "full event read" milestone before v1.0
All checks were successful
CI / ci (push) Successful in 12m21s
Plan to surface every readable CalendarContract field (reminders, status,
availability, attendee role + self-status, timezone, URL, access level) in
the detail view before write support. Recurrence-override badges and
CATEGORIES/ATTACH stay out (the former folds into v2, the latter is a
provider limitation). Noted only — implementation comes later.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 23:35:02 +02:00
2cb8b59fb7 docs: cut jump-to-date (M2) from V1 scope
The date-picker half of M2 is dropped entirely; the "Today" half already
shipped in v0.5. V1 is now feature-complete and only a polish/QA pass
remains before v1.0.

Updated the living planning docs (ROADMAP, STATE, REQUIREMENTS) and the
design spec; corrected the v0.5.0 CHANGELOG note that promised M2 would
return in v1.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 23:27:21 +02:00
7d36d22fd5 Merge pull request 'feat: calendar filter in drawer + settings (v0.5.0)' (#1) from feat/filter-settings-v0.5.0 into main
Some checks failed
CI / ci (push) Has been cancelled
Build and Release to F-Droid / ci (push) Successful in 7m48s
Build and Release to F-Droid / build-and-deploy (push) Successful in 9m50s
2026-06-10 20:57:57 +00:00
adcbed6e02 feat(filter,settings): calendar filter in drawer + settings (v0.5.0)
All checks were successful
CI / ci (push) Successful in 12m42s
CI / ci (pull_request) Successful in 12m23s
M3 — calendar filter: the navigation drawer now hosts the calendar list
inline (grouped by account, colour swatch + checkbox per calendar). Hidden
calendars are persisted app-side and filtered centrally in the repository,
so month/week/day re-filter live the moment a checkbox flips. Drawer trimmed
to Today, the calendar filter, and Settings, with leading icons and a clear
title/section type scale; the stubbed jump-to-date entry (M2) was removed.

M4 — settings: full-screen destination with appearance (theme System/Light/
Dark, Material You dynamic colour auto-disabled < API 31, week start Auto/Mon/
Sun), language (per-app locales via AppCompat, persisted to API 29), and an
about section (version, licence, source link). Theme is driven by one
activity-scoped settings source so changes apply app-wide at once. Week start
now drives the month grid and week view; Auto follows the locale.

Also:
- default view switched from month to week
- Settings screen handles system back (was closing the app)
- fix pre-existing NonObservableLocale/LocalContextConfigurationRead lint
  errors in EventDetailScreen so CI lint is green again
- versionName/versionCode bumped to 0.5.0 / 5

Tests: repository hidden-filter (incl. live re-emit), SettingsPrefs round-trip
+ week-start resolution, filter grouping. lint + unit tests + assembleDebug green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 22:55:33 +02:00
efa0abbaed feat(detail): full-screen event detail (S4) with humanized recurrence
Some checks failed
CI / ci (push) Failing after 6m55s
Build and Release to F-Droid / ci (push) Successful in 7m48s
Build and Release to F-Droid / build-and-deploy (push) Successful in 9m40s
Tapping an event in the week/day timeline opens a full-screen detail
destination (MD3 list→detail, not a bottom sheet) overlaying the calendar
with a slide transition. One card per field (when, calendar, location,
description, attendees, recurrence) with leading icons; location taps open
a maps intent. Loading/Failure/Success throughout.

Recurrence is humanized from the RRULE — e.g. "Every week on Tue and Thu
until 31 Dec 2026" — covering FREQ/INTERVAL/BYDAY/UNTIL/COUNT with
abbreviated, italicised day names and localized list formatting, falling
back to a generic label for rules it can't render.

Also:
- fix: recurring events failed to open (series row stores DURATION, not
  DTEND, so the mapper dropped them as EventNotFound). The detail keeps
  them and shows the tapped occurrence's own times from Instances.
- feat: month day cell → opens the day view anchored to that date.
- build: add material-icons-extended (R8 strips unused icons in release).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 21:52:35 +02:00
d3fbe28843 docs: record v0.3.0 (month/week/day views, view switcher) in CHANGELOG
All checks were successful
Build and Release to F-Droid / build-and-deploy (push) Successful in 8m43s
CI / ci (push) Successful in 10m49s
Build and Release to F-Droid / ci (push) Successful in 6m16s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 20:44:22 +02:00
951fb640a6 feat(day): single-column day view, wire into view switcher
Day view as a one-column slice of the week view: shared TimedBlock/
AllDaySpan layout, per-day swipe navigation, hoisted noon-centred scroll,
animated all-day strip, and a compact top bar showing the full date.

- DayUiState / DayViewModel / DayScreen under ui/day
- reuse layoutDay/layoutAllDay/coversDay from the week package
- add Day to IMPLEMENTED_VIEWS; CalendarHost routes it explicitly
- day_today_action strings (en/de)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 20:40:02 +02:00
94fa206e2e refactor(week): polish timeline — rounded viewport, scroll persistence, week badge
All checks were successful
CI / ci (push) Successful in 11m40s
- Rounded, permanently-soft day-column scroll viewport via two viewports
  sharing one scroll state (gutter + columns stay aligned); plain
  rectangular column cards inside
- Vertical scroll position now persists across week swipes; noon-centring
  only runs on first entry into the week view (from month/day)
- All-day strip height is hoisted + animated, shared by both swipe pages,
  so it slides along and resizes smoothly instead of jumping
- Multi-line event time label so the end time isn't clipped in narrow
  columns; hour labels centred in the gutter
- Calendar-week (ISO) badge in the header gutter, aligned with the date
  numbers; dropped the redundant "All-day" gutter label
- Small breathing room between the top section and the timeline

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 20:05:40 +02:00
6a90bade8a feat(ui): month card grid + week timeline, wire view switcher
Replace the throwaway debug screen with the first real calendar UI and a
functional Month <-> Week switcher, on Material 3 Expressive.

Month view (S1):
- Material 3 Expressive card-per-day grid; only the current month's weeks
  render (neighbouring days left blank)
- per-day event dots with "+N" overflow, today via primaryContainer
- spring-based press feedback from the active motion scheme
- swipe + drawer navigation, Loading/Failure/Success states

Week view (S2):
- vertical time schedule with overlap-resolved lanes (per-day clipping,
  midnight spanning, instant events)
- all-day / multi-day events as connected horizontal spans
- single scroll container (gutter + day columns stay aligned), columns
  bundled in a rounded container, noon-centred on load
- top section colour-shifts with the app bar on scroll; swipe navigation,
  three states

Shared / infra:
- CalendarHost holds the active view; RootScreen renders it post-permission
- ui/common building blocks: CalendarDrawer, CalendarFailure,
  ViewSwitcherPill, pastelize, observable locale, M3 Expressive slide
  transition (motionScheme fastSpatialSpec)
- unit tests for the week layout (lanes, clipping, all-day spans)
- build: compileSdk 37, material3 pinned to 1.5.0-alpha21 for Expressive

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 20:05:40 +02:00
0132201cf9 style(icon): regenerate F-Droid icon.png to match launcher exactly
All checks were successful
CI / ci (push) Successful in 10m41s
Build and Release to F-Droid / ci (push) Successful in 5m59s
Build and Release to F-Droid / build-and-deploy (push) Successful in 8m27s
Re-render both locale catalog icons (512x512) from the same logo as the
Android adaptive launcher icon, baking in the foreground group transform
(scale 0.5, pivot 114,108, translate 2,8) over the slate background so the
F-Droid render is pixel-faithful to the on-device icon.

Add design/icon/calendula_launcher.svg as the composed full-bleed source
of truth for store/F-Droid renders.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:38:08 +02:00
b792ddc2f0 style: fix launcher icon scaling and centering, update AGP
All checks were successful
CI / ci (push) Successful in 9m58s
2026-06-08 20:30:40 +02:00
440fa57161 fix(debug): use prefixed composite keys in LazyColumn
All checks were successful
CI / ci (push) Successful in 9m35s
Cal.id and Event.instanceId share a numeric range, and the LazyColumn
keys both sections — colliding values (e.g. cal-id=4 + event-instance-id=4)
crashed with "Key '4' was already used". Additionally, Instances._ID is
inherited from the parent Event, so recurring events produce multiple
rows with the same instanceId; the start instant disambiguates them.
2026-06-08 20:19:58 +02:00
Jean-Luc Makiola
00b5aeaac7 feat(icon): line-art cal + calendula bloom + numeral "1"
All checks were successful
CI / ci (push) Successful in 9m42s
Replaces the simple numeral-only foreground from v0.1.0. The new mark
keeps the kalendae reference explicit (a bold "1" inside the
calendar body) and adds a small calendula bloom as a badge in the
bottom-right corner so the app's "calendula" brand reads at first
glance.

- design/icon/calendula_mark.svg: source SVG (232x232 viewport,
  monochrome, lawnicons-style strokes 12/8)
- app/src/main/res/drawable/ic_launcher_foreground.xml: regenerated
  as a VectorDrawable preserving the source path data. Off-white
  (#FAF6F0) strokes on the existing slate background. Reused as the
  <monochrome> slot so Android 13+ themed-icon launchers can recolor
  it from wallpaper.
- fdroid-metadata/.../{en-US,de-DE}/icon.png: 512x512 PNG composed
  from the same source SVG with the slate background baked in, so
  F-Droid clients show a fully rendered tile in the app catalog.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-08 18:24:04 +02:00
2a2b919041 docs: record v0.2.0 data-layer + permission flow in CHANGELOG, planning
All checks were successful
CI / ci (push) Successful in 9m53s
Build and Release to F-Droid / ci (push) Successful in 5m58s
Build and Release to F-Droid / build-and-deploy (push) Successful in 8m7s
2026-06-08 17:58:02 +02:00
3ced240e23 test: instrumented repository smoke against real CalendarContract 2026-06-08 17:57:12 +02:00
035ac9b003 test: replace placeholder smoke with permission-rationale assert 2026-06-08 17:56:03 +02:00
c03389abe0 ui: replace placeholder with RootScreen routing permission ↔ debug 2026-06-08 17:55:34 +02:00
98f8433156 ui: add DebugScreen showing calendars + next 50 instances 2026-06-08 17:54:23 +02:00
8fbbab30e2 ui: add DebugViewModel combining calendars + next 30d instances 2026-06-08 17:52:50 +02:00
ef0a4b0568 ui: add PermissionScreen with rationale and denied recovery 2026-06-08 17:50:33 +02:00
43f12812b6 ui: add PermissionViewModel with three-state machine 2026-06-08 17:49:39 +02:00
2400d5482c i18n: add permission + debug screen strings (en, de) 2026-06-08 17:49:06 +02:00
4d54501ed4 di: wire CalendarRepository, DataSource, DataStore, IoDispatcher 2026-06-08 17:48:34 +02:00
748df761bf data: add CalendarPrefs (hidden calendar ids in DataStore) 2026-06-08 17:47:55 +02:00
d13f2f07a5 data: add CalendarRepository + Impl with SharedFlow re-emit on data-source ticks 2026-06-08 17:47:13 +02:00
7abb2e6ab4 data: add CalendarDataSource seam returning domain lists (not Cursors)
Deviation from Plan 02: changing from Cursor-returning interface to
domain-returning interface so the repository unit tests can use a simple
fake without constructing ContentObserver/Handler/Looper on the JVM
(which would either crash or no-op via the mockable.jar stubs).
2026-06-08 17:44:19 +02:00
fb003d8806 data: add ColumnReader.toEventDetailCore() and toAttendee() mappers 2026-06-08 17:42:42 +02:00
40b531fa52 data: add ColumnReader.toEventInstance() with defensive validation (§8) 2026-06-08 17:41:29 +02:00
0e4c47febe data: add ColumnReader abstraction + Cursor.toCalendarSource mapper
Deviation from Plan 02: the JVM mockable-android.jar stubs every Cursor
method even with isReturnDefaultValues=true (returns null/0 regardless of
the underlying MatrixCursor backing). Introduce an internal ColumnReader
interface so mappers stay pure-Kotlin and JVM-testable via MapColumnReader,
while production reads through CursorColumnReader.
2026-06-08 17:40:37 +02:00
fb723fba68 data: add CalendarContract column projections + indices
Some checks failed
Build and Release to F-Droid / ci (push) Has been cancelled
Build and Release to F-Droid / build-and-deploy (push) Has been cancelled
2026-06-08 17:37:23 +02:00
Jean-Luc Makiola
ffc7ed414f fix(fdroid): correct metadata format to fastlane convention + add icon.png
All checks were successful
CI / ci (push) Successful in 8m52s
Inspection of the local Hetzner-synced F-Droid repo after v0.1.0
revealed that fdroidserver only partially picked up Calendula's
metadata: summary was sourced from the YAML fallback (en-US only),
description appeared only for the "de" locale (not de-DE), and no
icon was shown anywhere. Root cause: we wrote Google Play conventions
(short_description.txt, full_description.txt, bare locale code "de")
where fdroidserver expects the fastlane format that the sibling
HouseHoldKeaper repo already uses successfully.

Changes:
- de/ -> de-DE/ (BCP-47 with region matches HHK and is more reliably
  parsed by fdroidserver)
- short_description.txt -> summary.txt
- full_description.txt -> description.txt
- Add icon.png (512x512) per locale, composed from the adaptive icon's
  foreground path + slate background (rendered via rsvg-convert).
  Required because XML-only adaptive icons in the APK aren't
  auto-rasterized by fdroidserver.

Verified locally against the previously-broken index by composing the
new icon and renaming the files in-tree.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-08 17:37:05 +02:00
af75965a31 domain: add pure-Kotlin models (CalendarSource, EventInstance, EventDetail, …) 2026-06-08 17:36:39 +02:00
1b456d2133 data: add TimeBridge helpers for epoch-millis ↔ kotlin.time.Instant 2026-06-08 17:35:49 +02:00
a826e82bdc build: add kotlinx-datetime, coroutines, turbine, hilt-nav-compose, lifecycle-compose 2026-06-08 17:32:45 +02:00
ed680b4482 docs: add Plan 02 - Data Layer & Permission Flow implementation plan
21 bite-sized tasks covering domain models, CalendarContract data layer
(Cursor mappers with §8 defensive validation, ContentObserver-backed
SharedFlow repository), DataStore-persisted hidden-calendar set, Hilt
wiring, READ_CALENDAR permission flow (rationale + denied recovery), and
a wegwerfbarer Debug screen that visually validates data is flowing.

Out of scope: Month/Week/Day views (Plans 03-05), Event Detail Sheet
(Plan 06), Filter/Settings (Plan 07).
2026-06-08 17:30:41 +02:00
96 changed files with 13532 additions and 110 deletions

View File

@@ -6,7 +6,11 @@ on:
- '**' - '**'
tags-ignore: tags-ignore:
- '**' - '**'
pull_request:
# Cancel superseded runs on the same branch.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
ci: ci:
@@ -26,30 +30,25 @@ jobs:
- name: Setup Android SDK - name: Setup Android SDK
uses: android-actions/setup-android@v3 uses: android-actions/setup-android@v3
with:
# Default ("tools platform-tools") drags in the Android Emulator
# (~300 MB) which the build never uses.
packages: ''
- name: Setup Android SDK cache
uses: actions/cache@v4
with:
path: /opt/android-sdk
key: ${{ runner.os }}-android-sdk-37-36.0.0
- name: Install Android SDK packages - name: Install Android SDK packages
run: | run: |
yes | sdkmanager --licenses >/dev/null || true yes | sdkmanager --licenses >/dev/null || true
sdkmanager \ sdkmanager \
"platform-tools" \ "platform-tools" \
"platforms;android-36" \
"platforms;android-37.0" \ "platforms;android-37.0" \
"build-tools;36.0.0" "build-tools;36.0.0"
- name: Install jq
run: |
set -e
SUDO=""
if command -v sudo >/dev/null 2>&1; then
SUDO="sudo"
fi
if command -v apt-get >/dev/null 2>&1; then
$SUDO apt-get update
$SUDO apt-get install -y jq
elif command -v apk >/dev/null 2>&1; then
$SUDO apk add --no-cache jq
fi
- name: Setup Gradle cache - name: Setup Gradle cache
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
@@ -63,16 +62,19 @@ jobs:
- name: Grant execute permission for gradlew - name: Grant execute permission for gradlew
run: chmod +x ./gradlew run: chmod +x ./gradlew
# No --no-daemon: the daemon lives only as long as this job container
# and lets the following steps skip JVM startup + reconfiguration.
- name: Lint (debug variant only) - name: Lint (debug variant only)
run: ./gradlew lintDebug --no-daemon run: ./gradlew lintDebug
- name: Unit tests - name: Unit tests
run: ./gradlew testDebugUnitTest --no-daemon run: ./gradlew testDebugUnitTest
- name: Assemble debug APK - name: Assemble debug APK
run: ./gradlew assembleDebug --no-daemon run: ./gradlew assembleDebug
- name: Trivy filesystem scan - name: Trivy filesystem scan
if: github.ref == 'refs/heads/main'
run: | run: |
set -e set -e
SUDO="" SUDO=""

View File

@@ -24,16 +24,33 @@ jobs:
- name: Setup Android SDK - name: Setup Android SDK
uses: android-actions/setup-android@v3 uses: android-actions/setup-android@v3
with:
packages: ''
- name: Setup Android SDK cache
uses: actions/cache@v4
with:
path: /opt/android-sdk
key: ${{ runner.os }}-android-sdk-37-36.0.0
- name: Install Android SDK packages - name: Install Android SDK packages
run: | run: |
yes | sdkmanager --licenses >/dev/null || true yes | sdkmanager --licenses >/dev/null || true
sdkmanager \ sdkmanager \
"platform-tools" \ "platform-tools" \
"platforms;android-36" \
"platforms;android-37.0" \ "platforms;android-37.0" \
"build-tools;36.0.0" "build-tools;36.0.0"
- name: Setup Gradle cache
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'gradle/libs.versions.toml') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Grant execute permission for gradlew - name: Grant execute permission for gradlew
run: chmod +x ./gradlew run: chmod +x ./gradlew
@@ -42,10 +59,10 @@ jobs:
# any tag-resolved drift (e.g. version code substitution issues). # any tag-resolved drift (e.g. version code substitution issues).
- name: Unit tests - name: Unit tests
run: ./gradlew testDebugUnitTest --no-daemon run: ./gradlew testDebugUnitTest
- name: Assemble debug APK (sanity) - name: Assemble debug APK (sanity)
run: ./gradlew assembleDebug --no-daemon run: ./gradlew assembleDebug
build-and-deploy: build-and-deploy:
needs: ci needs: ci
@@ -65,16 +82,33 @@ jobs:
- name: Setup Android SDK - name: Setup Android SDK
uses: android-actions/setup-android@v3 uses: android-actions/setup-android@v3
with:
packages: ''
- name: Setup Android SDK cache
uses: actions/cache@v4
with:
path: /opt/android-sdk
key: ${{ runner.os }}-android-sdk-37-36.0.0
- name: Install Android SDK packages - name: Install Android SDK packages
run: | run: |
yes | sdkmanager --licenses >/dev/null || true yes | sdkmanager --licenses >/dev/null || true
sdkmanager \ sdkmanager \
"platform-tools" \ "platform-tools" \
"platforms;android-36" \
"platforms;android-37.0" \ "platforms;android-37.0" \
"build-tools;36.0.0" "build-tools;36.0.0"
- name: Setup Gradle cache
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'gradle/libs.versions.toml') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Install jq - name: Install jq
run: | run: |
set -e set -e
@@ -121,7 +155,7 @@ jobs:
run: chmod +x ./gradlew run: chmod +x ./gradlew
- name: Build release APK - name: Build release APK
run: ./gradlew assembleRelease --no-daemon run: ./gradlew assembleRelease
- name: Setup F-Droid Server Tools - name: Setup F-Droid Server Tools
run: | run: |

View File

@@ -10,14 +10,14 @@ See full design spec: `docs/superpowers/specs/2026-06-08-calendar-app-design.md`
### Active (V1) ### Active (V1)
- [x] Foundation & CI infrastructure - [x] Foundation & CI infrastructure
- [ ] Data Layer over `CalendarContract` - [x] Data Layer over `CalendarContract`
- [ ] Permission flow (`READ_CALENDAR`) - [x] Permission flow (`READ_CALENDAR`)
- [ ] Month view (S1) - [ ] Month view (S1)
- [ ] Week view (S2) - [ ] Week view (S2)
- [ ] Day view (S3) - [ ] Day view (S3)
- [ ] Event Detail Sheet (S4) - [ ] Event Detail Sheet (S4)
- [ ] Multi-Calendar Filter (M3) - [ ] Multi-Calendar Filter (M3)
- [ ] Today button + Jump-to-Date (M2) - [x] Today button (M2) — shipped v0.5; Jump-to-Date **cut from scope**
- [ ] View-Switcher (M1) - [ ] View-Switcher (M1)
- [ ] Settings screen (M4) - [ ] Settings screen (M4)
- [ ] Empty / no-permission / no-calendars states - [ ] Empty / no-permission / no-calendars states

View File

@@ -5,22 +5,67 @@
| Version | Milestone | Status | | Version | Milestone | Status |
|---|---|---| |---|---|---|
| v0.1 | Foundation & CI | complete | | v0.1 | Foundation & CI | complete |
| v0.2 | Data Layer & Permission Flow | pending | | v0.2 | Data Layer & Permission Flow | complete |
| v0.3 | Month view | pending | | v0.3 | Month + Week + Day views, view switcher | complete |
| v0.4 | Week view | pending | | v0.4 | Event Detail (S4) + humanized recurrence | complete |
| v0.5 | Day view | pending | | v0.5 | Calendar filter (M3) + Settings (M4) | complete |
| v0.6 | Event Detail Sheet | pending | | v0.6 | Full event read — surface every readable field | complete |
| v0.7 | Filter & Settings | pending | | v1.0 | First public release — polish pass, F-Droid | complete |
## v1.0 — First Public Release Delivery ran ahead of the original table: Day view (S3) shipped in v0.3 and
Event Detail (S4) in v0.4, so the Filter/Settings milestone became v0.5.
All V1 features shipped, polished, on F-Droid. Read-only calendar. Jump-to-date (the date-picker half of M2) was **cut from scope** and will not
ship. The "Today" half of M2 already shipped in v0.5 (drawer entry).
## v2.0 — Write Support ## v0.6 — Full event read
- Event create / edit / delete via `CalendarContract` writes Round out the read-only model so a detail view shows everything the system
- Quick-add sheet actually stores, before write support starts. Scope = `CalendarContract`
- Conflict UX (event modified externally during edit) columns we don't yet read/display:
- **Reminders** (`VALARM`) — read `CalendarContract.Reminders`, list lead times
- **Status** — Confirmed / Tentative / Cancelled (cancelled shown struck-through)
- **Availability** (`TRANSP`) — Free / Busy chip
- **Attendee extras** — role (required / optional / organizer) + the user's own
`SELF_ATTENDEE_STATUS`
- **Timezone** (`EVENT_TIMEZONE`) — shown only when it differs from the device zone
- **URL** — ~~tappable link card~~ **cut**: `CalendarContract` exposes no
`Events.URL` column (only `CUSTOM_APP_URI`, an originating-app deep-link).
URLs are instead surfaced by linkifying the description text
- **Access level / class** (private / confidential) — small chip (optional, trivial)
All of the above shipped in v0.6.0 (2026-06-11).
Deliberately out of v0.6:
- Recurrence exception / modified-occurrence badges — `Instances` already
resolves correct per-occurrence times for display; this only matters for
editing, so it folds into v2
- `CATEGORIES`, `ATTACH` — not reliably exposed by `CalendarContract`
(provider limitation, not our choice)
## v1.0 — First Public Release — shipped 2026-06-11
All V1 features shipped, polished, on F-Droid. Read-only calendar. Cut directly
after v0.6 (full event read) plus the onboarding-screen polish pass.
### Polish backlog (pre-1.0)
- ~~Redesign the initial grant-access (permission) screen~~ — **done**
(Material 3 Expressive onboarding, shipped in v0.6.0 / v1.0.0)
## v2.0 — Write Support (in progress)
Delivered in four releasable slices (plan:
`docs/superpowers/plans/2026-06-11-03-write-support.md`). The V1 spec is a
guide here, not a contract — scope per slice is decided as we go.
| Version | Milestone | Status |
|---|---|---|
| v1.1 | Write foundation — `WRITE_CALENDAR`, read-only-calendar detection, delete (series + single occurrence) | complete (shipped 2026-06-11) |
| v1.2 | Create event — form, FAB, last-used-calendar preselect | complete (shipped 2026-06-11) |
| v1.2.1 | Form polish after on-device review — card design system, optional fields + settings defaults, OptionCard dialogs, expressive motion | complete (shipped 2026-06-11) |
| v1.3 | Edit event — shared form, series edit, reminders, simple recurrence picker | planned |
| v2.0 | Quick-add, occurrence edit, conflict dialog, polish, release | planned |
## v3.0 — Power-User Features ## v3.0 — Power-User Features

View File

@@ -1,22 +1,51 @@
# Calendula — Current State # Calendula — Current State
*Last updated: 2026-06-08* *Last updated: 2026-06-11*
## Status ## Status
**Milestone:** v0.1 — Foundation & CI **Milestone:** v2.0 — Write support (milestone 2, in progress)
**Phase:** Plan 01 complete; ready to start Plan 02 **Phase:** v1.2.1 shipped 2026-06-11 — the create-form polish pass after
Jean-Luc's on-device review (v1.2.0 and v1.1.0 shipped the same day).
Milestone 2 runs in four slices
(`docs/superpowers/plans/2026-06-11-03-write-support.md`); next up is v1.3
(edit event). Note: UI slices now hold release until his explicit approval.
## Progress ## Progress
- [x] Design spec written and committed (`docs/superpowers/specs/2026-06-08-calendar-app-design.md`) - [x] Design spec written and committed (`docs/superpowers/specs/2026-06-08-calendar-app-design.md`)
- [x] V1 design decisions resolved (App name "Calendula", icon, seed color) - [x] V1 design decisions resolved (App name "Calendula", icon, seed color)
- [x] Plan 01 written and executed (`docs/superpowers/plans/2026-06-08-01-foundation.md`) - [x] Plan 01 written and executed — foundation lands (theme, icon, i18n, Hilt, DataStore, CI green)
- [x] Foundation lands: theme, icon, i18n, Hilt, DataStore, CI green - [x] Plan 02 written and executed — data layer + permission flow + debug screen
- [ ] Plan 02 written (Data Layer & Permission Flow) - [x] Month view (S1) — 6-week grid, event dots, today marker, swipe nav, three states (replaces debug screen)
- [x] Week view (S2) — time schedule with overlap-resolved lanes, all-day strip, swipe nav, three states
- [x] Day view (S3) — single-column slice reusing the week layout
- [x] View-switcher (M1) wired — cycles Month ↔ Week ↔ Day
- [x] Event-detail screen (S4) — full-screen, humanized recurrence
- [x] Filter sheet (M3) — per-calendar visibility, grouped by account, persisted, applied centrally in the repository
- [x] Settings (M4) — appearance (theme, dynamic colour, week start), language (per-app locales), about
- [~] Jump-to-date (M2) — **cut from scope**; "Today" half shipped in v0.5, date-picker dropped
- [x] Full event read (v0.6) — reminders, status, availability, access level,
attendee role + self-response, foreign timezone, and linkified description
URLs in the detail view; new domain enums + mapper unit tests. (A dedicated
URL field was cut — no `CalendarContract` column backs it.)
- [x] v1.1 write foundation — `WRITE_CALENDAR` (onboarding asks READ+WRITE,
only READ gates; contextual upgrade for v1.0 installs), read-only-calendar
detection (`CALENDAR_ACCESS_LEVEL``canModifyContents`, actions hidden for
WebCal/birthday calendars), delete from the detail screen (recurring:
"only this event" via cancelled exception / "all events in the series"),
repository + mapper tests
- [x] v1.2 create event — full-screen `EventEditScreen` (title, all-day,
M3 date/time pickers with duration-preserving start moves, writable-only
calendar picker preselecting the last-used calendar, location, description),
"+" FAB on all three views prefilled with the visible day, `insertEvent`
with provider-correct all-day normalisation (UTC midnights, exclusive end),
domain/mapper/repository tests
## Next ## Next
1. Write Plan 02: Data Layer & Permission Flow 1. v1.3 — edit event: reuse the form, series edit, reminder edit, simple
2. Execute Plan 02 recurrence picker
3. Iterate on UI design (mockups) before screens are built 2. Monitor the F-Droid build/publish for v1.1.0 / v1.2.0

View File

@@ -7,6 +7,261 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [1.2.1] — 2026-06-11
### Added
- Optional event-form fields with user-controlled defaults: reminders,
availability (busy/free), and visibility (default/public/private/
confidential) joined location and description as form sections. Settings
gained a "New event form" section choosing which show by default; the rest
unfold via a "More fields" picker
- Reminders editor: stacked rows with right-bound remove, full-width add
action; the picker offers one-tap presets and a custom amount + unit
(minutes/hours/days/weeks) step
- `OptionCard` — the app's standard selection-dialog row (full-width tonal
card, optional icon + supporting line, highlighted selection). All dialogs
(calendar, visibility, more-fields, reminder presets, recurring-delete)
now use it; radio-row dialogs are retired
### Changed
- Event form redesigned onto the detail screen's design system: tonal cards
with gutter icons (top-aligned on tall cards), borderless inline text
fields, calendar-coloured accent bar under the title, no dividers, no
top-bar title; placeholders render clearly fainter than input
- M3 Expressive motion: the theme now provides a MotionScheme
(`MaterialExpressiveTheme`, standard springs — expressive bounce reviewed
as overdone), the FAB stack and "more fields" reveals animate on theme
springs
- The jump-to-today slide is direction-aware (future → today slides in from
the left, past → from the right)
- `versionName`/`versionCode` bumped to 1.2.1 / 10
### Fixed
- The keyboard no longer pans the whole event form; the screen stays
anchored and the focused field scrolls into view (`adjustResize` +
`imePadding`)
## [1.2.0] — 2026-06-11
### Added
- Create events (milestone 2, slice 2):
- A "+" FAB on the month, week, and day views opens a new full-screen event
form, prefilled with the visible day (today at the next full hour, or
09:00 on other days)
- The form covers title, all-day toggle, start/end with Material 3 date and
time pickers (moving the start drags the end along, preserving duration),
target calendar, location, and description
- The calendar picker offers only writable calendars and preselects the one
you last created an event in
- Validation on save ("ends before it starts", no writable calendar), with
the same contextual write-permission upgrade as delete
- All-day events are stored provider-correctly (UTC midnights, exclusive
end), timed events in the device time zone
### Changed
- The jump-to-today pill now stacks above the new "+" FAB instead of being
the only floating action
- `versionName`/`versionCode` bumped to 1.2.0 / 9
## [1.1.0] — 2026-06-11
### Added
- Write foundation (milestone 2, slice 1): Calendula can now **delete events**.
- Delete action on the event detail screen, with a confirmation dialog;
recurring events choose between "Only this event" (a cancelled exception,
so the rest of the series survives) and "All events in the series"
- `WRITE_CALENDAR` permission: onboarding asks for read+write in one system
dialog, but only read access is required — declining write keeps the app
fully usable read-only. Existing v1.0 installs are asked for the write
upgrade in place, on their first delete
- Read-only calendars (WebCal subscriptions, birthday calendars, …) are
detected via `CALENDAR_ACCESS_LEVEL` and show no edit/delete actions at all
### Changed
- Onboarding copy no longer claims "read-only"; it now says your data stays on
the device (still no internet permission, still zero telemetry)
- The placeholder Edit button on the detail screen (a no-op since v0.4) is
removed until editing ships in a later slice
- `versionName`/`versionCode` bumped to 1.1.0 / 8
## [1.0.0] — 2026-06-11
First public release. Calendula is a read-only, Material 3 Expressive calendar
that lives entirely on top of Android's `CalendarContract` — every calendar
synced to the device (CalDAV via DAVx5, Google, local, WebCal, …) shows up
automatically, with zero telemetry and no internet permission.
### Highlights (accumulated across v0.1 → v0.6)
- Month, week, and day views with a view switcher, swipe navigation, and
Loading / Failure / Success states on every screen
- Full-screen event detail surfacing every readable `CalendarContract` field —
times, recurrence (humanised), location, description (with tappable links),
attendees + roles + your own response, reminders, status, availability,
access level, and foreign time zones
- Per-calendar visibility filter (grouped by account, persisted) and a Settings
screen (theme, Material You dynamic colour, week start, app language)
- Material 3 Expressive first-run onboarding for calendar access
- German + English localization throughout
### Changed
- `versionName`/`versionCode` bumped to 1.0.0 / 7
## [0.6.0] — 2026-06-11
### Added
- Full event read (v0.6): the detail screen now surfaces every readable
`CalendarContract` field that V1 had been dropping —
- **Reminders** — each configured lead time, humanised ("10 minutes before",
"1 day before", "At time of event"), read from `CalendarContract.Reminders`
- **Status** — Tentative / Cancelled chip under the title; a cancelled event
also strikes through its title (Confirmed shows no chip)
- **Availability** — a "Free" pill pinned top-right of the title when the
event doesn't block your time (`Events.AVAILABILITY`, the iCal TRANSP
field); the default "Busy" is left implicit to avoid noise on every event
- **Access level** — a Private / Confidential chip when the event isn't public
- **Attendee role** — organizer / optional / resource badge under each
attendee, plus the device user's own response ("Your response: …") from
`Events.SELF_ATTENDEE_STATUS`
- **Time zone** — shown only for timed events pinned to a zone other than the
device's, so cross-zone events read unambiguously
- **Linked URLs** — http(s) links in the description are now tappable
- Domain model rounded out with `Reminder`, `EventStatus`, `Availability`,
`AccessLevel`, `AttendeeRelationship`, `AttendeeType`, and the attendee/self
status fields; mappers + unit tests cover every new column's integer codes
### Changed
- Redesigned the first-run grant-access screen — the onboarding a new user
sees. Material 3 Expressive layout: branded launcher-mark hero, an app-name
eyebrow, a benefit-led headline, three trust rows (on-device, every calendar,
no tracking) with tonal icon chips, a full-width filled CTA with a trailing
arrow, and a "Read-only · no internet permission" footnote (the app declares
only `READ_CALENDAR`). The denied/recovery state shares the same shell with a
lock-badged hero and Open-settings / Try-again actions
- `versionName`/`versionCode` bumped to 0.6.0 / 6
### Notes
- A dedicated event **URL** field was dropped from scope: `CalendarContract`
has no `Events.URL` column (only `CUSTOM_APP_URI`, an app deep-link), so URLs
are surfaced by linkifying the description instead
## [0.5.0] — 2026-06-10
### Added
- Calendar filter (M3): the navigation drawer now hosts the calendar list
inline — every calendar grouped by account, each with a colour swatch and a
visibility switch. Hiding a calendar is persisted app-side (DataStore,
separate from the system VISIBLE flag) and applied centrally in the
repository, so month/week/day re-filter live the moment a switch flips.
The drawer was trimmed to just Today, the calendar filter, and Settings
(the stubbed jump-to-date entry was removed; jump-to-date was later cut
from scope entirely)
- Settings (M4): a full-screen destination with
- **Appearance** — theme (System / Light / Dark), Material You dynamic colour
(auto-disabled below Android 12), week start (Automatic / Monday / Sunday)
- **Language** — app language (System / Deutsch / English) via per-app
locales, persisted across cold starts down to Android 10
- **About** — version, license, and a link to the source on Gitea
- Week-start preference now drives the month grid and week view; "Automatic"
follows the active locale (Monday in DE, Sunday in en-US)
### Changed
- Theme is driven by one activity-scoped settings source, so a theme or
dynamic-colour change applies app-wide immediately
- `versionName`/`versionCode` bumped to 0.5.0 / 5 (the in-repo version had
lagged behind the release tags); the About screen reads it directly
## [0.4.0] — 2026-06-10
### Added
- Event detail (S4): full-screen destination (MD3 list→detail, not a bottom
sheet) opened by tapping an event in the week/day timeline — title with a
calendar-colour accent line, a card per field (when, calendar, location,
description, attendees, recurrence) with leading icons, location tap opens a
maps intent, Loading/Failure/Success states, slide-in/out over the calendar
- Human-readable recurrence: RRULE rendered as e.g. "Every week on _Tue_ and
_Thu_ until 31 Dec 2026" (FREQ/INTERVAL/BYDAY/UNTIL/COUNT, abbreviated +
italicised day names, localized list formatting), with a generic fallback
- Month → day navigation: tapping a day cell opens the day view on that date
### Fixed
- Recurring events failed to open in the detail view: the series row stores
DURATION instead of DTEND, so the mapper dropped it (EventNotFound). The
detail now keeps such events and shows the tapped occurrence's own times
(from CalendarContract.Instances) instead of the series start
## [0.3.0] — 2026-06-10
### Added
- Month view (S1): Material 3 Expressive card-per-day grid (only the current
month's weeks; neighbouring days left blank), per-day event dots with "+N"
overflow, today emphasised via `primaryContainer`, spring-based press
feedback from the active motion scheme, swipe + drawer navigation,
Loading/Failure/Success states
- Week view (S2): vertical time schedule with overlap-resolved lanes,
separate all-day strip, midnight-spanning events clipped per day, swipe
navigation, Loading/Failure/Success states
- Day view (S3): single-column slice of the week schedule reusing its
overlap-lane layout, per-day swipe navigation, noon-centred scroll that
persists across swipes, animated all-day strip, compact top bar with the
full date, Loading/Failure/Success states
- Functional view-switcher (M1) cycling Month ↔ Week ↔ Day
- Shared calendar UI building blocks in `ui/common/` (navigation drawer,
failure screen, view-switcher pill, color pastelizer, observable locale)
### Removed
- Throwaway debug screen — superseded by the month view
## [0.2.1] — 2026-06-09
### Changed
- Regenerated the F-Droid catalog `icon.png` (512x512, both locales) so it
is pixel-faithful to the on-device adaptive launcher icon: same slate
background (`#5C6B7A`), off-white mark (`#FAF6F0`), and the foreground
group transform (`scale 0.5`, pivot `114,108`, translate `2,8`) baked in.
- Added `design/icon/calendula_launcher.svg` — the composed full-bleed
icon (background + transformed mark) as the single source of truth for
store/F-Droid renders.
## [0.2.0] — 2026-06-08
### Added
- Domain models for calendars, event instances, event detail, attendees
- `CalendarContract`-backed `CalendarRepository` with `ContentObserver`-driven live updates
- DataStore preference for app-side hidden-calendar visibility
- `READ_CALENDAR` permission flow (rationale + denied recovery + system-settings shortcut)
- Wegwerfbarer Debug-Screen: zeigt alle Kalender + die nächsten 50 Termine ab heute
- Hilt-Wiring für Data-Layer (Repository, DataSource, DataStore, IO-Dispatcher)
- Unit-Tests für Cursor-Mapping (alle §8-Defensiv-Cases), Repository-Flows mit Turbine, DataStore round-trip
- Instrumented smoke test against the real CalendarContract provider
### Changed
- Redesigned launcher icon: line-art calendar with a stylized "1" inside
(kalendae reference) and a small calendula bloom badge in the
bottom-right corner. Replaces the simple "1"-only foreground from
v0.1.0. Source SVG checked in at `design/icon/calendula_mark.svg`,
also used to regenerate the F-Droid catalog `icon.png` (512x512)
per locale.
## [0.1.1] — 2026-06-08
### Fixed
- F-Droid metadata format: renamed locale dirs from `de/` to `de-DE/`,
`short_description.txt` to `summary.txt`, `full_description.txt` to
`description.txt` (fastlane format that fdroidserver actually reads,
matching the working HouseHoldKeaper convention)
- Added `icon.png` (512x512) per locale; fdroidserver does NOT
auto-extract icons from APKs that only contain XML adaptive icons
(which is what minSdk-29 apps produce), so the app was rendered
blank-iconed in F-Droid clients
### Changed
- CI pipeline cleanup: `lintDebug`/`testDebugUnitTest` instead of full
`lint`/`test` (cuts ~50% of lint work since release variant lint is
redundant for V1 single-variant build)
- Release workflow drops the lint step from its CI-sanity job since
the same lint already ran via `ci.yaml` when the underlying commit
hit main
## [0.1.0] — 2026-06-08 ## [0.1.0] — 2026-06-08
### Added ### Added

View File

@@ -23,8 +23,8 @@ android {
applicationId = "de.jeanlucmakiola.calendula" applicationId = "de.jeanlucmakiola.calendula"
minSdk = 29 minSdk = 29
targetSdk = 36 targetSdk = 36
versionCode = 1 versionCode = 10
versionName = "0.1.0" versionName = "1.2.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -74,7 +74,10 @@ android {
} }
testOptions { testOptions {
unitTests.all { it.useJUnitPlatform() } unitTests {
all { it.useJUnitPlatform() }
isReturnDefaultValues = true
}
} }
} }
@@ -86,7 +89,9 @@ kotlin {
dependencies { dependencies {
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom)) implementation(platform(libs.androidx.compose.bom))
@@ -94,12 +99,18 @@ dependencies {
implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3) implementation(libs.androidx.material3)
implementation(libs.androidx.compose.material.icons.core)
implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.hilt.android) implementation(libs.hilt.android)
implementation(libs.androidx.hilt.navigation.compose)
ksp(libs.hilt.compiler) ksp(libs.hilt.compiler)
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.coroutines.core)
debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest) debugImplementation(libs.androidx.ui.test.manifest)
@@ -107,9 +118,13 @@ dependencies {
testRuntimeOnly(libs.junit.jupiter.engine) testRuntimeOnly(libs.junit.jupiter.engine)
testRuntimeOnly(libs.junit.platform.launcher) testRuntimeOnly(libs.junit.platform.launcher)
testImplementation(libs.truth) testImplementation(libs.truth)
testImplementation(libs.turbine)
testImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.test.rules)
androidTestImplementation(libs.truth)
androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4) androidTestImplementation(libs.androidx.ui.test.junit4)
} }

View File

@@ -4,23 +4,27 @@ import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
/**
* Smoke: launches MainActivity and asserts the permission rationale renders
* when calendar access has not yet been granted. Without GrantPermissionRule
* the system reports NOT_GRANTED on first launch so we land in PermissionScreen.
*/
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class MainActivitySmokeTest { class MainActivitySmokeTest {
@get:Rule @get:Rule
val composeTestRule = createAndroidComposeRule<MainActivity>() val composeTestRule = createAndroidComposeRule<MainActivity>()
@Test private val res = InstrumentationRegistry.getInstrumentation().targetContext.resources
fun appName_isDisplayed_onLaunch() {
composeTestRule.onNodeWithText("Calendula").assertIsDisplayed()
}
@Test @Test
fun tagline_isDisplayed_onLaunch() { fun permissionRationale_isDisplayed_onLaunch_withoutPermission() {
composeTestRule.onNodeWithText("A modern calendar.").assertIsDisplayed() composeTestRule.onNodeWithText(res.getString(R.string.permission_rationale_title))
.assertIsDisplayed()
} }
} }

View File

@@ -0,0 +1,52 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.Manifest
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import kotlin.time.Instant
@RunWith(AndroidJUnit4::class)
class CalendarRepositorySmokeTest {
@get:Rule
val permissionRule: GrantPermissionRule =
GrantPermissionRule.grant(Manifest.permission.READ_CALENDAR)
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private fun newRepo(): CalendarRepositoryImpl {
val dataSource = AndroidCalendarDataSource(context)
val store: DataStore<Preferences> = PreferenceDataStoreFactory.create(
produceFile = { context.cacheDir.resolve("smoke_test_prefs.preferences_pb") },
)
return CalendarRepositoryImpl(dataSource, CalendarPrefs(store), Dispatchers.IO)
}
@Test
fun calendars_returnsListWithoutCrashing() = runBlocking {
val repo = newRepo()
val first = repo.calendars().first()
assertThat(first).isNotNull()
}
@Test
fun instances_returnsListWithoutCrashing() = runBlocking {
val repo = newRepo()
val now = Instant.fromEpochMilliseconds(System.currentTimeMillis())
val oneDayLater = Instant.fromEpochMilliseconds(System.currentTimeMillis() + 86_400_000L)
val first = repo.instances(now..oneDayLater).first()
assertThat(first).isNotNull()
}
}

View File

@@ -0,0 +1,31 @@
package de.jeanlucmakiola.calendula.ui.permission
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import de.jeanlucmakiola.calendula.R
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class PermissionScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
private val res = InstrumentationRegistry.getInstrumentation().targetContext.resources
@Test
fun rationale_renders_title_and_button() {
composeTestRule.setContent {
PermissionScreen(onGranted = {})
}
composeTestRule.onNodeWithText(res.getString(R.string.permission_rationale_title))
.assertIsDisplayed()
composeTestRule.onNodeWithText(res.getString(R.string.permission_request_button))
.assertIsDisplayed()
}
}

View File

@@ -3,6 +3,7 @@
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.READ_CALENDAR" /> <uses-permission android:name="android.permission.READ_CALENDAR" />
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
<application <application
android:name=".CalendulaApp" android:name=".CalendulaApp"
@@ -17,12 +18,24 @@
tools:targetApi="35"> tools:targetApi="35">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true"> android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<!-- Persists the per-app language (M4) on API < 33, where the platform
per-app-languages API is unavailable. On 33+ this is a no-op. -->
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
android:enabled="false"
android:exported="false">
<meta-data
android:name="autoStoreLocales"
android:value="true" />
</service>
</application> </application>
</manifest> </manifest>

View File

@@ -4,19 +4,16 @@ import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.runtime.getValue
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel
import androidx.compose.ui.tooling.preview.Preview import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.ui.RootScreen
import de.jeanlucmakiola.calendula.ui.settings.SettingsViewModel
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
@AndroidEntryPoint @AndroidEntryPoint
@@ -25,35 +22,21 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
CalendulaTheme { // One activity-scoped SettingsViewModel drives both the theme here
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> // and the Settings screen, so a theme change applies app-wide at once.
PlaceholderScreen(modifier = Modifier.padding(innerPadding)) val settingsViewModel: SettingsViewModel = hiltViewModel()
} val settings by settingsViewModel.state.collectAsStateWithLifecycle()
val darkTheme = when (settings.themeMode) {
ThemeMode.SYSTEM -> isSystemInDarkTheme()
ThemeMode.LIGHT -> false
ThemeMode.DARK -> true
}
CalendulaTheme(
darkTheme = darkTheme,
dynamicColor = settings.dynamicColor,
) {
RootScreen(modifier = Modifier.fillMaxSize())
} }
} }
} }
} }
@Composable
private fun PlaceholderScreen(modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(R.string.app_name),
style = MaterialTheme.typography.displayMedium,
)
Text(
text = stringResource(R.string.app_tagline),
style = MaterialTheme.typography.bodyLarge,
)
}
}
@Preview(showBackground = true)
@Composable
private fun PlaceholderPreview() {
CalendulaTheme { PlaceholderScreen() }
}

View File

@@ -0,0 +1,202 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.content.ContentResolver
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.database.ContentObserver
import android.database.Cursor
import android.os.Handler
import android.os.Looper
import android.provider.CalendarContract
import android.util.Log
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.domain.Attendee
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.Reminder
import java.time.ZoneId
import javax.inject.Inject
import javax.inject.Singleton
/**
* Domain-shaped seam over Android's ContentResolver. Returns parsed lists so
* the repository can be unit-tested without constructing Cursors or
* ContentObservers on the JVM.
*
* Cursor handling and the ContentObserver-to-listener bridge live entirely
* in AndroidCalendarDataSource.
*/
interface CalendarDataSource {
fun calendars(): List<CalendarSource>
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
fun eventDetail(eventId: Long): EventDetail?
/** Insert a new event; returns the new `Events._ID`. */
fun insertEvent(form: EventForm): Long
/** Delete the whole event (for recurring events: the entire series). */
fun deleteEvent(eventId: Long)
/**
* Cancel a single occurrence of a recurring event by inserting a
* cancelled exception at [beginMillis] (the occurrence's `Instances.BEGIN`).
*/
fun deleteOccurrence(eventId: Long, beginMillis: Long)
fun registerChangeListener(listener: () -> Unit)
fun unregisterChangeListener(listener: () -> Unit)
}
@Singleton
class AndroidCalendarDataSource @Inject constructor(
@ApplicationContext private val context: Context,
) : CalendarDataSource {
private val resolver: ContentResolver get() = context.contentResolver
private val observers = mutableMapOf<() -> Unit, ContentObserver>()
override fun calendars(): List<CalendarSource> = resolver.query(
CalendarContract.Calendars.CONTENT_URI,
CalendarProjection.COLUMNS,
null, null,
CalendarContract.Calendars.CALENDAR_DISPLAY_NAME + " ASC",
)?.use { it.mapAll(::toCalendarSource) } ?: emptyList()
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> {
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
ContentUris.appendId(this, beginMillis)
ContentUris.appendId(this, endMillis)
}.build()
return resolver.query(
uri,
InstanceProjection.COLUMNS,
null, null,
CalendarContract.Instances.BEGIN + " ASC",
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
}
override fun eventDetail(eventId: Long): EventDetail? {
val attendees = queryAttendees(eventId)
val reminders = queryReminders(eventId)
return resolver.query(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
EventDetailProjection.COLUMNS,
null, null, null,
)?.use { c ->
if (!c.moveToFirst()) null
else CursorColumnReader(c).toEventDetailCore(attendees, reminders)
}
}
override fun insertEvent(form: EventForm): Long {
val times = form.toWriteTimes(ZoneId.systemDefault())
val values = ContentValues().apply {
put(
CalendarContract.Events.CALENDAR_ID,
requireNotNull(form.calendarId) { "EventForm.calendarId is required" },
)
put(CalendarContract.Events.TITLE, form.title.trim())
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
put(CalendarContract.Events.DTEND, times.dtEndMillis)
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
form.location.trim().takeIf { it.isNotEmpty() }
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
form.description.trim().takeIf { it.isNotEmpty() }
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
}
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
?: throw WriteFailedException("insert event into calendar id=${form.calendarId}")
val eventId = ContentUris.parseId(uri)
// Best effort (spec §8): the event exists at this point — a reminder
// that fails to attach is logged, not surfaced as a failed create.
form.reminders.distinct().forEach { minutes ->
val reminder = ContentValues().apply {
put(CalendarContract.Reminders.EVENT_ID, eventId)
put(CalendarContract.Reminders.MINUTES, minutes)
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
}
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
}
}
return eventId
}
override fun deleteEvent(eventId: Long) {
val deleted = resolver.delete(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
null, null,
)
if (deleted == 0) throw WriteFailedException("delete event id=$eventId")
}
override fun deleteOccurrence(eventId: Long, beginMillis: Long) {
// A cancelled exception row hides exactly this occurrence; the sync
// adapter turns it into an EXDATE/cancelled VEVENT upstream.
val values = ContentValues().apply {
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, beginMillis)
put(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED)
}
val uri = ContentUris.withAppendedId(
CalendarContract.Events.CONTENT_EXCEPTION_URI, eventId,
)
resolver.insert(uri, values)
?: throw WriteFailedException("cancel occurrence event id=$eventId begin=$beginMillis")
}
override fun registerChangeListener(listener: () -> Unit) {
val obs = object : ContentObserver(Handler(Looper.getMainLooper())) {
override fun onChange(selfChange: Boolean) {
listener()
}
}
observers[listener] = obs
resolver.registerContentObserver(
CalendarContract.CONTENT_URI,
/* notifyForDescendants = */ true,
obs,
)
}
override fun unregisterChangeListener(listener: () -> Unit) {
observers.remove(listener)?.let { resolver.unregisterContentObserver(it) }
}
private fun queryAttendees(eventId: Long): List<Attendee> = resolver.query(
CalendarContract.Attendees.CONTENT_URI,
AttendeeProjection.COLUMNS,
CalendarContract.Attendees.EVENT_ID + " = ?",
arrayOf(eventId.toString()),
null,
)?.use { c -> c.mapAll { CursorColumnReader(c).toAttendee() } } ?: emptyList()
private fun queryReminders(eventId: Long): List<Reminder> = resolver.query(
CalendarContract.Reminders.CONTENT_URI,
ReminderProjection.COLUMNS,
CalendarContract.Reminders.EVENT_ID + " = ?",
arrayOf(eventId.toString()),
null,
)?.use { c -> c.mapAll { CursorColumnReader(c).toReminder() } } ?: emptyList()
private fun toCalendarSource(c: Cursor): CalendarSource = CursorColumnReader(c).toCalendarSource()
/** Iterate every row and map; skips nothing. */
private inline fun <T> Cursor.mapAll(mapper: (Cursor) -> T): List<T> = buildList {
while (moveToNext()) add(mapper(this@mapAll))
}
/** Iterate every row and map; drops nulls. */
private inline fun <T : Any> Cursor.mapAllNotNull(mapper: (Cursor) -> T?): List<T> = buildList {
while (moveToNext()) mapper(this@mapAllNotNull)?.let(::add)
}
private companion object {
const val TAG = "CalendarDataSource"
}
}

View File

@@ -0,0 +1,16 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.provider.CalendarContract
import de.jeanlucmakiola.calendula.domain.CalendarSource
internal fun ColumnReader.toCalendarSource(): CalendarSource = CalendarSource(
id = getLong(CalendarProjection.IDX_ID),
displayName = getString(CalendarProjection.IDX_DISPLAY_NAME)
?: Fallbacks.UNNAMED_CALENDAR,
accountName = getString(CalendarProjection.IDX_ACCOUNT_NAME).orEmpty(),
accountType = getString(CalendarProjection.IDX_ACCOUNT_TYPE).orEmpty(),
color = getInt(CalendarProjection.IDX_COLOR),
isVisibleInSystem = getInt(CalendarProjection.IDX_VISIBLE) != 0,
canModifyContents = getInt(CalendarProjection.IDX_ACCESS_LEVEL) >=
CalendarContract.Calendars.CAL_ACCESS_CONTRIBUTOR,
)

View File

@@ -0,0 +1,30 @@
package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.coroutines.flow.Flow
import kotlin.time.Instant
interface CalendarRepository {
fun calendars(): Flow<List<CalendarSource>>
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
suspend fun eventDetail(eventId: Long): EventDetail
/** Create a new event from a validated form; returns the new `Events._ID`. */
suspend fun createEvent(form: EventForm): Long
/** Delete the whole event (for recurring events: the entire series). */
suspend fun deleteEvent(eventId: Long)
/** Cancel a single occurrence of a recurring event at its `Instances.BEGIN` time. */
suspend fun deleteOccurrence(eventId: Long, beginMillis: Long)
}
class NoSuchEventException(eventId: Long) :
NoSuchElementException("No event with id=$eventId")
/** A ContentResolver write affected no rows or returned no URI. */
class WriteFailedException(operation: String) :
RuntimeException("Calendar write failed: $operation")

View File

@@ -0,0 +1,88 @@
package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.withContext
import kotlin.time.Instant
import javax.inject.Inject
import javax.inject.Singleton
/**
* One ContentResolver-backed observer for the lifetime of the App process.
* Each public flow re-queries on subscribe and after every tick from the
* data source.
*/
@Singleton
class CalendarRepositoryImpl @Inject constructor(
private val dataSource: CalendarDataSource,
private val prefs: CalendarPrefs,
@IoDispatcher private val io: CoroutineDispatcher,
) : CalendarRepository {
private val ticks = MutableSharedFlow<Unit>(
replay = 0,
extraBufferCapacity = 1,
)
init {
dataSource.registerChangeListener { ticks.tryEmit(Unit) }
}
override fun calendars(): Flow<List<CalendarSource>> =
ticks
.onStart { emit(Unit) }
.reQuery { dataSource.calendars() }
.flowOn(io)
// Instances are filtered by the app-side hidden-calendar set (M3): an event
// is dropped whenever the user has hidden its calendar. Re-runs when the
// provider ticks *or* the hidden set changes — toggling a calendar in the
// filter sheet updates every view immediately. [calendars] stays unfiltered
// so the filter sheet can list and re-enable hidden calendars.
override fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>> =
combine(
ticks
.onStart { emit(Unit) }
.reQuery {
dataSource.instances(
beginMillis = range.start.toEpochMillis(),
endMillis = range.endInclusive.toEpochMillis(),
)
},
prefs.hiddenCalendarIds,
) { instances, hidden ->
if (hidden.isEmpty()) instances
else instances.filterNot { it.calendarId in hidden }
}.flowOn(io)
override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) {
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
}
override suspend fun createEvent(form: EventForm): Long = withContext(io) {
dataSource.insertEvent(form)
}
override suspend fun deleteEvent(eventId: Long) = withContext(io) {
dataSource.deleteEvent(eventId)
}
override suspend fun deleteOccurrence(eventId: Long, beginMillis: Long) = withContext(io) {
dataSource.deleteOccurrence(eventId, beginMillis)
}
}
private fun <T> Flow<Unit>.reQuery(block: suspend () -> T): Flow<T> = flow {
collect { emit(block()) }
}

View File

@@ -0,0 +1,22 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.database.Cursor
/**
* Read-only view over a single row's columns by index. Lets the mappers work
* on pure-Kotlin test fixtures (MapColumnReader) on the JVM, while the
* production path adapts an Android Cursor row via CursorColumnReader.
*/
internal interface ColumnReader {
fun getLong(index: Int): Long
fun getString(index: Int): String?
fun getInt(index: Int): Int
fun isNull(index: Int): Boolean
}
internal class CursorColumnReader(private val cursor: Cursor) : ColumnReader {
override fun getLong(index: Int): Long = cursor.getLong(index)
override fun getString(index: Int): String? = cursor.getString(index)
override fun getInt(index: Int): Int = cursor.getInt(index)
override fun isNull(index: Int): Boolean = cursor.isNull(index)
}

View File

@@ -0,0 +1,154 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.provider.CalendarContract
import android.util.Log
import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Attendee
import de.jeanlucmakiola.calendula.domain.AttendeeRelationship
import de.jeanlucmakiola.calendula.domain.AttendeeStatus
import de.jeanlucmakiola.calendula.domain.AttendeeType
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.calendula.domain.ReminderMethod
private const val TAG = "EventDetailMapper"
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")
return null
}
// 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.
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
}
val rawTitle = getString(EventDetailProjection.IDX_TITLE)
val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle
val color = if (isNull(EventDetailProjection.IDX_EVENT_COLOR)) {
getInt(EventDetailProjection.IDX_CALENDAR_COLOR)
} else {
getInt(EventDetailProjection.IDX_EVENT_COLOR)
}
val eventId = getLong(EventDetailProjection.IDX_EVENT_ID)
val instance = EventInstance(
instanceId = eventId,
eventId = eventId,
calendarId = getLong(EventDetailProjection.IDX_CALENDAR_ID),
title = title,
start = begin.toKotlinInstantFromEpochMillis(),
end = end.toKotlinInstantFromEpochMillis(),
isAllDay = getInt(EventDetailProjection.IDX_ALL_DAY) != 0,
color = color,
location = getString(EventDetailProjection.IDX_LOCATION),
)
// STATUS shares its 0 value with STATUS_TENTATIVE, so a missing column must
// be distinguished from a present 0 — an absent status means "just confirmed".
val status = if (isNull(EventDetailProjection.IDX_STATUS)) {
EventStatus.Confirmed
} else {
mapEventStatus(getInt(EventDetailProjection.IDX_STATUS))
}
return EventDetail(
instance = instance,
description = getString(EventDetailProjection.IDX_DESCRIPTION),
organizer = getString(EventDetailProjection.IDX_ORGANIZER),
attendees = attendees,
rrule = getString(EventDetailProjection.IDX_RRULE),
reminders = reminders,
status = status,
// BUSY and DEFAULT are both 0, so a null column folds into the same
// default these mappers already return — no isNull guard needed.
availability = mapAvailability(getInt(EventDetailProjection.IDX_AVAILABILITY)),
accessLevel = mapAccessLevel(getInt(EventDetailProjection.IDX_ACCESS_LEVEL)),
eventTimezone = getString(EventDetailProjection.IDX_EVENT_TIMEZONE),
selfStatus = mapAttendeeStatus(getInt(EventDetailProjection.IDX_SELF_ATTENDEE_STATUS)),
)
}
internal fun ColumnReader.toAttendee(): Attendee = Attendee(
name = getString(AttendeeProjection.IDX_NAME).orEmpty(),
email = getString(AttendeeProjection.IDX_EMAIL),
status = mapAttendeeStatus(getInt(AttendeeProjection.IDX_STATUS)),
relationship = mapAttendeeRelationship(getInt(AttendeeProjection.IDX_RELATIONSHIP)),
type = mapAttendeeType(getInt(AttendeeProjection.IDX_TYPE)),
)
internal fun ColumnReader.toReminder(): Reminder = Reminder(
minutes = getInt(ReminderProjection.IDX_MINUTES),
method = mapReminderMethod(getInt(ReminderProjection.IDX_METHOD)),
)
internal fun mapAttendeeStatus(raw: Int): AttendeeStatus = when (raw) {
CalendarContract.Attendees.ATTENDEE_STATUS_ACCEPTED -> AttendeeStatus.Accepted
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED -> AttendeeStatus.Declined
CalendarContract.Attendees.ATTENDEE_STATUS_TENTATIVE -> AttendeeStatus.Tentative
CalendarContract.Attendees.ATTENDEE_STATUS_INVITED -> AttendeeStatus.NeedsAction
else -> AttendeeStatus.Unknown
}
internal fun mapAttendeeRelationship(raw: Int): AttendeeRelationship = when (raw) {
CalendarContract.Attendees.RELATIONSHIP_ORGANIZER -> AttendeeRelationship.Organizer
CalendarContract.Attendees.RELATIONSHIP_PERFORMER -> AttendeeRelationship.Performer
CalendarContract.Attendees.RELATIONSHIP_SPEAKER -> AttendeeRelationship.Speaker
CalendarContract.Attendees.RELATIONSHIP_ATTENDEE -> AttendeeRelationship.Attendee
else -> AttendeeRelationship.None
}
internal fun mapAttendeeType(raw: Int): AttendeeType = when (raw) {
CalendarContract.Attendees.TYPE_REQUIRED -> AttendeeType.Required
CalendarContract.Attendees.TYPE_OPTIONAL -> AttendeeType.Optional
CalendarContract.Attendees.TYPE_RESOURCE -> AttendeeType.Resource
else -> AttendeeType.None
}
internal fun mapEventStatus(raw: Int): EventStatus = when (raw) {
CalendarContract.Events.STATUS_CONFIRMED -> EventStatus.Confirmed
CalendarContract.Events.STATUS_TENTATIVE -> EventStatus.Tentative
CalendarContract.Events.STATUS_CANCELED -> EventStatus.Cancelled
else -> EventStatus.Confirmed
}
internal fun mapAvailability(raw: Int): Availability = when (raw) {
CalendarContract.Events.AVAILABILITY_FREE -> Availability.Free
CalendarContract.Events.AVAILABILITY_TENTATIVE -> Availability.Tentative
else -> Availability.Busy
}
internal fun mapAccessLevel(raw: Int): AccessLevel = when (raw) {
CalendarContract.Events.ACCESS_CONFIDENTIAL -> AccessLevel.Confidential
CalendarContract.Events.ACCESS_PRIVATE -> AccessLevel.Private
CalendarContract.Events.ACCESS_PUBLIC -> AccessLevel.Public
else -> AccessLevel.Default
}
internal fun mapReminderMethod(raw: Int): ReminderMethod = when (raw) {
CalendarContract.Reminders.METHOD_EMAIL -> ReminderMethod.Email
CalendarContract.Reminders.METHOD_SMS -> ReminderMethod.Sms
CalendarContract.Reminders.METHOD_ALARM -> ReminderMethod.Alarm
CalendarContract.Reminders.METHOD_ALERT -> ReminderMethod.Alert
else -> ReminderMethod.Default
}

View File

@@ -0,0 +1,51 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.provider.CalendarContract
import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventForm
import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toJavaLocalDateTime
import java.time.ZoneId
import java.time.ZoneOffset
/** Provider-ready DTSTART / DTEND / EVENT_TIMEZONE for an event write. */
internal data class EventWriteTimes(
val dtStartMillis: Long,
val dtEndMillis: Long,
val timezone: String,
)
/**
* All-day events live at UTC midnights with an exclusive DTEND (the
* CalendarContract convention — a one-day event ends at the next midnight);
* timed events resolve their wall-clock values in [zone].
*/
internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDay) {
EventWriteTimes(
dtStartMillis = start.date.toJavaLocalDate()
.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli(),
dtEndMillis = end.date.toJavaLocalDate().plusDays(1)
.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli(),
timezone = "UTC",
)
} else {
EventWriteTimes(
dtStartMillis = start.toJavaLocalDateTime().atZone(zone).toInstant().toEpochMilli(),
dtEndMillis = end.toJavaLocalDateTime().atZone(zone).toInstant().toEpochMilli(),
timezone = zone.id,
)
}
internal fun Availability.toProviderValue(): Int = when (this) {
Availability.Busy -> CalendarContract.Events.AVAILABILITY_BUSY
Availability.Free -> CalendarContract.Events.AVAILABILITY_FREE
Availability.Tentative -> CalendarContract.Events.AVAILABILITY_TENTATIVE
}
internal fun AccessLevel.toProviderValue(): Int = when (this) {
AccessLevel.Default -> CalendarContract.Events.ACCESS_DEFAULT
AccessLevel.Confidential -> CalendarContract.Events.ACCESS_CONFIDENTIAL
AccessLevel.Private -> CalendarContract.Events.ACCESS_PRIVATE
AccessLevel.Public -> CalendarContract.Events.ACCESS_PUBLIC
}

View File

@@ -0,0 +1,41 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.util.Log
import de.jeanlucmakiola.calendula.domain.EventInstance
private const val TAG = "InstanceMapper"
internal fun ColumnReader.toEventInstance(): EventInstance? {
val begin = getLong(InstanceProjection.IDX_BEGIN)
val end = getLong(InstanceProjection.IDX_END)
if (begin < 0L) {
Log.w(TAG, "Dropping row with negative begin=$begin")
return null
}
if (end < begin) {
Log.w(TAG, "Dropping row with end=$end < begin=$begin")
return null
}
val rawTitle = getString(InstanceProjection.IDX_TITLE)
val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle
val color = if (isNull(InstanceProjection.IDX_EVENT_COLOR)) {
getInt(InstanceProjection.IDX_CALENDAR_COLOR)
} else {
getInt(InstanceProjection.IDX_EVENT_COLOR)
}
return EventInstance(
instanceId = getLong(InstanceProjection.IDX_INSTANCE_ID),
eventId = getLong(InstanceProjection.IDX_EVENT_ID),
calendarId = getLong(InstanceProjection.IDX_CALENDAR_ID),
title = title,
start = begin.toKotlinInstantFromEpochMillis(),
end = end.toKotlinInstantFromEpochMillis(),
isAllDay = getInt(InstanceProjection.IDX_ALL_DAY) != 0,
color = color,
location = getString(InstanceProjection.IDX_LOCATION),
)
}

View File

@@ -0,0 +1,120 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.provider.CalendarContract
internal object CalendarProjection {
val COLUMNS: Array<String> = arrayOf(
CalendarContract.Calendars._ID,
CalendarContract.Calendars.CALENDAR_DISPLAY_NAME,
CalendarContract.Calendars.ACCOUNT_NAME,
CalendarContract.Calendars.ACCOUNT_TYPE,
CalendarContract.Calendars.CALENDAR_COLOR,
CalendarContract.Calendars.VISIBLE,
CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL,
)
const val IDX_ID = 0
const val IDX_DISPLAY_NAME = 1
const val IDX_ACCOUNT_NAME = 2
const val IDX_ACCOUNT_TYPE = 3
const val IDX_COLOR = 4
const val IDX_VISIBLE = 5
const val IDX_ACCESS_LEVEL = 6
}
internal object InstanceProjection {
val COLUMNS: Array<String> = arrayOf(
CalendarContract.Instances._ID,
CalendarContract.Instances.EVENT_ID,
CalendarContract.Instances.CALENDAR_ID,
CalendarContract.Instances.TITLE,
CalendarContract.Instances.BEGIN,
CalendarContract.Instances.END,
CalendarContract.Instances.ALL_DAY,
CalendarContract.Instances.EVENT_COLOR,
CalendarContract.Instances.CALENDAR_COLOR,
CalendarContract.Instances.EVENT_LOCATION,
)
const val IDX_INSTANCE_ID = 0
const val IDX_EVENT_ID = 1
const val IDX_CALENDAR_ID = 2
const val IDX_TITLE = 3
const val IDX_BEGIN = 4
const val IDX_END = 5
const val IDX_ALL_DAY = 6
const val IDX_EVENT_COLOR = 7
const val IDX_CALENDAR_COLOR = 8
const val IDX_LOCATION = 9
}
internal object EventDetailProjection {
val COLUMNS: Array<String> = arrayOf(
CalendarContract.Events._ID,
CalendarContract.Events.TITLE,
CalendarContract.Events.DESCRIPTION,
CalendarContract.Events.ORGANIZER,
CalendarContract.Events.RRULE,
CalendarContract.Events.EVENT_COLOR,
CalendarContract.Events.CALENDAR_COLOR,
CalendarContract.Events.DTSTART,
CalendarContract.Events.DTEND,
CalendarContract.Events.ALL_DAY,
CalendarContract.Events.EVENT_LOCATION,
CalendarContract.Events.CALENDAR_ID,
CalendarContract.Events.STATUS,
CalendarContract.Events.AVAILABILITY,
CalendarContract.Events.ACCESS_LEVEL,
CalendarContract.Events.EVENT_TIMEZONE,
CalendarContract.Events.SELF_ATTENDEE_STATUS,
)
const val IDX_EVENT_ID = 0
const val IDX_TITLE = 1
const val IDX_DESCRIPTION = 2
const val IDX_ORGANIZER = 3
const val IDX_RRULE = 4
const val IDX_EVENT_COLOR = 5
const val IDX_CALENDAR_COLOR = 6
const val IDX_DTSTART = 7
const val IDX_DTEND = 8
const val IDX_ALL_DAY = 9
const val IDX_LOCATION = 10
const val IDX_CALENDAR_ID = 11
const val IDX_STATUS = 12
const val IDX_AVAILABILITY = 13
const val IDX_ACCESS_LEVEL = 14
const val IDX_EVENT_TIMEZONE = 15
const val IDX_SELF_ATTENDEE_STATUS = 16
}
internal object AttendeeProjection {
val COLUMNS: Array<String> = arrayOf(
CalendarContract.Attendees.ATTENDEE_NAME,
CalendarContract.Attendees.ATTENDEE_EMAIL,
CalendarContract.Attendees.ATTENDEE_STATUS,
CalendarContract.Attendees.ATTENDEE_RELATIONSHIP,
CalendarContract.Attendees.ATTENDEE_TYPE,
)
const val IDX_NAME = 0
const val IDX_EMAIL = 1
const val IDX_STATUS = 2
const val IDX_RELATIONSHIP = 3
const val IDX_TYPE = 4
}
internal object ReminderProjection {
val COLUMNS: Array<String> = arrayOf(
CalendarContract.Reminders.MINUTES,
CalendarContract.Reminders.METHOD,
)
const val IDX_MINUTES = 0
const val IDX_METHOD = 1
}
internal object Fallbacks {
const val UNNAMED_CALENDAR = "(Unbenannter Kalender)"
const val UNTITLED_EVENT = "(Ohne Titel)"
}

View File

@@ -0,0 +1,7 @@
package de.jeanlucmakiola.calendula.data.calendar
import kotlin.time.Instant
fun Long.toKotlinInstantFromEpochMillis(): Instant = Instant.fromEpochMilliseconds(this)
fun Instant.toEpochMillis(): Long = toEpochMilliseconds()

View File

@@ -0,0 +1,54 @@
package de.jeanlucmakiola.calendula.data.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.calendula.data.calendar.AndroidCalendarDataSource
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import javax.inject.Singleton
private val Context.calendulaDataStore: DataStore<Preferences> by preferencesDataStore(
name = "calendula_prefs",
)
@Module
@InstallIn(SingletonComponent::class)
abstract class DataBindModule {
@Binds
@Singleton
abstract fun bindCalendarDataSource(
impl: AndroidCalendarDataSource,
): CalendarDataSource
@Binds
@Singleton
abstract fun bindCalendarRepository(
impl: CalendarRepositoryImpl,
): CalendarRepository
}
@Module
@InstallIn(SingletonComponent::class)
object DataProvideModule {
@Provides
@Singleton
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
context.calendulaDataStore
@Provides
@IoDispatcher
fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
}

View File

@@ -0,0 +1,7 @@
package de.jeanlucmakiola.calendula.data.di
import javax.inject.Qualifier
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class IoDispatcher

View File

@@ -0,0 +1,58 @@
package de.jeanlucmakiola.calendula.data.prefs
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
/**
* App-side preference for "calendars the user has hidden in this app",
* separate from the system's per-calendar VISIBLE flag.
*
* Persisted as a comma-separated string of Long ids; non-numeric tokens are
* silently dropped (defensive — see CalendarPrefsTest).
*/
@Singleton
class CalendarPrefs @Inject constructor(
private val store: DataStore<Preferences>,
) {
val hiddenCalendarIds: Flow<Set<Long>> = store.data.map { prefs ->
prefs[HIDDEN_IDS_KEY].orEmpty()
.split(',')
.mapNotNull { it.trim().toLongOrNull() }
.toSet()
}
suspend fun setHiddenCalendarIds(ids: Set<Long>) {
store.edit { prefs ->
if (ids.isEmpty()) {
prefs.remove(HIDDEN_IDS_KEY)
} else {
prefs[HIDDEN_IDS_KEY] = ids.sorted().joinToString(",")
}
}
}
/**
* The calendar the user last created an event in; preselected in the
* event form. Null until the first event is created.
*/
val lastUsedCalendarId: Flow<Long?> = store.data.map { prefs ->
prefs[LAST_USED_CALENDAR_KEY]
}
suspend fun setLastUsedCalendarId(id: Long) {
store.edit { prefs -> prefs[LAST_USED_CALENDAR_KEY] = id }
}
companion object {
internal val HIDDEN_IDS_KEY = stringPreferencesKey("hidden_calendar_ids")
internal val LAST_USED_CALENDAR_KEY = longPreferencesKey("last_used_calendar_id")
}
}

View File

@@ -0,0 +1,107 @@
package de.jeanlucmakiola.calendula.data.prefs
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import de.jeanlucmakiola.calendula.domain.EventFormField
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.datetime.DayOfWeek
import java.time.temporal.WeekFields
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
/** Light/dark override. SYSTEM follows the device setting. */
enum class ThemeMode { SYSTEM, LIGHT, DARK }
/** Week-start override. AUTO derives the first day from the active locale. */
enum class WeekStartPref { AUTO, MONDAY, SUNDAY }
/**
* Resolve the preference to a concrete first-day-of-week. AUTO reads the
* locale's convention (e.g. Monday in DE, Sunday in en-US).
*/
fun WeekStartPref.resolveFirstDay(locale: Locale): DayOfWeek = when (this) {
WeekStartPref.MONDAY -> DayOfWeek.MONDAY
WeekStartPref.SUNDAY -> DayOfWeek.SUNDAY
// java.time.DayOfWeek.value is ISO 1..7 (Mon..Sun) — same numbering kotlinx uses.
WeekStartPref.AUTO -> DayOfWeek(WeekFields.of(locale).firstDayOfWeek.value)
}
/**
* Display settings (M4) persisted app-side: theme override, Material You
* dynamic colour, and week start. Language is handled separately through
* AppCompatDelegate (which persists its own per-app locale).
*
* Enum prefs round-trip by [Enum.name]; an unknown/garbage stored value falls
* back to the default rather than throwing (see SettingsPrefsTest).
*/
@Singleton
class SettingsPrefs @Inject constructor(
private val store: DataStore<Preferences>,
) {
val themeMode: Flow<ThemeMode> = store.data.map { prefs ->
prefs[THEME_MODE_KEY].toEnum(ThemeMode.SYSTEM)
}
val dynamicColor: Flow<Boolean> = store.data.map { prefs ->
prefs[DYNAMIC_COLOR_KEY] ?: true
}
val weekStart: Flow<WeekStartPref> = store.data.map { prefs ->
prefs[WEEK_START_KEY].toEnum(WeekStartPref.AUTO)
}
suspend fun setThemeMode(mode: ThemeMode) {
store.edit { it[THEME_MODE_KEY] = mode.name }
}
suspend fun setDynamicColor(enabled: Boolean) {
store.edit { it[DYNAMIC_COLOR_KEY] = enabled }
}
suspend fun setWeekStart(pref: WeekStartPref) {
store.edit { it[WEEK_START_KEY] = pref.name }
}
/**
* Optional event-form fields shown by default (the rest hide behind
* "more fields"). Stored comma-joined by enum name: an absent key means
* the factory default, an empty string means "none". Unknown names are
* dropped defensively, like the other enum prefs.
*/
val defaultFormFields: Flow<Set<EventFormField>> = store.data.map { prefs ->
parseFormFields(prefs[FORM_FIELDS_KEY])
}
suspend fun setFormFieldDefault(field: EventFormField, enabled: Boolean) {
store.edit { prefs ->
val current = parseFormFields(prefs[FORM_FIELDS_KEY])
val updated = if (enabled) current + field else current - field
prefs[FORM_FIELDS_KEY] = updated.joinToString(",") { it.name }
}
}
private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) {
null -> DEFAULT_FORM_FIELDS
else -> stored.split(',')
.mapNotNull { name -> EventFormField.entries.firstOrNull { it.name == name.trim() } }
.toSet()
}
companion object {
internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
internal val WEEK_START_KEY = stringPreferencesKey("week_start")
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
internal val DEFAULT_FORM_FIELDS =
setOf(EventFormField.Location, EventFormField.Description)
}
}
private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E =
this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default

View File

@@ -0,0 +1,51 @@
package de.jeanlucmakiola.calendula.domain
import kotlinx.datetime.LocalDateTime
/**
* User input for creating an event (and, from v1.3, editing one). Times are
* wall-clock values in the device zone; the data layer translates them to
* provider millis (all-day events normalise to UTC midnights there).
*/
data class EventForm(
val calendarId: Long?,
val title: String = "",
val isAllDay: Boolean = false,
val start: LocalDateTime,
val end: LocalDateTime,
val location: String = "",
val description: String = "",
/** Reminder lead times in minutes before the start, deduplicated. */
val reminders: List<Int> = emptyList(),
val availability: Availability = Availability.Busy,
val accessLevel: AccessLevel = AccessLevel.Default,
)
/**
* The form's optional sections. Which ones show by default is a user setting;
* the rest unfold behind a "more fields" button.
*/
enum class EventFormField {
Location,
Description,
Reminders,
Availability,
Visibility,
}
enum class EventFormProblem {
/** No target calendar — none picked and no writable calendar exists. */
NoCalendar,
EndBeforeStart,
}
/**
* Validation; an empty set means the form can be saved. A blank title is
* allowed (display falls back to "(No title)", matching the provider), and a
* zero-length timed event is allowed (spec §8: instant events exist).
*/
fun EventForm.problems(): Set<EventFormProblem> = buildSet {
if (calendarId == null) add(EventFormProblem.NoCalendar)
val endsTooEarly = if (isAllDay) end.date < start.date else end < start
if (endsTooEarly) add(EventFormProblem.EndBeforeStart)
}

View File

@@ -0,0 +1,124 @@
package de.jeanlucmakiola.calendula.domain
import kotlin.time.Instant
data class CalendarSource(
val id: Long,
val displayName: String,
val accountName: String,
val accountType: String,
val color: Int,
val isVisibleInSystem: Boolean,
/**
* Whether events in this calendar can be created/edited/deleted
* (`Calendars.CALENDAR_ACCESS_LEVEL` >= contributor). False for WebCal
* subscriptions, birthday calendars and other read-only sources.
*/
val canModifyContents: Boolean = false,
)
data class EventInstance(
val instanceId: Long,
val eventId: Long,
val calendarId: Long,
val title: String,
val start: Instant,
val end: Instant,
val isAllDay: Boolean,
val color: Int,
val location: String?,
)
data class EventDetail(
val instance: EventInstance,
val description: String?,
val organizer: String?,
val attendees: List<Attendee>,
val rrule: String?,
/** Reminders (VALARM) configured on the event, ascending lead time. */
val reminders: List<Reminder> = emptyList(),
/** Confirmed / Tentative / Cancelled (`Events.STATUS`). */
val status: EventStatus = EventStatus.Confirmed,
/** Busy / Free (`Events.AVAILABILITY`, the iCal TRANSP field). */
val availability: Availability = Availability.Busy,
/** Default / Private / Confidential / Public (`Events.ACCESS_LEVEL`). */
val accessLevel: AccessLevel = AccessLevel.Default,
/** Raw zone id (`Events.EVENT_TIMEZONE`), e.g. "Europe/Berlin"; null if unset. */
val eventTimezone: String? = null,
/** This device user's own response (`Events.SELF_ATTENDEE_STATUS`). */
val selfStatus: AttendeeStatus = AttendeeStatus.Unknown,
)
data class Attendee(
val name: String,
val email: String?,
val status: AttendeeStatus,
/** Organizer / performer / speaker / plain attendee (`ATTENDEE_RELATIONSHIP`). */
val relationship: AttendeeRelationship = AttendeeRelationship.None,
/** Required / optional / resource (`ATTENDEE_TYPE`). */
val type: AttendeeType = AttendeeType.None,
)
data class Reminder(
/** Lead time before the event start, in minutes. `-1` means the provider default. */
val minutes: Int,
val method: ReminderMethod,
)
enum class AttendeeStatus {
Accepted,
Declined,
Tentative,
NeedsAction,
Unknown,
}
enum class AttendeeRelationship {
Organizer,
Attendee,
Performer,
Speaker,
None,
}
enum class AttendeeType {
Required,
Optional,
Resource,
None,
}
enum class ReminderMethod {
Alert,
Email,
Sms,
Alarm,
Default,
}
enum class EventStatus {
Confirmed,
Tentative,
Cancelled,
}
enum class Availability {
Busy,
Free,
Tentative,
}
enum class AccessLevel {
Default,
Public,
Private,
Confidential,
}
enum class FailureReason {
PermissionRevoked,
NoCalendarsConfigured,
ProviderUnavailable,
EventNotFound,
Unknown,
}

View File

@@ -0,0 +1,147 @@
package de.jeanlucmakiola.calendula.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.day.DayScreen
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen
import de.jeanlucmakiola.calendula.ui.week.WeekScreen
import kotlinx.datetime.LocalDate
/**
* Holds the active top-level view (spec M1) and swaps between the calendar
* screens. Each screen owns its own ViewModel and date anchor; the view-switcher
* pill in their top bars writes back here via [onSelectView].
*/
@Composable
fun CalendarHost(modifier: Modifier = Modifier) {
var view by rememberSaveable { mutableStateOf(CalendarView.Week) }
val onSelectView: (CalendarView) -> Unit = { view = it }
// Tapping a day in the month grid opens the day view anchored to that date.
var pendingDayIso by rememberSaveable { mutableStateOf<String?>(null) }
val onOpenDay: (LocalDate) -> Unit = { date ->
pendingDayIso = date.toString()
view = CalendarView.Day
}
// The event-detail screen (S4) is a full-screen destination hoisted here so
// it overlays whichever calendar view is active. We forward the tapped
// occurrence's own times (eventId + begin + end, packed as a saveable
// long[]) so recurring events show the correct date, not the series start.
// [heldKey] keeps the last shown key alive through the slide-out (when
// [detailKey] is cleared); it is set in the tap callback — never a [0,0,0]
// placeholder — so the destination never loads a bogus id=0 on first frame.
var detailKey by rememberSaveable { mutableStateOf<LongArray?>(null) }
var heldKey by remember { mutableStateOf<LongArray?>(null) }
val onEventClick: (EventInstance) -> Unit = { event ->
val key = longArrayOf(
event.eventId,
event.start.toEpochMilliseconds(),
event.end.toEpochMilliseconds(),
)
heldKey = key
detailKey = key
}
// Settings (M4) is hoisted here so it overlays whichever calendar view is
// active and survives view switches. (The calendar filter now lives inline
// in the navigation drawer, so no overlay state is needed for it.)
var showSettings by rememberSaveable { mutableStateOf(false) }
val onOpenSettings = { showSettings = true }
// Event form (v1.2 create) — same held-key pattern as the detail screen:
// [heldCreateIso] keeps the prefill date alive through the slide-out.
var createDateIso by rememberSaveable { mutableStateOf<String?>(null) }
var heldCreateIso by remember { mutableStateOf<String?>(null) }
val onCreateEvent: (LocalDate) -> Unit = { date ->
heldCreateIso = date.toString()
createDateIso = date.toString()
}
val slideSpec = rememberCalendarSlideSpec()
Box(modifier = modifier.fillMaxSize()) {
when (view) {
CalendarView.Week -> WeekScreen(
selectedView = view,
onSelectView = onSelectView,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onCreateEvent = onCreateEvent,
)
CalendarView.Day -> DayScreen(
selectedView = view,
onSelectView = onSelectView,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onCreateEvent = onCreateEvent,
initialDateIso = pendingDayIso,
)
CalendarView.Month -> MonthScreen(
selectedView = view,
onSelectView = onSelectView,
onOpenDay = onOpenDay,
onOpenSettings = onOpenSettings,
onCreateEvent = onCreateEvent,
)
}
// Prefer the live key; fall back to the held one only while sliding out.
val activeKey = detailKey ?: heldKey
AnimatedVisibility(
visible = detailKey != null,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
) {
activeKey?.let { key ->
EventDetailScreen(
eventId = key[0],
beginMillis = key[1],
endMillis = key[2],
onBack = { detailKey = null },
)
}
}
// Event form (v1.2) — full-screen destination, slides over the calendar.
AnimatedVisibility(
visible = createDateIso != null,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
) {
(createDateIso ?: heldCreateIso)?.let { iso ->
EventEditScreen(
initialDateIso = iso,
onClose = { createDateIso = null },
)
}
}
// Settings (M4) — full-screen destination, slides over the calendar.
AnimatedVisibility(
visible = showSettings,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
) {
SettingsScreen(onBack = { showSettings = false })
}
}
}

View File

@@ -0,0 +1,50 @@
package de.jeanlucmakiola.calendula.ui
import android.Manifest
import android.content.pm.PackageManager
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
@Composable
fun RootScreen(modifier: Modifier = Modifier) {
val context = LocalContext.current
var hasPermission by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR)
== PackageManager.PERMISSION_GRANTED
)
}
val lifecycle = LocalLifecycleOwner.current.lifecycle
DisposableEffect(lifecycle) {
val obs = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
hasPermission = ContextCompat.checkSelfPermission(
context, Manifest.permission.READ_CALENDAR
) == PackageManager.PERMISSION_GRANTED
}
}
lifecycle.addObserver(obs)
onDispose { lifecycle.removeObserver(obs) }
}
if (hasPermission) {
CalendarHost(modifier = modifier)
} else {
PermissionScreen(
onGranted = { hasPermission = true },
modifier = modifier,
)
}
}

View File

@@ -0,0 +1,17 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.ui.graphics.Color
/**
* Soften a raw calendar color toward a pastel that fits the active theme.
* - Keeps the hue (so users still recognise their calendars)
* - Caps saturation so harsh provider colors stop screaming
* - Pins value/brightness to a band that reads on both light and dark surfaces
*/
fun pastelize(rawArgb: Int, dark: Boolean): Color {
val hsv = FloatArray(3)
android.graphics.Color.colorToHSV(rawArgb, hsv)
hsv[1] = (hsv[1] * 0.6f).coerceIn(0.25f, 0.65f)
hsv[2] = if (dark) 0.82f else 0.72f
return Color(android.graphics.Color.HSVToColor(hsv))
}

View File

@@ -0,0 +1,90 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Today
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.ui.filter.CalendarFilterList
/**
* Navigation drawer shared by every top-level calendar screen.
*
* Visual language (kept deliberately small so sizes don't drift):
* - Drawer title — `titleLarge`
* - Section headers (e.g. "Calendars") — `titleSmall`, primary, text only
* - Nav items (Today / Settings) — Material `NavigationDrawerItem`
* (`labelLarge` label + a single 24dp leading icon)
*
* Hosts the per-calendar visibility filter (M3) inline — the calendar list with
* its checkboxes lives here rather than in a separate sheet — plus the "today"
* jump and a Settings entry (M4). The host screen owns the drawer state.
*/
@Composable
fun CalendarDrawer(
onToday: () -> Unit,
onSettings: () -> Unit,
) {
ModalDrawerSheet {
Column(Modifier.fillMaxHeight()) {
Text(
text = stringResource(R.string.app_name),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(horizontal = 28.dp, vertical = 24.dp),
)
HorizontalDivider()
Spacer(Modifier.height(8.dp))
NavigationDrawerItem(
icon = { Icon(Icons.Filled.Today, contentDescription = null) },
label = { Text(stringResource(R.string.month_today_action)) },
selected = false,
onClick = onToday,
modifier = Modifier.padding(horizontal = 12.dp),
)
Spacer(Modifier.height(8.dp))
HorizontalDivider()
// Calendars (M3) — visibility checkboxes, scrollable, takes the slack
// between the top actions and the pinned Settings entry.
DrawerSectionHeader(stringResource(R.string.filter_title))
CalendarFilterList(modifier = Modifier.weight(1f))
HorizontalDivider()
Spacer(Modifier.height(8.dp))
NavigationDrawerItem(
icon = { Icon(Icons.Filled.Settings, contentDescription = null) },
label = { Text(stringResource(R.string.month_action_settings)) },
selected = false,
onClick = onSettings,
modifier = Modifier.padding(horizontal = 12.dp),
)
Spacer(Modifier.height(8.dp))
}
}
}
/** Top-level grouping label in the drawer. Text only, so it never reads as a
* tappable nav item. */
@Composable
private fun DrawerSectionHeader(text: String) {
Text(
text = text,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
)
}

View File

@@ -0,0 +1,58 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
/**
* The FAB stack shared by the three calendar views: a persistent "+" to
* create an event, with the jump-to-today pill appearing above it whenever
* the view isn't anchored on today.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun CalendarFabColumn(
todayVisible: Boolean,
todayText: String,
onToday: () -> Unit,
onCreate: () -> Unit,
) {
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
AnimatedVisibility(
visible = todayVisible,
enter = scaleIn(MaterialTheme.motionScheme.fastSpatialSpec()),
exit = scaleOut(MaterialTheme.motionScheme.fastSpatialSpec()),
) {
ExtendedFloatingActionButton(
onClick = onToday,
icon = { Icon(Icons.Default.Refresh, contentDescription = null) },
text = { Text(todayText) },
)
}
FloatingActionButton(onClick = onCreate) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = stringResource(R.string.event_edit_new_title),
)
}
}
}

View File

@@ -0,0 +1,56 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.FailureReason
/**
* Full-screen failure state shared by every calendar screen (spec §7).
* One explanation line + one recovery action, never a toast.
*/
@Composable
fun CalendarFailure(reason: FailureReason, onRetry: () -> Unit) {
val titleRes = when (reason) {
FailureReason.PermissionRevoked -> R.string.state_failure_permission
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars
FailureReason.ProviderUnavailable -> R.string.state_failure_provider
FailureReason.Unknown,
FailureReason.EventNotFound -> R.string.state_failure_unknown
}
val actionRes = when (reason) {
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars_action
FailureReason.PermissionRevoked -> R.string.state_failure_permission_action
else -> R.string.state_retry
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(titleRes),
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(24.dp))
FilledTonalButton(onClick = onRetry) {
Text(stringResource(actionRes))
}
}
}

View File

@@ -0,0 +1,40 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.IntOffset
/**
* The M3 Expressive spatial spring used for the month/week slide: the *fast*
* spring-physics spec from the active motion scheme — snappy with a subtle
* springy settle, rather than a fixed easing curve.
*
* Read it in a composable scope (this helper) so it can be captured by the
* non-composable `AnimatedContent` transitionSpec lambda.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun rememberCalendarSlideSpec(): FiniteAnimationSpec<IntOffset> =
MaterialTheme.motionScheme.fastSpatialSpec()
/**
* Horizontal slide for navigating between adjacent months/weeks.
*
* @param slideDir +1 = forward (incoming from the right), -1 = back, 0 = jump
* (e.g. "today"); a jump reuses the forward direction.
* @param spec spatial animation spec, typically [rememberCalendarSlideSpec].
*/
fun calendarSlideTransition(
slideDir: Int,
spec: FiniteAnimationSpec<IntOffset>,
): ContentTransform {
val dir = if (slideDir == 0) 1 else slideDir
return slideInHorizontally(spec) { w -> dir * w }
.togetherWith(slideOutHorizontally(spec) { w -> -dir * w })
}

View File

@@ -0,0 +1,25 @@
package de.jeanlucmakiola.calendula.ui.common
/**
* The top-level calendar views the user can switch between (spec M1).
* Day is declared but not yet implemented (v0.5) — see [IMPLEMENTED_VIEWS].
*/
enum class CalendarView {
Month,
Week,
Day,
}
/**
* Views that actually have a screen today. The view-switcher pill cycles
* through these in order.
*/
val IMPLEMENTED_VIEWS: List<CalendarView> =
listOf(CalendarView.Month, CalendarView.Week, CalendarView.Day)
/** Next view in [available], wrapping around. Falls back to Month if absent. */
fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): CalendarView {
val i = available.indexOf(this)
if (i < 0) return available.firstOrNull() ?: CalendarView.Month
return available[(i + 1) % available.size]
}

View File

@@ -0,0 +1,20 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalConfiguration
import androidx.core.os.ConfigurationCompat
import java.util.Locale
/**
* Current display [Locale], read observably from [LocalConfiguration] so the UI
* recomposes after a locale change (lint: NonObservableLocale). Used for
* weekday/month name formatting.
*/
@Composable
fun currentLocale(): Locale {
val configuration = LocalConfiguration.current
return remember(configuration) {
ConfigurationCompat.getLocales(configuration).get(0) ?: Locale.getDefault()
}
}

View File

@@ -0,0 +1,94 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
/**
* The app's standard pick in a selection dialog: a full-width tonal card,
* optionally with a leading icon and a supporting line; the selected option
* is highlighted. Stack with 8dp gaps inside an AlertDialog — this is the
* only sanctioned selection-modal style (no radio rows, no bare text lists).
*/
@Composable
fun OptionCard(
label: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
icon: ImageVector? = null,
/** Icon tint override, e.g. a calendar colour; unspecified follows selection. */
iconTint: Color = Color.Unspecified,
supportingText: String? = null,
selected: Boolean = false,
/** Label colour override, e.g. primary for an emphasised "Custom" entry. */
labelColor: Color = Color.Unspecified,
) {
val contentColor = if (selected) {
MaterialTheme.colorScheme.onSecondaryContainer
} else {
MaterialTheme.colorScheme.onSurface
}
Surface(
onClick = onClick,
color = if (selected) {
MaterialTheme.colorScheme.secondaryContainer
} else {
MaterialTheme.colorScheme.surfaceContainerHighest
},
shape = RoundedCornerShape(12.dp),
modifier = modifier.fillMaxWidth(),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
) {
if (icon != null) {
Icon(
imageVector = icon,
contentDescription = null,
tint = when {
iconTint.isSpecified -> iconTint
selected -> MaterialTheme.colorScheme.onSecondaryContainer
else -> MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.size(20.dp),
)
Spacer(Modifier.width(12.dp))
}
Column {
Text(
text = label,
style = MaterialTheme.typography.titleMedium,
color = if (labelColor.isSpecified) labelColor else contentColor,
)
if (supportingText != null) {
Text(
text = supportingText,
style = MaterialTheme.typography.bodySmall,
color = if (selected) {
MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.8f)
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
}
}
}

View File

@@ -0,0 +1,33 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import de.jeanlucmakiola.calendula.R
/**
* Top-bar pill that shows the current view and cycles to the next one on tap
* (spec M1: Month → Week → Day → Month, restricted to [IMPLEMENTED_VIEWS]).
*/
@Composable
fun ViewSwitcherPill(
current: CalendarView,
onCycle: () -> Unit,
modifier: Modifier = Modifier,
) {
val labelRes = when (current) {
CalendarView.Month -> R.string.view_month
CalendarView.Week -> R.string.view_week
CalendarView.Day -> R.string.view_day
}
FilledTonalButton(
onClick = onCycle,
shape = MaterialTheme.shapes.large,
modifier = modifier,
) {
Text(stringResource(labelRes))
}
}

View File

@@ -0,0 +1,584 @@
package de.jeanlucmakiola.calendula.ui.day
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshotFlow
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.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.calendula.ui.common.pastelize
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate
import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale
import kotlin.math.roundToInt
private val HOUR_HEIGHT = 56.dp
private val GUTTER_WIDTH = 48.dp
private val MIN_EVENT_HEIGHT = 24.dp
private val ALL_DAY_ROW_HEIGHT = 24.dp
private val ALL_DAY_VERTICAL_PADDING = 6.dp
/** Total all-day strip height for the day (0 when there are no all-day events). */
private fun DayUiState.Success.allDayStripHeight(): Dp {
if (allDay.isEmpty()) return 0.dp
val lanes = allDay.maxOf { it.lane } + 1
return ALL_DAY_ROW_HEIGHT * lanes + ALL_DAY_VERTICAL_PADDING * 2
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DayScreen(
selectedView: CalendarView,
onSelectView: (CalendarView) -> Unit,
onEventClick: (EventInstance) -> Unit,
onOpenSettings: () -> Unit,
onCreateEvent: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
initialDateIso: String? = null,
viewModel: DayViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val date by viewModel.date.collectAsStateWithLifecycle()
// When opened from the month grid, anchor to the tapped date.
LaunchedEffect(initialDateIso) {
initialDateIso?.let { viewModel.goToDate(LocalDate.parse(it)) }
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
// The all-day strip shares the app bar's scrolled colour so the whole top
// region elevates together once the timeline scrolls under it.
val topSectionColor by animateColorAsState(
targetValue = if (scrollBehavior.state.overlappedFraction > 0.01f) {
MaterialTheme.colorScheme.surfaceContainer
} else {
MaterialTheme.colorScheme.surface
},
label = "day-top-section-color",
)
val isOnToday = when (val s = state) {
is DayUiState.Success -> s.date == s.today
else -> true
}
// Slide direction for the day transition: +1 = next, -1 = prev, 0 = jump.
var slideDir by remember { mutableIntStateOf(0) }
val goNext = { slideDir = 1; viewModel.goToNext() }
val goPrev = { slideDir = -1; viewModel.goToPrev() }
// Slide toward today: viewing the future → today comes in from the left
// (back), viewing the past → from the right (forward).
val jumpToToday = {
slideDir = when (val s = state) {
is DayUiState.Success -> if (s.today < s.date) -1 else 1
else -> 0
}
viewModel.goToToday()
}
ModalNavigationDrawer(
drawerState = drawerState,
// Open only via the menu button — edge-swipe would fight the day swipe.
gesturesEnabled = drawerState.isOpen,
drawerContent = {
CalendarDrawer(
onToday = { jumpToToday(); scope.launch { drawerState.close() } },
onSettings = {
onOpenSettings()
scope.launch { drawerState.close() }
},
)
},
) {
Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
DayTopBar(
date = date,
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next()) },
onOpenDrawer = { scope.launch { drawerState.open() } },
scrollBehavior = scrollBehavior,
)
},
floatingActionButton = {
CalendarFabColumn(
todayVisible = !isOnToday,
todayText = stringResource(R.string.day_today_action),
onToday = jumpToToday,
onCreate = { onCreateEvent(date) },
)
},
) { innerPadding ->
DayContent(
state = state,
slideDir = slideDir,
topSectionColor = topSectionColor,
onSwipeNext = goNext,
onSwipePrev = goPrev,
onRetry = jumpToToday,
onEventClick = onEventClick,
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
)
}
}
}
@Composable
private fun DayContent(
state: DayUiState,
slideDir: Int,
topSectionColor: Color,
onSwipeNext: () -> Unit,
onSwipePrev: () -> Unit,
onRetry: () -> Unit,
onEventClick: (EventInstance) -> Unit,
modifier: Modifier = Modifier,
) {
val density = LocalDensity.current
val threshold = with(density) { 24.dp.toPx() }
var dragAccum by remember { mutableFloatStateOf(0f) }
val slideSpec = rememberCalendarSlideSpec()
// Hoisted above the per-day AnimatedContent so the vertical scroll position
// survives day-to-day swipes. We only centre on noon once, on first entry
// into the day view (i.e. when arriving from the month/week view).
val scrollState = rememberScrollState()
LaunchedEffect(Unit) {
snapshotFlow { scrollState.maxValue }.first { it > 0 }
val maxV = scrollState.maxValue
val target = with(density) {
(HOUR_HEIGHT.toPx() * 12 - (HOUR_HEIGHT.toPx() * 24 - maxV) / 2f).roundToInt()
}.coerceIn(0, maxV)
scrollState.scrollTo(target)
}
// Single, hoisted all-day strip height — shared by the outgoing and incoming
// day during a swipe, so the strip slides along but never jumps in height.
val targetAllDayHeight = (state as? DayUiState.Success)?.allDayStripHeight() ?: 0.dp
val allDayHeight by animateDpAsState(
targetValue = targetAllDayHeight,
label = "day-all-day-strip-height",
)
// Whole-page horizontal swipe, one level above the timeline's vertical
// scroll: a horizontal drag crosses this detector's slop, while a vertical
// drag is consumed by the inner scroll first — the two gestures coexist.
val swipeModifier = Modifier.pointerInput(Unit) {
detectHorizontalDragGestures(
onDragStart = { dragAccum = 0f },
onDragEnd = {
when {
dragAccum < -threshold -> onSwipeNext()
dragAccum > threshold -> onSwipePrev()
}
dragAccum = 0f
},
onDragCancel = { dragAccum = 0f },
onHorizontalDrag = { _, drag -> dragAccum += drag },
)
}
AnimatedContent(
targetState = state,
modifier = modifier.then(swipeModifier),
contentKey = { s ->
when (s) {
is DayUiState.Success -> "success-${s.date}"
is DayUiState.Failure -> "failure-${s.reason}"
DayUiState.Loading -> "loading"
}
},
transitionSpec = { calendarSlideTransition(slideDir, slideSpec) },
label = "day-transition",
) { s ->
when (s) {
DayUiState.Loading -> DayLoading()
is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
is DayUiState.Success -> DaySuccess(
state = s,
topSectionColor = topSectionColor,
scrollState = scrollState,
allDayHeight = allDayHeight,
onEventClick = onEventClick,
)
}
}
}
@Composable
private fun DaySuccess(
state: DayUiState.Success,
topSectionColor: Color,
scrollState: ScrollState,
allDayHeight: Dp,
onEventClick: (EventInstance) -> Unit,
) {
Column(modifier = Modifier.fillMaxSize()) {
// All-day strip collapses to nothing when the day has no all-day events,
// so the timeline sits directly under the app bar.
AllDayStrip(
state = state,
height = allDayHeight,
onEventClick = onEventClick,
modifier = Modifier
.fillMaxWidth()
.background(topSectionColor),
)
// Breathing room between the (colour-shifting) top section and the
// scrolling timeline below.
Spacer(Modifier.height(8.dp))
Timeline(state = state, scrollState = scrollState, onEventClick = onEventClick)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DayTopBar(
date: LocalDate,
selectedView: CalendarView,
onCycleView: () -> Unit,
onOpenDrawer: () -> Unit,
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
) {
TopAppBar(
title = {
Text(
text = formatDayTitle(date),
style = MaterialTheme.typography.titleLarge,
)
},
navigationIcon = {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = stringResource(R.string.month_open_menu),
)
}
},
actions = {
ViewSwitcherPill(
current = selectedView,
onCycle = onCycleView,
modifier = Modifier.padding(end = 8.dp),
)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
),
scrollBehavior = scrollBehavior,
)
}
@Composable
private fun AllDayStrip(
state: DayUiState.Success,
height: Dp,
onEventClick: (EventInstance) -> Unit,
modifier: Modifier = Modifier,
) {
val dark = isSystemInDarkTheme()
Row(
modifier = modifier
// Height is hoisted + animated so it resizes smoothly; padding sits
// inside it so the content area is lanes * row height.
.height(height)
.padding(vertical = ALL_DAY_VERTICAL_PADDING),
) {
// Keep the gutter-width offset so the bars line up with the day column.
Spacer(Modifier.width(GUTTER_WIDTH))
// Bars are positioned absolutely by lane (vertical stacking); each spans
// the full day-column width. clipToBounds keeps bars from spilling out
// while the height animates.
BoxWithConstraints(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.clipToBounds(),
) {
val barWidth = maxWidth
state.allDay.forEach { span ->
AllDayBar(
event = span.event,
dark = dark,
onClick = { onEventClick(span.event) },
modifier = Modifier
.offset(y = ALL_DAY_ROW_HEIGHT * span.lane)
.width(barWidth)
.height(ALL_DAY_ROW_HEIGHT)
.padding(horizontal = 1.dp, vertical = 1.dp),
)
}
}
}
}
@Composable
private fun AllDayBar(
event: EventInstance,
dark: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
Box(
modifier = modifier
.background(pastelize(event.color, dark), RoundedCornerShape(4.dp))
.clickable(onClick = onClick)
.padding(horizontal = 6.dp, vertical = 2.dp)
.semantics { contentDescription = title },
contentAlignment = Alignment.CenterStart,
) {
Text(
text = title,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = Color.Black.copy(alpha = 0.8f),
)
}
}
@Composable
private fun Timeline(
state: DayUiState.Success,
scrollState: ScrollState,
onEventClick: (EventInstance) -> Unit,
) {
val totalHeight = HOUR_HEIGHT * 24
val dark = isSystemInDarkTheme()
Box(modifier = Modifier.fillMaxSize()) {
// Gutter and day column are two scroll viewports that SHARE one scroll
// state, so they stay perfectly aligned. The day-column viewport is a
// static, rounded-clipped window — the content scrolls inside it, so the
// soft corners are permanent at any scroll position.
Row(modifier = Modifier.fillMaxSize()) {
// Hour gutter (scrolls in sync with the day column)
Column(
modifier = Modifier
.width(GUTTER_WIDTH)
.fillMaxHeight()
.verticalScroll(scrollState),
) {
(0 until 24).forEach { h ->
Box(
modifier = Modifier
.fillMaxWidth()
.height(HOUR_HEIGHT),
) {
if (h > 0) {
Text(
text = "%02d".format(h),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.align(Alignment.TopCenter)
.offset(y = (-6).dp),
)
}
}
}
}
// Day column: rounded, clipped scroll viewport (permanent corners).
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.clip(RoundedCornerShape(16.dp))
.verticalScroll(scrollState),
) {
DayColumnCard(
blocks = state.timed,
dark = dark,
onEventClick = onEventClick,
modifier = Modifier
.fillMaxWidth()
.height(totalHeight),
)
}
}
}
}
@Composable
private fun DayColumnCard(
blocks: List<TimedBlock>,
dark: Boolean,
onEventClick: (EventInstance) -> Unit,
modifier: Modifier = Modifier,
) {
Card(
// Plain rectangular column — the soft corners come from the outer
// rounded scroll viewport, so inner rounding would look odd at the edges.
shape = RectangleShape,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
modifier = modifier,
) {
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val colWidth = maxWidth
blocks.forEach { block ->
val laneWidth = colWidth / block.laneCount
val top = HOUR_HEIGHT * (block.startMin / 60f)
val rawHeight = HOUR_HEIGHT * ((block.endMin - block.startMin) / 60f)
val height = if (rawHeight < MIN_EVENT_HEIGHT) MIN_EVENT_HEIGHT else rawHeight
EventBlock(
block = block,
dark = dark,
onClick = { onEventClick(block.event) },
modifier = Modifier
.offset(x = laneWidth * block.lane, y = top)
.width(laneWidth)
.height(height)
.padding(horizontal = 1.dp),
)
}
}
}
}
@Composable
private fun EventBlock(
block: TimedBlock,
dark: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
val timeLabel = "${minToHm(block.startMin)}${minToHm(block.endMin)}"
val showTime = block.endMin - block.startMin >= 45
Box(
modifier = modifier
.background(pastelize(block.event.color, dark), RoundedCornerShape(4.dp))
.clickable(onClick = onClick)
.padding(horizontal = 4.dp, vertical = 2.dp)
.semantics { contentDescription = "$title, $timeLabel" },
) {
Column {
Text(
text = title,
style = MaterialTheme.typography.labelMedium,
maxLines = if (showTime) 1 else 2,
overflow = TextOverflow.Ellipsis,
color = Color.Black.copy(alpha = 0.85f),
)
if (showTime) {
Text(
text = timeLabel,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = Color.Black.copy(alpha = 0.6f),
)
}
}
}
}
@Composable
private fun DayLoading() {
val totalHeight = HOUR_HEIGHT * 24
val scrollState = rememberScrollState()
Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
Spacer(Modifier.width(GUTTER_WIDTH))
Box(
modifier = Modifier
.weight(1f)
.height(totalHeight)
.padding(horizontal = 2.dp)
.background(MaterialTheme.colorScheme.surfaceContainer),
)
}
}
private fun minToHm(min: Int): String =
if (min >= MINUTES_PER_DAY) "24:00" else "%02d:%02d".format(min / 60, min % 60)
private fun formatDayTitle(date: LocalDate): String {
val locale = Locale.getDefault()
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale)
val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale)
return "$weekday, ${date.day}. $monthName ${date.year}"
}

View File

@@ -0,0 +1,25 @@
package de.jeanlucmakiola.calendula.ui.day
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.ui.week.AllDaySpan
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
import kotlinx.datetime.LocalDate
/**
* The day view is a single-column slice of the week view (spec S3). It reuses the
* week's [TimedBlock] and [AllDaySpan] layout primitives — for one day, all-day
* spans collapse to a single column ([AllDaySpan.startCol] == [AllDaySpan.endCol]
* == 0) and only their [AllDaySpan.lane] (vertical stacking) matters.
*/
sealed interface DayUiState {
data object Loading : DayUiState
data class Failure(val reason: FailureReason) : DayUiState
data class Success(
val date: LocalDate,
val today: LocalDate,
/** All-day/multi-day events covering this day, stacked by lane. */
val allDay: List<AllDaySpan>,
/** Timed events clipped to this day with overlap lanes resolved. */
val timed: List<TimedBlock>,
) : DayUiState
}

View File

@@ -0,0 +1,111 @@
package de.jeanlucmakiola.calendula.ui.day
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
import de.jeanlucmakiola.calendula.ui.week.layoutDay
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.atTime
import kotlinx.datetime.minus
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.Instant
import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class DayViewModel @Inject constructor(
private val repository: CalendarRepository,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
private val zone = TimeZone.currentSystemDefault()
private val todayDate: LocalDate
get() = Clock.System.now().toLocalDateTime(zone).date
private val _date = MutableStateFlow(todayDate)
val date: StateFlow<LocalDate> = _date
val state: StateFlow<DayUiState> = _date
.flatMapLatest { day ->
val range = dayRange(day, zone)
combine(
repository.calendars(),
repository.instances(range),
) { calendars, instances ->
buildState(day, calendars, instances)
}
}
.catch { emit(DayUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = DayUiState.Loading,
)
fun goToPrev() {
_date.value = _date.value.minus(1, DateTimeUnit.DAY)
}
fun goToNext() {
_date.value = _date.value.plus(1, DateTimeUnit.DAY)
}
fun goToToday() {
_date.value = todayDate
}
/** Jump to a specific date (e.g. when opened from the month grid). */
fun goToDate(date: LocalDate) {
_date.value = date
}
private fun buildState(
day: LocalDate,
calendars: List<CalendarSource>,
instances: List<EventInstance>,
): DayUiState {
if (calendars.isEmpty()) {
return DayUiState.Failure(FailureReason.NoCalendarsConfigured)
}
val days = listOf(day)
val allDay = instances.filter { it.isAllDay }
val timed = instances.filterNot { it.isAllDay }
return DayUiState.Success(
date = day,
today = todayDate,
allDay = layoutAllDay(allDay, days, zone),
timed = layoutDay(timed, day, zone),
)
}
}
/** Half-open instant range covering the single calendar [date]. */
internal fun dayRange(date: LocalDate, zone: TimeZone): ClosedRange<Instant> {
val from = date.atStartOfDayIn(zone)
val to = date.atTime(23, 59, 59).toInstant(zone)
return from..to
}

View File

@@ -0,0 +1,882 @@
package de.jeanlucmakiola.calendula.ui.detail
import android.Manifest
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.icu.text.ListFormatter
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
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.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Notes
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.People
import androidx.compose.material.icons.filled.Place
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Repeat
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
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.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Attendee
import de.jeanlucmakiola.calendula.domain.AttendeeRelationship
import de.jeanlucmakiola.calendula.domain.AttendeeStatus
import de.jeanlucmakiola.calendula.domain.AttendeeType
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.OptionCard
import de.jeanlucmakiola.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.pastelize
import kotlinx.datetime.TimeZone
import java.time.DayOfWeek
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
/**
* Full-screen event detail (spec S4, realised as a navigation destination
* rather than a bottom sheet — MD3 list→detail pattern). Back gesture and the
* top-bar arrow both return to the calendar. Events in writable calendars can
* be deleted from here (v1.1); edit follows in v1.3.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EventDetailScreen(
eventId: Long,
beginMillis: Long,
endMillis: Long,
onBack: () -> Unit,
viewModel: EventDetailViewModel = hiltViewModel(),
) {
LaunchedEffect(eventId, beginMillis, endMillis) {
viewModel.open(eventId, beginMillis, endMillis)
}
val state by viewModel.state.collectAsStateWithLifecycle()
val deleteState by viewModel.deleteState.collectAsStateWithLifecycle()
BackHandler(onBack = onBack)
val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
// v1.0 installs only hold READ_CALENDAR; the first write asks for the
// upgrade in place. Granting continues straight into the confirm dialog.
val writePermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) showDeleteDialog = true
}
val onDeleteClick = {
val granted = ContextCompat.checkSelfPermission(
context,
Manifest.permission.WRITE_CALENDAR,
) == PackageManager.PERMISSION_GRANTED
if (granted) {
showDeleteDialog = true
} else {
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
}
}
val deleteFailedMessage = stringResource(R.string.event_delete_failed)
val writeDeniedMessage = stringResource(R.string.event_delete_write_denied)
LaunchedEffect(deleteState) {
when (deleteState) {
DeleteUiState.Deleted -> {
viewModel.consumeDeleteResult()
onBack()
}
DeleteUiState.Failed -> {
viewModel.consumeDeleteResult()
snackbarHostState.showSnackbar(deleteFailedMessage)
}
DeleteUiState.NeedsPermission -> {
viewModel.consumeDeleteResult()
snackbarHostState.showSnackbar(writeDeniedMessage)
}
DeleteUiState.Idle, DeleteUiState.Deleting -> Unit
}
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = {},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.event_detail_back),
)
}
},
actions = {
// Only writable calendars get actions — WebCal subscriptions,
// birthday calendars etc. are read-only at the provider level.
val s = state
if (s is EventDetailUiState.Success && s.canModify) {
IconButton(
onClick = onDeleteClick,
enabled = deleteState != DeleteUiState.Deleting,
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = stringResource(R.string.event_detail_delete),
)
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
},
) { innerPadding ->
val contentModifier = Modifier
.fillMaxSize()
.padding(innerPadding)
when (val s = state) {
EventDetailUiState.Loading -> EventDetailLoading(contentModifier)
is EventDetailUiState.Failure -> CalendarFailure(
reason = s.reason,
onRetry = viewModel::retry,
)
is EventDetailUiState.Success -> EventDetailContent(s, contentModifier)
}
}
val loaded = state
if (showDeleteDialog && loaded is EventDetailUiState.Success) {
DeleteEventDialog(
isRecurring = !loaded.detail.rrule.isNullOrBlank(),
onConfirm = { wholeSeries ->
showDeleteDialog = false
viewModel.delete(wholeSeries)
},
onDismiss = { showDeleteDialog = false },
)
}
}
/**
* Delete confirmation. Recurring events choose between cancelling just the
* tapped occurrence (default) and removing the whole series.
*/
@Composable
private fun DeleteEventDialog(
isRecurring: Boolean,
onConfirm: (wholeSeries: Boolean) -> Unit,
onDismiss: () -> Unit,
) {
var wholeSeries by rememberSaveable { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
stringResource(
if (isRecurring) R.string.event_delete_recurring_title
else R.string.event_delete_title,
),
)
},
text = {
if (isRecurring) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OptionCard(
label = stringResource(R.string.event_delete_option_occurrence),
onClick = { wholeSeries = false },
selected = !wholeSeries,
)
OptionCard(
label = stringResource(R.string.event_delete_option_series),
onClick = { wholeSeries = true },
selected = wholeSeries,
)
}
} else {
Text(stringResource(R.string.event_delete_body))
}
},
confirmButton = {
TextButton(onClick = { onConfirm(if (isRecurring) wholeSeries else true) }) {
Text(
text = stringResource(R.string.event_detail_delete),
color = MaterialTheme.colorScheme.error,
)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.dialog_cancel))
}
},
)
}
@Composable
private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modifier = Modifier) {
val detail = state.detail
val instance = detail.instance
val dark = isSystemInDarkTheme()
val locale = currentDetailLocale()
val accent = pastelize(instance.color, dark)
Column(
modifier = modifier
.verticalScroll(rememberScrollState())
.padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 40.dp),
) {
// Title row: title on the left, a "Free" pill pinned top-right when the
// event doesn't block your time. Busy is the default for nearly every
// event, so it's left implicit — only Free is worth surfacing. A
// cancelled event strikes through its title.
Row(verticalAlignment = Alignment.Top) {
Text(
text = instance.title.ifBlank { stringResource(R.string.event_untitled) },
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.SemiBold,
textDecoration = if (detail.status == EventStatus.Cancelled) {
TextDecoration.LineThrough
} else {
null
},
modifier = Modifier.weight(1f),
)
if (detail.availability == Availability.Free) {
Spacer(Modifier.width(12.dp))
InfoChip(
text = stringResource(R.string.event_availability_free),
modifier = Modifier.padding(top = 6.dp),
)
}
}
Spacer(Modifier.height(10.dp))
Box(
modifier = Modifier
.width(48.dp)
.height(3.dp)
.background(accent, RoundedCornerShape(2.dp)),
)
// Status / access chips — shown only when noteworthy (Confirmed status
// and Default/Public access are the silent norm).
val hasStatusChips = detail.status != EventStatus.Confirmed ||
detail.accessLevel == AccessLevel.Private ||
detail.accessLevel == AccessLevel.Confidential
if (hasStatusChips) {
Spacer(Modifier.height(16.dp))
StatusChips(detail.status, detail.accessLevel)
}
Spacer(Modifier.height(20.dp))
// Every piece of info shares one card design: a tonal container with a
// leading icon in the gutter and the value to the right. 12dp gaps stack
// them cleanly.
val gap = 12.dp
// "When" — date/all-day plus the time range.
val (whenPrimary, whenSecondary) = formatWhen(instance, TimeZone.currentSystemDefault(), locale)
DetailCard(icon = Icons.Default.Schedule, iconContentDescription = null) {
Text(text = whenPrimary, style = MaterialTheme.typography.titleMedium)
if (whenSecondary != null) {
Spacer(Modifier.height(2.dp))
Text(
text = whenSecondary,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// Time zone — only when the event is timed and pinned to a zone other
// than the device's, so cross-zone events read unambiguously.
foreignTimeZoneLabel(detail.eventTimezone, instance.isAllDay, locale)?.let { tzLabel ->
Spacer(Modifier.height(gap))
DetailCard(
icon = Icons.Default.Public,
iconContentDescription = stringResource(R.string.event_detail_timezone),
) {
Text(text = tzLabel, style = MaterialTheme.typography.titleMedium)
}
}
// Calendar — icon tinted in the calendar colour conveys identity, so no
// separate colour dot is needed.
Spacer(Modifier.height(gap))
DetailCard(
icon = Icons.Default.CalendarMonth,
iconTint = accent,
iconContentDescription = stringResource(R.string.event_detail_calendar),
) {
Text(
text = state.calendarName ?: stringResource(R.string.event_detail_calendar_unknown),
style = MaterialTheme.typography.titleMedium,
)
}
// Location (conditional, tap → maps).
instance.location?.takeIf { it.isNotBlank() }?.let { location ->
val context = LocalContext.current
Spacer(Modifier.height(gap))
DetailCard(
icon = Icons.Default.Place,
iconContentDescription = stringResource(R.string.event_detail_location),
) {
Text(
text = location,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.fillMaxWidth()
.clickable { openInMaps(context, location) }
.padding(vertical = 2.dp),
)
}
}
// Description (conditional). URLs are auto-linked.
detail.description?.takeIf { it.isNotBlank() }?.let { description ->
Spacer(Modifier.height(gap))
DetailCard(
icon = Icons.AutoMirrored.Filled.Notes,
iconContentDescription = stringResource(R.string.event_detail_description),
) {
Text(
text = linkifyUrls(description, MaterialTheme.colorScheme.primary),
style = MaterialTheme.typography.bodyMedium,
)
}
}
// Attendees (conditional). The user's own response leads the list, then
// each attendee with their role and reply.
detail.attendees.takeIf { it.isNotEmpty() }?.let { attendees ->
Spacer(Modifier.height(gap))
DetailCard(
icon = Icons.Default.People,
iconContentDescription = stringResource(R.string.event_detail_attendees),
) {
if (detail.selfStatus != AttendeeStatus.Unknown) {
Text(
text = stringResource(
R.string.event_detail_self_response,
stringResource(attendeeStatusLabel(detail.selfStatus)),
),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(8.dp))
}
attendees.forEach { AttendeeRow(it) }
}
}
// Reminders (conditional) — list each lead time before the event.
detail.reminders.takeIf { it.isNotEmpty() }?.let { reminders ->
Spacer(Modifier.height(gap))
DetailCard(
icon = Icons.Default.Notifications,
iconContentDescription = stringResource(R.string.event_detail_reminders),
) {
reminders
.distinctBy { it.minutes }
.sortedBy { it.minutes }
.forEach { reminder ->
Text(
text = reminderLeadText(reminder),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(vertical = 2.dp),
)
}
}
}
// Recurrence (conditional) — humanised from the RRULE.
detail.rrule?.takeIf { it.isNotBlank() }?.let { rrule ->
Spacer(Modifier.height(gap))
DetailCard(
icon = Icons.Default.Repeat,
iconContentDescription = stringResource(R.string.event_detail_recurrence),
) {
Text(
text = recurrenceText(rrule, locale),
style = MaterialTheme.typography.titleMedium,
)
}
}
}
}
/** One info card: tonal container, leading icon in the gutter, value to the right. */
@Composable
private fun DetailCard(
icon: ImageVector,
iconContentDescription: String?,
iconTint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
content: @Composable ColumnScope.() -> Unit,
) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = RoundedCornerShape(16.dp),
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = icon,
contentDescription = iconContentDescription,
tint = iconTint,
modifier = Modifier.size(24.dp),
)
Spacer(Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f), content = content)
}
}
}
@Composable
private fun AttendeeRow(attendee: Attendee) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 3.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = attendee.name.ifBlank { attendee.email.orEmpty() },
style = MaterialTheme.typography.bodyMedium,
)
attendeeRoleLabel(attendee)?.let { roleRes ->
Text(
text = stringResource(roleRes),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(Modifier.width(8.dp))
Text(
text = stringResource(attendeeStatusLabel(attendee.status)),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
/** Status / access pills shown directly under the title accent. */
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun StatusChips(status: EventStatus, accessLevel: AccessLevel) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
when (status) {
EventStatus.Cancelled -> InfoChip(
text = stringResource(R.string.event_status_cancelled),
container = MaterialTheme.colorScheme.errorContainer,
content = MaterialTheme.colorScheme.onErrorContainer,
)
EventStatus.Tentative -> InfoChip(
text = stringResource(R.string.event_status_tentative),
container = MaterialTheme.colorScheme.tertiaryContainer,
content = MaterialTheme.colorScheme.onTertiaryContainer,
)
EventStatus.Confirmed -> Unit
}
when (accessLevel) {
AccessLevel.Private -> InfoChip(text = stringResource(R.string.event_access_private))
AccessLevel.Confidential ->
InfoChip(text = stringResource(R.string.event_access_confidential))
AccessLevel.Default, AccessLevel.Public -> Unit
}
}
}
@Composable
private fun InfoChip(
text: String,
modifier: Modifier = Modifier,
container: Color = MaterialTheme.colorScheme.surfaceContainerHighest,
content: Color = MaterialTheme.colorScheme.onSurfaceVariant,
) {
Surface(color = container, shape = RoundedCornerShape(8.dp), modifier = modifier) {
Text(
text = text,
style = MaterialTheme.typography.labelMedium,
color = content,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp),
)
}
}
@Composable
private fun EventDetailLoading(modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp)) {
SkeletonBar(widthFraction = 0.7f, height = 32.dp)
Spacer(Modifier.height(24.dp))
SkeletonBar(widthFraction = 1f, height = 64.dp)
Spacer(Modifier.height(28.dp))
SkeletonBar(widthFraction = 0.35f, height = 14.dp)
Spacer(Modifier.height(8.dp))
SkeletonBar(widthFraction = 0.6f, height = 16.dp)
Spacer(Modifier.height(24.dp))
SkeletonBar(widthFraction = 0.35f, height = 14.dp)
Spacer(Modifier.height(8.dp))
SkeletonBar(widthFraction = 0.8f, height = 16.dp)
}
}
@Composable
private fun SkeletonBar(widthFraction: Float, height: Dp) {
Box(
modifier = Modifier
.fillMaxWidth(widthFraction)
.height(height)
.background(
MaterialTheme.colorScheme.surfaceContainerHighest,
RoundedCornerShape(8.dp),
),
)
}
// --- helpers -------------------------------------------------------------
// Observable locale read (shared helper) — avoids NonObservableLocale /
// LocalContextConfigurationRead lint by going through LocalConfiguration.
@Composable
private fun currentDetailLocale(): Locale = currentLocale()
private fun attendeeStatusLabel(status: AttendeeStatus): Int = when (status) {
AttendeeStatus.Accepted -> R.string.event_attendee_accepted
AttendeeStatus.Declined -> R.string.event_attendee_declined
AttendeeStatus.Tentative -> R.string.event_attendee_tentative
AttendeeStatus.NeedsAction -> R.string.event_attendee_needs_action
AttendeeStatus.Unknown -> R.string.event_attendee_unknown
}
/**
* The role badge shown under an attendee's name. Organizer wins over type;
* required attendees (the common case) get no badge to keep the list quiet.
*/
private fun attendeeRoleLabel(attendee: Attendee): Int? = when {
attendee.relationship == AttendeeRelationship.Organizer -> R.string.event_attendee_organizer
attendee.type == AttendeeType.Optional -> R.string.event_attendee_optional
attendee.type == AttendeeType.Resource -> R.string.event_attendee_resource
else -> null
}
/** Humanise a reminder's lead time, e.g. "10 minutes before" / "At time of event". */
@Composable
private fun reminderLeadText(reminder: Reminder): String {
val minutes = reminder.minutes
return when {
minutes < 0 -> stringResource(R.string.reminder_default)
minutes == 0 -> stringResource(R.string.reminder_at_time)
minutes % 10_080 == 0 -> {
val weeks = minutes / 10_080
pluralStringResource(R.plurals.reminder_weeks, weeks, weeks)
}
minutes % 1_440 == 0 -> {
val days = minutes / 1_440
pluralStringResource(R.plurals.reminder_days, days, days)
}
minutes % 60 == 0 -> {
val hours = minutes / 60
pluralStringResource(R.plurals.reminder_hours, hours, hours)
}
else -> pluralStringResource(R.plurals.reminder_minutes, minutes, minutes)
}
}
/**
* A localized label for [tz] (e.g. "Central European Time (Europe/Berlin)"),
* but only when the event is timed and pinned to a zone different from the
* device's. Returns null when there's nothing worth showing.
*/
private fun foreignTimeZoneLabel(tz: String?, isAllDay: Boolean, locale: Locale): String? {
if (isAllDay || tz.isNullOrBlank()) return null
val deviceZone = ZoneId.systemDefault().id
if (tz == deviceZone) return null
return try {
val zone = ZoneId.of(tz)
val name = zone.getDisplayName(JavaTextStyle.FULL, locale)
if (name == tz) tz else "$name ($tz)"
} catch (e: Exception) {
tz
}
}
/** Wrap http(s) URLs in [text] as tappable links tinted [linkColor]. */
@Composable
private fun linkifyUrls(text: String, linkColor: Color): AnnotatedString = remember(text, linkColor) {
val regex = Regex("""https?://\S+""")
val styles = TextLinkStyles(
style = SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline),
)
buildAnnotatedString {
append(text)
for (match in regex.findAll(text)) {
// Trim trailing punctuation that commonly abuts a URL in prose.
val raw = match.value
val url = raw.trimEnd('.', ',', ';', ':', '!', '?', ')', ']', '"', '\'')
val end = match.range.first + url.length
addLink(LinkAnnotation.Url(url, styles), match.range.first, end)
}
}
}
/**
* Humanise an RFC 5545 RRULE into a localized phrase, e.g.
* "Every week on Tue and Thu until 31 Dec 2026" or "Every day, 10 times".
* Falls back to a generic label for rules we don't render in full (ordinal
* monthly/yearly BYDAY, etc.).
*/
@Composable
private fun recurrenceText(rrule: String, locale: Locale): AnnotatedString {
val parts = rrule.removePrefix("RRULE:").split(';').mapNotNull { token ->
val eq = token.indexOf('=')
if (eq <= 0) null else token.substring(0, eq).uppercase() to token.substring(eq + 1)
}.toMap()
val freq = parts["FREQ"]?.uppercase()
val interval = parts["INTERVAL"]?.toIntOrNull() ?: 1
val base = when (freq) {
"DAILY" -> if (interval == 1) stringResource(R.string.recurrence_daily)
else stringResource(R.string.recurrence_every_n_days, interval)
"WEEKLY" -> if (interval == 1) stringResource(R.string.recurrence_weekly)
else stringResource(R.string.recurrence_every_n_weeks, interval)
"MONTHLY" -> if (interval == 1) stringResource(R.string.recurrence_monthly)
else stringResource(R.string.recurrence_every_n_months, interval)
"YEARLY" -> if (interval == 1) stringResource(R.string.recurrence_yearly)
else stringResource(R.string.recurrence_every_n_years, interval)
else -> return AnnotatedString(stringResource(R.string.event_detail_recurring))
}
// Weekly + BYDAY → "<base> on <days>"; other BYDAY forms keep just the base.
// The day names + their joined block are tracked so only the names (not the
// commas/conjunction) can be italicised in the final string.
val byDay = parts["BYDAY"]
var dayNames: List<String>? = null
var joinedDays: String? = null
val main = if (freq == "WEEKLY" && !byDay.isNullOrBlank()) {
val days = byDay.split(',').mapNotNull { rruleDayName(it.trim(), locale) }
if (days.isNotEmpty()) {
val joined = ListFormatter.getInstance(locale).format(days)
dayNames = days
joinedDays = joined
stringResource(R.string.recurrence_on_days, base, joined)
} else {
base
}
} else {
base
}
// End bound: UNTIL (a date) takes precedence over COUNT (a number of times).
val until = parts["UNTIL"]?.let { parseUntilDate(it, locale) }
val count = parts["COUNT"]?.toIntOrNull()
val full = when {
until != null -> stringResource(R.string.recurrence_with_until, main, until)
count != null -> stringResource(R.string.recurrence_with_count, main, count)
else -> main
}
return buildAnnotatedString {
append(full)
val names = dayNames
val joined = joinedDays
if (names != null && joined != null) {
// Italicise each day name within the joined block only — leaving the
// separators and conjunction ("und"/"and") in the regular style.
val regionStart = full.indexOf(joined)
if (regionStart >= 0) {
val regionEnd = regionStart + joined.length
var cursor = regionStart
for (name in names) {
val at = full.indexOf(name, cursor)
if (at in regionStart until regionEnd) {
addStyle(SpanStyle(fontStyle = FontStyle.Italic), at, at + name.length)
cursor = at + name.length
}
}
}
}
}
}
/** Map an RRULE BYDAY token (e.g. "TU" or "2TH") to a localized short weekday name. */
private fun rruleDayName(token: String, locale: Locale): String? {
val dow = when (token.takeLast(2).uppercase()) {
"MO" -> DayOfWeek.MONDAY
"TU" -> DayOfWeek.TUESDAY
"WE" -> DayOfWeek.WEDNESDAY
"TH" -> DayOfWeek.THURSDAY
"FR" -> DayOfWeek.FRIDAY
"SA" -> DayOfWeek.SATURDAY
"SU" -> DayOfWeek.SUNDAY
else -> return null
}
return dow.getDisplayName(JavaTextStyle.SHORT, locale)
}
/** Parse an RRULE UNTIL value ("20261231" or "20261231T235959Z") to a localized date. */
private fun parseUntilDate(raw: String, locale: Locale): String? {
val digits = raw.takeWhile { it.isDigit() }
if (digits.length < 8) return null
return try {
val date = java.time.LocalDate.of(
digits.substring(0, 4).toInt(),
digits.substring(4, 6).toInt(),
digits.substring(6, 8).toInt(),
)
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale).format(date)
} catch (e: Exception) {
null
}
}
/**
* Format an event's time into a primary line (date, or "All day") and an
* optional secondary line (time range). Multi-day timed events collapse into a
* single primary line spanning both ends.
*/
@Composable
private fun formatWhen(
instance: EventInstance,
zone: TimeZone,
locale: Locale,
): Pair<String, String?> {
val zid = ZoneId.of(zone.id)
val dateFull = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withLocale(locale)
val dateMedium = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
val timeShort = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
val startLdt = instance.start.toJavaLocalDateTime(zid)
val allDayLabel = stringResource(R.string.event_detail_all_day)
if (instance.isAllDay) {
// All-day end is the exclusive next midnight; step back to the last
// covered day so a one-day event reads as a single date.
val lastLdt = (instance.end - 1.seconds).toJavaLocalDateTime(zid)
return if (startLdt.toLocalDate() == lastLdt.toLocalDate()) {
allDayLabel to dateFull.format(startLdt.toLocalDate())
} else {
allDayLabel to
"${dateMedium.format(startLdt.toLocalDate())} ${dateMedium.format(lastLdt.toLocalDate())}"
}
}
val endLdt = instance.end.toJavaLocalDateTime(zid)
return if (startLdt.toLocalDate() == endLdt.toLocalDate()) {
dateFull.format(startLdt.toLocalDate()) to
"${timeShort.format(startLdt)} ${timeShort.format(endLdt)}"
} else {
val start = "${dateMedium.format(startLdt.toLocalDate())} ${timeShort.format(startLdt)}"
val end = "${dateMedium.format(endLdt.toLocalDate())} ${timeShort.format(endLdt)}"
"$start $end" to null
}
}
private fun Instant.toJavaLocalDateTime(zid: ZoneId): java.time.LocalDateTime =
java.time.LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(toEpochMilliseconds()), zid)
/** Open a maps intent for [query]; fall back to a web maps URL if no app handles geo:. */
private fun openInMaps(context: Context, query: String) {
val encoded = Uri.encode(query)
val geo = Intent(Intent.ACTION_VIEW, Uri.parse("geo:0,0?q=$encoded"))
try {
context.startActivity(geo)
} catch (e: ActivityNotFoundException) {
val web = Intent(
Intent.ACTION_VIEW,
Uri.parse("https://www.google.com/maps/search/?api=1&query=$encoded"),
)
try {
context.startActivity(web)
} catch (e2: ActivityNotFoundException) {
// No browser either — nothing sensible to do; swallow.
}
}
}

View File

@@ -0,0 +1,32 @@
package de.jeanlucmakiola.calendula.ui.detail
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.FailureReason
/**
* UI state for the event-detail screen (spec S4).
*/
sealed interface EventDetailUiState {
data object Loading : EventDetailUiState
data class Failure(val reason: FailureReason) : EventDetailUiState
data class Success(
val detail: EventDetail,
/** Display name of the owning calendar, null if it can't be resolved. */
val calendarName: String?,
/** Whether the owning calendar allows modifying events (shows edit/delete). */
val canModify: Boolean = false,
) : EventDetailUiState
}
/**
* One-shot state of a delete request, separate from the screen state so a
* failed delete leaves the loaded detail visible.
*/
sealed interface DeleteUiState {
data object Idle : DeleteUiState
data object Deleting : DeleteUiState
data object Deleted : DeleteUiState
/** WRITE_CALENDAR was revoked between the tap and the provider call. */
data object NeedsPermission : DeleteUiState
data object Failed : DeleteUiState
}

View File

@@ -0,0 +1,141 @@
package de.jeanlucmakiola.calendula.ui.detail
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.domain.FailureReason
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Instant
import javax.inject.Inject
/**
* Loads a single event's detail on demand for the bottom sheet (spec S4).
* The event id is set via [open]; the sheet observes [state].
*/
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class EventDetailViewModel @Inject constructor(
private val repository: CalendarRepository,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
private val _target = MutableStateFlow<Target?>(null)
// Bumped by retry() to re-run the load for the same target.
private val _reload = MutableStateFlow(0)
private val _deleteState = MutableStateFlow<DeleteUiState>(DeleteUiState.Idle)
val deleteState: StateFlow<DeleteUiState> = _deleteState.asStateFlow()
val state: StateFlow<EventDetailUiState> =
combine(_target, _reload) { target, _ -> target }
.flatMapLatest { target ->
if (target == null) {
flowOf<EventDetailUiState>(EventDetailUiState.Loading)
} else {
flow {
emit(EventDetailUiState.Loading)
emit(loadDetail(target))
}
}
}
.flowOn(io)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = EventDetailUiState.Loading,
)
/**
* Open (or switch) to the tapped occurrence. [beginMillis]/[endMillis] are
* the occurrence's own times (from `CalendarContract.Instances`); they
* override the series DTSTART/DTEND so recurring events show the correct
* date instead of the first occurrence.
*/
fun open(eventId: Long, beginMillis: Long, endMillis: Long) {
_target.value = Target(eventId, beginMillis, endMillis)
}
/** Re-run the current load after a failure. */
fun retry() {
_reload.value += 1
}
/**
* Delete the open event. [wholeSeries] is meaningful only for recurring
* events: false cancels just the tapped occurrence. Result lands in
* [deleteState]; the screen consumes it via [consumeDeleteResult].
*/
fun delete(wholeSeries: Boolean) {
val target = _target.value ?: return
if (_deleteState.value == DeleteUiState.Deleting) return
viewModelScope.launch {
_deleteState.value = DeleteUiState.Deleting
_deleteState.value = try {
if (wholeSeries) {
repository.deleteEvent(target.eventId)
} else {
repository.deleteOccurrence(target.eventId, target.beginMillis)
}
DeleteUiState.Deleted
} catch (e: CancellationException) {
throw e
} catch (e: SecurityException) {
DeleteUiState.NeedsPermission
} catch (e: Exception) {
DeleteUiState.Failed
}
}
}
/** Reset [deleteState] after the screen handled a terminal result. */
fun consumeDeleteResult() {
_deleteState.value = DeleteUiState.Idle
}
private suspend fun loadDetail(target: Target): EventDetailUiState = try {
val detail = repository.eventDetail(target.eventId)
// The Events row holds the series start; replace it with this
// occurrence's time so recurring events render correctly.
val corrected = detail.copy(
instance = detail.instance.copy(
start = Instant.fromEpochMilliseconds(target.beginMillis),
end = Instant.fromEpochMilliseconds(target.endMillis),
),
)
val calendar = repository.calendars().first()
.firstOrNull { it.id == corrected.instance.calendarId }
EventDetailUiState.Success(
detail = corrected,
calendarName = calendar?.displayName,
canModify = calendar?.canModifyContents == true,
)
} catch (e: CancellationException) {
throw e
} catch (e: NoSuchEventException) {
EventDetailUiState.Failure(FailureReason.EventNotFound)
} catch (e: SecurityException) {
EventDetailUiState.Failure(FailureReason.PermissionRevoked)
} catch (e: Exception) {
EventDetailUiState.Failure(FailureReason.ProviderUnavailable)
}
/** A tapped occurrence: the series [eventId] plus this occurrence's own times. */
private data class Target(val eventId: Long, val beginMillis: Long, val endMillis: Long)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,34 @@
package de.jeanlucmakiola.calendula.ui.edit
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.EventFormProblem
/**
* UI state for the event form (v1.2: create; v1.3 reuses it for edit). Null
* form means the screen hasn't been opened yet.
*/
data class EventEditUiState(
/** The form with its calendar id resolved (picked > last used > first writable). */
val form: EventForm,
/** Calendars that accept writes — the only valid targets. */
val calendars: List<CalendarSource>,
/** Validation problems; empty until a save was attempted. */
val problems: Set<EventFormProblem>,
val saveState: SaveUiState,
/** Optional sections currently rendered (settings defaults revealed). */
val visibleFields: Set<EventFormField> = emptySet(),
/** True while at least one optional section hides behind "more fields". */
val hasHiddenFields: Boolean = false,
)
/** One-shot state of a save request, mirroring DeleteUiState on the detail screen. */
sealed interface SaveUiState {
data object Idle : SaveUiState
data object Saving : SaveUiState
data object Saved : SaveUiState
/** WRITE_CALENDAR was revoked between the tap and the provider call. */
data object NeedsPermission : SaveUiState
data object Failed : SaveUiState
}

View File

@@ -0,0 +1,202 @@
package de.jeanlucmakiola.calendula.ui.edit
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.problems
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Clock
import kotlin.time.Duration.Companion.hours
import kotlin.time.Instant
import javax.inject.Inject
/**
* Holds the event form being composed. The form's calendar id resolves to
* (user pick > last used > first writable); the resolved value is what the UI
* shows and what gets saved.
*/
@HiltViewModel
class EventEditViewModel @Inject constructor(
private val repository: CalendarRepository,
private val prefs: CalendarPrefs,
private val settingsPrefs: SettingsPrefs,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
private val _form = MutableStateFlow<EventForm?>(null)
private val _saveState = MutableStateFlow<SaveUiState>(SaveUiState.Idle)
// Problems stay hidden until the first save attempt, so a half-filled
// form isn't already shouting errors.
private val _showProblems = MutableStateFlow(false)
// Fields added through the "more fields" picker; folds back on reset().
private val _revealed = MutableStateFlow<Set<EventFormField>>(emptySet())
private data class LocalInputs(
val form: EventForm?,
val saveState: SaveUiState,
val showProblems: Boolean,
val revealed: Set<EventFormField>,
)
private data class ExternalInputs(
val writable: List<CalendarSource>,
val lastUsed: Long?,
val defaultFields: Set<EventFormField>,
)
val state: StateFlow<EventEditUiState?> = combine(
combine(_form, _saveState, _showProblems, _revealed, ::LocalInputs),
combine(
repository.calendars()
.map { calendars -> calendars.filter { it.canModifyContents } }
.catch { emit(emptyList()) },
prefs.lastUsedCalendarId,
settingsPrefs.defaultFormFields,
::ExternalInputs,
),
) { local, external ->
val form = local.form ?: return@combine null
val resolvedId = form.calendarId
?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } }
?: external.writable.firstOrNull()?.id
val resolved = form.copy(calendarId = resolvedId)
val visibleFields = external.defaultFields + local.revealed
EventEditUiState(
form = resolved,
calendars = external.writable,
problems = if (local.showProblems) resolved.problems() else emptySet(),
saveState = local.saveState,
visibleFields = visibleFields,
hasHiddenFields = visibleFields.size < EventFormField.entries.size,
)
}
.flowOn(io)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = null,
)
/**
* Initialise a fresh form for a new event on [date]. No-op when a form is
* already open, so user input survives configuration changes; [reset]
* clears it when the screen closes.
*/
fun openNew(date: LocalDate) {
if (_form.value != null) return
val zone = TimeZone.currentSystemDefault()
val now = Clock.System.now()
val start = if (date == now.toLocalDateTime(zone).date) {
// Today: the next full hour (may roll into tomorrow before midnight).
val hourMillis = 3_600_000L
val rounded = (now.toEpochMilliseconds() / hourMillis + 1) * hourMillis
Instant.fromEpochMilliseconds(rounded).toLocalDateTime(zone)
} else {
LocalDateTime(date, LocalTime(9, 0))
}
val end = (start.toInstant(zone) + 1.hours).toLocalDateTime(zone)
_form.value = EventForm(calendarId = null, start = start, end = end)
}
/** Forget the open form; the next [openNew] starts clean. */
fun reset() {
_form.value = null
_saveState.value = SaveUiState.Idle
_showProblems.value = false
_revealed.value = emptySet()
}
/** Unfold one optional field, picked in the "more fields" dialog. */
fun revealField(field: EventFormField) {
_revealed.value = _revealed.value + field
}
fun setTitle(value: String) = update { it.copy(title = value) }
fun setLocation(value: String) = update { it.copy(location = value) }
fun setDescription(value: String) = update { it.copy(description = value) }
fun setAllDay(value: Boolean) = update { it.copy(isAllDay = value) }
fun setCalendar(id: Long) = update { it.copy(calendarId = id) }
fun setAvailability(value: Availability) = update { it.copy(availability = value) }
fun setAccessLevel(value: AccessLevel) = update { it.copy(accessLevel = value) }
fun addReminder(minutes: Int) = update {
it.copy(reminders = (it.reminders + minutes).distinct().sorted())
}
fun removeReminder(minutes: Int) = update {
it.copy(reminders = it.reminders - minutes)
}
/** Moving the start drags the end along, preserving the duration. */
fun setStartDate(date: LocalDate) = moveStart { LocalDateTime(date, it.time) }
fun setStartTime(time: LocalTime) = moveStart { LocalDateTime(it.date, time) }
fun setEndDate(date: LocalDate) = update { it.copy(end = LocalDateTime(date, it.end.time)) }
fun setEndTime(time: LocalTime) = update { it.copy(end = LocalDateTime(it.end.date, time)) }
/** Validate and write. Terminal results land in [saveState]. */
fun save() {
val current = state.value ?: return
if (current.saveState == SaveUiState.Saving) return
val form = current.form
if (form.problems().isNotEmpty()) {
_showProblems.value = true
return
}
viewModelScope.launch {
_saveState.value = SaveUiState.Saving
_saveState.value = try {
repository.createEvent(form)
prefs.setLastUsedCalendarId(requireNotNull(form.calendarId))
SaveUiState.Saved
} catch (e: CancellationException) {
throw e
} catch (e: SecurityException) {
SaveUiState.NeedsPermission
} catch (e: Exception) {
SaveUiState.Failed
}
}
}
/** Reset [saveState] after the screen handled a terminal result. */
fun consumeSaveResult() {
_saveState.value = SaveUiState.Idle
}
private fun moveStart(transform: (LocalDateTime) -> LocalDateTime) = update { form ->
val zone = TimeZone.currentSystemDefault()
val newStart = transform(form.start)
val duration = form.end.toInstant(zone) - form.start.toInstant(zone)
val newEnd = (newStart.toInstant(zone) + duration).toLocalDateTime(zone)
form.copy(start = newStart, end = newEnd)
}
private inline fun update(block: (EventForm) -> EventForm) {
_form.value = _form.value?.let(block)
}
}

View File

@@ -0,0 +1,154 @@
package de.jeanlucmakiola.calendula.ui.filter
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.ui.common.pastelize
/**
* Calendar-visibility filter (M3), rendered inline in the navigation drawer.
* Every calendar grouped by account, each with a colour swatch and a visibility
* switch; toggling writes straight to DataStore and every calendar view
* re-filters live. Three states (Loading / Failure / Success).
*/
@Composable
fun CalendarFilterList(
modifier: Modifier = Modifier,
viewModel: FilterViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
when (val s = state) {
FilterUiState.Loading -> FilterLoading(modifier)
is FilterUiState.Failure -> FilterMessage(s.reason, modifier)
is FilterUiState.Success -> FilterList(
groups = s.groups,
onSetVisible = viewModel::setVisible,
modifier = modifier,
)
}
}
@Composable
private fun FilterList(
groups: List<AccountGroup>,
onSetVisible: (Long, Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
val dark = isSystemInDarkTheme()
LazyColumn(
modifier = modifier.fillMaxWidth(),
contentPadding = PaddingValues(vertical = 4.dp),
) {
groups.forEach { group ->
item(key = "header-${group.account}") {
Text(
text = group.account,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 12.dp, bottom = 4.dp),
)
}
items(group.calendars, key = { it.id }) { cal ->
CalendarToggleRow(
row = cal,
dark = dark,
onCheckedChange = { onSetVisible(cal.id, it) },
)
}
}
}
}
@Composable
private fun CalendarToggleRow(
row: CalendarRow,
dark: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 28.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Box(
modifier = Modifier
.size(14.dp)
.background(pastelize(row.color, dark), CircleShape),
)
Text(
text = row.displayName,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
Checkbox(
checked = row.visible,
onCheckedChange = onCheckedChange,
)
}
}
@Composable
private fun FilterLoading(modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
repeat(4) {
Box(
modifier = Modifier
.padding(horizontal = 28.dp)
.fillMaxWidth()
.height(36.dp)
.background(
MaterialTheme.colorScheme.surfaceContainerHigh,
MaterialTheme.shapes.medium,
),
)
}
}
}
@Composable
private fun FilterMessage(reason: FailureReason, modifier: Modifier = Modifier) {
val msg = when (reason) {
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars
FailureReason.PermissionRevoked -> R.string.state_failure_permission
else -> R.string.state_failure_provider
}
Text(
text = stringResource(msg),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 28.dp, vertical = 24.dp),
)
}

View File

@@ -0,0 +1,27 @@
package de.jeanlucmakiola.calendula.ui.filter
import de.jeanlucmakiola.calendula.domain.FailureReason
/**
* State for the calendar-filter sheet (M3). The user toggles per-calendar
* visibility; the choice is persisted app-side (separate from the system's
* VISIBLE flag) and applied to every calendar view.
*/
sealed interface FilterUiState {
data object Loading : FilterUiState
data class Failure(val reason: FailureReason) : FilterUiState
data class Success(val groups: List<AccountGroup>) : FilterUiState
}
/** Calendars grouped under the account that owns them (Nextcloud / Local / …). */
data class AccountGroup(
val account: String,
val calendars: List<CalendarRow>,
)
data class CalendarRow(
val id: Long,
val displayName: String,
val color: Int,
val visible: Boolean,
)

View File

@@ -0,0 +1,85 @@
package de.jeanlucmakiola.calendula.ui.filter
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.FailureReason
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class FilterViewModel @Inject constructor(
private val repository: CalendarRepository,
private val prefs: CalendarPrefs,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
val state: StateFlow<FilterUiState> =
combine(
repository.calendars(),
prefs.hiddenCalendarIds,
) { calendars, hidden ->
if (calendars.isEmpty()) {
FilterUiState.Failure(FailureReason.NoCalendarsConfigured)
} else {
FilterUiState.Success(groupByAccount(calendars, hidden))
}
}
.catch { emit(FilterUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = FilterUiState.Loading,
)
/** Show or hide a single calendar; persists the new hidden set. */
fun setVisible(calendarId: Long, visible: Boolean) {
viewModelScope.launch {
val current = prefs.hiddenCalendarIds.first()
val next = if (visible) current - calendarId else current + calendarId
if (next != current) prefs.setHiddenCalendarIds(next)
}
}
}
/**
* Group calendars under their owning account, preserving the provider's order
* within each group and ordering groups by first appearance. A calendar is
* "visible" when its id is *not* in [hidden].
*/
internal fun groupByAccount(
calendars: List<CalendarSource>,
hidden: Set<Long>,
): List<AccountGroup> =
calendars
.groupBy { it.accountLabel() }
.map { (account, cals) ->
AccountGroup(
account = account,
calendars = cals.map { c ->
CalendarRow(
id = c.id,
displayName = c.displayName,
color = c.color,
visible = c.id !in hidden,
)
},
)
}
/** Account header text: the account name, falling back to its type. */
private fun CalendarSource.accountLabel(): String =
accountName.takeIf { it.isNotBlank() } ?: accountType.takeIf { it.isNotBlank() } ?: displayName

View File

@@ -0,0 +1,488 @@
package de.jeanlucmakiola.calendula.ui.month
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.isSystemInDarkTheme
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.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.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.calendula.ui.common.pastelize
import kotlinx.coroutines.launch
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MonthScreen(
selectedView: CalendarView,
onSelectView: (CalendarView) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onOpenSettings: () -> Unit,
onCreateEvent: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
viewModel: MonthViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val month by viewModel.month.collectAsStateWithLifecycle()
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
val isOnCurrentMonth = when (val s = state) {
is MonthUiState.Success -> s.month == YearMonth(s.today.year, s.today.month)
else -> true
}
// Slide direction for the grid transition: +1 = next, -1 = prev, 0 = jump (no slide).
var slideDir by remember { mutableIntStateOf(0) }
val goNext = {
slideDir = 1
viewModel.goToNext()
}
val goPrev = {
slideDir = -1
viewModel.goToPrev()
}
// Slide toward today: viewing the future → today comes in from the left
// (back), viewing the past → from the right (forward).
val jumpToToday = {
slideDir = when (val s = state) {
is MonthUiState.Success ->
if (YearMonth(s.today.year, s.today.month) < s.month) -1 else 1
else -> 0
}
viewModel.goToToday()
}
ModalNavigationDrawer(
drawerState = drawerState,
// Open only via the menu button — edge-swipe would fight the month swipe.
gesturesEnabled = drawerState.isOpen,
drawerContent = {
CalendarDrawer(
onToday = {
jumpToToday()
scope.launch { drawerState.close() }
},
onSettings = {
onOpenSettings()
scope.launch { drawerState.close() }
},
)
},
) {
Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
MonthTopBar(
month = month,
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next()) },
onOpenDrawer = { scope.launch { drawerState.open() } },
scrollBehavior = scrollBehavior,
)
},
floatingActionButton = {
CalendarFabColumn(
todayVisible = !isOnCurrentMonth,
todayText = stringResource(R.string.month_today_action),
onToday = jumpToToday,
onCreate = {
// Anchor on today when its month is shown, else the 1st.
val today = Clock.System.now()
.toLocalDateTime(TimeZone.currentSystemDefault()).date
onCreateEvent(
if (isOnCurrentMonth) today
else LocalDate(month.year, month.month, 1),
)
},
)
},
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
) {
WeekdayHeader(weekStart = weekStart)
MonthContent(
state = state,
weekStart = weekStart,
slideDir = slideDir,
onSwipeNext = goNext,
onSwipePrev = goPrev,
onRetry = jumpToToday,
onOpenDay = onOpenDay,
)
}
}
}
}
@Composable
private fun MonthContent(
state: MonthUiState,
weekStart: DayOfWeek,
slideDir: Int,
onSwipeNext: () -> Unit,
onSwipePrev: () -> Unit,
onRetry: () -> Unit,
onOpenDay: (LocalDate) -> Unit,
) {
val density = LocalDensity.current
val threshold = with(density) { 6.dp.toPx() }
var dragAccum by remember { mutableFloatStateOf(0f) }
val slideSpec = rememberCalendarSlideSpec()
val swipeModifier = Modifier.pointerInput(Unit) {
detectHorizontalDragGestures(
onDragStart = { dragAccum = 0f },
onDragEnd = {
when {
dragAccum < -threshold -> onSwipeNext()
dragAccum > threshold -> onSwipePrev()
}
dragAccum = 0f
},
onDragCancel = { dragAccum = 0f },
onHorizontalDrag = { _, drag -> dragAccum += drag },
)
}
AnimatedContent(
targetState = state,
modifier = Modifier.fillMaxSize().then(swipeModifier),
contentKey = { s ->
when (s) {
is MonthUiState.Success -> "success-${s.month}"
is MonthUiState.Failure -> "failure-${s.reason}"
MonthUiState.Loading -> "loading"
}
},
transitionSpec = { calendarSlideTransition(slideDir, slideSpec) },
label = "month-transition",
) { s ->
when (s) {
MonthUiState.Loading -> MonthGridLoading()
is MonthUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
is MonthUiState.Success -> MonthGrid(
state = s,
weekStart = weekStart,
onOpenDay = onOpenDay,
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun MonthTopBar(
month: YearMonth,
selectedView: CalendarView,
onCycleView: () -> Unit,
onOpenDrawer: () -> Unit,
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
) {
TopAppBar(
title = {
Text(
text = formatMonthYear(month),
style = MaterialTheme.typography.titleLarge,
)
},
navigationIcon = {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = stringResource(R.string.month_open_menu),
)
}
},
actions = {
ViewSwitcherPill(
current = selectedView,
onCycle = onCycleView,
modifier = Modifier.padding(end = 8.dp),
)
},
scrollBehavior = scrollBehavior,
)
}
@Composable
private fun WeekdayHeader(weekStart: DayOfWeek) {
val locale = currentLocale()
val days = remember(weekStart, locale) {
(0 until 7).map { offset ->
DayOfWeek.entries[((weekStart.ordinal + offset) % 7)]
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp),
) {
days.forEach { dow ->
val isWeekend = dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY
val javaDow = java.time.DayOfWeek.of(dow.ordinal + 1)
Text(
text = javaDow.getDisplayName(JavaTextStyle.SHORT, locale),
style = MaterialTheme.typography.labelMedium,
color = if (isWeekend) MaterialTheme.colorScheme.onSurfaceVariant
else MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
modifier = Modifier.weight(1f),
)
}
}
}
@Composable
private fun MonthGrid(
state: MonthUiState.Success,
weekStart: DayOfWeek,
onOpenDay: (LocalDate) -> Unit,
) {
val firstOfMonth = LocalDate(state.month.year, state.month.month, 1)
val gridStart = firstOfMonth.startOfGridWeek(weekStart)
// Show only the weeks the current month actually touches; leading/trailing
// days of neighbouring months are left blank rather than rendered.
val leadOffset = ((firstOfMonth.dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
val daysInMonth =
java.time.YearMonth.of(state.month.year, state.month.month.ordinal + 1).lengthOfMonth()
val weeks = (leadOffset + daysInMonth + 6) / 7
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
repeat(weeks) { row ->
Row(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
repeat(7) { col ->
val date = gridStart.plus(row * 7 + col, DateTimeUnit.DAY)
val inMonth =
date.month == state.month.month && date.year == state.month.year
if (inMonth) {
DayCard(
date = date,
isToday = date == state.today,
data = state.cells[date],
onClick = { onOpenDay(date) },
modifier = Modifier.weight(1f),
)
} else {
Spacer(Modifier.weight(1f))
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun DayCard(
date: LocalDate,
isToday: Boolean,
data: DayCellData?,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val todayPrefix = stringResource(R.string.month_a11y_today_prefix)
val cellLabel = buildString {
if (isToday) append(todayPrefix).append(", ")
append(date.year).append('-')
append((date.month.ordinal + 1).toString().padStart(2, '0')).append('-')
append(date.day.toString().padStart(2, '0'))
data?.let { append(", ").append(it.count).append(" Events") }
}
// M3 Expressive press feedback: a spatial spring from the active motion
// scheme drives a subtle scale, instead of a fixed easing curve.
val interactionSource = remember { MutableInteractionSource() }
val pressed by interactionSource.collectIsPressedAsState()
val scale by animateFloatAsState(
targetValue = if (pressed) 0.94f else 1f,
animationSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
label = "day-card-press",
)
Card(
onClick = onClick,
interactionSource = interactionSource,
shape = MaterialTheme.shapes.medium,
colors = CardDefaults.cardColors(
containerColor = if (isToday) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surfaceContainerHigh,
contentColor = if (isToday) MaterialTheme.colorScheme.onPrimaryContainer
else MaterialTheme.colorScheme.onSurface,
),
modifier = modifier
.fillMaxSize()
.graphicsLayer {
scaleX = scale
scaleY = scale
}
.semantics { contentDescription = cellLabel },
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(top = 4.dp, bottom = 2.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = date.day.toString(),
style = MaterialTheme.typography.labelLarge,
fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal,
)
Spacer(Modifier.height(2.dp))
EventDotRow(data)
}
}
}
@Composable
private fun EventDotRow(data: DayCellData?) {
if (data == null || data.swatches.isEmpty()) {
Spacer(Modifier.height(6.dp))
return
}
val dark = isSystemInDarkTheme()
Row(
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
data.swatches.forEach { argb ->
Box(
modifier = Modifier
.size(6.dp)
.background(pastelize(argb, dark), CircleShape),
)
}
if (data.count > data.swatches.size) {
Text(
text = "+${data.count - data.swatches.size}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun MonthGridLoading() {
val shape = MaterialTheme.shapes.medium
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
repeat(6) {
Row(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
repeat(7) {
Box(
modifier = Modifier
.weight(1f)
.fillMaxSize()
.background(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = shape,
),
)
}
}
}
}
}
private fun formatMonthYear(ym: YearMonth): String {
val locale = Locale.getDefault()
val name = java.time.Month.of(ym.month.ordinal + 1)
.getDisplayName(JavaTextStyle.FULL, locale)
return "$name ${ym.year}"
}

View File

@@ -0,0 +1,28 @@
package de.jeanlucmakiola.calendula.ui.month
import de.jeanlucmakiola.calendula.domain.FailureReason
import kotlinx.datetime.LocalDate
import kotlinx.datetime.YearMonth
/**
* Per-day aggregation surfaced to the month grid. We only need
* - the total event count (drives the optional "+N" indicator), and
* - up to three calendar colors for the dot row.
*
* The day cell never holds full event objects — the detail sheet pulls those
* lazily.
*/
data class DayCellData(
val count: Int,
val swatches: List<Int>,
)
sealed interface MonthUiState {
data object Loading : MonthUiState
data class Failure(val reason: FailureReason) : MonthUiState
data class Success(
val month: YearMonth,
val today: LocalDate,
val cells: Map<LocalDate, DayCellData>,
) : MonthUiState
}

View File

@@ -0,0 +1,141 @@
package de.jeanlucmakiola.calendula.ui.month
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.atTime
import kotlinx.datetime.minus
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import java.util.Locale
import kotlin.time.Clock
import kotlin.time.Instant
import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class MonthViewModel @Inject constructor(
private val repository: CalendarRepository,
settingsPrefs: SettingsPrefs,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
private val zone = TimeZone.currentSystemDefault()
private val locale: Locale = Locale.getDefault()
/** First day of the week, from the Settings preference (AUTO → locale). */
val weekStart: StateFlow<DayOfWeek> = settingsPrefs.weekStart
.map { it.resolveFirstDay(locale) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = DayOfWeek.MONDAY,
)
private val todayDate: LocalDate
get() = Clock.System.now().toLocalDateTime(zone).date
private val _month = MutableStateFlow(YearMonth(todayDate.year, todayDate.month))
val month: StateFlow<YearMonth> = _month
val state: StateFlow<MonthUiState> =
combine(_month, weekStart) { ym, ws -> ym to ws }
.flatMapLatest { (ym, ws) ->
val range = monthGridRange(ym, ws, zone)
combine(
repository.calendars(),
repository.instances(range),
) { calendars, instances ->
buildState(ym, calendars, instances)
}
}
.catch { emit(MonthUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = MonthUiState.Loading,
)
fun goToPrev() {
_month.value = _month.value.minus(1, DateTimeUnit.MONTH)
}
fun goToNext() {
_month.value = _month.value.plus(1, DateTimeUnit.MONTH)
}
fun goToToday() {
_month.value = YearMonth(todayDate.year, todayDate.month)
}
private fun buildState(
ym: YearMonth,
calendars: List<CalendarSource>,
instances: List<EventInstance>,
): MonthUiState {
if (calendars.isEmpty()) {
return MonthUiState.Failure(FailureReason.NoCalendarsConfigured)
}
val byDay = instances.groupBy { it.start.toLocalDateTime(zone).date }
.mapValues { (_, evs) ->
DayCellData(
count = evs.size,
swatches = evs.map { it.color }.distinct().take(3),
)
}
return MonthUiState.Success(
month = ym,
today = todayDate,
cells = byDay,
)
}
}
/**
* The on-screen grid spans 6 weeks anchored on [weekStart]. Includes the
* trailing days of the previous month and the leading days of the next month.
*/
internal fun monthGridRange(
ym: YearMonth,
weekStart: DayOfWeek,
zone: TimeZone,
): ClosedRange<Instant> {
val firstOfMonth = LocalDate(ym.year, ym.month, 1)
val gridStart = firstOfMonth.startOfGridWeek(weekStart)
val gridEnd = gridStart.plus(41, DateTimeUnit.DAY)
val start = gridStart.atStartOfDayIn(zone)
val end = gridEnd.atTime(23, 59, 59).toInstant(zone)
return start..end
}
internal fun LocalDate.startOfGridWeek(weekStart: DayOfWeek): LocalDate {
// DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering.
val offset = ((dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
return minus(offset, DateTimeUnit.DAY)
}

View File

@@ -0,0 +1,370 @@
package de.jeanlucmakiola.calendula.ui.permission
import android.Manifest
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
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.ColumnScope
import androidx.compose.foundation.layout.Row
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.navigationBarsPadding
import androidx.compose.foundation.layout.offset
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
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.foundation.Image
import de.jeanlucmakiola.calendula.R
private val CALENDAR_PERMISSIONS = arrayOf(
Manifest.permission.READ_CALENDAR,
Manifest.permission.WRITE_CALENDAR,
)
// MD3 8dp spacing scale, scoped to this screen.
private object Space {
val xs = 8.dp
val sm = 16.dp
val md = 24.dp
val lg = 32.dp
val xl = 48.dp
}
@Composable
fun PermissionScreen(
onGranted: () -> Unit,
modifier: Modifier = Modifier,
viewModel: PermissionViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
// READ and WRITE are requested together (one system dialog — same
// permission group), but only READ gates the app: declining write keeps
// Calendula usable read-only.
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestMultiplePermissions(),
) { results ->
if (results[Manifest.permission.READ_CALENDAR] == true) {
viewModel.onGranted()
} else {
viewModel.onDenied()
}
}
LaunchedEffect(state) {
if (state == PermissionUiState.Granted) onGranted()
}
when (state) {
is PermissionUiState.Rationale -> RationaleContent(
onRequest = { launcher.launch(CALENDAR_PERMISSIONS) },
modifier = modifier,
)
is PermissionUiState.Denied -> DeniedContent(
onRetry = {
viewModel.onRetry()
launcher.launch(CALENDAR_PERMISSIONS)
},
modifier = modifier,
)
is PermissionUiState.Granted -> {
// Transient — LaunchedEffect above fires and parent replaces us.
}
}
}
@Composable
private fun RationaleContent(
onRequest: () -> Unit,
modifier: Modifier = Modifier,
) {
PermissionScaffold(
modifier = modifier,
hero = { BrandHero(denied = false) },
actions = {
Button(
onClick = onRequest,
modifier = Modifier.fillMaxWidth().height(56.dp),
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
) {
Text(
text = stringResource(R.string.permission_request_button),
style = MaterialTheme.typography.titleMedium,
)
Spacer(Modifier.width(Space.xs))
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = null,
modifier = Modifier.size(20.dp),
)
}
PrivacyFootnote()
},
) {
Text(
text = stringResource(R.string.app_name).uppercase(),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
letterSpacing = 2.sp,
)
Spacer(Modifier.height(Space.xs))
Text(
text = stringResource(R.string.permission_rationale_title),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(12.dp))
Text(
text = stringResource(R.string.permission_rationale_body),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(Space.xl))
BenefitRow(
icon = Icons.Filled.Lock,
title = stringResource(R.string.permission_benefit_private_title),
body = stringResource(R.string.permission_benefit_private_body),
)
Spacer(Modifier.height(Space.sm))
BenefitRow(
icon = Icons.Filled.CalendarMonth,
title = stringResource(R.string.permission_benefit_sync_title),
body = stringResource(R.string.permission_benefit_sync_body),
)
Spacer(Modifier.height(Space.sm))
BenefitRow(
icon = Icons.Filled.VisibilityOff,
title = stringResource(R.string.permission_benefit_privacy_title),
body = stringResource(R.string.permission_benefit_privacy_body),
)
}
}
@Composable
private fun DeniedContent(
onRetry: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
PermissionScaffold(
modifier = modifier,
hero = { BrandHero(denied = true) },
actions = {
Button(
onClick = {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", context.packageName, null)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
},
modifier = Modifier.fillMaxWidth().height(56.dp),
) {
Text(
text = stringResource(R.string.permission_open_settings_button),
style = MaterialTheme.typography.titleMedium,
)
}
TextButton(
onClick = onRetry,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.permission_retry_button))
}
},
) {
Text(
text = stringResource(R.string.permission_denied_title),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(12.dp))
Text(
text = stringResource(R.string.permission_denied_body),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
/**
* Shared onboarding shell: a scrollable, centred hero + body with the call(s) to
* action pinned to the bottom (clear of the navigation bar). The content slot is
* centred horizontally; benefit rows fill the width so their own content
* left-aligns.
*/
@Composable
private fun PermissionScaffold(
hero: @Composable () -> Unit,
actions: @Composable ColumnScope.() -> Unit,
modifier: Modifier = Modifier,
body: @Composable ColumnScope.() -> Unit,
) {
Scaffold(
modifier = modifier,
containerColor = MaterialTheme.colorScheme.surface,
bottomBar = {
Column(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(horizontal = Space.md, vertical = Space.sm),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
content = actions,
)
},
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.verticalScroll(rememberScrollState())
.padding(horizontal = Space.md),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(Modifier.height(Space.xl))
hero()
Spacer(Modifier.height(Space.lg))
body()
Spacer(Modifier.height(Space.md))
}
}
}
/** The app's adaptive launcher mark, reconstructed as a large branded squircle. */
@Composable
private fun BrandHero(denied: Boolean) {
Box(contentAlignment = Alignment.Center) {
Box(
modifier = Modifier
.size(128.dp)
.clip(RoundedCornerShape(34.dp))
.background(colorResource(R.color.ic_launcher_background)),
) {
Image(
painter = painterResource(R.drawable.ic_launcher_foreground),
contentDescription = stringResource(R.string.app_name),
modifier = Modifier.fillMaxSize(),
)
}
if (denied) {
// A small lock badge sits over the corner to signal "blocked".
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.offset(x = 10.dp, y = 10.dp)
.size(44.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.errorContainer),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Filled.Lock,
contentDescription = null,
tint = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.size(24.dp),
)
}
}
}
}
/** One trust point: a tonal icon chip on the left, title + supporting text right. */
@Composable
private fun BenefitRow(icon: ImageVector, title: String, body: String) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.secondaryContainer),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.size(22.dp),
)
}
Spacer(Modifier.width(Space.sm))
Column(modifier = Modifier.weight(1f)) {
Text(text = title, style = MaterialTheme.typography.titleMedium)
Text(
text = body,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun PrivacyFootnote() {
Row(
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Filled.Lock,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(14.dp),
)
Spacer(Modifier.width(6.dp))
Text(
text = stringResource(R.string.permission_privacy_footnote),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}

View File

@@ -0,0 +1,7 @@
package de.jeanlucmakiola.calendula.ui.permission
sealed interface PermissionUiState {
data object Rationale : PermissionUiState
data object Denied : PermissionUiState
data object Granted : PermissionUiState
}

View File

@@ -0,0 +1,27 @@
package de.jeanlucmakiola.calendula.ui.permission
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
@HiltViewModel
class PermissionViewModel @Inject constructor() : ViewModel() {
private val _state = MutableStateFlow<PermissionUiState>(PermissionUiState.Rationale)
val state: StateFlow<PermissionUiState> = _state.asStateFlow()
fun onGranted() {
_state.value = PermissionUiState.Granted
}
fun onDenied() {
_state.value = PermissionUiState.Denied
}
fun onRetry() {
_state.value = PermissionUiState.Rationale
}
}

View File

@@ -0,0 +1,36 @@
package de.jeanlucmakiola.calendula.ui.settings
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
/** UI-facing language choice. AUTO follows the system languages. */
enum class LanguagePref { AUTO, GERMAN, ENGLISH }
/**
* Per-app language via AppCompatDelegate. On API 33+ this delegates to the
* platform per-app-languages API; below that the appcompat backport persists
* the choice itself (manifest `autoStoreLocales` service), so we don't mirror
* it in DataStore. Setting a locale recreates the activity, which re-reads the
* current value for the dropdown.
*/
object AppLanguage {
fun current(): LanguagePref {
val locales = AppCompatDelegate.getApplicationLocales()
if (locales.isEmpty) return LanguagePref.AUTO
return when (locales[0]?.language) {
"de" -> LanguagePref.GERMAN
"en" -> LanguagePref.ENGLISH
else -> LanguagePref.AUTO
}
}
fun apply(pref: LanguagePref) {
val locales = when (pref) {
LanguagePref.AUTO -> LocaleListCompat.getEmptyLocaleList()
LanguagePref.GERMAN -> LocaleListCompat.forLanguageTags("de")
LanguagePref.ENGLISH -> LocaleListCompat.forLanguageTags("en")
}
AppCompatDelegate.setApplicationLocales(locales)
}
}

View File

@@ -0,0 +1,372 @@
package de.jeanlucmakiola.calendula.ui.settings
import android.content.Intent
import androidx.activity.compose.BackHandler
import androidx.core.net.toUri
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.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.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.domain.EventFormField
/**
* Settings (M4) — appearance (theme, dynamic colour, week start), language,
* and an about section. A full-screen destination; [onBack] pops it.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
onBack: () -> Unit,
modifier: Modifier = Modifier,
viewModel: SettingsViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
// Intercept the system back button/gesture — without this it falls through
// to the activity and closes the app instead of returning to the calendar.
BackHandler { onBack() }
Scaffold(
modifier = modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.settings_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.settings_back),
)
}
},
)
},
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.verticalScroll(rememberScrollState()),
) {
SectionHeader(stringResource(R.string.settings_section_appearance))
SettingDropdownRow(
title = stringResource(R.string.settings_theme),
selected = state.themeMode,
options = ThemeMode.entries,
optionLabel = { themeLabel(it) },
onSelect = viewModel::setThemeMode,
)
DynamicColorRow(
checked = state.dynamicColor,
enabled = state.dynamicColorAvailable,
onCheckedChange = viewModel::setDynamicColor,
)
SettingDropdownRow(
title = stringResource(R.string.settings_week_start),
selected = state.weekStart,
options = WeekStartPref.entries,
optionLabel = { weekStartLabel(it) },
onSelect = viewModel::setWeekStart,
)
HorizontalDivider(Modifier.padding(vertical = 8.dp))
SectionHeader(stringResource(R.string.settings_section_event_form))
Text(
text = stringResource(R.string.settings_form_fields_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 24.dp),
)
EventFormField.entries.forEach { field ->
FormFieldRow(
title = stringResource(formFieldLabel(field)),
checked = field in state.defaultFormFields,
onCheckedChange = { viewModel.setFormFieldDefault(field, it) },
)
}
HorizontalDivider(Modifier.padding(vertical = 8.dp))
SectionHeader(stringResource(R.string.settings_section_language))
LanguageRow()
HorizontalDivider(Modifier.padding(vertical = 8.dp))
SectionHeader(stringResource(R.string.settings_section_about))
AboutSection()
Spacer(Modifier.height(24.dp))
}
}
}
@Composable
private fun LanguageRow() {
// Setting a locale recreates the activity; mirror the choice locally so the
// dropdown updates instantly even before the recreation lands.
var current by remember { mutableStateOf(AppLanguage.current()) }
SettingDropdownRow(
title = stringResource(R.string.settings_language),
selected = current,
options = LanguagePref.entries,
optionLabel = { languageLabel(it) },
onSelect = {
current = it
AppLanguage.apply(it)
},
)
}
@Composable
private fun SectionHeader(text: String) {
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp),
)
}
@Composable
private fun <T> SettingDropdownRow(
title: String,
selected: T,
options: List<T>,
optionLabel: @Composable (T) -> String,
onSelect: (T) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
Box {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { expanded = true }
.padding(horizontal = 24.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f),
)
Text(
text = optionLabel(selected),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Icon(
Icons.Default.ArrowDropDown,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
options.forEach { option ->
DropdownMenuItem(
text = { Text(optionLabel(option)) },
onClick = {
expanded = false
onSelect(option)
},
)
}
}
}
}
@Composable
private fun DynamicColorRow(
checked: Boolean,
enabled: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(
text = stringResource(R.string.settings_dynamic_color),
style = MaterialTheme.typography.bodyLarge,
color = if (enabled) MaterialTheme.colorScheme.onSurface
else MaterialTheme.colorScheme.onSurfaceVariant,
)
if (!enabled) {
Text(
text = stringResource(R.string.settings_dynamic_color_unavailable),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Switch(
checked = checked,
onCheckedChange = onCheckedChange,
enabled = enabled,
)
}
}
@Composable
private fun AboutSection() {
val context = LocalContext.current
val versionName = remember {
runCatching {
context.packageManager.getPackageInfo(context.packageName, 0).versionName
}.getOrNull() ?: ""
}
val sourceUrl = stringResource(R.string.about_source_url)
AboutRow(
title = stringResource(R.string.settings_version),
value = versionName,
)
AboutRow(
title = stringResource(R.string.settings_license),
value = stringResource(R.string.settings_license_value),
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f).padding(start = 8.dp)) {
Text(
text = stringResource(R.string.settings_source),
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = sourceUrl.removePrefix("https://"),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
TextButton(onClick = {
val intent = Intent(Intent.ACTION_VIEW, sourceUrl.toUri())
runCatching { context.startActivity(intent) }
}) {
Text(stringResource(R.string.settings_source_open))
}
}
}
@Composable
private fun AboutRow(title: String, value: String) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f),
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(8.dp))
}
}
@Composable
private fun FormFieldRow(
title: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f),
)
Switch(checked = checked, onCheckedChange = onCheckedChange)
}
}
private fun formFieldLabel(field: EventFormField): Int = when (field) {
EventFormField.Location -> R.string.event_detail_location
EventFormField.Description -> R.string.event_detail_description
EventFormField.Reminders -> R.string.event_detail_reminders
EventFormField.Availability -> R.string.event_edit_availability
EventFormField.Visibility -> R.string.event_edit_visibility
}
@Composable
private fun themeLabel(mode: ThemeMode): String = stringResource(
when (mode) {
ThemeMode.SYSTEM -> R.string.settings_theme_system
ThemeMode.LIGHT -> R.string.settings_theme_light
ThemeMode.DARK -> R.string.settings_theme_dark
},
)
@Composable
private fun weekStartLabel(pref: WeekStartPref): String = stringResource(
when (pref) {
WeekStartPref.AUTO -> R.string.settings_week_start_auto
WeekStartPref.MONDAY -> R.string.settings_week_start_monday
WeekStartPref.SUNDAY -> R.string.settings_week_start_sunday
},
)
@Composable
private fun languageLabel(pref: LanguagePref): String = stringResource(
when (pref) {
LanguagePref.AUTO -> R.string.settings_language_auto
LanguagePref.GERMAN -> R.string.settings_language_german
LanguagePref.ENGLISH -> R.string.settings_language_english
},
)

View File

@@ -0,0 +1,21 @@
package de.jeanlucmakiola.calendula.ui.settings
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.domain.EventFormField
/**
* Settings screen state (M4). Persisted preferences are instant to read, so
* there is no Loading/Failure here — only a populated Success snapshot.
* [dynamicColorAvailable] is false below Android 12, where the toggle is shown
* disabled.
*/
data class SettingsUiState(
val themeMode: ThemeMode = ThemeMode.SYSTEM,
val dynamicColor: Boolean = true,
val dynamicColorAvailable: Boolean = true,
val weekStart: WeekStartPref = WeekStartPref.AUTO,
/** Optional event-form fields shown by default (rest behind "more fields"). */
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
)

View File

@@ -0,0 +1,60 @@
package de.jeanlucmakiola.calendula.ui.settings
import android.os.Build
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.domain.EventFormField
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val prefs: SettingsPrefs,
) : ViewModel() {
private val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
val state: StateFlow<SettingsUiState> =
combine(
prefs.themeMode,
prefs.dynamicColor,
prefs.weekStart,
prefs.defaultFormFields,
) { theme, dynamic, weekStart, formFields ->
SettingsUiState(
themeMode = theme,
dynamicColor = dynamic && dynamicColorAvailable,
dynamicColorAvailable = dynamicColorAvailable,
weekStart = weekStart,
defaultFormFields = formFields,
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable),
)
fun setThemeMode(mode: ThemeMode) {
viewModelScope.launch { prefs.setThemeMode(mode) }
}
fun setDynamicColor(enabled: Boolean) {
viewModelScope.launch { prefs.setDynamicColor(enabled) }
}
fun setWeekStart(pref: WeekStartPref) {
viewModelScope.launch { prefs.setWeekStart(pref) }
}
fun setFormFieldDefault(field: EventFormField, enabled: Boolean) {
viewModelScope.launch { prefs.setFormFieldDefault(field, enabled) }
}
}

View File

@@ -2,7 +2,9 @@ package de.jeanlucmakiola.calendula.ui.theme
import android.os.Build import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialExpressiveTheme
import androidx.compose.material3.MotionScheme
import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -17,6 +19,7 @@ import androidx.compose.ui.platform.LocalContext
* The Settings screen (later) can override useDynamicColor and themePreference, * The Settings screen (later) can override useDynamicColor and themePreference,
* but the V1 foundation just follows the system. * but the V1 foundation just follows the system.
*/ */
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun CalendulaTheme( fun CalendulaTheme(
darkTheme: Boolean = isSystemInDarkTheme(), darkTheme: Boolean = isSystemInDarkTheme(),
@@ -32,9 +35,15 @@ fun CalendulaTheme(
else -> CalendulaLightFallback else -> CalendulaLightFallback
} }
MaterialTheme( // MaterialExpressiveTheme routes all component + custom motion through
// MaterialTheme.motionScheme (switches, chips, pickers, calendar slide,
// FAB, field reveal). The STANDARD scheme is a deliberate choice over
// expressive(): same spring choreography, but without the overshoot —
// the bouncy variant felt overdone in review (2026-06-11).
MaterialExpressiveTheme(
colorScheme = colorScheme, colorScheme = colorScheme,
typography = CalendulaTypography, typography = CalendulaTypography,
motionScheme = MotionScheme.standard(),
content = content, content = content,
) )
} }

View File

@@ -0,0 +1,731 @@
package de.jeanlucmakiola.calendula.ui.week
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.ScrollState
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.Menu
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalNavigationDrawer
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
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshotFlow
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.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.calendula.ui.common.pastelize
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale
import kotlin.math.roundToInt
private val HOUR_HEIGHT = 56.dp
private val GUTTER_WIDTH = 48.dp
private val MIN_EVENT_HEIGHT = 24.dp
private val ALL_DAY_ROW_HEIGHT = 24.dp
private val ALL_DAY_VERTICAL_PADDING = 6.dp
/** Total all-day strip height for a week (0 when there are no all-day events). */
private fun WeekUiState.Success.allDayStripHeight(): Dp {
if (allDaySpans.isEmpty()) return 0.dp
val lanes = allDaySpans.maxOf { it.lane } + 1
return ALL_DAY_ROW_HEIGHT * lanes + ALL_DAY_VERTICAL_PADDING * 2
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WeekScreen(
selectedView: CalendarView,
onSelectView: (CalendarView) -> Unit,
onEventClick: (EventInstance) -> Unit,
onOpenSettings: () -> Unit,
onCreateEvent: (LocalDate) -> Unit,
modifier: Modifier = Modifier,
viewModel: WeekViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val weekStart by viewModel.weekStartDate.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
// The static header + all-day strip share the app bar's scrolled colour so
// the whole top region elevates together once the timeline scrolls under it.
val topSectionColor by animateColorAsState(
targetValue = if (scrollBehavior.state.overlappedFraction > 0.01f) {
MaterialTheme.colorScheme.surfaceContainer
} else {
MaterialTheme.colorScheme.surface
},
label = "week-top-section-color",
)
val isOnCurrentWeek = when (val s = state) {
// True when today falls inside the displayed week — independent of which
// weekday the user picked as the first day.
is WeekUiState.Success ->
s.today >= s.weekStart && s.today <= s.weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY)
else -> true
}
// Slide direction for the week transition: +1 = next, -1 = prev, 0 = jump.
var slideDir by remember { mutableIntStateOf(0) }
val goNext = { slideDir = 1; viewModel.goToNext() }
val goPrev = { slideDir = -1; viewModel.goToPrev() }
// Slide toward today: viewing the future → today comes in from the left
// (back), viewing the past → from the right (forward).
val jumpToToday = {
slideDir = when (val s = state) {
is WeekUiState.Success -> if (s.today < s.weekStart) -1 else 1
else -> 0
}
viewModel.goToToday()
}
ModalNavigationDrawer(
drawerState = drawerState,
// Open only via the menu button — edge-swipe would fight the week swipe.
gesturesEnabled = drawerState.isOpen,
drawerContent = {
CalendarDrawer(
onToday = { jumpToToday(); scope.launch { drawerState.close() } },
onSettings = {
onOpenSettings()
scope.launch { drawerState.close() }
},
)
},
) {
Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
WeekTopBar(
weekStart = weekStart,
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next()) },
onOpenDrawer = { scope.launch { drawerState.open() } },
scrollBehavior = scrollBehavior,
)
},
floatingActionButton = {
CalendarFabColumn(
todayVisible = !isOnCurrentWeek,
todayText = stringResource(R.string.week_today_action),
onToday = jumpToToday,
onCreate = {
// Anchor on today when it's in view, else the week's first day.
val today = Clock.System.now()
.toLocalDateTime(TimeZone.currentSystemDefault()).date
onCreateEvent(if (isOnCurrentWeek) today else weekStart)
},
)
},
) { innerPadding ->
WeekContent(
state = state,
slideDir = slideDir,
topSectionColor = topSectionColor,
onSwipeNext = goNext,
onSwipePrev = goPrev,
onRetry = jumpToToday,
onEventClick = onEventClick,
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
)
}
}
}
@Composable
private fun WeekContent(
state: WeekUiState,
slideDir: Int,
topSectionColor: Color,
onSwipeNext: () -> Unit,
onSwipePrev: () -> Unit,
onRetry: () -> Unit,
onEventClick: (EventInstance) -> Unit,
modifier: Modifier = Modifier,
) {
val density = LocalDensity.current
val threshold = with(density) { 24.dp.toPx() }
var dragAccum by remember { mutableFloatStateOf(0f) }
val slideSpec = rememberCalendarSlideSpec()
// Hoisted above the per-week AnimatedContent so the vertical scroll position
// survives week-to-week swipes (e.g. 18:00 stays centred). We only centre on
// noon once, on first entry into the week view (i.e. when arriving from the
// month/day view), not on every swipe.
val scrollState = rememberScrollState()
LaunchedEffect(Unit) {
snapshotFlow { scrollState.maxValue }.first { it > 0 }
val maxV = scrollState.maxValue
val target = with(density) {
(HOUR_HEIGHT.toPx() * 12 - (HOUR_HEIGHT.toPx() * 24 - maxV) / 2f).roundToInt()
}.coerceIn(0, maxV)
scrollState.scrollTo(target)
}
// Single, hoisted all-day strip height — shared by the outgoing and incoming
// week during a swipe, so the strip slides along but never jumps in height;
// it just springs smoothly from the old to the new size.
val targetAllDayHeight = (state as? WeekUiState.Success)?.allDayStripHeight() ?: 0.dp
val allDayHeight by animateDpAsState(
targetValue = targetAllDayHeight,
label = "all-day-strip-height",
)
// Whole-page horizontal swipe. It sits one level above the timeline's
// vertical scroll: a horizontal drag only crosses *this* detector's slop,
// while a vertical drag is consumed by the inner scroll first — so the two
// gestures coexist without fighting.
val swipeModifier = Modifier.pointerInput(Unit) {
detectHorizontalDragGestures(
onDragStart = { dragAccum = 0f },
onDragEnd = {
when {
dragAccum < -threshold -> onSwipeNext()
dragAccum > threshold -> onSwipePrev()
}
dragAccum = 0f
},
onDragCancel = { dragAccum = 0f },
onHorizontalDrag = { _, drag -> dragAccum += drag },
)
}
AnimatedContent(
targetState = state,
modifier = modifier.then(swipeModifier),
contentKey = { s ->
when (s) {
is WeekUiState.Success -> "success-${s.weekStart}"
is WeekUiState.Failure -> "failure-${s.reason}"
WeekUiState.Loading -> "loading"
}
},
transitionSpec = { calendarSlideTransition(slideDir, slideSpec) },
label = "week-transition",
) { s ->
when (s) {
WeekUiState.Loading -> WeekLoading()
is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
is WeekUiState.Success -> WeekSuccess(
state = s,
topSectionColor = topSectionColor,
scrollState = scrollState,
allDayHeight = allDayHeight,
onEventClick = onEventClick,
)
}
}
}
@Composable
private fun WeekSuccess(
state: WeekUiState.Success,
topSectionColor: Color,
scrollState: ScrollState,
allDayHeight: Dp,
onEventClick: (EventInstance) -> Unit,
) {
Column(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(topSectionColor),
) {
WeekDayHeader(days = state.days, today = state.today)
AllDayStrip(state = state, height = allDayHeight, onEventClick = onEventClick)
}
// Breathing room between the (colour-shifting) top section and the
// scrolling timeline below.
Spacer(Modifier.height(8.dp))
Timeline(state = state, scrollState = scrollState, onEventClick = onEventClick)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun WeekTopBar(
weekStart: LocalDate,
selectedView: CalendarView,
onCycleView: () -> Unit,
onOpenDrawer: () -> Unit,
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
) {
TopAppBar(
title = {
Text(
text = formatWeekRange(weekStart),
style = MaterialTheme.typography.titleLarge,
)
},
navigationIcon = {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = stringResource(R.string.month_open_menu),
)
}
},
actions = {
ViewSwitcherPill(
current = selectedView,
onCycle = onCycleView,
modifier = Modifier.padding(end = 8.dp),
)
},
// Match the static top section exactly: plain surface, lifting to
// surfaceContainer once content scrolls under the bar.
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
),
scrollBehavior = scrollBehavior,
)
}
@Composable
private fun WeekDayHeader(days: List<LocalDate>, today: LocalDate) {
val locale = currentLocale()
val weekStart = days.first()
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)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp, bottom = 8.dp),
) {
// Mirror the day-column layout (empty weekday line + spacer) so the
// badge lines up vertically with the date numbers.
Column(
modifier = Modifier.width(GUTTER_WIDTH),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = " ", style = MaterialTheme.typography.labelSmall)
Spacer(Modifier.height(2.dp))
WeekNumberBadge(weekNumber = weekNumber)
}
days.forEach { date ->
val javaDow = java.time.DayOfWeek.of(date.dayOfWeek.ordinal + 1)
val isToday = date == today
Column(
modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = javaDow.getDisplayName(JavaTextStyle.SHORT, locale),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(2.dp))
// Always reserve the 28dp circle slot so the header height is
// identical whether or not the week contains today.
Box(
modifier = Modifier.size(28.dp),
contentAlignment = Alignment.Center,
) {
if (isToday) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.fillMaxSize(),
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = date.day.toString(),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
}
}
} else {
Text(
text = date.day.toString(),
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
}
}
/** Calendar-week badge shown in the header gutter, deliberately set apart with a
* filled box and bold number. */
@Composable
private fun WeekNumberBadge(weekNumber: Int, modifier: Modifier = Modifier) {
val label = stringResource(R.string.week_number_label)
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = modifier.semantics { contentDescription = "$label $weekNumber" },
) {
Text(
text = weekNumber.toString(),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
)
}
}
@Composable
private fun AllDayStrip(
state: WeekUiState.Success,
height: Dp,
onEventClick: (EventInstance) -> Unit,
) {
val dark = isSystemInDarkTheme()
Row(
modifier = Modifier
.fillMaxWidth()
// Height is hoisted + animated so it slides and resizes smoothly;
// padding sits inside it so the content area is lanes * row height.
.height(height)
.padding(vertical = ALL_DAY_VERTICAL_PADDING),
) {
// Keep the gutter-width offset so the bars line up with the day columns.
Spacer(Modifier.width(GUTTER_WIDTH))
// Span bars are positioned absolutely so a multi-day event is one
// connected bar across columns rather than a chip per day. clipToBounds
// keeps bars from spilling out while the height animates.
BoxWithConstraints(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.clipToBounds(),
) {
val colWidth = maxWidth / 7
state.allDaySpans.forEach { span ->
val spanCols = span.endCol - span.startCol + 1
AllDayBar(
event = span.event,
dark = dark,
onClick = { onEventClick(span.event) },
modifier = Modifier
.offset(
x = colWidth * span.startCol,
y = ALL_DAY_ROW_HEIGHT * span.lane,
)
.width(colWidth * spanCols)
.height(ALL_DAY_ROW_HEIGHT)
.padding(horizontal = 1.dp, vertical = 1.dp),
)
}
}
}
}
@Composable
private fun AllDayBar(
event: EventInstance,
dark: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
Box(
modifier = modifier
.background(pastelize(event.color, dark), RoundedCornerShape(4.dp))
.clickable(onClick = onClick)
.padding(horizontal = 6.dp, vertical = 2.dp)
.semantics { contentDescription = title },
contentAlignment = Alignment.CenterStart,
) {
Text(
text = title,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = Color.Black.copy(alpha = 0.8f),
)
}
}
@Composable
private fun Timeline(
state: WeekUiState.Success,
scrollState: ScrollState,
onEventClick: (EventInstance) -> Unit,
) {
val totalHeight = HOUR_HEIGHT * 24
val dark = isSystemInDarkTheme()
Box(modifier = Modifier.fillMaxSize()) {
// Gutter and day columns are two scroll viewports that SHARE one scroll
// state, so they stay perfectly aligned. The day-column viewport is a
// static, rounded-clipped window — the content scrolls inside it, so the
// soft corners are permanent at any scroll position (not just at the
// day's start/end).
Row(modifier = Modifier.fillMaxSize()) {
// Hour gutter (scrolls in sync with the day columns)
Column(
modifier = Modifier
.width(GUTTER_WIDTH)
.fillMaxHeight()
.verticalScroll(scrollState),
) {
(0 until 24).forEach { h ->
Box(
modifier = Modifier
.fillMaxWidth()
.height(HOUR_HEIGHT),
) {
if (h > 0) {
Text(
text = "%02d".format(h),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.align(Alignment.TopCenter)
.offset(y = (-6).dp),
)
}
}
}
}
// Day columns: rounded, clipped scroll viewport (permanent corners).
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.clip(RoundedCornerShape(16.dp))
.verticalScroll(scrollState),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(totalHeight),
horizontalArrangement = Arrangement.spacedBy(2.dp),
) {
state.days.forEach { day ->
DayColumnCard(
blocks = state.timedByDay[day].orEmpty(),
dark = dark,
onEventClick = onEventClick,
modifier = Modifier
.weight(1f)
.fillMaxHeight(),
)
}
}
}
}
}
}
@Composable
private fun DayColumnCard(
blocks: List<TimedBlock>,
dark: Boolean,
onEventClick: (EventInstance) -> Unit,
modifier: Modifier = Modifier,
) {
Card(
// Plain rectangular columns — the soft corners come from the outer
// rounded scroll viewport, so inner rounding would look odd at the edges.
shape = RectangleShape,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
modifier = modifier,
) {
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val colWidth = maxWidth
blocks.forEach { block ->
val laneWidth = colWidth / block.laneCount
val top = HOUR_HEIGHT * (block.startMin / 60f)
val rawHeight = HOUR_HEIGHT * ((block.endMin - block.startMin) / 60f)
val height = if (rawHeight < MIN_EVENT_HEIGHT) MIN_EVENT_HEIGHT else rawHeight
EventBlock(
block = block,
dark = dark,
onClick = { onEventClick(block.event) },
modifier = Modifier
.offset(x = laneWidth * block.lane, y = top)
.width(laneWidth)
.height(height)
.padding(horizontal = 1.dp),
)
}
}
}
}
@Composable
private fun EventBlock(
block: TimedBlock,
dark: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
val timeLabel = "${minToHm(block.startMin)}${minToHm(block.endMin)}"
val showTime = block.endMin - block.startMin >= 45
Box(
modifier = modifier
.background(pastelize(block.event.color, dark), RoundedCornerShape(4.dp))
.clickable(onClick = onClick)
.padding(horizontal = 4.dp, vertical = 2.dp)
.semantics { contentDescription = "$title, $timeLabel" },
) {
Column {
Text(
text = title,
style = MaterialTheme.typography.labelMedium,
maxLines = if (showTime) 1 else 2,
overflow = TextOverflow.Ellipsis,
color = Color.Black.copy(alpha = 0.85f),
)
if (showTime) {
// Narrow columns can't fit "13:0014:00" on one line, so let it
// wrap to a second line (after the dash) instead of clipping the
// end time.
Text(
text = timeLabel,
style = MaterialTheme.typography.labelSmall,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
color = Color.Black.copy(alpha = 0.6f),
)
}
}
}
}
@Composable
private fun WeekLoading() {
val totalHeight = HOUR_HEIGHT * 24
val scrollState = rememberScrollState()
Column(modifier = Modifier.fillMaxSize()) {
// Header skeleton
Row(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
Spacer(Modifier.width(GUTTER_WIDTH))
repeat(7) {
Box(
modifier = Modifier
.weight(1f)
.padding(horizontal = 2.dp)
.height(36.dp)
.background(
MaterialTheme.colorScheme.surfaceContainer,
RoundedCornerShape(8.dp),
),
)
}
}
Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
Spacer(Modifier.width(GUTTER_WIDTH))
repeat(7) {
Box(
modifier = Modifier
.weight(1f)
.height(totalHeight)
.padding(horizontal = 2.dp)
.background(MaterialTheme.colorScheme.surfaceContainer),
)
}
}
}
}
private fun minToHm(min: Int): String =
if (min >= MINUTES_PER_DAY) "24:00" else "%02d:%02d".format(min / 60, min % 60)
private fun formatWeekRange(weekStart: LocalDate): String {
val locale = Locale.getDefault()
val end = weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY)
val monthName = { d: LocalDate ->
java.time.Month.of(d.month.ordinal + 1).getDisplayName(JavaTextStyle.SHORT, locale)
}
return if (weekStart.month == end.month && weekStart.year == end.year) {
"${weekStart.day}.${end.day}. ${monthName(weekStart)} ${weekStart.year}"
} else if (weekStart.year == end.year) {
"${weekStart.day}. ${monthName(weekStart)} ${end.day}. ${monthName(end)} ${end.year}"
} else {
"${weekStart.day}. ${monthName(weekStart)} ${weekStart.year} " +
"${end.day}. ${monthName(end)} ${end.year}"
}
}

View File

@@ -0,0 +1,57 @@
package de.jeanlucmakiola.calendula.ui.week
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason
import kotlinx.datetime.LocalDate
/**
* One timed event clipped to a single day and assigned a horizontal lane so
* overlapping events render side-by-side (spec S2: "Overlap-Events nebeneinander
* aufgelöst").
*
* @param startMin minutes from this day's midnight, clamped to [0, 1440]
* @param endMin minutes from this day's midnight, clamped to [startMin, 1440];
* equal to [startMin] for instant events (render enforces a
* minimum tap-target height)
* @param lane 0-based column within [laneCount]
* @param laneCount number of columns the event's overlap-cluster needs
*/
data class TimedBlock(
val event: EventInstance,
val startMin: Int,
val endMin: Int,
val lane: Int,
val laneCount: Int,
)
/**
* An all-day (or multi-day) event laid out as a single horizontal bar spanning
* [startCol]..[endCol] of the visible week, stacked on row [lane] so overlapping
* spans don't collide. A multi-day event is one connected bar — not one chip per
* day.
*
* @param startCol first visible covered column, 0..6 (clamped to the week)
* @param endCol last visible covered column, 0..6, inclusive
* @param lane 0-based stacking row
*/
data class AllDaySpan(
val event: EventInstance,
val startCol: Int,
val endCol: Int,
val lane: Int,
)
sealed interface WeekUiState {
data object Loading : WeekUiState
data class Failure(val reason: FailureReason) : WeekUiState
data class Success(
val weekStart: LocalDate,
val today: LocalDate,
/** The seven days of the week, [weekStart] first. */
val days: List<LocalDate>,
/** All-day/multi-day events as connected horizontal spans. */
val allDaySpans: List<AllDaySpan>,
/** Timed events, clipped to each day with lanes resolved. */
val timedByDay: Map<LocalDate, List<TimedBlock>>,
) : WeekUiState
}

View File

@@ -0,0 +1,252 @@
package de.jeanlucmakiola.calendula.ui.week
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.atTime
import kotlinx.datetime.minus
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import java.util.Locale
import kotlin.time.Clock
import kotlin.time.Instant
import javax.inject.Inject
const val MINUTES_PER_DAY: Int = 24 * 60
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class WeekViewModel @Inject constructor(
private val repository: CalendarRepository,
settingsPrefs: SettingsPrefs,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
private val zone = TimeZone.currentSystemDefault()
private val locale: Locale = Locale.getDefault()
private val todayDate: LocalDate
get() = Clock.System.now().toLocalDateTime(zone).date
/** First day of the week, from the Settings preference (AUTO → locale). */
private val weekStart: StateFlow<DayOfWeek> = settingsPrefs.weekStart
.map { it.resolveFirstDay(locale) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = DayOfWeek.MONDAY,
)
// Anchor is a representative day inside the visible week; the actual week
// start is derived against [weekStart], so changing the first-day preference
// re-frames the same week instead of jumping.
private val _anchor = MutableStateFlow(todayDate)
val weekStartDate: StateFlow<LocalDate> =
combine(_anchor, weekStart) { anchor, ws -> anchor.startOfWeek(ws) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = todayDate.startOfWeek(DayOfWeek.MONDAY),
)
val state: StateFlow<WeekUiState> =
combine(_anchor, weekStart) { anchor, ws -> anchor.startOfWeek(ws) }
.distinctUntilChanged()
.flatMapLatest { start ->
val range = weekRange(start, zone)
combine(
repository.calendars(),
repository.instances(range),
) { calendars, instances ->
buildState(start, calendars, instances)
}
}
.catch { emit(WeekUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = WeekUiState.Loading,
)
fun goToPrev() {
_anchor.value = _anchor.value.minus(7, DateTimeUnit.DAY)
}
fun goToNext() {
_anchor.value = _anchor.value.plus(7, DateTimeUnit.DAY)
}
fun goToToday() {
_anchor.value = todayDate
}
private fun buildState(
start: LocalDate,
calendars: List<CalendarSource>,
instances: List<EventInstance>,
): WeekUiState {
if (calendars.isEmpty()) {
return WeekUiState.Failure(FailureReason.NoCalendarsConfigured)
}
val days = (0 until 7).map { start.plus(it, DateTimeUnit.DAY) }
val allDay = instances.filter { it.isAllDay }
val timed = instances.filterNot { it.isAllDay }
return WeekUiState.Success(
weekStart = start,
today = todayDate,
days = days,
allDaySpans = layoutAllDay(allDay, days, zone),
timedByDay = days.associateWith { day -> layoutDay(timed, day, zone) },
)
}
}
/**
* Lay out all-day events as connected horizontal spans across the visible week.
* Each event becomes one [AllDaySpan] from its first to its last covered column;
* overlapping spans are stacked on separate lanes (greedy first-fit by start).
*/
internal fun layoutAllDay(
events: List<EventInstance>,
days: List<LocalDate>,
zone: TimeZone,
): List<AllDaySpan> {
data class Raw(val event: EventInstance, val startCol: Int, val endCol: Int)
val raw = events
.mapNotNull { ev ->
val covered = days.indices.filter { ev.coversDay(days[it], zone) }
if (covered.isEmpty()) null else Raw(ev, covered.first(), covered.last())
}
.sortedWith(compareBy({ it.startCol }, { it.endCol }))
val laneEnd = ArrayList<Int>() // last occupied column per lane
return raw.map { r ->
var lane = laneEnd.indexOfFirst { it < r.startCol }
if (lane == -1) {
laneEnd.add(r.endCol)
lane = laneEnd.size - 1
} else {
laneEnd[lane] = r.endCol
}
AllDaySpan(r.event, r.startCol, r.endCol, lane)
}
}
/** Beginning of the week (at [weekStart]) that contains this date. */
internal fun LocalDate.startOfWeek(weekStart: DayOfWeek): LocalDate {
// DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering.
val offset = ((dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
return minus(offset, DateTimeUnit.DAY)
}
/** Half-open instant range covering the seven days starting at [start]. */
internal fun weekRange(start: LocalDate, zone: TimeZone): ClosedRange<Instant> {
val from = start.atStartOfDayIn(zone)
val to = start.plus(6, DateTimeUnit.DAY).atTime(23, 59, 59).toInstant(zone)
return from..to
}
/** True if this event overlaps the calendar [day] in [zone] (any portion). */
internal fun EventInstance.coversDay(day: LocalDate, zone: TimeZone): Boolean {
val dayStart = day.atStartOfDayIn(zone)
val dayEnd = day.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone)
return start < dayEnd && end > dayStart
}
/**
* Clip [events] to a single [day] and assign lanes so overlapping events render
* side-by-side. Lane count is computed per overlap-cluster (a maximal run of
* chained-overlapping events), matching the common phone week-view behaviour.
*
* All-day events are ignored here — they live in the all-day strip.
*/
internal fun layoutDay(
events: List<EventInstance>,
day: LocalDate,
zone: TimeZone,
): List<TimedBlock> {
val dayStart = day.atStartOfDayIn(zone)
val dayEnd = day.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone)
data class Raw(val event: EventInstance, val startMin: Int, val endMin: Int)
val raw = events.asSequence()
.filterNot { it.isAllDay }
.mapNotNull { ev ->
if (ev.start == ev.end) {
// Instant event: keep only if the point falls inside this day.
if (ev.start < dayStart || ev.start >= dayEnd) return@mapNotNull null
val m = (ev.start - dayStart).inWholeMinutes.toInt().coerceIn(0, MINUTES_PER_DAY)
Raw(ev, m, m)
} else {
val s = maxOf(ev.start, dayStart)
val e = minOf(ev.end, dayEnd)
if (e <= s) return@mapNotNull null
val startMin = (s - dayStart).inWholeMinutes.toInt().coerceIn(0, MINUTES_PER_DAY)
val endMin = (e - dayStart).inWholeMinutes.toInt().coerceIn(startMin, MINUTES_PER_DAY)
Raw(ev, startMin, endMin)
}
}
.sortedWith(compareBy({ it.startMin }, { it.endMin }))
.toList()
val result = ArrayList<TimedBlock>(raw.size)
var i = 0
while (i < raw.size) {
// Grow a cluster of chained-overlapping events.
var clusterEnd = raw[i].endMin
var j = i + 1
while (j < raw.size && raw[j].startMin < clusterEnd) {
clusterEnd = maxOf(clusterEnd, raw[j].endMin)
j++
}
val cluster = raw.subList(i, j)
// Greedy first-fit column assignment (= max overlap depth in the cluster).
val laneEnd = ArrayList<Int>()
val lanes = IntArray(cluster.size)
cluster.forEachIndexed { k, r ->
var placed = laneEnd.indexOfFirst { it <= r.startMin }
if (placed == -1) {
laneEnd.add(r.endMin)
placed = laneEnd.size - 1
} else {
laneEnd[placed] = r.endMin
}
lanes[k] = placed
}
val laneCount = laneEnd.size
cluster.forEachIndexed { k, r ->
result.add(TimedBlock(r.event, r.startMin, r.endMin, lanes[k], laneCount))
}
i = j
}
return result
}

View File

@@ -1,16 +1,95 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!--
Calendula launcher icon foreground.
Converted from design/icon/calendula_mark.svg (232x232 viewport).
Composition: rounded line-art calendar with a stylized "1" inside
(referencing kalendae, the Latin word for the first day of the month
that is the etymological root of both "calendar" and "calendula"),
plus a small Calendula bloom as a badge in the bottom-right corner.
Strokes render in off-white (#FAF6F0) over the slate background
drawable (drawable/ic_launcher_background.xml = @color/ic_launcher_background).
The same vector is reused as the <monochrome> slot in the adaptive icon
so Android 13+ themed-icon launchers can recolor it from wallpaper.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp" android:width="108dp"
android:height="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportWidth="232"
android:viewportHeight="108"> android:viewportHeight="232">
<!-- <!--
Stylized "1" centered in the 108x108 viewport. Android adaptive icon spec: 108dp canvas.
Reference: kalendae (the first day of the month) - etymological root
of both "Calendar" and "Calendula". Centering Logic:
Color is off-white for high contrast on the slate background. - The calendar body is a 142x142 square centered at (114, 108).
- The viewport center is (116, 116).
- We use pivot (114, 108) and translate by (2, 8) to align the
calendar's geometric center perfectly with the canvas center,
ignoring the visual weight of the bloom badge.
Scale:
- Scaled by 0.50 to provide significant padding, preventing a
"zoomed in" look on home screens and splash screens.
--> -->
<group
android:pivotX="114"
android:pivotY="108"
android:scaleX="0.50"
android:scaleY="0.50"
android:translateX="2"
android:translateY="8">
<!-- Calendar body (rounded square with horizontal header divider) -->
<path
android:strokeColor="#FFFAF6F0"
android:strokeWidth="12"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:strokeMiterLimit="12"
android:pathData="M43,69H185M185,115V63C185,48.64 173.359,37 159,37H69C54.64,37 43,48.64 43,63V153C43,167.359 54.64,179 69,179H124" />
<!-- Numeral "1" inside the calendar body -->
<path
android:strokeColor="#FFFAF6F0"
android:strokeWidth="12"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M103,110L113.999,99V142.428" />
<!-- Calendula bloom: 8 petals around a filled center -->
<path
android:strokeColor="#FFFAF6F0"
android:strokeWidth="8"
android:pathData="M163.072,136.714C163.072,142.886 168.214,153.429 170.786,157.929C173.357,153.429 178.5,142.886 178.5,136.714C178.5,130.543 173.357,129 170.786,129C168.214,129 163.072,130.543 163.072,136.714Z" />
<path
android:strokeColor="#FFFAF6F0"
android:strokeWidth="8"
android:pathData="M178.5,186.857C178.5,180.686 173.357,170.143 170.786,165.643C168.214,170.143 163.072,180.686 163.072,186.857C163.072,193.029 168.214,194.572 170.786,194.572C173.357,194.572 178.5,193.029 178.5,186.857Z" />
<path
android:strokeColor="#FFFAF6F0"
android:strokeWidth="8"
android:pathData="M195.857,154.072C189.686,154.072 179.143,159.214 174.643,161.786C179.143,164.357 189.686,169.5 195.857,169.5C202.029,169.5 203.572,164.357 203.572,161.786C203.572,159.214 202.029,154.072 195.857,154.072Z" />
<path
android:strokeColor="#FFFAF6F0"
android:strokeWidth="8"
android:pathData="M145.714,170.008C151.886,170.008 162.429,164.865 166.929,162.294C162.429,159.722 151.886,154.58 145.714,154.58C139.543,154.58 138,159.722 138,162.294C138,164.865 139.543,170.008 145.714,170.008Z" />
<path
android:strokeColor="#FFFAF6F0"
android:strokeWidth="8"
android:pathData="M194.768,174.858C190.404,170.494 179.312,166.676 174.312,165.312C175.676,170.312 179.494,181.404 183.858,185.768C188.222,190.132 192.949,187.586 194.768,185.768C196.586,183.949 199.132,179.222 194.768,174.858Z" />
<path
android:strokeColor="#FFFAF6F0"
android:strokeWidth="8"
android:pathData="M146.804,149.222C151.168,153.586 162.259,157.404 167.26,158.768C165.896,153.767 162.077,142.676 157.714,138.312C153.35,133.948 148.622,136.494 146.804,138.312C144.986,140.13 142.44,144.858 146.804,149.222Z" />
<path
android:strokeColor="#FFFAF6F0"
android:strokeWidth="8"
android:pathData="M183.858,138.312C179.494,142.676 175.676,153.767 174.312,158.768C179.312,157.404 190.404,153.586 194.768,149.222C199.132,144.858 196.586,140.13 194.768,138.312C192.949,136.494 188.222,133.948 183.858,138.312Z" />
<path
android:strokeColor="#FFFAF6F0"
android:strokeWidth="8"
android:pathData="M157.714,185.768C162.077,181.404 165.896,170.312 167.26,165.312C162.259,166.676 151.168,170.494 146.804,174.858C142.44,179.222 144.986,183.949 146.804,185.768C148.622,187.586 153.35,190.132 157.714,185.768Z" />
<!-- Calendula center disc -->
<path <path
android:fillColor="#FFFAF6F0" android:fillColor="#FFFAF6F0"
android:pathData="M51.5,38 L51.5,38 C49.5,40 46.5,41.5 43,42.5 L43,49 C46.2,48.2 49,47 51.5,45.5 L51.5,72 L43.5,72 L43.5,76 L65.5,76 L65.5,72 L57.5,72 L57.5,38 Z" /> android:pathData="M170.786,169C174.77,169 178,165.77 178,161.786C178,157.802 174.77,154.572 170.786,154.572C166.802,154.572 163.572,157.802 163.572,161.786C163.572,165.77 166.802,169 170.786,169Z" />
</group>
</vector> </vector>

View File

@@ -10,4 +10,170 @@
<string name="state_failure_no_calendars">Keine Kalender eingerichtet.</string> <string name="state_failure_no_calendars">Keine Kalender eingerichtet.</string>
<string name="state_failure_no_calendars_action">System-Kalender-Einstellungen öffnen</string> <string name="state_failure_no_calendars_action">System-Kalender-Einstellungen öffnen</string>
<string name="state_failure_provider">Kalender konnte nicht gelesen werden.</string> <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_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>
<string name="permission_open_settings_button">System-Einstellungen öffnen</string>
<string name="permission_retry_button">Erneut versuchen</string>
<string name="permission_benefit_private_title">Bleibt auf deinem Gerät</string>
<string name="permission_benefit_private_body">Deine Kalender werden lokal gelesen und verlassen das Telefon nie.</string>
<string name="permission_benefit_sync_title">Alle Kalender vereint</string>
<string name="permission_benefit_sync_body">Google, CalDAV, lokal — alles, was synchronisiert ist, erscheint automatisch.</string>
<string name="permission_benefit_privacy_title">Kein Tracking, niemals</string>
<string name="permission_benefit_privacy_body">Keine Telemetrie, keine Analyse, keine Werbung.</string>
<string name="permission_privacy_footnote">Bleibt auf deinem Gerät · keine Internet-Berechtigung</string>
<!-- Monatsansicht (S1) -->
<string name="month_prev">Vorheriger Monat</string>
<string name="month_next">Nächster Monat</string>
<string name="month_today_action">Heute</string>
<string name="month_more_actions">Weitere Aktionen</string>
<string name="month_open_menu">Menü öffnen</string>
<string name="month_action_settings">Einstellungen</string>
<string name="month_a11y_today_prefix">Heute</string>
<!-- Wochenansicht (S2) -->
<string name="week_today_action">Diese Woche</string>
<string name="week_number_label">KW</string>
<!-- Tagesansicht (S3) -->
<string name="day_today_action">Heute</string>
<!-- Event-Detail-Screen (S4) -->
<string name="event_detail_back">Zurück</string>
<string name="event_detail_delete">Löschen</string>
<string name="event_delete_title">Termin löschen?</string>
<string name="event_delete_body">Der Termin wird aus deinem Kalender und von allen synchronisierten Geräten entfernt.</string>
<string name="event_delete_recurring_title">Wiederkehrenden Termin löschen</string>
<string name="event_delete_option_occurrence">Nur dieser Termin</string>
<string name="event_delete_option_series">Alle Termine der Serie</string>
<string name="event_delete_failed">Termin konnte nicht gelöscht werden</string>
<string name="event_delete_write_denied">Calendula braucht Schreibzugriff, um Termine zu löschen</string>
<string name="dialog_cancel">Abbrechen</string>
<string name="dialog_ok">OK</string>
<!-- Termin-Formular (v1.2 Erstellen) -->
<string name="event_edit_new_title">Neuer Termin</string>
<string name="event_edit_close">Schließen</string>
<string name="event_edit_save">Speichern</string>
<string name="event_edit_title_hint">Titel hinzufügen</string>
<string name="event_edit_starts">Beginn</string>
<string name="event_edit_ends">Ende</string>
<string name="event_edit_error_end_before_start">Endet vor dem Beginn</string>
<string name="event_edit_error_no_calendar">Kein beschreibbarer Kalender verfügbar</string>
<string name="event_edit_save_failed">Termin konnte nicht gespeichert werden</string>
<string name="event_edit_write_denied">Calendula braucht Schreibzugriff, um Termine zu erstellen</string>
<string name="event_edit_more_fields">Weitere Felder</string>
<string name="event_edit_add">Hinzufügen</string>
<string name="event_edit_add_reminder">Erinnerung hinzufügen</string>
<string name="event_edit_remove_reminder">Erinnerung entfernen</string>
<string name="event_edit_reminder_custom">Benutzerdefiniert</string>
<string name="reminder_unit_minutes">Minuten</string>
<string name="reminder_unit_hours">Stunden</string>
<string name="reminder_unit_days">Tage</string>
<string name="reminder_unit_weeks">Wochen</string>
<string name="event_edit_availability">Verfügbarkeit</string>
<string name="event_edit_visibility">Sichtbarkeit</string>
<string name="event_availability_busy">Beschäftigt</string>
<string name="event_access_default">Standard</string>
<string name="event_access_public">Öffentlich</string>
<string name="event_detail_all_day">Ganztägig</string>
<string name="event_detail_calendar">Kalender</string>
<string name="event_detail_calendar_unknown">Unbekannter Kalender</string>
<string name="event_detail_location">Ort</string>
<string name="event_detail_description">Beschreibung</string>
<string name="event_detail_attendees">Teilnehmer</string>
<string name="event_detail_recurrence">Wiederholung</string>
<string name="event_detail_recurring">Wiederkehrender Termin</string>
<string name="recurrence_daily">Jeden Tag</string>
<string name="recurrence_weekly">Jede Woche</string>
<string name="recurrence_monthly">Jeden Monat</string>
<string name="recurrence_yearly">Jedes Jahr</string>
<string name="recurrence_every_n_days">Alle %1$d Tage</string>
<string name="recurrence_every_n_weeks">Alle %1$d Wochen</string>
<string name="recurrence_every_n_months">Alle %1$d Monate</string>
<string name="recurrence_every_n_years">Alle %1$d Jahre</string>
<string name="recurrence_on_days">%1$s am %2$s</string>
<string name="recurrence_with_until">%1$s bis %2$s</string>
<string name="recurrence_with_count">%1$s, %2$d Mal</string>
<string name="event_detail_not_found">Dieser Termin existiert nicht mehr.</string>
<string name="event_attendee_accepted">Zugesagt</string>
<string name="event_attendee_declined">Abgesagt</string>
<string name="event_attendee_tentative">Vorläufig</string>
<string name="event_attendee_needs_action">Keine Antwort</string>
<string name="event_attendee_unknown"></string>
<!-- Event-Detail — vollständiges Auslesen (v0.6) -->
<string name="event_detail_reminders">Erinnerungen</string>
<string name="event_detail_timezone">Zeitzone</string>
<string name="event_status_tentative">Vorläufig</string>
<string name="event_status_cancelled">Abgesagt</string>
<string name="event_availability_free">Frei</string>
<string name="event_access_private">Privat</string>
<string name="event_access_confidential">Vertraulich</string>
<string name="event_attendee_organizer">Organisator</string>
<string name="event_attendee_optional">Optional</string>
<string name="event_attendee_resource">Ressource</string>
<string name="event_detail_self_response">Deine Antwort: %1$s</string>
<string name="reminder_at_time">Zur Startzeit</string>
<string name="reminder_default">Standarderinnerung</string>
<plurals name="reminder_minutes">
<item quantity="one">%d Minute vorher</item>
<item quantity="other">%d Minuten vorher</item>
</plurals>
<plurals name="reminder_hours">
<item quantity="one">%d Stunde vorher</item>
<item quantity="other">%d Stunden vorher</item>
</plurals>
<plurals name="reminder_days">
<item quantity="one">%d Tag vorher</item>
<item quantity="other">%d Tage vorher</item>
</plurals>
<plurals name="reminder_weeks">
<item quantity="one">%d Woche vorher</item>
<item quantity="other">%d Wochen vorher</item>
</plurals>
<!-- Geteilte Event-Strings -->
<string name="event_untitled">(Ohne Titel)</string>
<!-- View-Switcher (M1) -->
<string name="view_month">Monat</string>
<string name="view_week">Woche</string>
<string name="view_day">Tag</string>
<!-- Kalender-Filter (M3) -->
<string name="filter_title">Kalender</string>
<!-- Einstellungen (M4) -->
<string name="settings_title">Einstellungen</string>
<string name="settings_back">Zurück</string>
<string name="settings_section_appearance">Darstellung</string>
<string name="settings_theme">Design</string>
<string name="settings_theme_system">System</string>
<string name="settings_theme_light">Hell</string>
<string name="settings_theme_dark">Dunkel</string>
<string name="settings_dynamic_color">Dynamische Farben</string>
<string name="settings_dynamic_color_unavailable">Erfordert Android 12 oder neuer</string>
<string name="settings_week_start">Wochenstart</string>
<string name="settings_week_start_auto">Automatisch</string>
<string name="settings_week_start_monday">Montag</string>
<string name="settings_week_start_sunday">Sonntag</string>
<string name="settings_section_event_form">Termin-Formular</string>
<string name="settings_form_fields_hint">Standardmäßig angezeigte Felder — alles Weitere liegt hinter \"Weitere Felder\"</string>
<string name="settings_section_language">Sprache</string>
<string name="settings_language">App-Sprache</string>
<string name="settings_language_auto">Systemstandard</string>
<string name="settings_language_german">Deutsch</string>
<string name="settings_language_english">English</string>
<string name="settings_section_about">Über</string>
<string name="settings_version">Version</string>
<string name="settings_license">Lizenz</string>
<string name="settings_license_value">MIT</string>
<string name="settings_source">Quellcode</string>
<string name="settings_source_open">Öffnen</string>
</resources> </resources>

View File

@@ -11,4 +11,171 @@
<string name="state_failure_no_calendars">No calendars configured.</string> <string name="state_failure_no_calendars">No calendars configured.</string>
<string name="state_failure_no_calendars_action">Open system calendar settings</string> <string name="state_failure_no_calendars_action">Open system calendar settings</string>
<string name="state_failure_provider">Could not read the calendar.</string> <string name="state_failure_provider">Could not read the calendar.</string>
<!-- Permission flow (F1) -->
<string name="permission_rationale_title">See all your events, beautifully</string>
<string name="permission_rationale_body">Calendula needs access to your calendar to show and manage your events. That\'s the only thing it ever asks for.</string>
<string name="permission_request_button">Grant calendar access</string>
<string name="permission_denied_title">Calendar access denied</string>
<string name="permission_denied_body">Calendula cannot show events without calendar access. You can grant it again in the system settings.</string>
<string name="permission_open_settings_button">Open system settings</string>
<string name="permission_retry_button">Try again</string>
<string name="permission_benefit_private_title">Stays on your device</string>
<string name="permission_benefit_private_body">Your calendars are read locally and never leave the phone.</string>
<string name="permission_benefit_sync_title">All your calendars, together</string>
<string name="permission_benefit_sync_body">Google, CalDAV, local — anything synced to the device just appears.</string>
<string name="permission_benefit_privacy_title">No tracking, ever</string>
<string name="permission_benefit_privacy_body">Zero telemetry, zero analytics, no ads.</string>
<string name="permission_privacy_footnote">Stays on your device · no internet permission</string>
<!-- Month view (S1) -->
<string name="month_prev">Previous month</string>
<string name="month_next">Next month</string>
<string name="month_today_action">Today</string>
<string name="month_more_actions">More actions</string>
<string name="month_open_menu">Open menu</string>
<string name="month_action_settings">Settings</string>
<string name="month_a11y_today_prefix">Today</string>
<!-- Week view (S2) -->
<string name="week_today_action">This week</string>
<string name="week_number_label">Wk</string>
<!-- Day view (S3) -->
<string name="day_today_action">Today</string>
<!-- Event detail screen (S4) -->
<string name="event_detail_back">Back</string>
<string name="event_detail_delete">Delete</string>
<string name="event_delete_title">Delete event?</string>
<string name="event_delete_body">The event is removed from your calendar and from every device it syncs to.</string>
<string name="event_delete_recurring_title">Delete recurring event</string>
<string name="event_delete_option_occurrence">Only this event</string>
<string name="event_delete_option_series">All events in the series</string>
<string name="event_delete_failed">Couldn\'t delete the event</string>
<string name="event_delete_write_denied">Calendula needs write access to delete events</string>
<string name="dialog_cancel">Cancel</string>
<string name="dialog_ok">OK</string>
<!-- Event form (v1.2 create) -->
<string name="event_edit_new_title">New event</string>
<string name="event_edit_close">Close</string>
<string name="event_edit_save">Save</string>
<string name="event_edit_title_hint">Add title</string>
<string name="event_edit_starts">Starts</string>
<string name="event_edit_ends">Ends</string>
<string name="event_edit_error_end_before_start">Ends before it starts</string>
<string name="event_edit_error_no_calendar">No writable calendar available</string>
<string name="event_edit_save_failed">Couldn\'t save the event</string>
<string name="event_edit_write_denied">Calendula needs write access to create events</string>
<string name="event_edit_more_fields">More fields</string>
<string name="event_edit_add">Add</string>
<string name="event_edit_add_reminder">Add reminder</string>
<string name="event_edit_remove_reminder">Remove reminder</string>
<string name="event_edit_reminder_custom">Custom</string>
<string name="reminder_unit_minutes">minutes</string>
<string name="reminder_unit_hours">hours</string>
<string name="reminder_unit_days">days</string>
<string name="reminder_unit_weeks">weeks</string>
<string name="event_edit_availability">Availability</string>
<string name="event_edit_visibility">Visibility</string>
<string name="event_availability_busy">Busy</string>
<string name="event_access_default">Default</string>
<string name="event_access_public">Public</string>
<string name="event_detail_all_day">All day</string>
<string name="event_detail_calendar">Calendar</string>
<string name="event_detail_calendar_unknown">Unknown calendar</string>
<string name="event_detail_location">Location</string>
<string name="event_detail_description">Description</string>
<string name="event_detail_attendees">Attendees</string>
<string name="event_detail_recurrence">Recurrence</string>
<string name="event_detail_recurring">Repeating event</string>
<string name="recurrence_daily">Every day</string>
<string name="recurrence_weekly">Every week</string>
<string name="recurrence_monthly">Every month</string>
<string name="recurrence_yearly">Every year</string>
<string name="recurrence_every_n_days">Every %1$d days</string>
<string name="recurrence_every_n_weeks">Every %1$d weeks</string>
<string name="recurrence_every_n_months">Every %1$d months</string>
<string name="recurrence_every_n_years">Every %1$d years</string>
<string name="recurrence_on_days">%1$s on %2$s</string>
<string name="recurrence_with_until">%1$s until %2$s</string>
<string name="recurrence_with_count">%1$s, %2$d times</string>
<string name="event_detail_not_found">This event no longer exists.</string>
<string name="event_attendee_accepted">Accepted</string>
<string name="event_attendee_declined">Declined</string>
<string name="event_attendee_tentative">Tentative</string>
<string name="event_attendee_needs_action">No response</string>
<string name="event_attendee_unknown"></string>
<!-- Event detail — full read (v0.6) -->
<string name="event_detail_reminders">Reminders</string>
<string name="event_detail_timezone">Time zone</string>
<string name="event_status_tentative">Tentative</string>
<string name="event_status_cancelled">Cancelled</string>
<string name="event_availability_free">Free</string>
<string name="event_access_private">Private</string>
<string name="event_access_confidential">Confidential</string>
<string name="event_attendee_organizer">Organizer</string>
<string name="event_attendee_optional">Optional</string>
<string name="event_attendee_resource">Resource</string>
<string name="event_detail_self_response">Your response: %1$s</string>
<string name="reminder_at_time">At time of event</string>
<string name="reminder_default">Default reminder</string>
<plurals name="reminder_minutes">
<item quantity="one">%d minute before</item>
<item quantity="other">%d minutes before</item>
</plurals>
<plurals name="reminder_hours">
<item quantity="one">%d hour before</item>
<item quantity="other">%d hours before</item>
</plurals>
<plurals name="reminder_days">
<item quantity="one">%d day before</item>
<item quantity="other">%d days before</item>
</plurals>
<plurals name="reminder_weeks">
<item quantity="one">%d week before</item>
<item quantity="other">%d weeks before</item>
</plurals>
<!-- Shared event strings -->
<string name="event_untitled">(No title)</string>
<!-- View switcher (M1) -->
<string name="view_month">Month</string>
<string name="view_week">Week</string>
<string name="view_day">Day</string>
<!-- Calendar filter (M3) -->
<string name="filter_title">Calendars</string>
<!-- Settings (M4) -->
<string name="settings_title">Settings</string>
<string name="settings_back">Back</string>
<string name="settings_section_appearance">Appearance</string>
<string name="settings_theme">Theme</string>
<string name="settings_theme_system">System</string>
<string name="settings_theme_light">Light</string>
<string name="settings_theme_dark">Dark</string>
<string name="settings_dynamic_color">Dynamic colour</string>
<string name="settings_dynamic_color_unavailable">Requires Android 12 or newer</string>
<string name="settings_week_start">Week starts on</string>
<string name="settings_week_start_auto">Automatic</string>
<string name="settings_week_start_monday">Monday</string>
<string name="settings_week_start_sunday">Sunday</string>
<string name="settings_section_event_form">New event form</string>
<string name="settings_form_fields_hint">Fields shown by default — everything else sits behind \"More fields\"</string>
<string name="settings_section_language">Language</string>
<string name="settings_language">App language</string>
<string name="settings_language_auto">System default</string>
<string name="settings_language_german">Deutsch</string>
<string name="settings_language_english">English</string>
<string name="settings_section_about">About</string>
<string name="settings_version">Version</string>
<string name="settings_license">License</string>
<string name="settings_license_value">MIT</string>
<string name="settings_source">Source code</string>
<string name="settings_source_open">Open</string>
<string name="about_source_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula</string>
</resources> </resources>

View File

@@ -0,0 +1,93 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.provider.CalendarContract
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class CalendarMapperTest {
private fun reader(
id: Long = 1L,
displayName: String? = "Cal",
accountName: String? = "x@y",
accountType: String? = "LOCAL",
color: Int = 0,
visible: Int = 1,
accessLevel: Int = CalendarContract.Calendars.CAL_ACCESS_OWNER,
): MapColumnReader = MapColumnReader(
CalendarProjection.IDX_ID to id,
CalendarProjection.IDX_DISPLAY_NAME to displayName,
CalendarProjection.IDX_ACCOUNT_NAME to accountName,
CalendarProjection.IDX_ACCOUNT_TYPE to accountType,
CalendarProjection.IDX_COLOR to color,
CalendarProjection.IDX_VISIBLE to visible,
CalendarProjection.IDX_ACCESS_LEVEL to accessLevel,
)
@Test
fun `happy path maps all six columns`() {
val src = reader(
id = 42L,
displayName = "Work",
accountName = "x@y",
accountType = "com.google",
color = 0xFF112233.toInt(),
visible = 1,
).toCalendarSource()
assertThat(src).isEqualTo(
de.jeanlucmakiola.calendula.domain.CalendarSource(
id = 42L,
displayName = "Work",
accountName = "x@y",
accountType = "com.google",
color = 0xFF112233.toInt(),
isVisibleInSystem = true,
canModifyContents = true,
)
)
}
@Test
fun `null displayName falls back to placeholder`() {
val src = reader(displayName = null).toCalendarSource()
assertThat(src.displayName).isEqualTo(Fallbacks.UNNAMED_CALENDAR)
}
@Test
fun `visible flag 0 maps to false`() {
assertThat(reader(visible = 0).toCalendarSource().isVisibleInSystem).isFalse()
}
@Test
fun `visible flag 1 maps to true`() {
assertThat(reader(visible = 1).toCalendarSource().isVisibleInSystem).isTrue()
}
@Test
fun `null accountName and accountType coerce to empty string`() {
val src = reader(accountName = null, accountType = null).toCalendarSource()
assertThat(src.accountName).isEqualTo("")
assertThat(src.accountType).isEqualTo("")
}
@Test
fun `contributor access and above can modify contents`() {
val contributor = reader(accessLevel = CalendarContract.Calendars.CAL_ACCESS_CONTRIBUTOR)
val owner = reader(accessLevel = CalendarContract.Calendars.CAL_ACCESS_OWNER)
assertThat(contributor.toCalendarSource().canModifyContents).isTrue()
assertThat(owner.toCalendarSource().canModifyContents).isTrue()
}
@Test
fun `read access cannot modify contents`() {
val src = reader(accessLevel = CalendarContract.Calendars.CAL_ACCESS_READ)
assertThat(src.toCalendarSource().canModifyContents).isFalse()
}
@Test
fun `missing access level defaults to read-only`() {
// WebCal subscriptions on some providers report level 0 (CAL_ACCESS_NONE).
val src = reader(accessLevel = CalendarContract.Calendars.CAL_ACCESS_NONE)
assertThat(src.toCalendarSource().canModifyContents).isFalse()
}
}

View File

@@ -0,0 +1,252 @@
package de.jeanlucmakiola.calendula.data.calendar
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.coroutines.Dispatchers
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import kotlin.time.Instant
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
@OptIn(ExperimentalCoroutinesApi::class)
class CalendarRepositoryImplTest {
private fun newPrefs(tempDir: Path): CalendarPrefs =
CalendarPrefs(newDataStore(tempDir))
private fun newDataStore(tempDir: Path): DataStore<Preferences> =
PreferenceDataStoreFactory.create(
produceFile = { tempDir.resolve("repo_test_prefs.preferences_pb").toFile() },
)
private fun makeCal(id: Long, name: String = "Cal $id"): CalendarSource =
CalendarSource(id, name, "x@y", "LOCAL", 0xFF112233.toInt(), true)
private fun makeEvent(
id: Long,
title: String = "E $id",
calendarId: Long = 1L,
): EventInstance = EventInstance(
instanceId = id, eventId = id, calendarId = calendarId,
title = title,
start = Instant.fromEpochMilliseconds(1_000_000_000L),
end = Instant.fromEpochMilliseconds(1_000_003_600L),
isAllDay = false, color = 0xFF000000.toInt(), location = null,
)
@Test
fun `calendars emits initial query result on subscribe`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(makeCal(1L), makeCal(2L))
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), UnconfinedTestDispatcher(testScheduler))
repo.calendars().test {
val first = awaitItem()
assertThat(first.map { it.id }).containsExactly(1L, 2L).inOrder()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `calendars re-emits after change listener tick`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(makeCal(1L))
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), UnconfinedTestDispatcher(testScheduler))
repo.calendars().test {
assertThat(awaitItem().map { it.id }).containsExactly(1L)
fake.calendarsResult = listOf(makeCal(1L), makeCal(2L))
fake.tick()
assertThat(awaitItem().map { it.id }).containsExactly(1L, 2L).inOrder()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `instances forwards epoch-millis bounds to data source`(@TempDir tempDir: Path) = runTest {
var observedBegin: Long? = null
var observedEnd: Long? = null
val fake = FakeCalendarDataSource().apply {
instancesResult = {b, e ->
observedBegin = b
observedEnd = e
listOf(makeEvent(10L))
}
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), UnconfinedTestDispatcher(testScheduler))
val range = Instant.fromEpochMilliseconds(1_000L)..Instant.fromEpochMilliseconds(2_000L)
repo.instances(range).test {
awaitItem()
cancelAndIgnoreRemainingEvents()
}
assertThat(observedBegin).isEqualTo(1_000L)
assertThat(observedEnd).isEqualTo(2_000L)
}
@Test
fun `instances passes-through whatever the data source returns`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource().apply {
instancesResult = {_, _ -> listOf(makeEvent(10L, "Good")) }
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), UnconfinedTestDispatcher(testScheduler))
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
repo.instances(range).test {
val first = awaitItem()
assertThat(first.map { it.title }).containsExactly("Good")
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `instances drops events whose calendar the user hid`(@TempDir tempDir: Path) = runTest {
val prefs = newPrefs(tempDir)
prefs.setHiddenCalendarIds(setOf(2L))
val fake = FakeCalendarDataSource().apply {
instancesResult = { _, _ ->
listOf(
makeEvent(10L, "Visible", calendarId = 1L),
makeEvent(11L, "Hidden", calendarId = 2L),
)
}
}
val repo = CalendarRepositoryImpl(fake, prefs, UnconfinedTestDispatcher(testScheduler))
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
repo.instances(range).test {
assertThat(awaitItem().map { it.title }).containsExactly("Visible")
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `instances re-emits when the hidden set changes`(@TempDir tempDir: Path) = runTest {
val prefs = newPrefs(tempDir)
val fake = FakeCalendarDataSource().apply {
instancesResult = { _, _ ->
listOf(
makeEvent(10L, "A", calendarId = 1L),
makeEvent(11L, "B", calendarId = 2L),
)
}
}
val repo = CalendarRepositoryImpl(fake, prefs, UnconfinedTestDispatcher(testScheduler))
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
repo.instances(range).test {
assertThat(awaitItem().map { it.title }).containsExactly("A", "B").inOrder()
prefs.setHiddenCalendarIds(setOf(2L))
assertThat(awaitItem().map { it.title }).containsExactly("A")
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `createEvent delegates and returns the new id`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource().apply { nextInsertId = 77L }
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
val form = EventForm(
calendarId = 1L,
title = "Stand-up",
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 15)),
)
val id = repo.createEvent(form)
assertThat(id).isEqualTo(77L)
assertThat(fake.insertedForms).containsExactly(form)
}
@Test
fun `createEvent propagates write failures`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource().apply {
writeError = WriteFailedException("insert event")
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
val form = EventForm(
calendarId = 1L,
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
)
try {
repo.createEvent(form)
error("Expected WriteFailedException")
} catch (expected: WriteFailedException) {
assertThat(expected.message).contains("insert")
}
}
@Test
fun `deleteEvent delegates to the data source`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource()
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
repo.deleteEvent(eventId = 42L)
assertThat(fake.deletedEventIds).containsExactly(42L)
assertThat(fake.deletedOccurrences).isEmpty()
}
@Test
fun `deleteOccurrence forwards event id and begin time`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource()
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
repo.deleteOccurrence(eventId = 42L, beginMillis = 1_000L)
assertThat(fake.deletedOccurrences).containsExactly(42L to 1_000L)
assertThat(fake.deletedEventIds).isEmpty()
}
@Test
fun `deleteEvent propagates write failures`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource().apply {
writeError = WriteFailedException("delete event id=42")
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
try {
repo.deleteEvent(eventId = 42L)
error("Expected WriteFailedException")
} catch (expected: WriteFailedException) {
assertThat(expected.message).contains("42")
}
}
@Test
fun `eventDetail throws NoSuchEventException when data source returns null`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource().apply {
eventDetailResult = { null }
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
try {
repo.eventDetail(eventId = 999L)
error("Expected NoSuchEventException")
} catch (expected: NoSuchEventException) {
assertThat(expected.message).contains("999")
}
}
}

View File

@@ -0,0 +1,219 @@
package de.jeanlucmakiola.calendula.data.calendar
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.AttendeeRelationship
import de.jeanlucmakiola.calendula.domain.AttendeeStatus
import de.jeanlucmakiola.calendula.domain.AttendeeType
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.calendula.domain.ReminderMethod
import org.junit.jupiter.api.Test
class EventDetailMapperTest {
private fun detailReader(
eventId: Long = 1L,
title: String? = "Meet",
description: String? = "Body",
organizer: String? = "x@y",
rrule: String? = null,
eventColor: Any? = null,
calendarColor: Int = 0xFFAABBCC.toInt(),
dtstart: Long = 1_000_000_000L,
dtend: Long = 1_000_003_600L,
allDay: Int = 0,
location: String? = "Berlin",
calendarId: Long = 7L,
status: Any? = null,
availability: Any? = null,
accessLevel: Any? = null,
timezone: String? = null,
selfStatus: Any? = null,
): MapColumnReader = MapColumnReader(
EventDetailProjection.IDX_EVENT_ID to eventId,
EventDetailProjection.IDX_TITLE to title,
EventDetailProjection.IDX_DESCRIPTION to description,
EventDetailProjection.IDX_ORGANIZER to organizer,
EventDetailProjection.IDX_RRULE to rrule,
EventDetailProjection.IDX_EVENT_COLOR to eventColor,
EventDetailProjection.IDX_CALENDAR_COLOR to calendarColor,
EventDetailProjection.IDX_DTSTART to dtstart,
EventDetailProjection.IDX_DTEND to dtend,
EventDetailProjection.IDX_ALL_DAY to allDay,
EventDetailProjection.IDX_LOCATION to location,
EventDetailProjection.IDX_CALENDAR_ID to calendarId,
EventDetailProjection.IDX_STATUS to status,
EventDetailProjection.IDX_AVAILABILITY to availability,
EventDetailProjection.IDX_ACCESS_LEVEL to accessLevel,
EventDetailProjection.IDX_EVENT_TIMEZONE to timezone,
EventDetailProjection.IDX_SELF_ATTENDEE_STATUS to selfStatus,
)
private fun attendeeReader(
name: String?,
email: String?,
status: Int,
relationship: Int = 0,
type: Int = 0,
): MapColumnReader =
MapColumnReader(
AttendeeProjection.IDX_NAME to name,
AttendeeProjection.IDX_EMAIL to email,
AttendeeProjection.IDX_STATUS to status,
AttendeeProjection.IDX_RELATIONSHIP to relationship,
AttendeeProjection.IDX_TYPE to type,
)
private fun reminderReader(minutes: Int, method: Int): MapColumnReader =
MapColumnReader(
ReminderProjection.IDX_MINUTES to minutes,
ReminderProjection.IDX_METHOD to method,
)
private fun MapColumnReader.toDetail(
attendees: List<de.jeanlucmakiola.calendula.domain.Attendee> = emptyList(),
reminders: List<Reminder> = emptyList(),
) = toEventDetailCore(attendees, reminders)
@Test
fun `happy path detail maps all fields and embeds matching EventInstance`() {
val detail = detailReader().toDetail()
assertThat(detail).isNotNull()
assertThat(detail!!.description).isEqualTo("Body")
assertThat(detail.organizer).isEqualTo("x@y")
assertThat(detail.instance.title).isEqualTo("Meet")
assertThat(detail.instance.location).isEqualTo("Berlin")
assertThat(detail.attendees).isEmpty()
}
@Test
fun `event color falls back to calendar color when null`() {
val detail = detailReader(eventColor = null, calendarColor = 0xFF112233.toInt())
.toDetail()
assertThat(detail!!.instance.color).isEqualTo(0xFF112233.toInt())
}
@Test
fun `dtend before dtstart drops detail`() {
val detail = detailReader(dtstart = 2000L, dtend = 1000L).toDetail()
assertThat(detail).isNull()
}
@Test
fun `rrule passes through when present`() {
val detail = detailReader(rrule = "FREQ=WEEKLY;BYDAY=MO").toDetail()
assertThat(detail!!.rrule).isEqualTo("FREQ=WEEKLY;BYDAY=MO")
}
// Raw CalendarContract.Attendees status integer constants from the Android
// source (kept inline so the test doesn't depend on the mockable.jar's
// possibly-stubbed constants):
// ACCEPTED=1, DECLINED=2, INVITED=3, TENTATIVE=4, NONE=0
@Test
fun `attendee status maps known integer codes`() {
assertThat(attendeeReader("A", "a@x", 1).toAttendee().status)
.isEqualTo(AttendeeStatus.Accepted)
assertThat(attendeeReader("B", "b@x", 2).toAttendee().status)
.isEqualTo(AttendeeStatus.Declined)
assertThat(attendeeReader("C", "c@x", 4).toAttendee().status)
.isEqualTo(AttendeeStatus.Tentative)
assertThat(attendeeReader("D", "d@x", 3).toAttendee().status)
.isEqualTo(AttendeeStatus.NeedsAction)
assertThat(attendeeReader("E", "e@x", 0).toAttendee().status)
.isEqualTo(AttendeeStatus.Unknown)
assertThat(attendeeReader("F", "f@x", 99).toAttendee().status)
.isEqualTo(AttendeeStatus.Unknown)
}
@Test
fun `attendee with null name maps to empty string`() {
val a = attendeeReader(null, "alice@x", 1).toAttendee()
assertThat(a.name).isEqualTo("")
}
@Test
fun `attendee email passes through nullably`() {
assertThat(attendeeReader("A", null, 1).toAttendee().email).isNull()
assertThat(attendeeReader("A", "x@y", 1).toAttendee().email).isEqualTo("x@y")
}
// RELATIONSHIP: NONE=0, ATTENDEE=1, ORGANIZER=2, PERFORMER=3, SPEAKER=4
@Test
fun `attendee relationship maps known integer codes`() {
assertThat(attendeeReader("A", "a@x", 1, relationship = 2).toAttendee().relationship)
.isEqualTo(AttendeeRelationship.Organizer)
assertThat(attendeeReader("A", "a@x", 1, relationship = 1).toAttendee().relationship)
.isEqualTo(AttendeeRelationship.Attendee)
assertThat(attendeeReader("A", "a@x", 1, relationship = 0).toAttendee().relationship)
.isEqualTo(AttendeeRelationship.None)
}
// TYPE: NONE=0, REQUIRED=1, OPTIONAL=2, RESOURCE=3
@Test
fun `attendee type maps known integer codes`() {
assertThat(attendeeReader("A", "a@x", 1, type = 1).toAttendee().type)
.isEqualTo(AttendeeType.Required)
assertThat(attendeeReader("A", "a@x", 1, type = 2).toAttendee().type)
.isEqualTo(AttendeeType.Optional)
assertThat(attendeeReader("A", "a@x", 1, type = 3).toAttendee().type)
.isEqualTo(AttendeeType.Resource)
}
// STATUS: TENTATIVE=0, CONFIRMED=1, CANCELED=2
@Test
fun `event status null maps to confirmed, codes map through`() {
assertThat(detailReader(status = null).toDetail()!!.status).isEqualTo(EventStatus.Confirmed)
assertThat(detailReader(status = 0).toDetail()!!.status).isEqualTo(EventStatus.Tentative)
assertThat(detailReader(status = 1).toDetail()!!.status).isEqualTo(EventStatus.Confirmed)
assertThat(detailReader(status = 2).toDetail()!!.status).isEqualTo(EventStatus.Cancelled)
}
// AVAILABILITY: BUSY=0, FREE=1, TENTATIVE=2
@Test
fun `availability null or busy maps to Busy, free maps to Free`() {
assertThat(detailReader(availability = null).toDetail()!!.availability)
.isEqualTo(Availability.Busy)
assertThat(detailReader(availability = 0).toDetail()!!.availability)
.isEqualTo(Availability.Busy)
assertThat(detailReader(availability = 1).toDetail()!!.availability)
.isEqualTo(Availability.Free)
}
// ACCESS_LEVEL: DEFAULT=0, CONFIDENTIAL=1, PRIVATE=2, PUBLIC=3
@Test
fun `access level maps known integer codes, null is Default`() {
assertThat(detailReader(accessLevel = null).toDetail()!!.accessLevel)
.isEqualTo(AccessLevel.Default)
assertThat(detailReader(accessLevel = 1).toDetail()!!.accessLevel)
.isEqualTo(AccessLevel.Confidential)
assertThat(detailReader(accessLevel = 2).toDetail()!!.accessLevel)
.isEqualTo(AccessLevel.Private)
}
@Test
fun `event timezone and self status pass through`() {
val detail = detailReader(timezone = "Europe/Berlin", selfStatus = 1).toDetail()
assertThat(detail!!.eventTimezone).isEqualTo("Europe/Berlin")
assertThat(detail.selfStatus).isEqualTo(AttendeeStatus.Accepted)
}
@Test
fun `reminders pass through to the detail`() {
val reminders = listOf(Reminder(10, ReminderMethod.Alert))
val detail = detailReader().toDetail(reminders = reminders)
assertThat(detail!!.reminders).isEqualTo(reminders)
}
// METHOD: DEFAULT=0, ALERT=1, EMAIL=2, SMS=3, ALARM=4
@Test
fun `reminder maps minutes and method codes`() {
assertThat(reminderReader(10, 1).toReminder())
.isEqualTo(Reminder(10, ReminderMethod.Alert))
assertThat(reminderReader(60, 2).toReminder())
.isEqualTo(Reminder(60, ReminderMethod.Email))
assertThat(reminderReader(0, 0).toReminder())
.isEqualTo(Reminder(0, ReminderMethod.Default))
}
}

View File

@@ -0,0 +1,74 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.provider.CalendarContract
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventForm
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import org.junit.jupiter.api.Test
import java.time.ZoneId
class EventWriteMapperTest {
private val berlin: ZoneId = ZoneId.of("Europe/Berlin")
private fun form(
isAllDay: Boolean = false,
start: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)),
end: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 30)),
): EventForm = EventForm(calendarId = 1L, isAllDay = isAllDay, start = start, end = end)
@Test
fun `timed event resolves wall clock in the given zone`() {
val times = form().toWriteTimes(berlin)
// 2026-06-11 is CEST (UTC+2): 10:00 local == 08:00Z.
assertThat(times.dtStartMillis).isEqualTo(1_781_164_800_000L)
assertThat(times.dtEndMillis - times.dtStartMillis).isEqualTo(90L * 60L * 1000L)
assertThat(times.timezone).isEqualTo("Europe/Berlin")
}
@Test
fun `all-day event lives at UTC midnights with exclusive end`() {
val times = form(isAllDay = true).toWriteTimes(berlin)
assertThat(times.timezone).isEqualTo("UTC")
assertThat(times.dtStartMillis % 86_400_000L).isEqualTo(0L)
// Single-day all-day event: DTEND is the NEXT UTC midnight.
assertThat(times.dtEndMillis - times.dtStartMillis).isEqualTo(86_400_000L)
}
@Test
fun `availability maps to the provider constants`() {
assertThat(Availability.Busy.toProviderValue())
.isEqualTo(CalendarContract.Events.AVAILABILITY_BUSY)
assertThat(Availability.Free.toProviderValue())
.isEqualTo(CalendarContract.Events.AVAILABILITY_FREE)
assertThat(Availability.Tentative.toProviderValue())
.isEqualTo(CalendarContract.Events.AVAILABILITY_TENTATIVE)
}
@Test
fun `access level maps to the provider constants`() {
assertThat(AccessLevel.Default.toProviderValue())
.isEqualTo(CalendarContract.Events.ACCESS_DEFAULT)
assertThat(AccessLevel.Private.toProviderValue())
.isEqualTo(CalendarContract.Events.ACCESS_PRIVATE)
assertThat(AccessLevel.Confidential.toProviderValue())
.isEqualTo(CalendarContract.Events.ACCESS_CONFIDENTIAL)
assertThat(AccessLevel.Public.toProviderValue())
.isEqualTo(CalendarContract.Events.ACCESS_PUBLIC)
}
@Test
fun `multi-day all-day event spans every covered day`() {
val times = form(
isAllDay = true,
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 6, 13), LocalTime(0, 0)),
).toWriteTimes(berlin)
// 11th, 12th, 13th inclusive = 3 days.
assertThat(times.dtEndMillis - times.dtStartMillis).isEqualTo(3L * 86_400_000L)
}
}

View File

@@ -0,0 +1,58 @@
package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
/**
* Test-only fake. Tunable via the three `var` properties; `tick()` simulates
* a provider change so the repository re-queries.
*/
internal class FakeCalendarDataSource : CalendarDataSource {
var calendarsResult: List<CalendarSource> = emptyList()
var instancesResult: (Long, Long) -> List<EventInstance> = { _, _ -> emptyList() }
var eventDetailResult: (Long) -> EventDetail? = { null }
/** Set to make the next write call throw. */
var writeError: Exception? = null
/** Id returned by the next [insertEvent]. */
var nextInsertId: Long = 100L
val insertedForms = mutableListOf<EventForm>()
val deletedEventIds = mutableListOf<Long>()
val deletedOccurrences = mutableListOf<Pair<Long, Long>>()
private val listeners = mutableListOf<() -> Unit>()
override fun calendars(): List<CalendarSource> = calendarsResult
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> =
instancesResult(beginMillis, endMillis)
override fun eventDetail(eventId: Long): EventDetail? = eventDetailResult(eventId)
override fun insertEvent(form: EventForm): Long {
writeError?.let { throw it }
insertedForms += form
return nextInsertId
}
override fun deleteEvent(eventId: Long) {
writeError?.let { throw it }
deletedEventIds += eventId
}
override fun deleteOccurrence(eventId: Long, beginMillis: Long) {
writeError?.let { throw it }
deletedOccurrences += eventId to beginMillis
}
override fun registerChangeListener(listener: () -> Unit) {
listeners += listener
}
override fun unregisterChangeListener(listener: () -> Unit) {
listeners -= listener
}
fun tick() {
listeners.forEach { it() }
}
}

View File

@@ -0,0 +1,93 @@
package de.jeanlucmakiola.calendula.data.calendar
import com.google.common.truth.Truth.assertThat
import kotlin.time.Instant
import org.junit.jupiter.api.Test
class InstanceMapperTest {
private fun reader(
instanceId: Long = 10L,
eventId: Long = 1L,
calendarId: Long = 1L,
title: String? = "Meet",
begin: Long = 1_000_000_000L,
end: Long = 1_000_003_600L,
allDay: Int = 0,
eventColor: Any? = null,
calendarColor: Int = 0xFFAABBCC.toInt(),
location: String? = null,
): MapColumnReader = MapColumnReader(
InstanceProjection.IDX_INSTANCE_ID to instanceId,
InstanceProjection.IDX_EVENT_ID to eventId,
InstanceProjection.IDX_CALENDAR_ID to calendarId,
InstanceProjection.IDX_TITLE to title,
InstanceProjection.IDX_BEGIN to begin,
InstanceProjection.IDX_END to end,
InstanceProjection.IDX_ALL_DAY to allDay,
InstanceProjection.IDX_EVENT_COLOR to eventColor,
InstanceProjection.IDX_CALENDAR_COLOR to calendarColor,
InstanceProjection.IDX_LOCATION to location,
)
@Test
fun `happy path - non-allday event`() {
val inst = reader().toEventInstance()
assertThat(inst).isNotNull()
assertThat(inst!!.title).isEqualTo("Meet")
assertThat(inst.isAllDay).isFalse()
assertThat(inst.start).isEqualTo(Instant.fromEpochMilliseconds(1_000_000_000L))
assertThat(inst.end).isEqualTo(Instant.fromEpochMilliseconds(1_000_003_600L))
}
@Test
fun `event color falls back to calendar color when null`() {
val inst = reader(eventColor = null, calendarColor = 0xFF112233.toInt()).toEventInstance()
assertThat(inst!!.color).isEqualTo(0xFF112233.toInt())
}
@Test
fun `event color wins over calendar color when present`() {
val inst = reader(
eventColor = 0xFFDEADBE.toInt(),
calendarColor = 0xFF112233.toInt(),
).toEventInstance()
assertThat(inst!!.color).isEqualTo(0xFFDEADBE.toInt())
}
@Test
fun `null title falls back to placeholder`() {
val inst = reader(title = null).toEventInstance()
assertThat(inst!!.title).isEqualTo(Fallbacks.UNTITLED_EVENT)
}
@Test
fun `empty title falls back to placeholder`() {
val inst = reader(title = "").toEventInstance()
assertThat(inst!!.title).isEqualTo(Fallbacks.UNTITLED_EVENT)
}
@Test
fun `dtend before dtstart drops the row`() {
val inst = reader(begin = 2000L, end = 1000L).toEventInstance()
assertThat(inst).isNull()
}
@Test
fun `dtstart before unix epoch drops the row`() {
val inst = reader(begin = -1L, end = 1000L).toEventInstance()
assertThat(inst).isNull()
}
@Test
fun `all-day flag 1 maps to true`() {
val inst = reader(allDay = 1).toEventInstance()
assertThat(inst!!.isAllDay).isTrue()
}
@Test
fun `location passes through when present`() {
val inst = reader(location = "Berlin").toEventInstance()
assertThat(inst!!.location).isEqualTo("Berlin")
}
}

View File

@@ -0,0 +1,29 @@
package de.jeanlucmakiola.calendula.data.calendar
/**
* Test-only ColumnReader. Backed by a Map<Int, Any?>; any missing index is
* treated as null. Numeric getters coerce via toLong/toInt; non-numeric values
* yield zero (matching Android Cursor behavior for type-mismatched reads).
*/
internal class MapColumnReader(values: Map<Int, Any?>) : ColumnReader {
private val data: Map<Int, Any?> = values
constructor(vararg pairs: Pair<Int, Any?>) : this(pairs.toMap())
override fun getLong(index: Int): Long = when (val v = data[index]) {
is Number -> v.toLong()
is String -> v.toLongOrNull() ?: 0L
else -> 0L
}
override fun getString(index: Int): String? = data[index]?.toString()
override fun getInt(index: Int): Int = when (val v = data[index]) {
is Number -> v.toInt()
is String -> v.toIntOrNull() ?: 0
else -> 0
}
override fun isNull(index: Int): Boolean = data[index] == null
}

View File

@@ -0,0 +1,26 @@
package de.jeanlucmakiola.calendula.data.calendar
import com.google.common.truth.Truth.assertThat
import kotlin.time.Instant
import org.junit.jupiter.api.Test
class TimeBridgeTest {
@Test
fun `epoch millis round-trips through Instant`() {
val original = 1_717_840_800_000L // 2024-06-08T10:00:00Z
val instant = original.toKotlinInstantFromEpochMillis()
assertThat(instant.toEpochMillis()).isEqualTo(original)
}
@Test
fun `zero millis maps to Instant epoch`() {
assertThat(0L.toKotlinInstantFromEpochMillis()).isEqualTo(Instant.fromEpochMilliseconds(0L))
}
@Test
fun `negative epoch millis is supported`() {
val original = -1_000_000L
assertThat(original.toKotlinInstantFromEpochMillis().toEpochMillis()).isEqualTo(original)
}
}

View File

@@ -0,0 +1,53 @@
package de.jeanlucmakiola.calendula.data.prefs
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
class CalendarPrefsTest {
private fun newDataStore(tempDir: Path): DataStore<Preferences> =
PreferenceDataStoreFactory.create(
produceFile = { tempDir.resolve("test_prefs.preferences_pb").toFile() },
)
@Test
fun `hiddenCalendarIds defaults to empty when unset`(@TempDir tempDir: Path) = runTest {
val prefs = CalendarPrefs(newDataStore(tempDir))
assertThat(prefs.hiddenCalendarIds.first()).isEmpty()
}
@Test
fun `setHiddenCalendarIds round-trips through DataStore`(@TempDir tempDir: Path) = runTest {
val store = newDataStore(tempDir)
val prefs = CalendarPrefs(store)
prefs.setHiddenCalendarIds(setOf(1L, 42L, 7L))
assertThat(prefs.hiddenCalendarIds.first()).isEqualTo(setOf(1L, 42L, 7L))
}
@Test
fun `setting empty set clears storage`(@TempDir tempDir: Path) = runTest {
val prefs = CalendarPrefs(newDataStore(tempDir))
prefs.setHiddenCalendarIds(setOf(1L))
prefs.setHiddenCalendarIds(emptySet())
assertThat(prefs.hiddenCalendarIds.first()).isEmpty()
}
@Test
fun `garbage stored string is parsed defensively`(@TempDir tempDir: Path) = runTest {
val store = newDataStore(tempDir)
val prefs = CalendarPrefs(store)
store.updateData { p ->
val mutable = p.toMutablePreferences()
mutable[CalendarPrefs.HIDDEN_IDS_KEY] = "1,abc,3"
mutable
}
assertThat(prefs.hiddenCalendarIds.first()).isEqualTo(setOf(1L, 3L))
}
}

View File

@@ -0,0 +1,114 @@
package de.jeanlucmakiola.calendula.data.prefs
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventFormField
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.DayOfWeek
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import java.util.Locale
class SettingsPrefsTest {
private fun newDataStore(tempDir: Path): DataStore<Preferences> =
PreferenceDataStoreFactory.create(
produceFile = { tempDir.resolve("settings_test.preferences_pb").toFile() },
)
@Test
fun `defaults are system theme, dynamic colour on, auto week start`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.themeMode.first()).isEqualTo(ThemeMode.SYSTEM)
assertThat(prefs.dynamicColor.first()).isTrue()
assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.AUTO)
}
@Test
fun `theme mode round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
prefs.setThemeMode(ThemeMode.DARK)
assertThat(prefs.themeMode.first()).isEqualTo(ThemeMode.DARK)
}
@Test
fun `dynamic colour round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
prefs.setDynamicColor(false)
assertThat(prefs.dynamicColor.first()).isFalse()
}
@Test
fun `week start round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
prefs.setWeekStart(WeekStartPref.SUNDAY)
assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.SUNDAY)
}
@Test
fun `garbage stored enum falls back to default`(@TempDir tempDir: Path) = runTest {
val store = newDataStore(tempDir)
val prefs = SettingsPrefs(store)
store.updateData { p ->
val m = p.toMutablePreferences()
m[SettingsPrefs.THEME_MODE_KEY] = "PLAID"
m
}
assertThat(prefs.themeMode.first()).isEqualTo(ThemeMode.SYSTEM)
}
@Test
fun `form fields default to location and description`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.defaultFormFields.first()).containsExactly(
EventFormField.Location,
EventFormField.Description,
)
}
@Test
fun `form field toggle round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
prefs.setFormFieldDefault(EventFormField.Reminders, enabled = true)
prefs.setFormFieldDefault(EventFormField.Location, enabled = false)
assertThat(prefs.defaultFormFields.first()).containsExactly(
EventFormField.Description,
EventFormField.Reminders,
)
}
@Test
fun `disabling every form field persists as none, not factory default`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
EventFormField.entries.forEach { prefs.setFormFieldDefault(it, enabled = false) }
assertThat(prefs.defaultFormFields.first()).isEmpty()
}
@Test
fun `unknown stored form-field names are dropped`(@TempDir tempDir: Path) = runTest {
val store = newDataStore(tempDir)
val prefs = SettingsPrefs(store)
store.updateData { p ->
val m = p.toMutablePreferences()
m[SettingsPrefs.FORM_FIELDS_KEY] = "Location,Hologram"
m
}
assertThat(prefs.defaultFormFields.first()).containsExactly(EventFormField.Location)
}
@Test
fun `explicit week-start prefs resolve regardless of locale`() {
assertThat(WeekStartPref.MONDAY.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.MONDAY)
assertThat(WeekStartPref.SUNDAY.resolveFirstDay(Locale.GERMANY)).isEqualTo(DayOfWeek.SUNDAY)
}
@Test
fun `auto week start follows the locale convention`() {
assertThat(WeekStartPref.AUTO.resolveFirstDay(Locale.GERMANY)).isEqualTo(DayOfWeek.MONDAY)
assertThat(WeekStartPref.AUTO.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.SUNDAY)
}
}

View File

@@ -0,0 +1,72 @@
package de.jeanlucmakiola.calendula.domain
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import org.junit.jupiter.api.Test
class EventFormTest {
private fun form(
calendarId: Long? = 1L,
isAllDay: Boolean = false,
start: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)),
end: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)),
): EventForm = EventForm(calendarId = calendarId, isAllDay = isAllDay, start = start, end = end)
@Test
fun `valid timed form has no problems`() {
assertThat(form().problems()).isEmpty()
}
@Test
fun `missing calendar is a problem`() {
assertThat(form(calendarId = null).problems())
.containsExactly(EventFormProblem.NoCalendar)
}
@Test
fun `timed end before start is a problem`() {
val bad = form(end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 0)))
assertThat(bad.problems()).containsExactly(EventFormProblem.EndBeforeStart)
}
@Test
fun `zero-length timed event is allowed`() {
val instant = form(end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)))
assertThat(instant.problems()).isEmpty()
}
@Test
fun `all-day single day is allowed even though times match`() {
val allDay = form(
isAllDay = true,
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
)
assertThat(allDay.problems()).isEmpty()
}
@Test
fun `all-day end date before start date is a problem`() {
val bad = form(
isAllDay = true,
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 6, 10), LocalTime(0, 0)),
)
assertThat(bad.problems()).containsExactly(EventFormProblem.EndBeforeStart)
}
@Test
fun `problems accumulate`() {
val bad = form(
calendarId = null,
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 0)),
)
assertThat(bad.problems()).containsExactly(
EventFormProblem.NoCalendar,
EventFormProblem.EndBeforeStart,
)
}
}

View File

@@ -0,0 +1,74 @@
package de.jeanlucmakiola.calendula.domain
import com.google.common.truth.Truth.assertThat
import kotlin.time.Instant
import org.junit.jupiter.api.Test
class ModelsTest {
@Test
fun `CalendarSource is a data class with structural equality`() {
val a = CalendarSource(1L, "Work", "x@y", "com.google", 0xFF112233.toInt(), true)
val b = CalendarSource(1L, "Work", "x@y", "com.google", 0xFF112233.toInt(), true)
assertThat(a).isEqualTo(b)
}
@Test
fun `EventInstance is a data class with structural equality`() {
val start = Instant.fromEpochMilliseconds(0L)
val end = Instant.fromEpochMilliseconds(3_600_000L)
val a = EventInstance(10L, 1L, 1L, "Meet", start, end, false, 0xFF000000.toInt(), null)
val b = EventInstance(10L, 1L, 1L, "Meet", start, end, false, 0xFF000000.toInt(), null)
assertThat(a).isEqualTo(b)
}
@Test
fun `AttendeeStatus enum has all five variants`() {
assertThat(AttendeeStatus.values().toSet()).isEqualTo(
setOf(
AttendeeStatus.Accepted,
AttendeeStatus.Declined,
AttendeeStatus.Tentative,
AttendeeStatus.NeedsAction,
AttendeeStatus.Unknown,
)
)
}
@Test
fun `FailureReason enum has all five variants`() {
assertThat(FailureReason.values().toSet()).isEqualTo(
setOf(
FailureReason.PermissionRevoked,
FailureReason.NoCalendarsConfigured,
FailureReason.ProviderUnavailable,
FailureReason.EventNotFound,
FailureReason.Unknown,
)
)
}
@Test
fun `EventDetail composes EventInstance plus extras`() {
val instance = EventInstance(
instanceId = 10L,
eventId = 1L,
calendarId = 1L,
title = "Meet",
start = Instant.fromEpochMilliseconds(0L),
end = Instant.fromEpochMilliseconds(60_000L),
isAllDay = false,
color = 0xFFAABBCC.toInt(),
location = null,
)
val detail = EventDetail(
instance = instance,
description = "Brief description",
organizer = "x@y",
attendees = listOf(Attendee("Alice", "alice@x", AttendeeStatus.Accepted)),
rrule = "FREQ=WEEKLY",
)
assertThat(detail.instance.title).isEqualTo("Meet")
assertThat(detail.attendees).hasSize(1)
}
}

View File

@@ -0,0 +1,61 @@
package de.jeanlucmakiola.calendula.ui.filter
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.CalendarSource
import org.junit.jupiter.api.Test
class FilterGroupingTest {
private fun cal(
id: Long,
name: String,
account: String,
type: String = "com.example",
) = CalendarSource(
id = id,
displayName = name,
accountName = account,
accountType = type,
color = 0xFF336699.toInt(),
isVisibleInSystem = true,
)
@Test
fun `groups calendars under their account, preserving order`() {
val calendars = listOf(
cal(1, "Personal", "alice@dav"),
cal(2, "Work", "alice@dav"),
cal(3, "Shared", "team@dav"),
)
val groups = groupByAccount(calendars, hidden = emptySet())
assertThat(groups.map { it.account }).containsExactly("alice@dav", "team@dav").inOrder()
assertThat(groups[0].calendars.map { it.displayName })
.containsExactly("Personal", "Work").inOrder()
assertThat(groups[1].calendars.map { it.id }).containsExactly(3L)
}
@Test
fun `hidden ids mark calendars not visible`() {
val calendars = listOf(
cal(1, "Personal", "alice@dav"),
cal(2, "Work", "alice@dav"),
)
val groups = groupByAccount(calendars, hidden = setOf(2L))
val rows = groups.single().calendars.associateBy { it.id }
assertThat(rows.getValue(1L).visible).isTrue()
assertThat(rows.getValue(2L).visible).isFalse()
}
@Test
fun `blank account name falls back to type`() {
val groups = groupByAccount(
listOf(cal(1, "Birthdays", account = "", type = "LOCAL")),
hidden = emptySet(),
)
assertThat(groups.single().account).isEqualTo("LOCAL")
}
}

View File

@@ -0,0 +1,179 @@
package de.jeanlucmakiola.calendula.ui.week
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atTime
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import kotlin.time.Instant
import org.junit.jupiter.api.Test
class WeekLayoutTest {
private val zone = TimeZone.UTC
// 2026-06-10 is a Wednesday; its Monday-anchored week starts 2026-06-08.
private val wed = LocalDate(2026, 6, 10)
private val mon = LocalDate(2026, 6, 8)
private val weekDays = (0..6).map { mon.plusDays(it) }
private fun at(date: LocalDate, h: Int, m: Int = 0): Instant =
date.atTime(h, m).toInstant(zone)
private fun event(
start: Instant,
end: Instant,
allDay: Boolean = false,
id: Long = 1L,
title: String = "E",
) = EventInstance(
instanceId = id,
eventId = id,
calendarId = 1L,
title = title,
start = start,
end = end,
isAllDay = allDay,
color = 0xFF112233.toInt(),
location = null,
)
@Test
fun `startOfWeek snaps to monday`() {
assertThat(wed.startOfWeek(DayOfWeek.MONDAY)).isEqualTo(mon)
assertThat(mon.startOfWeek(DayOfWeek.MONDAY)).isEqualTo(mon)
}
@Test
fun `weekRange spans seven days`() {
val range = weekRange(mon, zone)
assertThat(range.start).isEqualTo(at(mon, 0, 0))
// endInclusive is the last second of day 7 (Sunday 2026-06-14)
assertThat(range.endInclusive).isEqualTo(LocalDate(2026, 6, 14).atTime(23, 59, 59).toInstant(zone))
}
@Test
fun `coversDay is true for any overlap and false otherwise`() {
val ev = event(at(wed, 9), at(wed, 10))
assertThat(ev.coversDay(wed, zone)).isTrue()
assertThat(ev.coversDay(mon, zone)).isFalse()
val multiDay = event(at(mon, 22), at(wed, 2), allDay = true)
assertThat(multiDay.coversDay(mon, zone)).isTrue()
assertThat(multiDay.coversDay(LocalDate(2026, 6, 9), zone)).isTrue()
assertThat(multiDay.coversDay(wed, zone)).isTrue()
assertThat(multiDay.coversDay(LocalDate(2026, 6, 12), zone)).isFalse()
}
@Test
fun `single timed event gets one lane`() {
val blocks = layoutDay(listOf(event(at(wed, 9), at(wed, 10, 30))), wed, zone)
assertThat(blocks).hasSize(1)
val b = blocks.single()
assertThat(b.startMin).isEqualTo(9 * 60)
assertThat(b.endMin).isEqualTo(10 * 60 + 30)
assertThat(b.lane).isEqualTo(0)
assertThat(b.laneCount).isEqualTo(1)
}
@Test
fun `overlapping events resolve to side-by-side lanes`() {
val a = event(at(wed, 9), at(wed, 11), id = 1L)
val b = event(at(wed, 10), at(wed, 12), id = 2L)
val blocks = layoutDay(listOf(a, b), wed, zone).sortedBy { it.lane }
assertThat(blocks.map { it.lane }).containsExactly(0, 1)
assertThat(blocks.all { it.laneCount == 2 }).isTrue()
}
@Test
fun `back-to-back events reuse one lane`() {
val a = event(at(wed, 9), at(wed, 10), id = 1L)
val b = event(at(wed, 10), at(wed, 11), id = 2L)
val blocks = layoutDay(listOf(a, b), wed, zone)
assertThat(blocks).hasSize(2)
assertThat(blocks.all { it.lane == 0 && it.laneCount == 1 }).isTrue()
}
@Test
fun `event spanning midnight is clipped to the day`() {
// Starts the previous evening, ends 02:00 on wed.
val ev = event(at(mon.plusDays(1), 22), at(wed, 2))
val blocks = layoutDay(listOf(ev), wed, zone)
assertThat(blocks).hasSize(1)
assertThat(blocks.single().startMin).isEqualTo(0)
assertThat(blocks.single().endMin).isEqualTo(2 * 60)
}
@Test
fun `instant event is kept with zero-length`() {
val ev = event(at(wed, 12), at(wed, 12))
val blocks = layoutDay(listOf(ev), wed, zone)
assertThat(blocks).hasSize(1)
assertThat(blocks.single().startMin).isEqualTo(12 * 60)
assertThat(blocks.single().endMin).isEqualTo(12 * 60)
}
@Test
fun `all-day events are excluded from the timed layout`() {
val ev = event(at(wed, 0), at(wed.plusDays(1), 0), allDay = true)
assertThat(layoutDay(listOf(ev), wed, zone)).isEmpty()
}
@Test
fun `events on other days are dropped`() {
val ev = event(at(mon, 9), at(mon, 10))
assertThat(layoutDay(listOf(ev), wed, zone)).isEmpty()
}
@Test
fun `single-day all-day event is a one-column span`() {
// Wed only: start Wed 00:00, end Thu 00:00.
val ev = event(at(weekDays[2], 0), at(weekDays[3], 0), allDay = true)
val spans = layoutAllDay(listOf(ev), weekDays, zone)
assertThat(spans).hasSize(1)
val s = spans.single()
assertThat(s.startCol).isEqualTo(2)
assertThat(s.endCol).isEqualTo(2)
assertThat(s.lane).isEqualTo(0)
}
@Test
fun `multi-day all-day event becomes one span across columns`() {
// Tue..Thu: end Fri 00:00 is exclusive, so Fri is not covered.
val ev = event(at(weekDays[1], 0), at(weekDays[4], 0), allDay = true)
val s = layoutAllDay(listOf(ev), weekDays, zone).single()
assertThat(s.startCol).isEqualTo(1)
assertThat(s.endCol).isEqualTo(3)
}
@Test
fun `span reaching outside the week is clamped to visible columns`() {
// Starts two days before Monday, ends Wed 00:00 → covers Mon..Tue.
val ev = event(at(mon.plusDays(-2), 0), at(weekDays[2], 0), allDay = true)
val s = layoutAllDay(listOf(ev), weekDays, zone).single()
assertThat(s.startCol).isEqualTo(0)
assertThat(s.endCol).isEqualTo(1)
}
@Test
fun `overlapping all-day spans get separate lanes`() {
val a = event(at(weekDays[0], 0), at(weekDays[3], 0), allDay = true, id = 1L) // Mon..Wed
val b = event(at(weekDays[1], 0), at(weekDays[4], 0), allDay = true, id = 2L) // Tue..Thu
val spans = layoutAllDay(listOf(a, b), weekDays, zone)
assertThat(spans.map { it.lane }.toSet()).isEqualTo(setOf(0, 1))
}
@Test
fun `disjoint all-day spans reuse one lane`() {
val a = event(at(weekDays[0], 0), at(weekDays[1], 0), allDay = true, id = 1L) // Mon
val b = event(at(weekDays[3], 0), at(weekDays[4], 0), allDay = true, id = 2L) // Thu
val spans = layoutAllDay(listOf(a, b), weekDays, zone)
assertThat(spans.all { it.lane == 0 }).isTrue()
}
private fun LocalDate.plusDays(n: Int): LocalDate = plus(n, DateTimeUnit.DAY)
}

View File

@@ -0,0 +1,34 @@
<svg width="232" height="232" viewBox="0 0 232 232" xmlns="http://www.w3.org/2000/svg">
<!--
Composed Calendula launcher icon (full-bleed), generated to exactly match the
Android adaptive icon defined by:
- drawable/ic_launcher_background.xml (solid @color/ic_launcher_background = #5C6B7A)
- drawable/ic_launcher_foreground.xml (off-white #FAF6F0 mark from calendula_mark.svg)
The adaptive foreground group transform (scaleX/Y=0.50, pivot 114,108,
translate 2,8) is reproduced here as the SVG transform "translate(59,62) scale(0.5)"
because Android applies it as: p' = scale*p + pivot*(1-scale) + translate
x' = 0.5*x + 114*0.5 + 2 = 0.5*x + 59
y' = 0.5*y + 108*0.5 + 8 = 0.5*y + 62
This is the single source of truth for the F-Droid / store icon.png renders.
-->
<rect x="0" y="0" width="232" height="232" fill="#5C6B7A"/>
<g transform="translate(59,62) scale(0.5)" fill="none" stroke="#FAF6F0">
<!-- Calendar body (rounded square with horizontal header divider) -->
<path d="M43 69H185M185 115V63C185 48.64 173.359 37 159 37H69C54.64 37 43 48.64 43 63V153C43 167.359 54.64 179 69 179H124" stroke-width="12" stroke-miterlimit="12" stroke-linecap="round" stroke-linejoin="round"/>
<!-- Numeral "1" inside the calendar body -->
<path d="M103 110L113.999 99V142.428" stroke-width="12" stroke-linecap="round" stroke-linejoin="round"/>
<!-- Calendula bloom: 8 petals around a filled center -->
<path d="M163.072 136.714C163.072 142.886 168.214 153.429 170.786 157.929C173.357 153.429 178.5 142.886 178.5 136.714C178.5 130.543 173.357 129 170.786 129C168.214 129 163.072 130.543 163.072 136.714Z" stroke-width="8"/>
<path d="M178.5 186.857C178.5 180.686 173.357 170.143 170.786 165.643C168.214 170.143 163.072 180.686 163.072 186.857C163.072 193.029 168.214 194.572 170.786 194.572C173.357 194.572 178.5 193.029 178.5 186.857Z" stroke-width="8"/>
<path d="M195.857 154.072C189.686 154.072 179.143 159.214 174.643 161.786C179.143 164.357 189.686 169.5 195.857 169.5C202.029 169.5 203.572 164.357 203.572 161.786C203.572 159.214 202.029 154.072 195.857 154.072Z" stroke-width="8"/>
<path d="M145.714 170.008C151.886 170.008 162.429 164.865 166.929 162.294C162.429 159.722 151.886 154.58 145.714 154.58C139.543 154.58 138 159.722 138 162.294C138 164.865 139.543 170.008 145.714 170.008Z" stroke-width="8"/>
<path d="M194.768 174.858C190.404 170.494 179.312 166.676 174.312 165.312C175.676 170.312 179.494 181.404 183.858 185.768C188.222 190.132 192.949 187.586 194.768 185.768C196.586 183.949 199.132 179.222 194.768 174.858Z" stroke-width="8"/>
<path d="M146.804 149.222C151.168 153.586 162.259 157.404 167.26 158.768C165.896 153.767 162.077 142.676 157.714 138.312C153.35 133.948 148.622 136.494 146.804 138.312C144.986 140.13 142.44 144.858 146.804 149.222Z" stroke-width="8"/>
<path d="M183.858 138.312C179.494 142.676 175.676 153.767 174.312 158.768C179.312 157.404 190.404 153.586 194.768 149.222C199.132 144.858 196.586 140.13 194.768 138.312C192.949 136.494 188.222 133.948 183.858 138.312Z" stroke-width="8"/>
<path d="M157.714 185.768C162.077 181.404 165.896 170.312 167.26 165.312C162.259 166.676 151.168 170.494 146.804 174.858C142.44 179.222 144.986 183.949 146.804 185.768C148.622 187.586 153.35 190.132 157.714 185.768Z" stroke-width="8"/>
<!-- Calendula center disc (filled, matches foreground <fillColor> slot) -->
<path d="M170.786 169C174.77 169 178 165.77 178 161.786C178 157.802 174.77 154.572 170.786 154.572C166.802 154.572 163.572 157.802 163.572 161.786C163.572 165.77 166.802 169 170.786 169Z" fill="#FAF6F0" stroke="none"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@@ -0,0 +1,13 @@
<svg width="232" height="232" viewBox="0 0 232 232" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M43 69H185M185 115V63C185 48.64 173.359 37 159 37H69C54.64 37 43 48.64 43 63V153C43 167.359 54.64 179 69 179H124" stroke="black" stroke-width="12" stroke-miterlimit="12" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M103 110L113.999 99V142.428" stroke="black" stroke-width="12" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M163.072 136.714C163.072 142.886 168.214 153.429 170.786 157.929C173.357 153.429 178.5 142.886 178.5 136.714C178.5 130.543 173.357 129 170.786 129C168.214 129 163.072 130.543 163.072 136.714Z" stroke="black" stroke-width="8"/>
<path d="M178.5 186.857C178.5 180.686 173.357 170.143 170.786 165.643C168.214 170.143 163.072 180.686 163.072 186.857C163.072 193.029 168.214 194.572 170.786 194.572C173.357 194.572 178.5 193.029 178.5 186.857Z" stroke="black" stroke-width="8"/>
<path d="M195.857 154.072C189.686 154.072 179.143 159.214 174.643 161.786C179.143 164.357 189.686 169.5 195.857 169.5C202.029 169.5 203.572 164.357 203.572 161.786C203.572 159.214 202.029 154.072 195.857 154.072Z" stroke="black" stroke-width="8"/>
<path d="M145.714 170.008C151.886 170.008 162.429 164.865 166.929 162.294C162.429 159.722 151.886 154.58 145.714 154.58C139.543 154.58 138 159.722 138 162.294C138 164.865 139.543 170.008 145.714 170.008Z" stroke="black" stroke-width="8"/>
<path d="M194.768 174.858C190.404 170.494 179.312 166.676 174.312 165.312C175.676 170.312 179.494 181.404 183.858 185.768C188.222 190.132 192.949 187.586 194.768 185.768C196.586 183.949 199.132 179.222 194.768 174.858Z" stroke="black" stroke-width="8"/>
<path d="M146.804 149.222C151.168 153.586 162.259 157.404 167.26 158.768C165.896 153.767 162.077 142.676 157.714 138.312C153.35 133.948 148.622 136.494 146.804 138.312C144.986 140.13 142.44 144.858 146.804 149.222Z" stroke="black" stroke-width="8"/>
<path d="M183.858 138.312C179.494 142.676 175.676 153.767 174.312 158.768C179.312 157.404 190.404 153.586 194.768 149.222C199.132 144.858 196.586 140.13 194.768 138.312C192.949 136.494 188.222 133.948 183.858 138.312Z" stroke="black" stroke-width="8"/>
<path d="M157.714 185.768C162.077 181.404 165.896 170.312 167.26 165.312C162.259 166.676 151.168 170.494 146.804 174.858C142.44 179.222 144.986 183.949 146.804 185.768C148.622 187.586 153.35 190.132 157.714 185.768Z" stroke="black" stroke-width="8"/>
<path d="M170.786 169C174.77 169 178 165.77 178 161.786C178 157.802 174.77 154.572 170.786 154.572C166.802 154.572 163.572 157.802 163.572 161.786C163.572 165.77 166.802 169 170.786 169Z" fill="black" stroke="black"/>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,109 @@
# 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 | offen |
| v2.0 | Quick-Add, Occurrence-Edit, Konflikt-Dialog, Polish-Pass, Release | offen |
## 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 (Skizze)
- Formular lädt `EventDetail`, Dirty-Check, `update` auf Events-Row
- Reminder hinzufügen/entfernen (`Reminders`-Insert/Delete)
- Einfacher Recurrence-Picker (FREQ + INTERVAL + UNTIL/COUNT)
## v2.0 — Abschluss (Skizze)
- Quick-Add-Sheet (Titel + Zeit, Rest Defaults)
- Occurrence-Edit (Exception mit geänderten Werten)
- Konflikt-Dialog beim Speichern
- Changelog, F-Droid-Metadaten, Release-Tag

View File

@@ -27,7 +27,7 @@ von Google Calendar gibt - speziell mit dem 2025er Expressive-Design.
- 3 Hauptansichten: Monat, Woche, Tag - 3 Hauptansichten: Monat, Woche, Tag
- Event-Detail-Sheet (read-only Detailansicht) - Event-Detail-Sheet (read-only Detailansicht)
- Multi-Kalender-Toggle (Sichtbarkeit pro Kalender) - Multi-Kalender-Toggle (Sichtbarkeit pro Kalender)
- Heute-Button + Jump-to-Date - Heute-Button (Jump-to-Date gestrichen, siehe Out-of-Scope)
- Settings-Screen (Theme, Dynamic Color, Wochenstart, Sprache) - Settings-Screen (Theme, Dynamic Color, Wochenstart, Sprache)
- Permission-Flow für `READ_CALENDAR` - Permission-Flow für `READ_CALENDAR`
- Empty-States und Error-Recovery - Empty-States und Error-Recovery
@@ -35,6 +35,7 @@ von Google Calendar gibt - speziell mit dem 2025er Expressive-Design.
- Tests + CI ab Tag 1 - Tests + CI ab Tag 1
### Out-of-Scope (V2+) ### Out-of-Scope (V2+)
- Jump-to-Date / Datum-Picker (aus V1-Scope gestrichen)
- Event-Create/Edit/Delete (V2) - Event-Create/Edit/Delete (V2)
- Home-Screen-Widget - Home-Screen-Widget
- Volltextsuche - Volltextsuche
@@ -202,9 +203,9 @@ fragt Repository nur für sichtbaren Range an - kein "alle Events der Welt".
- Immer erreichbar von allen Hauptansichten - Immer erreichbar von allen Hauptansichten
- State persistent (zuletzt aktive Ansicht) - State persistent (zuletzt aktive Ansicht)
**M2 - Heute / Springe-zu-Datum** **M2 - Heute**
- Schnell zurück zu "heute" - Schnell zurück zu "heute" (Drawer-Eintrag, ausgeliefert in v0.5)
- Springe zu beliebigem Datum via Datum-Picker - ~~Springe zu beliebigem Datum via Datum-Picker~~ — **gestrichen**, siehe Out-of-Scope
- Erreichbar von allen Hauptansichten - Erreichbar von allen Hauptansichten
**M3 - Kalender-Filter (Bottom-Sheet)** **M3 - Kalender-Filter (Bottom-Sheet)**

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1,13 @@
#This file is generated by updateDaemonJvm
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7083b89563e7ce20943037b8cd2b8cc2/redirect
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/060bbb778a1f55ea705fdebd2ccfeab9/redirect
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/d09679dc60fe5aa05ef7d03efdefac20/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/ed4e3bf2f5e7c5d9aabc4cbd8acd555e/redirect
toolchainVendor=JETBRAINS
toolchainVersion=21

View File

@@ -1,9 +1,10 @@
[versions] [versions]
agp = "9.1.1" agp = "9.2.1"
kotlin = "2.3.21" kotlin = "2.3.21"
ksp = "2.3.9" ksp = "2.3.9"
hilt = "2.59.2" hilt = "2.59.2"
coreKtx = "1.19.0" coreKtx = "1.19.0"
appcompat = "1.7.1"
lifecycleRuntime = "2.10.0" lifecycleRuntime = "2.10.0"
activityCompose = "1.13.0" activityCompose = "1.13.0"
composeBom = "2026.05.01" composeBom = "2026.05.01"
@@ -17,10 +18,17 @@ junitPlatform = "6.1.0"
truth = "1.4.5" truth = "1.4.5"
androidxJunit = "1.3.0" androidxJunit = "1.3.0"
espressoCore = "3.7.0" espressoCore = "3.7.0"
kotlinxDatetime = "0.7.0"
kotlinxCoroutines = "1.10.2"
turbine = "1.2.0"
hiltNavigationCompose = "1.3.0"
lifecycleCompose = "2.10.0"
androidxTestRules = "1.7.0"
[libraries] [libraries]
# AndroidX core # AndroidX core
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntime" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntime" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
@@ -35,6 +43,8 @@ androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-man
# Material 3 (Expressive lives in this artifact for 1.5+) # Material 3 (Expressive lives in this artifact for 1.5+)
androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }
androidx-compose-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" }
androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
# Hilt # Hilt
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
@@ -53,6 +63,25 @@ truth = { group = "com.google.truth", name = "truth", version.ref = "truth" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
# Domain time
kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "kotlinxDatetime" }
# Coroutines (transitively pulled by hilt-android, pinned explicit)
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
# Test - Flow assertions
turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" }
# Hilt navigation-compose (for hiltViewModel() in Composables)
androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
# Lifecycle compose (for collectAsStateWithLifecycle)
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleCompose" }
# Android tests - GrantPermissionRule
androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidxTestRules" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }

View File

@@ -11,6 +11,9 @@ pluginManagement {
gradlePluginPortal() gradlePluginPortal()
} }
} }
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
dependencyResolutionManagement { dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)