Compare commits

...

38 Commits

Author SHA1 Message Date
Guido Schmit
dd347a07f1 chore: GPLv3 license + F-Droid packaging (metadata, recipe, guide)
- LICENSE: GNU GPLv3
- fastlane/metadata (de-DE + en-US): title, descriptions, changelog, icon —
  used by both the own F-Droid repo and the official listing
- fdroid/com.scarriffle.calendarr.yml: ready-to-submit fdroiddata build recipe
- FDROID.md: how to run the own repo (fdroid.scarriffle.com) and submit to
  official F-Droid, incl. the signing-key caveat

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 19:03:04 +02:00
Guido Schmit
cc51ede8a5 fix: week/day all-day strip spans multi-day events as continuous bars
Lay out all-day events as bars across their start→end day columns, packed into
lanes, instead of listing a multi-day event separately in each day's column
(mirrors the month view and iOS).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 11:20:21 +02:00
Guido Schmit
101370719b Android: full-screen menu landing page + much denser settings
- gear now opens a full-screen Menu screen (Profile / Appearance / Accounts /
  Groups / Sync / Switch server / Logout), replacing the bottom-sheet menu that
  sat partly under the system nav buttons. Each entry opens its own full-screen
  page and returns to the menu on close.
- settings 'Darstellung' is the appearance table only again (no long scroll to
  reach navigation)
- much more compact settings: dropdowns use a small outlined chip instead of a
  full-height OutlinedTextField, smaller sync icons, bodyMedium row labels

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 20:31:21 +02:00
Guido Schmit
e72ffab4f4 Android drawer/settings feedback: hamburger left, denser UI, full-screen settings
- move the top-bar hamburger to the LEFT (drawer opens from the left)
- much denser drawer: compact header, and calendar rows now use a small eye
  toggle instead of the bulky Switch, with tighter padding/text
- tapping the gear now opens the full-screen Settings directly (no bottom-sheet
  menu that sat under the system nav buttons, no redundant sync — that's in the
  drawer header). Accounts / Groups / Profile / Switch server / Logout moved
  into a 'More' section at the bottom of Settings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 20:02:08 +02:00
Guido Schmit
3212fe2c6a feat: import birthdays from Android Contacts
Mirror the iOS Contacts birthday importer: read the address book's birthday
events and reconcile them into the user's birthday calendar, scoped to this
device via external_uid 'contact:<deviceId>:<contactId>' (add/update/delete),
then report the device.

- READ_CONTACTS permission + ContactsReader (Event TYPE_BIRTHDAY, handles
  year/no-year date formats)
- API: GET /birthdays, POST /birthdays/sync-report; local-event create/update
  now carry external_uid (+ update carries rrule/birth_year)
- CalendarViewModel.syncContactBirthdays reconciles like iOS; device-local
  enable flag + stable device id in SettingsStore
- birthday dialog gains an 'Import from contacts' action (requests permission);
  auto-syncs on launch when enabled

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 18:48:24 +02:00
Guido Schmit
e042f92ec6 chore: bump version to 1.1.0 (versionCode 2) for Play Store testing 2026-07-15 18:38:06 +02:00
Guido Schmit
749eccfaaa feat: left navigation drawer (calendars + groups + view + menu)
Mirror the iOS side drawer on Android with a ModalNavigationDrawer opened by the
hamburger or an edge-swipe (replaces the calendar filter bottom sheet):
- header with avatar/name/server + manual sync, full menu (gear), and close
- view + group switcher chips
- flat calendar list: tap toggles visibility, long-press opens a context menu
  (hide/banish + reminders), and a Sort toggle reveals up/down reordering.
  Order is device-local (SettingsStore.calendarOrder, mirrors the web).
- month-switching is now a named dropdown (scroll vs pages) like web/iOS
- new device-local 'Hide menu button' setting (drawer opens via edge-swipe)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 18:35:03 +02:00
Guido Schmit
fd804000c5 feat: per-setting sync table with dynamic 8-colour theme
Redesign the settings screen into uniform rows (sync icon | name | value):
dropdowns replace the chip pickers, every colour gets a reset to a canonical
default, a per-row sync icon toggles that setting's cross-device sync, and a
global switch flips all. Enabling a flag adopts this device's current value.

- AppSettings/SettingsStore: add text/bg/line colours, cache_months,
  month_view_paged, and the account-wide sync_flags map (+ DEFAULT_SYNC,
  effective-merge on pull)
- CalendarRepository.updateSettings: partial PUT of only the synced values plus
  the flag map (unsynced columns stay untouched server-side)
- Theme: fully dynamic — primary/accent tint, background/text/line drive
  background/onBackground/outline (matching web); today/divider/label unchanged
- add hide-profile (directory_hidden) to the profile block (parity with web/iOS)
- language + text/line contrast stay device-local

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:09:46 +02:00
Guido Schmit
fdad9c9ba4 chore(android): target SDK 35 + version 1.0.0 for Play release
Play requires targeting a recent API level for new apps; bump compileSdk/
targetSdk to 35 and set the release version name to 1.0.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:30:27 +02:00
Guido Schmit
1f90503104 chore(android): git-ignore keystore.properties + add example
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:16:42 +02:00
Guido Schmit
486cc9c622 feat(android): single birthday-calendar model + group-visible exclusion
- one birthday calendar per user: BirthdayDialog targets the single calendar and
  offers activation when none exists; VM birthdayCalendar()/ensureBirthdayCalendar()
- removed the birthday toggle from the generic new-calendar dialog
- exclude the birthday calendar from the group-visible ("shared calendar") picker

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:08:19 +02:00
Guido Schmit
7dbaad4b00 feat(android): birthday display, calendar creation and manual entry
- CalEvent parses is_birthday and exposes renderTitle (server age)
- EventLabel renders a cake icon + render title in month/week/day/agenda/preview
- LocalCalendar model + repository/api gain is_birthday, notify-days, rrule,
  birth_year; AccountsScreen can create a birthday calendar with a notify picker
- FAB long-press menu -> "New birthday" opens a minimal name+date dialog
  (year-unknown option) targeting a birthday calendar
- L10n strings

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 17:39:33 +02:00
Guido Schmit
5b5a5d5cd8 chore(android): release signing via keystore.properties
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 17:39:33 +02:00
Guido Schmit
e49a2e027f feat(android): toggle month view between scroll feed and paged swipe
New device-local setting "Monatsansicht seitenweise wischen" (SettingsStore,
no server sync). When off, the month view stays the continuous vertical scroll
feed. When on, it becomes a HorizontalPager — one month per screen, six
height-filling week rows, swipe left/right to change month. The prev/next/today
signals route to the pager (animateScrollToPage) instead of the list, and the
visible month is derived from pagerState.currentPage. WeekRow now takes a
rowModifier so the same row renders at a fixed height (scroll) or weight(1f)
(paged). Setting is re-read when the settings screen closes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 22:47:26 +02:00
Guido Schmit
8d7ac124ae fix(android): edge-to-edge insets + show all calendars in the filter
1) Burger menu (and other bottom sheets) had rows hidden behind the system
   navigation bar: MainActivity never called enableEdgeToEdge(), so the sheets'
   navigationBarsPadding resolved to 0. Enable edge-to-edge and place the insets
   ourselves — top bar statusBarsPadding, content/FAB navigationBarsPadding,
   auth screens systemBarsPadding — and add navigationBarsPadding (+ imePadding
   for the editor) to the Filter/Groups/Accounts/Editor sheets.

2) The calendar filter only listed calendars that had events in the loaded
   range, so empty calendars couldn't be toggled. Load the full calendar list
   from all sources (loadAllCalendars) and merge it with the event-derived
   entries — matching iOS, which already shows the complete list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 22:29:07 +02:00
Guido Schmit
bd3c92aa86 feat(sharing): let share recipients recolour a shared calendar
The colour dot for a shared (non-owned) local calendar was disabled, and the
colour write hit the owner-only PUT /calendars/{id} (404 for recipients). Now
the dot is always editable and colour changes go through the dedicated
PUT /calendars/{id}/color endpoint, which stores a recipient's own per-user
colour server-side (synced across devices) without granting write or rename.
Renaming stays owner-only (no rename UI for shared calendars).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 20:49:29 +02:00
Guido Schmit
993d5d83e1 feat: clearer shared calendars + move group switch into the menu
Three Android UX fixes reported from on-device testing:

1. A calendar shared with the user now shows read-only affordances: parse the
   server's new read_only flag on events, show a lock icon in the filter sheet,
   and exclude read-only shared calendars from the event editor's calendar
   picker (getWritableCalendars) so saves no longer 403. The calendar's display
   name (owner's name) comes from the server.

2. (server-side) group event name prefix removed — no client change needed.

3. Move the personal/group view switcher out of the crowded top bar into the
   burger MenuSheet as a "group view" section (personal + each group with a
   check mark), mirroring iOS. The GroupSwitcher pill and its top-bar wiring are
   gone; the active-group banner stays.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 18:52:57 +02:00
Guido Schmit
e9d58d8239 feat(visibility): separate local quick-hide from server-synced banish
The filter-sheet eye was the only hide control on Android and it hard-synced
to the server (sidebar_hidden/enabled) — so a quick, per-device hide leaked to
the web and other devices. Now Android matches iOS' two-tier model:

- Quick-hide (eye toggle) is device-local only (setCalendarHidden → hiddenKeys),
  no server call.
- Banish ("permanently hide", new archive action per row) syncs sidebar_hidden
  to the server and moves the calendar into Settings; a new "Hidden calendars"
  section there re-enables it (server + auto-refetch).
- reconcileCalendarVisibility now drives banishedKeys (not hiddenKeys) from the
  server's sidebar_hidden, so the reconcile never overwrites the local quick-hide.
- Filter sheet lists calendars from the unfiltered cache (minus banished), so a
  locally hidden calendar still appears and can be toggled back on.

Colours and reminders stay server-synced as before. iOS already had this model
and is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 11:09:45 +02:00
Guido Schmit
ca4c875659 fix(visibility): auto-reconcile server sidebar_hidden on launch/resume/sync
Hiding or showing a calendar on the web sets enabled=false + sidebar_hidden
server-side, so the server stops returning that calendar's events entirely.
The app synced the filter toggle TO the server (setCalendarHiddenSynced) but
never pulled the server's sidebar_hidden flags back, so a calendar hidden or
re-enabled on the web (or another device) was never reflected until a manual
sync or relaunch — the "hide it on the web and the app won't bring it back" bug.

Add reconcileCalendarVisibility(): pulls the account lists and updates the
local hidden set from the server's sidebar_hidden flags for external calendars
(local/ical keys untouched). It runs before the initial load, on manual sync,
and on a new ON_RESUME lifecycle hook — forcing a refetch when the set changed
so a re-enabled calendar's events reappear on their own. Mirrors the iOS fix.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 22:05:08 +02:00
Guido Schmit
80bdb91acf fix(cache): preserve events from calendars/sources that failed to sync
mergeIntoCache() unconditionally evicted all cached events in the fetched
range, so a transient sync failure (expired CalDAV credentials, an HA/Google
token-refresh failure) wiped the affected calendar until the next clean fetch.
Home Assistant was the most visible victim: a token failure aborts the whole
account fetch and the error arrives without a calendar_id.

Now sync errors are split into per-calendar keys (error carries a calendar_id)
and whole sources (account-level error without one). mergeIntoCache() keeps
matching cached events, and only extends the cached range on a fully clean
fetch so failed calendars are retried on the next load. Mirrors the iOS fix.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 21:35:39 +02:00
Guido Schmit
382cb717a4 fix: match sync errors to calendars by id, not name suffix
The server now includes calendar_id in per-calendar sync-error entries.
Match CalendarFilterSheet's warning icon on (source, calendarId) like
calendarKey() already does everywhere else, instead of a fragile
name-suffix comparison that could false-positive across accounts whose
calendar names happen to overlap.
2026-07-02 19:00:49 +02:00
Guido Schmit
a3568fb6f3 fix: actually pass force through from loadVisible to loadRange
loadVisible(force = true) (used by the error-banner retry button) was
still funneling into loadRange with an implicit force = false, so the
isCached short-circuit was never actually bypassed on retry.
2026-07-02 18:34:08 +02:00
Guido Schmit
9dd41420b6 fix: safe performance and crash-safety fixes
- TimeGridView: remember Instant.now() (keyed on the day) and the day-header
  DateTimeFormatter instead of rebuilding both on every recomposition/frame —
  fixes flicker in the past-event dimming and avoids repeated allocation.
- CalendarViewModel.refreshFromCache: short-circuit the non-group filter pass
  when both hiddenKeys and banishedKeys are empty, mirroring the existing
  group-mode short-circuit.
- CalendarViewModel.setCalendarHiddenSynced: wrap the server PATCH in
  runCatching and surface failures via state.error instead of silently
  dropping them (the local toggle would otherwise diverge from the server
  unnoticed).
- CalendarViewModel.afterMutation: refetch only the already-cached range
  (force-bypassing loadRange's cache check) instead of invalidateCache() +
  initialLoad(), which reloaded the entire ±cacheMonths window and froze the
  UI on every single event save.
- CalendarrRoot: replace calendarVm!! with a null-safe ?.let, falling back to
  the splash screen instead of crashing if the VM isn't ready yet.
- GroupsScreen: replace existing!!.icon!! / g.icon!! double force-unwraps
  with safe-call chains (existing?.icon?.takeIf { GroupIcons.isKey(it) }).

Deliberately out of scope: R8/minify settings, MonthView's cache window
size (MONTHS_BACK/MONTHS_AHEAD), and mergeIntoCache's merge logic (already
reviewed as correct). No debounce added to setCalendarHiddenSynced — would
need new machinery beyond this fix's scope.
2026-07-02 18:30:21 +02:00
Guido Schmit
3137abcef2 fix: surface per-calendar sync errors from server
/api/caldav/events now returns an "errors" array alongside "events" for
calendars that failed to sync (e.g. expired credentials) while still
reporting success overall. Previously such failures were silently
swallowed, so a calendar could appear enabled/visible while showing zero
events with no explanation.

- CalendarRepository.fetchEvents now returns EventsResult(events, errors),
  parsing the new "errors" array the same way importIcs's Triple return
  handles multi-value results.
- CalendarUiState gains syncErrors, populated in loadRange's onSuccess.
- CalendarScreen shows a second error-style banner for sync errors,
  additive to the existing network-failure banner.
- CalendarFilterSheet shows a warning icon next to calendar rows whose
  source+name matches a sync error (best-effort suffix match, since the
  server's error name is "<account> – <calendar name>").
2026-07-02 18:27:00 +02:00
Guido Schmit
d2d5e2b19c feat(filter): sync calendar visibility to server for CalDAV/Google/HA
The filter-sheet Switch now calls setCalendarHiddenSynced(), which
updates local hiddenKeys immediately AND fires a sidebar_hidden PATCH
to the server for caldav/google/homeassistant calendars. This makes
the quick-hide consistent with the web sidebar across all devices.
CalendarFilterEntry gains a source field so the sheet knows which
backend to target.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 21:48:36 +02:00
Guido Schmit
740367b0b9 feat: show relative time in notifications + move default duration to profile settings
Notifications now show "in 30 Min. · Ort" instead of just the location,
giving users immediate context about how soon the event starts.

Default event duration picker moved from the View section to right below
the profile/privacy group, consistent with the iOS app arrangement.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 19:40:46 +02:00
Scarriffle
9ced441935 feat: event reminders UI, per-calendar mute, notifications, default duration
- Add reminder editor (presets + custom number+unit) wired through save
- Per-calendar reminder mute: filter-sheet toggle + greyed-out editor hint;
  reminders are kept, never deleted
- New AlarmManager-based NotificationScheduler + channel + POST_NOTIFICATIONS;
  skips muted calendars
- New synced setting default_event_duration_minutes for new events

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:02:46 +02:00
Guido Schmit
701db45f8a fix: set solid dark green adaptive icon background to eliminate ring
Transparent background caused Pixel launcher to inject its own tinted
ring. Replace with #16703D (sampled from iOS icon background) so the
launcher shape-clips cleanly without any visible halo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 18:45:39 +02:00
Guido Schmit
9815866911 fix: add adaptive icon to remove launcher ring effect on Pixel devices
Without mipmap-anydpi-v26 XML, Pixel launcher wraps legacy PNGs in a
white ring template. Adding adaptive-icon XML with the iOS-matching
foreground PNG at correct per-density sizes (108dp canvas) eliminates
the nested-ring appearance.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 18:36:17 +02:00
Guido Schmit
c6056fb7bd fix: CircularProgressIndicator no longer clipped in auth buttons
Use Modifier.size(20.dp) instead of height(20.dp) so the spinner is
constrained on both axes and doesn't get cut off horizontally.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 18:28:19 +02:00
Guido Schmit
b172a8f055 fix: round dropdown menu corners via theme shapes
Material 3 menus use shapes.extraSmall (4dp default) which looked boxy;
bump to 14dp so all dropdown popups (view switcher, group switcher, ...)
get rounded corners matching the iOS look.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 21:39:03 +02:00
Guido Schmit
e7bed5c5a8 feat: day preview popup, smoother month scrolling, UI polish
- Long-press on a day now opens a preview popup (ported from the iOS
  DayContextPreviewView): all-day events as colored bars with tapered
  ends when they continue beyond the day, timed events as dot+time+title
  rows, plus quick actions (new event, week view, day view)
- Month scroll performance: stable item keys + contentType in the
  LazyColumn, remembered Instant.now()/theme colors, remembered callbacks
  so visible week rows stay skippable during recomposition
- Fix "+N" and "KW" labels overlapping the lowest event lane (row bottom
  padding 6dp -> 16dp, mirrors the iOS fix)
- Event detail: delete is now a quiet red text button instead of a
  full-width filled container
- Top bar: hairline divider so the bar/calendar boundary is visible
- FAB: explicit circular shape

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 20:37:49 +02:00
Guido Schmit
6654012cbb feat: reminders data layer (Android) — model, settings, repository
CalEvent gains `reminders` (parsed from the server); AppSettings/SettingsStore
gain `defaultReminderMinutes` (null = off) and it's sent via updateSettings;
createLocalEvent/updateLocalEvent/eventBody and CalendarViewModel.saveEvent
thread `reminders` through. UI (editor + settings picker) and the local
notification scheduling follow next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 16:31:32 +02:00
Guido Schmit
807db6a57b feat: non-emoji group icons (Material icons) for consistent look (Android)
Group icons are semantic keys rendered as native Material icons (GroupIcons)
in the picker, group list, top-bar switcher and banner — mirroring iOS/web —
instead of OS emoji. Legacy emoji values render as a fallback. decorateGroup
fallback no longer prepends a glyph (server display_title is authoritative).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 20:24:48 +02:00
Guido Schmit
644b532104 feat: hide individual member calendars in the group view (Android)
The calendar filter, in group mode, lists the group's members (+ the shared
group calendar) with toggles to hide each individually. Filtering is client-side
via CalendarViewModel.hiddenGroupKeys (gm:<id> / gc), reset on group switch;
members + colours loaded from the group detail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 18:10:11 +02:00
Guido Schmit
87fc8df146 feat: render server display_title for group events (consistent across clients)
CalEvent parses display_title; decorateGroup uses it (group icon + owner prefix
from the server) instead of the hardcoded glyph, with a fallback for older
servers. Raw title kept for editing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 17:54:23 +02:00
Guido Schmit
7a9bdba557 fix: Android Gruppen-Umschalter sichtbar + Menü überlappt Navigationsleiste nicht
Top-Bar-Gruppenumschalter ist jetzt eine tonale Pille (Akzentfarbe im
Gruppenmodus) statt eines flachen Icons – klar als Button erkennbar; das
Dropdown markiert die aktive Auswahl. Gruppen-Verwaltung: Tipp auf eine Gruppe
öffnet direkt deren Ansicht, Verwalten liegt aufs Zahnrad. Menü-Bottom-Sheet
respektiert jetzt das Navigationsleisten-Inset (+ Scroll-Fallback), sodass
Abmelden/Server wechseln nicht mehr hinter den Android-Buttons liegen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 23:08:42 +02:00
Guido Schmit
3352aa3be7 feat: Android Gruppen + kombinierte Ansicht direkt im Kalender
Neuer Gruppen-Verwaltungsscreen (Liste, Erstellen, Verwalten: Name, wählbares
Emoji-Icon, Mitglieder, server-definierte Mitgliederfarben, Löschen) über das
Menü. Top-Bar-Umschalter (Mein Kalender / Gruppen) + Akzent-Banner schalten den
Kalender in die kombinierte Gruppenansicht: Events laufen durch die normale
Cache-/Render-Pipeline (Monat/Woche/Tag/Termine), mit Namens-Präfix der
Besitzer und 👥 für Gruppentermine; im Gruppenmodus kein Sichtbarkeitsfilter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:36:26 +02:00
62 changed files with 4430 additions and 443 deletions

1
.gitignore vendored
View File

@@ -38,6 +38,7 @@ captures/
# Keystore files
*.jks
*.keystore
keystore.properties
# macOS
.DS_Store

80
FDROID.md Normal file
View File

@@ -0,0 +1,80 @@
# Publishing Calendarr on F-Droid
Two independent channels — do both in parallel:
- **Own repo (live today):** you build + sign the APK, generate a small catalog,
host it at a URL; users add that URL in the F-Droid app.
- **Official F-Droid (~weeks to be accepted, then automatic):** F-Droid builds +
signs from source and lists it in the catalog every F-Droid user already has.
The app is GPLv3 and has **no** non-free dependencies (no Play Services / Firebase),
so it is F-Droid-eligible. Store-listing metadata lives in `fastlane/metadata/…`.
> ⚠️ Signatures differ between the two channels: your own repo serves APKs signed
> with **your** keystore; the official build is signed with **F-Droid's** key. A
> user can't seamlessly update from one to the other (reinstall once). To unify
> later, set up *Reproducible Builds* so F-Droid ships your signature.
---
## A) Your own repo (fdroid.scarriffle.com)
Needs the `fdroidserver` tool + Android SDK build-tools (for `aapt`).
```bash
# 1. Install the tool (macOS)
brew install fdroidserver # or: pipx install fdroidserver
# 2. Build a SIGNED release APK (uses keystore.properties; F-Droid repos serve
# APKs, not AABs)
./gradlew assembleRelease
# → app/build/outputs/apk/release/app-release.apk
# 3. Create the repo (once). Generates config.yml + an index-signing key.
mkdir -p ~/calendarr-fdroid && cd ~/calendarr-fdroid
fdroid init
# 4. Drop the signed APK in and copy the store metadata
cp "/path/to/Calendarr Android/app/build/outputs/apk/release/app-release.apk" repo/
mkdir -p metadata
# copy the fastlane texts/icons so descriptions show in the client:
cp -r "/path/to/Calendarr Android/fastlane/metadata/android" metadata/com.scarriffle.calendarr
# 5. Generate/refresh the signed index
fdroid update -c --pretty
# 6. Upload the whole ~/calendarr-fdroid/repo directory to your webserver at:
# https://fdroid.scarriffle.com/fdroid/repo
```
Users then open F-Droid → Settings → Repositories → **+** →
`https://fdroid.scarriffle.com/fdroid/repo` → Calendarr appears and auto-updates.
**Each new release:** bump `versionCode`/`versionName` in `app/build.gradle.kts`,
`./gradlew assembleRelease`, copy the new APK into `repo/`, `fdroid update -c`,
re-upload. Keep the OLD apks in `repo/` too (users on older versions still update).
---
## B) Official F-Droid (fdroiddata)
Prereqs: the `SourceCode` repo in `fdroid/com.scarriffle.calendarr.yml` must be
**publicly clonable** (make the Gitea repo public, or mirror to GitHub/GitLab and
point `Repo:`/`SourceCode:` there), and a signed git tag `v1.1.0` must exist
(created for this release).
1. Fork <https://gitlab.com/fdroid/fdroiddata>.
2. Add our recipe as `metadata/com.scarriffle.calendarr.yml`
(copy from `fdroid/com.scarriffle.calendarr.yml` in this repo).
3. Optional local sanity check: `fdroid lint com.scarriffle.calendarr` and
`fdroid build -v -l com.scarriffle.calendarr`.
4. Open a Merge Request. F-Droid reviews, builds and publishes (this first review
is the slow part — weeks). Afterwards, `AutoUpdateMode` picks up each new
`v<versionName>` tag automatically.
---
## Screenshots (both channels)
Add PNG/JPG screenshots to
`fastlane/metadata/android/en-US/images/phoneScreenshots/` (and `de-DE/…`),
named `1.png`, `2.png`, … They then show on the F-Droid listing.

674
LICENSE Normal file
View File

@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

View File

@@ -1,3 +1,6 @@
import java.io.FileInputStream
import java.util.Properties
plugins {
id("com.android.application") version "8.10.0"
id("org.jetbrains.kotlin.android") version "1.9.20"
@@ -5,25 +8,47 @@ plugins {
id("com.google.dagger.hilt.android") version "2.49"
}
// Release signing is driven by a git-ignored keystore.properties in the repo
// root (see keystore.properties.example). Absent (e.g. CI / fresh clone) the
// release build simply stays unsigned — debug builds are unaffected.
val keystorePropsFile = rootProject.file("keystore.properties")
val keystoreProps = Properties().apply {
if (keystorePropsFile.exists()) FileInputStream(keystorePropsFile).use { load(it) }
}
android {
namespace = "com.scarriffle.calendarr"
compileSdk = 34
compileSdk = 35
defaultConfig {
applicationId = "com.scarriffle.calendarr"
minSdk = 24
targetSdk = 34
versionCode = 1
versionName = "0.1.0"
targetSdk = 35
versionCode = 2
versionName = "1.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { useSupportLibrary = true }
}
signingConfigs {
if (keystorePropsFile.exists()) {
create("release") {
storeFile = file(keystoreProps.getProperty("storeFile"))
storePassword = keystoreProps.getProperty("storePassword")
keyAlias = keystoreProps.getProperty("keyAlias")
keyPassword = keystoreProps.getProperty("keyPassword")
}
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
if (keystorePropsFile.exists()) {
signingConfig = signingConfigs.getByName("release")
}
}
}

View File

@@ -2,6 +2,8 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<application
android:name=".CalendarrApplication"
@@ -21,5 +23,9 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<receiver
android:name=".notifications.ReminderReceiver"
android:exported="false" />
</application>
</manifest>

View File

@@ -3,6 +3,7 @@ package com.scarriffle.calendarr
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.scarriffle.calendarr.ui.CalendarrRoot
import dagger.hilt.android.AndroidEntryPoint
@@ -13,6 +14,10 @@ class MainActivity : ComponentActivity() {
// Covers the window from the first frame (incl. warm-start), then hands
// off to the in-app branded splash which stays until data is loaded.
installSplashScreen()
// Draw edge-to-edge so system-bar insets reach every window, including
// ModalBottomSheets — otherwise navigationBarsPadding() inside the sheets
// resolves to 0 and the bottom menu rows hide behind the nav bar.
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContent {
CalendarrRoot()

View File

@@ -35,6 +35,13 @@ data class LoginResult(val token: String, val username: String, val isAdmin: Boo
data class TotpSetup(val secret: String, val qrUrl: String)
/** A single calendar's sync failure, surfaced alongside a (still-successful) events fetch.
* [calendarId] is null for account-wide failures (no single calendar is at fault). */
data class SyncError(val source: String, val name: String, val calendarId: Int?, val message: String)
/** Result of [CalendarRepository.fetchEvents]: the merged events plus any per-calendar sync failures. */
data class EventsResult(val events: List<CalEvent>, val errors: List<SyncError>)
/**
* Single entry point for all server interaction. Wraps [com.scarriffle.calendarr.data.remote.CalendarrApi],
* converts HTTP failures into [ApiException]s carrying the server's `detail`
@@ -111,24 +118,31 @@ class CalendarRepository @Inject constructor(
suspend fun getSettings(): AppSettings = guarded { api.getSettings() }
suspend fun updateSettings(s: AppSettings) = guarded {
api.updateSettings(
jsonBody(
"default_view" to s.defaultView,
"week_start_day" to s.weekStartDay,
"primary_color" to s.primaryColor,
"accent_color" to s.accentColor,
"today_color" to s.todayColor,
"dim_past_events" to s.dimPastEvents,
"text_contrast" to s.textContrast,
"line_contrast" to s.lineContrast,
"hour_height" to s.hourHeight,
"language" to s.language,
"month_divider_color" to s.monthDividerColor,
"month_label_color" to s.monthLabelColor,
"private_event_visibility" to s.privateEventVisibility,
)
).ensureSuccess()
/** Push only the values whose sync flag is on (partial update; the server
* leaves unsynced columns untouched), plus the account-wide flag map. */
suspend fun updateSettings(s: AppSettings, flags: Map<String, Boolean>) = guarded {
val body = mutableMapOf<String, Any?>()
fun addIf(key: String, value: Any?) { if (flags[key] == true) body[key] = value }
addIf("default_view", s.defaultView)
addIf("week_start_day", s.weekStartDay)
addIf("dim_past_events", s.dimPastEvents)
addIf("hour_height", s.hourHeight)
addIf("default_event_duration_minutes", s.defaultEventDurationMinutes)
// Explicit JSON null clears it (off); jsonBody drops Kotlin nulls.
addIf("default_reminder_minutes", s.defaultReminderMinutes ?: org.json.JSONObject.NULL)
addIf("primary_color", s.primaryColor)
addIf("accent_color", s.accentColor)
addIf("today_color", s.todayColor)
addIf("text_color", s.textColor)
addIf("bg_color", s.backgroundColor)
addIf("line_color", s.lineColor)
addIf("month_divider_color", s.monthDividerColor)
addIf("month_label_color", s.monthLabelColor)
addIf("cache_months", s.cacheMonths)
addIf("month_view_paged", s.monthViewPaged)
// The flag map is always account-wide; send it every push.
body["sync_flags"] = org.json.JSONObject(flags as Map<*, *>)
api.updateSettings(jsonBody(body)).ensureSuccess()
}
suspend fun getProfile(): UserProfile = guarded { api.getProfile() }
@@ -176,8 +190,17 @@ class CalendarRepository @Inject constructor(
suspend fun getLocalCalendars(): List<LocalCalendar> = guarded { api.getLocalCalendars() }
suspend fun addLocalCalendar(name: String, color: String) =
guarded { api.addLocalCalendar(jsonBody("name" to name, "color" to color)) }
suspend fun addLocalCalendar(
name: String, color: String,
isBirthday: Boolean = false, birthdayNotifyDaysBefore: Int? = null,
) = guarded {
api.addLocalCalendar(jsonBody(buildMap {
put("name", name)
put("color", color)
if (isBirthday) put("is_birthday", true)
if (birthdayNotifyDaysBefore != null) put("birthday_notify_days_before", birthdayNotifyDaysBefore)
}))
}
suspend fun deleteLocalCalendar(id: Int) = guarded { api.deleteLocalCalendar(id).ensureSuccess() }
@@ -212,9 +235,11 @@ class CalendarRepository @Inject constructor(
suspend fun deleteHomeAssistantAccount(id: Int) =
guarded { api.deleteHomeAssistantAccount(id).ensureSuccess() }
/** Change a local calendar's colour. */
/** Change a local calendar's colour. Uses the colour-only endpoint so share
* recipients can recolour their view (own per-user colour) without needing
* write access or being able to rename the calendar. */
suspend fun updateLocalCalendarColor(id: Int, color: String) = guarded {
api.updateLocalCalendar(id, jsonBody("color" to color)).ensureSuccess()
api.setLocalCalendarColor(id, jsonBody("color" to color)).ensureSuccess()
}
/** Change an iCal subscription's colour. */
@@ -244,12 +269,30 @@ class CalendarRepository @Inject constructor(
}
}
/** Toggle a calendar's server-side `reminders_enabled` flag (all sources). */
suspend fun setCalendarRemindersEnabled(source: String, calendarId: Int, enabled: Boolean) = guarded {
val body = jsonBody("reminders_enabled" to enabled)
when (source) {
"caldav" -> api.updateCalDAVCalendar(calendarId, body).ensureSuccess()
"local" -> api.updateLocalCalendar(calendarId, body).ensureSuccess()
"ical" -> api.updateICalSubscription(calendarId, body).ensureSuccess()
"google" -> api.updateGoogleCalendar(calendarId, body).ensureSuccess()
"homeassistant" -> api.updateHACalendar(calendarId, body).ensureSuccess()
else -> Unit
}
}
/** Resolve all calendars the user can create events in. */
suspend fun getWritableCalendars(): List<WritableCalendar> = withContext(Dispatchers.IO) {
val result = mutableListOf<WritableCalendar>()
runCatching { api.getLocalCalendars() }.getOrDefault(emptyList()).forEach { cal ->
result += WritableCalendar("local-${cal.id}", cal.name, cal.color, "local", cal.id)
}
runCatching { api.getLocalCalendars() }.getOrDefault(emptyList())
// Exclude read-only shared calendars — offering them in the event
// editor only leads to a 403 on save. Own + read_write (incl. group)
// calendars stay.
.filter { it.owned || it.permission == "read_write" }
.forEach { cal ->
result += WritableCalendar("local-${cal.id}", cal.name, cal.color, "local", cal.id)
}
runCatching { api.getCalDAVAccounts() }.getOrDefault(emptyList())
.filter { it.enabled }
.forEach { acc ->
@@ -281,35 +324,50 @@ class CalendarRepository @Inject constructor(
// ---- Events ----
suspend fun fetchEvents(start: Instant, end: Instant): List<CalEvent> = withContext(Dispatchers.IO) {
suspend fun fetchEvents(start: Instant, end: Instant): EventsResult = withContext(Dispatchers.IO) {
val resp = api.fetchEvents(Dates.isoUtc(start), Dates.isoUtc(end))
resp.ensureSuccess()
val raw = resp.body()?.string() ?: return@withContext emptyList()
val raw = resp.body()?.string() ?: return@withContext EventsResult(emptyList(), emptyList())
val root = JSONObject(raw)
val arr = root.optJSONArray("events") ?: return@withContext emptyList()
buildList {
for (i in 0 until arr.length()) {
val arr = root.optJSONArray("events")
val events = buildList {
if (arr != null) for (i in 0 until arr.length()) {
val obj = arr.optJSONObject(i) ?: continue
CalEvent.fromJson(obj)?.let { add(it) }
}
}
val errArr = root.optJSONArray("errors")
val errors = buildList {
if (errArr != null) for (i in 0 until errArr.length()) {
val obj = errArr.optJSONObject(i) ?: continue
add(SyncError(
source = obj.optString("source"),
name = obj.optString("name"),
calendarId = if (obj.has("calendar_id")) obj.optInt("calendar_id") else null,
message = obj.optString("message"),
))
}
}
EventsResult(events, errors)
}
suspend fun createLocalEvent(
calendarId: Int, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String, color: String?,
isPrivate: Boolean = false,
isPrivate: Boolean = false, reminders: List<Int>? = null,
rrule: String? = null, birthYear: Int? = null, externalUid: String? = null,
) = guarded {
api.createLocalEvent(eventBody(calendarId, title, start, end, isAllDay, location, description, color, isPrivate))
api.createLocalEvent(eventBody(calendarId, title, start, end, isAllDay, location, description, color, isPrivate, reminders, rrule, birthYear, externalUid))
.ensureSuccess()
}
suspend fun updateLocalEvent(
uid: String, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String, color: String?,
isPrivate: Boolean = false,
isPrivate: Boolean = false, reminders: List<Int>? = null,
rrule: String? = null, birthYear: Int? = null, externalUid: String? = null,
) = guarded {
api.updateLocalEvent(uid, eventBody(null, title, start, end, isAllDay, location, description, color, isPrivate))
api.updateLocalEvent(uid, eventBody(null, title, start, end, isAllDay, location, description, color, isPrivate, reminders, rrule, birthYear, externalUid))
.ensureSuccess()
}
@@ -446,8 +504,13 @@ class CalendarRepository @Inject constructor(
// ---- Profile & targeted settings ----
suspend fun updateProfile(displayName: String?, username: String?, email: String?): String? = guarded {
val resp = api.updateProfile(jsonBody("display_name" to displayName, "username" to username, "email" to email))
suspend fun updateProfile(displayName: String?, username: String?, email: String?, directoryHidden: Boolean? = null): String? = guarded {
val resp = api.updateProfile(jsonBody(
"display_name" to displayName,
"username" to username,
"email" to email,
"directory_hidden" to directoryHidden,
))
resp.ensureSuccess()
runCatching { JSONObject(resp.body()?.string() ?: "{}").optString("access_token").ifBlank { null } }.getOrNull()
}
@@ -555,7 +618,8 @@ class CalendarRepository @Inject constructor(
private fun eventBody(
calendarId: Int?, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String, color: String?,
isPrivate: Boolean = false,
isPrivate: Boolean = false, reminders: List<Int>? = null,
rrule: String? = null, birthYear: Int? = null, externalUid: String? = null,
) = jsonBody(
buildMap {
calendarId?.let { put("calendar_id", it) }
@@ -567,6 +631,20 @@ class CalendarRepository @Inject constructor(
put("description", description)
if (!color.isNullOrBlank()) put("color", color)
put("private", isPrivate)
if (reminders != null) put("reminders", org.json.JSONArray(reminders))
if (!rrule.isNullOrBlank()) put("rrule", rrule)
if (birthYear != null) put("birth_year", birthYear)
if (!externalUid.isNullOrBlank()) put("external_uid", externalUid)
}
)
// ---- Birthdays (Contacts import) ----
suspend fun getBirthdayEntries(calendarId: Int): List<com.scarriffle.calendarr.domain.model.BirthdayEntry> =
guarded { api.getBirthdays(calendarId) }
suspend fun reportBirthdaySync(deviceId: String, deviceName: String, count: Int) = guarded {
api.reportBirthdaySync(jsonBody("device_id" to deviceId, "device_name" to deviceName, "count" to count))
.ensureSuccess()
}
}

View File

@@ -0,0 +1,66 @@
package com.scarriffle.calendarr.data
import android.content.Context
import android.provider.ContactsContract
/** One birthday read from the address book. */
data class ContactBirthday(
val contactId: String,
val name: String,
val month: Int,
val day: Int,
val year: Int?,
)
/** Reads birthdays from the system Contacts (requires READ_CONTACTS). */
object ContactsReader {
fun readBirthdays(context: Context): List<ContactBirthday> {
val out = mutableListOf<ContactBirthday>()
val projection = arrayOf(
ContactsContract.Data.CONTACT_ID,
ContactsContract.CommonDataKinds.Event.START_DATE,
ContactsContract.Data.DISPLAY_NAME,
)
val selection = "${ContactsContract.Data.MIMETYPE} = ? AND ${ContactsContract.CommonDataKinds.Event.TYPE} = ?"
val args = arrayOf(
ContactsContract.CommonDataKinds.Event.CONTENT_ITEM_TYPE,
ContactsContract.CommonDataKinds.Event.TYPE_BIRTHDAY.toString(),
)
context.contentResolver.query(ContactsContract.Data.CONTENT_URI, projection, selection, args, null)
?.use { c ->
val idIdx = c.getColumnIndexOrThrow(ContactsContract.Data.CONTACT_ID)
val dateIdx = c.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Event.START_DATE)
val nameIdx = c.getColumnIndexOrThrow(ContactsContract.Data.DISPLAY_NAME)
while (c.moveToNext()) {
val raw = c.getString(dateIdx) ?: continue
val name = c.getString(nameIdx)?.takeIf { it.isNotBlank() } ?: continue
val id = c.getString(idIdx) ?: continue
val ymd = parseDate(raw) ?: continue
out.add(ContactBirthday(id, name, ymd.second, ymd.third, ymd.first))
}
}
// A contact could carry more than one birthday row — keep the first.
return out.distinctBy { it.contactId }
}
/** Returns (year?, month, day). Handles "yyyy-MM-dd", "--MM-dd" (no year),
* and "yyyyMMdd". */
private fun parseDate(raw: String): Triple<Int?, Int, Int>? = try {
val s = raw.trim()
when {
s.startsWith("--") -> {
val p = s.removePrefix("--").split("-")
Triple(null, p[0].toInt(), p[1].toInt())
}
s.contains("-") -> {
val p = s.split("-")
if (p.size == 3) Triple(p[0].toIntOrNull()?.takeIf { it > 0 }, p[1].toInt(), p[2].toInt()) else null
}
s.length == 8 -> Triple(s.substring(0, 4).toInt().takeIf { it > 0 }, s.substring(4, 6).toInt(), s.substring(6, 8).toInt())
else -> null
}
} catch (e: Exception) {
null
}
}

View File

@@ -34,6 +34,13 @@ class SettingsStore @Inject constructor(
language = prefs.getString(K_LANGUAGE, null) ?: "de",
monthDividerColor = prefs.getString(K_DIVIDER, null) ?: "#7090c0",
monthLabelColor = prefs.getString(K_LABEL, null) ?: "#7090c0",
textColor = prefs.getString(K_TEXT_COLOR, null) ?: "#FFFFFF",
backgroundColor = prefs.getString(K_BG_COLOR, null) ?: "#000000",
lineColor = prefs.getString(K_LINE_COLOR, null) ?: "#3A3A52",
defaultReminderMinutes = prefs.getInt(K_DEFAULT_REMINDER, -1).takeIf { it >= 0 },
defaultEventDurationMinutes = prefs.getInt(K_DEFAULT_DURATION, 60),
cacheMonths = prefs.getInt(K_CACHE_MONTHS, 3),
monthViewPaged = prefs.getBoolean(K_MONTH_PAGED, false),
)
fun saveSettings(s: AppSettings) {
@@ -50,14 +57,127 @@ class SettingsStore @Inject constructor(
.putString(K_LANGUAGE, s.language)
.putString(K_DIVIDER, s.monthDividerColor)
.putString(K_LABEL, s.monthLabelColor)
.putString(K_TEXT_COLOR, s.textColor)
.putString(K_BG_COLOR, s.backgroundColor)
.putString(K_LINE_COLOR, s.lineColor)
.putInt(K_DEFAULT_REMINDER, s.defaultReminderMinutes ?: -1)
.putInt(K_DEFAULT_DURATION, s.defaultEventDurationMinutes)
.putInt(K_CACHE_MONTHS, s.cacheMonths)
.putBoolean(K_MONTH_PAGED, s.monthViewPaged)
.apply()
}
// --- Per-setting cross-device sync flags (account-wide; server is authority) ---
/** Keys this Android client can sync. Excludes language (device "system" has
* no server value) and text/line contrast (iOS-style opacity, device-local). */
val syncableKeys: List<String> = listOf(
"default_view", "week_start_day", "dim_past_events", "hour_height",
"default_event_duration_minutes", "default_reminder_minutes",
"primary_color", "accent_color", "today_color",
"text_color", "bg_color", "line_color",
"month_divider_color", "month_label_color",
"cache_months", "month_view_paged",
)
private val defaultSync: Map<String, Boolean> = syncableKeys.associateWith { key ->
key != "cache_months" && key != "month_view_paged"
}
fun loadSyncFlags(): Map<String, Boolean> {
val result = defaultSync.toMutableMap()
val raw = prefs.getString(K_SYNC_FLAGS, null)
if (!raw.isNullOrBlank()) {
runCatching { org.json.JSONObject(raw) }.getOrNull()?.let { obj ->
for (key in syncableKeys) if (obj.has(key)) result[key] = obj.getBoolean(key)
}
}
return result
}
private fun writeSyncFlags(f: Map<String, Boolean>) {
prefs.edit().putString(K_SYNC_FLAGS, org.json.JSONObject(f as Map<*, *>).toString()).apply()
}
fun storeServerFlags(server: Map<String, Boolean>?) {
if (server == null) return
val f = loadSyncFlags().toMutableMap()
for (key in syncableKeys) server[key]?.let { f[key] = it }
writeSyncFlags(f)
}
fun setSyncFlag(key: String, on: Boolean) {
val f = loadSyncFlags().toMutableMap(); f[key] = on; writeSyncFlags(f)
}
fun setAllSyncFlags(on: Boolean) {
val f = loadSyncFlags().toMutableMap(); for (key in syncableKeys) f[key] = on; writeSyncFlags(f)
}
/** Merge a server snapshot with local values honouring the (refreshed) flags:
* synced keys take the server value, others keep the local value. Persists
* the result and returns the effective settings. */
fun applyServerPull(server: AppSettings): AppSettings {
storeServerFlags(server.syncFlags)
val flags = loadSyncFlags()
val local = loadSettings()
fun on(key: String) = flags[key] == true
val eff = local.copy(
defaultView = if (on("default_view")) server.defaultView else local.defaultView,
weekStartDay = if (on("week_start_day")) server.weekStartDay else local.weekStartDay,
dimPastEvents = if (on("dim_past_events")) server.dimPastEvents else local.dimPastEvents,
hourHeight = if (on("hour_height")) server.hourHeight else local.hourHeight,
defaultEventDurationMinutes = if (on("default_event_duration_minutes")) server.defaultEventDurationMinutes else local.defaultEventDurationMinutes,
defaultReminderMinutes = if (on("default_reminder_minutes")) server.defaultReminderMinutes else local.defaultReminderMinutes,
primaryColor = if (on("primary_color")) server.primaryColor else local.primaryColor,
accentColor = if (on("accent_color")) server.accentColor else local.accentColor,
todayColor = if (on("today_color")) server.todayColor else local.todayColor,
textColor = if (on("text_color")) server.textColor else local.textColor,
backgroundColor = if (on("bg_color")) server.backgroundColor else local.backgroundColor,
lineColor = if (on("line_color")) server.lineColor else local.lineColor,
monthDividerColor = if (on("month_divider_color")) server.monthDividerColor else local.monthDividerColor,
monthLabelColor = if (on("month_label_color")) server.monthLabelColor else local.monthLabelColor,
cacheMonths = if (on("cache_months")) server.cacheMonths else local.cacheMonths,
monthViewPaged = if (on("month_view_paged")) server.monthViewPaged else local.monthViewPaged,
// Device-local (never synced): language + contrast levels.
language = local.language,
textContrast = local.textContrast,
lineContrast = local.lineContrast,
)
saveSettings(eff)
return eff
}
/** Device-local cache range in months around today (default 3). */
var cacheMonths: Int
get() = prefs.getInt(K_CACHE_MONTHS, 3)
set(value) = prefs.edit().putInt(K_CACHE_MONTHS, value).apply()
/** Device-local: month view as horizontal paged (swipe) instead of the
* continuous vertical scroll feed (default false = scroll). */
var monthViewPaged: Boolean
get() = prefs.getBoolean(K_MONTH_PAGED, false)
set(value) = prefs.edit().putBoolean(K_MONTH_PAGED, value).apply()
/** Device-local: hide the top-bar menu button (drawer opens via edge-swipe). */
var hideMenuButton: Boolean
get() = prefs.getBoolean(K_HIDE_MENU, false)
set(value) = prefs.edit().putBoolean(K_HIDE_MENU, value).apply()
/** Whether this device mirrors its Contacts birthdays into the birthday calendar. */
var birthdaysSyncEnabled: Boolean
get() = prefs.getBoolean(K_BDAY_ENABLED, false)
set(value) = prefs.edit().putBoolean(K_BDAY_ENABLED, value).apply()
/** Stable per-install id used to scope this device's contact-birthday rows. */
val birthdaysDeviceId: String
get() {
prefs.getString(K_BDAY_DEVICE, null)?.let { return it }
val id = java.util.UUID.randomUUID().toString()
prefs.edit().putString(K_BDAY_DEVICE, id).apply()
return id
}
// --- Hidden calendars ("source:id") ---
var hiddenCalendarKeys: Set<String>
@@ -70,6 +190,27 @@ class SettingsStore @Inject constructor(
get() = prefs.getStringSet(K_BANISHED, emptySet())?.toSet() ?: emptySet()
set(value) = prefs.edit().putStringSet(K_BANISHED, value).apply()
// --- Reminder-disabled calendars ("source:id") ---
// Mirrors the server's per-calendar `reminders_enabled` flag so the
// notification scheduler can skip muted calendars without deleting any
// event reminders.
var reminderDisabledCalendarKeys: Set<String>
get() = prefs.getStringSet(K_REMINDER_DISABLED, emptySet())?.toSet() ?: emptySet()
set(value) = prefs.edit().putStringSet(K_REMINDER_DISABLED, value).apply()
// --- Calendar display order ("source:id" keys), device-local (mirrors web cal_order) ---
var calendarOrder: List<String>
get() {
val raw = prefs.getString(K_CAL_ORDER, null) ?: return emptyList()
return runCatching {
val arr = org.json.JSONArray(raw)
(0 until arr.length()).map { arr.getString(it) }
}.getOrDefault(emptyList())
}
set(value) = prefs.edit().putString(K_CAL_ORDER, org.json.JSONArray(value).toString()).apply()
private companion object {
const val K_DEFAULT_VIEW = "default_view"
const val K_WEEK_START = "week_start_day"
@@ -83,8 +224,20 @@ class SettingsStore @Inject constructor(
const val K_LANGUAGE = "language"
const val K_DIVIDER = "month_divider_color"
const val K_LABEL = "month_label_color"
const val K_TEXT_COLOR = "text_color"
const val K_BG_COLOR = "bg_color"
const val K_LINE_COLOR = "line_color"
const val K_SYNC_FLAGS = "sync_flags"
const val K_DEFAULT_REMINDER = "default_reminder_minutes"
const val K_DEFAULT_DURATION = "default_event_duration_minutes"
const val K_CACHE_MONTHS = "cache_months"
const val K_MONTH_PAGED = "month_view_paged"
const val K_HIDE_MENU = "hide_menu_button"
const val K_BDAY_ENABLED = "birthdays_sync_enabled"
const val K_BDAY_DEVICE = "birthdays_device_id"
const val K_HIDDEN = "hidden_calendar_keys"
const val K_BANISHED = "banished_calendar_keys"
const val K_REMINDER_DISABLED = "reminder_disabled_calendar_keys"
const val K_CAL_ORDER = "calendar_order"
}
}

View File

@@ -155,6 +155,11 @@ interface CalendarrApi {
@PUT("api/local/calendars/{id}")
suspend fun updateLocalCalendar(@Path("id") id: Int, @Body body: RequestBody): Response<ResponseBody>
// Colour-only update that works for owners AND share recipients (recipients
// get their own per-user colour; owners change the calendar's colour).
@PUT("api/local/calendars/{id}/color")
suspend fun setLocalCalendarColor(@Path("id") id: Int, @Body body: RequestBody): Response<ResponseBody>
@DELETE("api/local/calendars/{id}")
suspend fun deleteLocalCalendar(@Path("id") id: Int): Response<ResponseBody>
@@ -217,6 +222,12 @@ interface CalendarrApi {
@DELETE("api/local/events/{uid}")
suspend fun deleteLocalEvent(@Path("uid") uid: String): Response<ResponseBody>
@GET("api/local/calendars/{id}/birthdays")
suspend fun getBirthdays(@Path("id") id: Int): List<com.scarriffle.calendarr.domain.model.BirthdayEntry>
@POST("api/birthdays/sync-report")
suspend fun reportBirthdaySync(@Body body: RequestBody): Response<ResponseBody>
@POST("api/caldav/events")
suspend fun createCalDAVEvent(@Body body: RequestBody): Response<ResponseBody>

View File

@@ -21,6 +21,7 @@ data class CalDAVCalendar(
val color: String? = null,
val enabled: Boolean = true,
@Json(name = "sidebar_hidden") val sidebarHidden: Boolean = false,
@Json(name = "reminders_enabled") val remindersEnabled: Boolean = true,
)
@JsonClass(generateAdapter = false)
@@ -34,6 +35,9 @@ data class LocalCalendar(
@Json(name = "shared_by") val sharedBy: String? = null,
val permission: String? = null,
val group: Boolean = false,
@Json(name = "reminders_enabled") val remindersEnabled: Boolean = true,
@Json(name = "is_birthday") val isBirthday: Boolean = false,
@Json(name = "birthday_notify_days_before") val birthdayNotifyDaysBefore: Int? = null,
)
@JsonClass(generateAdapter = false)
@@ -45,6 +49,7 @@ data class ICalSubscription(
val enabled: Boolean = true,
@Json(name = "refresh_minutes") val refreshMinutes: Int = 60,
@Json(name = "last_fetched") val lastFetched: String? = null,
@Json(name = "reminders_enabled") val remindersEnabled: Boolean = true,
)
@JsonClass(generateAdapter = false)
@@ -61,6 +66,7 @@ data class GoogleCalendar(
val color: String? = null,
val enabled: Boolean = true,
@Json(name = "sidebar_hidden") val sidebarHidden: Boolean = false,
@Json(name = "reminders_enabled") val remindersEnabled: Boolean = true,
)
@JsonClass(generateAdapter = false)
@@ -80,6 +86,7 @@ data class HACalendar(
val color: String? = null,
val enabled: Boolean = true,
@Json(name = "sidebar_hidden") val sidebarHidden: Boolean = false,
@Json(name = "reminders_enabled") val remindersEnabled: Boolean = true,
)
@JsonClass(generateAdapter = false)
@@ -91,6 +98,19 @@ data class UserProfile(
@Json(name = "is_admin") val isAdmin: Boolean = false,
@Json(name = "has_avatar") val hasAvatar: Boolean = false,
@Json(name = "totp_enabled") val totpEnabled: Boolean = false,
@Json(name = "directory_hidden") val directoryHidden: Boolean = false,
)
/** One stored birthday (GET /api/local/calendars/{id}/birthdays). Contact-sourced
* rows carry an external_uid ("contact:<deviceId>:<contactId>"); manual ones null. */
@JsonClass(generateAdapter = false)
data class BirthdayEntry(
val uid: String,
@Json(name = "external_uid") val externalUid: String? = null,
val title: String = "",
val month: Int? = null,
val day: Int? = null,
@Json(name = "birth_year") val birthYear: Int? = null,
)
/** A calendar the user can create events in (resolved from all writable sources). */

View File

@@ -22,9 +22,23 @@ data class AppSettings(
@Json(name = "language") val language: String = "de",
@Json(name = "month_divider_color") val monthDividerColor: String = "#7090c0",
@Json(name = "month_label_color") val monthLabelColor: String = "#7090c0",
// Text / background / line colours drive the Material theme (onBackground,
// background, outline) so the whole app follows them, matching web/iOS.
@Json(name = "text_color") val textColor: String = "#FFFFFF",
@Json(name = "bg_color") val backgroundColor: String = "#000000",
@Json(name = "line_color") val lineColor: String = "#3A3A52",
// How this user's private events appear to other group members: 'hidden' | 'busy'.
@Json(name = "private_event_visibility") val privateEventVisibility: String = "busy",
@Json(name = "group_visible_calendar_id") val groupVisibleCalendarId: Int? = null,
// Minutes-before-start applied to all events client-side; null = off.
@Json(name = "default_reminder_minutes") val defaultReminderMinutes: Int? = null,
// Duration (minutes) applied to a newly created event's end time.
@Json(name = "default_event_duration_minutes") val defaultEventDurationMinutes: Int = 60,
// Preload range in months (device-local by default) and month-view paging.
@Json(name = "cache_months") val cacheMonths: Int = 3,
@Json(name = "month_view_paged") val monthViewPaged: Boolean = false,
// Account-wide per-setting sync flags, fully resolved by the server.
@Json(name = "sync_flags") val syncFlags: Map<String, Boolean>? = null,
) {
val weekStartsOnMonday: Boolean get() = weekStartDay != "sunday"
}

View File

@@ -31,7 +31,23 @@ data class CalEvent(
val owner: EventPerson? = null,
val isGroupEvent: Boolean = false,
val displayColor: String? = null,
// Server-decorated title for the group combined view (group icon / owner
// prefix); rendered in group mode while `title` stays raw for editing.
val displayTitle: String? = null,
// Reminder offsets in minutes-before-start (0 = at start). Local events only.
val reminders: List<Int> = emptyList(),
// True for events from a calendar shared with the user read-only.
val readOnly: Boolean = false,
// True for events from a birthday calendar — clients show a cake icon and
// the server bakes the age into `displayTitle`.
val isBirthday: Boolean = false,
) {
/**
* Title to render: the server-decorated one (birthday age, group prefix)
* wins over the raw title, which is kept for editing.
*/
val renderTitle: String
get() = displayTitle?.takeIf { it.isNotBlank() } ?: title
/**
* Group view supplies a server-resolved colour (display_color); otherwise
* per-event override colour, then the calendar's colour, then a stable
@@ -117,6 +133,12 @@ data class CalEvent(
owner = personFrom(json, "owner"),
isGroupEvent = json.optBoolean("is_group_event", false),
displayColor = json.strOrNull("display_color"),
displayTitle = json.strOrNull("display_title"),
reminders = json.optJSONArray("reminders")?.let { arr ->
(0 until arr.length()).mapNotNull { (arr.opt(it) as? Number)?.toInt() }
} ?: emptyList(),
readOnly = json.optBoolean("read_only", false),
isBirthday = json.optBoolean("is_birthday", false),
)
}
}

View File

@@ -0,0 +1,29 @@
package com.scarriffle.calendarr.domain.model
/**
* Reminder offsets are stored as minutes-before-start integers (0 = at start).
* A few quick presets are offered; anything else is entered as a custom
* number + unit. Mirrors the iOS `ReminderOptions`.
*/
object ReminderOptions {
/** Quick presets: at start, 30 min, 1 day. */
val presets = listOf(0, 30, 1440)
/** Default for a freshly-switched custom row (deliberately not a preset). */
const val customDefault = 120
enum class Unit(val mult: Int, val labelKey: String) {
MINUTES(1, "event.reminder_unit.minutes"),
HOURS(60, "event.reminder_unit.hours"),
DAYS(1440, "event.reminder_unit.days"),
WEEKS(10080, "event.reminder_unit.weeks"),
}
/** Split a minutes value into the largest exact {value, unit} for the custom picker. */
fun split(minutes: Int): Pair<Int, Unit> {
for (u in Unit.values().reversed()) {
if (minutes > 0 && minutes % u.mult == 0) return (minutes / u.mult) to u
}
return maxOf(1, minutes) to Unit.MINUTES
}
}

View File

@@ -0,0 +1,111 @@
package com.scarriffle.calendarr.notifications
import android.app.AlarmManager
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import com.scarriffle.calendarr.domain.model.CalEvent
import com.scarriffle.calendarr.ui.calendar.calendarKey
/**
* Schedules OS reminder notifications for upcoming events via AlarmManager.
* Per-event reminders take precedence; otherwise the user's default reminder
* applies. Calendars the user muted (`disabledKeys`) are skipped — their
* reminders are kept on the events, just never fired. Mirrors the iOS
* `NotificationScheduler`.
*/
object NotificationScheduler {
const val CHANNEL_ID = "calendarr_reminders"
const val EXTRA_TITLE = "title"
const val EXTRA_BODY = "body"
const val EXTRA_ID = "id"
private const val PREFS = "calendarr_reminders_sched"
private const val KEY_COUNT = "scheduled_count"
private const val MAX = 50 // keep the alarm count bounded
fun ensureChannel(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val mgr = context.getSystemService(NotificationManager::class.java)
if (mgr.getNotificationChannel(CHANNEL_ID) == null) {
mgr.createNotificationChannel(
NotificationChannel(CHANNEL_ID, "Reminders", NotificationManager.IMPORTANCE_HIGH)
)
}
}
}
private data class Pending(val fire: Long, val title: String, val body: String)
fun reschedule(
context: Context,
events: List<CalEvent>,
disabledKeys: Set<String>,
defaultMinutes: Int,
) {
ensureChannel(context)
val am = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val now = System.currentTimeMillis()
val pending = mutableListOf<Pending>()
for (ev in events) {
if (disabledKeys.contains(calendarKey(ev.source, ev.calendarId))) continue
val offsets = if (ev.reminders.isEmpty()) {
if (defaultMinutes >= 0) listOf(defaultMinutes) else emptyList()
} else ev.reminders
for (m in offsets) {
val fire = ev.startDate.toEpochMilli() - m * 60_000L
if (fire > now) {
val rel = relativeText(m)
val body = if (ev.location.isNotBlank()) "$rel · ${ev.location}" else rel
pending.add(Pending(fire, ev.title, body))
}
}
}
pending.sortBy { it.fire }
val limited = pending.take(MAX)
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val lastCount = prefs.getInt(KEY_COUNT, 0)
// Cancel every alarm from the previous run (extras are ignored when
// matching a PendingIntent, so a bare intent with the same code cancels).
for (i in 0 until maxOf(lastCount, limited.size)) {
am.cancel(intentFor(context, i, null))
}
limited.forEachIndexed { i, p ->
val pi = intentFor(context, i, p)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, p.fire, pi)
} else {
am.set(AlarmManager.RTC_WAKEUP, p.fire, pi)
}
}
prefs.edit().putInt(KEY_COUNT, limited.size).apply()
}
private fun relativeText(minutes: Int): String = when {
minutes < 60 -> "in $minutes Min."
minutes == 60 -> "in 1 Std."
minutes < 1440 -> "in ${minutes / 60} Std."
minutes == 1440 -> "morgen"
else -> "in ${minutes / 1440} Tagen"
}
private fun intentFor(context: Context, code: Int, p: Pending?): PendingIntent {
val intent = Intent(context, ReminderReceiver::class.java).apply {
// Distinct action per code so PendingIntents don't collapse together.
action = "com.scarriffle.calendarr.REMINDER_$code"
if (p != null) {
putExtra(EXTRA_ID, code)
putExtra(EXTRA_TITLE, p.title)
putExtra(EXTRA_BODY, p.body)
}
}
var flags = PendingIntent.FLAG_UPDATE_CURRENT
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) flags = flags or PendingIntent.FLAG_IMMUTABLE
return PendingIntent.getBroadcast(context, code, intent, flags)
}
}

View File

@@ -0,0 +1,33 @@
package com.scarriffle.calendarr.notifications
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.scarriffle.calendarr.R
/** Posts the reminder notification when an AlarmManager alarm fires. */
class ReminderReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
NotificationScheduler.ensureChannel(context)
val title = intent.getStringExtra(NotificationScheduler.EXTRA_TITLE) ?: return
val body = intent.getStringExtra(NotificationScheduler.EXTRA_BODY) ?: ""
val id = intent.getIntExtra(NotificationScheduler.EXTRA_ID, 0)
val notification = NotificationCompat.Builder(context, NotificationScheduler.CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(title)
.setContentText(body)
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.build()
// notify() is a no-op (and may throw on some OEMs) without the runtime
// POST_NOTIFICATIONS permission; ignore that case.
try {
NotificationManagerCompat.from(context).notify(id, notification)
} catch (_: SecurityException) {
}
}
}

View File

@@ -1,6 +1,8 @@
package com.scarriffle.calendarr.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
@@ -48,19 +50,29 @@ fun CalendarrRoot(vm: MainViewModel = hiltViewModel()) {
}
when (route) {
AppRoute.SETUP -> ServerSetupScreen(onConfigured = vm::onServerConfigured)
AppRoute.LOGIN -> LoginScreen(
serverUrl = vm.serverUrl,
onLoggedIn = vm::onLoggedIn,
onBack = vm::switchServer,
)
AppRoute.MAIN -> CalendarScreen(
vm = calendarVm!!,
onLogout = vm::logout,
onSwitchServer = vm::switchServer,
onSettingsChanged = vm::applyLocalSettings,
onSettingsSynced = vm::refreshSettings,
)
// Auth screens draw full-screen; inset them from the system
// bars (edge-to-edge) so their content isn't under status/nav.
AppRoute.SETUP -> Box(Modifier.systemBarsPadding()) {
ServerSetupScreen(onConfigured = vm::onServerConfigured)
}
AppRoute.LOGIN -> Box(Modifier.systemBarsPadding()) {
LoginScreen(
serverUrl = vm.serverUrl,
onLoggedIn = vm::onLoggedIn,
onBack = vm::switchServer,
)
}
AppRoute.MAIN -> calendarVm?.let { cvm ->
CalendarScreen(
vm = cvm,
onLogout = vm::logout,
onSwitchServer = vm::switchServer,
onSettingsChanged = vm::applyLocalSettings,
onSettingsSynced = vm::refreshSettings,
username = vm.username,
serverUrl = vm.serverUrl,
)
} ?: SplashScreen()
}
}
}

View File

@@ -38,6 +38,9 @@ object L10n {
"cal.no_events_title" to "Keine Termine",
"cal.no_events_body" to "In den nächsten 90 Tagen sind keine Termine vorhanden.",
"cal.loading_more" to "Lade weitere Wochen…", "cal.new_event" to "Neues Ereignis",
"cal.show_in_day_view" to "In Tagesansicht öffnen",
"cal.show_in_week_view" to "In Wochenansicht öffnen",
"cal.no_events_day" to "Keine Termine an diesem Tag",
"menu.section.settings" to "Einstellungen", "menu.profile" to "Profil",
"menu.appearance" to "Darstellung", "menu.accounts" to "Konten & Kalender",
"menu.server" to "Server", "menu.logout" to "Abmelden", "menu.admin" to "Admin",
@@ -52,6 +55,25 @@ object L10n {
"settings.colors" to "Farben", "settings.color.primary" to "Primärfarbe",
"settings.color.accent" to "Akzentfarbe", "settings.color.today" to "Heutige-Tag-Farbe",
"settings.color.divider" to "Monatswechsel-Linie", "settings.color.label" to "Monatskürzel",
"settings.color.text" to "Schriftfarbe", "settings.color.background" to "Hintergrundfarbe",
"settings.color.line" to "Linienfarbe",
"settings.sync_all" to "Alle synchronisieren",
"settings.sync_all.desc" to "Diese Einstellungen zwischen deinen Geräten teilen",
"settings.sync_this" to "Zwischen Geräten synchronisieren",
"settings.reset" to "Zurücksetzen",
"settings.appearance" to "Ansicht",
"settings.device" to "Nur auf diesem Gerät",
"settings.more" to "Mehr",
"settings.hide_menu_button" to "Menü-Button ausblenden",
"settings.month_mode" to "Monatswechsel",
"settings.month_mode.scroll" to "Fortlaufend scrollen",
"settings.month_mode.paged" to "Seitenweise blättern",
"settings.device.footer" to "Diese Einstellungen gelten nur auf diesem Gerät und werden nicht synchronisiert.",
"settings.directory_hidden" to "Profil verbergen",
"settings.directory_hidden.desc" to "Nicht in der Teilen-/Gruppen-Auswahl anderer Nutzer erscheinen.",
"settings.defaultreminder" to "Standard-Erinnerung",
"reminder.off" to "Aus", "reminder.at_start" to "Zur Startzeit",
"reminder.1d" to "1 Tag vorher", "reminder.1w" to "1 Woche vorher",
"settings.textcontrast" to "Schriftkontrast", "settings.linecontrast" to "Linienkontrast",
"settings.contrast.dark" to "Dunkel", "settings.contrast.medium" to "Mittel",
"settings.contrast.bright" to "Hell", "settings.contrast.max" to "Maximum",
@@ -60,6 +82,7 @@ object L10n {
"settings.calview" to "Kalenderansicht", "settings.defaultview" to "Standardansicht",
"settings.firstweekday" to "Erster Wochentag", "settings.monday" to "Montag",
"settings.sunday" to "Sonntag", "settings.dimpast" to "Vergangene Termine ausgrauen",
"settings.month_paged" to "Monatsansicht seitenweise wischen",
"settings.hourheight" to "Stundenhöhe",
"settings.hourheight.compact" to "Kompakt", "settings.hourheight.normal" to "Normal",
"settings.hourheight.comfort" to "Komfort", "settings.hourheight.large" to "Gross",
@@ -98,6 +121,14 @@ object L10n {
"event.detail_title" to "Termin", "event.source" to "Quelle",
"event.save" to "Sichern", "event.add" to "Hinzufügen",
"event.delete_confirm" to "Diesen Termin löschen?",
"event.reminders" to "Benachrichtigungen", "event.reminder_add" to "Benachrichtigung hinzufügen",
"event.reminder_custom" to "Benutzerdefiniert…", "event.reminder_at_start" to "Zur Startzeit",
"event.reminder_before" to "vorher",
"event.reminder_unit.minutes" to "Minuten", "event.reminder_unit.hours" to "Stunden",
"event.reminder_unit.days" to "Tage", "event.reminder_unit.weeks" to "Wochen",
"event.reminders_disabled" to "Für diesen Kalender sind Benachrichtigungen deaktiviert Erinnerungen werden nicht ausgeführt.",
"settings.default_duration" to "Standard-Termindauer",
"filter.reminders_on" to "Benachrichtigungen aktivieren", "filter.reminders_off" to "Benachrichtigungen deaktivieren",
"accounts.title" to "Konten", "accounts.loading" to "Lade Konten…",
"accounts.caldav.header" to "CalDAV-Konten", "accounts.caldav.empty" to "Keine CalDAV-Konten",
"accounts.caldav.add" to "CalDAV hinzufügen", "accounts.local.header" to "Lokale Kalender",
@@ -110,12 +141,35 @@ object L10n {
"accounts.ha.add" to "Home Assistant hinzufügen",
"filter.title" to "Kalender", "filter.empty" to "Keine Kalender vorhanden",
"filter.show_all" to "Alle anzeigen", "filter.hide_all" to "Alle ausblenden",
"filter.sort" to "Sortieren", "filter.done" to "Fertig",
"filter.move_up" to "Nach oben", "filter.move_down" to "Nach unten",
"filter.button" to "Kalender ein-/ausblenden",
"filter.sync_error" to "Synchronisierung fehlgeschlagen",
"filter.banish" to "Dauerhaft ausblenden",
"filter.read_only" to "Nur lesen",
"filter.banished_footer" to "Dauerhaft ausgeblendete Kalender erscheinen unter »Konten & Kalender« und können dort wieder eingeblendet werden.",
"accounts.banished_header" to "Ausgeblendete Kalender",
"accounts.banished_unhide" to "Wieder einblenden",
"accounts.banished_unknown" to "Unbekannter Kalender",
"caldav.display_name" to "Anzeigename", "caldav.url" to "CalDAV-URL",
"caldav.username" to "Benutzername", "caldav.password" to "Passwort",
"caldav.color" to "Farbe", "caldav.connect" to "Verbinden", "caldav.title" to "CalDAV-Konto",
"local.title" to "Lokaler Kalender", "local.name" to "Name", "local.color" to "Farbe",
"local.create" to "Erstellen",
"birthday.new" to "Neuer Geburtstag", "birthday.new_title" to "Neuen Geburtstag hinzufügen",
"birthday.calendar_name" to "Geburtstage",
"birthday.activate" to "Geburtstagskalender aktivieren",
"birthday.import_contacts" to "Aus Kontakten importieren",
"birthday.activate_hint" to "Aktiviere den Geburtstagskalender, um Geburtstage anzulegen. Er erscheint als eigener Kalender in der Seitenleiste.",
"birthday.is_calendar" to "Geburtstagskalender",
"birthday.person" to "Name", "birthday.person_ph" to "Name der Person",
"birthday.date" to "Geburtstag", "birthday.year_unknown" to "Jahr unbekannt",
"birthday.target" to "Geburtstagskalender",
"birthday.no_calendars" to "Kein Geburtstagskalender vorhanden. Erstelle zuerst einen Kalender und aktiviere „Geburtstagskalender\".",
"birthday.notify" to "Erinnerung", "birthday.notify.off" to "Aus",
"birthday.notify.same_day" to "Am Tag", "birthday.notify.one_day" to "1 Tag vorher",
"birthday.notify.days" to "%d Tage vorher",
"birthday.created" to "Geburtstag „%s\" hinzugefügt",
"ical.title" to "iCal abonnieren", "ical.name" to "Name", "ical.url" to "iCal-URL",
"ical.color" to "Farbe", "ical.interval" to "Intervall", "ical.subscribe" to "Abonnieren",
"ical.refresh.15m" to "Alle 15 Min.", "ical.refresh.30m" to "Alle 30 Min.",
@@ -165,6 +219,9 @@ object L10n {
"cal.no_events_title" to "No events",
"cal.no_events_body" to "No events in the next 90 days.",
"cal.loading_more" to "Loading more weeks…", "cal.new_event" to "New event",
"cal.show_in_day_view" to "Open in day view",
"cal.show_in_week_view" to "Open in week view",
"cal.no_events_day" to "No events on this day",
"menu.section.settings" to "Settings", "menu.profile" to "Profile",
"menu.appearance" to "Appearance", "menu.accounts" to "Accounts & Calendars",
"menu.server" to "Server", "menu.logout" to "Sign out", "menu.admin" to "Admin",
@@ -179,6 +236,25 @@ object L10n {
"settings.colors" to "Colors", "settings.color.primary" to "Primary color",
"settings.color.accent" to "Accent color", "settings.color.today" to "Today color",
"settings.color.divider" to "Month divider line", "settings.color.label" to "Month abbreviation",
"settings.color.text" to "Text color", "settings.color.background" to "Background color",
"settings.color.line" to "Line color",
"settings.sync_all" to "Sync all",
"settings.sync_all.desc" to "Share these settings across your devices",
"settings.sync_this" to "Sync across devices",
"settings.reset" to "Reset",
"settings.appearance" to "View",
"settings.device" to "This device only",
"settings.more" to "More",
"settings.hide_menu_button" to "Hide menu button",
"settings.month_mode" to "Month switching",
"settings.month_mode.scroll" to "Continuous scroll",
"settings.month_mode.paged" to "Page by page",
"settings.device.footer" to "These settings apply to this device only and are not synced.",
"settings.directory_hidden" to "Hide profile",
"settings.directory_hidden.desc" to "Don't appear in other users' share/group pickers.",
"settings.defaultreminder" to "Default reminder",
"reminder.off" to "Off", "reminder.at_start" to "At start time",
"reminder.1d" to "1 day before", "reminder.1w" to "1 week before",
"settings.textcontrast" to "Text contrast", "settings.linecontrast" to "Line contrast",
"settings.contrast.dark" to "Dark", "settings.contrast.medium" to "Medium",
"settings.contrast.bright" to "Bright", "settings.contrast.max" to "Maximum",
@@ -187,6 +263,7 @@ object L10n {
"settings.calview" to "Calendar view", "settings.defaultview" to "Default view",
"settings.firstweekday" to "First day of week", "settings.monday" to "Monday",
"settings.sunday" to "Sunday", "settings.dimpast" to "Dim past events",
"settings.month_paged" to "Swipe month view as pages",
"settings.hourheight" to "Hour height",
"settings.hourheight.compact" to "Compact", "settings.hourheight.normal" to "Normal",
"settings.hourheight.comfort" to "Comfort", "settings.hourheight.large" to "Large",
@@ -225,6 +302,14 @@ object L10n {
"event.detail_title" to "Event", "event.source" to "Source",
"event.save" to "Save", "event.add" to "Add",
"event.delete_confirm" to "Delete this event?",
"event.reminders" to "Reminders", "event.reminder_add" to "Add reminder",
"event.reminder_custom" to "Custom…", "event.reminder_at_start" to "At start time",
"event.reminder_before" to "before",
"event.reminder_unit.minutes" to "minutes", "event.reminder_unit.hours" to "hours",
"event.reminder_unit.days" to "days", "event.reminder_unit.weeks" to "weeks",
"event.reminders_disabled" to "Reminders are disabled for this calendar they will not fire.",
"settings.default_duration" to "Default event duration",
"filter.reminders_on" to "Enable reminders", "filter.reminders_off" to "Disable reminders",
"accounts.title" to "Accounts", "accounts.loading" to "Loading accounts…",
"accounts.caldav.header" to "CalDAV accounts", "accounts.caldav.empty" to "No CalDAV accounts",
"accounts.caldav.add" to "Add CalDAV", "accounts.local.header" to "Local calendars",
@@ -237,12 +322,35 @@ object L10n {
"accounts.ha.add" to "Add Home Assistant",
"filter.title" to "Calendars", "filter.empty" to "No calendars available",
"filter.show_all" to "Show all", "filter.hide_all" to "Hide all",
"filter.sort" to "Sort", "filter.done" to "Done",
"filter.move_up" to "Move up", "filter.move_down" to "Move down",
"filter.button" to "Show/hide calendars",
"filter.sync_error" to "Sync failed",
"filter.banish" to "Hide permanently",
"filter.read_only" to "Read-only",
"filter.banished_footer" to "Permanently hidden calendars appear under “Accounts & Calendars”, where you can show them again.",
"accounts.banished_header" to "Hidden calendars",
"accounts.banished_unhide" to "Show again",
"accounts.banished_unknown" to "Unknown calendar",
"caldav.display_name" to "Display name", "caldav.url" to "CalDAV URL",
"caldav.username" to "Username", "caldav.password" to "Password",
"caldav.color" to "Color", "caldav.connect" to "Connect", "caldav.title" to "CalDAV account",
"local.title" to "Local calendar", "local.name" to "Name", "local.color" to "Color",
"local.create" to "Create",
"birthday.new" to "New birthday", "birthday.new_title" to "Add new birthday",
"birthday.calendar_name" to "Birthdays",
"birthday.activate" to "Enable birthday calendar",
"birthday.import_contacts" to "Import from contacts",
"birthday.activate_hint" to "Enable the birthday calendar to add birthdays. It appears as its own calendar in the sidebar.",
"birthday.is_calendar" to "Birthday calendar",
"birthday.person" to "Name", "birthday.person_ph" to "Person's name",
"birthday.date" to "Birthday", "birthday.year_unknown" to "Year unknown",
"birthday.target" to "Birthday calendar",
"birthday.no_calendars" to "No birthday calendar yet. Create a calendar and enable \"Birthday calendar\" first.",
"birthday.notify" to "Reminder", "birthday.notify.off" to "Off",
"birthday.notify.same_day" to "On the day", "birthday.notify.one_day" to "1 day before",
"birthday.notify.days" to "%d days before",
"birthday.created" to "Birthday \"%s\" added",
"ical.title" to "Subscribe to iCal", "ical.name" to "Name", "ical.url" to "iCal URL",
"ical.color" to "Color", "ical.interval" to "Interval", "ical.subscribe" to "Subscribe",
"ical.refresh.15m" to "Every 15 min", "ical.refresh.30m" to "Every 30 min",

View File

@@ -62,12 +62,12 @@ class MainViewModel @Inject constructor(
_route.value = computeRoute()
}
/** Pull appearance settings from the server, caching them locally. */
/** Pull settings from the server and merge them with local values honouring
* each setting's sync flag (synced keys take the server value). */
fun refreshSettings() {
viewModelScope.launch {
runCatching { repository.getSettings() }.onSuccess { s ->
settingsStore.saveSettings(s)
_settings.value = s
_settings.value = settingsStore.applyServerPull(s)
}
}
}

View File

@@ -11,6 +11,7 @@ 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.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
@@ -116,6 +117,26 @@ fun AccountsScreen(
}
}
// Calendars permanently hidden ("banished") on the server — offered for
// re-enabling. Derived from the loaded account lists' sidebar_hidden flags.
val banishedCals: List<BanishedCalendar> = buildList {
vm.caldav.forEach { acc ->
acc.calendars.orEmpty().filter { it.sidebarHidden }.forEach {
add(BanishedCalendar("caldav", it.id, "${acc.name} ${it.name}", it.color ?: acc.color))
}
}
vm.google.forEach { acc ->
acc.calendars.orEmpty().filter { it.sidebarHidden }.forEach {
add(BanishedCalendar("google", it.id, "${acc.email} ${it.name}", it.color ?: "#4285f4"))
}
}
vm.homeAssistant.forEach { acc ->
acc.calendars.orEmpty().filter { it.sidebarHidden }.forEach {
add(BanishedCalendar("homeassistant", it.id, "${acc.name} ${it.name}", it.color ?: "#46bdc6"))
}
}
}
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Scaffold(
topBar = {
@@ -197,6 +218,14 @@ fun AccountsScreen(
}
}
// Banished (permanently hidden) calendars — re-enable here.
if (banishedCals.isNotEmpty()) {
item { SectionHeaderNoAdd(tr("accounts.banished_header")) }
items(banishedCals, key = { "b${it.source}${it.id}" }) { cal ->
BanishedCalendarRow(cal.label, cal.color) { vm.unbanishCalendar(cal.source, cal.id, onChanged) }
}
}
vm.error?.let { item { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(vertical = 12.dp)) } }
item { Spacer(Modifier.size(40.dp)) }
}
@@ -308,6 +337,26 @@ private fun ChildCalendarRow(name: String, color: String, onColor: () -> Unit) {
}
}
/** A permanently-hidden ("banished") calendar with a "show again" button. */
private data class BanishedCalendar(val source: String, val id: Int, val label: String, val color: String)
@Composable
private fun BanishedCalendarRow(name: String, color: String, onUnbanish: () -> Unit) {
Row(
Modifier.fillMaxWidth().padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
ColorDot(color, editable = false, onClick = {})
Text(
name,
modifier = Modifier.weight(1f).padding(start = 12.dp),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
TextButton(onClick = onUnbanish) { Text(tr("accounts.banished_unhide")) }
}
}
/** A simple row (iCal) with editable colour + delete. */
@Composable
private fun EditableColorRow(name: String, color: String, onColor: () -> Unit, onDelete: () -> Unit) {
@@ -332,7 +381,9 @@ private fun LocalCalendarRow(
) {
var menu by remember { mutableStateOf(false) }
Row(Modifier.fillMaxWidth().padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) {
ColorDot(cal.color, editable = cal.owned, onClick = onColor)
// Recipients of a shared calendar may recolour it (their own per-user
// colour); only renaming/other management stays owner-only.
ColorDot(cal.color, editable = true, onClick = onColor)
Column(Modifier.weight(1f).padding(start = 12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(cal.name, style = MaterialTheme.typography.bodyLarge)
@@ -380,7 +431,7 @@ private fun SharingSheet(vm: AccountsViewModel, calendarId: Int, onDismiss: () -
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(
Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 24.dp).verticalScroll(rememberScrollState()),
Modifier.fillMaxWidth().navigationBarsPadding().padding(horizontal = 20.dp).padding(bottom = 24.dp).verticalScroll(rememberScrollState()),
) {
Text(tr("share.title"), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.size(16.dp))

View File

@@ -61,8 +61,12 @@ class AccountsViewModel @Inject constructor(
}
}
fun addLocal(name: String, color: String, onChanged: () -> Unit) =
mutate(onChanged) { repository.addLocalCalendar(name, color) }
fun addLocal(
name: String, color: String, onChanged: () -> Unit,
isBirthday: Boolean = false, birthdayNotifyDaysBefore: Int? = null,
) = mutate(onChanged) {
repository.addLocalCalendar(name, color, isBirthday, birthdayNotifyDaysBefore)
}
fun deleteLocal(id: Int, onChanged: () -> Unit) =
mutate(onChanged) { repository.deleteLocalCalendar(id) }
@@ -99,6 +103,13 @@ class AccountsViewModel @Inject constructor(
fun setSourceColor(source: String, calendarId: Int, color: String, onChanged: () -> Unit) =
mutate(onChanged) { repository.setCalendarColor(source, calendarId, color) }
// ---- Banished (permanently hidden) calendars ----
/** Lift the server-side sidebar_hidden flag so a banished calendar reappears.
* `onChanged` triggers the calendar screen's reconcile + refetch. */
fun unbanishCalendar(source: String, calendarId: Int, onChanged: () -> Unit) =
mutate(onChanged) { repository.setCalendarSidebarHidden(source, calendarId, hidden = false) }
// ---- Sharing ----
var shares by mutableStateOf<List<CalendarShareEntry>>(emptyList())

View File

@@ -9,6 +9,7 @@ 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.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
@@ -102,7 +103,7 @@ fun LoginScreen(
modifier = Modifier.fillMaxWidth(),
) {
if (vm.loggingIn) {
CircularProgressIndicator(modifier = Modifier.height(20.dp), strokeWidth = 2.dp)
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
} else {
Text(tr("auth.login"))
}

View File

@@ -7,6 +7,7 @@ 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.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CalendarMonth
@@ -78,7 +79,7 @@ fun ServerSetupScreen(
modifier = Modifier.fillMaxWidth(),
) {
if (vm.checking) {
CircularProgressIndicator(modifier = Modifier.height(20.dp), strokeWidth = 2.dp)
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
} else {
Text(tr("auth.continue"))
}

View File

@@ -98,7 +98,12 @@ private fun AgendaRow(event: CalEvent, lang: String, dimmed: Boolean, onClick: (
Box(Modifier.size(10.dp).clip(CircleShape).background(colorFromHex(event.effectiveColor)))
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(event.title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium)
EventLabel(
event = event,
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.bodyLarge.fontSize,
fontWeight = FontWeight.Medium,
)
if (event.location.isNotBlank()) {
Text(event.location, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}

View File

@@ -0,0 +1,115 @@
package com.scarriffle.calendarr.ui.calendar
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDatePickerState
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.Modifier
import androidx.compose.ui.unit.dp
import com.scarriffle.calendarr.domain.model.LocalCalendar
import com.scarriffle.calendarr.ui.tr
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
/**
* Minimal "new birthday" mask for the user's single birthday calendar: name +
* date (with an optional "year unknown"). Saves an all-day, yearly-recurring
* local event; the server adds the age suffix and cake icon. If no birthday
* calendar exists yet, offers to activate one.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BirthdayDialog(
calendar: LocalCalendar?,
onDismiss: () -> Unit,
onActivate: () -> Unit,
onImportContacts: () -> Unit,
onSave: (name: String, date: LocalDate, yearKnown: Boolean) -> Unit,
) {
var name by remember { mutableStateOf("") }
var yearUnknown by remember { mutableStateOf(false) }
var pickedDate by remember { mutableStateOf(LocalDate.now()) }
var showPicker by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(tr("birthday.new_title")) },
text = {
Column {
if (calendar == null) {
Text(tr("birthday.activate_hint"), color = MaterialTheme.colorScheme.onSurfaceVariant)
} else {
OutlinedTextField(
name, { name = it },
label = { Text(tr("birthday.person")) },
singleLine = true, modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.size(8.dp))
FilterChip(
selected = true, onClick = { showPicker = true },
label = { Text("${tr("birthday.date")}: ${formatBirthday(pickedDate, yearUnknown)}") },
)
Spacer(Modifier.size(8.dp))
FilterChip(
selected = yearUnknown, onClick = { yearUnknown = !yearUnknown },
label = { Text(tr("birthday.year_unknown")) },
)
Spacer(Modifier.size(4.dp))
TextButton(onClick = onImportContacts) { Text(tr("birthday.import_contacts")) }
}
}
},
confirmButton = {
if (calendar == null) {
TextButton(onClick = onActivate) { Text(tr("birthday.activate")) }
} else {
TextButton(
enabled = name.isNotBlank(),
onClick = { onSave(name.trim(), pickedDate, !yearUnknown) },
) { Text(tr("common.save")) }
}
},
dismissButton = { TextButton(onClick = onDismiss) { Text(tr("common.cancel")) } },
)
if (showPicker) {
val dpState = rememberDatePickerState(
initialSelectedDateMillis = pickedDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli(),
)
DatePickerDialog(
onDismissRequest = { showPicker = false },
confirmButton = {
TextButton(onClick = {
dpState.selectedDateMillis?.let { millis ->
pickedDate = Instant.ofEpochMilli(millis).atZone(ZoneOffset.UTC).toLocalDate()
}
showPicker = false
}) { Text(tr("common.save")) }
},
dismissButton = { TextButton(onClick = { showPicker = false }) { Text(tr("common.cancel")) } },
) { DatePicker(state = dpState) }
}
}
private val DAY_MONTH = DateTimeFormatter.ofPattern("dd.MM.")
private val DAY_MONTH_YEAR = DateTimeFormatter.ofPattern("dd.MM.yyyy")
private fun formatBirthday(date: LocalDate, yearUnknown: Boolean): String =
date.format(if (yearUnknown) DAY_MONTH else DAY_MONTH_YEAR)

View File

@@ -0,0 +1,293 @@
package com.scarriffle.calendarr.ui.calendar
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.horizontalScroll
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.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Archive
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.NotificationsOff
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Divider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.scarriffle.calendarr.domain.model.CalViewType
import com.scarriffle.calendarr.domain.model.Group
import com.scarriffle.calendarr.ui.groups.GroupIcon
import com.scarriffle.calendarr.ui.tr
import com.scarriffle.calendarr.util.colorFromHex
/**
* The left navigation drawer: calendar visibility (flat, reorderable) + view /
* group switching + a header with manual sync, the full menu, and close. Mirrors
* the iOS side drawer. Order is device-local (CalendarViewModel.calendarOrder).
*/
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun CalendarDrawerContent(
state: CalendarUiState,
vm: CalendarViewModel,
username: String,
serverUrl: String,
calendars: List<CalendarFilterEntry>,
onOpenMenu: () -> Unit,
onSync: () -> Unit,
onClose: () -> Unit,
) {
var isSorting by remember { mutableStateOf(false) }
// Working order: entries sorted by the stored order (unknown → end, by name).
var order by remember(calendars, vm.calendarOrder) {
mutableStateOf(
calendars.sortedWith(
compareBy(
{ vm.calendarOrder.indexOf(it.key).let { i -> if (i < 0) Int.MAX_VALUE else i } },
{ it.name.lowercase() },
)
).map { it.key }
)
}
val entries = order.mapNotNull { key -> calendars.firstOrNull { it.key == key } }
fun move(index: Int, delta: Int) {
val to = index + delta
if (to < 0 || to >= order.size) return
val m = order.toMutableList()
val x = m.removeAt(index); m.add(to, x)
order = m
vm.setCalendarOrder(m)
}
ModalDrawerSheet(Modifier.width(320.dp).fillMaxHeight()) {
Column(Modifier.fillMaxHeight()) {
header(username, serverUrl, onSync, onOpenMenu, onClose)
Divider()
viewSwitcher(state.viewType) { vm.setViewType(it) }
Divider()
if (state.groups.isNotEmpty()) {
groupSwitcher(state.groups, state.activeGroup) { vm.switchGroup(it) }
Divider()
}
Row(
Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(tr("filter.title"), style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.weight(1f))
TextButton(onClick = { isSorting = !isSorting }) {
Text(tr(if (isSorting) "filter.done" else "filter.sort"))
}
}
LazyColumn(Modifier.fillMaxWidth().weight(1f).navigationBarsPadding()) {
items(entries, key = { it.key }) { entry ->
CalendarRow(
entry = entry,
visible = entry.key !in state.hiddenKeys,
reminderDisabled = entry.key in state.reminderDisabledKeys,
sorting = isSorting,
onToggle = { vm.setCalendarHidden(entry.key, !it) },
onBanish = { vm.setCalendarBanished(entry.key, banished = true) },
onToggleReminders = { vm.setCalendarRemindersDisabled(entry.key, disabled = it) },
onMoveUp = { move(order.indexOf(entry.key), -1) },
onMoveDown = { move(order.indexOf(entry.key), 1) },
)
}
}
}
}
}
@Composable
private fun header(
username: String,
serverUrl: String,
onSync: () -> Unit,
onOpenMenu: () -> Unit,
onClose: () -> Unit,
) {
Row(
Modifier.fillMaxWidth().statusBarsPadding().padding(start = 16.dp, end = 4.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
Modifier.size(38.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary),
contentAlignment = Alignment.Center,
) {
Text(username.firstOrNull()?.uppercase() ?: "?",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onPrimary, fontWeight = FontWeight.Bold)
}
Column(Modifier.weight(1f).padding(start = 12.dp)) {
Text(username, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, maxLines = 1)
Text(serverUrl.removePrefix("https://").removePrefix("http://"),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
}
IconButton(onClick = onSync, modifier = Modifier.size(40.dp)) {
Icon(Icons.Filled.Refresh, contentDescription = tr("menu.sync"), modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary)
}
IconButton(onClick = onOpenMenu, modifier = Modifier.size(40.dp)) {
Icon(Icons.Filled.Settings, contentDescription = tr("menu.appearance"), modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary)
}
IconButton(onClick = onClose, modifier = Modifier.size(40.dp)) {
Icon(Icons.Filled.Close, contentDescription = tr("common.close"), modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun viewSwitcher(current: CalViewType, onSelect: (CalViewType) -> Unit) {
Row(
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
CalViewType.entries.forEach { type ->
FilterChip(
selected = current == type,
onClick = { onSelect(type) },
label = { Text(tr("view.${type.key}")) },
leadingIcon = { Icon(type.icon, contentDescription = null, modifier = Modifier.size(18.dp)) },
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun groupSwitcher(groups: List<Group>, active: Group?, onSwitch: (Group?) -> Unit) {
Row(
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FilterChip(
selected = active == null,
onClick = { onSwitch(null) },
label = { Text(tr("group.switch.personal")) },
)
groups.forEach { g ->
FilterChip(
selected = active?.id == g.id,
onClick = { onSwitch(g) },
label = { Text(g.name) },
leadingIcon = { GroupIcon(g.icon) },
)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun CalendarRow(
entry: CalendarFilterEntry,
visible: Boolean,
reminderDisabled: Boolean,
sorting: Boolean,
onToggle: (Boolean) -> Unit,
onBanish: () -> Unit,
onToggleReminders: (Boolean) -> Unit,
onMoveUp: () -> Unit,
onMoveDown: () -> Unit,
) {
var menuOpen by remember { mutableStateOf(false) }
Box {
Row(
Modifier.fillMaxWidth()
.combinedClickable(
onClick = { if (!sorting) onToggle(visible) },
onLongClick = { if (!sorting) menuOpen = true },
)
.padding(start = 16.dp, end = 4.dp, top = 5.dp, bottom = 5.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(Modifier.size(12.dp).clip(CircleShape).background(colorFromHex(entry.color)))
Text(
entry.name,
modifier = Modifier.weight(1f).padding(start = 12.dp),
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
color = if (visible) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
)
if (entry.readOnly) {
Icon(Icons.Filled.Lock, contentDescription = tr("filter.read_only"),
tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(end = 4.dp).size(14.dp))
}
if (sorting) {
IconButton(onClick = onMoveUp, modifier = Modifier.size(36.dp)) {
Icon(Icons.Filled.KeyboardArrowUp, contentDescription = tr("filter.move_up"))
}
IconButton(onClick = onMoveDown, modifier = Modifier.size(36.dp)) {
Icon(Icons.Filled.KeyboardArrowDown, contentDescription = tr("filter.move_down"))
}
} else {
if (reminderDisabled) {
Icon(Icons.Filled.NotificationsOff, contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(end = 2.dp).size(15.dp))
}
IconButton(onClick = { onToggle(visible) }, modifier = Modifier.size(40.dp)) {
Icon(
if (visible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = if (visible) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
DropdownMenuItem(
text = { Text(tr(if (reminderDisabled) "filter.reminders_on" else "filter.reminders_off")) },
leadingIcon = { Icon(if (reminderDisabled) Icons.Filled.Notifications else Icons.Filled.NotificationsOff, contentDescription = null) },
onClick = { menuOpen = false; onToggleReminders(!reminderDisabled) },
)
DropdownMenuItem(
text = { Text(tr("filter.banish")) },
leadingIcon = { Icon(Icons.Filled.Archive, contentDescription = null) },
onClick = { menuOpen = false; onBanish() },
)
}
}
}

View File

@@ -6,10 +6,21 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Archive
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.NotificationsOff
import androidx.compose.material.icons.filled.WarningAmber
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Switch
@@ -26,7 +37,7 @@ import androidx.compose.ui.unit.dp
import com.scarriffle.calendarr.ui.tr
import com.scarriffle.calendarr.util.colorFromHex
data class CalendarFilterEntry(val key: String, val name: String, val color: String)
data class CalendarFilterEntry(val key: String, val name: String, val color: String, val source: String = "", val readOnly: Boolean = false)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -36,9 +47,28 @@ fun CalendarFilterSheet(
onDismiss: () -> Unit,
) {
val state by vm.state.collectAsState()
val groupMode = state.activeGroup != null
// In group mode the filter lists members (+ the group calendar) so they can
// be hidden individually, Outlook-style; otherwise the normal calendars.
val groupEntries: List<CalendarFilterEntry> = if (groupMode) {
buildList {
state.activeGroupMembers.forEach { m ->
add(CalendarFilterEntry(groupMemberKey(m.id), m.displayName, m.color ?: "#4285f4"))
}
add(CalendarFilterEntry(GROUP_CALENDAR_KEY, tr("groups.calendar"), state.activeGroup?.groupCalendarColor ?: "#4285f4"))
}
} else emptyList()
val rows = if (groupMode) groupEntries else events
val hiddenSet = if (groupMode) state.hiddenGroupKeys else state.hiddenKeys
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
Column(
Modifier.fillMaxWidth()
.verticalScroll(rememberScrollState())
.navigationBarsPadding()
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
@@ -46,37 +76,92 @@ fun CalendarFilterSheet(
) {
Text(tr("filter.title"), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
Row {
TextButton(onClick = { vm.setHiddenCalendars(emptySet()) }) { Text(tr("filter.show_all")) }
TextButton(onClick = {
vm.setHiddenCalendars(events.map { it.key }.toSet())
if (groupMode) vm.setHiddenGroupKeys(emptySet()) else vm.setHiddenCalendars(emptySet())
}) { Text(tr("filter.show_all")) }
TextButton(onClick = {
if (groupMode) vm.setHiddenGroupKeys(rows.map { it.key }.toSet())
else vm.setHiddenCalendars(rows.map { it.key }.toSet())
}) { Text(tr("filter.hide_all")) }
}
}
if (events.isEmpty()) {
if (rows.isEmpty()) {
Text(
tr("filter.empty"),
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 24.dp),
)
}
events.forEach { entry ->
val visible = entry.key !in state.hiddenKeys
rows.forEach { entry ->
val visible = entry.key !in hiddenSet
// entry.key is "source:id" (see calendarKey()); match sync errors on the
// same (source, calendarId) pair the server already attaches to events.
val entryId = entry.key.substringAfter(":")
val hasSyncError = !groupMode && state.syncErrors.any { err ->
err.source == entry.source && err.calendarId?.toString() == entryId
}
Row(
Modifier.fillMaxWidth().padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(Modifier.size(14.dp).clip(CircleShape).background(colorFromHex(entry.color)))
if (hasSyncError) {
Icon(
Icons.Filled.WarningAmber,
contentDescription = tr("filter.sync_error"),
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(start = 8.dp).size(16.dp),
)
}
Text(
entry.name,
modifier = Modifier.weight(1f).padding(start = 12.dp),
style = MaterialTheme.typography.bodyLarge,
)
if (entry.readOnly) {
Icon(
Icons.Filled.Lock,
contentDescription = tr("filter.read_only"),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(end = 4.dp).size(15.dp),
)
}
if (!groupMode) {
val remDisabled = entry.key in state.reminderDisabledKeys
IconButton(onClick = { vm.setCalendarRemindersDisabled(entry.key, disabled = !remDisabled) }) {
Icon(
if (remDisabled) Icons.Filled.NotificationsOff else Icons.Filled.Notifications,
contentDescription = tr(if (remDisabled) "filter.reminders_on" else "filter.reminders_off"),
tint = if (remDisabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.primary,
)
}
// Banish = permanently hide (syncs to the server); moves the
// calendar into Settings, distinct from the local quick-hide.
IconButton(onClick = { vm.setCalendarBanished(entry.key, banished = true) }) {
Icon(
Icons.Filled.Archive,
contentDescription = tr("filter.banish"),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Switch(
checked = visible,
onCheckedChange = { vm.setCalendarHidden(entry.key, hidden = !it) },
onCheckedChange = {
if (groupMode) vm.setGroupKeyHidden(entry.key, hidden = !it)
else vm.setCalendarHidden(entry.key, !it)
},
)
}
}
if (!groupMode && state.banishedKeys.isNotEmpty()) {
Text(
tr("filter.banished_footer"),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
}
Box(Modifier.padding(bottom = 24.dp))
}
}

View File

@@ -5,27 +5,37 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
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.WindowInsets
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Today
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Divider
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.rememberDrawerState
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
@@ -35,15 +45,18 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.remember
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@@ -55,36 +68,110 @@ import com.scarriffle.calendarr.domain.model.CalEvent
import com.scarriffle.calendarr.domain.model.CalViewType
import com.scarriffle.calendarr.ui.LocalLang
import com.scarriffle.calendarr.ui.accounts.AccountsScreen
import com.scarriffle.calendarr.domain.model.Group
import com.scarriffle.calendarr.ui.event.EventDetailScreen
import com.scarriffle.calendarr.ui.event.EventEditorSheet
import com.scarriffle.calendarr.ui.menu.MenuSheet
import com.scarriffle.calendarr.ui.groups.GroupIcon
import com.scarriffle.calendarr.ui.groups.GroupsScreen
import com.scarriffle.calendarr.ui.menu.MenuScreen
import com.scarriffle.calendarr.ui.profile.ProfileScreen
import com.scarriffle.calendarr.ui.settings.SettingsScreen
import com.scarriffle.calendarr.ui.tr
import kotlinx.coroutines.launch
import java.time.LocalDate
private enum class Overlay { NONE, PROFILE, SETTINGS, ACCOUNTS }
private enum class Overlay { NONE, MENU, PROFILE, SETTINGS, ACCOUNTS, GROUPS }
data class EditorRequest(val existing: CalEvent?, val date: LocalDate, val prefill: CalEvent? = null)
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun CalendarScreen(
onLogout: () -> Unit,
onSwitchServer: () -> Unit,
onSettingsChanged: (AppSettings) -> Unit,
onSettingsSynced: () -> Unit,
username: String = "",
serverUrl: String = "",
vm: CalendarViewModel = hiltViewModel(),
) {
val state by vm.state.collectAsState()
val lang = LocalLang.current
val context = androidx.compose.ui.platform.LocalContext.current
// Ask once for notification permission (Android 13+), then keep the OS
// reminder alarms in sync with the visible events / muted-calendar set.
val notifPermLauncher = androidx.activity.compose.rememberLauncherForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.RequestPermission()
) {}
androidx.compose.runtime.LaunchedEffect(Unit) {
if (android.os.Build.VERSION.SDK_INT >= 33 &&
androidx.core.content.ContextCompat.checkSelfPermission(
context, android.Manifest.permission.POST_NOTIFICATIONS
) != android.content.pm.PackageManager.PERMISSION_GRANTED
) {
notifPermLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS)
}
}
androidx.compose.runtime.LaunchedEffect(state.events, state.reminderDisabledKeys) {
com.scarriffle.calendarr.notifications.NotificationScheduler.reschedule(
context, state.events, state.reminderDisabledKeys, vm.defaultReminderMinutes
)
}
// Re-check server-side calendar visibility whenever the app returns to the
// foreground (e.g. after hiding/showing a calendar on the web or another
// device); reloads only if something changed.
val lifecycleOwner = androidx.compose.ui.platform.LocalLifecycleOwner.current
androidx.compose.runtime.DisposableEffect(lifecycleOwner) {
val observer = androidx.lifecycle.LifecycleEventObserver { _, event ->
if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) vm.onAppResumed()
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
// Contacts birthday import (mirrors iOS): request permission on demand; sync
// this device's contact birthdays into the birthday calendar.
val deviceName = android.os.Build.MODEL ?: "Android"
fun runBirthdayImport() {
vm.birthdaysSyncEnabled = true
vm.syncContactBirthdays(com.scarriffle.calendarr.data.ContactsReader.readBirthdays(context), deviceName)
}
val contactsPermLauncher = androidx.activity.compose.rememberLauncherForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.RequestPermission()
) { granted -> if (granted) runBirthdayImport() }
fun startBirthdayImport() {
if (androidx.core.content.ContextCompat.checkSelfPermission(
context, android.Manifest.permission.READ_CONTACTS
) == android.content.pm.PackageManager.PERMISSION_GRANTED
) runBirthdayImport() else contactsPermLauncher.launch(android.Manifest.permission.READ_CONTACTS)
}
androidx.compose.runtime.LaunchedEffect(Unit) {
if (vm.birthdaysSyncEnabled &&
androidx.core.content.ContextCompat.checkSelfPermission(
context, android.Manifest.permission.READ_CONTACTS
) == android.content.pm.PackageManager.PERMISSION_GRANTED
) runBirthdayImport()
}
var viewMenuOpen by remember { mutableStateOf(false) }
var showMenu by remember { mutableStateOf(false) }
var showFilter by remember { mutableStateOf(false) }
var detailEvent by remember { mutableStateOf<CalEvent?>(null) }
var editor by remember { mutableStateOf<EditorRequest?>(null) }
var overlay by remember { mutableStateOf(Overlay.NONE) }
var dayPreview by remember { mutableStateOf<LocalDate?>(null) }
var fabMenuOpen by remember { mutableStateOf(false) }
var showBirthday by remember { mutableStateOf(false) }
var birthdayCal by remember { mutableStateOf<com.scarriffle.calendarr.domain.model.LocalCalendar?>(null) }
val birthdayScope = rememberCoroutineScope()
val birthdayCalName = tr("birthday.calendar_name")
// Side navigation drawer (calendars + groups + view + menu).
val drawerState = rememberDrawerState(DrawerValue.Closed)
val drawerScope = rememberCoroutineScope()
val drawerCalendars = remember(state.events, state.banishedKeys, state.allCalendars) { allKnownCalendars(vm) }
androidx.compose.runtime.LaunchedEffect(drawerState.currentValue) {
if (drawerState.isOpen) vm.loadAllCalendars()
}
// Continuous month scrolling
val monthListState = rememberLazyListState()
@@ -106,6 +193,18 @@ fun CalendarScreen(
if (isMonth) todaySignal++ else vm.moveToToday()
}
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
CalendarDrawerContent(
state = state, vm = vm, username = username, serverUrl = serverUrl,
calendars = drawerCalendars,
onOpenMenu = { drawerScope.launch { drawerState.close() }; overlay = Overlay.MENU },
onSync = { drawerScope.launch { drawerState.close() }; vm.syncWithServer() },
onClose = { drawerScope.launch { drawerState.close() } },
)
},
) {
Scaffold(
topBar = {
CompactTopBar(
@@ -113,30 +212,67 @@ fun CalendarScreen(
viewType = state.viewType,
loading = state.isLoading || state.isBackgroundCaching,
viewMenuOpen = viewMenuOpen,
onMenu = { showMenu = true },
showMenuButton = !state.hideMenuButton,
onMenu = { drawerScope.launch { drawerState.open() } },
onPrev = { goPrev() },
onToday = { goToday() },
onNext = { goNext() },
onFilter = { showFilter = true },
onViewMenuToggle = { viewMenuOpen = it },
onSelectView = { vm.setViewType(it) },
)
},
floatingActionButton = {
FloatingActionButton(
onClick = { editor = EditorRequest(null, if (isMonth) visibleMonth else state.currentDate) },
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
) {
Icon(Icons.Filled.Add, contentDescription = tr("cal.new_event"))
// Tap = new event; long-press opens a small menu (new event / new
// birthday). A transparent overlay carries the combined click so the
// long-press is reliable on the Material FAB.
Box(Modifier.navigationBarsPadding()) {
FloatingActionButton(
onClick = {},
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
) {
Icon(Icons.Filled.Add, contentDescription = tr("cal.new_event"))
}
Box(
Modifier
.matchParentSize()
.clip(CircleShape)
.combinedClickable(
onClick = { editor = EditorRequest(null, if (isMonth) visibleMonth else state.currentDate) },
onLongClick = { fabMenuOpen = true },
),
)
DropdownMenu(expanded = fabMenuOpen, onDismissRequest = { fabMenuOpen = false }) {
DropdownMenuItem(
text = { Text(tr("cal.new_event")) },
onClick = { fabMenuOpen = false; editor = EditorRequest(null, if (isMonth) visibleMonth else state.currentDate) },
)
DropdownMenuItem(
text = { Text(tr("birthday.new")) },
onClick = { fabMenuOpen = false; showBirthday = true },
)
}
}
},
// Edge-to-edge: we place the system-bar insets ourselves (top bar gets
// statusBarsPadding, the content column gets navigationBarsPadding) so
// nothing hides behind the status/nav bars.
contentWindowInsets = WindowInsets(0),
) { padding ->
Column(Modifier.fillMaxSize().padding(padding)) {
Column(Modifier.fillMaxSize().padding(padding).navigationBarsPadding()) {
state.error?.let { err ->
ErrorBanner(err, onRetry = { vm.loadVisible(force = true) }, onDismiss = vm::clearError)
}
if (state.syncErrors.isNotEmpty()) {
SyncErrorBanner(state.syncErrors, onDismiss = vm::clearSyncErrors)
}
state.activeGroup?.let { g ->
GroupBanner(group = g, onExit = { vm.switchGroup(null) })
}
Box(Modifier.fillMaxSize()) {
// remember: stable callbacks keep the lazily composed week rows
// skippable during scroll (fresh lambdas would recompose them all).
CalendarBody(
state = state,
vm = vm,
@@ -144,35 +280,41 @@ fun CalendarScreen(
scrollToTodaySignal = todaySignal,
monthJumpSignal = monthJumpSignal,
monthJumpTarget = monthJumpTarget,
onVisibleMonthChange = { visibleMonth = it },
onEventClick = { detailEvent = it },
onDayClick = { date -> vm.goToDate(date, CalViewType.DAY) },
onDayLongPress = { date -> editor = EditorRequest(null, date) },
onVisibleMonthChange = remember { { visibleMonth = it } },
onEventClick = remember { { detailEvent = it } },
onDayClick = remember(vm) { { date -> vm.goToDate(date, CalViewType.DAY) } },
onDayLongPress = remember { { date -> dayPreview = date } },
)
}
}
}
}
// ---- Sheets ----
if (showMenu) {
MenuSheet(
isAdmin = false,
onDismiss = { showMenu = false },
onProfile = { showMenu = false; overlay = Overlay.PROFILE },
onAppearance = { showMenu = false; overlay = Overlay.SETTINGS },
onAccounts = { showMenu = false; overlay = Overlay.ACCOUNTS },
onSync = { showMenu = false; vm.syncWithServer() },
onLogout = { showMenu = false; onLogout() },
onSwitchServer = { showMenu = false; onSwitchServer() },
dayPreview?.let { date ->
DayPreviewDialog(
date = date,
events = remember(state.events, date) { vm.eventsOn(date, state.events) },
onDismiss = { dayPreview = null },
onEventClick = { ev -> dayPreview = null; detailEvent = ev },
onCreateEvent = { dayPreview = null; editor = EditorRequest(null, date) },
onOpenDay = { dayPreview = null; vm.goToDate(date, CalViewType.DAY) },
onOpenWeek = { dayPreview = null; vm.goToDate(date, CalViewType.WEEK) },
)
}
if (showFilter) {
CalendarFilterSheet(
events = remember(state.events, state.hiddenKeys) { allKnownCalendars(vm) },
vm = vm,
onDismiss = { showFilter = false },
if (showBirthday) {
androidx.compose.runtime.LaunchedEffect(Unit) { birthdayCal = vm.birthdayCalendar() }
BirthdayDialog(
calendar = birthdayCal,
onDismiss = { showBirthday = false },
onActivate = { birthdayScope.launch { birthdayCal = vm.ensureBirthdayCalendar(birthdayCalName) } },
onImportContacts = { showBirthday = false; startBirthdayImport() },
onSave = { name, date, yearKnown ->
birthdayCal?.let { vm.createBirthday(it.id, name, date, yearKnown) {} }
showBirthday = false
},
)
}
@@ -211,8 +353,10 @@ fun CalendarScreen(
request = req,
writableCalendars = state.writableCalendars,
onDismiss = { editor = null },
onSave = { cal, title, start, end, allDay, location, desc, color, isPrivate ->
vm.saveEvent(cal, req.existing, title, start, end, allDay, location, desc, color, isPrivate) { error ->
defaultDurationMinutes = vm.defaultEventDurationMinutes,
reminderDisabledKeys = state.reminderDisabledKeys,
onSave = { cal, title, start, end, allDay, location, desc, color, isPrivate, reminders ->
vm.saveEvent(cal, req.existing, title, start, end, allDay, location, desc, color, isPrivate, reminders) { error ->
if (error == null) editor = null
}
},
@@ -220,16 +364,33 @@ fun CalendarScreen(
}
when (overlay) {
Overlay.PROFILE -> ProfileScreen(onClose = { overlay = Overlay.NONE })
Overlay.SETTINGS -> SettingsScreen(
Overlay.MENU -> MenuScreen(
username = username,
serverUrl = serverUrl,
onClose = { overlay = Overlay.NONE },
onProfile = { overlay = Overlay.PROFILE },
onAppearance = { overlay = Overlay.SETTINGS },
onAccounts = { overlay = Overlay.ACCOUNTS },
onGroups = { overlay = Overlay.GROUPS },
onSync = { overlay = Overlay.NONE; vm.syncWithServer() },
onSwitchServer = onSwitchServer,
onLogout = onLogout,
)
Overlay.PROFILE -> ProfileScreen(onClose = { overlay = Overlay.MENU })
Overlay.SETTINGS -> SettingsScreen(
onClose = { overlay = Overlay.MENU; vm.refreshMonthViewMode() },
onSettingsChanged = onSettingsChanged,
onSettingsSynced = onSettingsSynced,
)
Overlay.ACCOUNTS -> AccountsScreen(
onClose = { overlay = Overlay.NONE },
onClose = { overlay = Overlay.MENU },
onChanged = { vm.loadWritableCalendars(); vm.syncWithServer() },
)
Overlay.GROUPS -> GroupsScreen(
onClose = { overlay = Overlay.MENU },
onChanged = { vm.loadGroups(); vm.loadWritableCalendars() },
onOpenGroupView = { g -> overlay = Overlay.NONE; vm.switchGroup(g) },
)
Overlay.NONE -> Unit
}
}
@@ -283,6 +444,33 @@ private fun ErrorBanner(message: String, onRetry: () -> Unit, onDismiss: () -> U
}
}
/**
* Additive to [ErrorBanner]: the fetch as a whole succeeded, but one or more
* enabled calendars failed to sync (e.g. expired credentials) and are showing
* zero events with no other indication. Same visual language, no retry button
* (retrying the whole range wouldn't target just the broken calendar).
*/
@Composable
private fun SyncErrorBanner(errors: List<com.scarriffle.calendarr.data.SyncError>, onDismiss: () -> Unit) {
androidx.compose.material3.Surface(
color = MaterialTheme.colorScheme.errorContainer,
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.padding(12.dp)) {
errors.forEach { err ->
Text(
"${err.name}: ${err.message}",
color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.bodySmall,
)
}
androidx.compose.foundation.layout.Row {
TextButton(onClick = onDismiss) { Text(tr("common.close")) }
}
}
}
}
@Composable
private fun loadingPlaceholder() {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@@ -296,20 +484,24 @@ private fun CompactTopBar(
viewType: CalViewType,
loading: Boolean,
viewMenuOpen: Boolean,
showMenuButton: Boolean,
onMenu: () -> Unit,
onPrev: () -> Unit,
onToday: () -> Unit,
onNext: () -> Unit,
onFilter: () -> Unit,
onViewMenuToggle: (Boolean) -> Unit,
onSelectView: (CalViewType) -> Unit,
) {
val twoLine = viewType == CalViewType.WEEK || viewType == CalViewType.DAY
// Surface fills behind the status bar; the content column is inset below it.
Surface(color = MaterialTheme.colorScheme.background) {
Column(Modifier.statusBarsPadding()) {
Row(
Modifier.fillMaxWidth().height(50.dp).padding(horizontal = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Menu (hamburger) on the left — the drawer opens from the left.
if (showMenuButton) CompactIcon(Icons.Filled.Menu, onMenu, tr("nav.menu"))
CompactIcon(Icons.Filled.ChevronLeft, onPrev)
TextButton(onClick = onToday, contentPadding = PaddingValues(horizontal = 6.dp)) {
Text(tr("nav.today"), fontSize = 13.sp)
@@ -331,7 +523,6 @@ private fun CompactTopBar(
strokeWidth = 2.dp,
)
}
CompactIcon(Icons.Filled.FilterList, onFilter, tr("filter.button"))
Box {
CompactIcon(viewType.icon, { onViewMenuToggle(true) }, tr("view.change"))
DropdownMenu(expanded = viewMenuOpen, onDismissRequest = { onViewMenuToggle(false) }) {
@@ -344,7 +535,11 @@ private fun CompactTopBar(
}
}
}
CompactIcon(Icons.Filled.Menu, onMenu, tr("nav.menu"))
}
Divider(
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.7f),
)
}
}
}
@@ -360,11 +555,36 @@ private fun CompactIcon(
}
}
/** Distinct calendars currently present in the cache, for the filter sheet. */
@Composable
private fun GroupBanner(group: Group, onExit: () -> Unit) {
Surface(color = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f), modifier = Modifier.fillMaxWidth()) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
GroupIcon(group.icon, modifier = Modifier.padding(end = 6.dp))
Text(
"${tr("groups.view")}: ${group.name}",
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodyMedium,
)
TextButton(onClick = onExit) { Text(tr("group.switch.personal")) }
}
}
}
/** Distinct calendars in the cache for the filter sheet — from the UNFILTERED
* cache (minus banished) so a locally quick-hidden calendar still shows up and
* can be toggled back on. */
private fun allKnownCalendars(vm: CalendarViewModel): List<CalendarFilterEntry> {
val st = vm.state.value
return st.events
.map { CalendarFilterEntry(calendarKey(it.source, it.calendarId), it.calendarName.ifBlank { it.source }, it.effectiveColor) }
val fromEvents = vm.knownCalendars()
.map { CalendarFilterEntry(calendarKey(it.source, it.calendarId), it.calendarName.ifBlank { it.source }, it.effectiveColor, it.source, it.readOnly) }
// Event-derived entries first (current server colour / owner name), then the
// full source list so calendars WITHOUT events in range still appear;
// distinctBy keeps the event-derived entry when a calendar has both.
return (fromEvents + vm.state.value.allCalendars)
.distinctBy { it.key }
.sortedBy { it.name.lowercase() }
}

View File

@@ -4,9 +4,14 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.scarriffle.calendarr.data.CalendarRepository
import com.scarriffle.calendarr.data.SettingsStore
import com.scarriffle.calendarr.data.SyncError
import com.scarriffle.calendarr.domain.model.CalEvent
import com.scarriffle.calendarr.domain.model.CalViewType
import com.scarriffle.calendarr.domain.model.Group
import com.scarriffle.calendarr.domain.model.GroupMember
import com.scarriffle.calendarr.domain.model.LocalCalendar
import com.scarriffle.calendarr.domain.model.WritableCalendar
import com.scarriffle.calendarr.util.Dates
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -34,12 +39,36 @@ data class CalendarUiState(
val isLoading: Boolean = false,
val isBackgroundCaching: Boolean = false,
val error: String? = null,
// Per-calendar sync failures from the last successful /events fetch (e.g.
// expired CalDAV credentials) — the fetch as a whole succeeded, but one or
// more enabled calendars silently returned nothing. Additive to `error`.
val syncErrors: List<SyncError> = emptyList(),
val weekStartsOnMonday: Boolean = true,
val writableCalendars: List<WritableCalendar> = emptyList(),
val hiddenKeys: Set<String> = emptySet(),
val banishedKeys: Set<String> = emptySet(),
// Calendars ("source:id") the user muted for reminders — events keep their
// reminders but the scheduler skips them.
val reminderDisabledKeys: Set<String> = emptySet(),
// Group overlay: when non-null the calendar shows the group's combined view.
val groups: List<Group> = emptyList(),
val activeGroup: Group? = null,
// Group overlay: full member list (for the filter) + per-member / group-cal
// hidden keys ("gm:<userId>" / "gc"). In-memory; reset when switching group.
val activeGroupMembers: List<GroupMember> = emptyList(),
val hiddenGroupKeys: Set<String> = emptySet(),
// Full calendar list across all sources (loaded on demand) so the filter
// shows every calendar, including ones with no events in the loaded range.
val allCalendars: List<CalendarFilterEntry> = emptyList(),
// Device-local: month view as horizontal paged (swipe) vs. scroll feed.
val monthViewPaged: Boolean = false,
// Device-local: hide the top-bar menu button (drawer opens via edge-swipe).
val hideMenuButton: Boolean = false,
)
fun groupMemberKey(ownerId: Int): String = "gm:$ownerId"
const val GROUP_CALENDAR_KEY = "gc"
@HiltViewModel
class CalendarViewModel @Inject constructor(
private val repository: CalendarRepository,
@@ -68,7 +97,8 @@ class CalendarViewModel @Inject constructor(
init {
loadWritableCalendars()
initialLoad()
loadGroups()
initialLoad(reconcile = true)
}
/**
@@ -76,12 +106,15 @@ class CalendarViewModel @Inject constructor(
* mark ready. Entering the app fully-loaded avoids the first-open jank of
* loading a big batch while the user is already scrolling.
*/
private fun initialLoad() {
private fun initialLoad(reconcile: Boolean = false) {
val months = settingsStore.cacheMonths.toLong()
val today = LocalDate.now().withDayOfMonth(1)
val start = instant(today.minusMonths(months))
val end = instant(today.plusMonths(months + 1))
viewModelScope.launch {
// Honour a calendar hidden/shown on the web BEFORE the first load so
// the visibility filter is correct before events are rendered.
if (reconcile) reconcileCalendarVisibility()
loadRange(start, end, background = false)
markReady()
}
@@ -94,9 +127,100 @@ class CalendarViewModel @Inject constructor(
weekStartsOnMonday = s.weekStartsOnMonday,
hiddenKeys = settingsStore.hiddenCalendarKeys,
banishedKeys = settingsStore.banishedCalendarKeys,
reminderDisabledKeys = settingsStore.reminderDisabledCalendarKeys,
monthViewPaged = settingsStore.monthViewPaged,
hideMenuButton = settingsStore.hideMenuButton,
)
}
/** Re-read the device-local view prefs (called when settings close). */
fun refreshMonthViewMode() {
_state.update { it.copy(
monthViewPaged = settingsStore.monthViewPaged,
hideMenuButton = settingsStore.hideMenuButton,
) }
}
// Device-local calendar order ("source:id"), mirrors the web cal_order.
val calendarOrder: List<String> get() = settingsStore.calendarOrder
fun setCalendarOrder(keys: List<String>) { settingsStore.calendarOrder = keys }
// ---- Contacts birthday import (mirrors the iOS BirthdaysImporter) ----
var birthdaysSyncEnabled: Boolean
get() = settingsStore.birthdaysSyncEnabled
set(value) { settingsStore.birthdaysSyncEnabled = value }
/** Mirror this device's contact birthdays into the (existing) birthday
* calendar: reconcile rows scoped to this device's external_uid prefix
* (add / update / delete), then report the device. No-op if disabled or
* the birthday calendar hasn't been created. */
fun syncContactBirthdays(contacts: List<com.scarriffle.calendarr.data.ContactBirthday>, deviceName: String) {
if (!settingsStore.birthdaysSyncEnabled) return
viewModelScope.launch {
runCatching {
val cal = repository.getLocalCalendars().firstOrNull { it.isBirthday && it.owned }
?: return@runCatching
val existing = repository.getBirthdayEntries(cal.id)
val deviceId = settingsStore.birthdaysDeviceId
val prefix = "contact:$deviceId:"
val byExt = existing.filter { it.externalUid?.startsWith(prefix) == true }
.associateBy { it.externalUid!! }
val seen = mutableSetOf<String>()
for (c in contacts) {
val ext = prefix + c.contactId
seen.add(ext)
val (start, end) = birthdayRange(c.month, c.day, c.year)
val match = byExt[ext]
if (match != null) {
val changed = match.title != c.name || match.month != c.month ||
match.day != c.day || match.birthYear != c.year
if (changed) repository.updateLocalEvent(
uid = match.uid, title = c.name, start = start, end = end,
isAllDay = true, location = "", description = "", color = null,
rrule = "FREQ=YEARLY", birthYear = c.year, externalUid = ext,
)
} else {
repository.createLocalEvent(
calendarId = cal.id, title = c.name, start = start, end = end,
isAllDay = true, location = "", description = "", color = null,
rrule = "FREQ=YEARLY", birthYear = c.year, externalUid = ext,
)
}
}
byExt.filterKeys { it !in seen }.values.forEach { repository.deleteLocalEvent(it.uid) }
repository.reportBirthdaySync(deviceId, deviceName, contacts.size)
}
loadVisible(force = true)
}
}
private fun birthdayRange(month: Int, day: Int, year: Int?): Pair<Instant, Instant> {
val anchor = year ?: 2000 // leap-safe anchor for Feb 29 with unknown year
val date = LocalDate.of(anchor, month, day)
val start = date.atTime(12, 0).atZone(zone).toInstant()
val end = date.plusDays(1).atTime(12, 0).atZone(zone).toInstant()
return start to end
}
/** Default duration (minutes) for a new event's end time. */
val defaultEventDurationMinutes: Int get() = settingsStore.loadSettings().defaultEventDurationMinutes
/** Default reminder offset (minutes before start), or -1 when off. */
val defaultReminderMinutes: Int get() = settingsStore.loadSettings().defaultReminderMinutes ?: -1
/** Toggle a calendar's reminders without deleting any event reminders. */
fun setCalendarRemindersDisabled(key: String, disabled: Boolean) {
val keys = settingsStore.reminderDisabledCalendarKeys.toMutableSet()
if (disabled) keys.add(key) else keys.remove(key)
settingsStore.reminderDisabledCalendarKeys = keys
_state.update { it.copy(reminderDisabledKeys = keys) }
val parts = key.split(":")
val source = parts.getOrNull(0) ?: return
val id = parts.getOrNull(1)?.toIntOrNull() ?: return
viewModelScope.launch { runCatching { repository.setCalendarRemindersEnabled(source, id, enabled = !disabled) } }
}
// ---- Navigation ----
fun setViewType(type: CalViewType) {
@@ -174,7 +298,7 @@ class CalendarViewModel @Inject constructor(
return
}
viewModelScope.launch {
loadRange(start, end, background = false)
loadRange(start, end, background = false, force = force)
markReady()
}
}
@@ -189,19 +313,49 @@ class CalendarViewModel @Inject constructor(
}
/** Single serialized loader: avoids overlapping fetches that cause scroll jank. */
private suspend fun loadRange(start: Instant, end: Instant, background: Boolean) {
private suspend fun loadRange(start: Instant, end: Instant, background: Boolean, force: Boolean = false) {
loadMutex.withLock {
// Another load (e.g. the background prefetch) may have covered this range.
if (isCached(start, end)) return
// `force` bypasses this so callers can explicitly re-fetch an already-cached
// range (e.g. to pick up a mutation) without wiping the whole cache first.
if (!force && isCached(start, end)) return
val group = _state.value.activeGroup
val flag = if (background) "bg" else "fg"
_state.update { if (flag == "bg") it.copy(isBackgroundCaching = true) else it.copy(isLoading = true, error = null) }
runCatching { repository.fetchEvents(start, end) }
.onSuccess { mergeIntoCache(it, start, end); refreshFromCache() }
runCatching {
if (group != null) decorateGroup(repository.fetchGroupCombined(group.id, start, end)) to emptyList<SyncError>()
else repository.fetchEvents(start, end).let { it.events to it.errors }
}
.onSuccess { (events, errors) ->
val (keepKeys, keepSources) = failedCalendarKeys(errors)
mergeIntoCache(events, start, end, keepKeys, keepSources)
refreshFromCache()
_state.update { it.copy(syncErrors = errors) }
}
.onFailure { e -> if (!background) _state.update { it.copy(error = e.message) } }
_state.update { it.copy(isLoading = false, isBackgroundCaching = false) }
}
}
/** Prefix combined-view events with the owner's / creator's first name (and 👥 for group events). */
private fun decorateGroup(events: List<CalEvent>): List<CalEvent> {
val me = currentUserId
return events.map { ev ->
// Prefer the server-decorated title (group icon + owner prefix) so
// web, iOS and Android render identically; fall back for old servers.
val serverTitle = ev.displayTitle?.takeIf { it.isNotEmpty() }
if (serverTitle != null) return@map ev.copy(title = serverTitle)
val prefix = when {
ev.isGroupEvent && ev.creator != null && ev.creator.id != me -> "${firstName(ev.creator.displayName)}: "
ev.owner != null && ev.owner.id != me -> "${firstName(ev.owner.displayName)}: "
else -> ""
}
if (prefix.isEmpty()) ev else ev.copy(title = prefix + ev.title)
}
}
private fun firstName(s: String): String = s.trim().substringBefore(' ').ifBlank { s }
private fun markReady() {
_ready.value = true
}
@@ -212,21 +366,75 @@ class CalendarViewModel @Inject constructor(
return !cs.isAfter(start) && !ce.isBefore(end)
}
private fun mergeIntoCache(newEvents: List<CalEvent>, rangeStart: Instant, rangeEnd: Instant) {
val retained = allCachedEvents.filter {
!it.startDate.isBefore(rangeEnd) || !it.endDate.isAfter(rangeStart)
/**
* Split sync errors into per-calendar keys and whole sources whose cached
* events must NOT be evicted on a partial sync — stale data beats an empty
* calendar. An error carrying a calendar_id protects just that calendar; an
* account-level error without one (e.g. an HA / Google token-refresh
* failure, which aborts the whole account fetch) protects every cached
* calendar of that source.
*/
private fun failedCalendarKeys(errors: List<SyncError>): Pair<Set<String>, Set<String>> {
val keys = mutableSetOf<String>()
val sources = mutableSetOf<String>()
for (err in errors) {
val cid = err.calendarId
if (cid != null) keys.add(calendarKey(err.source, cid.toString()))
else sources.add(err.source)
}
return keys to sources
}
private fun mergeIntoCache(
newEvents: List<CalEvent>, rangeStart: Instant, rangeEnd: Instant,
keepKeysInRange: Set<String> = emptySet(),
keepSourcesInRange: Set<String> = emptySet(),
) {
// Remove old events in the fetched range to avoid duplicates — but
// PRESERVE events from calendars / sources that had sync errors so a
// transient CalDAV / Google / HA failure doesn't wipe the visible calendar.
val retained = allCachedEvents.filter { ev ->
val outsideRange = !ev.startDate.isBefore(rangeEnd) || !ev.endDate.isAfter(rangeStart)
when {
outsideRange -> true
ev.source in keepSourcesInRange -> true
keepKeysInRange.isEmpty() -> false
else -> calendarKey(ev.source, ev.calendarId) in keepKeysInRange
}
}
allCachedEvents = retained + newEvents
cachedStart = cachedStart?.let { minOf(it, rangeStart) } ?: rangeStart
cachedEnd = cachedEnd?.let { maxOf(it, rangeEnd) } ?: rangeEnd
// Only extend the cached range on a fully clean fetch. When some
// calendars/sources failed, leave cachedStart/End unchanged so
// isCached() stays false and they're retried on the next load rather
// than being silently treated as "done" with empty data.
if (keepKeysInRange.isEmpty() && keepSourcesInRange.isEmpty()) {
cachedStart = cachedStart?.let { minOf(it, rangeStart) } ?: rangeStart
cachedEnd = cachedEnd?.let { maxOf(it, rangeEnd) } ?: rangeEnd
}
}
private fun refreshFromCache() {
val hidden = _state.value.hiddenKeys
val banished = _state.value.banishedKeys
val visible = allCachedEvents.filter { ev ->
val key = calendarKey(ev.source, ev.calendarId)
key !in hidden && key !in banished
val st = _state.value
// In group mode: server scopes/filters by privacy; locally honour the
// per-member / group-calendar hide toggles (hiddenGroupKeys).
val visible = if (st.activeGroup != null) {
val hg = st.hiddenGroupKeys
if (hg.isEmpty()) allCachedEvents
else allCachedEvents.filter { ev ->
when {
ev.isGroupEvent -> GROUP_CALENDAR_KEY !in hg
ev.owner != null -> groupMemberKey(ev.owner.id ?: -1) !in hg
else -> true
}
}
} else {
val hidden = st.hiddenKeys
val banished = st.banishedKeys
if (hidden.isEmpty() && banished.isEmpty()) allCachedEvents
else allCachedEvents.filter { ev ->
val key = calendarKey(ev.source, ev.calendarId)
key !in hidden && key !in banished
}
}
// Skip the state write (and resulting recomposition) when nothing changed.
_state.update { if (it.events == visible) it else it.copy(events = visible) }
@@ -240,11 +448,61 @@ class CalendarViewModel @Inject constructor(
fun syncWithServer() {
invalidateCache()
initialLoad()
loadGroups()
initialLoad(reconcile = true)
}
/**
* Called when the app returns to the foreground. Re-checks the server's
* per-calendar visibility (a calendar may have been hidden/shown on the web
* or another device meanwhile) and reloads only if something changed.
*/
fun onAppResumed() {
viewModelScope.launch {
if (reconcileCalendarVisibility()) {
invalidateCache()
loadGroups()
initialLoad(reconcile = false) // just reconciled above
}
}
}
/**
* Reconcile the local **banished** set with the server's per-calendar
* `sidebar_hidden` flags for external calendars (CalDAV / Google / HA).
* Returns `true` if the set changed, so the caller can force a refetch — a
* calendar re-enabled on the web has NO events in the cache (the server
* excludes a hidden calendar's events entirely).
*
* NOTE: This deliberately drives `banishedKeys`, NOT `hiddenKeys`. The
* quick-hide (`hiddenKeys`) is a device-local filter and must never be
* overwritten from the server; only the "banish / permanently hide" state
* maps to the server's `sidebar_hidden` (mirrors iOS).
*/
private suspend fun reconcileCalendarVisibility(): Boolean {
val caldav = runCatching { repository.getCalDAVAccounts() }.getOrDefault(emptyList())
val google = runCatching { repository.getGoogleAccounts() }.getOrDefault(emptyList())
val ha = runCatching { repository.getHomeAssistantAccounts() }.getOrDefault(emptyList())
val banished = settingsStore.banishedCalendarKeys.toMutableSet()
fun apply(source: String, id: Int, serverHidden: Boolean) {
val key = calendarKey(source, id.toString())
if (serverHidden) banished.add(key) else banished.remove(key)
}
caldav.forEach { acc -> acc.calendars?.forEach { apply("caldav", it.id, it.sidebarHidden) } }
google.forEach { acc -> acc.calendars?.forEach { apply("google", it.id, it.sidebarHidden) } }
ha.forEach { acc -> acc.calendars?.forEach { apply("homeassistant", it.id, it.sidebarHidden) } }
if (banished == settingsStore.banishedCalendarKeys) return false
settingsStore.banishedCalendarKeys = banished
_state.update { it.copy(banishedKeys = banished) }
return true
}
fun clearError() = _state.update { it.copy(error = null) }
fun clearSyncErrors() = _state.update { it.copy(syncErrors = emptyList()) }
// ---- Visibility filters ----
fun setCalendarHidden(key: String, hidden: Boolean) {
@@ -262,6 +520,98 @@ class CalendarViewModel @Inject constructor(
refreshFromCache()
}
/** Distinct calendars present in the full cache, IGNORING the device-local
* quick-hide filter but excluding banished ones — for the filter sheet, so
* a locally hidden calendar still appears there and can be toggled back on. */
fun knownCalendars(): List<CalEvent> {
val banished = _state.value.banishedKeys
return allCachedEvents
.distinctBy { calendarKey(it.source, it.calendarId) }
.filter { calendarKey(it.source, it.calendarId) !in banished }
}
/**
* Load the full calendar list across all sources into [CalendarUiState.allCalendars]
* so the filter shows every calendar, even ones with no events in the loaded
* range. Called when the filter sheet opens. Read-only flag for shared local
* calendars from owned/permission; banished calendars are dropped.
*/
fun loadAllCalendars() {
viewModelScope.launch {
val banished = _state.value.banishedKeys
val entries = mutableListOf<CalendarFilterEntry>()
runCatching { repository.getLocalCalendars() }.getOrDefault(emptyList()).forEach { c ->
entries += CalendarFilterEntry(
key = calendarKey("local", c.id.toString()),
name = if (c.owned) c.name else (c.sharedBy ?: c.name),
color = c.color,
source = "local",
readOnly = !c.owned && c.permission != "read_write",
)
}
runCatching { repository.getCalDAVAccounts() }.getOrDefault(emptyList()).forEach { acc ->
acc.calendars.orEmpty().forEach { c ->
entries += CalendarFilterEntry(calendarKey("caldav", c.id.toString()), c.name, c.color ?: acc.color, "caldav")
}
}
runCatching { repository.getGoogleAccounts() }.getOrDefault(emptyList()).forEach { acc ->
acc.calendars.orEmpty().forEach { c ->
entries += CalendarFilterEntry(calendarKey("google", c.id.toString()), c.name, c.color ?: "#4285f4", "google")
}
}
runCatching { repository.getHomeAssistantAccounts() }.getOrDefault(emptyList()).forEach { acc ->
acc.calendars.orEmpty().forEach { c ->
entries += CalendarFilterEntry(calendarKey("homeassistant", c.id.toString()), c.name, c.color ?: "#46bdc6", "homeassistant")
}
}
runCatching { repository.getICalSubscriptions() }.getOrDefault(emptyList()).forEach { s ->
entries += CalendarFilterEntry(calendarKey("ical", s.id.toString()), s.name, s.color, "ical")
}
_state.update { st -> st.copy(allCalendars = entries.filter { it.key !in banished }) }
}
}
/** The user's single birthday calendar, or null if not activated yet. */
suspend fun birthdayCalendar(): LocalCalendar? =
runCatching { repository.getLocalCalendars() }.getOrDefault(emptyList())
.firstOrNull { it.isBirthday && it.owned }
/** The birthday calendar, creating the single one (named [name]) if none. */
suspend fun ensureBirthdayCalendar(name: String): LocalCalendar? {
birthdayCalendar()?.let { return it }
return runCatching {
repository.addLocalCalendar(name, "#E0407F", isBirthday = true)
}.getOrNull()
}
/**
* Create a manual birthday: an all-day, yearly-recurring local event whose
* age suffix and cake icon are added server-side. When the year is unknown
* we anchor to 1970 and send no birth_year (so no age is shown).
*/
fun createBirthday(
calendarId: Int, name: String, date: LocalDate, yearKnown: Boolean, onDone: () -> Unit,
) {
viewModelScope.launch {
val anchor = LocalDate.of(if (yearKnown) date.year else 1970, date.monthValue, date.dayOfMonth)
val start = Dates.startOfDay(anchor)
val end = Dates.startOfDay(anchor.plusDays(1))
runCatching {
repository.createLocalEvent(
calendarId, name, start, end, isAllDay = true,
location = "", description = "", color = null,
rrule = "FREQ=YEARLY", birthYear = if (yearKnown) date.year else null,
)
}.onSuccess { loadVisible(force = true); onDone() }
}
}
/**
* Banish ("permanently hide") a calendar, or lift the banish. Unlike the
* quick-hide, this DOES sync to the server (`sidebar_hidden`/`enabled`) for
* external calendars, matching iOS. Banishing also clears any local
* quick-hide flag for the same key (redundant once banished).
*/
fun setCalendarBanished(key: String, banished: Boolean) {
val nextBanished = _state.value.banishedKeys.toMutableSet().apply {
if (banished) add(key) else remove(key)
@@ -273,6 +623,70 @@ class CalendarViewModel @Inject constructor(
settingsStore.hiddenCalendarKeys = nextHidden
_state.update { it.copy(banishedKeys = nextBanished, hiddenKeys = nextHidden) }
refreshFromCache()
val parts = key.split(":")
val source = parts.getOrNull(0)
val id = parts.getOrNull(1)?.toIntOrNull()
if (source != null && id != null && source in listOf("caldav", "google", "homeassistant")) {
viewModelScope.launch {
runCatching { repository.setCalendarSidebarHidden(source, id, banished) }
.onFailure { e -> _state.update { it.copy(error = e.message ?: "Fehler beim Speichern") } }
// Un-banishing re-enables the calendar on the server, but its
// events were excluded while hidden — force a refetch so they
// reappear without a manual sync.
if (!banished) {
invalidateCache()
initialLoad(reconcile = false)
}
}
}
}
// ---- Groups ----
fun loadGroups() {
viewModelScope.launch {
runCatching { repository.getGroups() }
.onSuccess { gs ->
_state.update { st ->
// If the active group was deleted elsewhere, drop back to personal.
val stillActive = st.activeGroup?.let { a -> gs.firstOrNull { it.id == a.id } }
st.copy(groups = gs, activeGroup = stillActive)
}
}
}
}
/** Flip between personal and a group's combined overlay; reloads the wide window. */
fun switchGroup(group: Group?) {
if (_state.value.activeGroup?.id == group?.id) return
_state.update {
it.copy(activeGroup = group, hiddenGroupKeys = emptySet(), activeGroupMembers = emptyList())
}
invalidateCache()
initialLoad()
// Load the full member list (with server colours) for the filter sheet.
if (group != null) {
viewModelScope.launch {
runCatching { repository.getGroup(group.id) }
.onSuccess { g -> _state.update { it.copy(activeGroupMembers = g.members) } }
}
}
}
/** Toggle a single member's calendar / the group calendar in the overlay. */
fun setGroupKeyHidden(key: String, hidden: Boolean) {
_state.update {
val next = it.hiddenGroupKeys.toMutableSet().apply { if (hidden) add(key) else remove(key) }
it.copy(hiddenGroupKeys = next)
}
refreshFromCache()
}
/** Replace the group-overlay hidden set (bulk show/hide all). */
fun setHiddenGroupKeys(keys: Set<String>) {
_state.update { it.copy(hiddenGroupKeys = keys) }
refreshFromCache()
}
// ---- Writable calendars ----
@@ -286,9 +700,23 @@ class CalendarViewModel @Inject constructor(
// ---- Event mutations ----
/**
* Re-fetch just the already-cached range after a create/update so the edit is
* reflected everywhere it's currently loaded, without nuking and reloading the
* whole ±cacheMonths window (which caused a full-screen freeze on every save).
* Falls back to [initialLoad] if nothing was cached yet.
*/
private fun afterMutation() {
invalidateCache()
initialLoad()
val start = cachedStart
val end = cachedEnd
if (start == null || end == null) {
initialLoad()
return
}
viewModelScope.launch {
loadRange(start, end, background = false, force = true)
markReady()
}
}
fun saveEvent(
@@ -302,20 +730,21 @@ class CalendarViewModel @Inject constructor(
description: String,
color: String?,
isPrivate: Boolean,
reminders: List<Int> = emptyList(),
onResult: (String?) -> Unit,
) {
viewModelScope.launch {
val result = runCatching {
if (existing != null && existing.source == calendar.source) {
when (existing.source) {
"local" -> repository.updateLocalEvent(existing.id, title, start, end, isAllDay, location, description, color, isPrivate)
"local" -> repository.updateLocalEvent(existing.id, title, start, end, isAllDay, location, description, color, isPrivate, reminders)
"caldav" -> repository.updateCalDAVEvent(existing.id, existing.url, calendar.numericId, title, start, end, isAllDay, location, description, color)
"homeassistant" -> repository.updateHAEvent(calendar.numericId, existing.id, title, start, end, isAllDay, location, description)
"google" -> repository.updateGoogleEvent(calendar.numericId, existing.id, title, start, end, isAllDay, location, description)
else -> createForSource(calendar, title, start, end, isAllDay, location, description, color, isPrivate)
else -> createForSource(calendar, title, start, end, isAllDay, location, description, color, isPrivate, reminders)
}
} else {
createForSource(calendar, title, start, end, isAllDay, location, description, color, isPrivate)
createForSource(calendar, title, start, end, isAllDay, location, description, color, isPrivate, reminders)
}
}
result.onSuccess { afterMutation(); onResult(null) }
@@ -326,9 +755,10 @@ class CalendarViewModel @Inject constructor(
private suspend fun createForSource(
calendar: WritableCalendar, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String, color: String?, isPrivate: Boolean,
reminders: List<Int> = emptyList(),
) {
when (calendar.source) {
"local" -> repository.createLocalEvent(calendar.numericId, title, start, end, isAllDay, location, description, color, isPrivate)
"local" -> repository.createLocalEvent(calendar.numericId, title, start, end, isAllDay, location, description, color, isPrivate, reminders)
"caldav" -> repository.createCalDAVEvent(calendar.numericId, title, start, end, isAllDay, location, description, color)
"google" -> repository.createGoogleEvent(calendar.numericId, title, start, end, isAllDay, location, description)
"homeassistant" -> repository.createHAEvent(calendar.numericId, title, start, end, isAllDay, location, description)

View File

@@ -0,0 +1,255 @@
package com.scarriffle.calendarr.ui.calendar
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.fillMaxWidth
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.filled.Add
import androidx.compose.material.icons.filled.CalendarViewWeek
import androidx.compose.material.icons.filled.WbSunny
import androidx.compose.material3.Divider
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.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.geometry.toRect
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import com.scarriffle.calendarr.domain.model.CalEvent
import com.scarriffle.calendarr.ui.L10n
import com.scarriffle.calendarr.ui.LocalLang
import com.scarriffle.calendarr.ui.tr
import com.scarriffle.calendarr.util.colorFromHex
import com.scarriffle.calendarr.util.contrastingTextColor
import java.time.LocalDate
import java.time.format.TextStyle
/**
* Long-press day preview (mirrors the iOS DayContextPreviewView): all-day
* events as colored bars with tapered ends when they continue beyond this day,
* timed events as dot + time + title rows, followed by quick actions.
*/
@Composable
fun DayPreviewDialog(
date: LocalDate,
events: List<CalEvent>,
onDismiss: () -> Unit,
onEventClick: (CalEvent) -> Unit,
onCreateEvent: () -> Unit,
onOpenDay: () -> Unit,
onOpenWeek: () -> Unit,
) {
val lang = LocalLang.current
val sorted = remember(events) {
events.sortedWith(compareByDescending<CalEvent> { it.isAllDay }.thenBy { it.startDate })
}
val weekdayAbbr = remember(date, lang) {
date.dayOfWeek.getDisplayName(TextStyle.SHORT, L10n.locale(lang)).uppercase()
}
Dialog(onDismissRequest = onDismiss) {
Surface(
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 3.dp,
) {
Column(
Modifier
.width(290.dp)
.padding(vertical = 14.dp)
.verticalScroll(rememberScrollState()),
) {
Row(
Modifier.padding(horizontal = 16.dp),
verticalAlignment = Alignment.Bottom,
) {
Text(
weekdayAbbr,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(end = 6.dp, bottom = 3.dp),
)
Text(
"${date.dayOfMonth}",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
)
}
Divider(Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
if (sorted.isEmpty()) {
Text(
tr("cal.no_events_day"),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
} else {
sorted.forEach { ev ->
if (ev.isAllDay) {
AllDayPreviewBar(event = ev, date = date, onClick = { onEventClick(ev) })
} else {
TimedPreviewRow(event = ev, lang = lang, onClick = { onEventClick(ev) })
}
}
}
Divider(Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
PreviewAction(Icons.Filled.Add, tr("cal.new_event"), onCreateEvent)
PreviewAction(Icons.Filled.CalendarViewWeek, tr("cal.show_in_week_view"), onOpenWeek)
PreviewAction(Icons.Filled.WbSunny, tr("cal.show_in_day_view"), onOpenDay)
}
}
}
}
@Composable
private fun AllDayPreviewBar(event: CalEvent, date: LocalDate, onClick: () -> Unit) {
val color = colorFromHex(event.effectiveColor)
val startDay = localDate(event.startDate)
val lastDay = localDate(event.endDate.minusSeconds(1)) // all-day end is exclusive
val cLeft = startDay.isBefore(date)
val cRight = lastDay.isAfter(date)
val shape = remember(cLeft, cRight) { ChevronBarShape(cLeft, cRight) }
Box(
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 2.dp)
.clip(shape)
.background(color)
.clickable(onClick = onClick)
.padding(
start = if (cLeft) 14.dp else 8.dp,
end = if (cRight) 14.dp else 8.dp,
top = 4.dp,
bottom = 4.dp,
),
) {
EventLabel(
event = event,
color = color.contrastingTextColor(),
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
)
}
}
@Composable
private fun TimedPreviewRow(event: CalEvent, lang: String, onClick: () -> Unit) {
Row(
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
Modifier
.size(7.dp)
.clip(CircleShape)
.background(colorFromHex(event.effectiveColor)),
)
Text(
timeLabel(event.startDate, lang),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp).width(42.dp),
)
EventLabel(
event = event,
color = MaterialTheme.colorScheme.onSurface,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f),
)
}
}
@Composable
private fun PreviewAction(icon: ImageVector, label: String, onClick: () -> Unit) {
Row(
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 9.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
icon,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(12.dp))
Text(label, style = MaterialTheme.typography.bodyMedium)
}
}
/**
* Bar shape with tapered (pointed) ends on the sides where the event continues
* beyond the shown day — port of the iOS ChevronBarShape.
*/
private class ChevronBarShape(
private val continuesLeft: Boolean,
private val continuesRight: Boolean,
) : Shape {
override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline {
val r = with(density) { 4.dp.toPx() }
if (!continuesLeft && !continuesRight) {
return Outline.Rounded(RoundRect(size.toRect(), CornerRadius(r)))
}
val t = with(density) { 8.dp.toPx() }.coerceAtMost(size.width / 2)
val p = Path()
if (continuesLeft && continuesRight) {
p.moveTo(t, 0f)
p.lineTo(size.width - t, 0f)
p.lineTo(size.width, size.height / 2)
p.lineTo(size.width - t, size.height)
p.lineTo(t, size.height)
p.lineTo(0f, size.height / 2)
} else if (continuesLeft) {
p.moveTo(t, 0f)
p.lineTo(size.width, 0f)
p.lineTo(size.width, size.height)
p.lineTo(t, size.height)
p.lineTo(0f, size.height / 2)
} else {
p.moveTo(0f, 0f)
p.lineTo(size.width - t, 0f)
p.lineTo(size.width, size.height / 2)
p.lineTo(size.width - t, size.height)
p.lineTo(0f, size.height)
}
p.close()
return Outline.Generic(p)
}
}

View File

@@ -0,0 +1,57 @@
package com.scarriffle.calendarr.ui.calendar
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cake
import androidx.compose.material3.Icon
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.scarriffle.calendarr.domain.model.CalEvent
/**
* Event label for the calendar grids: an optional leading cake icon for
* birthday events, followed by the render title (which carries the
* server-computed age). The icon inherits the text colour/size so the label
* matches whatever bar it's dropped into.
*/
@Composable
fun EventLabel(
event: CalEvent,
color: Color,
fontSize: TextUnit = 10.sp,
fontWeight: FontWeight = FontWeight.Medium,
maxLines: Int = 1,
modifier: Modifier = Modifier,
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
if (event.isBirthday) {
Icon(
Icons.Filled.Cake,
contentDescription = null,
tint = color,
modifier = Modifier
.size(fontSize.value.dp)
.padding(end = 2.dp),
)
}
Text(
event.renderTitle,
maxLines = maxLines,
overflow = TextOverflow.Ellipsis,
fontSize = fontSize,
fontWeight = fontWeight,
color = color,
modifier = Modifier.weight(1f, fill = false),
)
}
}

View File

@@ -16,6 +16,8 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -62,7 +64,8 @@ private const val MAX_LANES = 4
private val DAY_NUM_H = 22.dp
private val LANE_H = 15.dp
private val LANE_SPACE = 2.dp
private val ROW_HEIGHT = DAY_NUM_H + (LANE_H + LANE_SPACE) * MAX_LANES + 6.dp
// Bottom strip must fit the 8sp "+N"/"KW" labels without event bars overlapping them.
private val ROW_HEIGHT = DAY_NUM_H + (LANE_H + LANE_SPACE) * MAX_LANES + 16.dp
private enum class DividerEdge { NONE, TOP, BOTTOM }
@@ -78,7 +81,10 @@ private data class PlacedBar(
private class WeekLayout(val bars: List<PlacedBar>, val overflowPerCol: IntArray)
/** Continuous, vertically scrolling month calendar with multi-day event bars (iOS-style). */
/** Month calendar with multi-day event bars (iOS-style). Two modes: a
* continuous vertical scroll feed, or a horizontally-paged one-month-per-screen
* grid (swipe left/right), toggled by [CalendarUiState.monthViewPaged]. */
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MonthView(
state: CalendarUiState,
@@ -97,11 +103,14 @@ fun MonthView(
val mondayFirst = state.weekStartsOnMonday
val today = LocalDate.now()
val dividerColor = colorFromHex(settings.monthDividerColor, Color(0xFF7090C0))
val labelColor = colorFromHex(settings.monthLabelColor, MaterialTheme.colorScheme.onSurfaceVariant)
val gridColor = MaterialTheme.colorScheme.outline.copy(alpha = gridLineOpacity(settings.lineContrast))
val secondaryText = MaterialTheme.colorScheme.onBackground.copy(alpha = secondaryTextOpacity(settings.textContrast))
val todayColor = colorFromHex(settings.todayColor)
val fallbackLabel = MaterialTheme.colorScheme.onSurfaceVariant
val outline = MaterialTheme.colorScheme.outline
val onBackground = MaterialTheme.colorScheme.onBackground
val dividerColor = remember(settings.monthDividerColor) { colorFromHex(settings.monthDividerColor, Color(0xFF7090C0)) }
val labelColor = remember(settings.monthLabelColor, fallbackLabel) { colorFromHex(settings.monthLabelColor, fallbackLabel) }
val gridColor = remember(outline, settings.lineContrast) { outline.copy(alpha = gridLineOpacity(settings.lineContrast)) }
val secondaryText = remember(onBackground, settings.textContrast) { onBackground.copy(alpha = secondaryTextOpacity(settings.textContrast)) }
val todayColor = remember(settings.todayColor) { colorFromHex(settings.todayColor) }
// Column width measured once → no per-row BoxWithConstraints (smooth scrolling).
val density = LocalDensity.current
@@ -123,28 +132,58 @@ fun MonthView(
val todayIndex = remember(firstVisible) { weekIndexOf(today) }
LaunchedEffect(Unit) { listState.scrollToItem((todayIndex - 1).coerceAtLeast(0)) }
LaunchedEffect(scrollToTodaySignal) {
if (scrollToTodaySignal > 0) listState.animateScrollToItem((todayIndex - 1).coerceAtLeast(0))
}
LaunchedEffect(monthJumpSignal) {
if (monthJumpSignal > 0 && monthJumpTarget != null) {
listState.animateScrollToItem(weekIndexOf(monthJumpTarget.withDayOfMonth(1)))
}
}
LaunchedEffect(listState, weekCount) {
snapshotFlow { listState.firstVisibleItemIndex }
.map { firstVisible.plusWeeks(it.toLong()).plusDays(3).withDayOfMonth(1) }
.distinctUntilChanged()
.collect { month ->
onVisibleMonthChange(month)
vm.ensureMonthLoaded(month)
}
}
// Month-paged mode bookkeeping (one page per calendar month).
val firstMonth = remember { today.withDayOfMonth(1).minusMonths(MONTHS_BACK) }
val monthCount = remember { (MONTHS_BACK + MONTHS_AHEAD + 1).toInt() }
fun monthIndexOf(date: LocalDate): Int =
ChronoUnit.MONTHS.between(firstMonth, date.withDayOfMonth(1)).toInt().coerceIn(0, monthCount - 1)
val todayMonthIndex = remember(firstMonth) { monthIndexOf(today) }
val pagerState = rememberPagerState(initialPage = todayMonthIndex) { monthCount }
val eventsByWeek = remember(state.events, mondayFirst) { buildEventsByWeek(state.events, mondayFirst) }
val dimPast = settings.dimPastEvents
val now = java.time.Instant.now()
val cwLabel = tr("cal.cw")
// remember: a fresh Instant per recomposition would invalidate every visible
// WeekRow (the main source of scroll jank); minute precision is plenty here.
val now = remember { java.time.Instant.now() }
// Route the title / prev-next / today signals to the active surface (pager or list).
if (state.monthViewPaged) {
LaunchedEffect(scrollToTodaySignal) {
if (scrollToTodaySignal > 0) pagerState.animateScrollToPage(todayMonthIndex)
}
LaunchedEffect(monthJumpSignal) {
if (monthJumpSignal > 0 && monthJumpTarget != null) pagerState.animateScrollToPage(monthIndexOf(monthJumpTarget))
}
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.currentPage }
.map { firstMonth.plusMonths(it.toLong()) }
.distinctUntilChanged()
.collect { month ->
onVisibleMonthChange(month)
vm.ensureMonthLoaded(month)
}
}
} else {
LaunchedEffect(Unit) { listState.scrollToItem((todayIndex - 1).coerceAtLeast(0)) }
LaunchedEffect(scrollToTodaySignal) {
if (scrollToTodaySignal > 0) listState.animateScrollToItem((todayIndex - 1).coerceAtLeast(0))
}
LaunchedEffect(monthJumpSignal) {
if (monthJumpSignal > 0 && monthJumpTarget != null) {
listState.animateScrollToItem(weekIndexOf(monthJumpTarget.withDayOfMonth(1)))
}
}
LaunchedEffect(listState, weekCount) {
snapshotFlow { listState.firstVisibleItemIndex }
.map { firstVisible.plusWeeks(it.toLong()).plusDays(3).withDayOfMonth(1) }
.distinctUntilChanged()
.collect { month ->
onVisibleMonthChange(month)
vm.ensureMonthLoaded(month)
}
}
}
Column(Modifier.fillMaxSize()) {
Row(Modifier.fillMaxWidth().padding(vertical = 3.dp)) {
@@ -159,29 +198,66 @@ fun MonthView(
}
}
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize().onSizeChanged { gridWidthPx = it.width },
) {
items(weekCount) { index ->
val weekStart = firstVisible.plusWeeks(index.toLong())
WeekRow(
weekStart = weekStart,
today = today,
weekEvents = eventsByWeek[weekStart] ?: emptyList(),
cellW = cellW,
dimPast = dimPast,
now = now,
lang = lang,
dividerColor = dividerColor,
gridColor = gridColor,
labelColor = labelColor,
secondaryText = secondaryText,
todayColor = todayColor,
onDayClick = onDayClick,
onDayLongPress = onDayLongPress,
onEventClick = onEventClick,
)
if (state.monthViewPaged) {
// One month per page, six height-filling week rows, swipe to change month.
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize().onSizeChanged { gridWidthPx = it.width },
) { page ->
val month = firstMonth.plusMonths(page.toLong())
val firstWeek = startOfWeek(month.withDayOfMonth(1), mondayFirst)
Column(Modifier.fillMaxSize()) {
repeat(6) { w ->
val weekStart = firstWeek.plusWeeks(w.toLong())
WeekRow(
rowModifier = Modifier.fillMaxWidth().weight(1f),
weekStart = weekStart,
today = today,
weekEvents = eventsByWeek[weekStart] ?: emptyList(),
cellW = cellW,
dimPast = dimPast,
now = now,
lang = lang,
cwLabel = cwLabel,
dividerColor = dividerColor,
gridColor = gridColor,
labelColor = labelColor,
secondaryText = secondaryText,
todayColor = todayColor,
onDayClick = onDayClick,
onDayLongPress = onDayLongPress,
onEventClick = onEventClick,
)
}
}
}
} else {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize().onSizeChanged { gridWidthPx = it.width },
) {
items(weekCount, key = { it }, contentType = { "week" }) { index ->
val weekStart = firstVisible.plusWeeks(index.toLong())
WeekRow(
rowModifier = Modifier.fillMaxWidth().height(ROW_HEIGHT),
weekStart = weekStart,
today = today,
weekEvents = eventsByWeek[weekStart] ?: emptyList(),
cellW = cellW,
dimPast = dimPast,
now = now,
lang = lang,
cwLabel = cwLabel,
dividerColor = dividerColor,
gridColor = gridColor,
labelColor = labelColor,
secondaryText = secondaryText,
todayColor = todayColor,
onDayClick = onDayClick,
onDayLongPress = onDayLongPress,
onEventClick = onEventClick,
)
}
}
}
}
@@ -189,6 +265,7 @@ fun MonthView(
@Composable
private fun WeekRow(
rowModifier: Modifier,
weekStart: LocalDate,
today: LocalDate,
weekEvents: List<CalEvent>,
@@ -196,6 +273,7 @@ private fun WeekRow(
dimPast: Boolean,
now: java.time.Instant,
lang: String,
cwLabel: String,
dividerColor: Color,
gridColor: Color,
labelColor: Color,
@@ -208,11 +286,12 @@ private fun WeekRow(
val days = remember(weekStart) { (0 until 7).map { weekStart.plusDays(it.toLong()) } }
val boundaryCol = (1 until 7).firstOrNull { days[it].dayOfMonth == 1 }
val rowStartsNewMonth = days[0].dayOfMonth == 1
val cwLabel = tr("cal.cw")
val packed = remember(weekStart, weekEvents) { packEvents(weekStart, weekEvents) }
Box(Modifier.fillMaxWidth().height(ROW_HEIGHT)) {
// Scroll mode passes a fixed ROW_HEIGHT; paged mode passes weight(1f) so six
// rows fill the screen. Event bars anchor to the top; the day cells fill height.
Box(rowModifier) {
Row(Modifier.fillMaxSize()) {
days.forEachIndexed { idx, day ->
val edge = when {
@@ -275,13 +354,11 @@ private fun EventBar(bar: PlacedBar, cellW: Dp, dimmed: Boolean, onClick: () ->
.padding(horizontal = 4.dp),
contentAlignment = Alignment.CenterStart,
) {
Text(
bar.event.title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
EventLabel(
event = bar.event,
color = bar.textColor,
fontSize = 10.sp,
fontWeight = FontWeight.Medium,
color = bar.textColor,
)
}
}

View File

@@ -4,6 +4,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -18,6 +19,7 @@ import androidx.compose.material3.Divider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
@@ -38,6 +40,9 @@ import java.time.format.DateTimeFormatter
private val GUTTER = 48.dp
/** An all-day event laid out as a bar spanning columns [start]..[end] in lane [lane]. */
private data class AllDayBar(val ev: CalEvent, val start: Int, val end: Int, val lane: Int)
@Composable
fun TimeGridView(
days: List<LocalDate>,
@@ -47,16 +52,21 @@ fun TimeGridView(
) {
val hourHeight = LocalAppSettings.current.hourHeight.coerceIn(28, 100).dp
val dimPast = LocalAppSettings.current.dimPastEvents
val now = java.time.Instant.now()
val lang = LocalLang.current
val today = LocalDate.now()
// Recomputed once per day (not on every recomposition/frame) — Instant.now()
// was previously called fresh each recomposition, causing visible flicker in
// the "is this now" past-dimming and wasted allocation.
val now = remember(today) { java.time.Instant.now() }
val lang = LocalLang.current
Column(Modifier.fillMaxSize()) {
// Day headers (only for multi-day / week view)
if (days.size > 1) {
Row(Modifier.fillMaxWidth()) {
Box(Modifier.width(GUTTER))
val dayFmt = DateTimeFormatter.ofPattern("EEE d", com.scarriffle.calendarr.ui.L10n.locale(lang))
val dayFmt = remember(lang) {
DateTimeFormatter.ofPattern("EEE d", com.scarriffle.calendarr.ui.L10n.locale(lang))
}
days.forEach { day ->
Text(
dayFmt.format(day),
@@ -70,16 +80,48 @@ fun TimeGridView(
}
}
// All-day row
val allDay = days.map { d -> d to vm.eventsOn(d, state.events).filter { it.isAllDay } }
if (allDay.any { it.second.isNotEmpty() }) {
Row(Modifier.fillMaxWidth().padding(bottom = 2.dp)) {
Box(Modifier.width(GUTTER), contentAlignment = Alignment.Center) {
Text(com.scarriffle.calendarr.ui.tr("cal.allday"), fontSize = 8.sp, color = MaterialTheme.colorScheme.outline)
// All-day row: continuous bars spanning an event's start→end columns and
// packed into lanes, instead of repeating a multi-day event once per day.
val perDay = days.map { d -> vm.eventsOn(d, state.events).filter { it.isAllDay } }
if (perDay.any { it.isNotEmpty() }) {
// event id -> [minCol, maxCol] within this week
val range = LinkedHashMap<String, IntArray>()
val evById = HashMap<String, CalEvent>()
perDay.forEachIndexed { i, evs ->
evs.forEach { ev ->
evById[ev.id] = ev
val r = range[ev.id]
if (r == null) range[ev.id] = intArrayOf(i, i) else r[1] = i
}
allDay.forEach { (_, evs) ->
Column(Modifier.weight(1f).padding(horizontal = 1.dp)) {
evs.forEach { ev -> AllDayChip(ev, dimmed = dimPast && ev.endDate.isBefore(now), onClick = { onEventClick(ev) }) }
}
// earlier / longer first, then greedily pack into lanes
val spans = range.entries
.map { Triple(evById.getValue(it.key), it.value[0], it.value[1]) }
.sortedWith(compareBy({ it.second }, { -(it.third - it.second) }))
val laneEnd = ArrayList<Int>()
val bars = ArrayList<AllDayBar>()
for ((ev, s, e) in spans) {
var lane = 0
while (lane < laneEnd.size && laneEnd[lane] >= s) lane++
if (lane == laneEnd.size) laneEnd.add(e) else laneEnd[lane] = e
bars.add(AllDayBar(ev, s, e, lane))
}
val laneCount = (bars.maxOfOrNull { it.lane } ?: -1) + 1
Column(Modifier.fillMaxWidth().padding(bottom = 2.dp)) {
for (lane in 0 until laneCount) {
Row(Modifier.fillMaxWidth()) {
Box(Modifier.width(GUTTER), contentAlignment = Alignment.Center) {
if (lane == 0) Text(com.scarriffle.calendarr.ui.tr("cal.allday"), fontSize = 8.sp, color = MaterialTheme.colorScheme.outline)
}
var col = 0
bars.filter { it.lane == lane }.sortedBy { it.start }.forEach { bar ->
if (bar.start > col) { Spacer(Modifier.weight((bar.start - col).toFloat())); col = bar.start }
Box(Modifier.weight((bar.end - bar.start + 1).toFloat()).padding(horizontal = 1.dp)) {
AllDayChip(bar.ev, dimmed = dimPast && bar.ev.endDate.isBefore(now), onClick = { onEventClick(bar.ev) })
}
col = bar.end + 1
}
if (col < days.size) Spacer(Modifier.weight((days.size - col).toFloat()))
}
}
}
@@ -161,13 +203,12 @@ private fun TimedEvent(
.padding(horizontal = 4.dp, vertical = 2.dp),
) {
Column {
Text(
event.title,
fontSize = 10.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
EventLabel(
event = event,
color = color.contrastingTextColor(),
fontSize = 10.sp,
fontWeight = FontWeight.Medium,
maxLines = 2,
)
if (height > 36.dp) {
Text(
@@ -206,7 +247,7 @@ private fun AllDayChip(event: CalEvent, dimmed: Boolean, onClick: () -> Unit) {
.clickable(onClick = onClick)
.padding(horizontal = 3.dp, vertical = 1.dp),
) {
Text(event.title, fontSize = 9.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, color = color.contrastingTextColor())
EventLabel(event = event, color = color.contrastingTextColor(), fontSize = 9.sp)
}
}

View File

@@ -28,7 +28,6 @@ import androidx.compose.material.icons.filled.Notes
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -99,7 +98,7 @@ fun EventDetailScreen(
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(event.title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold)
Text(event.renderTitle, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold)
if (event.calendarName.isNotBlank()) {
Text(event.calendarName, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
@@ -126,13 +125,15 @@ fun EventDetailScreen(
Text(tr("event.copy_title"))
}
if (canDelete) {
Spacer(Modifier.height(12.dp))
Button(
Spacer(Modifier.height(4.dp))
// Deliberately low-key (plain text button): destructive action
// should be findable, not the loudest element on the screen.
TextButton(
onClick = { confirmDelete = true },
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.errorContainer, contentColor = MaterialTheme.colorScheme.onErrorContainer),
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Filled.Delete, contentDescription = null)
Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(tr("common.delete"))
}

View File

@@ -11,15 +11,21 @@ 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.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
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.text.KeyboardOptions
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.ArrowDropDown
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.IconButton
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
@@ -43,8 +49,10 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.scarriffle.calendarr.domain.model.CalEvent
import com.scarriffle.calendarr.domain.model.ReminderOptions
import com.scarriffle.calendarr.domain.model.WritableCalendar
import com.scarriffle.calendarr.ui.L10n
import com.scarriffle.calendarr.ui.LocalLang
@@ -69,7 +77,9 @@ fun EventEditorSheet(
request: EditorRequest,
writableCalendars: List<WritableCalendar>,
onDismiss: () -> Unit,
onSave: (WritableCalendar, String, Instant, Instant, Boolean, String, String, String?, Boolean) -> Unit,
defaultDurationMinutes: Int = 60,
reminderDisabledKeys: Set<String> = emptySet(),
onSave: (WritableCalendar, String, Instant, Instant, Boolean, String, String, String?, Boolean, List<Int>) -> Unit,
) {
val zone = ZoneId.systemDefault()
val context = LocalContext.current
@@ -95,17 +105,20 @@ fun EventEditorSheet(
var startTime by remember {
mutableStateOf(initialStart?.let { LocalTime.ofInstant(it, zone).withSecond(0).withNano(0) } ?: LocalTime.of(9, 0))
}
// New events default to start (09:00) + the user's default duration.
val defaultEnd = request.date.atTime(9, 0).plusMinutes(defaultDurationMinutes.toLong())
var endDate by remember {
mutableStateOf(
initialEnd?.let {
val d = LocalDate.ofInstant(it, zone)
if (template?.isAllDay == true) d.minusDays(1) else d
} ?: request.date
} ?: defaultEnd.toLocalDate()
)
}
var endTime by remember {
mutableStateOf(initialEnd?.let { LocalTime.ofInstant(it, zone).withSecond(0).withNano(0) } ?: LocalTime.of(10, 0))
mutableStateOf(initialEnd?.let { LocalTime.ofInstant(it, zone).withSecond(0).withNano(0) } ?: defaultEnd.toLocalTime())
}
var reminders by remember { mutableStateOf(template?.reminders ?: emptyList<Int>()) }
val preselected = template?.let { ev ->
val id = calendarKey(ev.source, ev.calendarId).substringAfter(":").toIntOrNull()
@@ -138,6 +151,8 @@ fun EventEditorSheet(
Column(
Modifier
.fillMaxWidth()
.navigationBarsPadding()
.imePadding()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp)
.padding(bottom = 32.dp),
@@ -231,13 +246,42 @@ fun EventEditorSheet(
}
Spacer(Modifier.size(12.dp))
// Private (local calendars only)
// Private + reminders (local calendars only)
if (calendar?.source == "local") {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
Text(tr("event.private"), style = MaterialTheme.typography.bodyLarge)
Switch(checked = isPrivate, onCheckedChange = { isPrivate = it })
}
Spacer(Modifier.size(12.dp))
val remindersDisabled = calendar?.let {
reminderDisabledKeys.contains(calendarKey(it.source, it.numericId.toString()))
} ?: false
Text(tr("event.reminders"), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
if (remindersDisabled) {
Text(
tr("event.reminders_disabled"),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.padding(top = 2.dp),
)
}
reminders.forEachIndexed { idx, min ->
ReminderRow(
minutes = min,
enabled = !remindersDisabled,
onChange = { v -> reminders = reminders.toMutableList().also { it[idx] = v } },
onRemove = { reminders = reminders.toMutableList().also { it.removeAt(idx) } },
)
}
androidx.compose.material3.TextButton(
enabled = !remindersDisabled,
onClick = {
val next = ReminderOptions.presets.firstOrNull { it !in reminders } ?: ReminderOptions.customDefault
reminders = reminders + next
},
) { Text(tr("event.reminder_add")) }
Spacer(Modifier.size(12.dp))
}
// Color
@@ -280,7 +324,8 @@ fun EventEditorSheet(
start = startDate.atTime(startTime).atZone(zone).toInstant()
end = endDate.atTime(endTime).atZone(zone).toInstant()
}
onSave(cal, title.trim(), start, end, allDay, location.trim(), description.trim(), color, isPrivate && cal.source == "local")
val rem = if (cal.source == "local") reminders else emptyList()
onSave(cal, title.trim(), start, end, allDay, location.trim(), description.trim(), color, isPrivate && cal.source == "local", rem)
},
enabled = writableCalendars.isNotEmpty(),
modifier = Modifier.fillMaxWidth(),
@@ -290,3 +335,73 @@ fun EventEditorSheet(
}
}
}
/** One reminder row: a preset dropdown plus, in custom mode, a number field +
* unit dropdown. The value is always emitted as minutes-before-start. */
@Composable
private fun ReminderRow(
minutes: Int,
enabled: Boolean,
onChange: (Int) -> Unit,
onRemove: () -> Unit,
) {
val isPreset = minutes in ReminderOptions.presets
var presetMenu by remember { mutableStateOf(false) }
var unitMenu by remember { mutableStateOf(false) }
@Composable
fun label(min: Int): String =
if (min == 0) tr("event.reminder_at_start")
else ReminderOptions.split(min).let { (v, u) -> "$v ${tr(u.labelKey)} ${tr("event.reminder_before")}" }
Column(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Box(Modifier.weight(1f)) {
OutlinedButton(onClick = { presetMenu = true }, enabled = enabled, modifier = Modifier.fillMaxWidth()) {
Text(if (isPreset) label(minutes) else tr("event.reminder_custom"), modifier = Modifier.weight(1f))
Icon(Icons.Filled.ArrowDropDown, contentDescription = null)
}
DropdownMenu(expanded = presetMenu, onDismissRequest = { presetMenu = false }) {
ReminderOptions.presets.forEach { p ->
DropdownMenuItem(text = { Text(label(p)) }, onClick = { onChange(p); presetMenu = false })
}
DropdownMenuItem(text = { Text(tr("event.reminder_custom")) }, onClick = {
if (minutes in ReminderOptions.presets) onChange(ReminderOptions.customDefault)
presetMenu = false
})
}
}
IconButton(onClick = onRemove, enabled = enabled) {
Icon(Icons.Filled.Close, contentDescription = null)
}
}
if (!isPreset) {
val (value, unit) = ReminderOptions.split(minutes)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = value.toString(),
onValueChange = { txt ->
val n = txt.filter { it.isDigit() }.toIntOrNull()?.coerceAtLeast(1) ?: 1
onChange(n * unit.mult)
},
enabled = enabled,
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.width(96.dp),
)
Box {
OutlinedButton(onClick = { unitMenu = true }, enabled = enabled) {
Text(tr(unit.labelKey))
Icon(Icons.Filled.ArrowDropDown, contentDescription = null)
}
DropdownMenu(expanded = unitMenu, onDismissRequest = { unitMenu = false }) {
ReminderOptions.Unit.values().forEach { u ->
DropdownMenuItem(text = { Text(tr(u.labelKey)) }, onClick = { onChange(value * u.mult); unitMenu = false })
}
}
}
Text(tr("event.reminder_before"), color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}

View File

@@ -0,0 +1,365 @@
package com.scarriffle.calendarr.ui.groups
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.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.navigationBarsPadding
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.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.Add
import androidx.compose.material.icons.filled.Celebration
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.DirectionsRun
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Flight
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.MusicNote
import androidx.compose.material.icons.filled.People
import androidx.compose.material.icons.filled.Pets
import androidx.compose.material.icons.filled.Restaurant
import androidx.compose.material.icons.filled.School
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material.icons.filled.Work
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Divider
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
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.setValue
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.scarriffle.calendarr.domain.model.Group
import com.scarriffle.calendarr.ui.components.ColorPickerDialog
import com.scarriffle.calendarr.ui.tr
import com.scarriffle.calendarr.util.colorFromHex
/**
* Cross-platform group-icon keys (stored server-side) rendered as native
* Material icons — consistent everywhere instead of OS-emoji that vary by
* platform. Mirrors iOS GroupIcons / the web SVG set.
*/
object GroupIcons {
val keys = listOf(
"people", "home", "heart", "work", "school", "sports",
"party", "pet", "travel", "music", "food", "star",
)
fun vector(key: String?): ImageVector = when (key) {
"people" -> Icons.Filled.People
"home" -> Icons.Filled.Home
"heart" -> Icons.Filled.Favorite
"work" -> Icons.Filled.Work
"school" -> Icons.Filled.School
"sports" -> Icons.Filled.DirectionsRun
"party" -> Icons.Filled.Celebration
"pet" -> Icons.Filled.Pets
"travel" -> Icons.Filled.Flight
"music" -> Icons.Filled.MusicNote
"food" -> Icons.Filled.Restaurant
"star" -> Icons.Filled.Star
else -> Icons.Filled.People
}
fun isKey(s: String?): Boolean = s != null && s in keys
}
/** Render a group's icon: native Material icon for keys, legacy emoji fallback. */
@Composable
fun GroupIcon(icon: String?, modifier: Modifier = Modifier, tint: androidx.compose.ui.graphics.Color? = null) {
if (GroupIcons.isKey(icon)) {
Icon(GroupIcons.vector(icon), contentDescription = null, modifier = modifier,
tint = tint ?: androidx.compose.material3.LocalContentColor.current)
} else if (!icon.isNullOrEmpty()) {
Text(icon, modifier = modifier)
} else {
Icon(Icons.Filled.People, contentDescription = null, modifier = modifier,
tint = tint ?: androidx.compose.material3.LocalContentColor.current)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GroupsScreen(
onClose: () -> Unit,
onChanged: () -> Unit,
onOpenGroupView: (Group) -> Unit = {},
vm: GroupsViewModel = hiltViewModel(),
) {
var createOpen by remember { mutableStateOf(false) }
var manageId by remember { mutableStateOf<Int?>(null) }
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(tr("groups.title")) },
navigationIcon = {
IconButton(onClick = onClose) { Icon(Icons.Filled.Close, contentDescription = tr("common.close")) }
},
actions = {
IconButton(onClick = { createOpen = true }) { Icon(Icons.Filled.Add, contentDescription = tr("groups.create")) }
},
)
},
) { padding ->
if (vm.loading) {
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
return@Scaffold
}
LazyColumn(Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp)) {
if (vm.groups.isEmpty()) {
item {
Text(tr("groups.empty"), color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(vertical = 16.dp))
}
}
items(vm.groups, key = { it.id }) { g ->
Row(
Modifier.fillMaxWidth().clickable { onOpenGroupView(g) }.padding(vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
GroupIcon(g.icon, tint = MaterialTheme.colorScheme.onSurface)
Column(Modifier.weight(1f).padding(start = 12.dp)) {
Text(g.name, style = MaterialTheme.typography.bodyLarge)
Text(
tr("groups.member_count", g.memberCount),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(onClick = { manageId = g.id }) {
Icon(Icons.Filled.Tune, contentDescription = tr("groups.manage"), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
Icon(Icons.Filled.ChevronRight, contentDescription = tr("groups.view"), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
Divider()
}
vm.error?.let { item { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(vertical = 12.dp)) } }
}
}
}
if (createOpen) {
GroupEditSheet(
vm = vm,
existing = null,
onDismiss = { createOpen = false },
onSaved = { createOpen = false; onChanged() },
)
}
manageId?.let { id ->
val existing = vm.groups.firstOrNull { it.id == id }
GroupEditSheet(
vm = vm,
existing = existing,
onDismiss = { manageId = null },
onSaved = { manageId = null; onChanged() },
)
}
}
/** Create (existing == null) or manage (existing != null) a group. */
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
private fun GroupEditSheet(
vm: GroupsViewModel,
existing: Group?,
onDismiss: () -> Unit,
onSaved: () -> Unit,
) {
val me = vm.currentUserId
var name by remember { mutableStateOf(existing?.name ?: "") }
var icon by remember { mutableStateOf(existing?.icon?.takeIf { GroupIcons.isKey(it) } ?: "people") }
var selected by remember { mutableStateOf(setOf<Int>()) }
var existingMembers by remember { mutableStateOf(setOf<Int>()) }
var detail by remember { mutableStateOf<Group?>(null) }
var loaded by remember { mutableStateOf(existing == null) }
var confirmDelete by remember { mutableStateOf(false) }
var memberColorTarget by remember { mutableStateOf<Pair<Int, String>?>(null) } // userId, current hex
// Manage: load full details (members + colours) and pre-fill selection.
LaunchedEffect(existing?.id) {
val id = existing?.id ?: return@LaunchedEffect
val g = vm.groupDetail(id)
detail = g
if (g != null) {
name = g.name
icon = g.icon?.takeIf { GroupIcons.isKey(it) } ?: "people"
val members = g.members.map { it.id }.filter { it != me }.toSet()
existingMembers = members
selected = members
}
loaded = true
}
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(Modifier.fillMaxWidth().navigationBarsPadding().padding(horizontal = 20.dp).padding(bottom = 24.dp).verticalScroll(rememberScrollState())) {
Text(
if (existing == null) tr("groups.create") else tr("groups.manage"),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.size(16.dp))
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text(tr("groups.name")) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.size(16.dp))
Text(tr("groups.icon"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.size(8.dp))
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
GroupIcons.keys.forEach { ic ->
val sel = ic == icon
Box(
Modifier.size(44.dp).clip(RoundedCornerShape(8.dp))
.background(if (sel) MaterialTheme.colorScheme.primary.copy(alpha = 0.25f) else MaterialTheme.colorScheme.surfaceVariant)
.clickable { icon = ic },
contentAlignment = Alignment.Center,
) {
Icon(
GroupIcons.vector(ic),
contentDescription = ic,
tint = if (sel) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
Spacer(Modifier.size(16.dp))
Text(tr("groups.members"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
if (!loaded) {
Box(Modifier.fillMaxWidth().padding(12.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator(Modifier.size(22.dp), strokeWidth = 2.dp) }
} else {
vm.directory.forEach { u ->
Row(
Modifier.fillMaxWidth().clickable {
selected = if (u.id in selected) selected - u.id else selected + u.id
}.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(checked = u.id in selected, onCheckedChange = {
selected = if (u.id in selected) selected - u.id else selected + u.id
})
Text(u.displayName, modifier = Modifier.padding(start = 4.dp))
}
}
}
// Member colours (manage only)
detail?.members?.takeIf { it.isNotEmpty() }?.let { members ->
Spacer(Modifier.size(8.dp))
Divider()
Spacer(Modifier.size(8.dp))
Text(tr("groups.member_color"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
members.forEach { m ->
val hex = m.color ?: "#4285f4"
Row(
Modifier.fillMaxWidth().clickable { memberColorTarget = m.id to hex }.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(Modifier.size(20.dp).clip(CircleShape).background(colorFromHex(hex)))
Text(m.displayName, modifier = Modifier.weight(1f).padding(start = 12.dp))
}
}
}
Spacer(Modifier.size(20.dp))
Button(onClick = {
val n = name.trim()
if (n.isEmpty()) return@Button
if (existing == null) {
vm.createGroup(n, icon, selected.toList()) { onSaved() }
} else {
vm.saveGroup(existing.id, n, icon, selected, existingMembers) { onSaved() }
}
}, enabled = name.isNotBlank(), modifier = Modifier.fillMaxWidth()) {
Text(tr("event.save"))
}
if (existing != null) {
Spacer(Modifier.size(8.dp))
OutlinedButton(
onClick = { confirmDelete = true },
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Filled.Delete, contentDescription = null, tint = MaterialTheme.colorScheme.error)
Spacer(Modifier.size(8.dp))
Text(tr("groups.delete"), color = MaterialTheme.colorScheme.error)
}
}
}
}
memberColorTarget?.let { (userId, hex) ->
ColorPickerDialog(
initial = hex,
title = tr("groups.member_color"),
onDismiss = { memberColorTarget = null },
onConfirm = { picked ->
existing?.let { vm.setMemberColor(it.id, userId, picked) }
// reflect locally
detail = detail?.let { d -> d.copy(members = d.members.map { if (it.id == userId) it.copy(color = picked) else it }) }
memberColorTarget = null
},
)
}
if (confirmDelete && existing != null) {
AlertDialog(
onDismissRequest = { confirmDelete = false },
title = { Text(tr("groups.delete")) },
text = { Text(tr("groups.delete_confirm")) },
confirmButton = {
TextButton(onClick = { confirmDelete = false; vm.deleteGroup(existing.id) { onSaved() } }) {
Text(tr("groups.delete"), color = MaterialTheme.colorScheme.error)
}
},
dismissButton = { TextButton(onClick = { confirmDelete = false }) { Text(tr("common.cancel")) } },
)
}
}

View File

@@ -0,0 +1,77 @@
package com.scarriffle.calendarr.ui.groups
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.scarriffle.calendarr.data.CalendarRepository
import com.scarriffle.calendarr.domain.model.DirectoryUser
import com.scarriffle.calendarr.domain.model.Group
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class GroupsViewModel @Inject constructor(
private val repository: CalendarRepository,
) : ViewModel() {
var loading by mutableStateOf(true)
private set
var groups by mutableStateOf<List<Group>>(emptyList())
private set
var directory by mutableStateOf<List<DirectoryUser>>(emptyList())
private set
var error by mutableStateOf<String?>(null)
private set
val currentUserId: Int get() = repository.currentUserId
init { load() }
fun load() {
viewModelScope.launch {
loading = true
error = null
groups = runCatching { repository.getGroups() }.getOrDefault(emptyList())
directory = runCatching { repository.getUserDirectory() }.getOrDefault(emptyList())
loading = false
}
}
suspend fun groupDetail(id: Int): Group? = runCatching { repository.getGroup(id) }.getOrNull()
fun createGroup(name: String, icon: String, memberIds: List<Int>, onDone: () -> Unit) {
viewModelScope.launch {
runCatching { repository.createGroup(name, memberIds, icon) }
.onSuccess { load(); onDone() }
.onFailure { error = it.message }
}
}
/** Save name/icon, then reconcile membership against [existingMemberIds]. */
fun saveGroup(id: Int, name: String, icon: String, desired: Set<Int>, existing: Set<Int>, onDone: () -> Unit) {
viewModelScope.launch {
runCatching {
repository.updateGroup(id, name, icon)
for (uid in desired - existing) repository.addGroupMember(id, uid)
for (uid in existing - desired) repository.removeGroupMember(id, uid)
}
.onSuccess { load(); onDone() }
.onFailure { error = it.message }
}
}
fun setMemberColor(groupId: Int, userId: Int, color: String) {
viewModelScope.launch { runCatching { repository.setGroupMemberColor(groupId, userId, color) } }
}
fun deleteGroup(id: Int, onDone: () -> Unit) {
viewModelScope.launch {
runCatching { repository.deleteGroup(id) }
.onSuccess { load(); onDone() }
.onFailure { error = it.message }
}
}
}

View File

@@ -0,0 +1,124 @@
package com.scarriffle.calendarr.ui.menu
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
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.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Dns
import androidx.compose.material.icons.filled.Logout
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.People
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material3.Divider
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.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.scarriffle.calendarr.ui.tr
/** Full-screen menu / landing page reached from the drawer's gear: the hub for
* Profile / Appearance / Accounts / Groups / Sync / Server / Logout. Each entry
* opens its own full-screen page. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MenuScreen(
username: String,
serverUrl: String,
onClose: () -> Unit,
onProfile: () -> Unit,
onAppearance: () -> Unit,
onAccounts: () -> Unit,
onGroups: () -> Unit,
onSync: () -> Unit,
onSwitchServer: () -> Unit,
onLogout: () -> Unit,
) {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(tr("nav.menu")) },
navigationIcon = {
IconButton(onClick = onClose) { Icon(Icons.Filled.Close, contentDescription = tr("common.close")) }
},
)
},
contentWindowInsets = WindowInsets(0),
) { padding ->
Column(
Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).navigationBarsPadding(),
) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
Modifier.size(44.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary),
contentAlignment = Alignment.Center,
) {
Text(username.firstOrNull()?.uppercase() ?: "?",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onPrimary, fontWeight = FontWeight.Bold)
}
Spacer(Modifier.width(14.dp))
Column {
Text(username, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, maxLines = 1)
Text(serverUrl.removePrefix("https://").removePrefix("http://"),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
}
}
Divider()
MenuRow(Icons.Filled.Person, tr("menu.profile"), onProfile)
MenuRow(Icons.Filled.Palette, tr("menu.appearance"), onAppearance)
MenuRow(Icons.Filled.CalendarMonth, tr("menu.accounts"), onAccounts)
MenuRow(Icons.Filled.People, tr("menu.groups"), onGroups)
Divider(Modifier.padding(vertical = 4.dp))
MenuRow(Icons.Filled.Sync, tr("menu.sync"), onSync)
MenuRow(Icons.Filled.Dns, tr("server.switch"), onSwitchServer)
MenuRow(Icons.Filled.Logout, tr("menu.logout"), onLogout, destructive = true)
}
}
}
}
@Composable
private fun MenuRow(icon: ImageVector, label: String, onClick: () -> Unit, destructive: Boolean = false) {
val tint = if (destructive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant
Row(
Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 20.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(icon, contentDescription = null, tint = tint)
Spacer(Modifier.width(18.dp))
Text(label, style = MaterialTheme.typography.bodyLarge,
color = if (destructive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface)
}
}

View File

@@ -1,74 +0,0 @@
package com.scarriffle.calendarr.ui.menu
import androidx.compose.foundation.clickable
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Dns
import androidx.compose.material.icons.filled.Logout
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material3.Divider
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
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.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.scarriffle.calendarr.ui.tr
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MenuSheet(
isAdmin: Boolean,
onDismiss: () -> Unit,
onProfile: () -> Unit,
onAppearance: () -> Unit,
onAccounts: () -> Unit,
onSync: () -> Unit,
onLogout: () -> Unit,
onSwitchServer: () -> Unit,
) {
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(Modifier.fillMaxWidth().padding(bottom = 24.dp)) {
Text(
"Calendarr",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
)
MenuRow(Icons.Filled.AccountCircle, tr("menu.profile"), onProfile)
MenuRow(Icons.Filled.Palette, tr("menu.appearance"), onAppearance)
MenuRow(Icons.Filled.CalendarMonth, tr("menu.accounts"), onAccounts)
Divider(Modifier.padding(vertical = 4.dp))
MenuRow(Icons.Filled.Sync, tr("menu.sync"), onSync)
Divider(Modifier.padding(vertical = 4.dp))
MenuRow(Icons.Filled.Logout, tr("menu.logout"), onLogout)
MenuRow(Icons.Filled.Dns, tr("server.switch"), onSwitchServer)
}
}
}
@Composable
private fun MenuRow(icon: ImageVector, label: String, onClick: () -> Unit) {
Row(
Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 20.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.width(18.dp))
Text(label, style = MaterialTheme.typography.bodyLarge)
}
}

View File

@@ -3,31 +3,32 @@ package com.scarriffle.calendarr.ui.settings
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
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.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.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Button
import androidx.compose.material3.Divider
import androidx.compose.foundation.shape.RoundedCornerShape
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.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -36,6 +37,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
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
@@ -56,8 +58,11 @@ import com.scarriffle.calendarr.ui.tr
import com.scarriffle.calendarr.util.colorFromHex
import com.scarriffle.calendarr.util.toHex
private val PALETTE = listOf(
"#4285f4", "#ea4335", "#34a853", "#fbbc05", "#46bdc6", "#9c27b0", "#ff7043", "#7090c0",
// Canonical default colours (single source for the reset buttons).
private val DEFAULT_COLORS = mapOf(
"primary_color" to "#4285F4", "accent_color" to "#EA4335", "today_color" to "#4285F4",
"text_color" to "#FFFFFF", "bg_color" to "#000000", "line_color" to "#3A3A52",
"month_divider_color" to "#7090C0", "month_label_color" to "#7090C0",
)
@OptIn(ExperimentalMaterial3Api::class)
@@ -70,7 +75,6 @@ fun SettingsScreen(
) {
val initialSettings = LocalAppSettings.current
var settings by remember { mutableStateOf(initialSettings) }
var cacheMonths by remember { mutableStateOf(vm.cacheMonths) }
fun update(newSettings: AppSettings) {
settings = newSettings
@@ -78,6 +82,10 @@ fun SettingsScreen(
vm.apply(newSettings, onSettingsSynced)
}
// Per-row sync toggle: enabling adopts this device's current value.
fun toggle(key: String) { vm.toggleSync(key, settings, onSettingsSynced) }
fun synced(key: String) = vm.syncFlags[key] == true
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Scaffold(
topBar = {
@@ -91,99 +99,254 @@ fun SettingsScreen(
) { padding ->
Column(Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(16.dp)) {
ProfileChapter(vm)
Divider(Modifier.padding(vertical = 16.dp))
Section(tr("settings.calview"))
ChipRow(
options = CalViewType.entries.map { it.key to tr("view.${it.key}") },
selected = settings.defaultView,
onSelect = { update(settings.copy(defaultView = it)) },
)
Spacer(Modifier.size(16.dp))
Section(tr("settings.firstweekday"))
ChipRow(
options = listOf("monday" to tr("settings.monday"), "sunday" to tr("settings.sunday")),
selected = settings.weekStartDay,
onSelect = { update(settings.copy(weekStartDay = it)) },
)
Spacer(Modifier.size(16.dp))
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
Text(tr("settings.dimpast"), style = MaterialTheme.typography.bodyLarge)
Switch(checked = settings.dimPastEvents, onCheckedChange = { update(settings.copy(dimPastEvents = it)) })
// Global "sync everything" master switch.
Row(Modifier.fillMaxWidth().padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(tr("settings.sync_all"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
Text(tr("settings.sync_all.desc"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Switch(
checked = vm.syncableKeys.all { vm.syncFlags[it] == true },
onCheckedChange = { on -> vm.setAllSync(on, settings, onSettingsSynced) },
)
}
Divider(Modifier.padding(vertical = 16.dp))
Section(tr("settings.language"))
ChipRow(
options = listOf("system" to tr("lang.system"), "de" to tr("lang.german"), "en" to tr("lang.english")),
selected = settings.language,
onSelect = { update(settings.copy(language = it)) },
ProfileChapter(vm)
Divider(Modifier.padding(vertical = 16.dp))
// ---- Termine (synced) ----
Section(tr("settings.calview"))
SyncDropdownRow(
tr("settings.default_duration"),
listOf("15" to "15 min", "30" to "30 min", "45" to "45 min", "60" to "1 h", "90" to "1.5 h", "120" to "2 h"),
settings.defaultEventDurationMinutes.toString(),
{ update(settings.copy(defaultEventDurationMinutes = it.toInt())) },
synced("default_event_duration_minutes"), { toggle("default_event_duration_minutes") },
)
SyncDropdownRow(
tr("settings.defaultreminder"),
reminderOptions(),
(settings.defaultReminderMinutes ?: -1).toString(),
{ update(settings.copy(defaultReminderMinutes = it.toInt().takeIf { m -> m >= 0 })) },
synced("default_reminder_minutes"), { toggle("default_reminder_minutes") },
)
Divider(Modifier.padding(vertical = 16.dp))
// ---- Ansicht (synced) ----
Section(tr("settings.appearance"))
SyncDropdownRow(
tr("settings.defaultview"),
CalViewType.entries.map { it.key to tr("view.${it.key}") },
settings.defaultView,
{ update(settings.copy(defaultView = it)) },
synced("default_view"), { toggle("default_view") },
)
SyncDropdownRow(
tr("settings.firstweekday"),
listOf("monday" to tr("settings.monday"), "sunday" to tr("settings.sunday")),
settings.weekStartDay,
{ update(settings.copy(weekStartDay = it)) },
synced("week_start_day"), { toggle("week_start_day") },
)
SyncSwitchRow(tr("settings.dimpast"), settings.dimPastEvents, { update(settings.copy(dimPastEvents = it)) }, synced("dim_past_events"), { toggle("dim_past_events") })
SyncDropdownRow(
tr("settings.month_mode"),
listOf("false" to tr("settings.month_mode.scroll"), "true" to tr("settings.month_mode.paged")),
settings.monthViewPaged.toString(),
{ update(settings.copy(monthViewPaged = it.toBoolean())) },
synced("month_view_paged"), { toggle("month_view_paged") },
)
SyncDropdownRow(
tr("settings.hourheight"),
listOf("28" to tr("settings.hourheight.compact"), "44" to tr("settings.hourheight.normal"), "60" to tr("settings.hourheight.comfort"), "80" to tr("settings.hourheight.large")),
settings.hourHeight.toString(),
{ update(settings.copy(hourHeight = it.toInt())) },
synced("hour_height"), { toggle("hour_height") },
)
Divider(Modifier.padding(vertical = 16.dp))
// ---- Farben (synced) ----
Section(tr("settings.colors"))
ColorRow(tr("settings.color.primary"), settings.primaryColor) { update(settings.copy(primaryColor = it)) }
ColorRow(tr("settings.color.accent"), settings.accentColor) { update(settings.copy(accentColor = it)) }
ColorRow(tr("settings.color.today"), settings.todayColor) { update(settings.copy(todayColor = it)) }
ColorRow(tr("settings.color.divider"), settings.monthDividerColor) { update(settings.copy(monthDividerColor = it)) }
ColorRow(tr("settings.color.label"), settings.monthLabelColor) { update(settings.copy(monthLabelColor = it)) }
SyncColorRow("primary_color", tr("settings.color.primary"), settings.primaryColor, synced("primary_color"), { toggle("primary_color") }) { update(settings.copy(primaryColor = it)) }
SyncColorRow("accent_color", tr("settings.color.accent"), settings.accentColor, synced("accent_color"), { toggle("accent_color") }) { update(settings.copy(accentColor = it)) }
SyncColorRow("today_color", tr("settings.color.today"), settings.todayColor, synced("today_color"), { toggle("today_color") }) { update(settings.copy(todayColor = it)) }
SyncColorRow("text_color", tr("settings.color.text"), settings.textColor, synced("text_color"), { toggle("text_color") }) { update(settings.copy(textColor = it)) }
SyncColorRow("bg_color", tr("settings.color.background"), settings.backgroundColor, synced("bg_color"), { toggle("bg_color") }) { update(settings.copy(backgroundColor = it)) }
SyncColorRow("line_color", tr("settings.color.line"), settings.lineColor, synced("line_color"), { toggle("line_color") }) { update(settings.copy(lineColor = it)) }
SyncColorRow("month_divider_color", tr("settings.color.divider"), settings.monthDividerColor, synced("month_divider_color"), { toggle("month_divider_color") }) { update(settings.copy(monthDividerColor = it)) }
SyncColorRow("month_label_color", tr("settings.color.label"), settings.monthLabelColor, synced("month_label_color"), { toggle("month_label_color") }) { update(settings.copy(monthLabelColor = it)) }
Divider(Modifier.padding(vertical = 16.dp))
Section(tr("settings.hourheight"))
ChipRow(
options = listOf(
"28" to tr("settings.hourheight.compact"),
"44" to tr("settings.hourheight.normal"),
"60" to tr("settings.hourheight.comfort"),
"80" to tr("settings.hourheight.large"),
),
selected = settings.hourHeight.toString(),
onSelect = { update(settings.copy(hourHeight = it.toInt())) },
)
Spacer(Modifier.size(16.dp))
Section(tr("settings.textcontrast"))
ChipRow(
options = listOf(
"1" to tr("settings.contrast.dark"), "2" to tr("settings.contrast.medium"),
"3" to tr("settings.contrast.bright"), "4" to tr("settings.contrast.max"),
),
selected = settings.textContrast.toString(),
onSelect = { update(settings.copy(textContrast = it.toInt())) },
)
Spacer(Modifier.size(16.dp))
Section(tr("settings.linecontrast"))
ChipRow(
options = listOf(
"1" to tr("settings.linecontrast.barely"), "2" to tr("settings.linecontrast.subtle"),
"3" to tr("settings.linecontrast.normal"), "4" to tr("settings.linecontrast.strong"),
),
selected = settings.lineContrast.toString(),
onSelect = { update(settings.copy(lineContrast = it.toInt())) },
// ---- Cache (synced) ----
SyncDropdownRow(
tr("settings.cache.range"),
listOf("1" to tr("settings.cache.1m"), "3" to tr("settings.cache.3m"), "6" to tr("settings.cache.6m"), "12" to tr("settings.cache.1y")),
settings.cacheMonths.toString(),
{ update(settings.copy(cacheMonths = it.toInt())) },
synced("cache_months"), { toggle("cache_months") },
)
Divider(Modifier.padding(vertical = 16.dp))
Section(tr("settings.cache.title"))
ChipRow(
options = listOf("1" to tr("settings.cache.1m"), "3" to tr("settings.cache.3m"), "6" to tr("settings.cache.6m"), "12" to tr("settings.cache.1y")),
selected = cacheMonths.toString(),
onSelect = { cacheMonths = it.toInt(); vm.cacheMonths = it.toInt() },
)
// ---- Gerät (device-local: language + contrast) ----
Section(tr("settings.device"))
DeviceDropdownRow(
tr("settings.language"),
listOf("system" to tr("lang.system"), "de" to tr("lang.german"), "en" to tr("lang.english")),
settings.language,
) { update(settings.copy(language = it)) }
DeviceDropdownRow(
tr("settings.textcontrast"),
listOf("1" to tr("settings.contrast.dark"), "2" to tr("settings.contrast.medium"), "3" to tr("settings.contrast.bright"), "4" to tr("settings.contrast.max")),
settings.textContrast.toString(),
) { update(settings.copy(textContrast = it.toInt())) }
DeviceDropdownRow(
tr("settings.linecontrast"),
listOf("1" to tr("settings.linecontrast.barely"), "2" to tr("settings.linecontrast.subtle"), "3" to tr("settings.linecontrast.normal"), "4" to tr("settings.linecontrast.strong")),
settings.lineContrast.toString(),
) { update(settings.copy(lineContrast = it.toInt())) }
var hideMenu by remember { mutableStateOf(vm.hideMenuButton) }
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
Spacer(Modifier.size(44.dp))
Text(tr("settings.hide_menu_button"), style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
Switch(checked = hideMenu, onCheckedChange = { hideMenu = it; vm.hideMenuButton = it })
}
Text(tr("settings.device.footer"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 44.dp, top = 6.dp))
Spacer(Modifier.size(40.dp))
}
}
}
}
@Composable
private fun reminderOptions(): List<Pair<String, String>> = listOf(
"-1" to tr("reminder.off"), "0" to tr("reminder.at_start"), "5" to "5 min", "15" to "15 min",
"30" to "30 min", "60" to "1 h", "1440" to tr("reminder.1d"), "10080" to tr("reminder.1w"),
)
@Composable
private fun Section(title: String) {
Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(bottom = 8.dp))
}
/** Server-backed "Profil" chapter: display name, login name, email, privacy, shared calendar. */
/** Leading per-row sync toggle: highlighted = synced across devices. */
@Composable
private fun SyncIcon(on: Boolean, onClick: () -> Unit) {
IconButton(onClick = onClick, modifier = Modifier.size(32.dp)) {
Icon(
Icons.Filled.Refresh,
contentDescription = tr("settings.sync_this"),
modifier = Modifier.size(18.dp),
tint = if (on) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// Compact dropdown trigger (a small outlined chip) — much shorter than a full
// OutlinedTextField, so the settings rows aren't inflated.
@Composable
private fun Dropdown(options: List<Pair<String, String>>, selected: String, onSelect: (String) -> Unit) {
var expanded by remember { mutableStateOf(false) }
val selectedLabel = options.firstOrNull { it.first == selected }?.second ?: selected
Box {
Row(
Modifier
.clip(RoundedCornerShape(8.dp))
.border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(8.dp))
.clickable { expanded = true }
.padding(start = 12.dp, end = 6.dp, top = 6.dp, bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(selectedLabel, style = MaterialTheme.typography.bodyMedium, maxLines = 1)
Icon(Icons.Filled.ArrowDropDown, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
options.forEach { (key, label) ->
DropdownMenuItem(text = { Text(label) }, onClick = { onSelect(key); expanded = false })
}
}
}
}
@Composable
private fun SyncDropdownRow(
label: String,
options: List<Pair<String, String>>,
selected: String,
onSelect: (String) -> Unit,
synced: Boolean,
onToggleSync: () -> Unit,
) {
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
SyncIcon(synced, onToggleSync)
Spacer(Modifier.size(8.dp))
Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
Dropdown(options, selected, onSelect)
}
}
@Composable
private fun DeviceDropdownRow(
label: String,
options: List<Pair<String, String>>,
selected: String,
onSelect: (String) -> Unit,
) {
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
Spacer(Modifier.size(44.dp))
Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
Dropdown(options, selected, onSelect)
}
}
@Composable
private fun SyncSwitchRow(label: String, checked: Boolean, onChange: (Boolean) -> Unit, synced: Boolean, onToggleSync: () -> Unit) {
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
SyncIcon(synced, onToggleSync)
Spacer(Modifier.size(8.dp))
Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
Switch(checked = checked, onCheckedChange = onChange)
}
}
@Composable
private fun SyncColorRow(
syncKey: String,
label: String,
current: String,
synced: Boolean,
onToggleSync: () -> Unit,
onPick: (String) -> Unit,
) {
var showPicker by remember { mutableStateOf(false) }
Row(Modifier.fillMaxWidth().padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) {
SyncIcon(synced, onToggleSync)
Spacer(Modifier.size(8.dp))
Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
Text(colorFromHex(current).toHex(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.size(12.dp))
Box(
Modifier.size(28.dp).clip(CircleShape).background(colorFromHex(current))
.border(1.dp, MaterialTheme.colorScheme.outline, CircleShape)
.clickable { showPicker = true },
)
TextButton(onClick = { onPick(DEFAULT_COLORS[syncKey] ?: "#000000") }, contentPadding = PaddingValues(horizontal = 8.dp)) {
Text(tr("settings.reset"), style = MaterialTheme.typography.labelSmall)
}
}
if (showPicker) {
ColorPickerDialog(
initial = current,
title = label,
onDismiss = { showPicker = false },
onConfirm = { showPicker = false; onPick(it) },
)
}
}
/** Server-backed "Profil" chapter: name, login, email, hide-profile, privacy, shared calendar. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ProfileChapter(vm: SettingsViewModel) {
val savedLabel = tr("settings.saved")
@@ -210,6 +373,14 @@ private fun ProfileChapter(vm: SettingsViewModel) {
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.size(8.dp))
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(tr("settings.directory_hidden"), style = MaterialTheme.typography.bodyLarge)
Text(tr("settings.directory_hidden.desc"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Switch(checked = vm.directoryHidden, onCheckedChange = vm::onDirectoryHiddenChange)
}
Spacer(Modifier.size(8.dp))
Button(onClick = { vm.saveProfile(savedLabel) }) { Text(tr("event.save")) }
vm.profileMessage?.let {
Spacer(Modifier.size(8.dp))
@@ -218,11 +389,14 @@ private fun ProfileChapter(vm: SettingsViewModel) {
Divider(Modifier.padding(vertical = 16.dp))
Section(tr("settings.privacy"))
ChipRow(
options = listOf("busy" to tr("settings.private.busy"), "hidden" to tr("settings.private.hidden")),
selected = vm.privateVisibility,
onSelect = vm::changePrivateVisibility,
)
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
Text(tr("settings.private_visibility"), style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
Dropdown(
listOf("busy" to tr("settings.private.busy"), "hidden" to tr("settings.private.hidden")),
vm.privateVisibility,
vm::changePrivateVisibility,
)
}
Spacer(Modifier.size(6.dp))
Text(tr("settings.private_visibility.desc"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Divider(Modifier.padding(vertical = 16.dp))
@@ -256,35 +430,3 @@ private fun CalendarDropdown(vm: SettingsViewModel) {
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ChipRow(options: List<Pair<String, String>>, selected: String, onSelect: (String) -> Unit) {
Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
options.forEach { (key, label) ->
FilterChip(selected = key == selected, onClick = { onSelect(key) }, label = { Text(label) })
}
}
}
@Composable
private fun ColorRow(label: String, current: String, onPick: (String) -> Unit) {
var showPicker by remember { mutableStateOf(false) }
Row(
Modifier.fillMaxWidth().clickable { showPicker = true }.padding(vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(Modifier.size(28.dp).clip(CircleShape).background(colorFromHex(current)).border(1.dp, MaterialTheme.colorScheme.outline, CircleShape))
Spacer(Modifier.size(12.dp))
Text(label, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
Text(colorFromHex(current).toHex(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
if (showPicker) {
ColorPickerDialog(
initial = current,
title = label,
onDismiss = { showPicker = false },
onConfirm = { showPicker = false; onPick(it) },
)
}
}

View File

@@ -19,18 +19,40 @@ class SettingsViewModel @Inject constructor(
private val settingsStore: SettingsStore,
) : ViewModel() {
/** Persist locally immediately, then sync to the server in the background. */
/** Persist locally immediately, then push the synced values to the server. */
fun apply(settings: AppSettings, onSynced: () -> Unit) {
settingsStore.saveSettings(settings)
viewModelScope.launch {
runCatching { repository.updateSettings(settings) }
runCatching { repository.updateSettings(settings, settingsStore.loadSyncFlags()) }
onSynced()
}
}
var cacheMonths: Int
get() = settingsStore.cacheMonths
set(value) { settingsStore.cacheMonths = value }
/** Device-local: hide the top-bar menu button (drawer opens via edge-swipe). */
var hideMenuButton: Boolean
get() = settingsStore.hideMenuButton
set(value) { settingsStore.hideMenuButton = value }
// ---- Per-setting sync flags ----
val syncableKeys: List<String> get() = settingsStore.syncableKeys
var syncFlags by mutableStateOf(settingsStore.loadSyncFlags())
private set
/** Toggle one setting's flag. Enabling adopts this device's current value
* (pushes the synced values up); disabling keeps the value local. */
fun toggleSync(key: String, current: AppSettings, onSynced: () -> Unit) {
settingsStore.setSyncFlag(key, !(syncFlags[key] ?: false))
syncFlags = settingsStore.loadSyncFlags()
viewModelScope.launch { runCatching { repository.updateSettings(current, syncFlags) }; onSynced() }
}
/** Global "sync everything" switch. */
fun setAllSync(on: Boolean, current: AppSettings, onSynced: () -> Unit) {
settingsStore.setAllSyncFlags(on)
syncFlags = settingsStore.loadSyncFlags()
viewModelScope.launch { runCatching { repository.updateSettings(current, syncFlags) }; onSynced() }
}
// ---- Profile chapter (server-backed) ----
@@ -38,6 +60,8 @@ class SettingsViewModel @Inject constructor(
var loginName by mutableStateOf("")
var email by mutableStateOf("")
private set
var directoryHidden by mutableStateOf(false)
private set
var privateVisibility by mutableStateOf("busy")
private set
var groupVisibleId by mutableStateOf(0) // 0 = none
@@ -51,6 +75,7 @@ class SettingsViewModel @Inject constructor(
fun onDisplayNameChange(v: String) { displayName = v }
fun onEmailChange(v: String) { email = v }
fun onDirectoryHiddenChange(v: Boolean) { directoryHidden = v }
private fun loadProfile() {
viewModelScope.launch {
@@ -58,13 +83,16 @@ class SettingsViewModel @Inject constructor(
displayName = p.displayName ?: p.username
loginName = p.username
email = p.email ?: ""
directoryHidden = p.directoryHidden
}
runCatching { repository.getSettings() }.onSuccess { s ->
privateVisibility = s.privateEventVisibility
groupVisibleId = s.groupVisibleCalendarId ?: 0
}
runCatching { repository.getLocalCalendars() }.onSuccess { cals ->
ownLocalCalendars = cals.filter { it.owned && !it.group }
// A birthday calendar may be shared directly, but never stand in
// as the group-visible personal calendar.
ownLocalCalendars = cals.filter { it.owned && !it.group && !it.isBirthday }
}
}
}
@@ -76,6 +104,7 @@ class SettingsViewModel @Inject constructor(
displayName = displayName.trim().ifEmpty { null },
username = null,
email = email.trim(),
directoryHidden = directoryHidden,
)
}
.onSuccess { profileMessage = savedLabel }

View File

@@ -1,58 +1,74 @@
package com.scarriffle.calendarr.ui.theme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Shapes
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.unit.dp
import com.scarriffle.calendarr.domain.model.AppSettings
import com.scarriffle.calendarr.util.colorFromHex
import com.scarriffle.calendarr.util.contrastingTextColor
/**
* The Calendarr brand accent — the green from the iOS `AccentColor` asset
* (#20A050). This drives the global control tint (buttons, FAB, switches,
* top bar) regardless of the server's per-calendar colours, matching iOS
* where the app tint is fixed and `primary_color` only styles calendar
* elements (e.g. the "today" highlight, read from [AppSettings]).
*/
/** Fallback brand accent (iOS `AccentColor` #20A050) used only when the user's
* primary colour is unset. */
val BrandGreen = Color(0xFF20A050)
/**
* Fully dynamic theme: every colour is derived from [AppSettings] so the whole
* app follows the user's palette (matching the web client). primary/accent tint
* the controls; background/text/line drive `background`/`onBackground`/`outline`,
* which the calendar views already read (grid, secondary text). "today", divider
* and label colours are read directly in the views.
*/
@Composable
fun CalendarrTheme(
settings: AppSettings = AppSettings(),
content: @Composable () -> Unit,
) {
val primary = BrandGreen
val primary = colorFromHex(settings.primaryColor, BrandGreen)
val accent = colorFromHex(settings.accentColor, primary)
val bg = colorFromHex(settings.backgroundColor, Color(0xFF000000))
val onBg = colorFromHex(settings.textColor, Color(0xFFF2F2F7))
val line = colorFromHex(settings.lineColor, Color(0xFF3A3A52))
val surface = lerp(bg, Color.White, 0.10f)
val surfaceVariant = lerp(bg, Color.White, 0.17f)
val container = lerp(primary, Color.Black, 0.55f)
val onContainer = lerp(primary, Color.White, 0.75f)
val container = Color(0xFF14532D)
val onContainer = Color(0xFFB7F0C6)
val colors = darkColorScheme(
primary = primary,
onPrimary = primary.contrastingTextColor(),
primaryContainer = container,
onPrimaryContainer = onContainer,
secondary = primary,
onSecondary = primary.contrastingTextColor(),
secondary = accent,
onSecondary = accent.contrastingTextColor(),
secondaryContainer = container,
onSecondaryContainer = onContainer,
tertiary = primary,
onTertiary = primary.contrastingTextColor(),
tertiary = accent,
onTertiary = accent.contrastingTextColor(),
tertiaryContainer = container,
onTertiaryContainer = onContainer,
surfaceTint = primary,
background = Color(0xFF000000),
onBackground = Color(0xFFF2F2F7),
surface = Color(0xFF1C1C1E),
onSurface = Color(0xFFF2F2F7),
surfaceVariant = Color(0xFF2C2C2E),
onSurfaceVariant = Color(0xFFBEBEC4),
outline = Color(0xFF3A3A3C),
background = bg,
onBackground = onBg,
surface = surface,
onSurface = onBg,
surfaceVariant = surfaceVariant,
onSurfaceVariant = onBg.copy(alpha = 0.7f),
outline = line,
)
MaterialTheme(
colorScheme = colors,
typography = Typography(),
// extraSmall drives DropdownMenu containers (default 4dp looks boxy);
// rounder popups to match the iOS look.
shapes = Shapes(extraSmall = RoundedCornerShape(14.dp)),
content = content,
)
}

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -1,5 +1,6 @@
<resources>
<color name="splash_bg">#000000</color>
<color name="ic_launcher_background">#16703D</color>
<style name="Theme.Calendarr" parent="Theme.Material3.DayNight.NoActionBar">
<!-- Black window/system bars (matches the app background). -->

View File

@@ -0,0 +1,5 @@
* Pro-Einstellung-Synchronisierung mit einheitlicher Einstellungstabelle
* Neue linke Seitenleiste: Kalender ein-/ausblenden & sortieren, Gruppenwechsel, Schnellmenü
* Geburtstage aus den Kontakten importieren
* Voll dynamische Theme-Farben (8 einstellbare Farben mit Reset)
* Fix: mehrtägige Ganztags-Termine werden in Wochen-/Tagesansicht durchgehend dargestellt

View File

@@ -0,0 +1,19 @@
Calendarr ist die Android-App für deinen eigenen, selbst gehosteten Calendarr-Server — ein privater, werbefreier Kalender, dessen Daten auf deiner Infrastruktur bleiben.
Binde die Quellen ein, die du ohnehin nutzt:
* Lokale Kalender auf dem Server
* CalDAV-Konten
* iCal-/ICS-Abos
* Google Kalender
* Home Assistant
Funktionen:
* Monats-, Wochen-, Tages-, Quartals- und Terminansicht
* Seitenleiste zum Ein-/Ausblenden und Sortieren von Kalendern, Gruppenwechsel und Zugriff aufs Menü
* Kalender mit anderen teilen und in Gruppen organisieren
* Geburtstage: eigener Geburtstagskalender mit Alter und Erinnerungen, optional aus deinen Kontakten importiert
* Pro-Einstellung-Synchronisierung über Geräte — du wählst genau, welche Einstellung dir über Web, iOS und Android folgt
* Frei anpassbare Farben und dunkle Oberfläche
* Termin-Erinnerungen / Benachrichtigungen
Calendarr benötigt Zugriff auf einen (selbst gehosteten) Calendarr-Server. Server-URL eingeben, dann anmelden.

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -0,0 +1 @@
Kalender-App für deinen selbst gehosteten Calendarr-Server

View File

@@ -0,0 +1 @@
Calendarr

View File

@@ -0,0 +1,5 @@
* Per-setting cross-device sync with a unified settings table
* New left navigation drawer: show/hide & reorder calendars, group switch, quick menu
* Import birthdays from your contacts
* Fully dynamic theme colours (8 configurable colours with reset)
* Fix: multi-day all-day events now span correctly in week/day view

View File

@@ -0,0 +1,19 @@
Calendarr is the Android client for your own self-hosted Calendarr server — a private, ad-free calendar whose data stays on your infrastructure.
Bring together the sources you already use:
* Local calendars on the server
* CalDAV accounts
* iCal / ICS subscriptions
* Google Calendar
* Home Assistant
Features:
* Month, week, day, quarter and agenda views
* A left drawer to show/hide and reorder your calendars, switch groups, and reach the menu
* Share calendars with other users and organise them in groups
* Birthdays: a dedicated birthday calendar with age and reminders, optionally imported from your contacts
* Per-setting cross-device sync — choose exactly which settings follow you across web, iOS and Android
* Fully customisable colours and a dark interface
* Event reminders / notifications
Calendarr requires access to a Calendarr server (self-hosted). Enter your server URL, then sign in.

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -0,0 +1 @@
Calendar client for your self-hosted Calendarr server

View File

@@ -0,0 +1 @@
Calendarr

View File

@@ -0,0 +1,30 @@
# F-Droid build recipe for the OFFICIAL repository.
# Submit this file to https://gitlab.com/fdroid/fdroiddata as
# metadata/com.scarriffle.calendarr.yml
# (see FDROID.md). F-Droid clones the source below, builds it on their servers
# and signs it with F-Droid's key. Requires the SourceCode repo to be publicly
# clonable and a git tag `v<versionName>` (e.g. v1.1.0) for each release.
Categories:
- Time
License: GPL-3.0-or-later
AuthorName: Scarriffle
WebSite: https://calendar.scarriffle.com
SourceCode: https://git.scarriffle.com/Scarriffle/Calendarr-Android
IssueTracker: https://git.scarriffle.com/Scarriffle/Calendarr-Android/issues
RepoType: git
Repo: https://git.scarriffle.com/Scarriffle/Calendarr-Android.git
Builds:
- versionName: 1.1.0
versionCode: 2
commit: v1.1.0
subdir: app
gradle:
- yes
AutoUpdateMode: Version v%v
UpdateCheckMode: Tags
CurrentVersion: 1.1.0
CurrentVersionCode: 2

View File

@@ -0,0 +1,9 @@
# Copy this file to "keystore.properties" (git-ignored) and fill in your real
# values to enable a signed release build: ./gradlew bundleRelease
#
# storeFile: ABSOLUTE path to your upload keystore (.jks). Keep the keystore and
# this filled-in file OUT of version control (both are git-ignored).
storeFile=/absolute/path/to/upload-keystore.jks
storePassword=CHANGE_ME
keyAlias=upload
keyPassword=CHANGE_ME