Compare commits

...
241 Commits
Author SHA1 Message Date
ScarriffleandClaude Opus 5 ab0c64a9e1 Bring CalendarrKit into the repo and fix the iOS build
The monorepo's Xcode project referenced ../CalendarrKit, but the package was
never brought across: it is the one repo that never had a remote, so it existed
only on one machine. Added as a subtree at the root, where that existing
relative path resolves, so the project needs no change. History is preserved —
the commits explaining why the snapshot colours sit flat on the wire and why
the package must stay Foundation-only are the kind of thing that cannot be
reconstructed later.

The iOS target also did not compile on beta, independent of any of this. The
attachments section combined a string title with a footer, and SwiftUI has no
initializer for that pair: the title then binds as the first positional
argument of Section(content:footer:), which is what the three misleading
"generic parameter 'Content'" errors were. Rewritten as header:/footer:, the
way the reminders section directly above already does it.

Carries over the pending change from the archived iOS repo: each calendar
source now reports its own failure instead of being swallowed by `try?`, so an
absent Home Assistant calendar can finally be diagnosed from the app.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 05:38:06 +02:00
Scarriffle dccf8671b2 Add 'CalendarrKit/' from commit '9ea1a8705d608b1aa476c0b35685c57466bb0c50'
git-subtree-dir: CalendarrKit
git-subtree-mainline: 8bc32faa7f
git-subtree-split: 9ea1a8705d
2026-10-19 05:34:15 +02:00
ScarriffleandClaude Opus 5 8bc32faa7f fix(theme): Farben erben wieder den Standard statt ihn einzufrieren
Fuenf Farbspalten trugen einen fest eingebauten Vorgabewert — primary_color
#4285f4, accent_color #ea4335, today_color #4285f4, month_divider_color und
month_label_color #7090c0. Damit hatte *jeder* Nutzer immer eine eigene
Farbe, und der Fallback settings.X || baseColor(X) kam nie zum Zug. Das
Admin-Versprechen "Gilt fuer alle Nutzer ohne eigene Farbe" war fuer diese
fuenf unerfuellbar, und die Werte widersprachen DEFAULT_COLORS im Frontend,
das laut Kommentar die einzige Quelle sein soll.

Das betraf nicht nur Altbestand: UserSettings wird ohne Farbwerte angelegt,
also bekam auch jeder heute neu angelegte Nutzer das Blau.

- models.py: die fuenf Spalten sind nullable, NULL heisst "erbt".
- settings_router: NULL wird ausgeliefert statt durch einen Standard ersetzt,
  und ein explizites null wird gespeichert (NULLABLE_OVERRIDES).
- main.py: einmalige Migration setzt genau die alten Vorgabewerte auf NULL.
  Bewusst gewaehlte Farben bleiben unangetastet.
- calendar.js: eine erbende Zeile merkt sich das und sendet beim Speichern
  null. "Zurueck" heisst wieder erben, statt den aktuellen Standard in die
  Nutzerzeile zu kopieren — sonst erreichen spaetere Aenderungen des
  Standards den Nutzer nie mehr.

Verifiziert: frische Installation liefert --primary #58B900 statt #4285f4;
zurueckgeschriebenes Alt-Blau wird beim Neustart geleert; eine eigene Farbe
(#FF00AA) ueberlebt die Migration. Suite 129 gruen, davon 5 neue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 11:44:25 +02:00
ScarriffleandClaude Opus 5 aaecfc435d fix(theme): Theme aus den wirksamen statt den rohen Einstellungen anwenden
initCalendar baute mit mergeEffective() korrekt die wirksamen Einstellungen
zusammen, benutzte danach aber die rohe Server-Antwort zum Anwenden:

  applyTheme(settings) statt applyTheme(state.settings)

Fuer jede Einstellung, deren Sync-Flag aus ist, ist der wirksame Wert laut
effectiveValue() der browser-lokale — der wurde damit stillschweigend
ignoriert. Die Einstellungstabelle zeigte also einen Wert an, waehrend die
Oberflaeche einen anderen benutzte.

Derselbe Fehler bei setLang: die Zeile mit dem wirksamen Wert wurde wenige
Zeilen spaeter vom rohen Server-Wert ueberschrieben. Die doppelte Zuweisung
faellt weg, die richtige bleibt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 11:34:55 +02:00
ScarriffleandClaude Opus 5 6b7317b4e0 docs: Design-Vertrag fuer Web, iOS und Android
Unterhalb der Farbebene war nie etwas einmal entschieden worden: Android
hat 40 verschiedene Abstandswerte, 9 Schriftgroessen, 9 Radien und vier
verschiedene Seitenraender; das Web streut aehnlich. Deshalb springt der
Textrand beim Screenwechsel und dieselbe Termin-Pille hat vier Radien.

Der Vertrag legt Abstandsleiter, Radien nach Rolle, Schriftskala,
Trefferflaechen, Komponenten-Inventar, Zustaende, Termin-Darstellung und
den gemeinsamen Widget-Zuschnitt fest -- nach dem Vorbild von
SETTINGS_SYNC.md, das denselben Zweck fuer die Einstellungen erfuellt.

Bewusst nicht vereinheitlicht: Navigation, Systemschrift, Bedienelemente
und Gesten. Gleiche Struktur und gleiche Masse, nicht gleiche Pixel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 11:56:59 +02:00
ScarriffleandClaude Opus 5 cd6a4698aa fix(anhaenge): Anhang-Bereich ruhiger und im Stil der Eingabefelder
Die beiden Texte steckten in .form-hint -- eine spaetere Regel fuer den
SSO-Login macht daraus einen umrandeten Kasten in Primaerfarbe. Fuer eine
Warnung ist das richtig, fuer eine Bildunterschrift viel zu laut. Eigene
leise Klasse statt dessen.

"Datei hinzufuegen" sieht jetzt aus wie die Textfelder darueber: gleicher
Grund, Rahmen, Radius und Innenabstand, nur linksbuendig und in der
gedaempften Textfarbe.

Der Leerzustand "Keine Anhaenge" faellt weg -- die Erinnerungsliste macht
es genauso, und die Zeile unter dem Knopf sagt ohnehin, was hier hin kann.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 10:47:16 +02:00
ScarriffleandClaude Opus 5 a1f15e33aa feat(android): Anhaenge an Terminen
attachmentCount im Event-Parser, EventAttachment und StagedAttachment als
Modelle, fuenf Retrofit-Endpunkte plus Repository-Wrapper. Die
Event-Endpunkte liefern wie bisher den rohen Body und werden von Hand
geparst, passend zum iOS-Client.

Die Detailansicht zeigt die Liste und speichert eine Datei ueber SAF --
derselbe Zweischritt wie beim ICS-Export, damit weder ein FileProvider im
Manifest noch eine Speicherberechtigung noetig wird. Der Editor staged
neue Dateien und wendet sie erst beim Speichern an; createLocalEvent gibt
dafuer jetzt die neue UID zurueck.

Thumbnails werden mit BitmapFactory dekodiert statt eine Bildbibliothek
aufzunehmen: es sind serverseitig auf 320 px verkleinerte JPEGs, dafuer
lohnt Coil nicht.

strOrNull ist aus dem Companion von CalEvent auf Dateiebene gewandert,
damit EventAttachment es auch nutzen kann.

Bewusst TextOverflow.Ellipsis statt MiddleEllipsis: die Compose-BOM
2023.10.01 kennt letzteres noch nicht.

Nicht kompiliert: auf diesem Rechner gibt es weder JDK noch Android-SDK.
Muss auf einer Maschine mit beidem gebaut werden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 10:39:16 +02:00
ScarriffleandClaude Opus 5 3b649ec310 feat(ios): Anhaenge an Terminen
attachmentCount im Event-Parser, EventAttachment als Modell, API-Methoden
fuer Liste, Upload, Download, Thumbnail und Loeschen. Der Multipart-Body
ist von importICS uebernommen.

Die Detailansicht laedt die Liste beim Oeffnen und teilt eine Datei ueber
das System-Share-Sheet; der Editor staged neue Dateien und wendet sie
erst nach dem Speichern an, weil ein neuer Termin vorher keine UID hat.

EventAttachment liegt bewusst in CalEvent.swift statt in einer eigenen
Datei: eine hier angelegte .swift-Datei waere in keinem Xcode-Target
Mitglied und wuerde schlicht nicht mitkompiliert -- das faellt erst auf
dem Mac auf.

Die URL fuer das Share-Sheet steckt in einem kleinen Identifiable-Wrapper,
weil URL das nicht ist und eine nachtraegliche Konformitaet auf einen
Stdlib-Typ modulweit sichtbar waere.

Nicht kompiliert: auf diesem Rechner gibt es kein Xcode. Muss auf dem Mac
gebaut werden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 10:32:01 +02:00
ScarriffleandClaude Opus 5 cd8aba74b6 docs: Anhaenge, Hintergrund-Jobs und neue Variablen im README
Dokumentiert auch den ehrlichen Haken der ATTACH-Links: sie muessen ohne
Login funktionieren, weil externe Kalender-Clients keinen mitschicken
koennen, und sind damit unbefristet gueltige Capability-URLs. Mit dem
Abschalter, falls das fuer eine Installation nicht tragbar ist.

Nebenbei die seit dem Monorepo-Merge falsche Aussage korrigiert, die
mobilen Apps laegen in eigenen Repos.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 10:28:08 +02:00
ScarriffleandClaude Opus 5 e2a7edf421 feat(anhaenge): Anhaenge im Web-Editor und im Detail-Popup
Im Editor werden Dateien gestaget und erst beim Speichern angewendet --
sonst haette ein neuer Termin keine UID, an die der Anhang gehen koennte,
und "Abbrechen" wuerde nicht mehr bedeuten, dass nichts passiert ist.
Schlaegt danach ein Upload fehl, sagt der Toast das, statt Erfolg
vorzutaeuschen.

Das Popup laedt die Liste erst beim Oeffnen, weil das Event-JSON nur die
Anzahl traegt. Thumbnails gehen ueber authentifiziertes fetch auf eine
Blob-URL, weil ein img-Tag den Token nicht mitschicken kann -- die URL
wird nach dem Dekodieren freigegeben, anders als an den beiden
bestehenden Stellen, die sie liegen lassen.

Die fest verdrahtete Popup-Hoehe von 200 px wird jetzt gemessen; mit einer
Anhangliste stimmte die Annahme nicht mehr.

api.js haengt den HTTP-Status an geworfene Fehler, damit die Oberflaeche
409/413/400 uebersetzen kann statt das deutsche Backend-Detail zu zeigen.

Der ASCII-Rueckfallname im Content-Disposition war keiner: isalnum() ist
fuer Umlaute wahr. Faellt beim Herunterladen von "Rechnung Zahnaerztin.pdf"
auf, jetzt mit Test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 10:27:11 +02:00
ScarriffleandClaude Opus 5 a024fa54b9 feat(anhaenge): ATTACH-Zeile im erzeugten ICS
Jeder Anhang wird als ATTACH mit FMTTYPE, SIZE und FILENAME ausgegeben,
als URI statt inline base64 -- bei 10 MB je Datei wuerde sonst bei jeder
Synchronisation der ganze Anhang im ICS-Rumpf mitgeschleppt.

Die absolute URL kommt aus dav_util.public_base, das PUBLIC_BASE_URL und
die Forwarded-Header schon behandelt; ohne Basis-URL wird die Zeile
weggelassen, weil eine relative ATTACH-URL einem externen Client nichts
nuetzt. Abschaltbar ueber ATTACHMENT_PUBLIC_LINKS=0.

Der CalDAV-PUT ist geprueft: ein externer Client, der den Termin
zurueckschreibt, laesst die Anhaenge unberuehrt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 10:19:14 +02:00
ScarriffleandClaude Opus 5 4b9c09df74 feat(scheduler): Hintergrund-Jobs im Serverprozess
Das Backend hatte bisher keinerlei Hintergrund-Mechanik. Neues Modul mit
einer Job-Registry, angebunden ueber den FastAPI-Lifespan. Der Server
laeuft als genau ein Prozess, ein In-Process-Loop laeuft damit garantiert
nur einmal -- steht als Vorbehalt im Modulkopf, falls spaeter Worker
dazukommen.

Jeder Job laeuft im Thread mit eigener Session, weil SQLAlchemy hier
synchron ist und Datei- oder Netz-I/O sonst den Event-Loop blockiert.
Fehler werden pro Job gefangen.

Zwei Jobs: der Anhang-Sweep alle 24 h und die iCal-Aktualisierung alle
15 min. Letztere treibt nur den vorhandenen _refresh_if_needed an, der das
Intervall je Abo schon respektiert -- neu ist allein, dass Abos auch
aktuell bleiben, wenn niemand die App offen hat. Jedes Abo einzeln
gekapselt, weil der Helfer ohne Cache weiterwirft.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 10:14:28 +02:00
ScarriffleandClaude Opus 5 b885e731ee feat(anhaenge): attachment_count im Termin-JSON
Die Terminliste traegt nur die Anzahl, nicht die Anhaenge selbst. Der
Merge-Read laeuft pro Termin durch eine Schleife -- eine Lazy-Relationship
waere dort eine Abfrage je Termin ueber eine ganze Monatsansicht, und ein
wiederkehrender Termin wuerde dieselbe Liste an jeder Instanz wiederholen.
Stattdessen je Kalender eine gruppierte Abfrage; die Liste holen die
Clients beim Oeffnen der Detailansicht.

mask_busy_event setzt die Zahl ausdruecklich auf 0: dass ein als
"Beschaeftigt" maskierter Termin drei Dateien haette, ist auch eine
Information.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 10:11:30 +02:00
ScarriffleandClaude Opus 5 0e8a1a6a3d feat(anhaenge): Endpunkte zum Hochladen, Abrufen und Loeschen
Neuer Router unter /api/local mit Upload, Liste, Download, Thumbnail und
Loeschen, dazu permissions.readable_local_event als fehlendes Gegenstueck
zu _writable_event.

Der Lese-Helfer prueft nicht nur den Kalender, sondern auch die
Privatsphaere: ein fremder privater Termin wird im Lesepfad maskiert, also
duerfen auch seine Anhaenge nicht ueber die UID erreichbar sein. Fremde
bekommen durchgaengig 404 statt 403, damit die Existenz nicht durchsickert.

Die Mengenbegrenzung zaehlt nach dem Insert innerhalb derselben
Transaktion -- zaehlen-dann-einfuegen waere ein Wettlauf, bei dem zwei
gleichzeitige Uploads beide 9 sehen.

Ausgeliefert wird immer als Download mit nosniff, nie inline. Dazu die
Capability-URL unter /api/attach/<token> fuer externe CalDAV-Clients,
abschaltbar ueber ATTACHMENT_PUBLIC_LINKS=0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 10:08:47 +02:00
ScarriffleandClaude Opus 5 22a469d449 feat(anhaenge): Datenmodell und Ablage fuer Termin-Anhaenge
Neue Tabelle event_attachments samt Ablage-Modul. Dateien liegen unter
DATA_DIR/attachments, geshardet nach einem servergenerierten Namen -- weder
der hochgeladene Dateiname noch die Termin-UID beruehren je einen Pfad, da
die UID bei CalDAV-Terminen aus einem fremden Client stammt.

Der Dateityp wird an den Bytes bestimmt, nie am Content-Type des Clients.
SVG und HTML sind bewusst nicht erlaubt: beide fuehren Skript im Origin der
App aus, wo das Sitzungstoken liegt.

Aufgeraeumt wird an allen fuenf Loeschpfaden (Termin, Kalender, Gruppe,
Benutzer, CalDAV-DELETE), und zwar Zeilen vor dem Commit, Dateien erst
danach -- ein Rollback darf keine Bytes verlieren. Der Sweep als
Sicherheitsnetz ist implementiert, haengt aber noch an keinem Scheduler.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 10:04:44 +02:00
ScarriffleandClaude Opus 5 6e981e7aea chore(repo): iOS-App als ios/ ins Hauptrepo uebernehmen
Calendarr-IOS wird mit vollstaendiger Historie als Unterverzeichnis
ios/ eingebunden. Damit liegen Server, Web, Android und iOS in einem
gemeinsamen Repo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 09:20:12 +02:00
ScarriffleandClaude Opus 5 560457945c chore(repo): Android-App als android/ ins Hauptrepo uebernehmen
Calendarr-Android wird mit vollstaendiger Historie als Unterverzeichnis
android/ eingebunden, damit Server, Web und die mobilen Apps in einem
Repo liegen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 09:19:55 +02:00
ScarriffleandClaude Opus 5 31d7a0080e Ship the app for iPad, and keep the watch out of the Mac build
Device family becomes 1,2 for the app and the widget extension, and the iPad
gets all four interface orientations. That is not a preference: App Store
Connect rejects an iPad-capable bundle that declares fewer (error 90474),
because iPad multitasking can present the app at any size. The iPhone key stays
portrait-only. Mac Catalyst still resolves to family 2 alone, which is what
84f5c00 set up and what this had to avoid breaking.

Building for Catalyst also turned up a regression from the watch work: the
Embed Watch Content phase carried watchOS content into a macOS build, which
fails outright. It went unnoticed because no Catalyst build was run after the
watch targets were added. Both the copy and its target dependency are now
filtered to iOS.

Verified on all three destinations: iOS carries Watch/CalendarrWatch.app with
its complications, Catalyst carries no Watch folder, and both report the right
device family.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 14:57:02 +02:00
ScarriffleandClaude Opus 5 e5b3e549e8 Catch multi-day events that do not end on a day boundary
Requiring both ends to sit exactly on midnight was too strict. A holiday stored
as the 16th at 00:00 through the 21st at 01:00 slipped through and rendered as
a one-hour slot. Starting on a day boundary and running into a later day is
enough to call it all-day.

The agenda row now names the weekday on both sides when an event crosses a day.
Showing only "00:00 – 01:00" for something that runs from Wednesday to Monday
is true and useless.

The timezone handling above this is deliberately untouched: timed events show
the right time, so the UTC assumption for timestamps without an offset is not
what produced the odd hour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 14:52:52 +02:00
ScarriffleandClaude Opus 5 465c465af2 Treat a whole-day span as all-day, whatever the payload claims
The previous rule only caught date-only strings. A multi-day event sent with
full timestamps and no allDay flag still arrived as a timed event, so a holiday
running from the 16th to the 21st rendered as a one-hour slot at midnight.

Now an event that starts and ends on a day boundary and covers at least one
whole day is all-day regardless. That is a statement about what the data means,
not about how any one client formats it, which is why it lives in the parser
and not in a view.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 14:49:57 +02:00
ScarriffleandClaude Opus 5 6e91eaaaa2 Read all-day events correctly, and make the date circle say a date
Three things, one of which is not a watch problem at all.

parseDate already treated a date-only string as all-day, but isAllDay came from
the JSON flag alone. An event sent as "2026-09-12" with no flag therefore
landed at midnight with isAllDay false, and every surface that formats a time
put "00:00" in front of it — the widgets as much as the watch. Now derived from
both.

The date complication showed the day number and nothing else, because
.accessoryCircularCapacity renders only currentValueLabel and the month sat in
the label closure, where it is never drawn. It now stacks weekday over day
number, which is what makes it read as a date rather than a number.

The ring only appears while an appointment is running, and then shows how far
through it we are. An empty track around an idle day said nothing, so there is
no Gauge at all in that case. All-day events do not count as running: one would
hold the ring at an arbitrary fraction for twenty-four hours.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 14:10:21 +02:00
ScarriffleandClaude Opus 5 1a1230a796 Put the next appointment on the Infograph bezel
The position asked for is the bezel slot: a circular complication plus a label
that arcs across the top, where the tick marks otherwise are. ClockKit called
that family graphicBezel; in WidgetKit it is an accessoryCircular widget with
.widgetLabel attached. Nothing was attached, so that arc stayed empty while
Apple's Calendar fills it — which is the difference that was visible on the
watch and invisible in every check made here.

All three circular kinds now carry it. Faces without a bezel slot ignore the
label, so there is no cost to always providing it.

cornerLabel becomes eventLabel: the same text serves the corner edge and the
bezel arc, and the old name claimed otherwise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 20:19:21 +02:00
ScarriffleandClaude Opus 5 2c26be2ab5 Add a corner complication with the appointment's name as the value
Requested shape: the event title as the text, the time small alongside. This
inverts how accessoryCorner is built — the curved bezel is where a corner has
room for long text and the inner area holds three or four characters — so the
title shrinks and truncates. Kept alongside WatchCornerTime, which carries the
same information with the roles the other way round, so both can be compared on
the face rather than argued about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 18:53:17 +02:00
ScarriffleandClaude Opus 5 938ca7ca98 Render the circular complications as edge-to-edge closed gauges
Date and "events today" filled their slot with AccessoryWidgetBackground and
centred text, which reads as a small element with space around it. Both are now
Gauges in .accessoryCircularCapacity — the WidgetKit equivalent of ClockKit's
ClosedGauge templates, which no longer exist and cannot be reached from a
WidgetKit complication at all.

The rings carry meaning rather than decorating: the date's drains through the
day, and the count's drains as today's appointments are worked through. Both
drain, matching the countdown rather than inventing a second direction. An
empty day leaves the ring empty instead of full, so it does not read as an
untouched one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 18:38:54 +02:00
ScarriffleandClaude Opus 5 b489f10c28 Make the countdown narrow enough for the slot it sits in
Text(style: .timer) renders 1:23:45 once more than an hour out. An
accessoryCorner has room for three or four characters beside its curved label,
and a circular gauge for about the same, so the live timer overlapped what it
sat next to.

Replaced with a compact value — "23m", "2h", "3d" — recomputed per timeline
entry, which is why the timeline now carries a mark every minute for the first
hour. Both corner kinds and the gauge also get lineLimit(1) and a minimum scale
factor, so an unexpectedly wide value shrinks instead of colliding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 18:33:38 +02:00
ScarriffleandClaude Opus 5 9ea1a8705d Give complication timelines minute granularity in the first hour
A compact countdown needs an entry per minute to stay honest. The live
Text(style: .timer) it replaces needed none, but renders 1:23:45 — eight
characters, which does not fit an accessoryCorner beside its curved label.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 18:32:51 +02:00
ScarriffleandClaude Opus 5 fdae680e02 Match the watch targets' version to the iOS app
A companion watch app has to carry the same CFBundleShortVersionString and
CFBundleVersion as the app it ships inside. The template left the watch at
1.0 (1) against the app's 3.5 (7), and watchOS answers that by refusing the
install without reporting anything: the Install button simply comes back.

Same class of drift as c9bd42a, which unified the app and widget versions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 17:21:56 +02:00
ScarriffleandClaude Opus 5 e7fbe5c092 Give the watch app an icon and the right name
The target template left AppIcon.appiconset with an entry carrying no filename
— an empty slot. watchOS refuses to install an app without a valid icon, which
is why the Watch app's Install button kept coming back after ten seconds with
no error anywhere. Reuses the iOS 1024 icon, which is already alpha-free.

Both watch targets also called themselves "CalendarrWatch", which is what would
have appeared under the icon and in the complication gallery.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 17:16:08 +02:00
ScarriffleandClaude Opus 5 d0cff20be8 Note the watch plan as implemented
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 17:08:44 +02:00
ScarriffleandClaude Opus 5 f9bc1965a5 Add the watch app and its eight complications
The watch receives the phone's payload over WatchConnectivity and writes it
into its own App Group container through the same SnapshotStore, so there is
one wire format and one reader. The agenda, the detail screen and the filter
all read that; the complications read what the app last wrote, since an
extension cannot run a WCSession of its own.

Complication timelines land on event boundaries rather than a schedule, so the
next appointment flips in the exact minute it changes without spending refresh
budget.

CalendarrWatchShared holds what both watch targets need, attached to each the
way Shared/ is attached to the app and the widgets.

recommendations() is implemented because watchOS requires it — iOS has a
default, the watch does not, since the complication gallery has to be
populated. Found by building, not by reading.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 17:08:27 +02:00
ScarriffleandClaude Opus 5 6a7afe0647 Drop -sdk from the plan's build commands
-sdk overrides SDKROOT for the whole dependency graph, so the watch app was
built against the iOS SDK. It still succeeded, and produced a watch app with no
WKApplication key — the kind of failure that only shows up on the device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:56:37 +02:00
ScarriffleandClaude Opus 5 9394a80ea2 Add the watchOS app and complication targets
The companion dropdown in Xcode's target template came up empty, which left
both bundle identifiers starting with a bare dot and
WKCompanionAppBundleIdentifier as an empty string. Both are set explicitly
here, along with the App Group entitlements and watchOS 26 as the floor.

The iOS app had no Embed Watch Content phase, so the watch app would have built
and never shipped. Added, with the matching target dependency: the product now
lands at Calendarr iOS.app/Watch/CalendarrWatch.app with the complications
inside it.

Folders follow what they contain: CalendarrWatch is the app,
CalendarrWatchComplications the extension. Xcode had them the other way round.

Package.resolved is now tracked. It pins AppAuth, whose version drift is what
produced the two compile errors fixed in 75a6a5b.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:56:26 +02:00
ScarriffleandClaude Opus 5 ddda0ecd3e Correct the plan's Info.plist step to what actually works
Xcode exposes neither UIBackgroundModes nor BGTaskSchedulerPermittedIdentifiers
as an INFOPLIST_KEY_ build setting, and a partial plist inside a synchronized
group collides with the processed one. Both found by building, not by reading.

Also records that synchronized groups pick up new source files on their own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:41:07 +02:00
ScarriffleandClaude Opus 5 b28c5a0502 Send the trimmed snapshot to the watch over WatchConnectivity
App Groups are per-device, so the watch cannot read the file the phone writes
and has to be handed the data. Three delivery paths: application context for
the latest state, a complication push only when the next appointment actually
changed (the budget is small), and a reply to the watch asking directly.

Sign-out pushes an empty signed-out snapshot. Clearing our own container is not
enough — the watch holds its own copy and would keep rendering the previous
user's calendar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:40:45 +02:00
ScarriffleandClaude Opus 5 ad7a405f32 Refresh the shared snapshot in the background so widgets stop going stale
publishWidgetSnapshot() only ever ran from a user action, so an event created
on the web or on Android did not reach the widgets until the app was opened
again. A BGAppRefreshTask now republishes without the foreground.

The two keys this needs are arrays, which Xcode does not expose as
INFOPLIST_KEY_ build settings — verified by building and inspecting the
result. Hence a partial Calendarr-Info.plist that generation merges into. It
sits at the project root rather than inside the target folder: that folder is
a synchronized group, which would also copy the file as a resource and collide
with the processed one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:39:34 +02:00
ScarriffleandClaude Opus 5 c8a36c161f Filter widget snapshots through the shared package rule
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:35:23 +02:00
ScarriffleandClaude Opus 5 75a6a5bd79 Fix two OIDC compile errors against the current SDK and AppAuth
UIAccessibilityIsGuidedAccessEnabled() is gone in iOS 26; use the property.

AppAuth declares lastAuthorizationResponse and its request as nonnull, so the
optional chain was rejected. Behaviour is unchanged: nonce is optional either
way, which is the tolerance the comment there already describes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:35:23 +02:00
ScarriffleandClaude Opus 5 d70cdca4e2 Compute complication timeline entries from event boundaries
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:32:54 +02:00
ScarriffleandClaude Opus 5 2e9989eab9 Decide complication pushes from the next appointment's identity
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:32:54 +02:00
ScarriffleandClaude Opus 5 50446c8de5 Add the WatchConnectivity payload format with schema refusal
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:32:53 +02:00
ScarriffleandClaude Opus 5 66a51cc6fc Move calendar-key filtering into the shared package
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:32:53 +02:00
ScarriffleandClaude Opus 5 19454379e5 Add the watch coverage policy and snapshot trimming
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:29:20 +02:00
ScarriffleandClaude Opus 5 cbec421367 Declare watchOS support so a watch app can consume the snapshot
Uses the string form rather than .watchOS(.v26): that enum case requires
PackageDescription 6.2, and raising swift-tools-version for one constant
would change defaults across the whole manifest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:28:24 +02:00
ScarriffleandClaude Opus 5 e3ff2b824c Add the implementation plan for the Apple Watch app
Seventeen tasks. Every piece of testable logic lands in CalendarrCore first,
under TDD, because that is the only target this machine can actually run tests
for; the watch targets hold view and WCSession glue that is build-verified only.

The iOS background refresh is task 7 and commits on its own: it fixes the
home-screen widgets' staleness whether or not the watch app follows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:13:59 +02:00
ScarriffleandClaude Opus 5 214f120859 Add design spec for the Apple Watch app and watch-face complications
The watch is a read-only consumer of the phone's snapshot: WatchConnectivity
carries a trimmed CalendarrSnapshot to the watch, which writes it into its own
App Group container through the same SnapshotStore. One format, one reader.

Also specifies the iOS background refresh the watch depends on, which fixes a
pre-existing staleness in the home-screen widgets as a side effect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 15:35:43 +02:00
ScarriffleandClaude Opus 5 3d73642f2e fix(login): 2FA-Einrichtungsfeld fuer Passwortmanager ausblenden
Die Seite trug zweimal autocomplete="one-time-code": im Login und im Feld
zum Einrichten von 2FA in den Einstellungen. Fuer einen Passwortmanager ist
damit nicht eindeutig, zu welchem Feld sein Inline-Symbol gehoert.

Das Einrichtungsfeld bekommt data-bwignore (plus die Entsprechungen fuer
1Password und LastPass) — dort tippt man den Code aus der Authenticator-App
beim Anlegen eines neuen Geheimnisses ab, ein Ausfuellen ergibt da ohnehin
keinen Sinn. Die Passwortfelder bleiben unangetastet, die sollen weiter
befuellbar sein.

Ob das die fehlplatzierte Bitwarden-Anzeige behebt, ist offen: hier laesst
sich keine Erweiterung installieren. Der vorherige Versuch ueber das
Fokus-Timing hat es nicht geloest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 06:41:53 +02:00
ScarriffleandClaude Opus 5 1b09cd5167 fix(login): 2FA-Feld erst nach dem Layout fokussieren
Benutzername und Passwort liegen ab Seitenaufbau im Layout, das 2FA-Feld
taucht erst aus display:none auf — und der Fokus wurde im selben Tick
gesetzt. Passwortmanager oeffnen ihr Inline-Overlay beim Fokus und messen die
Feldposition in genau dem Moment; Bitwarden platzierte sein Symbol dadurch
weit neben dem Feld.

Der Fokus laeuft jetzt ueber setTimeout(0), sodass das Layout vorher steht,
plus ein resize-Event als Anstoss zum Neupositionieren. Bewusst kein
requestAnimationFrame: das feuert in einem nicht sichtbaren Tab nicht und
haette den Fokus ganz verschluckt.

Nicht gegen Bitwarden verifizierbar — hier ist keine Erweiterung installiert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 06:32:54 +02:00
ScarriffleandClaude Opus 5 a977ee3c2b feat(oidc): verknuepfte SSO-Konten in den Einstellungen
Die Endpunkte gab es schon, die Oberflaeche fehlte — es war schlicht nicht
sichtbar, ob und womit ein Konto verknuepft ist.

Unter Einstellungen > Profil, zwischen App-Passwoertern und Privatsphaere,
steht jetzt "Single Sign-On" mit den verknuepften Konten (Anbieter, E-Mail,
letzte Nutzung) und je einem Knopf zum Aufheben. Ohne Verknuepfung erscheint
ein Hinweis, wie man eine anlegt; ist auf dem Server ueberhaupt kein Provider
konfiguriert, bleibt der ganze Abschnitt unsichtbar.

Bei einem per SSO angelegten Konto ist die letzte Verknuepfung nicht
loesbar — es hat kein brauchbares Passwort und waere danach dauerhaft
unerreichbar. Der Knopf ist in dem Fall deaktiviert und erklaert sich per
Tooltip; der Server lehnt es zusaetzlich ab.

Im Browser durchgespielt: verknuepfen, Eintrag erscheint mit Anbieter und
Datum, aufheben, Bestaetigung, leerer Zustand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 06:10:43 +02:00
ScarriffleandClaude Opus 5 725b61c8cb fix(auth): Sitzungen ueber die Benutzer-ID statt ueber den Namen aufloesen
Das Sitzungs-Token trug den Benutzernamen als Subjekt, und get_current_user
suchte danach. Eine Umbenennung machte damit jedes Token auf jedem anderen
Geraet stillschweigend ungueltig — der Nutzer flog ohne Hinweis raus.

Tokens tragen jetzt zusaetzlich uid, die unveraenderliche Zeilen-ID, und die
Aufloesung geht darueber. sub bleibt als Anzeigename erhalten. Tokens ohne
uid werden weiter ueber den Namen aufgeloest, damit vor der Aenderung
ausgestellte Sitzungen gueltig bleiben — mit "Angemeldet bleiben" laufen die
180 Tage.

Fuer SSO war die Zuordnung schon vorher richtig: OIDCIdentity haengt an
(provider_key, sub) des Providers, und bei bekanntem sub wird der lokale
Benutzername nicht angefasst. Ein umbenannter Nutzer in Authentik landet also
unveraendert auf demselben Konto. Vier Tests halten beide Richtungen fest:
Umbenennung beim Provider, lokale Umbenennung mit weiterlaufender Sitzung,
lokale Umbenennung mit erhaltener SSO-Verknuepfung, und die Kompatibilitaet
alter Tokens.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 06:01:05 +02:00
ScarriffleandClaude Opus 5 cc4daf4a84 feat(oidc): SSO-Identitaet bewusst mit einem Konto verknuepfen
Bisher war eine SSO-Anmeldung ohne passendes Konto eine Sackgasse: rote
Fehlermeldung, kein Weg weiter. Die Verknuepfung ging nur ueber
LINK_BY_EMAIL, das der E-Mail-Verwaltung des Providers vertrauen muss.

Jetzt endet der Callback bei einer besitzerlosen Identitaet nicht im Fehler,
sondern parkt sie in einem kurzlebigen signierten HttpOnly-Cookie und leitet
auf /?sso_link=1. Der Login-Screen bittet daraufhin einmalig um das
Calendarr-Passwort; nach erfolgreichem Login loest der Client
POST /api/auth/oidc/link-pending ein und die Identitaet haengt an genau dem
Konto, das sich gerade ausgewiesen hat. Beide Haelften sind damit bewiesen —
das ID-Token haben wir selbst geprueft, das Konto durch das Passwort — und
die Vertrauensannahme ueber die E-Mail entfaellt.

Das greift fuer alle drei Faelle, in denen eine gueltige Identitaet keinen
Besitzer hat: kein Konto vorhanden, Selbstregistrierung aus, oder E-Mail
bereits vergeben.

Ausserdem: unbekannte Fehler-Slugs werden jetzt mit angezeigt statt hinter
"Anmeldung fehlgeschlagen" zu verschwinden — genau die Information, die zur
Diagnose fehlte. Und oidc_token_exchange_failed hat eine eigene Meldung, die
auf Client-Typ und Redirect-URI hinweist, weil das der haeufigste
Konfigurationsfehler ist.

Sechs Tests dazu; Ablauf zusaetzlich im Browser gegen einen echten
OIDC-Stub-Provider durchgespielt: Hinweis statt Fehler, Verknuepfung nach
Passwort-Login, danach Anmeldung allein per SSO.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 20:46:34 +02:00
ScarriffleandClaude Opus 5 7b51cab693 feat(auth): SSO-Anmeldung ueber OpenID Connect (AppAuth)
Zusaetzlicher Anmeldeweg neben Benutzername/Passwort. Der Login-Screen holt
die konfigurierten Provider von GET /api/auth/oidc/providers; ohne Provider
oder gegen einen aelteren Server bleibt der Screen unveraendert.

Die App laeuft als Public Client: Authorization Code Flow mit PKCE in einer
ASWebAuthenticationSession (nie ein WKWebView), ohne Client Secret. Das
ID-Token wird an POST /api/auth/oidc/exchange gegen ein Calendarr-Token
getauscht; die Antwort hat dieselbe Form wie der Passwort-Login, sodass der
bestehende Session-Pfad unveraendert weiterlaeuft.

- OIDCAuthenticator kapselt Discovery, PKCE-Request, Code-Tausch und die
  stille Erneuerung. Der AuthState mit dem Refresh-Token liegt in der
  Keychain (kSecAttrAccessibleAfterFirstUnlock) und wird beim Logout aus
  beiden Zugriffsgruppen entfernt.
- OIDCSessionRefresher erneuert die Sitzung beim Start, wenn das
  Calendarr-Token binnen 24 h ablaeuft — ohne Browser, ueber den
  Refresh-Token des Providers.
- Die angefragten Scopes kommen vom Server (mobile_scopes); ein Scope, den
  der Provider nicht kennt, wuerde die ganze Anfrage scheitern lassen.
- Bei aktiviertem gefuehrten Zugriff weicht AppAuth auf SFSafariViewController
  aus, dessen Redirect nur ueber ein registriertes URL-Schema zurueckkaeme.
  Da das App-Target seine Info.plist generiert und CFBundleURLTypes dort
  nicht setzbar ist, wird der Fall vorab sauber abgelehnt statt endlos auf
  einen Callback zu warten, der nicht kommen kann.
- Abbrueche werden mit Fehlerdomaene geprueft (user- und programCanceled) und
  still behandelt; alle uebrigen Fehler tragen Domain, Code und OAuth-Fehler
  ins DEBUG-Log, statt spurlos zu verschwinden.

project.pbxproj: AppAuth-iOS 1.7.5 als SPM-Abhaengigkeit, nur am App-Target.
OTHER_LDFLAGS bekommt -ObjC, weil OIDAuthState+IOS.m ausschliesslich eine
Kategorie enthaelt und ohne Symbol sonst wegoptimiert werden koennte.

Achtung: mangels macOS/Xcode ist dieser Stand nicht kompiliert. Beim ersten
Oeffnen loest Xcode das Paket auf und legt Package.resolved an; die bitte
mit committen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 18:33:59 +02:00
ScarriffleandClaude Opus 5 08f8267dcc fix(auth): SSO-Korrekturen aus dem Review + stiller Token-Refresh
Drei Fehler, die den Flow gebrochen haetten:

- Ein Abbruch im Browser liefert bei AppAuth ein regulaeres Ergebnis mit
  USER_CANCELED_AUTH_FLOW, nicht null. Der bisherige Null-Check lief ins
  Leere und "Zurueck" endete in einer roten Fehlermeldung.
- launch(intent) lag ausserhalb von runCatching. Verlaesst der Nutzer den
  Screen waehrend der Discovery, ist der Result-Launcher abgemeldet und die
  IllegalStateException haette die App beendet.
- Nonce und Provider lagen nur in ViewModel-Feldern. Der Custom Tab laeuft
  in eigener Task, dieser Prozess wird dabei routinemaessig getoetet — die
  Anmeldung waere nach erfolgreichem Login beim IdP gescheitert. Der Nonce
  kommt jetzt aus AuthorizationResponse.request, Provider und Client-ID
  werden vor dem Start persistiert.

Weiter: AuthState.update verlangt genau eines von (Response, Exception) und
haette bei null/null geworfen; onCleared() verwarf den @Singleton-Service
fuer die zweite AuthViewModel-Instanz mit.

Neu ist der geforderte Hintergrund-Refresh: laeuft das Calendarr-Token
binnen 24 h ab, wird beim Start ueber den Provider-Refresh-Token ein frisches
ID-Token geholt und getauscht — ohne Browser. Best effort; schlaegt es fehl,
bleibt das vorhandene Token in Kraft.

Ausserdem: Scopes kommen ausschliesslich vom Server (mobile_scopes), Spinner
pro Provider statt global, fehlender Browser als eigener Fehlerfall, Issuer
wird validiert, keine namenlosen Identitaeten mehr.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 18:33:36 +02:00
ScarriffleandClaude Opus 5 b560ee260c fix(oidc): Nonce-Pruefung selbst kontrollieren statt Authlib zu ueberlassen
Authlib wertet "Aufrufer hat eine Nonce uebergeben, das Token traegt keine"
als Mismatch. Das ist fuer den erneuerten Token der mobilen Clients falsch:
ein per Refresh-Token geholtes ID-Token laesst die nonce-Claim regulaer weg,
womit der Hintergrund-Refresh immer an einem 401 gescheitert waere.

Die Nonce wird daher nicht mehr an Authlib durchgereicht, sondern hier
geprueft: vorhandene Nonce muss passen, und der Browser-Flow verlangt sie
zusaetzlich zwingend (require_nonce) — dort erzeugen wir sie selbst, also
muss der Provider sie zurueckspiegeln. Signatur und uebrige Claims bleiben
unveraendert bei Authlib.

Zwei Tests dazu: erneuertes Token ohne Nonce wird angenommen, Browser-Flow
ohne Nonce abgelehnt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 18:33:19 +02:00
ScarriffleandClaude Opus 5 8b0af0f44a feat(auth): SSO-Anmeldung ueber OpenID Connect (AppAuth)
Zusaetzlicher Anmeldeweg neben Benutzername/Passwort. Der Login-Screen holt
die konfigurierten Provider von GET /api/auth/oidc/providers; ohne Provider
oder gegen einen aelteren Server bleibt der Screen unveraendert.

Die App laeuft als Public Client: Authorization Code Flow mit PKCE in einem
Chrome Custom Tab (nie ein WebView), ohne Client Secret. Das ID-Token wird
anschliessend an POST /api/auth/oidc/exchange gegen ein Calendarr-Token
getauscht; die Antwort hat dieselbe Form wie der Passwort-Login, sodass der
bestehende Session-Pfad unveraendert weiterlaeuft.

- OidcAuthenticator kapselt Discovery, PKCE-Request und Code-Tausch.
- Die angefragten Scopes kommen vom Server (mobile_scopes) — ein Scope, den
  der Provider nicht kennt, wuerde die ganze Anfrage scheitern lassen.
- AuthState mit dem Refresh-Token liegt in den EncryptedSharedPreferences,
  nie im Klartext, und wird beim Logout mitgeloescht.
- Provider und Client-ID werden vor dem Browser-Start persistiert, weil der
  Custom Tab in einer eigenen Task laeuft und dieser Prozess dabei getoetet
  werden kann; der Nonce kommt aus AuthorizationResponse.request, das AppAuth
  ueber den Redirect zurueckreicht.
- Abbruch im Browser meldet AppAuth als regulaeres Ergebnis mit
  USER_CANCELED_AUTH_FLOW und wird still behandelt, nicht als Fehler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 18:25:15 +02:00
ScarriffleandClaude Opus 5 07235fd4c9 feat(oidc): Mobile-Scopes serverseitig konfigurierbar (offline_access)
Die Apps duerfen keine Scopes erfinden: ein Scope, den der Provider nicht
kennt, laesst ihn die gesamte Authorization-Anfrage ablehnen. Daher legt
jetzt der Server fest, was die nativen Clients anfragen.

OIDC_<KEY>_MOBILE_SCOPES ist frei setzbar; ohne Angabe sind es die
Web-Scopes plus offline_access, damit Authentik ab 2024.2 ueberhaupt ein
Refresh-Token ausstellt. Der Browser-Flow bleibt unveraendert, der braucht
kein offline_access. Ausgeliefert wird der Wert als mobile_scopes in
/api/auth/oidc/providers, aber nur wenn eine MOBILE_CLIENT_ID konfiguriert
ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 18:24:59 +02:00
ScarriffleandClaude Opus 5 25fde49cee docs: echte Redirect-URIs der Mobile-Clients in der SSO-Anleitung
Die Platzhalter-URI calendarr://oauth/callback durch die tatsaechlichen
Reverse-DNS-Schemata der beiden Apps ersetzt und vermerkt, dass sie als
Public Clients ohne Secret laufen und ohne MOBILE_CLIENT_ID keinen
SSO-Button zeigen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 18:14:33 +02:00
ScarriffleandClaude Opus 5 31baf4b18c feat(auth): OpenID-Connect-SSO gegen Authentik (Backend + Web)
Zusaetzlicher Anmeldeweg per OIDC neben dem bestehenden Passwort-Login. Ohne
gesetztes OIDC_PROVIDERS aendert sich nichts: /api/auth/oidc/providers meldet
enabled:false und der Login-Screen bleibt unveraendert.

Backend
- oidc_config.py: Provider-Registry rein aus ENV. Ein zweiter Provider ist
  reine Konfiguration (OIDC_PROVIDERS + OIDC_<KEY>_* Block), keine Codeaenderung.
- oidc_client.py: Discovery, JWKS, Token-Tausch, ID-Token-Pruefung ueber
  authlib.jose mit fest gepinnter Signatur-Allowlist (nur asymmetrisch, damit
  alg:none und Algorithmus-Confusion ausgeschlossen sind).
- oidc_identity.py: Zuordnung ueber (provider_key, sub). Ein E-Mail-Treffer auf
  ein bestehendes Konto wird standardmaessig abgelehnt statt verknuepft — sonst
  erbt jeder, der im IdP eine beliebige E-Mail setzen kann, das passende Konto.
  Verknuepfen ist eine explizite, authentifizierte Aktion. Auto-Provisioning
  nur per ENV-Flag, standardmaessig aus, optional mit Domain-Allowlist.
- routers/oidc_router.py: /providers, /start, /callback, /complete, /exchange,
  /link, /identities. PKCE S256; Flow-State als kurzlebiges signiertes JWT in
  einem HttpOnly-Cookie mit jti==state statt eines prozesslokalen Dicts, damit
  der Flow Neustarts und mehrere Worker uebersteht. Das Calendarr-JWT wird ueber
  ein Einmal-Cookie uebergeben, nie ueber URL oder Fragment.
- /exchange als Kontrakt fuer die AppAuth-Clients: die App fuehrt den Flow als
  Public Client selbst aus und tauscht das ID-Token; kein Secret im Request.
- models.py: neue Tabelle oidc_identities, users.auth_source; Migration in
  main.py im dortigen try/except-Stil.

Nebenbefund, mitbehoben: auth.verify_password liess ValueError aus
bcrypt.checkpw durch. Ein nicht wohlgeformter password_hash wurde damit zu
HTTP 500 auf /api/auth/login und bei jedem CalDAV-Basic-Auth. SSO-Nutzer
bekommen einen echten bcrypt-Hash ueber ein zufaelliges, nirgends gespeichertes
Geheimnis; fuer CalDAV nutzen sie App-Passwoerter wie 2FA-Nutzer.

Web
- SSO-Block auf dem Login-Screen, aus /providers befuellt; Buttons sind Links,
  weil /start eine Top-Level-Navigation braucht.
- consumeSsoHandoff() laeuft vor den Setup-/Token-Pruefungen in boot() und
  raeumt die Query per replaceState ab, bevor es awaited.
- 401-Auto-Logout um die SSO-Pfade erweitert, sonst dreht ein fehlgeschlagener
  Handoff eine Reload-Schleife.
- i18n-Keys in de + en (Fallback-Kette deckt die uebrigen acht Sprachen).
- Service Worker laesst /api/auth/oidc/ unangetastet durch.

37 neue Tests (Gesamtsuite 60 gruen): Signaturpruefung mit echten RSA-Tokens,
alg:none, falscher iss/aud/nonce, unbekannte client_id, Provisioning-Policies,
Username-Kollision, Einmaligkeit des Handoff-Cookies, unerreichbarer Provider.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 18:00:46 +02:00
ScarriffleandClaude Opus 5 a30375aa63 feat(mini-cal): ein Punkt je Kalender in dessen Farbe pro Tag
Statt eines einzelnen Punkts in --primary zeigt der Mini-Kalender jetzt fuer
jeden Kalender, der an dem Tag mindestens einen Termin hat, einen Punkt in
dessen Farbe (max. 4, stabil nach erstem Auftreten sortiert). Die Farbquelle
ist dieselbe wie in Monats-/Wochenansicht: ev.color || ev.calendarColor.

Ausserdem laeuft der Mini-Kalender jetzt ueber filterEvents(), damit
ausgeblendete Gruppenmitglieder keinen Punkt mehr erzeugen. Auf dem
"heute"-Kreis bekommen die Punkte einen duennen weissen Ring, damit sie auf
der farbigen Flaeche lesbar bleiben.

Cherry-pick von 7a2085b (master) auf beta; die selected-Day-Regel aus beta
(color-mix) bleibt erhalten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 13:07:02 +02:00
ScarriffleandClaude Opus 5 d1da8de06a Read and write the widget snapshot through CalendarrCore
The snapshot format was an internal detail shared between the app and its own
widget extension. A second Mac app needs the same data, and the way to give it
that is a documented contract rather than a format it reverse-engineers and then
drifts from. Both targets now link CalendarrCore and go through SnapshotStore.

Shared/WidgetData.swift becomes a thin facade. The typealiases and the flat
colour accessors exist so the ~70 existing call sites across the app and the
widget views compile unchanged; they are a migration convenience, not a design.

The snapshot now carries what a reader outside this app actually needs:

coverageStart / coverageEnd, because the published window is ~7 days back and
~42 ahead. Outside it the snapshot holds no information, which is not the same
as holding no events — and only the writer knows where that edge is. Without it
a consumer renders a convincingly empty March and is simply wrong.

isLoggedIn plus a session record, so a reader can say "sign in to Calendarr"
rather than "open Calendarr once". The events are deleted on sign-out, so the
absence of a cache alone cannot tell those two apart.

writerVersion, purely so a mismatch between the two apps is diagnosable.

The coverage constants move to SnapshotCoverage, so the writer and the code that
reconstructs the window for older files can no longer disagree about it.

CalendarStore needs an explicit `import CalendarrCore` because the app target
builds with SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY, which requires the
defining module to be imported directly rather than picked up transitively.

Verified: builds for iOS and Mac Catalyst; CalendarrKit's 11 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:42:00 +02:00
Scarriffle d5a9dad769 Answer "signed out" vs "never ran" in one read; make SnapshotTheme constructible
Signing out deletes the cached events — keeping them would leak the previous
user's calendar to anything reading the container — so an absent cache alone
cannot distinguish "signed out" from "Calendarr has never run on this machine".
Those need different messages, so read() now falls back to the session record
and reports .loggedOut when one exists and says so. Every consumer would
otherwise have to reimplement that two-step, and any that forgot would tell a
signed-out user to go install an app they already have.

SnapshotTheme only had the compiler's internal memberwise initializer, so no
consumer outside the package could actually build one.
2026-08-10 17:41:32 +02:00
ScarriffleandClaude Opus 5 ab2328969e Add CalendarrCore: the shared read contract for the calendar snapshot
Calendarr already publishes an event snapshot into its App Group container for
the widgets to render. That channel is what a second Mac app needs too, so this
turns an internal detail into a documented contract rather than something a
second app reverse-engineers and then drifts from.

Foundation only — no SwiftUI, no UIKit, no AppKit — so one source compiles for
iOS, Mac Catalyst and native macOS. Building against the native macOS SDK is the
point: it fails loudly on a UIKit leak that a Catalyst build would accept.

Three things earn their place here rather than being copied per app:

CalendarrAppGroup resolves the container identifier per platform. macOS requires
the Team ID prefix and iOS forbids it, and a mismatch fails silently — nil
container, every read and write a no-op, widgets blank forever.

CalendarKey normalises the two ways the server reports calendar_id (bare for
CalDAV, "local-3" style for everything else). Normalising them differently is
what made per-calendar filtering quietly do nothing for some sources.

The snapshot schema is versioned, with the coverage window now explicit. That
window is the easiest thing to get wrong downstream: outside it the snapshot
carries no information, which is not the same as no events, and rendering an
empty week for an uncovered date produces a screen that looks right and is
false. covers() exists so consumers can tell the two apart.

Reads return a named result rather than an optional. "Never ran", "signed out",
"too new to read" and "unreadable" need different messages, and a bare nil is
how the App Group misconfiguration used to hide.

Writing is behind @_spi(Writer) so a read-only consumer cannot corrupt the
shared cache. Writes post a Darwin notification, the one cross-process signal
that works between two sandboxed apps without an extra entitlement.

The six colour values stay flat on the wire instead of nesting under `theme`,
which would read better. A v1 reader requires them flat, so nesting would have
broken every older build on the first v2 write; `theme` is a computed view.
Tests pin both directions of compatibility, since the two apps ship
independently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:30:23 +02:00
ScarriffleandClaude Opus 5 84f5c00516 Keep iOS iPhone-only; give the iPad family to Mac Catalyst alone
Enabling Mac Catalyst pulled in the iPad device family, which made App Store
Connect reject the iOS build with error 90474: an iPad-capable bundle must
declare all four interface orientations to support iPad multitasking, and this
app declared two.

Catalyst genuinely needs the iPad idiom, but only for its own build. A per-SDK
condition gives macOS family 2 while iOS keeps family 1 — verified to resolve
correctly for both destinations, and Catalyst still builds with family 2 alone.
The shipped iOS bundle is back to UIDeviceFamily [1], exactly as before the
port started.

The iPad orientation key is removed rather than extended. With an iPhone-only
iOS build it is ignored, and macOS ignores orientation keys entirely, so it was
dead configuration that only served to trip the validator.

Supporting iPad properly is a deliberate piece of work — the layout is
portrait-only with no size-class handling — and is better done on purpose than
inherited as a side effect of the Mac port.

Verified: builds for both Mac Catalyst and iOS Simulator; built bundle reports
UIDeviceFamily [1] and portrait-only with no iPad orientation key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:19:55 +02:00
ScarriffleandClaude Opus 5 0d2ad6e021 Give Mac Catalyst its own App Group prefix and sandbox entitlements
The App Group identifier registered in the portal does not change, but the
string the runtime expects does: macOS and Mac Catalyst require the Team ID
prefix, iOS forbids it. CalendarrAppGroup now resolves the right one per
platform. Getting this wrong is the worst failure mode in the whole port —
containerURL() returns nil, every snapshot read and write quietly no-ops, and
the widgets show placeholder content forever with no error anywhere. A DEBUG
assertion now makes that loud during development.

Because the two platforms need different values, they need different
entitlements files — listing both strings in one file breaks iOS provisioning
on the unregistered prefixed value. Selected via CODE_SIGN_ENTITLEMENTS[sdk=macosx*],
verified to resolve correctly for both destinations.

The Catalyst entitlements are written App Store grade from the start, so one
configuration serves both the Mac App Store and a notarized DMG: sandbox,
network client, Contacts (birthday import), user-selected files (.ics import
and export), the prefixed App Group, and keychain sharing. Deliberately absent:
files.downloads, network.server, device.*, temporary-exception.* — nothing needs
them and each is App Review friction. ENABLE_HARDENED_RUNTIME is required for
notarization and ignored by the App Store, so it is safe to set unconditionally.

Verified: builds for both Mac Catalyst and iOS Simulator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:03:33 +02:00
ScarriffleandClaude Opus 5 7fa8dbdbdf Enable Mac Catalyst on both targets; make the widget intent parameter optional
Supported Destinations now includes Mac (Mac Catalyst) on the app and the
widget extension. Catalyst requires the iPad device family, so both targets
move to TARGETED_DEVICE_FAMILY "1,2".

The first Catalyst build failed on CalendarSelectionIntent: WidgetConfigurationIntent
requires every @Parameter to be optional, and the macOS SDK enforces that where
the iOS one lets a bare array through. selectedCalendars is now optional.

Behaviour is unchanged — the timeline provider already treated an empty
selection as "show all calendars", so nil folds into the same path.

Verified: builds for both Mac Catalyst and iOS Simulator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 16:57:10 +02:00
ScarriffleandClaude Opus 5 c9bd42aae5 Unify app and widget version numbers; raise widget to iOS 26
The app shipped 3.5 (6) while the widget shipped 1.0.1 (2). Apple requires an
embedded appex to match its host's CFBundleShortVersionString and CFBundleVersion,
so this was a latent App Store validation failure that only surfaces at upload —
and it would surface on the Mac submission too. Both keys now live at project
level, where they cannot drift apart again.

The widget also targeted iOS 17 while the container app requires 26.0, so the
extension could never have been installed on 17 anyway. Raising it to 26.0
removes the availability skew that Mac Catalyst would otherwise inherit as
macOS 14 vs macOS 26.

Verified: app and appex Info.plists now both report 3.5 / 7 / 26.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 16:37:35 +02:00
ScarriffleandClaude Opus 5 781ccd1752 Make Keychain and shared-container state correct before the Mac port
Two failures here are silent rather than loud, which is why they have gone
unnoticed on iOS and would have been much harder to diagnose on a Mac.

The old `enum Keychain` discarded all four OSStatus results. On Mac Catalyst a
missing `keychain-access-groups` entitlement makes SecItem calls fail with
errSecMissingEntitlement, and because nothing checked, that was indistinguishable
from "no token stored" — the user would be signed out on every launch, with no
error anywhere. KeychainStore now checks every status, distinguishes
errSecItemNotFound from real failures, and sets kSecUseDataProtectionKeychain so
macOS selects the modern entitlement-gated keychain instead of the legacy login
keychain.

Adding the entitlement moves the default access group to the first array entry,
so that entry is deliberately the app's own group: existing tokens keep
resolving with an unqualified query and nobody is signed out. loadToken() then
migrates forward in three steps — shared group, own default group, and the
pre-Keychain UserDefaults copy.

If the entitlement is not provisioned yet, KeychainStore falls back to the
default group rather than throwing. A hard failure would make the app unusable
for everyone whose provisioning lags; the fallback asserts in DEBUG instead, so
a misconfiguration is loud in development and survivable in production.

Separately, logout() left widget-cache.json in the App Group container, so
widgets kept rendering the signed-out user's events indefinitely. That is an
existing iOS bug, and it would have leaked the same data to any other app
reading the container. WidgetStore.clear() now removes both cache files and
reloads the timelines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 16:31:06 +02:00
ScarriffleandClaude Opus 5 a45bf6ea73 Web: eight more UI languages behind a single language registry
Adds Swiss German, French, Italian, Dutch, Danish, Norwegian, Swedish and
Finnish — ten selectable languages, each complete at 418/418 keys with
matching placeholders and date arrays.

A language used to be registered in four unrelated places (dictionary,
settings dropdown, date-picker locale ternary, resolver fallback), so
adding one meant touching all of them. LANGUAGES in i18n.js is now the
single source of truth: adding a language is one entry plus one
dictionary, and the dropdown derives from it.

The registry also separates the stored code from the BCP-47 tag used for
formatting. That distinction is what makes 'ch' safe — "ch" is a country,
not a language, and would throw if handed to Intl; it maps to de-CH.

Fixes locale handling along the way: fmtTime/fmtDatetime in calendar.js,
month.js and week.js hardcoded 'de', so English users saw German-formatted
times, and two toLocaleDateString() calls silently used the browser locale
instead of the app language. All formatting now goes through getLocale().
Missing keys fall back language → English → German rather than straight to
German, and the dead per-dictionary `locale` key is gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 14:58:03 +02:00
ScarriffleandClaude Opus 4.8 c632dabc31 feat(web): branded green PWA icons + maskable, installable app polish
Replace the stale blue calendar icon with the real green Calendarr app
icon (matching iOS/Android) for the installable web app:
- icon-192/512.png regenerated from the branded 1024 app icon
- add dedicated maskable icons (192/512) with brand-green safe-zone
  padding so Android/Chrome render a proper adaptive icon (no white box)
- manifest: maskable purpose entries, id, description, lang/dir,
  categories; theme_color -> brand green (#16713d)
- index.html: theme-color -> green, add modern mobile-web-app-capable
- favicon.svg + icon.svg recolored to brand green
Bump APP_VERSION v89 -> v90.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 18:31:58 +02:00
ScarriffleandClaude Opus 4.8 995d9bb5b4 feat(web): every sidebar calendar can be hidden (shared, birthday, ical too)
Equal rights for all calendar types: the sidebar remove button is now a uniform
"hide" (eye) for every calendar instead of delete-for-some / nothing-for-shared.

- Owned local (incl. birthday) and iCal now hide via the server sidebar_hidden
  flag (delete stays available in Settings' calendar table).
- Calendars shared with me get a per-device sidebar hide (they can't carry a
  server flag); their events are filtered out while hidden, and the Settings
  calendar table's "shared with me" rows gain an un-hide eye toggle.
- External caldav/google/ha unchanged.

Group calendars intentionally left as-is for now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 12:46:53 +02:00
ScarriffleandClaude Opus 4.8 4c7cdc362a feat(web): link Impressum version to Gitea source repo
The version line in the Impressum footer ("Calendarr vNN") is now a
subtle link to the Calendarr Gitea repo (git.scarriffle.com), opening
in a new tab. Styled to stay muted at rest and reveal as a link on
hover. Bump APP_VERSION v87 -> v88.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 17:01:20 +02:00
ScarriffleandClaude Opus 4.8 37e5507261 feat(web): admin theme import + branding dimension hints
- Admin default-theme editor: "Theme importieren" button loads a .theme.json
  into the editor (colours only, live preview) so an imported theme can be set
  as the instance default (still confirmed via "Standard speichern").
- Branding: drop the "Standard" placeholder; show pixel-dimension hints instead
  (logo shown at max 150×40, favicon 128×128). Logo preview box now mirrors its
  real topbar footprint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 14:33:59 +02:00
ScarriffleandClaude Opus 4.8 22a58e0d9f feat(web): set built-in DEFAULT_COLORS to the green brand palette
Replaces the blue defaults with the provided green theme (primary #58B900,
accent #45A148, today #6FB669, greens for month/selected/today-bg, etc.). These
apply when no admin instance theme is set. THEME.md defaults table updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 14:21:03 +02:00
ScarriffleandClaude Opus 4.8 cea96660d9 feat(web): admin area with instance default theme, custom logo/favicon; .theme.json export
- Admin settings tab (admin-only) now holds user management plus a server-wide
  default theme editor (live preview + discard) and logo/favicon branding.
- New singleton InstanceSettings model + /api/instance router (public GET for
  the login screen; admin-gated theme PUT and logo/favicon upload/delete,
  reusing the avatar PIL/validation pattern).
- Instance default theme is the base users inherit and the target a per-colour
  "Reset" returns to (baseColor: instance default -> built-in).
- Custom favicon overrides the primary-colour tinting; custom logo replaces the
  top-left glyph+text, hard-capped so it only scales down and never breaks layout.
  Branding + defaults load before login (public endpoint).
- Theme export now uses the recognised .theme.json extension (old .theme still
  imports); import stays partial/unknown-key aware.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 14:19:15 +02:00
ScarriffleandClaude Opus 4.8 6316ed3a6b feat(web): theme import/export, more themable colors, hideable local/ical calendars
Theme:
- Export/import themes via UI as <date>_<time>.theme (JSON, readable keys,
  link to THEME.md). Import is partial-aware: only present params are written
  (server accepts partial); prompts before importing files with unknown params.
- Dynamic favicon + theme-color tinted to the primary colour on load/save.
- New themable colours: general hover-highlight, day hover/selected/bg,
  today background, plus two unified sidebar action-icon colours
  (inactive/active) covering bell, hide, delete and read-only icons.
- All new colours are per-setting syncable; documented in THEME.md.

UX:
- Styled confirm dialog (#modal-confirm) replaces window.confirm() for
  calendar delete and account disconnect.
- Birthday/local calendars and iCal subscriptions can now be hidden from the
  sidebar via Settings (new sidebar_hidden column + hide toggle).

Backend: additive nullable columns + idempotent migrations for user_settings
colours and local_calendars/ical_subscriptions.sidebar_hidden.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 13:20:30 +02:00
Guido Schmit aeb50c593c chore: F-Droid recipe category -> Calendar & Agenda (valid category) 2026-07-16 20:07:09 +02:00
Guido Schmit 52dd6e7ff0 docs: F-Droid serving via existing reverse proxy (no nginx/TLS on the LXC) 2026-07-16 19:13:15 +02:00
Guido Schmit 42187b8254 docs: F-Droid own-repo hosting on a Proxmox LXC (build on Mac, index+serve on LXC) 2026-07-16 19:09:31 +02:00
Guido SchmitandClaude Opus 4.8 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
ScarriffleandClaude Opus 4.8 554ad425b1 Week view: render multi-day all-day events as continuous spanning bars
The all-day strip repeated a multi-day event once per day; now events span their
start→end columns and pack into lanes (like the month view) via a GeometryReader
layout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 11:20:21 +02:00
Guido SchmitandClaude Opus 4.8 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
ScarriffleandClaude Opus 4.8 12c869451b Never leak a shared calendar's real name to recipients
_cal_dict now labels shared (non-owned) calendars by the sharer/group name
(the value already computed for shared_by) instead of the owner's real calendar
name. Fixes recipients seeing the true name in calendar-management lists (and
anywhere else the name is shown); all clients pick it up automatically.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 11:15:35 +02:00
Guido SchmitandClaude Opus 4.8 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 SchmitandClaude Opus 4.8 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 SchmitandClaude Opus 4.8 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 SchmitandClaude Opus 4.8 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
ScarriffleandClaude Opus 4.8 7981ced5ba iOS drawer: declutter controls, bigger header, more top spacing
- remove the show-all / hide-all buttons (rarely needed with a few calendars;
  the web has none). The calendar section now shows a 'Calendars' header + a
  slim Sort toggle.
- larger header (avatar, name, action icons) with more padding
- more vertical breathing room between header / view switcher / groups / list

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 18:06:56 +02:00
ScarriffleandClaude Opus 4.8 6881c68935 iOS drawer: flat reorderable calendars, long-press menu, left-swipe close, header actions
Testing feedback:
- calendars are now one flat, drag-reorderable list (no per-source grouping);
  order is device-local (CalendarStore.calendarOrder, mirrors the web cal_order).
  Reordering is done via a 'Sort' toggle (native edit mode with drag handles).
- banish + reminder-mute moved from row swipe actions to a long-press context
  menu, which frees the horizontal swipe: swiping the drawer left closes it.
- the hamburger moved to the LEFT (drawer opens left); new device-local setting
  'Hide menu button' (open via edge-swipe only), not synced.
- the settings entry moved from the footer into the header as an icon, next to a
  restored manual-sync icon and the close button; the footer button is gone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 16:19:29 +02:00
ScarriffleandClaude Opus 4.8 daf0345120 iOS drawer: drop the grouped-section card behind show/hide-all
listRowBackground(.clear) so only the two bordered button pills show, without
the surrounding grouped-list capsule.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 06:46:32 +02:00
ScarriffleandClaude Opus 4.8 108503ad53 iOS: month-switching setting as a named dropdown (parity with web)
Replace the boolean "month view as pages" toggle with a Monatswechsel dropdown:
Continuous scroll vs. Page by page — same names/format as the web client.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 06:45:22 +02:00
ScarriffleandClaude Opus 4.8 a7802778b6 Web: expose month-navigation mode as a named dropdown
New 'Monatswechsel' setting (synced flag already existed as month_view_paged):
- Continuous scroll (week): current behaviour — wheel scrolls by a week,
  buttons/swipe jump 4 weeks.
- Page by page (month): wheel navigation is disabled; buttons and horizontal
  swipe jump a whole calendar month.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 06:26:02 +02:00
ScarriffleandClaude Opus 4.8 e008d5d98a iOS drawer: compact show/hide-all, single menu button, corner-safe footer
Testing feedback:
- show-all / hide-all are now side by side (bordered), not two big stacked rows
- the busy quick-access strip becomes a single "Einstellungen" button that
  opens the full menu (which already holds accounts/profile/groups/server/sync/
  logout)
- extra horizontal + bottom padding so the footer button is not clipped by the
  iPhone's rounded corners / home indicator

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 06:09:38 +02:00
ScarriffleandClaude Opus 4.8 d90a1a552c iOS: left side drawer (calendars + groups + nav) and opt-in top-bar colour
Replace the top-bar menu popup with an off-canvas left drawer, opened by the
hamburger or a narrow left-edge swipe (kept off the content area so month/week
swipe stay free). The drawer hosts the calendar visibility list, a view
switcher, group switching, and quick access to Settings/Accounts/Profile/
Groups/Server plus sync and logout.

- extract the filter list into reusable CalendarFilterContent (shared by the
  drawer and the still-present CalendarFilterSheet); the sheet is now a thin
  wrapper
- new CalendarDrawer view; CalendarHostView hosts it as a ZStack overlay with a
  scrim and edge-swipe gesture, and presents drawer destinations as sheets
- opt-in surface colour: surfaceColor (device-local) tints the flat top bar;
  empty keeps the translucent .bar material. Settings row with an Auto reset.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:24:24 +02:00
Guido SchmitandClaude Opus 4.8 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
ScarriffleandClaude Opus 4.8 7a89cc44f8 Web: settings page nav/header follow the surface colour too
The settings modal's own left nav and header were on --bg-app; point them at
--bg-sidebar / --bg-topbar so they track surface_color like the main sidebar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:08:22 +02:00
ScarriffleandClaude Opus 4.8 131f6b496d Add configurable surface/sidebar colour + clearer sync-icon state
- new user_settings.surface_color (nullable, device-local default, not synced):
  drives the web sidebar / top bar / surfaces; NULL derives from bg_color as
  before. Added to schema, migration, GET/PUT, NULLABLE_OVERRIDES, DEFAULT_SYNC.
- web: surface colour row in the settings table; applyTheme derives the whole
  surface family from it
- web: per-row sync icon now has real styling — ON shows the primary-tinted
  glyph on a pill, OFF shows a slashed, muted glyph (was previously unstyled,
  so synced/not-synced looked identical)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:01:16 +02:00
ScarriffleandClaude Opus 4.8 9a50f25f57 iOS: per-setting sync table with sync icons, dropdowns, colour resets
Generalise the two-tier SettingsSync into a server-driven flag map: each
syncable key has an account-wide flag (fetched from the server); pull applies
only on-flagged keys, push (read-modify-write) sends only on-flagged keys plus
the flag map. Per-row link icon toggles a setting's sync; a global switch flips
all. Enabling a flag adopts this device's current value as the shared one.

- AppSettings: fix bg_color mapping (was background_color), add cache_months,
  month_view_paged, sync_flags
- SettingsView: uniform rows (sync icon | name | value), ContrastSelector and
  the button pickers replaced by menu dropdowns, every colour gets a reset to a
  canonical default, global "sync all" toggle; account settings (privacy,
  group calendar, hide profile) kept in their own block; language and text/line
  contrast kept device-local (iOS-specific)
- unify default line colour to #3A3A52 across views

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 21:31:16 +02:00
ScarriffleandClaude Opus 4.8 4ab07ddcc3 Web polish from testing feedback + admin role toggle
- style the settings-table dropdowns for the dark theme (were white)
- share-icon picker on one even row (no lopsided wrap)
- more breathing room between colour hex and swatch
- user management: promote/demote admin (new PUT /users/{id}/admin,
  guarded against self-change and removing the last admin)
- calendar management table: clip/ellipsis cells so narrow columns no
  longer overlap (full-width detail rows still wrap)
- centralise default colours: utils.applyTheme now derives every fallback
  from settings-sync.DEFAULT_COLORS (single place for coders to edit)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 21:25:01 +02:00
ScarriffleandClaude Opus 4.8 137694ed98 Web: unified settings sync table with per-setting sync toggles
Replace the ad-hoc appearance panel with a uniform table (sync icon | name |
value): dropdowns instead of button groups, every colour with a reset to a
canonical default, a per-row sync toggle, and a global "sync all" master
switch.

- new settings-sync.js: canonical default colours, declarative table
  definition, browser-local value copy, effective-value resolution
- calendar.js: generalised sync engine (pull applies only synced keys from the
  server, save pushes only synced keys + the flag map), unified the two save
  buttons into one, dropped the dead text/line-contrast code, moved default
  duration into the appearance table, effective UI language on load
- account settings (private visibility, group calendar, hide profile) stay in a
  dedicated profile block, not the sync table
- app.css: settings table / sync toggle / sync icon styles
- i18n: sync-all / sync-this / share-icon strings; sw cache bump

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 21:07:26 +02:00
ScarriffleandClaude Opus 4.8 3d58e8fef8 Document the cross-device settings-sync contract
Shared reference for Web/iOS/Android: canonical keys, default flags, the
GET/PUT sync_flags API, and the pull/push rules each client implements.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 20:46:23 +02:00
ScarriffleandClaude Opus 4.8 91ae434e7c Add per-setting cross-device sync flags to user settings
Introduce an account-wide sync-flag map that is the single server-side
authority for which settings each client sends/fetches, plus value columns
for two newly-syncable device-local prefs.

- models.UserSettings: sync_flags (JSON), cache_months, month_view_paged
- main._migrate(): idempotent ALTER TABLE for the three new columns
- settings_router: DEFAULT_SYNC map + _resolve_sync_flags(); GET returns
  fully-resolved sync_flags (+ cache_months, month_view_paged); PUT accepts
  and merges a partial sync_flags map and the two new value fields

Rollout defaults: settings that already lived on the server sync ON; the
four newly-syncable prefs (language, share icon, cache range, month paging)
sync OFF until the user opts in.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 20:44:44 +02:00
ScarriffleandClaude Opus 4.8 1f1eb582ed Birthdays: explicit "Birthday calendar" add type + tidier table defaults (web)
- add "Geburtstagskalender" to the sidebar +calendar dropdown and a "+ Geburtstage"
  button to Settings > Calendars; both call ensureBirthdayCalendar (server enforces
  one per account) and toast "already exists" if present
- Settings birthday section: when none exists, just a hint pointing to "+ Geburtstage"
  (creation lives in the add area now, not a separate activate button)
- resizable calendar table: sensible default column widths instead of raw measured
- i18n de/en

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 21:34:38 +02:00
ScarriffleandClaude Opus 4.8 d4fd7beaf7 Birthdays: explicit calendar creation, sync never auto-creates (iOS)
- Contacts sync fills only an EXISTING birthday calendar (birthdayCalendar),
  never auto-creates it — deleting it no longer resurrects it on sync
- Accounts + menu gains "Geburtstagskalender" (only when none exists) to create
  the single birthday calendar explicitly
- the Birthdays-from-Contacts section shows a "create one first" hint when there
  is no birthday calendar; enabling sync only requests Contacts access

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 21:25:06 +02:00
ScarriffleandClaude Opus 4.8 29ddc5acbb Enforce a single birthday calendar per account (server-side)
- POST /local/calendars with is_birthday returns the existing birthday calendar
  if one exists (idempotent, no second created)
- PUT /local/calendars rejects (422) marking a second calendar as is_birthday

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 21:22:10 +02:00
ScarriffleandClaude Opus 4.8 0ce77ccdf8 Add drag-resizable columns to the calendar settings table
Column widths are draggable via a handle on each header's right edge and
persisted in localStorage (re-applied on re-render). Uses fixed table layout
locked to measured px widths so resizing one column doesn't reflow the others.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 21:06:36 +02:00
ScarriffleandClaude Opus 4.8 16ff434bef Make imported-event dedup bulletproof (server-side, id-based)
Repeated Contacts syncs must never create duplicate birthdays. Enforce it on
the server, independent of the client's reconcile:

- POST /local/events is now idempotent on external_uid: if an event with the
  same (calendar_id, external_uid) exists, update it in place instead of
  inserting a new row
- startup cleanup removes existing duplicates sharing the same
  (calendar_id, external_uid), keeping the earliest — auto-heals old data
- unique DB index on (calendar_id, external_uid) as a hard guarantee

external_uid is the stable "contact:<deviceId>:<contactId>", so the same contact
from the same device always maps to exactly one event.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:53:11 +02:00
ScarriffleandClaude Opus 4.8 1a30b4066a Fix: don't squeeze the Kalender settings table into the 680px column
The reading-column max-width added for the profile panel also constrained the
wide calendar table, forcing a half-width horizontal scroll. Exempt
#settings-panel-accounts so the table uses the full width.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:49:26 +02:00
Guido SchmitandClaude Opus 4.8 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 SchmitandClaude Opus 4.8 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 SchmitandClaude Opus 4.8 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
ScarriffleandClaude Opus 4.8 69ed8bf67d Birthdays: single calendar model + sync on server-sync (iOS)
- one birthday calendar per user; BirthdaysImporter auto-creates/targets it
  (no picker) and reconciles per-device (external_uid contact:<deviceId>:<id>)
  so multiple devices don't clash
- Contacts birthday sync now runs on every "Sync with server" (syncFromServer),
  not just app launch and the manual button
- report the device after each sync (POST /api/birthdays/sync-report) for the
  web device list
- Accounts: Birthdays-from-Contacts section drops the target picker, keeps the
  reminder picker; removed the birthday toggle from the generic new-calendar sheet
- New-birthday sheet targets the single calendar, offers activation if none
- 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:07:46 +02:00
ScarriffleandClaude Opus 4.8 aa12b83302 Birthday sync-device list + guard group-visible against birthday calendar
- new BirthdaySyncDevice table + /api/birthdays/sync-report & /devices so the
  web can show "birthdays come from these devices" (iOS reports its device on
  each Contacts sync)
- settings: reject setting a birthday calendar as the group-visible calendar
  (it can still be shared directly)
- web: Settings > Calendars > Birthdays shows the device list; exclude birthday
  calendars from the group-visible picker

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:52:02 +02:00
Guido SchmitandClaude Opus 4.8 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 SchmitandClaude Opus 4.8 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
ScarriffleandClaude Opus 4.8 871fb833ba chore: sync Xcode project structure (Shared group + widget embedding)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 17:18:34 +02:00
ScarriffleandClaude Opus 4.8 19258096e2 Tidy settings layout (web)
- constrain settings panels to a 680px reading column so fields no longer
  stretch edge-to-edge on wide screens (main "looks bad" cause)
- fix the app-password "Erstellen" button rendering centred below the input:
  .app-pw-create inherited flex-direction:column from .form-group; force row so
  it sits inline to the right of the field

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 04:31:12 +02:00
ScarriffleandClaude Opus 4.8 6baa07379f Rework birthdays to a single calendar + UI fixes (web)
- one dedicated birthday calendar per user; enable it in Settings > Calendars
  (or from the New-birthday dialog) — it then shows in the sidebar like any
  calendar (colour/visibility)
- New-birthday dialog: no target picker; day/month selects + a year field that
  hides when "year unknown"; fixed the year-unknown checkbox layout
- create split-button is now one seamless pill; the caret opens the menu
- Settings > Calendars gains a Birthdays section (enable + notify-days-before)
- removed the birthday toggle from the generic new-calendar modal
- profile settings: right-align the section save button for consistency
- bump service-worker cache to v25 so assets refresh

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 04:20:49 +02:00
ScarriffleandClaude Opus 4.8 eb0684b99c Add birthday feature (web)
- views render display_title (age) and a cake icon for is_birthday events
  (month/week/agenda + quarter tooltip) via shared eventTitle/birthdayIconSvg
- create split-button caret opens a menu: new event / new birthday
- new birthday modal: name + date + "year unknown" + target birthday calendar,
  saved as an all-day FREQ=YEARLY local event with birth_year
- local-calendar modal gains a "birthday calendar" toggle + "notify N days before"
- i18n de/en strings

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 22:10:52 +02:00
ScarriffleandClaude Opus 4.8 cf990e4279 Add birthday feature (iOS)
- LocalCalendar/CalEvent gain is_birthday + birthday fields; API sends
  rrule/external_uid/birth_year and can create/update birthday calendars
- BirthdaysImporter: mirrors Contacts birthdays into a chosen birthday
  calendar, reconciling by external_uid (leaves manual entries untouched)
- AccountsView: "birthday calendar" toggle + notify-days when creating a
  calendar, and a "Birthdays from Contacts" section (enable + target + sync now)
- FAB long-press menu -> "New birthday" opens a minimal name+date mask
  (BirthdayEditorSheet) targeting birthday calendars, with year-unknown option
- Event bars render display_title (age) and a cake icon via EventLabel
- NSContactsUsageDescription added to both app build configs

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 21:58:31 +02:00
ScarriffleandClaude Opus 4.8 ca09538971 Add birthday calendar backend support
Birthday calendars are ordinary local calendars flagged is_birthday, so they
flow to all clients via the merge read and inherit sharing/colors/reminders.

- models: LocalCalendar.is_birthday + birthday_notify_days_before;
  LocalEvent.external_uid (Contacts dedup) + birth_year
- build_local_event_dict: server-computed display_title "Name (age)" per
  occurrence, is_birthday flag for the client cake icon, and a reminder injected
  from birthday_notify_days_before so mobile schedulers fire it
- groups combined view keeps the birthday display_title instead of overwriting it
- local_router: calendar flags + event fields on create/update, plus
  GET /calendars/{id}/birthdays for importer reconcile by external_uid
- additive SQLite migrations

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 21:38:37 +02:00
ScarriffleandClaude Opus 4.8 e55e6c7c92 feat(ios): toggle month view between scroll feed and paged swipe
New device-local setting "Monatsansicht seitenweise wischen" (@AppStorage, no
server sync). Off keeps the continuous vertical scroll feed; on switches the
month view to a paged TabView — one month per screen, six height-filling week
rows, swipe left/right to change month (bounded ±monthsBack/Ahead range to keep
the TabView light). WeekRow gains a fillHeight flag so the same row renders at a
fixed height (scroll) or fills the page evenly (paged); its inner cells/divider
now size to the actual geometry. Paged selection is two-way bound to the store's
current date so prev/next/today and the title stay in sync.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 05:15:58 +02:00
Guido SchmitandClaude Opus 4.8 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 SchmitandClaude Opus 4.8 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
ScarriffleandClaude Opus 4.8 d426f8985c style(web): dark-mode the share modal's add-user search + permission select
The user-search input and permission dropdown in the share modal were raw white
browser controls. Style them like the rest of the app (dark bg, border, radius,
focus ring) and give the user checkboxes the primary accent colour. v80.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 13:25:03 +02:00
ScarriffleandClaude Opus 4.8 89a1355149 fix(web): inverted condition broke hiding shared calendars
The per-device hide branch was gated on owned!==false (my own calendars) instead
of owned===false (shared with me), so unchecking a shared calendar hit the
owner-only enabled PUT and 404'd. Swap to owned===false.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 10:32:34 +02:00
ScarriffleandClaude Opus 4.8 f844ded57d fix(web): hide edit/delete for others' events; persistent hide for shared calendars
Bug 1 — a calendar shared with me stayed visible after unchecking it: the hide
was a one-shot cache filter the server undid on refetch. Add a per-device
hidden set (localStorage 'hiddenLocalCalendars'), honoured in filterEvents
(normal view) and used to drive the checkbox state, so it survives refetch/reload.

Bug 2 — in the group combined view, other members' events showed edit/delete and
403'd on save. The combined endpoint now emits read_only (editable = the group
calendar OR my own events), via a read_only param threaded through
build_local_event_dict/expand_recurring_local. The event popup and edit modal now
treat read_only events as read-only (copy still allowed). Test added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 10:11:46 +02:00
ScarriffleandClaude Opus 4.8 ec85a5b5f3 fix(web): show dialogs opened from settings on top of the settings page
The settings modal is a full-screen OPAQUE overlay at z-index 500. Dialogs
opened from within it (share, add-account, color picker) share z-index 500 but
sit earlier in the DOM, so they rendered BEHIND the opaque settings page — the
Share button appeared to "do nothing". Drop the settings page to z-index 400 so
real modals stack above it. v79.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 22:31:03 +02:00
ScarriffleandClaude Opus 4.8 af64e191ec fix(web): reset checkbox size inside .form-group so the label isn't squeezed
The global ".form-group input" rule (width:100% + padding + border) also hit the
directory-hidden checkbox, blowing it up to a full-width field that pushed the
label text into a narrow wrapping column. Reset the checkbox to a small native
box (16px, no padding/border/background) so the label reads normally beside it.
v78.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 22:23:28 +02:00
ScarriffleandClaude Opus 4.8 52bc7066db fix(web): settings modal — table crash, tab restore, checkbox layout
Three separate settings bugs (all pre-existing, exposed once the modal actually
opened):

1. renderCalendarTable() referenced a bare `owned` variable (undefined in that
   scope; should be cal.owned) → ReferenceError on the first owned local calendar
   → renderAllAccounts threw → the calendar table never rendered. This was the
   ROOT cause of "settings won't open" (it threw out of openSettingsModal); the
   earlier try/catch only masked it. Fixed to cal.owned.

2. On reload, writeUrlState() (via fetchAndRender) ran before openSettingsModal
   activated the saved tab and wrote the HTML-default (Profile) tab into the URL,
   so every reload landed on Profile. Now the stab is only rewritten once the
   modal is actually shown; otherwise the saved one is preserved.

3. The directory-hidden checkbox label inherited the global ".form-group label"
   uppercase/spaced/12px styling, stretching the text into a broken column. A
   higher-specificity .checkbox-row rule restores normal inline layout.

v77.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 22:16:10 +02:00
ScarriffleandClaude Opus 4.8 7d5530e51c fix(web): open settings modal before populating it
openSettingsModal() populated every field and ran render helpers
(renderGroupVisibleList, renderAllAccounts, initAppPasswords) BEFORE the final
openModal() call, so an error in any of them left the modal unopened — the
"settings button does nothing" report. Now the modal opens first and the
populate step runs in try/catch (errors logged, not fatal). v76.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 21:51:56 +02:00
ScarriffleandClaude Opus 4.8 90bc4b1531 fix(web): bind UI handlers before first fetch so a data error can't kill the UI
initCalendar() awaited fetchAndRender() BEFORE binding the topbar/settings/menu
handlers, so any error from /caldav/events (e.g. a transient 500) threw out of
init and left the buttons dead — the reported "settings won't open". Same root
cause as the reload-logout bug: app wiring must not depend on event data loading.

Bind all handlers first, then fetch inside try/catch (errors are logged + shown
as a toast, no longer fatal). v75.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 21:44:32 +02:00
ScarriffleandClaude Opus 4.8 1eecf834a3 fix(sharing): per-user calendar colour works for every sharing path
The recipient colour was stored on calendar_shares, so it only worked for
DIRECT shares. A calendar made visible through a group (a co-member's
group_visible_calendar_id) has no CalendarShare row, so the colour endpoint
returned 403 and nothing was saved — the reported "colour picker opens but the
colour stays the same" for a group-shared calendar.

Replace the share-scoped colour with a general per-user override table
(calendar_color_prefs, keyed by user+calendar). PUT /calendars/{id}/color now
accepts any calendar the user can read (readable_local_calendar_ids covers
direct shares, group calendars AND group-visible), and the merge read + calendar
list apply the override for all of them. Owners still set the shared colour.
The new table is created by create_all; the old calendar_shares.color is unused.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 21:38:16 +02:00
ScarriffleandClaude Opus 4.8 b8a578cf53 refactor(web): use server per-user colour for shared calendars
Replace the per-device localStorage colour override with the server-backed
per-user colour: the colour dot on a shared calendar now PUTs /calendars/{id}/color
(stores the recipient's own colour server-side, synced across devices) and the
list/events already carry share.color from the server, so the localStorage
re-apply layer is removed. Owners and recipients share one code path. v74.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 21:22:33 +02:00
ScarriffleandClaude Opus 4.8 572172c424 fix(web): stay logged in when app initialisation fails after reload
boot() ran token validation and launchApp() inside the SAME try/catch, so ANY
error during app init (a failed calendar/event fetch, a render error) cleared
the token and bounced a validly-authenticated user to the login screen — the
"logged out on every F5" bug. Now /auth/me validates the token alone; launchApp()
runs outside that catch, so a data/render error can no longer log the user out.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 21:22:33 +02:00
ScarriffleandClaude Opus 4.8 c4195243c0 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). The
dot is now always editable and colour changes go through the dedicated
PUT /calendars/{id}/color endpoint, storing a recipient's own per-user colour
server-side (synced across devices) without granting write or rename. Renaming
stays owner-only. Parity with Android + server.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 20:57:53 +02:00
Guido SchmitandClaude Opus 4.8 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
ScarriffleandClaude Opus 4.8 343f7a5e7b feat(sharing): recipients can recolour a shared calendar (per-user, no rename)
A share recipient couldn't change anything on a shared calendar (update_calendar
is owner-only → 404), yet the clients still showed a colour picker for it.

Add a per-recipient colour: new nullable calendar_shares.color column (+ migration).
New PUT /calendars/{id}/color endpoint sets the calendar colour for the owner
(global) or, for a recipient, only their own share colour — never the name, so
recipients can recolour but not rename. The merge read and the calendar list now
prefer the recipient's share colour over the owner's (NULL = owner's colour).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 20:43:10 +02:00
ScarriffleandClaude Opus 4.8 cad48efcc6 feat(web): recolour calendars shared with me (per-device local override)
Shared calendars belong to someone else, so a server colour PUT would 403.
Instead store the recipient's chosen colour locally (localStorage 'sharedCalColors',
keyed by calendar id) and re-apply it to events on every fetch. The colour dot
on a shared calendar now opens the picker instead of showing an error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:49:07 +02:00
ScarriffleandClaude Opus 4.8 32cb8e1689 feat(sharing): hidden-profile toggle + hide non-sharing group members
- UserProfile decodes directory_hidden; updateProfile() sends it; Settings has
  a "hide my profile" toggle (excludes you from share/group pickers).
- GroupMember decodes shares_calendar; the group filter list now only shows
  members who actually share a calendar (no phantom empty rows).
- Robust custom decoders (decodeIfPresent) for the new fields.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:45:49 +02:00
ScarriffleandClaude Opus 4.8 2ae8499247 fix(web): can't edit calendars shared with me; struck-pencil read-only marker
- Sidebar: don't start an inline rename on a calendar I don't own (owned=false)
  — the save would only 403. Colour picker was already gated.
- Sidebar: show a struck-through pencil icon on read-only shared calendars.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:38:01 +02:00
ScarriffleandClaude Opus 4.8 79fdf4f54a feat(sharing): group-shared calendars in every member's sidebar, person share picker, hidden profiles
- Backend: co-member group_visible calendars now surface in /local/calendars
  (owned=false, shared_by=owner, read-only, group_shared) and in the normal
  /caldav/events merge (via readable_local_calendar_ids), deduped against
  direct shares / group calendars so nothing appears twice.
- Backend: new User.directory_hidden — a user can hide from sharing/group
  pickers (/users/directory), while admin user management (/users/) still lists
  them. Migration + profile GET/PUT.
- Backend: /groups/{id} members carry shares_calendar so clients can drop
  phantom rows for members who share nothing.
- Frontend: reachable "Teilen" button on owned local calendars; share modal is
  now a checkbox multi-select of users (checked = shared). Hidden-profile toggle
  in Settings → Profile. Group member filter only lists members who actually
  share (phantom fix). Calendars shared with me moved to a dedicated read-only
  "shared with me" section in the manage table.
- Tests: group_visible propagation, no-share absence, dedup, directory_hidden.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 16:35:11 +02:00
ScarriffleandClaude Opus 4.8 f76d2783d9 feat(web): show shared calendars under the owner's name
A calendar shared with another person now appears to the recipient under the
OWNER's name (Guido's "Persönlich" shows as "Guido"), matching the iOS filter
sheet. The original calendar name moves to the sub-label / settings source
column so it stays identifiable. Sidebar list + settings table.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 14:41:31 +02:00
ScarriffleandClaude Opus 4.8 3f5de5cdfa fix(security): store auth token in Keychain and restrict ATS to local networking
- Move the bearer token from UserDefaults to the Keychain (accessible after
  first unlock), with a one-time migration so existing logins survive.
- Replace NSAllowsArbitraryLoads=YES with NSAllowsLocalNetworking=YES so ATS
  still permits cleartext to LAN/self-hosted servers but enforces TLS for
  public hosts (no arbitrary cleartext/MITM).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 14:32:01 +02:00
ScarriffleandClaude Opus 4.8 e539508bec fix(security): scope CalDAV PUT to its calendar, block iCal SSRF, auth avatar endpoint
- dav_router: PUT now looks up the event within the authenticated calendar only
  (local_events.uid is globally unique), so a CalDAV client can no longer
  overwrite another user's/calendar's event; a cross-calendar UID clash returns
  409 instead of a 500 from the UNIQUE constraint.
- ical_router: _fetch_ics validates the URL (http/https only), resolves the host
  and rejects private/loopback/link-local/reserved targets, follows redirects
  manually re-validating each hop, and caps the response size — closing an
  authenticated SSRF into internal services / cloud metadata.
- profile_router: GET /profile/avatar/{user_id} now requires authentication.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 14:17:24 +02:00
ScarriffleandClaude Opus 4.8 784c9013eb feat(web): mark read-only shared calendars and hide them from the event editor
- Sidebar: a calendar shared with read access shows "· Nur lesen" next to the
  "shared with me · <owner>" label.
- Event editor calendar picker: exclude read-only shared calendars (own +
  read_write, incl. group calendars, stay) so a save can't 403.

Parity with the server/iOS/Android sharing changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 19:02:12 +02:00
ScarriffleandClaude Opus 4.8 9db04361cc feat(sharing): show shared calendars read-only (parity with server/Android)
- Parse the server's new read_only flag on events.
- Filter sheet: a calendar shared with me is listed under the owner's name
  (sharedBy) with a lock icon when I only have read access.
- loadWritableCalendars: exclude read-only shared calendars so they no longer
  appear in the event editor (a save would 403).

The group-title prefix removal is server-side and needs no iOS change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 18:56:32 +02:00
Guido SchmitandClaude Opus 4.8 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
ScarriffleandClaude Opus 4.8 e3844294ae feat(sharing): label shared personal calendars by owner + drop group title prefix
Two group/sharing display fixes, both server-side so every client benefits:

1. A personal calendar shared WITH a user showed only its raw name
   ("Persönlich"), indistinguishable from the user's own. The merge read now
   relabels a shared *personal* calendar under the owner's display name (so
   Guido's "Persönlich" reads as "Guido" for recipients) and adds read_only:true
   when the share isn't read_write. Group calendars are excluded — they keep
   their own name and stay writable for members.

2. The combined group view prefixed every foreign event with the owner's first
   name ("Guido: …"). Each member already has a distinct display_color, so the
   prefix was redundant. _decorate_title now returns the raw title; display_title
   stays non-empty so clients' legacy prefix fallback never triggers. The change
   takes effect on already-installed clients with no app update.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 18:42:26 +02:00
Guido SchmitandClaude Opus 4.8 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 SchmitandClaude Sonnet 4.6 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
ScarriffleandClaude Sonnet 4.6 325f357357 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 only reconciled these per-calendar flags when the filter sheet or the
accounts screen happened to be opened — never on launch, resume or the
periodic pull. So a calendar re-enabled on the web stayed invisible (still
banished locally, and its events weren't in the cache) until a manual sync or
relaunch — the "hide it on the web and the app won't bring it back" bug.

Add CalendarStore.reconcileCalendarVisibility(api:) which pulls the account
lists, updates the banished set from the server's sidebar_hidden flags, and
returns whether anything changed. CalendarHostView now runs it (via
syncFromServer) on launch, on scenePhase .active, in the periodic loop, and on
manual sync — forcing a refetch when the set changed so a re-enabled calendar's
events actually reappear without user intervention.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 22:04:58 +02:00
Guido SchmitandClaude Sonnet 4.6 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
ScarriffleandClaude Sonnet 4.6 444772c959 fix: attribute account-level token failures to each calendar
When a Home Assistant or Google token refresh fails, the whole account fetch
aborts. Previously this raised, so the outer handler in caldav_router emitted
a single sync error WITH NO calendar_id. Clients that preserve cached events
per calendar (iOS) couldn't attribute it and wiped the affected calendars.

Now both get_ha_events and get_google_events catch the token failure and emit
one error per enabled calendar, each carrying its calendar_id — so every
client can pin the failure to a specific calendar and keep its cached data.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 21:31:26 +02:00
ScarriffleandClaude Sonnet 4.6 61237134f0 fix(cache): preserve whole source on account-level sync errors (HA/Google tokens)
The per-calendar preservation added in the previous fix only kicks in when a
sync error carries a calendar_id. But account-level failures — most commonly
a Home Assistant or Google token-refresh failure — abort the entire account
fetch and arrive WITHOUT a calendar_id. Those events were still being evicted,
so HA/Google calendars kept vanishing on a transient token hiccup.

failedCalendarKeys() now returns both per-calendar keys and whole sources:
an error without a calendar_id protects every cached calendar of that source.
mergeIntoCache() keeps events whose source is in keepSourcesInRange, and the
cached range is only extended when neither set is populated (auto-retry).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 21:31:05 +02:00
ScarriffleandClaude Sonnet 4.6 fb22aeb090 fix(cache): retry failed-calendar ranges instead of marking them as cached
mergeIntoCache() previously extended cachedStart/End even when sync errors
occurred — so isCached() returned true on the next call and the failed
calendar was silently left empty (especially bad on first launch or after
forceReload when there's nothing to preserve).

Now the cached range is only extended on a fully clean fetch. When
keepKeysInRange is non-empty (sync errors present), we leave cachedStart/End
unchanged, forcing isCached() to return false and triggering an automatic
retry on the next loadEvents call.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 20:55:57 +02:00
ScarriffleandClaude Sonnet 4.6 1cbab3f3ec fix: preserve cached events when a calendar has a per-source sync error
Root cause: mergeIntoCache() unconditionally evicted every event in the
fetched date range, even for calendars whose sync failed. Those calendars'
events were removed and never restored, so the calendar appeared to vanish.

Fix:
- SyncError now decodes calendarId from the server's "calendar_id" field
  (added server-side in e0ea16f but not yet read by iOS).
- mergeIntoCache() gains a keepKeysInRange parameter: events from failed
  calendars are retained (not evicted) even within the fetch window.
- loadEvents() and prefetchBackground() compute the failed keys from the
  syncErrors list and pass them to mergeIntoCache.

Result: when CalDAV / Google / HA sync fails for a specific calendar, the
user sees stale-but-correct events alongside the existing error banner,
instead of a completely empty calendar with no explanation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 20:06:11 +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
Scarriffle e0ea16f2ef fix: include calendar_id in per-calendar sync errors
Events already carry calendar_id, but the corresponding sync-error
entries didn't, forcing clients to match errors to calendars by
fragile name-suffix comparison. Account-level failures (whole account
unreachable) still omit calendar_id since no single calendar is at fault.
2026-07-02 18:36:46 +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
Scarriffle 8ed474663e feat: surface per-calendar sync errors from /api/caldav/events
The server now reports per-calendar sync failures (e.g. expired
credentials) alongside an otherwise-successful events response, via an
"errors" array. Previously such failures were silently swallowed, so a
calendar could appear enabled while showing zero events with no
explanation.

- Decode the new "errors" field into SyncError (source/name/message),
  following the existing JSONSerialization + from(json:) convention.
- CalendarStore.syncErrors captures these on successful personal
  fetches (loadEvents, prefetchBackground); left untouched on a hard
  fetch failure so it isn't conflated with lastError. Group overlay
  fetches never populate it (the endpoint doesn't apply there).
- CalendarHostView shows a second red warning banner alongside the
  existing lastError banner, reusing the same visual style.
2026-07-02 18:25:47 +02:00
Scarriffle 9fb350eb29 fix: surface all calendar sync failures, not just Google
CalDAV and Home Assistant sync failures were previously only logged
server-side, leaving clients unable to distinguish an empty calendar
from a broken sync. Unify error reporting across CalDAV, Home
Assistant, and Google into a single errors list on GET
/api/caldav/events, shaped as {source, name, message}. Messages are
fixed generic strings, never raw exception text, to avoid leaking
URLs or credential fragments. get_ha_events and get_google_events now
return (events, errors) tuples so per-calendar failures propagate to
the caller in addition to account-level failures. Frontend toast now
picks its label from err.source instead of assuming Google/err.email.
2026-07-02 18:22:11 +02:00
ScarriffleandClaude Opus 4.8 94655ce7c5 fix(caldav): match Basic Auth username case-insensitively
Login names are stored lowercase and the web login already compares with
func.lower(); CalDAV Basic Auth used an exact match, so "Scarriffle" failed to
authenticate while "scarriffle" worked. Compare case-insensitively too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 14:38:21 +02:00
ScarriffleandClaude Opus 4.8 9306d638ad fix(caldav): move app-password section to Settings → Profile where users look
The app-password UI was in the user-menu profile modal, but users manage CalDAV
in Settings, so it went unnoticed. Move the section into the Settings → Profile
panel (next to account/privacy) and drive it from openSettingsModal via a
top-level initAppPasswords().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 14:17:25 +02:00
ScarriffleandClaude Opus 4.8 f662163185 feat(caldav): app-specific passwords so MFA accounts can use CalDAV
CalDAV clients send only user+password over Basic Auth and can't provide a TOTP
code, so account passwords would bypass 2FA. Add revocable app passwords:

- models: AppPassword table (bcrypt hash, label, last_used); auto-created via
  create_all
- profile_router: GET/POST/DELETE /profile/app-passwords (plaintext shown once)
- dav_router: Basic Auth accepts any app password; the account password is
  accepted only when 2FA is disabled
- frontend: "App-Passwörter (CalDAV)" section in the profile modal (create/show-
  once/copy/revoke) + i18n (de/en); login hint now says app password

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 13:15:35 +02:00
ScarriffleandClaude Opus 4.8 fb32f0424f feat(caldav): username/password (Basic Auth) access with discovery + https URLs
- dav_router: add Basic-Auth principal-discovery tree at /caldav/ (and
  /.well-known/caldav) so clients can add a CalDAV account with server URL +
  username + password; lists all published calendars. Token URL /dav/{token}/
  still works without login. Handlers generalised over a base href.
- dav_util: derive the public origin from X-Forwarded-Proto/-Host (or
  PUBLIC_BASE_URL) so published URLs are https, not internal http:8080.
- local_router: expose caldav_login_url alongside caldav_url.
- frontend/i18n: show both the no-login token URL and the login URL + hint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 12:44:16 +02:00
ScarriffleandClaude Opus 4.8 34e701fa78 feat(pwa): auto-update service worker so new releases take effect without manual cache clearing
- app.js: reload once on SW controllerchange (guarded against loops / first
  install) and poll reg.update() hourly for long-open tabs
- sw.js: bump cache to v24 so the new (network-first) worker replaces any stale
  cache-first worker and cleans old caches on activate

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 11:24:05 +02:00
ScarriffleandClaude Opus 4.8 661a8ea579 chore: bump APP_VERSION to v65 (cache-bust / deploy verification)
Fresh commit so a git pull visibly advances the deployed version and the PWA
service worker refreshes its cached assets. No functional change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 11:12:21 +02:00
ScarriffleandClaude Opus 4.8 47944ed952 feat: publish local calendars via two-way CalDAV (opt-in, secret token URL)
Backend:
- LocalCalendar gains caldav_published + dav_token + dav_ctag; LocalEvent gains
  etag (migrations in main.py). bump_dav() refreshes ctag/etag on every local
  event write (create/update/delete/import).
- local_router: PUT /calendars/{id} accepts caldav_published (mints/revokes
  token), new POST /calendars/{id}/dav-token/rotate, _cal_dict exposes
  caldav_published + caldav_url.
- New dav_router mounted at root (/dav/{token}/...): a minimal two-way CalDAV
  server (OPTIONS/PROPFIND/REPORT/GET/PUT/DELETE) reusing ical_io build/parse,
  ctag-based change detection. Secret token = auth, no login.

Frontend:
- Settings calendar table: per-local-calendar publish toggle + subscribe URL
  with copy and token-rotate; i18n (de/en) and styling.

Note: reverse proxy must allow WebDAV methods (PROPFIND/REPORT/PUT/DELETE).
VALARM/reminders are not round-tripped via CalDAV.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:19:01 +02:00
ScarriffleandClaude Opus 4.8 a591eb51d3 chore: bump APP_VERSION to v63 to ship mini-cal + week-view fixes
The multi-day mini-calendar and short-event week-view fixes were already
committed but not visible because the PWA served cached assets. Bump the
version so clients pick up the new JS/CSS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:15:41 +02:00
ScarriffleandClaude Sonnet 4.6 4e9ae83299 feat(widget): calendar filter config + fix group-view data leak
Bug fix: publishWidgetSnapshot() now guards against activeGroup != nil,
so group view events/colors never contaminate the widget cache.

Feature: widgets can now be configured via long-press → Edit Widget.
- WidgetCalendar struct + writeCalendars/readCalendars in WidgetData.swift
- calendarKey added to WidgetEvent (backward-compatible decoder)
- CalendarIntent.swift: CalendarAppEntity + CalendarEntityQuery + CalendarSelectionIntent
- CalendarrTimelineProvider migrated from TimelineProvider to AppIntentTimelineProvider
- All 13 StaticConfiguration widgets changed to AppIntentConfiguration
- publishWidgetSnapshot() builds + writes the calendar list for the intent

An empty selection (default) shows all calendars; selecting specific
calendars filters the widget events accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 18:41:43 +02:00
Guido SchmitandClaude Sonnet 4.6 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 SchmitandClaude Sonnet 4.6 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
ScarriffleandClaude Sonnet 4.6 52040fff53 feat: show relative time in notifications + move default duration to profile settings
Notifications now show "in 30 Min. · 10:00" instead of just the time,
giving users immediate context about how soon the event is.

Default event duration picker moved from the View section to right below
the privacy setting, grouped under the calendar/profile settings area.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 19:38:20 +02:00
ScarriffleandClaude Opus 4.8 cc3d16ddce feat: custom reminder picker, muted-calendar hint, synced default duration
- Reminder editor: presets + custom number+unit (minutes/hours/days/weeks)
- Grey out + footer hint when the selected calendar's reminders are muted;
  reminders are kept, scheduler already skips them
- New synced setting defaultEventDurationMinutes for new events

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:03:24 +02:00
ScarriffleandClaude Opus 4.8 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 SchmitandClaude Sonnet 4.6 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 SchmitandClaude Sonnet 4.6 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 SchmitandClaude Sonnet 4.6 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 SchmitandClaude Fable 5 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 SchmitandClaude Fable 5 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
ScarriffleandClaude Sonnet 4.6 544e0d9265 iOS: bump marketing version to 2.7
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 20:52:04 +02:00
ScarriffleandClaude Sonnet 4.6 1dbe405689 iOS: replace chevron icons with clip-path shape, fix +N overlap in month view
- DayPreviewAllDayBar: use custom ChevronBarShape (polygon clip) instead of
  chevron SF Symbols; bars have tapered pointed ends for continuation
- MonthView rowHeight: increase bottom padding from +4 to +16 so the
  overflow "+N" count no longer overlaps the last event bar lane

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 20:34:58 +02:00
ScarriffleandClaude Sonnet 4.6 17ebf788ce iOS: widget padding, week event limit, long-press day preview
- CalendarDayWidget: add 6pt top padding to header so content isn't flush
  against the widget edge
- ThisWeekWidget: increase per-day event cap from 6 to 8 to prevent "+1"
  overflow when there is vertical space available
- MonthView: add DayContextPreviewView to the long-press context menu using
  .contextMenu(menuItems:preview:); shows all-day events as colored bars
  with chevron continuation arrows, timed events as dot+time+title rows

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 20:15:53 +02:00
ScarriffleandClaude Opus 4.8 69a32121df iOS: show more events in the "Next 7 days" widget
The per-day cap of 3 made busy days collapse to "+N" so only ~6-7 events
showed total. Raise it (25) and let the total-row cap govern; extraLarge
fills up to 40 rows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 21:32:59 +02:00
ScarriffleandClaude Opus 4.8 864eb31072 iOS: align reminder options with web (5/15/30 min, 1 h, 1 day, 1 week)
Drop 10 min / 2 h / 2 days, add 1 week, and use full-word "… vorher" /
"… before" labels so the choices match the web client.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 20:32:34 +02:00
ScarriffleandClaude Opus 4.8 c0edca338e iOS: localization fixes, per-calendar reminders, widget polish
C1 — Localization: route the remaining hardcoded German strings through
L10n (LoginView, ServerSetupView, SettingsView email, EventDetailSheet) so
"System Default" + English device language shows fully English text.

C2 — Per-calendar reminders: parse the new reminders_enabled flag on every
calendar type; CalendarStore persists a reminderDisabledKeys set and passes
it to NotificationScheduler, which skips events of muted calendars (default
and per-event reminders). Filter sheet gains a per-calendar reminder toggle
(leading swipe + bell.slash indicator), reconciled from the server and
synced back via PUT.

C3 — Widgets:
- Shared WidgetTime.range helper; Today / Today & Tomorrow / Three Days /
  Up Next now show start–end instead of only the start time.
- This Week: show up to 6 events per day (was 3) to use the height.
- Two Weeks: mini event-title pills instead of bare dots.
- Two Months: weeks expand to fill the column (no more empty lower third).
- Day & Events: smaller header/strip/rows so content stops clipping.
- Next 5 days → Next 7 days (range + labels), higher row cap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 20:14:39 +02:00
Scarriffle 13d80981c6 Liquid Glass ICON 2026-06-08 19:20:10 +02:00
ScarriffleandClaude Opus 4.8 59a879ea23 fix: move Today button to the right so the month title sits centered (iOS)
Left side now holds only the prev/next chevrons; "Heute" moved next to the
burger menu on the right (both flat and Liquid Glass bars), so the month title
is centered again instead of pushed right by a heavy left cluster.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 10:14:08 +02:00
Guido SchmitandClaude Opus 4.8 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
ScarriffleandClaude Opus 4.8 f480b438cb feat: top bar declutter — view/filter/groups/sync into one burger popup (iOS)
The top bar now shows only nav + title + a single burger. Tapping it opens a
compact menu: View (with a fixed icon, no longer per-view), Filter, Groups
(if any), Sync, and an "Einstellungen" entry that opens the existing full menu.
Removed the separate group/view/filter icons from both the flat bar and the
Liquid Glass toolbar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 16:23:56 +02:00
ScarriffleandClaude Opus 4.8 587a0e65fa feat: event reminders + default reminder setting + local notifications (iOS)
Per-event reminders (multiple, local calendars only) in the editor, prefilled
from a new "default reminder" setting that applies to all events otherwise.
CalEvent gains `reminders`; AppSettings/SettingsSync sync default_reminder_minutes
(always group). New NotificationScheduler requests permission and schedules the
soonest ≤60 upcoming reminders via UNUserNotificationCenter, rescheduling on
load/sync/edit and when the default changes (skipped in group overlay).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 16:21:08 +02:00
ScarriffleandClaude Opus 4.8 e7d8effb47 fix: Liquid Glass month title as inline content strip (not the system toolbar)
The NavigationStack toolbar title never refreshes on month change on iOS 26
(4 approaches tried: principal Text, navigationTitle, @Observable store). The
title now renders as a normal inline Text in a top safe-area inset just below
the system glass toolbar — the same mechanism as the flat variant, which does
update. The system toolbar keeps the buttons + the real Liquid Glass look.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 21:30:07 +02:00
Guido SchmitandClaude Opus 4.8 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
ScarriffleandClaude Opus 4.8 68349d36e5 feat: non-emoji group icons (SF Symbols) for consistent cross-platform look
Group icons are now semantic keys (people/home/heart/work/school/sports/party/
pet/travel/music/food/star) rendered as SF Symbols in the picker, group list,
switcher, banner and filter — instead of OS emoji that looked different on every
platform. Legacy emoji values still render as a fallback. GroupCombinedView uses
the server display_title.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 19:20:32 +02:00
ScarriffleandClaude Opus 4.8 451d3d4d6b fix: Liquid Glass month title updates via @Observable store (visibleMonth)
The system NavigationStack toolbar title would not refresh on a plain @State
change (title kept disappearing on iPhone). Moved visibleMonth into the
@Observable CalendarStore so the toolbar's read is tracked with @Observable's
fine-grained observation and refreshes on month change. Reverted the @State/.id
workaround. Real system glass bar retained.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 19:06:53 +02:00
ScarriffleandClaude Opus 4.8 51218b9aa3 fix: restore real Liquid Glass bar; drive title via @State so it refreshes
The custom safeAreaInset bar removed the actual iOS-26 Liquid Glass look
("no glass even though enabled"). Restored the system NavigationStack glass
toolbar and instead fixed the disappearing month title by driving it from a
@State (navTitle) updated via onChange(of: titleString) and keyed with .id,
which forces the system bar to refresh on month change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 18:22:16 +02:00
Guido SchmitandClaude Opus 4.8 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
ScarriffleandClaude Opus 4.8 b61a90d960 feat: hide individual member calendars in the group view (iOS)
The calendar filter, when a group overlay is active, now lists the group's
members (+ the shared group calendar) and lets you hide each one individually
(Outlook-style). Filtering is client-side via CalendarStore.hiddenGroupKeys
(per-member gm:<id> / group-calendar gc keys), reset when switching groups.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 18:00:30 +02:00
Guido SchmitandClaude Opus 4.8 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
ScarriffleandClaude Opus 4.8 b9547c15f9 feat: render server display_title for group events (consistent across clients)
CalEvent parses display_title; the combined view uses it (group icon + owner
prefix from the server) instead of client-side decoration, 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
ScarriffleandClaude Opus 4.8 8521a28520 fix: visible Liquid Glass again, group icon on group events, week today colour
- Liquid Glass: the calendar content now scrolls underneath a translucent
  safeAreaInset bar (real glass look restored) while the inline title stays
  reliable — toggling Liquid Glass is visibly different again.
- Group events are prefixed with the group's own emoji icon (from group
  settings) instead of a generic people glyph, so they're recognisable.
- Week view: today's column header now uses the configured "today" colour
  instead of the accent colour (matches the current-time line).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 17:22:02 +02:00
ScarriffleandClaude Opus 4.8 7f76df2600 fix: Liquid Glass month title now updates reliably (custom glass bar)
navigationTitle/principal items in the NavigationStack toolbar silently fail to
refresh on scroll-driven state changes (visibleMonth), so the month label
vanished on month change and only returned after an unrelated rebuild. The glass
variant now uses a custom top bar with the same inline Text title as the flat
variant (proven to update), styled with glassEffect (iOS 26) / ultraThinMaterial.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 09:00:48 +02:00
ScarriffleandClaude Opus 4.8 852e46fcf8 fix: month title disappears in Liquid Glass mode on month change
The glass variant rendered the month title in a `.principal` ToolbarItem, which
SwiftUI drops when the state it reads (visibleMonth) changes while on screen —
it only reappeared on an unrelated rebuild (opening/closing the menu) and
vanished again on the next month change. Switched to `.navigationTitle`, which
the system updates reliably.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 08:50:32 +02:00
Guido SchmitandClaude Opus 4.8 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 SchmitandClaude Opus 4.8 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
Guido SchmitandClaude Opus 4.8 e0d1b24afc feat: Android Kalenderfarben, Sharing & iCal-Import/Export (lokale Kalender)
AccountsScreen überarbeitet: editierbare Farb-Punkte (lokal, iCal, sowie
Unterkalender von CalDAV/Google/HA via Farbwähler-Dialog); lokale Kalender
zeigen Gruppen-Marker + "geteilt von" und ein Aktionsmenü (Teilen/Importieren/
Exportieren/Löschen je nach Besitz & Berechtigung). Teilen-Sheet mit
Benutzersuche + Berechtigung; Import via OpenDocument, Export via CreateDocument.
Repository: importIcsFile(bytes) + Farb-Update-Methoden; ViewModel um Farb-,
Sharing- und Import/Export-Logik erweitert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:17:53 +02:00
Guido SchmitandClaude Opus 4.8 716dd01abb feat: Android Profil-Kapitel in Einstellungen + Kalenderfarb-Endpunkte
Einstellungen zeigen jetzt ein Profil-Kapitel: Anzeigename (editierbar),
Anmeldename (read-only), E-Mail + Speichern; Privatsphäre (private Termine
beschäftigt/verbergen) und geteilter Kalender (Dropdown der eigenen lokalen
Kalender) — serverseitig geladen und gezielt gespeichert.
Datenebene um Farb-Endpunkte ergänzt (lokal/iCal PUT + setCalendarColor für
caldav/google/ha) als Basis für die Farbbearbeitung.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:07:06 +02:00
Guido SchmitandClaude Opus 4.8 4a44c20b12 feat: Android Ersteller-Anzeige + Privat-Flag
Event-Detail zeigt "Erstellt von" (wenn != ich) + Privat-Hinweis; Editor hat
Privat-Toggle (nur lokale Kalender, durch saveEvent/Repo durchgereicht).
Login speichert userId + displayName (CredentialStore) für Vergleiche.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:54:19 +02:00
Guido SchmitandClaude Opus 4.8 d10a4dc79f feat: Android Datenebene für Sharing/Gruppen/Import-Export/Ersteller
- Modelle: CalEvent (creator/isPrivate/owner/isGroupEvent/displayColor),
  LocalCalendar (owned/sharedBy/permission/group), AppSettings
  (privateEventVisibility/groupVisibleCalendarId), UserProfile (displayName);
  neue Modelle Group/GroupMember/DirectoryUser/CalendarShareEntry.
- API (Retrofit): Profil-Update, Sharing-CRUD, Gruppen-CRUD + combined,
  Mitglieder-Farbe, iCal Import (multipart)/Export, Kalenderfarbe pro Quelle,
  gezielte Settings-PUTs (private_visibility/group_visible).
- Repository: passende Methoden inkl. private-Flag bei lokalen Events.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:35:54 +02:00
ScarriffleandClaude Opus 4.8 a62b200dfa chore: Marketing-Version auf 2.0 (Sharing/Gruppen/Import-Export-Release)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:33:12 +02:00
ScarriffleandClaude Opus 4.8 c6f9981a54 fix: Gruppenansicht – Termine über Cache/Prefetch laden (kein Range-Gap)
Der Gruppen-Modus ersetzte events nur mit dem schmalen aktuellen Fetch, statt
denselben Cache/Prefetch-Pfad wie die Normalansicht zu nutzen -> Termine
erschienen erst nach Scrollen. Jetzt fetchForMode (personal/group) läuft durch
loadEvents + prefetchBackground + refreshFromCache; Moduswechsel lädt breit neu.
In der Gruppenansicht greift der "ausgeblendet"-Filter nicht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:14:48 +02:00
ScarriffleandClaude Opus 4.8 815f2cf01a feat: iOS Kalenderfarben änderbar + Top-Bar entzerrt
- Kalenderverwaltung: tappbarer ColorPicker pro Kalender (lokal/iCal direkt;
  CalDAV/Google/HA klappen ihre Unterkalender mit je eigenem Farbwähler auf).
  Neue API: updateLocalCalendarColor, updateICalColor, setCalendarColor
  (caldav/google/homeassistant) -> PUT …/{id} {color}. Geteilte Kalender
  read-only (nur Besitzer).
- Top-Bar: Gruppen-Umschalter nur bei vorhandenen Gruppen, "Heute" nicht mehr
  quetschbar (fixedSize), kompaktere Icons -> "Heute" wird nicht mehr zu "H…".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:05:31 +02:00
ScarriffleandClaude Opus 4.8 6dc8724a9a feat: iOS Gruppenansicht direkt im Kalender (Umschalter + Banner)
Gruppen sind nicht mehr nur im Menü versteckt: im Top-Bar gibt es einen
Gruppen-Umschalter (Persönlich / <Gruppe>). Beim Wählen einer Gruppe zeigt
der echte Monats-/Wochen-/Tagesansicht die kombinierte Überlagerung
(GET /groups/{id}/combined) mit server-definierten Farben und
Besitzer-Präfix; ein Banner "Gruppenansicht: <Name>" mit "Verlassen".
CalendarStore.activeGroup steuert den Modus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 20:55:28 +02:00
ScarriffleandClaude Opus 4.8 c9803d80a3 fix: doppelter L10n-Key "settings.saved" entfernt (Runtime-Crash beim Start)
Ein doppelter Dictionary-Key liess die App beim Start abstürzen
("Dictionary literal contains duplicate keys"). Meine Dublette entfernt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 20:35:04 +02:00
ScarriffleandClaude Opus 4.8 9fac13f99c feat: iOS Gruppen – Liste, Erstellen/Verwalten, kombinierte Ansicht
- Menü-Eintrag "Gruppen" -> GroupsView (Liste, Erstellen mit Icon-Auswahl +
  Mitglieder, Verwalten: umbenennen/Icon/Mitglieder/Mitglieder-Farben/löschen).
- GroupCombinedView: monatsweise Agenda der überlagerten Mitglieder-Kalender
  + Gruppenkalender; Termine mit Besitzer-Vorname bzw. 👥 + Ersteller,
  server-definierte Farben (display_color).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 20:08:53 +02:00
ScarriffleandClaude Opus 4.8 da2e39911c feat: iOS Sharing + iCal Import/Export für lokale Kalender
Kalenderverwaltung: pro lokalem Kalender ein Menü mit Teilen (SharingView:
Benutzer aus Verzeichnis, read/read_write, entfernbar), Importieren
(.ics File Picker) und Exportieren (Share Sheet). Geteilte Kalender mit
"geteilt von"-Badge; Gruppenkalender markiert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 19:40:41 +02:00
ScarriffleandClaude Opus 4.8 023f90be3b feat: iOS Einstellungen – Profil, Privatsphäre, geteilter Kalender
Neue Settings-Sektionen: Anzeigename + E-Mail ändern (Login-Name read-only),
Private-Termine-Sichtbarkeit (busy/hidden) und Auswahl des für Gruppen
sichtbaren Kalenders. Gezielte API-PUTs, damit nicht die ganze AppSettings
überschrieben wird.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 19:35:22 +02:00
ScarriffleandClaude Opus 4.8 e7e4998fb9 feat: iOS Datenebene + Ersteller-Anzeige + Privat-Flag
- Modelle: CalEvent (creator, isPrivate, owner, isGroupEvent, displayColor),
  LocalCalendar (owned/sharedBy/permission/group), AppSettings
  (privateEventVisibility, groupVisibleCalendarId), UserProfile (displayName);
  neue Modelle CalGroup/GroupMember/DirectoryUser/CalendarShare.
- API: Profil-Update (Name/Login), Sharing-CRUD, Gruppen-CRUD + combined,
  Mitglieder-Farbe, iCal Import (multipart) & Export, private-Flag bei Events.
- Event-Detail zeigt "Erstellt von" (wenn != ich) + Privat-Hinweis;
  Editor hat Privat-Toggle (nur lokale Kalender). Login speichert userId.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 19:32:31 +02:00
Scarriffle b1e0cf1fdc WIP: Widget-, Sync- & Event-Editor-Änderungen
Zwischenstand vor den Sharing/Gruppen/Import-Export-Features (gesichert,
damit die neuen Features sauber darauf aufbauen).
2026-05-31 19:22:12 +02:00
Guido SchmitandClaude Opus 4.8 9467fe7bb6 feat: dim past events when enabled (month bars, week/day grid, agenda)
Past events (endDate before now) are drawn at reduced opacity across the month
bars, the week/day time grid (timed + all-day) and the agenda list when the
'dim past events' setting is on.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 15:26:05 +02:00
Guido SchmitandClaude Opus 4.8 bc93964e05 fix: single branded splash (invisible system icon), December title, first-open lag
- System splash now uses a transparent icon, so there is no system icon that
  shrinks into the in-app splash: just black, then the branded screen (icon +
  Calendarr name + copyright), held until data is loaded
- Month/quarter title uses TextStyle.FULL (format style) with a hard fallback;
  FULL_STANDALONE had the same desugar bug as the LLLL pattern (December showed
  only the year)
- First open: load the full +/- cacheMonths window behind the splash and only
  then reveal, so the app no longer enters mid-load and stutters

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 15:17:01 +02:00
Guido SchmitandClaude Opus 4.8 cb51284bef fix: branded splash back, wider month range, December title bug
- Restore the branded in-app splash (Calendarr name + icon + © Scarriffle),
  held until the first events load; logo sized to match the system splash so it
  doesn't visibly jump, name/copyright fade in
- Month calendar now scrolls 10 years back / 50 years ahead (was ±18 months)
- Fix month/quarter title: use Month.getDisplayName instead of the 'LLLL'
  pattern, which dropped the month name (e.g. December showed only '2026')

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 15:07:37 +02:00
Guido SchmitandClaude Opus 4.8 ba7daf8559 perf: unified splash held until loaded, smoother month scrolling, serialized loads
- Single system splash (core-splashscreen) kept on screen until the first
  events load (StartupState), then reveals the ready app — removes the
  two-stage 'icon then title' splash and the laggy half-loaded entry
- Month scrolling: dropped per-row BoxWithConstraints (measured grid width once
  via onSizeChanged) and resolve bar colours once during packing instead of
  every recomposition → much smoother scroll
- Serialize all event loads behind a Mutex and skip redundant state writes so
  scroll-triggered month loads no longer pile up / thrash the UI

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 14:53:06 +02:00
Guido SchmitandClaude Opus 4.8 3734e17c3f feat: multi-day event bars, readiness-gated splash, top-bar spinner, 2-line titles, detail animation
- Month view now draws multi-day events as continuous bars (lane packing,
  per-week column span clipped at week boundaries, +N overflow) instead of a
  chip on every day; events bucketed per week once + memoized packing for
  smooth scrolling
- Startup: core-splashscreen covers the window from frame 0 (no warm-start
  flash); the in-app splash stays until the first events finish loading (loading
  starts behind the splash via an early-obtained CalendarViewModel + ready flow),
  so you no longer enter a laggy app
- Background load shows a small spinner in the top bar (removed the linear bar)
- Week/Day titles wrap to two smaller lines (no more truncation)
- Event detail opens with a slide+fade animation

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 14:40:23 +02:00
Guido SchmitandClaude Opus 4.8 b8eb6597ec feat: HA/Google edit, full-page event detail, KW+zigzag divider, HSV picker, splash
- Home Assistant & Google events now editable/deletable (server PUT/DELETE
  endpoints wired; saveEvent had no HA/Google update branch -> created dupes)
- Event detail is now a full screen (Scaffold) instead of a bottom sheet:
  long notes scroll, action buttons no longer hidden behind the system nav bar;
  distinct calendar vs notes icons
- Month view: calendar-week number on Mondays, month-boundary divider with
  zigzag step (month_divider_color), month label in month_label_color,
  text/line contrast applied
- Compact top bar with smaller title; prev/next chevrons present in month view
  and scroll one month at a time
- Real HSV colour picker (SV field + hue slider + hex) for primary, accent,
  today, month-divider and month-label colours
- Black status/navigation bars; portrait-locked; crisp custom splash with app
  name, high-res icon and © Scarriffle

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 14:19:56 +02:00
Guido SchmitandClaude Opus 4.8 608580fc7e fix: event colors/null parsing, edit+copy, settings chips, scroll perf, splash
- CalEvent parsing: handle Android's JSONObject.optString JSON-null quirk that
  returned the string "null" — this made every event blue (color "null" →
  fallback) and showed "null" for empty location/description. Now null-safe.
- Event detail: copy-to-calendar action; calendar row uses a calendar icon
  (was identical to the notes icon); empty location/notes rows hidden
- Event editor: copy mode (prefill from an existing event, save as new)
- Settings: hour height, text/line contrast are now button chips (like the
  cache range); color picker wraps, shows the current colour and a check mark
- Month scroll perf: precompute a day→events index once per change and only
  reload when the visible month actually changes (was filtering every cell
  every frame → laggy)
- Splash: dark window/splash background + inset launcher icon (was an ugly
  white box on startup)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 13:27:03 +02:00
Guido SchmitandClaude Opus 4.8 c236db7fe9 feat: scrolling month calendar, green theme fixes, long-press create, event colors
- Month view rewritten as a continuous vertical scroll (iOS-style): no
  prev/next buttons, compact fixed-height week rows, month label on the 1st,
  top-bar title follows the visible month, Today scrolls back to today
- Long-press a day opens the event editor pre-filled with that date
- Theme: green container/tint colors so the FAB and accents are no longer the
  Material default purple
- Event colors: parse #RGB shorthand, and fall back to a stable per-calendar
  palette colour instead of a constant blue when the server omits a colour
- Top bar hides prev/next arrows in month view (scroll instead)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 13:08:54 +02:00
Guido SchmitandClaude Opus 4.8 a1c36a8a03 fix: green brand theme, iOS launcher icon, password reveal, real login error
- Theme primary now the iOS brand green (#20A050), fixed app-wide (was tied
  to the server's blue primary_color)
- Launcher icon generated from the iOS AppIcon across all densities;
  dropped the placeholder adaptive icon
- PasswordField: eye toggle + brief last-character reveal; used on login,
  profile and CalDAV password inputs
- Login now surfaces the server's actual error detail (verified the JSON
  contract against the live server; a 401 is a genuine credential mismatch)
- CLAUDE.md: correct prod base URL to calendar.scarriffle.com

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 12:48:53 +02:00
Guido SchmitandClaude Opus 4.8 aff6cef493 feat: full Compose UI (auth, calendar, events, accounts, settings, profile)
- Root navigation (server setup -> login+2FA -> calendar) with settings-driven theme
- CalendarViewModel mirroring iOS CalendarStore: range-based caching,
  background prefetch, hidden/banished calendar filters, event CRUD
- Five calendar views: Month, Week, Day, Quarter, Agenda (shared time grid)
- Event detail + editor sheets (platform date/time pickers, color override,
  writable-calendar picker) across local/caldav/google/homeassistant
- Calendar filter sheet (per-calendar show/hide)
- Accounts screen: add/delete CalDAV, local, iCal, Home Assistant; list Google
- Settings: view/week-start/language/colors/contrast/hour-height/cache range,
  synced to server
- Profile: email, password change, 2FA enable/disable
- gradlew build (lint + tests + assemble) passes

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 12:22:56 +02:00
Guido SchmitandClaude Opus 4.8 676b7ee2c6 feat: data foundation (models, secure storage, Retrofit API, repository, L10n)
- java.time via core library desugaring (minSdk 24)
- Domain models: CalEvent, AppSettings, account/calendar models
- CredentialStore (EncryptedSharedPreferences) + SettingsStore
- Retrofit CalendarrApi covering all server endpoints
- CalendarRepository: auth, settings, accounts, events (org.json parsing
  for mixed-type payloads), writable calendars, visibility toggles
- Hilt DI (Moshi), dynamic-baseUrl ApiProvider, AuthInterceptor
- L10n (de/en) ported from iOS

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 11:53:34 +02:00
Guido SchmitandClaude Opus 4.8 a015a45265 chore: get Android project building (toolchain, deps, manifest, launcher icon)
- Fix dependency versions (Retrofit 2.11, OkHttp 4.12, Compose BOM)
- Add Material Components for the app theme
- Add --add-exports/--add-opens for kapt on JDK 21
- Remove deprecated manifest package attr; add INTERNET permission
- Add adaptive launcher icon

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 11:44:48 +02:00
Scarriffle e71fd7512f Widget änderungen, sync änderungen 2026-05-28 21:43:18 +02:00
ScarriffleandClaude Opus 4.7 4125bfc728 Settings sync, calendar visibility sync, event refresh & week-view fixes
- Add two-way settings sync (SettingsSync) with toggle, app-start/foreground/
  10-min pull and debounced push; server wins; view/week-start/dim-past always
  sync. Wire previously-ignored settings (hour height, contrasts, week start,
  default view, dim past) into the actual UI.
- Make AppSettings decoding resilient (decodeIfPresent) so getSettings no longer
  fails on iOS-only fields the server omits; keep text/bg/line colors local-only;
  month divider/label colors now sync.
- Auto-refresh after create/edit (cache-busting) and optimistic removal on
  delete; switch delete confirm to a centered alert. Add HA event deletion.
- Calendar visibility: fix inverted hide/show toggle; normalize calendar keys so
  local filtering works for all sources; sync banish with server sidebar_hidden
  (CalDAV/Google/HA), refetch on un-banish.
- Manual "sync with server" button in the menu.
- Upcoming widget shows next 5 days (renamed).
- Week/Day view: route multi-day timed events to the all-day strip so they no
  longer render as a full-height block.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 20:44:14 +02:00
Scarriffle 07a9e9eb7f anzeige korrigiert Kopfzeilen Monat 2026-05-25 18:16:55 +02:00
Scarriffle 1395aaa0c0 fix anzeige Termine 2026-05-25 15:48:55 +02:00
Scarriffle 6c506770ba Widget anpassung vorbereitung 2026-05-25 11:53:02 +02:00
Scarriffle d1004a9111 Fix month scroll event disappearance by improving visible month detection and cache refresh rules 2026-05-25 10:33:31 +02:00
ScarriffleandClaude Opus 4.7 8b3cc11e25 Add localization (DE/EN), vertical-scroll month view, context menus, custom colors
- Vertical-scroll month view with multi-day event spans, zig-zag month
  divider, CW number per week, on-demand event loading while scrolling
- Top bar redesign: icon-only view picker on right, month title centered
- Long-press context menus on day cells (month) and hour slots (week/day)
  for "New event", "Open in week view", "Open in day view", "Open in month view"
- Localization system with system/de/en switch covering top bar, view picker,
  settings, menu, profile, server, accounts, event editor, agenda
- Three new color pickers (text/background/line) + today-marker color
  applied in calendar views; current-time line now uses today color
- App icon: removed alpha channel, accent color set to icon green (#20A050)
- TestFlight: ITSAppUsesNonExemptEncryption=NO baked into Info.plist keys

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 22:00:49 +02:00
Scarriffle e5529ca653 Initial Commit 2026-05-17 08:32:34 +02:00
296 changed files with 46021 additions and 617 deletions
+5
View File
@@ -0,0 +1,5 @@
.build/
.swiftpm/
DerivedData/
*.xcuserstate
.DS_Store
+37
View File
@@ -0,0 +1,37 @@
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "CalendarrKit",
platforms: [
.iOS(.v17),
// String form, not `.v26`: that enum case needs PackageDescription 6.2,
// and raising swift-tools-version for one constant would change
// defaults across the whole manifest.
.watchOS("26.0"),
.macOS(.v14),
.macCatalyst(.v17),
],
products: [
// Foundation only — deliberately no SwiftUI, no UIKit, no AppKit, so it
// compiles for iOS, Mac Catalyst and native macOS from one source.
.library(name: "CalendarrCore", targets: ["CalendarrCore"]),
],
targets: [
.target(
name: "CalendarrCore",
// Matches the apps, which build in Swift 5 language mode. Note that
// this package deliberately does NOT set defaultIsolation(MainActor),
// even though the Calendarr iOS app target does: the widget extension
// and the read-only consumer app have no MainActor default, and making
// the package MainActor-by-default would force `await` at every
// SnapshotStore.read() call site.
swiftSettings: [.swiftLanguageMode(.v5)]
),
.testTarget(
name: "CalendarrCoreTests",
dependencies: ["CalendarrCore"],
swiftSettings: [.swiftLanguageMode(.v5)]
),
]
)
+105
View File
@@ -0,0 +1,105 @@
# CalendarrKit
Shared code between Calendarr (iOS / Mac Catalyst) and other apps from the same
developer team that want to **read** the user's calendar.
`CalendarrCore` depends on Foundation only — no SwiftUI, no UIKit, no AppKit — so
it compiles for iOS, Mac Catalyst and native macOS from one source. That
constraint is load-bearing: `swift build` against the native macOS SDK is what
catches a UIKit import before it reaches a consumer, because a Mac Catalyst build
would compile it happily.
## What a reading app gets
```swift
import CalendarrCore
let store = SnapshotStore() // uses CalendarrAppGroup.current
switch store.read() {
case .ok(let snap):
snap.events // [SnapshotEvent]
snap.theme // the colours Calendarr is currently rendering with
snap.writtenAt // "as of 14:32"
snap.coverageStart // see "Coverage" below
snap.coverageEnd
case .neverWritten: // Calendarr has never run here → "open Calendarr once"
case .loggedOut: // signed out → "sign in to Calendarr", discard old events
case .incompatible: // Calendarr is newer than this package → "update the app"
case .unreadable(let why): // almost always a wrong App Group; log `why`
}
store.readCalendars() // [SnapshotCalendar] for a filter UI
```
React to changes without polling:
```swift
let token = SnapshotChangeNotifier.observe { /* hop to your actor, read again */ }
// keep `token` alive; releasing it removes the observer
```
Also reload on window activation — the notification is a best-effort signal, and
missing one should leave you slightly stale, never wrong.
## Coverage — the part that bites
The snapshot is not the whole calendar. Outside `coverageStart..<coverageEnd` it
carries **no information**, which is not the same as "no events". Rendering an
empty March because it is outside the window produces a screen that looks correct
and is false. Use `snapshot.covers(date)` and grey out what you cannot speak for.
| | |
|---|---|
| Window | ~7 days back, ~42 days ahead, frozen at write time |
| Cap | 500 events, sorted by start |
| Filters | hidden and "banished" calendars are already excluded — you see the user's filtered view |
| Scope | personal only; group overlays are never published, deliberately |
| Dropped | notes, url, source, creator, privacy, read-only, reminders, recurrence |
What remains is enough to *display* a calendar and deliberately not enough to
edit one or reason about permissions.
## Setup for a consuming app
Same developer team (`PP34X97WS3`) is required — that is what makes the container
and the keychain group shared.
```xml
<key>com.apple.security.app-sandbox</key><true/>
<key>com.apple.security.application-groups</key>
<array><string>PP34X97WS3.group.com.scarriffleservices.calendarr</string></array>
```
The Team ID prefix is mandatory on macOS and forbidden on iOS. `CalendarrAppGroup.current`
resolves the right one; it must match the entitlement byte for byte, or
`containerURL(…)` returns nil and every read silently yields nothing.
Never sign in from a consuming app. Credentials, 2FA and token storage belong to
Calendarr alone.
## Wire format rules
`widget-cache.json`, `widget-calendars.json` and `session.json` live in the group
container and are read by apps that ship on independent schedules.
1. Filenames do not change within a major version, so an older build keeps reading
after the writer updates.
2. Readers refuse `schemaVersion > maxSupportedSchema` instead of guessing.
3. Writers only ever *add* optional fields. Never rename, remove or retype one.
4. A breaking change means a new major version *and* a new filename, with the old
file still written for one full release cycle.
5. The package version is the contract: `CalendarrKit 1.x` ⇔ snapshot schema 2.
Depend on `.upToNextMajor(from: "1.0.0")`.
Rule 3 is why the six colour values sit flat at the top level instead of nested
under `theme`, which would read better: a v1 reader requires them flat, so nesting
would have broken every older build on the first v2 write. `theme` is a computed
view over them. `SnapshotSchemaTests` pins this down in both directions.
## Verify
```bash
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift build # native macOS — catches UIKit leaks
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift test
```
@@ -0,0 +1,31 @@
import Foundation
/// The App Group the Calendarr apps share.
///
/// The identifier registered in the Apple Developer portal never changes. What
/// changes is the string the *runtime* expects: macOS — including Mac Catalyst —
/// requires the Team ID prefix, and iOS forbids it.
///
/// Getting this wrong fails silently. `containerURL(forSecurityApplication‑
/// GroupIdentifier:)` returns nil, every read and write no-ops, and widgets show
/// placeholder content forever with no error raised anywhere. `current` must
/// therefore stay byte-identical to `com.apple.security.application-groups` in
/// the entitlements file used for the platform being built.
public enum CalendarrAppGroup {
/// As registered in the Apple Developer portal.
public static let unprefixed = "group.com.scarriffleservices.calendarr"
/// Team ID — the value `$(AppIdentifierPrefix)` expands to at build time.
public static let teamID = "PP34X97WS3"
/// The value to hand to `FileManager.containerURL(forSecurityApplicationGroupIdentifier:)`.
#if os(macOS) || targetEnvironment(macCatalyst)
public static let current = "\(teamID).\(unprefixed)"
#else
public static let current = unprefixed
#endif
/// Keychain access group shared across apps from this team. Requires
/// `keychain-access-groups` in the entitlements of every app that uses it.
public static let sharedKeychainGroup = "\(teamID).com.scarriffleservices.calendarr.shared"
}
@@ -0,0 +1,30 @@
import Foundation
/// Stable identifier for one calendar, used to tie a snapshot event back to the
/// calendar it came from.
///
/// This exists because the server reports `calendar_id` inconsistently: a raw
/// numeric for CalDAV sources, but `"<source>-<id>"` for local, iCal, Google and
/// Home Assistant (e.g. `"local-3"`). Any consumer that filters by calendar has
/// to normalise the two forms identically, or filtering silently does nothing
/// for the prefixed sources — which is exactly the bug this rule was written to
/// fix. Keeping the rule in one shared place is why it lives here rather than
/// being reimplemented per app.
public enum CalendarKey {
/// Build a key, stripping a redundant `"<source>-"` prefix from the id.
public static func make(source: String, calendarId: String) -> String {
var id = calendarId
let prefix = "\(source)-"
if id.hasPrefix(prefix) { id = String(id.dropFirst(prefix.count)) }
return "\(source):\(id)"
}
/// Split a key back into its source and numeric id.
/// Returns nil for malformed keys or non-numeric ids.
public static func parse(_ key: String) -> (source: String, id: Int)? {
guard let colon = key.firstIndex(of: ":") else { return nil }
let source = String(key[..<colon])
guard let id = Int(key[key.index(after: colon)...]) else { return nil }
return (source, id)
}
}
@@ -0,0 +1,61 @@
import Foundation
/// When a complication needs to be redrawn.
///
/// A watch-face complication has a small refresh budget, so the timeline is
/// pre-computed from data the watch already holds instead of being refreshed on
/// a schedule: an entry on every event boundary means the complication changes
/// in the exact minute the next appointment does, without a network call.
public enum ComplicationTimeline {
/// Dates at which a complication should re-render.
///
/// Always begins with `now` so the first entry renders immediately. Event
/// starts and ends within `horizon` become entries.
///
/// The first `fineGrainedWindow` then gets a mark every minute, and the rest
/// of the horizon one every hour. The fine-grained part is what lets a
/// complication show a *compact* countdown — "23m" rather than "1:23:45" —
/// which is the only kind that fits an accessoryCorner or a circular gauge.
/// A live `Text(style: .timer)` needs no entries at all but is eight
/// characters wide, and overlaps the curved label it sits next to.
///
/// The result is sorted, deduplicated to the second, and capped at
/// `maxEntries`, keeping the earliest.
public static func entryDates(from now: Date,
in snapshot: CalendarrSnapshot,
horizon: TimeInterval = 24 * 3600,
fineGrainedWindow: TimeInterval = 3600,
maxEntries: Int = 150) -> [Date] {
let end = now.addingTimeInterval(horizon)
var seen: Set<Int> = []
var dates: [Date] = []
func add(_ date: Date) {
guard date >= now, date <= end else { return }
// Deduplicate to whole seconds: two events that meet exactly would
// otherwise produce two entries a float apart.
let key = Int(date.timeIntervalSince1970.rounded())
guard seen.insert(key).inserted else { return }
dates.append(date)
}
add(now)
for event in snapshot.events {
add(event.start)
add(event.end)
}
let fineEnd = min(end, now.addingTimeInterval(fineGrainedWindow))
var minute = now.addingTimeInterval(60)
while minute <= fineEnd {
add(minute)
minute = minute.addingTimeInterval(60)
}
var hourly = now.addingTimeInterval(3600)
while hourly <= end {
add(hourly)
hourly = hourly.addingTimeInterval(3600)
}
return Array(dates.sorted().prefix(maxEntries))
}
}
@@ -0,0 +1,85 @@
import Foundation
/// Non-secret session facts published alongside the snapshot.
///
/// This exists so a consumer can answer "which server, and is anyone signed in?"
/// without reading Calendarr's own `UserDefaults`, which lives in a different
/// sandbox. The auth token is deliberately *not* here — it belongs in the shared
/// keychain group, not in a plain file in the container.
public struct SharedSession: Codable, Sendable {
public static let currentSchema = 1
public static let maxSupportedSchema = 1
public let schemaVersion: Int
public let baseURL: String
public let username: String
public let isLoggedIn: Bool
public let writtenAt: Date
public init(baseURL: String, username: String, isLoggedIn: Bool, writtenAt: Date) {
self.schemaVersion = Self.currentSchema
self.baseURL = baseURL
self.username = username
self.isLoggedIn = isLoggedIn
self.writtenAt = writtenAt
}
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
schemaVersion = try c.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 1
baseURL = try c.decode(String.self, forKey: .baseURL)
username = try c.decodeIfPresent(String.self, forKey: .username) ?? ""
isLoggedIn = try c.decodeIfPresent(Bool.self, forKey: .isLoggedIn) ?? true
writtenAt = try c.decodeIfPresent(Date.self, forKey: .writtenAt) ?? Date(timeIntervalSince1970: 0)
}
enum CodingKeys: String, CodingKey {
case schemaVersion, baseURL, username, isLoggedIn, writtenAt
}
/// The server URL as a `URL`, or nil when unset or malformed.
public var url: URL? { baseURL.isEmpty ? nil : URL(string: baseURL) }
}
public struct SharedSessionStore: Sendable {
private static let filename = "session.json"
public let appGroupID: String
public init(appGroupID: String = CalendarrAppGroup.current) {
self.appGroupID = appGroupID
}
private var fileURL: URL? {
FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: appGroupID)?
.appendingPathComponent(Self.filename)
}
/// Nil when Calendarr has never published, or the file is unreadable or newer
/// than this reader understands.
public func read() -> SharedSession? {
guard let url = fileURL, let data = try? Data(contentsOf: url) else { return nil }
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
guard let session = try? decoder.decode(SharedSession.self, from: data),
session.schemaVersion <= SharedSession.maxSupportedSchema else { return nil }
return session
}
@_spi(Writer)
public func write(_ session: SharedSession) throws {
guard let url = fileURL else {
throw SnapshotStoreError.containerUnavailable(appGroupID: appGroupID)
}
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
try encoder.encode(session).write(to: url, options: .atomic)
}
@_spi(Writer)
public func clear() {
guard let url = fileURL else { return }
try? FileManager.default.removeItem(at: url)
}
}
@@ -0,0 +1,62 @@
import Foundation
/// Cross-process "the snapshot changed" broadcast.
///
/// Two sandboxed apps cannot see each other's `NotificationCenter`, and neither
/// can poll the container cheaply. Darwin notifications are the one signal that
/// crosses the process boundary without an extra entitlement, a running
/// connection, or a shared framework.
///
/// The payload is always empty — Darwin notifications carry no data. The signal
/// means only "read again". If it is ever missed, a consumer that also reloads
/// on window activation degrades to a slightly stale view rather than a wrong one.
public enum SnapshotChangeNotifier {
public static let name = "com.scarriffleservices.calendarr.snapshot-changed"
/// Announce that the snapshot was rewritten. Called by `SnapshotStore`.
public static func post() {
CFNotificationCenterPostNotification(
CFNotificationCenterGetDarwinNotifyCenter(),
CFNotificationName(name as CFString),
nil, nil, true)
}
/// Start listening. The handler runs on an arbitrary thread, so hop to
/// whatever actor your UI needs before touching state.
///
/// Returns a token you must keep alive; deinit removes the observer.
public static func observe(_ handler: @escaping @Sendable () -> Void) -> ObservationToken {
ObservationToken(handler: handler)
}
public final class ObservationToken: @unchecked Sendable {
private let handler: @Sendable () -> Void
fileprivate init(handler: @escaping @Sendable () -> Void) {
self.handler = handler
// Darwin notifications hand back no context pointer we can rely on
// across process boundaries, so route through an unretained pointer
// to this token and look it up in the callback.
let observer = Unmanaged.passUnretained(self).toOpaque()
CFNotificationCenterAddObserver(
CFNotificationCenterGetDarwinNotifyCenter(),
observer,
{ _, observer, _, _, _ in
guard let observer else { return }
Unmanaged<ObservationToken>.fromOpaque(observer)
.takeUnretainedValue().handler()
},
SnapshotChangeNotifier.name as CFString,
nil,
.deliverImmediately)
}
deinit {
CFNotificationCenterRemoveObserver(
CFNotificationCenterGetDarwinNotifyCenter(),
Unmanaged.passUnretained(self).toOpaque(),
CFNotificationName(SnapshotChangeNotifier.name as CFString),
nil)
}
}
}
@@ -0,0 +1,32 @@
import Foundation
extension CalendarrSnapshot {
/// Keep only the listed calendars. An empty set means "every calendar",
/// which is what an unconfigured widget or complication asks for.
///
/// Filtering narrows the events but deliberately not the window: the
/// snapshot still speaks for the same range, it just has fewer calendars
/// in it.
public func filtered(toCalendarKeys keys: Set<String>) -> CalendarrSnapshot {
guard !keys.isEmpty else { return self }
return replacingEvents(events.filter { keys.contains($0.calendarKey) })
}
/// Remove the listed calendars, keeping everything else. An empty set is a
/// no-op.
public func filtered(excludingCalendarKeys keys: Set<String>) -> CalendarrSnapshot {
guard !keys.isEmpty else { return self }
return replacingEvents(events.filter { !keys.contains($0.calendarKey) })
}
private func replacingEvents(_ events: [SnapshotEvent]) -> CalendarrSnapshot {
CalendarrSnapshot(writtenAt: writtenAt,
coverageStart: coverageStart,
coverageEnd: coverageEnd,
isLoggedIn: isLoggedIn,
writerVersion: writerVersion,
events: events,
theme: theme,
language: language)
}
}
@@ -0,0 +1,223 @@
import Foundation
// MARK: - Coverage policy
/// How much of the calendar the writer publishes. Shared so the writer and the
/// v1-file reconstruction below can never disagree about it.
public enum SnapshotCoverage {
/// Days before today. The current week must stay complete for widgets that
/// render Monday–today.
public static let daysBehind = 7
/// Days after today.
public static let daysAhead = 42
/// Hard cap on events written, applied after sorting by start date.
public static let maxEvents = 500
}
// MARK: - Event
/// One event as published to the shared container.
///
/// Deliberately lossy: notes, URL, source, creator, privacy, read-only status,
/// reminders and recurrence rules are all dropped. This is enough to *display* a
/// calendar and deliberately not enough to edit one or reason about permissions.
public struct SnapshotEvent: Codable, Sendable, Identifiable, Hashable {
public let id: String
public let title: String
public let start: Date
public let end: Date
public let isAllDay: Bool
public let colorHex: String
public let location: String
/// Ties the event to a calendar. See `CalendarKey`.
public let calendarKey: String
public init(id: String, title: String, start: Date, end: Date,
isAllDay: Bool, colorHex: String, location: String, calendarKey: String) {
self.id = id; self.title = title; self.start = start; self.end = end
self.isAllDay = isAllDay; self.colorHex = colorHex
self.location = location; self.calendarKey = calendarKey
}
// Lenient: caches written before calendarKey existed simply lack the field.
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(String.self, forKey: .id)
title = try c.decode(String.self, forKey: .title)
start = try c.decode(Date.self, forKey: .start)
end = try c.decode(Date.self, forKey: .end)
isAllDay = try c.decode(Bool.self, forKey: .isAllDay)
colorHex = try c.decode(String.self, forKey: .colorHex)
location = try c.decode(String.self, forKey: .location)
calendarKey = try c.decodeIfPresent(String.self, forKey: .calendarKey) ?? ""
}
}
// MARK: - Calendar
/// A calendar available for filtering, published alongside the events so a
/// consumer can build a picker without a network call.
public struct SnapshotCalendar: Codable, Sendable, Identifiable, Hashable {
/// A `CalendarKey`, matching `SnapshotEvent.calendarKey`.
public let id: String
public let name: String
public let colorHex: String
public init(id: String, name: String, colorHex: String) {
self.id = id; self.name = name; self.colorHex = colorHex
}
}
// MARK: - Theme
/// The colours the app is currently rendering with, so a consumer can match it
/// without reading the app's own preferences.
public struct SnapshotTheme: Sendable, Hashable {
public let today: String
public let text: String
public let background: String
public let line: String
public let primary: String
public let accent: String
public init(today: String, text: String, background: String,
line: String, primary: String, accent: String) {
self.today = today; self.text = text; self.background = background
self.line = line; self.primary = primary; self.accent = accent
}
}
// MARK: - Snapshot
/// The published calendar snapshot.
///
/// ## Wire compatibility
///
/// The six colour values are stored **flat** at the top level rather than nested
/// under `theme`, even though `theme` is the nicer thing to read in code. That is
/// deliberate: v1 files carry them flat and a v1 reader requires them flat, so
/// nesting would break older readers on the very first v2 write. Within a major
/// version this format only ever gains optional fields — old readers ignore what
/// they do not know, new readers default what is missing. `theme` is a computed
/// view over the stored values, so callers get the clean shape anyway.
public struct CalendarrSnapshot: Codable, Sendable {
/// Bump only for a breaking change, and see the rules in the README.
public static let currentSchema = 2
/// Files claiming a higher schema are refused rather than mis-decoded.
public static let maxSupportedSchema = 2
public let schemaVersion: Int
public let writtenAt: Date
/// Start of the published window. Outside `coverageStart..<coverageEnd` the
/// snapshot carries *no information* — which is not the same as "no events".
public let coverageStart: Date
public let coverageEnd: Date
/// False once the user signs out. Distinguishes "signed out" from "never ran".
public let isLoggedIn: Bool
/// CFBundleShortVersionString of whoever wrote the file. Diagnostics only.
public let writerVersion: String
public let events: [SnapshotEvent]
public let language: String
// Stored flat for wire compatibility — read them via `theme`.
let todayColorHex: String
let textColorHex: String
let backgroundColorHex: String
let lineColorHex: String
let primaryColorHex: String
let accentColorHex: String
public var theme: SnapshotTheme {
SnapshotTheme(today: todayColorHex, text: textColorHex,
background: backgroundColorHex, line: lineColorHex,
primary: primaryColorHex, accent: accentColorHex)
}
/// True when `date` falls inside the published window, i.e. when an absence
/// of events for that date genuinely means "nothing scheduled".
public func covers(_ date: Date) -> Bool {
date >= coverageStart && date < coverageEnd
}
public init(writtenAt: Date,
coverageStart: Date,
coverageEnd: Date,
isLoggedIn: Bool,
writerVersion: String,
events: [SnapshotEvent],
theme: SnapshotTheme,
language: String) {
self.schemaVersion = Self.currentSchema
self.writtenAt = writtenAt
self.coverageStart = coverageStart
self.coverageEnd = coverageEnd
self.isLoggedIn = isLoggedIn
self.writerVersion = writerVersion
self.events = events
self.language = language
self.todayColorHex = theme.today
self.textColorHex = theme.text
self.backgroundColorHex = theme.background
self.lineColorHex = theme.line
self.primaryColorHex = theme.primary
self.accentColorHex = theme.accent
}
/// Lenient decoder. Everything added after v1 is optional here, and a v1
/// file's coverage window is reconstructed from the policy that produced it.
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
schemaVersion = try c.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 1
writtenAt = try c.decode(Date.self, forKey: .writtenAt)
events = try c.decode([SnapshotEvent].self, forKey: .events)
language = try c.decode(String.self, forKey: .language)
todayColorHex = try c.decode(String.self, forKey: .todayColorHex)
textColorHex = try c.decode(String.self, forKey: .textColorHex)
backgroundColorHex = try c.decode(String.self, forKey: .backgroundColorHex)
lineColorHex = try c.decode(String.self, forKey: .lineColorHex)
primaryColorHex = try c.decodeIfPresent(String.self, forKey: .primaryColorHex) ?? "#4285f4"
accentColorHex = try c.decodeIfPresent(String.self, forKey: .accentColorHex) ?? "#ea4335"
isLoggedIn = try c.decodeIfPresent(Bool.self, forKey: .isLoggedIn) ?? true
writerVersion = try c.decodeIfPresent(String.self, forKey: .writerVersion) ?? "unknown"
// v1 had no explicit window; it always wrote the same one, measured from
// the start of the day it was written.
let cal = Calendar(identifier: .gregorian)
let dayStart = cal.startOfDay(for: writtenAt)
coverageStart = try c.decodeIfPresent(Date.self, forKey: .coverageStart)
?? cal.date(byAdding: .day, value: -SnapshotCoverage.daysBehind, to: dayStart) ?? dayStart
coverageEnd = try c.decodeIfPresent(Date.self, forKey: .coverageEnd)
?? cal.date(byAdding: .day, value: SnapshotCoverage.daysAhead, to: dayStart) ?? dayStart
}
enum CodingKeys: String, CodingKey {
case schemaVersion, writtenAt, coverageStart, coverageEnd, isLoggedIn, writerVersion
case events, language
case todayColorHex, textColorHex, backgroundColorHex, lineColorHex
case primaryColorHex, accentColorHex
}
}
// MARK: - Read result
/// Why a read produced no usable snapshot. The distinction matters for the UI:
/// "never ran", "signed out" and "genuinely empty" call for different messages.
public enum SnapshotReadResult: Sendable {
case ok(CalendarrSnapshot)
/// The user signed out of Calendarr. Show a sign-in prompt, not stale events.
case loggedOut(writtenAt: Date)
/// Calendarr has never published on this machine. Ask the user to open it once.
case neverWritten
/// Written by a newer Calendarr than this reader understands. Refuse rather
/// than guess; ask the user to update.
case incompatible(foundSchema: Int, maxSupported: Int)
/// Corrupt file, or — much more likely — a misconfigured App Group.
case unreadable(String)
public var snapshot: CalendarrSnapshot? {
if case .ok(let s) = self { return s }
return nil
}
}
@@ -0,0 +1,139 @@
import Foundation
public enum SnapshotStoreError: Error, LocalizedError {
case containerUnavailable(appGroupID: String)
public var errorDescription: String? {
switch self {
case .containerUnavailable(let id):
return "App Group container unavailable for \(id). Check that "
+ "com.apple.security.application-groups in this target's "
+ "entitlements matches exactly, and that macOS builds use the "
+ "Team ID prefix."
}
}
}
/// Reads — and, for Calendarr itself, writes — the shared calendar snapshot.
///
/// Writing is gated behind `@_spi(Writer)` so a read-only consumer cannot
/// corrupt the shared cache even by accident. Calendarr imports the package as
/// `@_spi(Writer) import CalendarrCore`; everyone else gets read access only.
public struct SnapshotStore: Sendable {
private static let cacheFilename = "widget-cache.json"
private static let calendarsFilename = "widget-calendars.json"
public let appGroupID: String
public init(appGroupID: String = CalendarrAppGroup.current) {
self.appGroupID = appGroupID
}
private var containerURL: URL? {
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupID)
}
private static func encoder() -> JSONEncoder {
let e = JSONEncoder()
e.dateEncodingStrategy = .iso8601
return e
}
private static func decoder() -> JSONDecoder {
let d = JSONDecoder()
d.dateDecodingStrategy = .iso8601
return d
}
// MARK: - Reading
/// Load the published snapshot.
///
/// Never returns a bare nil: every failure mode is named, because they call
/// for different things in the UI and because a silent nil is precisely how
/// a misconfigured App Group used to hide itself.
public func read() -> SnapshotReadResult {
guard let container = containerURL else {
return .unreadable(SnapshotStoreError
.containerUnavailable(appGroupID: appGroupID).localizedDescription)
}
let url = container.appendingPathComponent(Self.cacheFilename)
guard FileManager.default.fileExists(atPath: url.path) else {
// No cache. Signing out deletes the events — keeping them would leak
// the previous user's calendar — so the session file is what tells
// "signed out" apart from "never ran here". Answering that in one
// call keeps every consumer from reimplementing the distinction.
if let session = SharedSessionStore(appGroupID: appGroupID).read(),
!session.isLoggedIn {
return .loggedOut(writtenAt: session.writtenAt)
}
return .neverWritten
}
let data: Data
do { data = try Data(contentsOf: url) }
catch { return .unreadable("Could not read \(Self.cacheFilename): \(error.localizedDescription)") }
// Check the version before decoding: a newer writer may have changed the
// meaning of a field we think we understand, so refusing beats guessing.
if let probe = try? Self.decoder().decode(SchemaProbe.self, from: data) {
let found = probe.schemaVersion ?? 1
if found > CalendarrSnapshot.maxSupportedSchema {
return .incompatible(foundSchema: found,
maxSupported: CalendarrSnapshot.maxSupportedSchema)
}
}
do {
let snapshot = try Self.decoder().decode(CalendarrSnapshot.self, from: data)
guard snapshot.isLoggedIn else { return .loggedOut(writtenAt: snapshot.writtenAt) }
return .ok(snapshot)
} catch {
return .unreadable("Could not decode \(Self.cacheFilename): \(error)")
}
}
/// The calendars available for filtering. Empty when nothing is published.
public func readCalendars() -> [SnapshotCalendar] {
guard let url = containerURL?.appendingPathComponent(Self.calendarsFilename),
let data = try? Data(contentsOf: url) else { return [] }
return (try? Self.decoder().decode([SnapshotCalendar].self, from: data)) ?? []
}
/// Reads only the version field, so an unsupported file is never fully decoded.
private struct SchemaProbe: Decodable { let schemaVersion: Int? }
// MARK: - Writing (Calendarr only)
@_spi(Writer)
public func write(_ snapshot: CalendarrSnapshot) throws {
guard let container = containerURL else {
throw SnapshotStoreError.containerUnavailable(appGroupID: appGroupID)
}
let data = try Self.encoder().encode(snapshot)
try data.write(to: container.appendingPathComponent(Self.cacheFilename), options: .atomic)
SnapshotChangeNotifier.post()
}
@_spi(Writer)
public func writeCalendars(_ calendars: [SnapshotCalendar]) throws {
guard let container = containerURL else {
throw SnapshotStoreError.containerUnavailable(appGroupID: appGroupID)
}
let data = try Self.encoder().encode(calendars)
try data.write(to: container.appendingPathComponent(Self.calendarsFilename), options: .atomic)
}
/// Remove everything published. Call this on sign-out and on server reset —
/// the container outlives the session, so without it the next reader keeps
/// rendering the previous user's calendar.
@_spi(Writer)
public func clear() {
guard let container = containerURL else { return }
for name in [Self.cacheFilename, Self.calendarsFilename] {
try? FileManager.default.removeItem(at: container.appendingPathComponent(name))
}
SnapshotChangeNotifier.post()
}
}
@@ -0,0 +1,67 @@
import Foundation
/// How much of the calendar travels to the watch.
///
/// Deliberately narrower than `SnapshotCoverage`: the payload crosses
/// WatchConnectivity on every publish, and a watch complication never needs six
/// weeks of history to answer "what is next".
public enum WatchCoverage {
/// Days before today. One is enough for "still running" all-day events.
public static let daysBehind = 1
/// Days after today.
public static let daysAhead = 14
/// Hard cap on events sent, applied after sorting by start date.
public static let maxEvents = 120
}
extension CalendarrSnapshot {
/// A narrower copy of this snapshot, suitable for sending to the watch.
///
/// The coverage window shrinks together with the events, and never widens
/// past what the writer already claimed. If `WatchCoverage.maxEvents`
/// truncates the list, `coverageEnd` is pulled back to the first event we
/// had to drop: everything before that point is provably complete, and
/// beyond it the watch must not pretend to know anything.
public func trimmedForWatch(now: Date = Date(),
calendar: Calendar = Calendar(identifier: .gregorian)) -> CalendarrSnapshot {
let dayStart = calendar.startOfDay(for: now)
let windowStart = max(coverageStart,
calendar.date(byAdding: .day, value: -WatchCoverage.daysBehind, to: dayStart) ?? dayStart)
let windowEnd = min(coverageEnd,
calendar.date(byAdding: .day, value: WatchCoverage.daysAhead, to: dayStart) ?? dayStart)
guard windowEnd > windowStart else {
return Self.rebuilt(from: self, start: windowStart, end: windowStart, events: [])
}
let inWindow = events
.filter { $0.start < windowEnd && $0.end > windowStart }
.sorted { $0.start < $1.start }
guard inWindow.count > WatchCoverage.maxEvents else {
return Self.rebuilt(from: self, start: windowStart, end: windowEnd, events: inWindow)
}
let kept = Array(inWindow.prefix(WatchCoverage.maxEvents))
// The first dropped event marks the edge of what we can prove. Its start
// becomes the new end of coverage; the last kept event is dropped too
// when it starts at that same instant, which keeps the invariant
// "every event we carry starts before coverageEnd" exactly true.
let provenEnd = max(windowStart, min(windowEnd, inWindow[WatchCoverage.maxEvents].start))
return Self.rebuilt(from: self, start: windowStart, end: provenEnd,
events: kept.filter { $0.start < provenEnd && $0.end > windowStart })
}
private static func rebuilt(from source: CalendarrSnapshot,
start: Date, end: Date,
events: [SnapshotEvent]) -> CalendarrSnapshot {
CalendarrSnapshot(writtenAt: source.writtenAt,
coverageStart: start,
coverageEnd: end,
isLoggedIn: source.isLoggedIn,
writerVersion: source.writerVersion,
events: events,
theme: source.theme,
language: source.language)
}
}
@@ -0,0 +1,35 @@
import Foundation
/// Identifies the appointment a complication is currently showing.
///
/// The start date is part of the identity on purpose: a moved event keeps its
/// id, and the complication is wrong until it is told.
public struct NextEventIdentity: Equatable, Sendable, Codable {
public let id: String
public let start: Date
public init(id: String, start: Date) {
self.id = id
self.start = start
}
}
public enum WatchPushPolicy {
/// The first event that has not ended yet — which is what every "next
/// appointment" complication renders, including one that is running now.
public static func nextEventIdentity(in snapshot: CalendarrSnapshot,
at now: Date) -> NextEventIdentity? {
snapshot.events
.filter { $0.end > now }
.min { $0.start < $1.start }
.map { NextEventIdentity(id: $0.id, start: $0.start) }
}
/// Whether this change is worth one of the day's limited complication
/// pushes. Only a changed next appointment is — everything else reaches the
/// watch on the next application-context delivery.
public static func shouldPushComplicationUpdate(previous: NextEventIdentity?,
current: NextEventIdentity?) -> Bool {
previous != current
}
}
@@ -0,0 +1,101 @@
import Foundation
/// Dictionary keys used on the WatchConnectivity wire. Stable within a major
/// version, like the snapshot filenames.
public enum WatchTransportKey {
public static let schema = "schema"
public static let snapshot = "snapshot"
public static let calendars = "calendars"
public static let session = "session"
}
public enum WatchTransportError: Error, Equatable, LocalizedError {
case missingSnapshot
case unsupportedSchema(found: Int, maxSupported: Int)
case malformed(String)
public var errorDescription: String? {
switch self {
case .missingSnapshot:
return "Watch payload carried no snapshot."
case .unsupportedSchema(let found, let max):
return "Watch payload schema \(found) is newer than this build understands (\(max))."
case .malformed(let why):
return "Watch payload could not be decoded: \(why)"
}
}
}
/// What the phone sends to the watch: a trimmed snapshot, the calendar list for
/// a filter UI, and the non-secret session facts so the watch can tell "signed
/// out" from "never received anything".
///
/// The auth token is deliberately absent. The watch never talks to the server,
/// so there is nothing for it to authenticate with and nothing to leak.
public struct WatchTransportPayload: Sendable {
/// Bump only for a breaking change to the dictionary shape.
public static let currentSchema = 1
/// Payloads claiming a higher schema are refused rather than mis-decoded.
public static let maxSupportedSchema = 1
public let snapshot: CalendarrSnapshot
public let calendars: [SnapshotCalendar]
public let session: SharedSession?
public init(snapshot: CalendarrSnapshot,
calendars: [SnapshotCalendar],
session: SharedSession?) {
self.snapshot = snapshot
self.calendars = calendars
self.session = session
}
private static func encoder() -> JSONEncoder {
let e = JSONEncoder(); e.dateEncodingStrategy = .iso8601; return e
}
private static func decoder() -> JSONDecoder {
let d = JSONDecoder(); d.dateDecodingStrategy = .iso8601; return d
}
/// Encode for `WCSession`. Values are `Data` and `Int` only, because
/// WatchConnectivity accepts property-list types and rejects everything
/// else at runtime rather than at compile time.
public func encoded() throws -> [String: Any] {
let e = Self.encoder()
var dict: [String: Any] = [
WatchTransportKey.schema: Self.currentSchema,
WatchTransportKey.snapshot: try e.encode(snapshot),
WatchTransportKey.calendars: try e.encode(calendars),
]
if let session { dict[WatchTransportKey.session] = try e.encode(session) }
return dict
}
public static func decode(_ dict: [String: Any]) throws -> WatchTransportPayload {
let found = dict[WatchTransportKey.schema] as? Int ?? 1
guard found <= maxSupportedSchema else {
throw WatchTransportError.unsupportedSchema(found: found, maxSupported: maxSupportedSchema)
}
guard let snapshotData = dict[WatchTransportKey.snapshot] as? Data else {
throw WatchTransportError.missingSnapshot
}
let d = decoder()
let snapshot: CalendarrSnapshot
do { snapshot = try d.decode(CalendarrSnapshot.self, from: snapshotData) }
catch { throw WatchTransportError.malformed("snapshot: \(error)") }
var calendars: [SnapshotCalendar] = []
if let data = dict[WatchTransportKey.calendars] as? Data {
calendars = (try? d.decode([SnapshotCalendar].self, from: data)) ?? []
}
var session: SharedSession?
if let data = dict[WatchTransportKey.session] as? Data {
session = try? d.decode(SharedSession.self, from: data)
}
return WatchTransportPayload(snapshot: snapshot, calendars: calendars, session: session)
}
}
@@ -0,0 +1,51 @@
import Testing
@testable import CalendarrCore
/// The server reports `calendar_id` two different ways, and normalising them
/// inconsistently is what made per-calendar filtering silently do nothing for
/// some sources. These pin the rule down.
struct CalendarKeyTests {
@Test("CalDAV ids arrive bare and are used as-is")
func bareNumericId() {
#expect(CalendarKey.make(source: "caldav", calendarId: "12") == "caldav:12")
}
@Test("Prefixed ids from the other sources get the prefix stripped")
func strippedPrefix() {
#expect(CalendarKey.make(source: "local", calendarId: "local-3") == "local:3")
#expect(CalendarKey.make(source: "google", calendarId: "google-5") == "google:5")
#expect(CalendarKey.make(source: "ical", calendarId: "ical-7") == "ical:7")
#expect(CalendarKey.make(source: "homeassistant",
calendarId: "homeassistant-9") == "homeassistant:9")
}
@Test("Both spellings of the same calendar produce the same key")
func bothFormsAgree() {
#expect(CalendarKey.make(source: "local", calendarId: "local-3")
== CalendarKey.make(source: "local", calendarId: "3"))
}
@Test("Only a leading prefix is stripped, and only once")
func stripsOnlyLeadingPrefix() {
// A prefix appearing later in the id must survive untouched.
#expect(CalendarKey.make(source: "local", calendarId: "3-local") == "local:3-local")
// Two prefixes: only the first goes.
#expect(CalendarKey.make(source: "local", calendarId: "local-local-3") == "local:local-3")
}
@Test("parse round-trips make")
func roundTrip() {
let key = CalendarKey.make(source: "local", calendarId: "local-42")
let parsed = CalendarKey.parse(key)
#expect(parsed?.source == "local")
#expect(parsed?.id == 42)
}
@Test("parse rejects what it cannot represent")
func rejectsMalformed() {
#expect(CalendarKey.parse("nocolon") == nil)
#expect(CalendarKey.parse("local:abc") == nil) // non-numeric id
#expect(CalendarKey.parse("local:") == nil)
}
}
@@ -0,0 +1,110 @@
import Foundation
import Testing
@testable import CalendarrCore
struct ComplicationTimelineTests {
private static let now = Date(timeIntervalSince1970: 1_789_000_000)
private static func event(_ id: String, startOffset: TimeInterval,
duration: TimeInterval) -> SnapshotEvent {
SnapshotEvent(id: id, title: id,
start: now.addingTimeInterval(startOffset),
end: now.addingTimeInterval(startOffset + duration),
isAllDay: false, colorHex: "#4285f4", location: "", calendarKey: "local:1")
}
private static func snapshot(_ events: [SnapshotEvent]) -> CalendarrSnapshot {
CalendarrSnapshot(writtenAt: now,
coverageStart: now.addingTimeInterval(-86400),
coverageEnd: now.addingTimeInterval(14 * 86400),
isLoggedIn: true, writerVersion: "1.0", events: events,
theme: SnapshotTheme(today: "#1", text: "#2", background: "#3",
line: "#4", primary: "#5", accent: "#6"),
language: "de")
}
@Test("The first entry is always now, so the complication renders immediately")
func startsAtNow() {
let dates = ComplicationTimeline.entryDates(from: Self.now, in: Self.snapshot([]))
#expect(dates.first == Self.now)
}
@Test("Entries are sorted and free of duplicates")
func sortedAndDeduplicated() {
let snap = Self.snapshot([Self.event("a", startOffset: 3600, duration: 1800),
Self.event("b", startOffset: 5400, duration: 1800)])
let dates = ComplicationTimeline.entryDates(from: Self.now, in: snap)
#expect(dates == dates.sorted())
#expect(Set(dates).count == dates.count)
}
@Test("Event starts and ends inside the horizon become entries")
func includesEventBoundaries() {
let snap = Self.snapshot([Self.event("a", startOffset: 3600, duration: 1800)])
let dates = ComplicationTimeline.entryDates(from: Self.now, in: snap)
#expect(dates.contains(Self.now.addingTimeInterval(3600)))
#expect(dates.contains(Self.now.addingTimeInterval(5400)))
}
@Test("Boundaries outside the horizon are ignored")
func ignoresBoundariesBeyondHorizon() {
let snap = Self.snapshot([Self.event("far", startOffset: 40 * 3600, duration: 1800)])
let dates = ComplicationTimeline.entryDates(from: Self.now, in: snap, horizon: 24 * 3600)
#expect(dates.contains(Self.now.addingTimeInterval(40 * 3600)) == false)
#expect(dates.allSatisfy { $0 <= Self.now.addingTimeInterval(24 * 3600) })
}
@Test("Boundaries already in the past are ignored")
func ignoresPastBoundaries() {
let snap = Self.snapshot([Self.event("past", startOffset: -7200, duration: 1800)])
let dates = ComplicationTimeline.entryDates(from: Self.now, in: snap)
#expect(dates.allSatisfy { $0 >= Self.now })
}
@Test("An empty calendar still gets hourly entries across the horizon")
func fillsHourlyWhenEmpty() {
let dates = ComplicationTimeline.entryDates(from: Self.now, in: Self.snapshot([]),
horizon: 24 * 3600)
#expect(dates.count >= 24)
}
@Test("The first hour gets minute-granular entries, so a compact countdown stays honest")
func minuteGranularFirstHour() {
let dates = ComplicationTimeline.entryDates(from: Self.now, in: Self.snapshot([]))
let firstHour = dates.filter { $0 <= Self.now.addingTimeInterval(3600) }
// 60 minute marks plus `now` itself.
#expect(firstHour.count >= 60)
#expect(firstHour.contains(Self.now.addingTimeInterval(60)))
#expect(firstHour.contains(Self.now.addingTimeInterval(1800)))
}
@Test("Past the first hour the entries thin out to hourly")
func hourlyAfterFirstHour() {
let dates = ComplicationTimeline.entryDates(from: Self.now, in: Self.snapshot([]))
let later = dates.filter { $0 > Self.now.addingTimeInterval(3600) }
// 23 further hours, not 23 * 60 minutes.
#expect(later.count < 40)
#expect(later.contains(Self.now.addingTimeInterval(2 * 3600)))
}
@Test("The entry count is capped, keeping the earliest entries")
func capsEntries() {
var events: [SnapshotEvent] = []
for i in 0..<300 {
events.append(Self.event("e\(i)", startOffset: Double(i) * 60, duration: 30))
}
let dates = ComplicationTimeline.entryDates(from: Self.now, in: Self.snapshot(events),
maxEntries: 50)
#expect(dates.count == 50)
#expect(dates.first == Self.now)
#expect(dates == dates.sorted())
}
}
@@ -0,0 +1,51 @@
import Foundation
import Testing
@testable import CalendarrCore
struct SnapshotFilterTests {
private static let now = Date(timeIntervalSince1970: 1_789_000_000)
private static func event(_ id: String, key: String) -> SnapshotEvent {
SnapshotEvent(id: id, title: id, start: now, end: now.addingTimeInterval(3600),
isAllDay: false, colorHex: "#4285f4", location: "", calendarKey: key)
}
private static func snapshot(_ events: [SnapshotEvent]) -> CalendarrSnapshot {
CalendarrSnapshot(writtenAt: now,
coverageStart: now.addingTimeInterval(-86400),
coverageEnd: now.addingTimeInterval(86400),
isLoggedIn: true, writerVersion: "1.0", events: events,
theme: SnapshotTheme(today: "#1", text: "#2", background: "#3",
line: "#4", primary: "#5", accent: "#6"),
language: "de")
}
@Test("An empty allow-list means every calendar, not no calendar")
func emptyAllowListKeepsEverything() {
let snap = Self.snapshot([Self.event("a", key: "local:1"), Self.event("b", key: "caldav:2")])
#expect(snap.filtered(toCalendarKeys: []).events.count == 2)
}
@Test("An allow-list keeps only the listed calendars")
func allowListNarrows() {
let snap = Self.snapshot([Self.event("a", key: "local:1"), Self.event("b", key: "caldav:2")])
#expect(snap.filtered(toCalendarKeys: ["local:1"]).events.map(\.id) == ["a"])
}
@Test("A deny-list removes only the listed calendars")
func denyListNarrows() {
let snap = Self.snapshot([Self.event("a", key: "local:1"), Self.event("b", key: "caldav:2")])
#expect(snap.filtered(excludingCalendarKeys: ["local:1"]).events.map(\.id) == ["b"])
}
@Test("Filtering narrows the events but never the window")
func windowIsUntouched() {
let snap = Self.snapshot([Self.event("a", key: "local:1")])
let filtered = snap.filtered(toCalendarKeys: ["nothing:0"])
#expect(filtered.events.isEmpty)
#expect(filtered.coverageStart == snap.coverageStart)
#expect(filtered.coverageEnd == snap.coverageEnd)
}
}
@@ -0,0 +1,172 @@
import Foundation
import Testing
@testable import CalendarrCore
/// The wire format is a contract between apps that ship independently, so both
/// directions of compatibility need a test: a new reader must understand an old
/// file, and an old reader must survive a new one.
struct SnapshotSchemaTests {
private static func decoder() -> JSONDecoder {
let d = JSONDecoder(); d.dateDecodingStrategy = .iso8601; return d
}
private static func encoder() -> JSONEncoder {
let e = JSONEncoder(); e.dateEncodingStrategy = .iso8601; return e
}
/// A real v1 file: no schemaVersion, no coverage window, no isLoggedIn,
/// colours flat at the top level.
private static let v1Fixture = """
{
"writtenAt": "2026-08-10T12:00:00Z",
"events": [
{
"id": "abc",
"title": "Zahnarzt",
"start": "2026-08-11T09:00:00Z",
"end": "2026-08-11T10:00:00Z",
"isAllDay": false,
"colorHex": "#58B900",
"location": "Bern",
"calendarKey": "local:3"
}
],
"todayColorHex": "#6FB669",
"textColorHex": "#FFFFFF",
"backgroundColorHex": "#000000",
"lineColorHex": "#3B3B3D",
"primaryColorHex": "#58B900",
"accentColorHex": "#45A148",
"language": "de"
}
"""
@Test("A v1 file decodes, and its missing fields get sane defaults")
func decodesV1() throws {
let snap = try Self.decoder().decode(
CalendarrSnapshot.self, from: Data(Self.v1Fixture.utf8))
#expect(snap.schemaVersion == 1)
#expect(snap.events.count == 1)
#expect(snap.events[0].calendarKey == "local:3")
#expect(snap.language == "de")
#expect(snap.theme.today == "#6FB669")
#expect(snap.theme.accent == "#45A148")
// v1 never said whether anyone was signed in; assuming yes preserves the
// old behaviour of simply rendering whatever was cached.
#expect(snap.isLoggedIn)
#expect(snap.writerVersion == "unknown")
// The window is reconstructed from the policy v1 always used.
let cal = Calendar(identifier: .gregorian)
let dayStart = cal.startOfDay(for: snap.writtenAt)
#expect(snap.coverageStart == cal.date(byAdding: .day, value: -7, to: dayStart))
#expect(snap.coverageEnd == cal.date(byAdding: .day, value: 42, to: dayStart))
}
/// Older still: written before per-calendar filtering existed, so the events
/// carry no calendarKey at all.
private static let v1FixtureNoCalendarKey = """
{
"writtenAt": "2026-08-10T12:00:00Z",
"events": [
{
"id": "abc",
"title": "Zahnarzt",
"start": "2026-08-11T09:00:00Z",
"end": "2026-08-11T10:00:00Z",
"isAllDay": false,
"colorHex": "#58B900",
"location": "Bern"
}
],
"todayColorHex": "#6FB669",
"textColorHex": "#FFFFFF",
"backgroundColorHex": "#000000",
"lineColorHex": "#3B3B3D",
"language": "de"
}
"""
@Test("A v1 file that predates calendarKey and the newer colours still decodes")
func decodesV1WithoutCalendarKey() throws {
let snap = try Self.decoder().decode(
CalendarrSnapshot.self, from: Data(Self.v1FixtureNoCalendarKey.utf8))
#expect(snap.events[0].calendarKey == "")
// primary/accent were added later too, so they fall back to defaults.
#expect(snap.theme.primary == "#4285f4")
#expect(snap.theme.accent == "#ea4335")
}
@Test("v2 round-trips without losing anything")
func roundTripsV2() throws {
let start = Date(timeIntervalSince1970: 1_780_000_000)
let original = CalendarrSnapshot(
writtenAt: start,
coverageStart: start.addingTimeInterval(-7 * 86_400),
coverageEnd: start.addingTimeInterval(42 * 86_400),
isLoggedIn: true,
writerVersion: "3.5",
events: [SnapshotEvent(id: "x", title: "Termin", start: start,
end: start.addingTimeInterval(3600),
isAllDay: false, colorHex: "#58B900",
location: "", calendarKey: "local:1")],
theme: SnapshotTheme(today: "#1", text: "#2", background: "#3",
line: "#4", primary: "#5", accent: "#6"),
language: "ch")
let decoded = try Self.decoder().decode(
CalendarrSnapshot.self, from: Self.encoder().encode(original))
#expect(decoded.schemaVersion == CalendarrSnapshot.currentSchema)
#expect(decoded.writerVersion == "3.5")
#expect(decoded.language == "ch")
#expect(decoded.coverageStart == original.coverageStart)
#expect(decoded.coverageEnd == original.coverageEnd)
#expect(decoded.theme == original.theme)
#expect(decoded.events == original.events)
}
/// The reason the colours are stored flat rather than nested under `theme`:
/// a v1 reader requires them at the top level, so nesting them would have
/// broken every older build on the first v2 write.
@Test("v2 keeps the colour keys where a v1 reader expects them")
func v2StaysReadableByV1() throws {
let start = Date(timeIntervalSince1970: 1_780_000_000)
let snap = CalendarrSnapshot(
writtenAt: start, coverageStart: start, coverageEnd: start,
isLoggedIn: true, writerVersion: "3.5", events: [],
theme: SnapshotTheme(today: "#a", text: "#b", background: "#c",
line: "#d", primary: "#e", accent: "#f"),
language: "de")
let object = try JSONSerialization.jsonObject(
with: Self.encoder().encode(snap)) as? [String: Any]
for key in ["todayColorHex", "textColorHex", "backgroundColorHex",
"lineColorHex", "primaryColorHex", "accentColorHex",
"writtenAt", "events", "language"] {
#expect(object?[key] != nil, "v1 readers require \(key) at the top level")
}
#expect(object?["theme"] == nil, "theme must stay a computed view, not a stored key")
}
@Test("covers() separates 'no events' from 'no information'")
func coverageWindow() {
let now = Date(timeIntervalSince1970: 1_780_000_000)
let snap = CalendarrSnapshot(
writtenAt: now,
coverageStart: now.addingTimeInterval(-86_400),
coverageEnd: now.addingTimeInterval(86_400),
isLoggedIn: true, writerVersion: "3.5", events: [],
theme: SnapshotTheme(today: "", text: "", background: "",
line: "", primary: "", accent: ""),
language: "de")
#expect(snap.covers(now))
#expect(snap.covers(now.addingTimeInterval(-86_399)))
#expect(!snap.covers(now.addingTimeInterval(-90_000)))
#expect(!snap.covers(snap.coverageEnd)) // half-open: end is excluded
}
}
@@ -0,0 +1,76 @@
import Foundation
import Testing
@testable import CalendarrCore
struct WatchPushPolicyTests {
private static let now = Date(timeIntervalSince1970: 1_789_000_000)
private static func event(_ id: String, startOffset: TimeInterval,
duration: TimeInterval = 3600) -> SnapshotEvent {
SnapshotEvent(id: id, title: id,
start: now.addingTimeInterval(startOffset),
end: now.addingTimeInterval(startOffset + duration),
isAllDay: false, colorHex: "#4285f4", location: "", calendarKey: "local:1")
}
private static func snapshot(_ events: [SnapshotEvent]) -> CalendarrSnapshot {
CalendarrSnapshot(writtenAt: now,
coverageStart: now.addingTimeInterval(-86400),
coverageEnd: now.addingTimeInterval(14 * 86400),
isLoggedIn: true, writerVersion: "1.0", events: events,
theme: SnapshotTheme(today: "#1", text: "#2", background: "#3",
line: "#4", primary: "#5", accent: "#6"),
language: "de")
}
@Test("The next event is the first one that has not ended yet")
func picksFirstUnfinished() {
let snap = Self.snapshot([Self.event("past", startOffset: -7200),
Self.event("running", startOffset: -600),
Self.event("later", startOffset: 3600)])
#expect(WatchPushPolicy.nextEventIdentity(in: snap, at: Self.now)?.id == "running")
}
@Test("An empty calendar has no next event")
func emptyHasNoIdentity() {
#expect(WatchPushPolicy.nextEventIdentity(in: Self.snapshot([]), at: Self.now) == nil)
}
@Test("Events are considered in start order regardless of input order")
func sortsBeforePicking() {
let snap = Self.snapshot([Self.event("late", startOffset: 7200),
Self.event("soon", startOffset: 1800)])
#expect(WatchPushPolicy.nextEventIdentity(in: snap, at: Self.now)?.id == "soon")
}
@Test("An unchanged next event does not spend a push")
func unchangedDoesNotPush() {
let identity = NextEventIdentity(id: "a", start: Self.now)
#expect(WatchPushPolicy.shouldPushComplicationUpdate(previous: identity, current: identity) == false)
}
@Test("A different event, a moved event, or a first event all spend a push")
func changesPush() {
let a = NextEventIdentity(id: "a", start: Self.now)
let b = NextEventIdentity(id: "b", start: Self.now)
let moved = NextEventIdentity(id: "a", start: Self.now.addingTimeInterval(600))
#expect(WatchPushPolicy.shouldPushComplicationUpdate(previous: a, current: b))
#expect(WatchPushPolicy.shouldPushComplicationUpdate(previous: a, current: moved))
#expect(WatchPushPolicy.shouldPushComplicationUpdate(previous: nil, current: a))
}
@Test("Losing the last event spends a push, so the watch stops showing it")
func clearingPushes() {
let a = NextEventIdentity(id: "a", start: Self.now)
#expect(WatchPushPolicy.shouldPushComplicationUpdate(previous: a, current: nil))
}
@Test("Two empty calendars in a row do not push")
func stayingEmptyDoesNotPush() {
#expect(WatchPushPolicy.shouldPushComplicationUpdate(previous: nil, current: nil) == false)
}
}
@@ -0,0 +1,81 @@
import Foundation
import Testing
@testable import CalendarrCore
/// The phone and the watch ship on independent schedules, so the transport gets
/// the same treatment as the file format: refuse a newer schema rather than
/// mis-decode it.
struct WatchTransportTests {
private static let now = Date(timeIntervalSince1970: 1_789_000_000)
private static func snapshot() -> CalendarrSnapshot {
CalendarrSnapshot(writtenAt: now,
coverageStart: now.addingTimeInterval(-86400),
coverageEnd: now.addingTimeInterval(14 * 86400),
isLoggedIn: true, writerVersion: "1.2",
events: [SnapshotEvent(id: "a", title: "Zahnarzt",
start: now.addingTimeInterval(3600),
end: now.addingTimeInterval(7200),
isAllDay: false, colorHex: "#4285f4",
location: "Bern", calendarKey: "local:3")],
theme: SnapshotTheme(today: "#1", text: "#2", background: "#3",
line: "#4", primary: "#5", accent: "#6"),
language: "de")
}
@Test("A payload survives a round trip")
func roundTrips() throws {
let payload = WatchTransportPayload(
snapshot: Self.snapshot(),
calendars: [SnapshotCalendar(id: "local:3", name: "Privat", colorHex: "#4285f4")],
session: SharedSession(baseURL: "https://cal.example.com", username: "guido",
isLoggedIn: true, writtenAt: Self.now))
let decoded = try WatchTransportPayload.decode(try payload.encoded())
#expect(decoded.snapshot.events.map(\.id) == ["a"])
#expect(decoded.snapshot.language == "de")
#expect(decoded.calendars.map(\.name) == ["Privat"])
#expect(decoded.session?.username == "guido")
}
@Test("A payload with no session record still decodes")
func sessionIsOptional() throws {
let payload = WatchTransportPayload(snapshot: Self.snapshot(), calendars: [], session: nil)
let decoded = try WatchTransportPayload.decode(try payload.encoded())
#expect(decoded.session == nil)
#expect(decoded.calendars.isEmpty)
}
@Test("The encoded form only contains property-list types")
func encodesToPlistTypes() throws {
let dict = try WatchTransportPayload(snapshot: Self.snapshot(), calendars: [], session: nil).encoded()
#expect(dict[WatchTransportKey.schema] is Int)
#expect(dict[WatchTransportKey.snapshot] is Data)
#expect(PropertyListSerialization.propertyList(dict, isValidFor: .binary))
}
@Test("A newer schema is refused rather than guessed at")
func refusesNewerSchema() throws {
var dict = try WatchTransportPayload(snapshot: Self.snapshot(), calendars: [], session: nil).encoded()
dict[WatchTransportKey.schema] = WatchTransportPayload.maxSupportedSchema + 1
#expect(throws: WatchTransportError.unsupportedSchema(
found: WatchTransportPayload.maxSupportedSchema + 1,
maxSupported: WatchTransportPayload.maxSupportedSchema)) {
try WatchTransportPayload.decode(dict)
}
}
@Test("A payload without a snapshot is rejected")
func rejectsMissingSnapshot() {
let dict: [String: Any] = [WatchTransportKey.schema: WatchTransportPayload.currentSchema]
#expect(throws: WatchTransportError.missingSnapshot) {
try WatchTransportPayload.decode(dict)
}
}
}
@@ -0,0 +1,118 @@
import Foundation
import Testing
@testable import CalendarrCore
/// The watch payload is a narrower snapshot, not a different one. These tests
/// pin down the part that is easy to get wrong: the coverage window has to
/// shrink with the events, or the watch renders an empty week it knows nothing
/// about.
struct WatchTrimmingTests {
private static let cal = Calendar(identifier: .gregorian)
private static let now = Date(timeIntervalSince1970: 1_789_000_000)
private static func event(_ id: String, dayOffset: Int, hour: Int = 9) -> SnapshotEvent {
let dayStart = cal.startOfDay(for: now)
let day = cal.date(byAdding: .day, value: dayOffset, to: dayStart)!
let start = cal.date(byAdding: .hour, value: hour, to: day)!
return SnapshotEvent(id: id, title: "Event \(id)",
start: start, end: start.addingTimeInterval(3600),
isAllDay: false, colorHex: "#4285f4",
location: "", calendarKey: "local:1")
}
private static func snapshot(_ events: [SnapshotEvent]) -> CalendarrSnapshot {
let dayStart = cal.startOfDay(for: now)
return CalendarrSnapshot(
writtenAt: now,
coverageStart: cal.date(byAdding: .day, value: -SnapshotCoverage.daysBehind, to: dayStart)!,
coverageEnd: cal.date(byAdding: .day, value: SnapshotCoverage.daysAhead, to: dayStart)!,
isLoggedIn: true,
writerVersion: "1.0",
events: events,
theme: SnapshotTheme(today: "#1", text: "#2", background: "#3",
line: "#4", primary: "#5", accent: "#6"),
language: "de")
}
@Test("The window narrows to the watch policy, not just the events")
func narrowsWindow() {
let trimmed = Self.snapshot([]).trimmedForWatch(now: Self.now, calendar: Self.cal)
let dayStart = Self.cal.startOfDay(for: Self.now)
#expect(trimmed.coverageStart == Self.cal.date(byAdding: .day, value: -WatchCoverage.daysBehind, to: dayStart))
#expect(trimmed.coverageEnd == Self.cal.date(byAdding: .day, value: WatchCoverage.daysAhead, to: dayStart))
}
@Test("Events outside the watch window are dropped")
func dropsEventsOutsideWindow() {
let inside = Self.event("in", dayOffset: 3)
let outside = Self.event("out", dayOffset: 30)
let trimmed = Self.snapshot([inside, outside]).trimmedForWatch(now: Self.now, calendar: Self.cal)
#expect(trimmed.events.map(\.id) == ["in"])
}
@Test("An event still running at the window start is kept")
func keepsRunningEvent() {
let dayStart = Self.cal.startOfDay(for: Self.now)
let start = Self.cal.date(byAdding: .day, value: -5, to: dayStart)!
let running = SnapshotEvent(id: "running", title: "Urlaub",
start: start,
end: Self.cal.date(byAdding: .day, value: 5, to: dayStart)!,
isAllDay: true, colorHex: "#4285f4",
location: "", calendarKey: "local:1")
let trimmed = Self.snapshot([running]).trimmedForWatch(now: Self.now, calendar: Self.cal)
#expect(trimmed.events.map(\.id) == ["running"])
}
@Test("Metadata survives trimming unchanged")
func preservesMetadata() {
let original = Self.snapshot([Self.event("a", dayOffset: 1)])
let trimmed = original.trimmedForWatch(now: Self.now, calendar: Self.cal)
#expect(trimmed.language == "de")
#expect(trimmed.isLoggedIn == true)
#expect(trimmed.writerVersion == "1.0")
#expect(trimmed.theme.accent == "#6")
#expect(trimmed.writtenAt == original.writtenAt)
#expect(trimmed.schemaVersion == CalendarrSnapshot.currentSchema)
}
@Test("When the event cap truncates, coverageEnd pulls back to what we can prove")
func capPullsBackCoverage() {
var many: [SnapshotEvent] = []
for day in 0..<14 {
for hour in 8..<18 { many.append(Self.event("d\(day)h\(hour)", dayOffset: day, hour: hour)) }
}
#expect(many.count > WatchCoverage.maxEvents)
let trimmed = Self.snapshot(many).trimmedForWatch(now: Self.now, calendar: Self.cal)
#expect(trimmed.events.count <= WatchCoverage.maxEvents)
#expect(trimmed.events.allSatisfy { $0.start < trimmed.coverageEnd })
let dayStart = Self.cal.startOfDay(for: Self.now)
let fullEnd = Self.cal.date(byAdding: .day, value: WatchCoverage.daysAhead, to: dayStart)!
#expect(trimmed.coverageEnd < fullEnd)
#expect(trimmed.coverageEnd > trimmed.coverageStart)
}
@Test("Trimming never widens a window the writer already narrowed")
func neverWidens() {
let dayStart = Self.cal.startOfDay(for: Self.now)
let narrow = CalendarrSnapshot(
writtenAt: Self.now,
coverageStart: dayStart,
coverageEnd: Self.cal.date(byAdding: .day, value: 2, to: dayStart)!,
isLoggedIn: true, writerVersion: "1.0", events: [],
theme: SnapshotTheme(today: "#1", text: "#2", background: "#3",
line: "#4", primary: "#5", accent: "#6"),
language: "de")
let trimmed = narrow.trimmedForWatch(now: Self.now, calendar: Self.cal)
#expect(trimmed.coverageStart == dayStart)
#expect(trimmed.coverageEnd == Self.cal.date(byAdding: .day, value: 2, to: dayStart))
}
}
+146 -1
View File
@@ -24,6 +24,7 @@ A clean, self-hosted calendar application with support for CalDAV, Google Calend
- Create, edit, and delete events with title, description, location, and time
- All-day events
- Per-event color overrides
- **Attachments** — PDFs, images and text files on local events (10 MB each, 10 per event)
- iCal event overrides (hide or edit individual events from subscriptions)
- Dim past events option
@@ -102,10 +103,154 @@ Configuration is done via environment variables. The `install.sh` script generat
| `SECRET_KEY` | *(auto-generated)* | JWT signing key — use a strong random value in production |
| `PORT` | `8080` | Server port |
| `HOST` | `0.0.0.0` | Bind address |
| `DATA_DIR` | `./data` | Directory for SQLite database and avatars |
| `DATA_DIR` | `./data` | Directory for the SQLite database, avatars, branding and attachments |
| `GOOGLE_CLIENT_ID` | — | Google OAuth2 Client ID *(optional)* |
| `GOOGLE_CLIENT_SECRET` | — | Google OAuth2 Client Secret *(optional)* |
| `GOOGLE_REDIRECT_URI` | — | e.g. `https://yourdomain.com/api/google/callback` *(optional)* |
| `PUBLIC_BASE_URL` | *(derived)* | Public origin behind a reverse proxy — **required for SSO** and for attachment links in CalDAV |
| `ATTACHMENT_PUBLIC_LINKS` | `1` | `0` disables the unauthenticated attachment URLs (see below) |
| `SCHEDULER_ENABLED` | `1` | `0` disables all background jobs |
| `ATTACHMENT_SWEEP_HOURS` | `24` | How often orphaned attachments are cleaned up |
| `ICAL_REFRESH_MINUTES` | `15` | How often iCal subscriptions are checked in the background |
| `OIDC_PROVIDERS` | — | Comma-separated provider keys, e.g. `authentik` *(optional)* |
Per provider `<KEY>` listed in `OIDC_PROVIDERS` (variable names are uppercased):
| Variable | Default | Description |
|---|---|---|
| `OIDC_<KEY>_ISSUER` | — | **Required.** Issuer URL, e.g. `https://authentik.example.com/application/o/calendarr/` |
| `OIDC_<KEY>_CLIENT_ID` | — | **Required.** Client ID of the web application |
| `OIDC_<KEY>_CLIENT_SECRET` | — | Leave empty for a public client (PKCE only) |
| `OIDC_<KEY>_NAME` | *(key)* | Label on the login button |
| `OIDC_<KEY>_SCOPES` | `openid profile email` | Requested scopes — **must be quoted**, see below |
| `OIDC_<KEY>_MOBILE_CLIENT_ID` | — | Client ID(s) of the native apps, comma-separated |
| `OIDC_<KEY>_MOBILE_ISSUER` | *(issuer)* | Only if mobile uses a second application |
| `OIDC_<KEY>_ALLOW_SIGNUP` | `false` | Create accounts on first SSO login |
| `OIDC_<KEY>_ALLOWED_DOMAINS` | — | Email-domain allowlist for signup |
| `OIDC_<KEY>_LINK_BY_EMAIL` | `false` | Auto-link to an existing account by email |
| `OIDC_<KEY>_USERNAME_CLAIM` | `preferred_username` | Claim used to derive the username |
| `OIDC_<KEY>_REDIRECT_URI` | *(derived)* | Overrides the derived callback URL |
### Single Sign-On (OpenID Connect / Authentik)
Adds an SSO button to the login screen **alongside** password login. With
`OIDC_PROVIDERS` unset, nothing changes — the feature is invisible.
**In Authentik:** create one OAuth2/OpenID provider, client type **Public**
(the web flow uses PKCE, so a secret buys little), with two redirect URIs:
- Web: `https://calendar.example.com/api/auth/oidc/authentik/callback`
- iOS: `com.scarriffleservices.calendarr.ios:/oauth2redirect`
- Android: `com.scarriffle.calendarr:/oauth2redirect`
The mobile apps live in this repository under `ios/` and `android/`, and
authenticate as **public** clients via AppAuth — PKCE, no client secret.
Set `OIDC_<KEY>_MOBILE_CLIENT_ID` to the client id they use; without it the apps
show no SSO button.
Signing key RS256, scope mappings `openid`, `profile`, `email` — and
**`offline_access` for the mobile apps**. Since Authentik 2024.2 that scope has
to be mapped explicitly; without it no refresh token is issued and the apps
appear to log out every week. Restrict access with an Authentik group binding —
that, not `ALLOWED_DOMAINS`, is the real authorization control.
**In `.env`:**
```bash
PUBLIC_BASE_URL=https://calendar.example.com
OIDC_PROVIDERS=authentik
OIDC_AUTHENTIK_NAME=Authentik
OIDC_AUTHENTIK_ISSUER=https://authentik.example.com/application/o/calendarr/
OIDC_AUTHENTIK_CLIENT_ID=...
OIDC_AUTHENTIK_MOBILE_CLIENT_ID=calendarr-mobile
OIDC_AUTHENTIK_SCOPES="openid profile email"
OIDC_AUTHENTIK_ALLOW_SIGNUP=false
```
> **`install.sh` does not overwrite an existing `.env`.** On an existing
> installation append these lines by hand, then `systemctl restart calendarr`.
> **Quote every value containing a space.** `start.sh` does
> `set -a; source .env; set +a`, so an unquoted
> `OIDC_AUTHENTIK_SCOPES=openid profile email` makes the shell try to run
> `profile` and startup fails.
**Account matching.** An identity is matched on the provider's `sub` claim.
A first SSO login with no matching account does **not** fail. The verified
identity is parked in a short-lived signed cookie and the login screen asks the
user to sign in once with their Calendarr password; the identity is then
attached to exactly that account. Both halves are proven — the ID token by us,
the account by the password — so no trust in the provider'''s email handling is
required.
`LINK_BY_EMAIL=true` links automatically to an existing account with the same
address instead (additionally requiring `email_verified`). It saves one step,
but means anyone who can set an arbitrary email at the identity provider
inherits the matching account, including an admin'''s. Prefer the default.
With `ALLOW_SIGNUP=true`, unknown users get an account on first login. Such
accounts have no usable password; they use **app passwords** for CalDAV, the
same as 2FA users.
**Adding a second provider** is configuration only: append its key to
`OIDC_PROVIDERS`, add the matching `OIDC_<KEY>_*` block, restart. No code change.
**Rollback:** remove `OIDC_PROVIDERS` and restart. Accounts created via SSO
remain and must be removed (or given a password) by an admin.
### Attachments
Events in **local** calendars can carry files: PDFs, images (PNG/JPEG/WebP/GIF)
and text files (TXT/Markdown/CSV), up to **10 MB per file and 10 per event**.
Events from CalDAV, Google, Home Assistant or iCal subscriptions cannot — they
are fetched live from their own server and have no record here to attach to.
Files are stored under `DATA_DIR/attachments`; **back that up together with the
database**, or a restored database will point at files that no longer exist.
Who may attach or remove a file is the same rule as who may edit the event:
the calendar's owner, group members, and anyone with a read-write share. A
read-only share can view and download. Private events of other users expose
neither their attachments nor their number.
**Attachments in external calendar clients.** The generated ICS carries an
`ATTACH` line per file, so Apple Calendar, Thunderbird and DAVx5 show the
attachment and can download it. Those clients cannot send a login, so the URL
has to work without one: each attachment gets its own unguessable 256-bit
token, and anyone who has that URL can fetch the file.
Weigh that honestly. The link only ever reaches clients that were already
allowed to read the calendar, and it dies when the attachment is deleted — but
it is long-lived, it ends up in plain text in the client's local calendar
store, and it would appear in a reverse-proxy access log. If that is not
acceptable for your deployment, set `ATTACHMENT_PUBLIC_LINKS=0`: no `ATTACH`
line is written and the public URL stops answering. Attachments then work in
the web UI and the mobile apps only.
`PUBLIC_BASE_URL` must be set for the links to be correct behind a reverse
proxy; without a usable base URL the `ATTACH` line is omitted entirely.
### Background jobs
The server runs two periodic jobs in-process:
| Job | Interval | What it does |
|---|---|---|
| `attachments.sweep` | 24 h | Removes attachment records whose event is gone, and files with no record |
| `ical.refresh` | 15 min | Re-fetches iCal subscriptions that are due |
Attachments are normally cleaned up the moment an event, calendar or user is
deleted; the sweep is the safety net for what gets past that — an external
CalDAV client deleting an event, or a request that died mid-upload. It leaves
files younger than an hour alone, so it cannot race an upload in progress.
The iCal job only drives the same refresh the app already did on demand, so
subscription intervals are unchanged. What is new is that subscriptions stay
current while nobody has the app open.
Both jobs assume a single server process, which is how `main.py` starts
uvicorn. Running multiple workers would run every job once per worker.
### Google Calendar Setup
+66
View File
@@ -0,0 +1,66 @@
# Theme parameters (Web)
Calendarr's web client lets you customise the colour theme under
**Settings → Darstellung → Farben**. Every colour has its own sync toggle (share
it across your devices or keep it device-local — see
[backend/SETTINGS_SYNC.md](backend/SETTINGS_SYNC.md)).
You can also **export** the current theme to a `<date>_<time>.theme` file and
**import** one later. A `.theme` file is plain JSON:
```json
{
"_format": "calendarr-theme",
"_version": 1,
"_docs": "https://git.scarriffle.com/Scarriffle/Calendarr/src/branch/beta/THEME.md",
"exported_at": "2026-07-20T14:33:00.000Z",
"settings": {
"primary_color": "#4285F4",
"hover_highlight_color": "#2A2A38",
"...": "..."
}
}
```
A theme may be **partial** — delete any keys you don't want and only the
remaining ones are applied. Importing writes **only the parameters present in
the file** (keys whose sync toggle is on are also pushed to the server; the rest
update this browser only); omitted parameters are left untouched. If the file
contains parameters this version doesn't recognise, you're asked whether to
import the rest anyway.
## Colour parameters
Source of truth for the defaults: `DEFAULT_COLORS` in
[frontend/js/settings-sync.js](frontend/js/settings-sync.js). Each value is a
`#RRGGBB` hex string. Where a default is listed as "derived", leaving the value
untouched reproduces the previous automatic look; setting it overrides that.
| Key | What it colours | Default |
|---|---|---|
| `primary_color` | Primary/brand colour — buttons, links, active states, and the browser favicon/tab colour | `#58B900` |
| `accent_color` | Accent — danger actions, the "now" line, reminders | `#45A148` |
| `today_color` | "Today" accent: the day-number circle and today's labels | `#6FB669` |
| `text_color` | Base text colour (secondary/tertiary text is derived from it) | `#FFFFFF` |
| `bg_color` | App background | `#000000` |
| `surface_color` | Sidebar / top bar / card surfaces (derived from `bg_color` when unset) | `#2B2B2B` |
| `line_color` | Borders and grid lines | `#3B3B3D` |
| `month_divider_color` | The line marking a month change in the scrolling month view | `#95D25E` |
| `month_label_color` | The month abbreviation shown at a month change | `#95D25E` |
| `hover_highlight_color` | General interactive hover — buttons, menu items, list rows | `#2A2A38` |
| `icon_inactive_color` | Sidebar action icons (notification bell *off*, hide/eye, delete/trash, "not editable") in their resting / off / not-hovered state | `#90AA91` |
| `icon_active_color` | The same sidebar action icons when hovered, pressed, or *on* (e.g. notification bell enabled) | `#E8E8F0` |
| `day_hover_color` | Hover background over a calendar day (month / week / quarter / agenda / mini-calendar / date picker) | `#2B382A` |
| `day_selected_color` | The selected day — applied as a subtle tint of this colour | `#88EF9A` |
| `day_bg_color` | Normal (unselected, non-today) day background. Defaults to the app background so days look transparent | `#000000` |
| `today_bg_color` | Today's day-cell background — applied as a subtle tint of this colour | `#477650` |
Notes:
- `day_selected_color` and `today_bg_color` are applied as a low-opacity tint of
the chosen colour (so content stays readable). The colour you pick is the base
hue; the swatch shows the full colour.
- `day_bg_color` defaults to the app background. If you set a custom
`bg_color`, also set `day_bg_color` to match if you want fully transparent days.
- The other clients (iOS / Android) currently ignore the fine-grained element
colours; they are stored and synced by the server but only the web client
renders them.
+47
View File
@@ -0,0 +1,47 @@
# Built application files
*.apk
*.aar
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
release/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc.)
local.properties
# Log files
*.log
# Android Studio / IntelliJ
*.iml
.idea/
*.ipr
*.iws
captures/
.externalNativeBuild/
.cxx/
# Keystore files
*.jks
*.keystore
keystore.properties
# macOS
.DS_Store
# Kotlin
.kotlin/
+96
View File
@@ -0,0 +1,96 @@
# 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)
The repo is just static files (APKs + a signed index). Host + generate it on an
always-on **Debian/Ubuntu LXC on Proxmox**, and keep the APK build/signing on the
Mac (where the keystore lives).
### One-time: set up the LXC
```bash
# Debian/Ubuntu LXC — fdroidserver + the Android build-tools it reads APKs with.
apt update
apt install -y fdroidserver android-sdk-build-tools default-jdk
# (if the distro has no android-sdk-build-tools pkg, install Android command-line
# tools and `sdkmanager "build-tools;34.0.0"`, then point $ANDROID_HOME at it)
# Create the repo once (generates config.yml + an index-signing key)
mkdir -p /srv/fdroid && cd /srv/fdroid
fdroid init
```
**Serving:** you already run a reverse proxy, so it terminates TLS for
`fdroid.scarriffle.com`. Either point the reverse proxy straight at the
`/srv/fdroid` directory (no webserver on the LXC needed — just `fdroidserver` to
generate), or run a tiny static server on the LXC over plain HTTP and forward to
it, e.g. `darkhttpd /srv/fdroid --port 8080` (or `caddy file-server` /
`python3 -m http.server`). Only requirement: the public URL is reachable over
**HTTPS** (the reverse proxy does that); it's plain static file serving, no
directory listing needed.
### Each release
```bash
# 1. On the MAC: build a SIGNED release APK (F-Droid repos serve APKs, not AABs)
./gradlew assembleRelease # → app/build/outputs/apk/release/app-release.apk
# 2. Copy APK + store metadata to the LXC
scp app/build/outputs/apk/release/app-release.apk root@lxc:/srv/fdroid/repo/
rsync -a "fastlane/metadata/android/" root@lxc:/srv/fdroid/metadata/com.scarriffle.calendarr/
# 3. On the LXC: regenerate the signed index
cd /srv/fdroid && fdroid update -c --pretty
```
Users then open F-Droid → Settings → Repositories → **+** →
`https://fdroid.scarriffle.com/fdroid/repo` → Calendarr appears and auto-updates.
Keep the OLD apks in `repo/` too (users on older versions still update). For each
new version bump `versionCode`/`versionName` in `app/build.gradle.kts` first.
*(Fully hands-off alternative: build on the LXC too — then copy the keystore +
`keystore.properties` there and run `assembleRelease` in a checkout. The Mac
split above avoids putting the signing key on the server.)*
---
## 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
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>.
+130
View File
@@ -0,0 +1,130 @@
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"
id("org.jetbrains.kotlin.kapt") version "1.9.20"
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 = 35
defaultConfig {
applicationId = "com.scarriffle.calendarr"
minSdk = 24
targetSdk = 35
versionCode = 2
versionName = "1.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { useSupportLibrary = true }
// AppAuth merges its RedirectUriReceiverActivity into the manifest and
// needs the custom scheme for the OIDC redirect. Reverse-DNS on the
// application id, per RFC 8252; the same value must be registered as a
// redirect URI in the identity provider:
// com.scarriffle.calendarr:/oauth2redirect
manifestPlaceholders["appAuthRedirectScheme"] = "com.scarriffle.calendarr"
}
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")
}
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
isCoreLibraryDesugaringEnabled = true
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.5"
}
packaging {
resources {
excludes += setOf("/META-INF/{AL2.0,LGPL2.1}")
}
}
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.core:core-splashscreen:1.0.1")
implementation("com.google.android.material:material:1.11.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
implementation("androidx.activity:activity-compose:1.9.0")
// Compose (managed by BOM)
implementation(platform("androidx.compose:compose-bom:2023.10.01"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material3:material3-window-size-class")
implementation("androidx.compose.material:material-icons-extended")
debugImplementation("androidx.compose.ui:ui-tooling")
// DI
implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
implementation("com.google.dagger:hilt-android:2.49")
kapt("com.google.dagger:hilt-compiler:2.49")
// Networking
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-moshi:2.11.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
implementation("com.squareup.moshi:moshi-kotlin:1.15.0")
// Secure credential storage
implementation("androidx.security:security-crypto:1.1.0-alpha06")
// OIDC / SSO — Authorization Code Flow with PKCE in a Custom Tab
implementation("net.openid:appauth:0.11.1")
// Async
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation(platform("androidx.compose:compose-bom:2023.10.01"))
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
}
+2
View File
@@ -0,0 +1,2 @@
# Add project-specific ProGuard rules here.
# Keep this file empty until release optimization rules are needed.
+31
View File
@@ -0,0 +1,31 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<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"
android:allowBackup="true"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:theme="@style/Theme.Calendarr"
android:supportsRtl="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="portrait"
android:theme="@style/Theme.Calendarr.Starting">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<receiver
android:name=".notifications.ReminderReceiver"
android:exported="false" />
</application>
</manifest>
@@ -0,0 +1,7 @@
package com.scarriffle.calendarr
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class CalendarrApplication : Application()
@@ -0,0 +1,26 @@
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
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
// 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()
}
}
}
@@ -0,0 +1,781 @@
package com.scarriffle.calendarr.data
import com.scarriffle.calendarr.data.remote.ApiException
import com.scarriffle.calendarr.data.remote.OidcException
import com.scarriffle.calendarr.data.remote.ApiProvider
import com.scarriffle.calendarr.data.remote.TwoFactorRequiredException
import com.scarriffle.calendarr.data.remote.UnauthorizedException
import com.scarriffle.calendarr.data.remote.ensureSuccess
import com.scarriffle.calendarr.data.remote.errorDetail
import com.scarriffle.calendarr.data.remote.jsonBody
import com.scarriffle.calendarr.domain.model.AppSettings
import com.scarriffle.calendarr.domain.model.CalDAVAccount
import com.scarriffle.calendarr.domain.model.CalEvent
import com.scarriffle.calendarr.domain.model.EventAttachment
import com.scarriffle.calendarr.domain.model.CalendarShareEntry
import com.scarriffle.calendarr.domain.model.DirectoryUser
import com.scarriffle.calendarr.domain.model.Group
import com.scarriffle.calendarr.domain.model.GroupMember
import com.scarriffle.calendarr.domain.model.GoogleAccount
import com.scarriffle.calendarr.domain.model.HomeAssistantAccount
import com.scarriffle.calendarr.domain.model.ICalSubscription
import com.scarriffle.calendarr.domain.model.LocalCalendar
import com.scarriffle.calendarr.domain.model.OidcProvider
import com.scarriffle.calendarr.domain.model.UserProfile
import com.scarriffle.calendarr.domain.model.WritableCalendar
import com.scarriffle.calendarr.util.Dates
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
import retrofit2.HttpException
import java.time.Instant
import javax.inject.Inject
import javax.inject.Singleton
data class LoginResult(val token: String, val username: String, val isAdmin: Boolean)
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`
* message, and parses the mixed-type event payloads with org.json.
*/
@Singleton
class CalendarRepository @Inject constructor(
private val apiProvider: ApiProvider,
private val credentialStore: CredentialStore,
private val settingsStore: SettingsStore,
) {
private val api get() = apiProvider.api()
/** Current logged-in user id (0 if unknown) — for creator/owner comparisons. */
val currentUserId: Int get() = credentialStore.userId
private suspend fun <T> guarded(block: suspend () -> T): T = withContext(Dispatchers.IO) {
try {
block()
} catch (e: HttpException) {
if (e.code() == 401) throw UnauthorizedException()
throw ApiException(errorDetail(e.response()?.errorBody(), e.code()))
}
}
// ---- Auth ----
suspend fun setupRequired(baseUrl: String): Boolean = withContext(Dispatchers.IO) {
val resp = apiProvider.apiFor(baseUrl).setupRequired()
if (!resp.isSuccessful) return@withContext false
val raw = resp.body()?.string() ?: return@withContext false
runCatching { JSONObject(raw).optBoolean("required", false) }.getOrDefault(false)
}
suspend fun login(
baseUrl: String,
username: String,
password: String,
totpCode: String?,
rememberMe: Boolean,
): LoginResult = withContext(Dispatchers.IO) {
val body = jsonBody(
"username" to username,
"password" to password,
"remember_me" to rememberMe,
"totp_code" to totpCode,
)
val resp = apiProvider.apiFor(baseUrl).login(body)
if (resp.code() == 401) {
val detail = runCatching {
JSONObject(resp.errorBody()?.string() ?: "").optString("detail")
}.getOrNull()
if (detail == "2fa_required") throw TwoFactorRequiredException()
throw UnauthorizedException(detail?.takeIf { it.isNotBlank() } ?: "Benutzername oder Passwort falsch")
}
if (!resp.isSuccessful) throw ApiException(errorDetail(resp.errorBody(), resp.code()))
val json = JSONObject(resp.body()?.string() ?: throw ApiException("Leere Antwort"))
persistLogin(baseUrl, json, fallbackUsername = username)
}
// ---- Single sign-on (OpenID Connect) ----
/** Providers configured on the server. Empty list when SSO is off. */
suspend fun oidcProviders(baseUrl: String): List<OidcProvider> = withContext(Dispatchers.IO) {
val resp = apiProvider.apiFor(baseUrl).oidcProviders()
if (!resp.isSuccessful) return@withContext emptyList()
val raw = resp.body()?.string() ?: return@withContext emptyList()
runCatching {
val json = JSONObject(raw)
if (!json.optBoolean("enabled", false)) return@runCatching emptyList<OidcProvider>()
val arr = json.optJSONArray("providers") ?: return@runCatching emptyList<OidcProvider>()
(0 until arr.length()).mapNotNull { i ->
val o = arr.optJSONObject(i) ?: return@mapNotNull null
// Without a mobile client id the app cannot start a flow.
val clientId = o.optString("mobile_client_id").takeIf { it.isNotBlank() }
?: return@mapNotNull null
// Without an issuer the flow can only fail later with a
// misleading "unreachable" — drop the provider here instead.
val issuer = o.optString("issuer").takeIf { it.isNotBlank() }
?: return@mapNotNull null
OidcProvider(
key = o.optString("key"),
name = o.optString("name").takeIf { it.isNotBlank() } ?: o.optString("key"),
issuer = issuer,
// The server decides the scopes (it knows whether the
// provider grants offline_access); the app never adds any.
scopes = o.optString("mobile_scopes").takeIf { it.isNotBlank() }
?: o.optString("scopes").takeIf { it.isNotBlank() }
?: "openid profile email",
mobileClientId = clientId,
)
}
}.getOrDefault(emptyList())
}
/**
* Trade a verified provider ID token for a Calendarr token.
*
* The app is a public client: the request carries no secret, only the id
* token the provider issued for [clientId] plus the [nonce] AppAuth bound
* into the flow.
*/
suspend fun exchangeOidc(
baseUrl: String,
provider: String,
clientId: String,
idToken: String,
accessToken: String?,
nonce: String?,
): LoginResult = withContext(Dispatchers.IO) {
val body = jsonBody(
"provider" to provider,
"client_id" to clientId,
"id_token" to idToken,
"access_token" to accessToken,
"nonce" to nonce,
)
val resp = apiProvider.apiFor(baseUrl).oidcExchange(body)
if (!resp.isSuccessful) {
// The server answers with a stable slug in `detail`; map the ones a
// user can act on, and fall back to the raw detail otherwise.
val detail = runCatching {
JSONObject(resp.errorBody()?.string() ?: "").optString("detail")
}.getOrNull().orEmpty()
throw OidcException(detail.takeIf { it.isNotBlank() } ?: "oidc_failed")
}
val json = JSONObject(resp.body()?.string() ?: throw ApiException("Leere Antwort"))
persistLogin(baseUrl, json, fallbackUsername = null)
}
/** Shared tail of [login] and [exchangeOidc]: read the user, persist, reset. */
private fun persistLogin(baseUrl: String, json: JSONObject, fallbackUsername: String?): LoginResult {
val token = json.optString("access_token").takeIf { it.isNotBlank() }
?: throw ApiException("Antwort konnte nicht verarbeitet werden")
val user = json.optJSONObject("user")
// A blank username would be persisted as a blank display name too, so
// refuse rather than store a nameless identity.
val uname = user?.optString("username")?.takeIf { it.isNotBlank() }
?: fallbackUsername
?: throw ApiException("Antwort konnte nicht verarbeitet werden")
val isAdmin = user?.optBoolean("is_admin", false) ?: false
val uid = user?.optInt("id", 0) ?: 0
val displayName = user?.optString("display_name")?.takeIf { it.isNotBlank() } ?: uname
credentialStore.serverUrl = ApiProvider.normalize(baseUrl)
credentialStore.saveLogin(token, uname, isAdmin, uid, displayName)
apiProvider.invalidate()
return LoginResult(token, uname, isAdmin)
}
// ---- Settings / profile ----
suspend fun getSettings(): AppSettings = guarded { api.getSettings() }
/** 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() }
suspend fun updateEmail(email: String) = guarded {
api.updateEmail(jsonBody("email" to email)).ensureSuccess()
}
suspend fun changePassword(current: String, new: String) = guarded {
api.changePassword(
jsonBody("current_password" to current, "new_password" to new)
).ensureSuccess()
}
suspend fun setup2fa(): TotpSetup = guarded {
val resp = api.setup2fa()
resp.ensureSuccess()
val json = JSONObject(resp.body()?.string() ?: "{}")
TotpSetup(json.optString("secret"), json.optString("qr_url"))
}
suspend fun enable2fa(code: String) = guarded {
api.enable2fa(jsonBody("code" to code)).ensureSuccess()
}
suspend fun disable2fa(password: String) = guarded {
api.disable2fa(jsonBody("password" to password)).ensureSuccess()
}
// ---- Accounts ----
suspend fun getCalDAVAccounts(): List<CalDAVAccount> = guarded { api.getCalDAVAccounts() }
suspend fun addCalDAVAccount(name: String, url: String, username: String, password: String, color: String) =
guarded {
api.addCalDAVAccount(
jsonBody(
"name" to name, "url" to url, "username" to username,
"password" to password, "color" to color,
)
)
}
suspend fun deleteCalDAVAccount(id: Int) = guarded { api.deleteCalDAVAccount(id).ensureSuccess() }
suspend fun getLocalCalendars(): List<LocalCalendar> = guarded { api.getLocalCalendars() }
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() }
suspend fun getICalSubscriptions(): List<ICalSubscription> = guarded { api.getICalSubscriptions() }
suspend fun addICalSubscription(name: String, url: String, color: String, refreshMinutes: Int) =
guarded {
api.addICalSubscription(
jsonBody(
"name" to name, "url" to url, "color" to color,
"refresh_minutes" to refreshMinutes,
)
)
}
suspend fun deleteICalSubscription(id: Int) = guarded { api.deleteICalSubscription(id).ensureSuccess() }
suspend fun getGoogleAccounts(): List<GoogleAccount> = guarded { api.getGoogleAccounts() }
suspend fun deleteGoogleAccount(id: Int) = guarded { api.deleteGoogleAccount(id).ensureSuccess() }
suspend fun getHomeAssistantAccounts(): List<HomeAssistantAccount> =
guarded { api.getHomeAssistantAccounts() }
suspend fun addHomeAssistantAccount(name: String, url: String, token: String) =
guarded {
api.addHomeAssistantAccount(
jsonBody("name" to name, "url" to url, "token" to token, "auth_method" to "token")
)
}
suspend fun deleteHomeAssistantAccount(id: Int) =
guarded { api.deleteHomeAssistantAccount(id).ensureSuccess() }
/** 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.setLocalCalendarColor(id, jsonBody("color" to color)).ensureSuccess()
}
/** Change an iCal subscription's colour. */
suspend fun updateICalColor(id: Int, color: String) = guarded {
api.updateICalSubscription(id, jsonBody("color" to color)).ensureSuccess()
}
/** Set a per-calendar colour for server-managed sources (caldav/google/homeassistant). */
suspend fun setCalendarColor(source: String, calendarId: Int, color: String) = guarded {
val body = jsonBody("color" to color)
when (source) {
"caldav" -> api.updateCalDAVCalendar(calendarId, body).ensureSuccess()
"google" -> api.updateGoogleCalendar(calendarId, body).ensureSuccess()
"homeassistant" -> api.updateHACalendar(calendarId, body).ensureSuccess()
else -> Unit
}
}
/** Toggle a calendar's server-side visibility (caldav/google/homeassistant only). */
suspend fun setCalendarSidebarHidden(source: String, calendarId: Int, hidden: Boolean) = guarded {
val body = jsonBody("enabled" to !hidden, "sidebar_hidden" to hidden)
when (source) {
"caldav" -> api.updateCalDAVCalendar(calendarId, body).ensureSuccess()
"google" -> api.updateGoogleCalendar(calendarId, body).ensureSuccess()
"homeassistant" -> api.updateHACalendar(calendarId, body).ensureSuccess()
else -> Unit
}
}
/** 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())
// 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 ->
acc.calendars.orEmpty().filter { it.enabled }.forEach { cal ->
result += WritableCalendar(
"caldav-${cal.id}", "${acc.name} – ${cal.name}",
cal.color ?: acc.color, "caldav", cal.id,
)
}
}
runCatching { api.getGoogleAccounts() }.getOrDefault(emptyList()).forEach { acc ->
acc.calendars.orEmpty().filter { it.enabled }.forEach { cal ->
result += WritableCalendar(
"google-${cal.id}", "${acc.email} – ${cal.name}",
cal.color ?: "#4285f4", "google", cal.id,
)
}
}
runCatching { api.getHomeAssistantAccounts() }.getOrDefault(emptyList()).forEach { acc ->
acc.calendars.orEmpty().filter { it.enabled }.forEach { cal ->
result += WritableCalendar(
"ha-${cal.id}", "${acc.name} – ${cal.name}",
cal.color ?: "#46bdc6", "homeassistant", cal.id,
)
}
}
result
}
// ---- Events ----
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 EventsResult(emptyList(), emptyList())
val root = JSONObject(raw)
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, reminders: List<Int>? = null,
rrule: String? = null, birthYear: Int? = null, externalUid: String? = null,
): String? = guarded {
val resp = api.createLocalEvent(eventBody(calendarId, title, start, end, isAllDay, location, description, color, isPrivate, reminders, rrule, birthYear, externalUid))
resp.ensureSuccess()
// The response is the full event dict; its "id" is the uid attachments
// are addressed by. Callers that don't need it simply ignore it.
runCatching { JSONObject(resp.body()?.string() ?: "{}").optString("id").ifBlank { null } }
.getOrNull()
}
suspend fun updateLocalEvent(
uid: String, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String, color: String?,
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, reminders, rrule, birthYear, externalUid))
.ensureSuccess()
}
// ---- Sharing ----
suspend fun getUserDirectory(): List<DirectoryUser> = guarded {
val resp = api.getUserDirectory()
resp.ensureSuccess()
val arr = org.json.JSONArray(resp.body()?.string() ?: "[]")
buildList {
for (i in 0 until arr.length()) {
val o = arr.optJSONObject(i) ?: continue
add(DirectoryUser(o.optInt("id"), o.optString("display_name")))
}
}
}
suspend fun getShares(calendarId: Int): List<CalendarShareEntry> = guarded {
val resp = api.getShares(calendarId)
resp.ensureSuccess()
val arr = org.json.JSONArray(resp.body()?.string() ?: "[]")
buildList {
for (i in 0 until arr.length()) {
val o = arr.optJSONObject(i) ?: continue
add(CalendarShareEntry(o.optInt("user_id"), o.optString("display_name"), o.optString("permission")))
}
}
}
suspend fun addShare(calendarId: Int, userId: Int, permission: String) = guarded {
api.addShare(calendarId, jsonBody("user_id" to userId, "permission" to permission)).ensureSuccess()
}
suspend fun removeShare(calendarId: Int, userId: Int) = guarded {
api.removeShare(calendarId, userId).ensureSuccess()
}
// ---- iCal import/export ----
suspend fun importIcs(calendarId: Int, part: okhttp3.MultipartBody.Part): Triple<Int, Int, List<String>> = guarded {
val resp = api.importCalendar(calendarId, part)
resp.ensureSuccess()
val o = JSONObject(resp.body()?.string() ?: "{}")
val errors = o.optJSONArray("errors")
val errList = buildList<String> { if (errors != null) for (i in 0 until errors.length()) add(errors.optString(i)) }
Triple(o.optInt("imported"), o.optInt("skipped"), errList)
}
/** Build the multipart body from raw bytes and import (server form field: "file"). */
suspend fun importIcsFile(calendarId: Int, bytes: ByteArray, filename: String): Triple<Int, Int, List<String>> {
val body = bytes.toRequestBody("text/calendar".toMediaTypeOrNull())
val part = okhttp3.MultipartBody.Part.createFormData("file", filename, body)
return importIcs(calendarId, part)
}
// ---- Attachments ----
suspend fun listAttachments(uid: String): List<EventAttachment> = guarded {
val resp = api.listAttachments(uid)
resp.ensureSuccess()
val arr = JSONArray(resp.body()?.string() ?: "[]")
buildList {
for (i in 0 until arr.length()) {
arr.optJSONObject(i)?.let { EventAttachment.fromJson(it)?.let(::add) }
}
}
}
/** Upload one file. The server sniffs the bytes, so the media type here is
* only about forming a valid multipart part. */
suspend fun uploadAttachment(uid: String, bytes: ByteArray, filename: String,
mimeType: String?): EventAttachment = guarded {
val body = bytes.toRequestBody((mimeType ?: "application/octet-stream").toMediaTypeOrNull())
val part = okhttp3.MultipartBody.Part.createFormData("file", filename, body)
val resp = api.uploadAttachment(uid, part)
resp.ensureSuccess()
EventAttachment.fromJson(JSONObject(resp.body()?.string() ?: "{}"))
?: error("Unerwartete Antwort")
}
suspend fun downloadAttachment(id: Int): ByteArray = guarded {
val resp = api.downloadAttachment(id)
resp.ensureSuccess()
resp.body()?.bytes() ?: ByteArray(0)
}
/** Thumbnail bytes, or null when there is none — a missing preview just
* means the row shows its type icon. */
suspend fun attachmentThumbnail(id: Int): ByteArray? = try {
val resp = api.attachmentThumbnail(id)
if (resp.isSuccessful) resp.body()?.bytes() else null
} catch (e: Exception) {
null
}
suspend fun deleteAttachment(id: Int): Unit = guarded {
api.deleteAttachment(id).ensureSuccess()
}
suspend fun exportIcs(calendarId: Int): ByteArray = guarded {
val resp = api.exportCalendar(calendarId)
resp.ensureSuccess()
resp.body()?.bytes() ?: ByteArray(0)
}
// ---- Groups ----
suspend fun getGroups(): List<Group> = guarded {
val resp = api.getGroups()
resp.ensureSuccess()
val arr = org.json.JSONArray(resp.body()?.string() ?: "[]")
buildList {
for (i in 0 until arr.length()) {
val o = arr.optJSONObject(i) ?: continue
add(Group(
id = o.optInt("id"),
name = o.optString("name"),
icon = if (o.isNull("icon")) null else o.optString("icon").ifBlank { null },
role = o.optString("role", "member"),
memberCount = o.optInt("member_count", 0),
groupCalendarId = if (o.isNull("group_calendar_id")) null else o.optInt("group_calendar_id"),
groupCalendarColor = if (o.isNull("group_calendar_color")) null else o.optString("group_calendar_color").ifBlank { null },
members = emptyList(),
))
}
}
}
suspend fun getGroup(id: Int): Group = guarded {
val resp = api.getGroup(id)
resp.ensureSuccess()
val o = JSONObject(resp.body()?.string() ?: "{}")
val membersArr = o.optJSONArray("members")
val members = buildList<GroupMember> {
if (membersArr != null) for (i in 0 until membersArr.length()) {
val m = membersArr.optJSONObject(i) ?: continue
add(GroupMember(
m.optInt("id"), m.optString("display_name"), m.optString("role", "member"),
if (m.isNull("color")) null else m.optString("color").ifBlank { null },
))
}
}
Group(
id = o.optInt("id"),
name = o.optString("name"),
icon = if (o.isNull("icon")) null else o.optString("icon").ifBlank { null },
role = "",
memberCount = members.size,
groupCalendarId = if (o.isNull("group_calendar_id")) null else o.optInt("group_calendar_id"),
groupCalendarColor = if (o.isNull("group_calendar_color")) null else o.optString("group_calendar_color").ifBlank { null },
members = members,
)
}
suspend fun createGroup(name: String, memberIds: List<Int>, icon: String?) = guarded {
val arr = org.json.JSONArray()
memberIds.forEach { arr.put(it) }
api.createGroup(jsonBody("name" to name, "member_ids" to arr, "icon" to icon)).ensureSuccess()
}
suspend fun updateGroup(id: Int, name: String?, icon: String?) = guarded {
api.updateGroup(id, jsonBody("name" to name, "icon" to icon)).ensureSuccess()
}
suspend fun addGroupMember(groupId: Int, userId: Int) = guarded {
api.addGroupMember(groupId, jsonBody("user_id" to userId)).ensureSuccess()
}
suspend fun removeGroupMember(groupId: Int, userId: Int) = guarded {
api.removeGroupMember(groupId, userId).ensureSuccess()
}
suspend fun setGroupMemberColor(groupId: Int, userId: Int, color: String) = guarded {
api.setGroupMemberColor(groupId, userId, jsonBody("color" to color)).ensureSuccess()
}
suspend fun deleteGroup(id: Int) = guarded { api.deleteGroup(id).ensureSuccess() }
// ---- Profile & targeted settings ----
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()
}
suspend fun updatePrivateVisibility(value: String) = guarded {
api.updateSettings(jsonBody("private_event_visibility" to value)).ensureSuccess()
}
suspend fun updateGroupVisibleCalendar(calendarId: Int?) = guarded {
// Send explicit JSON null to clear (jsonBody drops Kotlin nulls).
api.updateSettings(jsonBody("group_visible_calendar_id" to (calendarId ?: org.json.JSONObject.NULL)))
.ensureSuccess()
}
suspend fun fetchGroupCombined(groupId: Int, start: Instant, end: Instant): List<CalEvent> = withContext(Dispatchers.IO) {
val resp = api.fetchGroupCombined(groupId, Dates.isoUtc(start), Dates.isoUtc(end))
resp.ensureSuccess()
val root = JSONObject(resp.body()?.string() ?: "{}")
val arr = root.optJSONArray("events") ?: return@withContext emptyList()
buildList {
for (i in 0 until arr.length()) {
val obj = arr.optJSONObject(i) ?: continue
CalEvent.fromJson(obj)?.let { add(it) }
}
}
}
suspend fun deleteLocalEvent(uid: String) = guarded { api.deleteLocalEvent(uid).ensureSuccess() }
suspend fun createCalDAVEvent(
calendarId: Int, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String, color: String?,
) = guarded {
api.createCalDAVEvent(eventBody(calendarId, title, start, end, isAllDay, location, description, color))
.ensureSuccess()
}
suspend fun updateCalDAVEvent(
uid: String, url: String, calendarId: Int?, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String, color: String?,
) = guarded {
api.updateCalDAVEvent(
uid, url, calendarId,
eventBody(null, title, start, end, isAllDay, location, description, color),
).ensureSuccess()
}
suspend fun deleteCalDAVEvent(uid: String, url: String, calendarId: Int?) =
guarded { api.deleteCalDAVEvent(uid, url, calendarId).ensureSuccess() }
suspend fun createGoogleEvent(
calendarDbId: Int, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String,
) = guarded {
api.createGoogleEvent(simpleEventBody("calendar_db_id", calendarDbId, title, start, end, isAllDay, location, description))
.ensureSuccess()
}
suspend fun updateGoogleEvent(
gcalDbId: Int, eventId: String, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String,
) = guarded {
api.updateGoogleEvent(gcalDbId, eventId, simpleEventBody(null, null, title, start, end, isAllDay, location, description))
.ensureSuccess()
}
suspend fun deleteGoogleEvent(gcalDbId: Int, eventId: String) =
guarded { api.deleteGoogleEvent(gcalDbId, eventId).ensureSuccess() }
suspend fun createHAEvent(
calendarId: Int, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String,
) = guarded {
api.createHAEvent(simpleEventBody("calendar_id", calendarId, title, start, end, isAllDay, location, description))
.ensureSuccess()
}
suspend fun updateHAEvent(
calendarId: Int, uid: String, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String,
) = guarded {
api.updateHAEvent(calendarId, uid, simpleEventBody(null, null, title, start, end, isAllDay, location, description))
.ensureSuccess()
}
suspend fun deleteHAEvent(calendarId: Int, uid: String) =
guarded { api.deleteHAEvent(calendarId, uid).ensureSuccess() }
/** Body for Google/HA events (no per-event colour). */
private fun simpleEventBody(
calKey: String?, calId: Int?, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String,
) = jsonBody(
buildMap {
if (calKey != null && calId != null) put(calKey, calId)
put("title", title)
put("start", Dates.format(start, isAllDay))
put("end", Dates.format(end, isAllDay))
put("allDay", isAllDay)
put("location", location)
put("description", description)
}
)
private fun eventBody(
calendarId: Int?, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String, color: String?,
isPrivate: Boolean = false, reminders: List<Int>? = null,
rrule: String? = null, birthYear: Int? = null, externalUid: String? = null,
) = jsonBody(
buildMap {
calendarId?.let { put("calendar_id", it) }
put("title", title)
put("start", Dates.format(start, isAllDay))
put("end", Dates.format(end, isAllDay))
put("allDay", isAllDay)
put("location", location)
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()
}
}
@@ -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
}
}
@@ -0,0 +1,128 @@
package com.scarriffle.calendarr.data
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* Securely stores the server URL and auth token in EncryptedSharedPreferences
* (Android's equivalent of the iOS Keychain). The iOS app keeps these in
* UserDefaults; on Android we encrypt them at rest.
*/
@Singleton
class CredentialStore @Inject constructor(
@ApplicationContext context: Context,
) {
private val prefs: SharedPreferences = run {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
"calendarr_secure_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}
var serverUrl: String?
get() = prefs.getString(KEY_SERVER_URL, null)
set(value) = prefs.edit().putString(KEY_SERVER_URL, value).apply()
var token: String?
get() = prefs.getString(KEY_TOKEN, null)
set(value) = prefs.edit().putString(KEY_TOKEN, value).apply()
var username: String?
get() = prefs.getString(KEY_USERNAME, null)
set(value) = prefs.edit().putString(KEY_USERNAME, value).apply()
var isAdmin: Boolean
get() = prefs.getBoolean(KEY_IS_ADMIN, false)
set(value) = prefs.edit().putBoolean(KEY_IS_ADMIN, value).apply()
var userId: Int
get() = prefs.getInt(KEY_USER_ID, 0)
set(value) = prefs.edit().putInt(KEY_USER_ID, value).apply()
var displayName: String?
get() = prefs.getString(KEY_DISPLAY_NAME, null)
set(value) = prefs.edit().putString(KEY_DISPLAY_NAME, value).apply()
/**
* Serialised AppAuth [net.openid.appauth.AuthState] for the SSO session —
* holds the provider's refresh token, so it must stay in the encrypted
* store and never in plain preferences.
*/
var oidcState: String?
get() = prefs.getString(KEY_OIDC_STATE, null)
set(value) = prefs.edit().putString(KEY_OIDC_STATE, value).apply()
/** Provider key and client id the current SSO session was obtained with. */
var oidcProvider: String?
get() = prefs.getString(KEY_OIDC_PROVIDER, null)
set(value) = prefs.edit().putString(KEY_OIDC_PROVIDER, value).apply()
var oidcClientId: String?
get() = prefs.getString(KEY_OIDC_CLIENT_ID, null)
set(value) = prefs.edit().putString(KEY_OIDC_CLIENT_ID, value).apply()
/** True once a server URL has been entered (setup step complete). */
val isConfigured: Boolean get() = !serverUrl.isNullOrBlank()
/** True once we hold an auth token (logged in). */
val isLoggedIn: Boolean get() = !token.isNullOrBlank()
fun saveLogin(token: String, username: String, isAdmin: Boolean, userId: Int = 0, displayName: String? = null) {
prefs.edit()
.putString(KEY_TOKEN, token)
.putString(KEY_USERNAME, username)
.putBoolean(KEY_IS_ADMIN, isAdmin)
.putInt(KEY_USER_ID, userId)
.putString(KEY_DISPLAY_NAME, displayName ?: username)
.apply()
}
/** Persist the SSO session alongside the Calendarr token. */
fun saveOidcSession(state: String, provider: String, clientId: String) {
prefs.edit()
.putString(KEY_OIDC_STATE, state)
.putString(KEY_OIDC_PROVIDER, provider)
.putString(KEY_OIDC_CLIENT_ID, clientId)
.apply()
}
/** Clear the token (logout) but keep the server URL. */
fun clearToken() {
prefs.edit()
.remove(KEY_TOKEN)
.remove(KEY_USERNAME)
.remove(KEY_IS_ADMIN)
.remove(KEY_OIDC_STATE)
.remove(KEY_OIDC_PROVIDER)
.remove(KEY_OIDC_CLIENT_ID)
.apply()
}
/** Full reset, including the server URL ("switch server"). */
fun clearAll() {
prefs.edit().clear().apply()
}
private companion object {
const val KEY_SERVER_URL = "server_url"
const val KEY_TOKEN = "auth_token"
const val KEY_USERNAME = "username"
const val KEY_IS_ADMIN = "is_admin"
const val KEY_USER_ID = "user_id"
const val KEY_DISPLAY_NAME = "display_name"
const val KEY_OIDC_STATE = "oidc_state"
const val KEY_OIDC_PROVIDER = "oidc_provider"
const val KEY_OIDC_CLIENT_ID = "oidc_client_id"
}
}
@@ -0,0 +1,160 @@
package com.scarriffle.calendarr.data
import android.content.Context
import android.content.Intent
import com.scarriffle.calendarr.domain.model.OidcProvider
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.suspendCancellableCoroutine
import net.openid.appauth.AuthState
import net.openid.appauth.AuthorizationException
import net.openid.appauth.AuthorizationRequest
import net.openid.appauth.AuthorizationResponse
import net.openid.appauth.AuthorizationService
import net.openid.appauth.AuthorizationServiceConfiguration
import net.openid.appauth.ResponseTypeValues
import net.openid.appauth.TokenResponse
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
/** The app is registered as a *public* client — no secret is ever sent. */
private const val REDIRECT_URI = "com.scarriffle.calendarr:/oauth2redirect"
/** Outcome of a silent renewal against the identity provider. */
data class RenewedTokens(
val idToken: String,
val accessToken: String?,
val nonce: String?,
val serialisedState: String,
)
/**
* Drives the OpenID Connect Authorization Code Flow with PKCE against the
* identity provider, using AppAuth and a Chrome Custom Tab.
*
* Deliberately never uses a WebView: a custom tab keeps the credentials in the
* browser's own security context and lets the provider's session cookies work.
*/
@Singleton
class OidcAuthenticator @Inject constructor(
@ApplicationContext private val context: Context,
) {
@Volatile
private var service: AuthorizationService? = null
private fun service(): AuthorizationService =
service ?: synchronized(this) {
service ?: AuthorizationService(context).also { service = it }
}
/** Fetch the provider's OpenID configuration from its issuer URL. */
suspend fun discover(provider: OidcProvider): AuthorizationServiceConfiguration =
suspendCancellableCoroutine { cont ->
AuthorizationServiceConfiguration.fetchFromIssuer(
android.net.Uri.parse(provider.issuer)
) { config, ex ->
when {
config != null -> cont.resume(config)
else -> cont.resumeWithException(ex ?: AuthorizationException.GeneralErrors.NETWORK_ERROR)
}
}
}
/**
* Build the authorization request. AppAuth generates the PKCE verifier and
* the nonce itself; both are carried on the returned request, and the nonce
* has to travel to our server later so it can verify the ID token.
*/
fun buildRequest(
config: AuthorizationServiceConfiguration,
provider: OidcProvider,
): AuthorizationRequest {
// Exactly what the server asked for — including offline_access when it
// configured that. A scope the provider does not know would make it
// reject the whole authorization request.
val scopes = buildSet {
addAll(provider.scopes.split(" ").filter { it.isNotBlank() })
add("openid")
}
return AuthorizationRequest.Builder(
config,
requireNotNull(provider.mobileClientId) { "provider has no mobile client id" },
ResponseTypeValues.CODE,
android.net.Uri.parse(REDIRECT_URI),
)
.setScopes(scopes)
.build()
}
/** Intent that opens the provider's login page in a Custom Tab. */
fun authorizationIntent(request: AuthorizationRequest): Intent =
service().getAuthorizationRequestIntent(request)
/**
* Complete the code exchange with the provider.
*
* PKCE means no client secret is involved: the verifier AppAuth kept from
* [buildRequest] is what proves this is the same app that started the flow.
*/
suspend fun exchangeCode(data: Intent): Pair<AuthorizationResponse, TokenResponse> {
val response = AuthorizationResponse.fromIntent(data)
val error = AuthorizationException.fromIntent(data)
if (response == null) throw error ?: AuthorizationException.GeneralErrors.NETWORK_ERROR
val tokens: TokenResponse = suspendCancellableCoroutine { cont ->
service().performTokenRequest(response.createTokenExchangeRequest()) { tokens, ex ->
when {
tokens != null -> cont.resume(tokens)
else -> cont.resumeWithException(ex ?: AuthorizationException.GeneralErrors.NETWORK_ERROR)
}
}
}
return response to tokens
}
/**
* Ask the provider for a fresh ID token using a stored [AuthState].
*
* Returns null when there is no session to renew. The refreshed state is
* handed back too, because the provider may have rotated the refresh token.
*/
suspend fun renewIdToken(serialisedState: String): RenewedTokens? {
val state = runCatching { AuthState.jsonDeserialize(serialisedState) }.getOrNull()
?: return null
val tokens: Pair<String?, String?> = suspendCancellableCoroutine { cont ->
state.performActionWithFreshTokens(service()) { accessToken, idToken, ex ->
if (ex != null) {
cont.resumeWithException(ex)
} else {
cont.resume(idToken to accessToken)
}
}
}
val idToken = tokens.first ?: return null
return RenewedTokens(
idToken = idToken,
accessToken = tokens.second,
// The original request rides along inside the state, so the nonce
// survives. A refreshed token often drops the claim, which the
// server tolerates — but when it keeps it, it has to match.
nonce = state.lastAuthorizationResponse?.request?.nonce,
serialisedState = state.jsonSerializeString(),
)
}
/**
* Serialised AuthState (including the refresh token) for secure storage.
*
* Both arguments are non-null on purpose: `AuthState.update` asserts that
* exactly one of (response, exception) is set and throws otherwise.
*/
fun stateFor(response: AuthorizationResponse, tokens: TokenResponse): String =
AuthState().apply {
update(response, null)
update(tokens, null)
}.jsonSerializeString()
}
@@ -0,0 +1,243 @@
package com.scarriffle.calendarr.data
import android.content.Context
import android.content.SharedPreferences
import com.scarriffle.calendarr.domain.model.AppSettings
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* Local persistence for cached appearance settings and the per-device
* calendar visibility sets (hidden / banished). Mirrors the UserDefaults
* usage in the iOS `CalendarStore`.
*/
@Singleton
class SettingsStore @Inject constructor(
@ApplicationContext context: Context,
) {
private val prefs: SharedPreferences =
context.getSharedPreferences("calendarr_prefs", Context.MODE_PRIVATE)
// --- Appearance settings cache (mirror of the server) ---
fun loadSettings(): AppSettings = AppSettings(
defaultView = prefs.getString(K_DEFAULT_VIEW, null) ?: "month",
weekStartDay = prefs.getString(K_WEEK_START, null) ?: "monday",
primaryColor = prefs.getString(K_PRIMARY, null) ?: "#4285f4",
accentColor = prefs.getString(K_ACCENT, null) ?: "#ea4335",
todayColor = prefs.getString(K_TODAY, null) ?: "#4285f4",
dimPastEvents = prefs.getBoolean(K_DIM_PAST, false),
textContrast = prefs.getInt(K_TEXT_CONTRAST, 3),
lineContrast = prefs.getInt(K_LINE_CONTRAST, 3),
hourHeight = prefs.getInt(K_HOUR_HEIGHT, 60),
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) {
prefs.edit()
.putString(K_DEFAULT_VIEW, s.defaultView)
.putString(K_WEEK_START, s.weekStartDay)
.putString(K_PRIMARY, s.primaryColor)
.putString(K_ACCENT, s.accentColor)
.putString(K_TODAY, s.todayColor)
.putBoolean(K_DIM_PAST, s.dimPastEvents)
.putInt(K_TEXT_CONTRAST, s.textContrast)
.putInt(K_LINE_CONTRAST, s.lineContrast)
.putInt(K_HOUR_HEIGHT, s.hourHeight)
.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>
get() = prefs.getStringSet(K_HIDDEN, emptySet())?.toSet() ?: emptySet()
set(value) = prefs.edit().putStringSet(K_HIDDEN, value).apply()
// --- Banished calendars ("source:id") ---
var banishedCalendarKeys: Set<String>
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"
const val K_PRIMARY = "primary_color"
const val K_ACCENT = "accent_color"
const val K_TODAY = "today_color"
const val K_DIM_PAST = "dim_past_events"
const val K_TEXT_CONTRAST = "text_contrast"
const val K_LINE_CONTRAST = "line_contrast"
const val K_HOUR_HEIGHT = "hour_height"
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"
}
}
@@ -0,0 +1,42 @@
package com.scarriffle.calendarr.data.remote
import okhttp3.ResponseBody
import org.json.JSONObject
import retrofit2.Response
/** Generic server error carrying the `detail` message when available. */
open class ApiException(message: String) : Exception(message)
/** Credentials rejected (HTTP 401, not a 2FA prompt). Carries the server detail. */
class UnauthorizedException(message: String = "Benutzername oder Passwort falsch") : ApiException(message)
/** Server requires a TOTP code to finish login (HTTP 401, detail "2fa_required"). */
class TwoFactorRequiredException : ApiException("2FA-Code erforderlich")
/** Extract the `detail` field from an error response body, with a fallback. */
fun errorDetail(errorBody: ResponseBody?, status: Int): String {
val raw = runCatching { errorBody?.string() }.getOrNull()
if (!raw.isNullOrBlank()) {
runCatching {
val detail = JSONObject(raw).optString("detail")
if (detail.isNotBlank()) return detail
}
}
return "Fehler $status"
}
/** Throw an [ApiException] if the response is unsuccessful. */
fun Response<*>.ensureSuccess() {
if (!isSuccessful) {
if (code() == 401) throw UnauthorizedException()
throw ApiException(errorDetail(errorBody(), code()))
}
}
/**
* An SSO exchange the server refused. [slug] is the stable machine-readable
* reason (e.g. `oidc_account_not_linked`), which the UI maps to a translated
* message; unknown slugs fall back to a generic one.
*/
class OidcException(val slug: String) : Exception(slug)
@@ -0,0 +1,71 @@
package com.scarriffle.calendarr.data.remote
import com.scarriffle.calendarr.data.CredentialStore
import com.squareup.moshi.Moshi
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
/**
* Builds [CalendarrApi] instances. The base URL is dynamic (the user's server),
* so the cached instance is rebuilt whenever the stored server URL changes.
*/
@Singleton
class ApiProvider @Inject constructor(
private val credentialStore: CredentialStore,
private val moshi: Moshi,
private val authInterceptor: AuthInterceptor,
) {
private val client: OkHttpClient by lazy {
OkHttpClient.Builder()
.addInterceptor(authInterceptor)
.addInterceptor(
HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC }
)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.build()
}
@Volatile private var cached: Pair<String, CalendarrApi>? = null
/** API bound to the currently stored server URL. Requires a configured server. */
fun api(): CalendarrApi {
val base = normalize(credentialStore.serverUrl ?: error("No server URL configured"))
cached?.let { (url, api) -> if (url == base) return api }
val api = build(base)
cached = base to api
return api
}
/** API for an explicit base URL (used during server setup / login). */
fun apiFor(baseUrl: String): CalendarrApi = build(normalize(baseUrl))
/** Force the cached instance to be rebuilt (e.g. after switching servers). */
fun invalidate() { cached = null }
private fun build(baseUrl: String): CalendarrApi =
Retrofit.Builder()
.baseUrl(baseUrl)
.client(client)
.addConverterFactory(MoshiConverterFactory.create(moshi))
.build()
.create(CalendarrApi::class.java)
companion object {
/** Ensure https:// prefix, strip trailing slashes, then add exactly one. */
fun normalize(raw: String): String {
var url = raw.trim()
if (url.isEmpty()) return url
if (!url.startsWith("http://") && !url.startsWith("https://")) {
url = "https://$url"
}
url = url.trimEnd('/')
return "$url/"
}
}
}
@@ -0,0 +1,27 @@
package com.scarriffle.calendarr.data.remote
import com.scarriffle.calendarr.data.CredentialStore
import okhttp3.Interceptor
import okhttp3.Response
import javax.inject.Inject
/**
* Attaches the current bearer token (read dynamically from the
* [CredentialStore]) to every request. Requests made before login simply
* carry no Authorization header.
*/
class AuthInterceptor @Inject constructor(
private val credentialStore: CredentialStore,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val token = credentialStore.token
val request = if (!token.isNullOrBlank()) {
chain.request().newBuilder()
.header("Authorization", "Bearer $token")
.build()
} else {
chain.request()
}
return chain.proceed(request)
}
}
@@ -0,0 +1,312 @@
package com.scarriffle.calendarr.data.remote
import com.scarriffle.calendarr.domain.model.AppSettings
import com.scarriffle.calendarr.domain.model.CalDAVAccount
import com.scarriffle.calendarr.domain.model.GoogleAccount
import com.scarriffle.calendarr.domain.model.HomeAssistantAccount
import com.scarriffle.calendarr.domain.model.ICalSubscription
import com.scarriffle.calendarr.domain.model.LocalCalendar
import com.scarriffle.calendarr.domain.model.UserProfile
import okhttp3.MultipartBody
import okhttp3.RequestBody
import okhttp3.ResponseBody
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.HTTP
import retrofit2.http.Multipart
import retrofit2.http.PATCH
import retrofit2.http.POST
import retrofit2.http.Streaming
import retrofit2.http.PUT
import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Query
/**
* Retrofit definition of the Calendarr server HTTP API.
*
* Endpoints whose responses contain mixed-type fields (event `id` /
* `calendar_id` arrive as either String or Int) return a raw [ResponseBody]
* and are parsed manually with org.json, mirroring the iOS client.
*/
interface CalendarrApi {
// ---- Auth ----
@GET("api/auth/setup-required")
suspend fun setupRequired(): Response<ResponseBody>
/** Configured OIDC providers. Public — used before login. */
@GET("api/auth/oidc/providers")
suspend fun oidcProviders(): Response<ResponseBody>
/** Trade a provider ID token for a Calendarr token. Same response shape as
* [login]. Public client: never send a client secret. */
@POST("api/auth/oidc/exchange")
suspend fun oidcExchange(@Body body: RequestBody): Response<ResponseBody>
@POST("api/auth/login")
suspend fun login(@Body body: RequestBody): Response<ResponseBody>
@GET("api/auth/me")
suspend fun getProfile(): UserProfile
// ---- Settings ----
@GET("api/settings/")
suspend fun getSettings(): AppSettings
@PUT("api/settings/")
suspend fun updateSettings(@Body body: RequestBody): Response<ResponseBody>
@PUT("api/profile/")
suspend fun updateProfile(@Body body: RequestBody): Response<ResponseBody>
// ---- Sharing ----
@GET("api/users/directory")
suspend fun getUserDirectory(): Response<ResponseBody>
@GET("api/local/calendars/{id}/shares")
suspend fun getShares(@Path("id") id: Int): Response<ResponseBody>
@POST("api/local/calendars/{id}/shares")
suspend fun addShare(@Path("id") id: Int, @Body body: RequestBody): Response<ResponseBody>
@DELETE("api/local/calendars/{id}/shares/{userId}")
suspend fun removeShare(@Path("id") id: Int, @Path("userId") userId: Int): Response<ResponseBody>
// ---- iCal import/export ----
@Multipart
@POST("api/local/calendars/{id}/import")
suspend fun importCalendar(@Path("id") id: Int, @Part part: MultipartBody.Part): Response<ResponseBody>
@GET("api/local/calendars/{id}/export")
suspend fun exportCalendar(@Path("id") id: Int): Response<ResponseBody>
// ---- Attachments ----
// Like the event endpoints, these return the raw body and are parsed by
// hand, mirroring the iOS client.
@GET("api/local/events/{uid}/attachments")
suspend fun listAttachments(@Path("uid") uid: String): Response<ResponseBody>
@Multipart
@POST("api/local/events/{uid}/attachments")
suspend fun uploadAttachment(@Path("uid") uid: String,
@Part part: MultipartBody.Part): Response<ResponseBody>
@Streaming
@GET("api/local/attachments/{id}")
suspend fun downloadAttachment(@Path("id") id: Int): Response<ResponseBody>
@GET("api/local/attachments/{id}/thumb")
suspend fun attachmentThumbnail(@Path("id") id: Int): Response<ResponseBody>
@DELETE("api/local/attachments/{id}")
suspend fun deleteAttachment(@Path("id") id: Int): Response<ResponseBody>
// ---- Groups ----
@GET("api/groups/")
suspend fun getGroups(): Response<ResponseBody>
@GET("api/groups/{id}")
suspend fun getGroup(@Path("id") id: Int): Response<ResponseBody>
@POST("api/groups/")
suspend fun createGroup(@Body body: RequestBody): Response<ResponseBody>
@PUT("api/groups/{id}")
suspend fun updateGroup(@Path("id") id: Int, @Body body: RequestBody): Response<ResponseBody>
@DELETE("api/groups/{id}")
suspend fun deleteGroup(@Path("id") id: Int): Response<ResponseBody>
@POST("api/groups/{id}/members")
suspend fun addGroupMember(@Path("id") id: Int, @Body body: RequestBody): Response<ResponseBody>
@DELETE("api/groups/{id}/members/{userId}")
suspend fun removeGroupMember(@Path("id") id: Int, @Path("userId") userId: Int): Response<ResponseBody>
@PUT("api/groups/{id}/members/{userId}/color")
suspend fun setGroupMemberColor(@Path("id") id: Int, @Path("userId") userId: Int, @Body body: RequestBody): Response<ResponseBody>
@GET("api/groups/{id}/combined")
suspend fun fetchGroupCombined(
@Path("id") id: Int,
@Query("start") start: String,
@Query("end") end: String,
): Response<ResponseBody>
// ---- Profile ----
@PATCH("api/profile/")
suspend fun updateEmail(@Body body: RequestBody): Response<ResponseBody>
@POST("api/profile/password")
suspend fun changePassword(@Body body: RequestBody): Response<ResponseBody>
@POST("api/profile/2fa/setup")
suspend fun setup2fa(): Response<ResponseBody>
@POST("api/profile/2fa/enable")
suspend fun enable2fa(@Body body: RequestBody): Response<ResponseBody>
@POST("api/profile/2fa/disable")
suspend fun disable2fa(@Body body: RequestBody): Response<ResponseBody>
// ---- CalDAV ----
@GET("api/caldav/accounts")
suspend fun getCalDAVAccounts(): List<CalDAVAccount>
@POST("api/caldav/accounts")
suspend fun addCalDAVAccount(@Body body: RequestBody): CalDAVAccount
@DELETE("api/caldav/accounts/{id}")
suspend fun deleteCalDAVAccount(@Path("id") id: Int): Response<ResponseBody>
@POST("api/caldav/accounts/{id}/sync")
suspend fun syncCalDAVAccount(@Path("id") id: Int): Response<ResponseBody>
@PUT("api/caldav/calendars/{id}")
suspend fun updateCalDAVCalendar(@Path("id") id: Int, @Body body: RequestBody): Response<ResponseBody>
// ---- Local ----
@GET("api/local/calendars")
suspend fun getLocalCalendars(): List<LocalCalendar>
@POST("api/local/calendars")
suspend fun addLocalCalendar(@Body body: RequestBody): LocalCalendar
@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>
// ---- iCal subscriptions ----
@GET("api/ical/subscriptions")
suspend fun getICalSubscriptions(): List<ICalSubscription>
@POST("api/ical/subscriptions")
suspend fun addICalSubscription(@Body body: RequestBody): ICalSubscription
@PUT("api/ical/subscriptions/{id}")
suspend fun updateICalSubscription(@Path("id") id: Int, @Body body: RequestBody): Response<ResponseBody>
@DELETE("api/ical/subscriptions/{id}")
suspend fun deleteICalSubscription(@Path("id") id: Int): Response<ResponseBody>
@POST("api/ical/subscriptions/{id}/refresh")
suspend fun refreshICalSubscription(@Path("id") id: Int): Response<ResponseBody>
// ---- Google ----
@GET("api/google/accounts")
suspend fun getGoogleAccounts(): List<GoogleAccount>
@DELETE("api/google/accounts/{id}")
suspend fun deleteGoogleAccount(@Path("id") id: Int): Response<ResponseBody>
@PUT("api/google/calendars/{id}")
suspend fun updateGoogleCalendar(@Path("id") id: Int, @Body body: RequestBody): Response<ResponseBody>
// ---- Home Assistant ----
@GET("api/homeassistant/accounts")
suspend fun getHomeAssistantAccounts(): List<HomeAssistantAccount>
@POST("api/homeassistant/accounts")
suspend fun addHomeAssistantAccount(@Body body: RequestBody): HomeAssistantAccount
@DELETE("api/homeassistant/accounts/{id}")
suspend fun deleteHomeAssistantAccount(@Path("id") id: Int): Response<ResponseBody>
@PUT("api/homeassistant/calendars/{id}")
suspend fun updateHACalendar(@Path("id") id: Int, @Body body: RequestBody): Response<ResponseBody>
// ---- Events ----
@GET("api/caldav/events")
suspend fun fetchEvents(
@Query("start") start: String,
@Query("end") end: String,
): Response<ResponseBody>
@POST("api/local/events")
suspend fun createLocalEvent(@Body body: RequestBody): Response<ResponseBody>
@PUT("api/local/events/{uid}")
suspend fun updateLocalEvent(@Path("uid") uid: String, @Body body: RequestBody): Response<ResponseBody>
@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>
@PUT("api/caldav/events/{uid}")
suspend fun updateCalDAVEvent(
@Path("uid") uid: String,
@Query("event_url") eventUrl: String,
@Query("calendar_id") calendarId: Int?,
@Body body: RequestBody,
): Response<ResponseBody>
@HTTP(method = "DELETE", path = "api/caldav/events/{uid}", hasBody = false)
suspend fun deleteCalDAVEvent(
@Path("uid") uid: String,
@Query("event_url") eventUrl: String,
@Query("calendar_id") calendarId: Int?,
): Response<ResponseBody>
@POST("api/google/events")
suspend fun createGoogleEvent(@Body body: RequestBody): Response<ResponseBody>
@PUT("api/google/events/{gcalDbId}/{eventId}")
suspend fun updateGoogleEvent(
@Path("gcalDbId") gcalDbId: Int,
@Path("eventId") eventId: String,
@Body body: RequestBody,
): Response<ResponseBody>
@HTTP(method = "DELETE", path = "api/google/events/{gcalDbId}/{eventId}", hasBody = false)
suspend fun deleteGoogleEvent(
@Path("gcalDbId") gcalDbId: Int,
@Path("eventId") eventId: String,
): Response<ResponseBody>
@POST("api/homeassistant/events")
suspend fun createHAEvent(@Body body: RequestBody): Response<ResponseBody>
@PUT("api/homeassistant/events/{calendarId}/{uid}")
suspend fun updateHAEvent(
@Path("calendarId") calendarId: Int,
@Path("uid") uid: String,
@Body body: RequestBody,
): Response<ResponseBody>
@HTTP(method = "DELETE", path = "api/homeassistant/events/{calendarId}/{uid}", hasBody = false)
suspend fun deleteHAEvent(
@Path("calendarId") calendarId: Int,
@Path("uid") uid: String,
): Response<ResponseBody>
}
@@ -0,0 +1,26 @@
package com.scarriffle.calendarr.data.remote
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
private val JSON = "application/json; charset=utf-8".toMediaType()
/** Build a JSON request body from key/value pairs, dropping null values. */
fun jsonBody(vararg pairs: Pair<String, Any?>): RequestBody {
val obj = JSONObject()
for ((k, v) in pairs) {
if (v != null) obj.put(k, v)
}
return obj.toString().toRequestBody(JSON)
}
/** Build a JSON request body from a map, dropping null values. */
fun jsonBody(map: Map<String, Any?>): RequestBody {
val obj = JSONObject()
for ((k, v) in map) {
if (v != null) obj.put(k, v)
}
return obj.toString().toRequestBody(JSON)
}
@@ -0,0 +1,21 @@
package com.scarriffle.calendarr.di
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideMoshi(): Moshi =
Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()
}
@@ -0,0 +1,123 @@
package com.scarriffle.calendarr.domain.model
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = false)
data class CalDAVAccount(
val id: Int,
val name: String = "",
val url: String = "",
val username: String = "",
val color: String = "#4285f4",
val enabled: Boolean = true,
val calendars: List<CalDAVCalendar>? = null,
)
@JsonClass(generateAdapter = false)
data class CalDAVCalendar(
val id: Int,
val name: String = "",
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)
data class LocalCalendar(
val id: Int,
val name: String = "",
val color: String = "#34a853",
val enabled: Boolean = true,
val type: String = "local",
val owned: Boolean = true,
@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)
data class ICalSubscription(
val id: Int,
val name: String = "",
val url: String = "",
val color: String = "#46bdc6",
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)
data class GoogleAccount(
val id: Int,
val email: String = "",
val calendars: List<GoogleCalendar>? = null,
)
@JsonClass(generateAdapter = false)
data class GoogleCalendar(
val id: Int,
val name: String = "",
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)
data class HomeAssistantAccount(
val id: Int,
val name: String = "",
val url: String = "",
@Json(name = "auth_method") val authMethod: String = "token",
val calendars: List<HACalendar>? = null,
)
@JsonClass(generateAdapter = false)
data class HACalendar(
val id: Int,
val name: String = "",
@Json(name = "entity_id") val entityId: String = "",
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)
data class UserProfile(
val id: Int,
val username: String = "",
@Json(name = "display_name") val displayName: String? = null,
val email: String? = null,
@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). */
data class WritableCalendar(
val id: String,
val name: String,
val color: String,
val source: String,
val numericId: Int,
)
@@ -0,0 +1,44 @@
package com.scarriffle.calendarr.domain.model
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* User appearance / behaviour settings, synced with the server.
* Mirrors iOS `AppSettings`. Missing keys fall back to the defaults below
* (the server only persists a subset).
*/
@JsonClass(generateAdapter = false)
data class AppSettings(
@Json(name = "default_view") val defaultView: String = "month",
@Json(name = "week_start_day") val weekStartDay: String = "monday",
@Json(name = "primary_color") val primaryColor: String = "#4285f4",
@Json(name = "accent_color") val accentColor: String = "#ea4335",
@Json(name = "today_color") val todayColor: String = "#4285f4",
@Json(name = "dim_past_events") val dimPastEvents: Boolean = false,
@Json(name = "text_contrast") val textContrast: Int = 3,
@Json(name = "line_contrast") val lineContrast: Int = 3,
@Json(name = "hour_height") val hourHeight: Int = 60,
@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"
}
@@ -0,0 +1,210 @@
package com.scarriffle.calendarr.domain.model
import com.scarriffle.calendarr.util.Dates
import org.json.JSONObject
import java.time.Instant
/**
* A unified calendar event, blended from all server sources
* (local, caldav, google, ical, homeassistant). Mirrors iOS `CalEvent`.
*/
/** Creator (or owner, in the group combined view) of an event. id is null for imported events. */
data class EventPerson(val id: Int?, val displayName: String)
/**
* Read a string field, treating JSON null correctly. Android's
* [JSONObject.optString] returns the literal string "null" for a JSON null
* value, which previously made every event blue (color "null" → unparseable
* → fallback) and showed "null" for empty location/notes.
*
* File-scope rather than inside CalEvent's companion, so EventAttachment can
* use it as well.
*/
private fun JSONObject.strOrNull(key: String): String? {
if (!has(key) || isNull(key)) return null
return optString(key, "").takeIf { it.isNotBlank() && it != "null" }
}
/**
* A file attached to an event. Mirrors iOS `EventAttachment`.
*/
data class EventAttachment(
val id: Int,
val filename: String,
val contentType: String,
val sizeBytes: Int,
val hasThumbnail: Boolean,
val uploadedBy: EventPerson? = null,
) {
val isImage: Boolean get() = contentType.startsWith("image/")
companion object {
fun fromJson(json: JSONObject): EventAttachment? {
val id = (json.opt("id") as? Number)?.toInt() ?: return null
val filename = json.strOrNull("filename") ?: return null
return EventAttachment(
id = id,
filename = filename,
contentType = json.strOrNull("content_type") ?: "application/octet-stream",
sizeBytes = json.optInt("size_bytes", 0),
hasThumbnail = json.optBoolean("has_thumb", false),
uploadedBy = json.optJSONObject("uploaded_by")?.let { obj ->
obj.strOrNull("display_name")?.let { name ->
EventPerson((obj.opt("id") as? Number)?.toInt(), name)
}
},
)
}
}
}
/**
* A file picked in the editor but not uploaded yet. The bytes are read at pick
* time (the content URI may not survive the save), and the event it belongs to
* may not exist yet.
*/
data class StagedAttachment(
val bytes: ByteArray,
val filename: String,
val mimeType: String?,
) {
// ByteArray uses identity equality, so a data class holding one needs these
// spelled out or two distinct picks of the same file compare unequal in a
// list diff.
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is StagedAttachment) return false
return filename == other.filename && mimeType == other.mimeType &&
bytes.contentEquals(other.bytes)
}
override fun hashCode(): Int =
31 * (31 * filename.hashCode() + (mimeType?.hashCode() ?: 0)) + bytes.contentHashCode()
}
data class CalEvent(
val id: String,
val url: String,
val title: String,
val startDate: Instant,
val endDate: Instant,
val isAllDay: Boolean,
val location: String,
val notes: String,
val color: String?,
val calendarId: String,
val calendarName: String,
val calendarColor: String,
val source: String,
val creator: EventPerson? = null,
val isPrivate: Boolean = false,
// Only set in the group combined view:
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,
// How many files hang off this event. The list payload carries only the
// count; the files themselves are fetched when the detail screen opens.
// Absent on non-local sources and on busy-masked private events, so 0.
val attachmentCount: Int = 0,
) {
/**
* 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
* per-calendar palette colour (so events never collapse to one default).
*/
val effectiveColor: String
get() = displayColor?.takeIf { it.isNotBlank() }
?: color?.takeIf { it.isNotBlank() }
?: calendarColor.takeIf { it.isNotBlank() }
?: fallbackColorFor("$source:$calendarId")
companion object {
private val FALLBACK_PALETTE = listOf(
"#34a853", "#4285f4", "#ea4335", "#fbbc05",
"#46bdc6", "#9c27b0", "#ff7043", "#7090c0",
)
private fun fallbackColorFor(key: String): String {
val idx = (key.hashCode().and(Int.MAX_VALUE)) % FALLBACK_PALETTE.size
return FALLBACK_PALETTE[idx]
}
/** Parse a {id, display_name} person object (creator/owner). */
private fun personFrom(json: JSONObject?, key: String): EventPerson? {
val obj = json?.optJSONObject(key) ?: return null
val name = obj.strOrNull("display_name") ?: return null
val id = if (obj.isNull("id")) null else obj.opt("id")?.let {
when (it) { is Number -> it.toInt(); is String -> it.toIntOrNull(); else -> null }
}
return EventPerson(id, name)
}
/** Parse one event object from the `/api/caldav/events` aggregate response. */
fun fromJson(json: JSONObject): CalEvent? {
val title = json.strOrNull("title") ?: return null
val startStr = json.strOrNull("start") ?: return null
val endStr = json.strOrNull("end") ?: return null
// id may be a String (local UUID) or an Int (CalDAV numeric)
val id: String = when (val raw = json.opt("id")) {
is String -> raw
is Number -> raw.toString()
else -> return null
}
val isAllDay = json.optBoolean("allDay", false)
val start = Dates.parse(startStr, isAllDay) ?: return null
val end = Dates.parse(endStr, isAllDay) ?: return null
// calendar_id arrives as raw numeric (caldav) or "<source>-<id>" string
val calendarId = when (val raw = json.opt("calendar_id")) {
null, JSONObject.NULL -> ""
else -> raw.toString()
}
return CalEvent(
id = id,
url = json.strOrNull("url") ?: "",
title = title,
startDate = start,
endDate = end,
isAllDay = isAllDay,
location = json.strOrNull("location") ?: "",
notes = json.strOrNull("description") ?: "",
color = json.strOrNull("color"),
calendarId = calendarId,
calendarName = json.strOrNull("calendar_name") ?: "",
calendarColor = json.strOrNull("calendarColor") ?: "",
source = json.strOrNull("source") ?: "local",
creator = personFrom(json, "creator"),
isPrivate = json.optBoolean("private", false),
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),
attachmentCount = json.optInt("attachment_count", 0),
)
}
}
}
@@ -0,0 +1,31 @@
package com.scarriffle.calendarr.domain.model
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.CalendarViewWeek
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material.icons.filled.ViewList
import androidx.compose.material.icons.filled.WbSunny
import androidx.compose.ui.graphics.vector.ImageVector
enum class CalViewType(val key: String) {
MONTH("month"),
WEEK("week"),
DAY("day"),
QUARTER("quarter"),
AGENDA("agenda");
val icon: ImageVector
get() = when (this) {
MONTH -> Icons.Filled.CalendarMonth
WEEK -> Icons.Filled.CalendarViewWeek
DAY -> Icons.Filled.WbSunny
QUARTER -> Icons.Filled.DateRange
AGENDA -> Icons.Filled.ViewList
}
companion object {
fun fromKey(key: String?): CalViewType =
entries.firstOrNull { it.key == key } ?: MONTH
}
}
@@ -0,0 +1,27 @@
package com.scarriffle.calendarr.domain.model
/** Lightweight user from /api/users/directory (for sharing/group pickers). */
data class DirectoryUser(val id: Int, val displayName: String)
/** A share entry on a local calendar. */
data class CalendarShareEntry(val userId: Int, val displayName: String, val permission: String)
/** A member of a group, with their server-defined colour. */
data class GroupMember(
val id: Int,
val displayName: String,
val role: String,
val color: String? = null,
)
/** A user group + its shared group calendar. */
data class Group(
val id: Int,
val name: String,
val icon: String? = null,
val role: String,
val memberCount: Int,
val groupCalendarId: Int?,
val groupCalendarColor: String? = null,
val members: List<GroupMember>,
)
@@ -0,0 +1,13 @@
package com.scarriffle.calendarr.domain.model
/**
* One identity provider configured on the Calendarr server, as returned by
* `GET /api/auth/oidc/providers`.
*/
data class OidcProvider(
val key: String,
val name: String,
val issuer: String,
val scopes: String,
val mobileClientId: String?,
)
@@ -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
}
}
@@ -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)
}
}
@@ -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) {
}
}
}
@@ -0,0 +1,80 @@
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
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
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.hilt.navigation.compose.hiltViewModel
import com.scarriffle.calendarr.ui.auth.LoginScreen
import com.scarriffle.calendarr.ui.auth.ServerSetupScreen
import com.scarriffle.calendarr.ui.calendar.CalendarScreen
import com.scarriffle.calendarr.ui.calendar.CalendarViewModel
import com.scarriffle.calendarr.ui.theme.CalendarrTheme
import kotlinx.coroutines.delay
@Composable
fun CalendarrRoot(vm: MainViewModel = hiltViewModel()) {
val route by vm.route.collectAsState()
val settings by vm.settings.collectAsState()
CalendarrTheme(settings) {
CompositionLocalProvider(
LocalLang provides L10n.resolved(settings.language),
LocalAppSettings provides settings,
) {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
// Obtain the calendar VM early when logged in so events load
// *behind* the splash; the branded splash stays until ready.
val calendarVm: CalendarViewModel? =
if (route == AppRoute.MAIN) hiltViewModel() else null
val dataReady = calendarVm?.ready?.collectAsState()?.value ?: true
var minElapsed by remember { mutableStateOf(false) }
var timedOut by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { delay(500); minElapsed = true }
LaunchedEffect(Unit) { delay(6000); timedOut = true }
if (!minElapsed || (!dataReady && !timedOut)) {
SplashScreen()
return@Surface
}
when (route) {
// 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()
}
}
}
}
}
@@ -0,0 +1,430 @@
package com.scarriffle.calendarr.ui
import java.util.Locale
/**
* In-app localization mirroring the iOS `L10n`. The stored language may be
* "de", "en" or "system" (resolve to device language).
*/
object L10n {
fun resolved(stored: String): String {
if (stored == "de" || stored == "en") return stored
val pref = Locale.getDefault().language.lowercase()
return if (pref.startsWith("de")) "de" else "en"
}
fun locale(stored: String): Locale = Locale(resolved(stored))
fun t(key: String, stored: String): String {
val lang = resolved(stored)
return strings[lang]?.get(key) ?: strings["en"]?.get(key) ?: key
}
fun t(key: String, stored: String, vararg args: Any): String {
val template = t(key, stored)
// Support both %@ (iOS) and %s / %d (Java) placeholders.
val javaTemplate = template.replace("%@", "%s")
return runCatching { String.format(locale(stored), javaTemplate, *args) }
.getOrDefault(template)
}
private val strings: Map<String, Map<String, String>> = mapOf(
"de" to mapOf(
"nav.today" to "Heute", "nav.menu" to "Menü", "nav.done" to "Fertig",
"view.month" to "Monat", "view.week" to "Woche", "view.day" to "Tag",
"view.quarter" to "Quartal", "view.agenda" to "Termine", "view.change" to "Ansicht",
"cal.cw" to "KW", "cal.allday" to "Ganztägig",
"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",
"menu.sync" to "Mit Server synchronisieren", "menu.sync.section" to "Synchronisierung",
"settings.title" to "Darstellung", "settings.loading" to "Lade Einstellungen…",
"settings.save" to "Speichern", "settings.saved" to "Gespeichert",
"settings.cache.title" to "Vorladen", "settings.cache.range" to "Zeitraum",
"settings.cache.1m" to "±1 Monat", "settings.cache.3m" to "±3 Monate",
"settings.cache.6m" to "±6 Monate", "settings.cache.1y" to "±1 Jahr",
"settings.language" to "Sprache", "lang.system" to "Systemstandard",
"lang.german" to "Deutsch", "lang.english" to "English",
"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",
"settings.linecontrast.barely" to "Kaum", "settings.linecontrast.subtle" to "Subtil",
"settings.linecontrast.normal" to "Normal", "settings.linecontrast.strong" to "Stark",
"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",
"common.cancel" to "Abbrechen", "common.close" to "Schliessen",
"common.ok" to "OK", "common.error" to "Fehler", "common.delete" to "Löschen",
"common.save" to "Sichern", "common.retry" to "Erneut versuchen",
"server.title" to "Server", "server.connected" to "Verbundener Server",
"server.switch" to "Server wechseln", "server.logout_title" to "Abmelden",
"server.version" to "Version",
"profile.title" to "Profil", "profile.loading" to "Lade Profil…",
"profile.account" to "Konto", "profile.username" to "Benutzername",
"profile.role" to "Rolle", "profile.role.admin" to "Administrator",
"profile.role.user" to "Benutzer", "profile.email" to "E-Mail",
"profile.no_email" to "Keine E-Mail", "profile.save_email" to "E-Mail speichern",
"profile.email_saved" to "E-Mail gespeichert", "profile.change_password" to "Passwort ändern",
"profile.current_password" to "Aktuelles Passwort", "profile.new_password" to "Neues Passwort",
"profile.new_password_repeat" to "Neues Passwort wiederholen",
"profile.password_mismatch" to "Passwörter stimmen nicht überein",
"profile.password_changed" to "Passwort geändert",
"profile.twofa" to "Zwei-Faktor-Authentifizierung",
"profile.twofa.active" to "2FA ist aktiviert", "profile.twofa.inactive" to "2FA ist deaktiviert",
"profile.twofa.enable" to "2FA einrichten", "profile.twofa.disable" to "2FA deaktivieren",
"twofa.setup_title" to "2FA einrichten",
"twofa.scan_hint" to "Scanne den QR-Code mit deiner Authenticator-App.",
"twofa.code_placeholder" to "6-stelliger Code", "twofa.activate" to "Aktivieren",
"twofa.disable_title" to "2FA deaktivieren", "twofa.password_placeholder" to "Passwort",
"twofa.disable" to "Deaktivieren",
"event.title_placeholder" to "Titel", "event.allday" to "Ganztägig", "event.private" to "Privat", "event.created_by" to "Erstellt von",
"event.start" to "Start", "event.end" to "Ende", "event.location" to "Ort",
"event.description" to "Beschreibung", "event.calendar_section" to "Kalender",
"event.no_writable" to "Keine beschreibbaren Kalender vorhanden",
"event.calendar_picker" to "Kalender", "event.color_section" to "Farbe",
"event.color" to "Terminfarbe", "event.reset_color" to "Zurücksetzen",
"event.edit_title" to "Termin bearbeiten", "event.new_title" to "Neuer Termin",
"event.copy_title" to "Termin kopieren", "event.copy_to" to "In Kalender kopieren",
"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.attachments" to "Anhänge", "event.attachment_add" to "Datei hinzufügen",
"event.attachment_hint" to "Max. 10 MB pro Datei, 10 Dateien. PDF, Bilder, Text.",
"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",
"accounts.local.empty" to "Keine lokalen Kalender", "accounts.local.add" to "Lokalen Kalender erstellen",
"accounts.ical.header" to "iCal-Abonnements", "accounts.ical.empty" to "Keine Abonnements",
"accounts.ical.add" to "iCal-URL abonnieren", "accounts.google.header" to "Google-Konten",
"accounts.google.empty" to "Keine Google-Konten",
"accounts.google.hint" to "Google-Konten werden über den Browser verknüpft",
"accounts.ha.header" to "Home Assistant", "accounts.ha.empty" to "Keine Home Assistant-Konten",
"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.",
"ical.refresh.1h" to "Stündlich", "ical.refresh.6h" to "Alle 6 Std.", "ical.refresh.1d" to "Täglich",
"ha.display_name" to "Anzeigename", "ha.url_placeholder" to "URL (z.B. http://homeassistant.local:8123)",
"ha.token" to "Long-Lived Access Token", "ha.connect" to "Verbinden",
// Collaboration: profile / privacy / sharing / groups / import-export
"settings.nav.profile" to "Profil",
"profile.display_name" to "Anzeigename", "profile.login_name" to "Anmeldename",
"settings.privacy" to "Privatsphäre", "settings.private_visibility" to "Private Termine",
"settings.private.busy" to "Als beschäftigt zeigen", "settings.private.hidden" to "Verbergen",
"settings.private_visibility.desc" to "Legt fest, wie deine privaten Termine anderen Gruppenmitgliedern erscheinen.",
"settings.group_visible" to "Geteilter Kalender",
"settings.group_visible.desc" to "Dieser Kalender wird in der Gruppenansicht für andere sichtbar.",
"group.visible.none" to "Keiner",
"share.title" to "Teilen", "share.with" to "Geteilt mit", "share.add" to "Person hinzufügen",
"share.permission.read" to "Lesen", "share.permission.read_write" to "Bearbeiten",
"share.none" to "Noch nicht geteilt", "share.search" to "Benutzer suchen",
"accounts.shared.header" to "Kalender von anderen", "accounts.shared_by" to "geteilt von %@",
"accounts.color" to "Farbe ändern", "accounts.action.share" to "Teilen",
"accounts.action.import" to "Importieren", "accounts.action.export" to "Exportieren",
"import.title" to "iCal importieren", "import.result" to "%d importiert, %d übersprungen",
"import.failed" to "Import fehlgeschlagen", "export.failed" to "Export fehlgeschlagen",
"menu.groups" to "Gruppen", "groups.title" to "Gruppen", "groups.empty" to "Noch keine Gruppen",
"groups.create" to "Gruppe erstellen", "groups.name" to "Gruppenname",
"groups.members" to "Mitglieder", "groups.member_count" to "%d Mitglieder",
"groups.icon" to "Symbol", "groups.manage" to "Verwalten",
"groups.delete" to "Gruppe löschen", "groups.delete_confirm" to "Diese Gruppe löschen?",
"groups.leave" to "Gruppe verlassen", "groups.member_color" to "Farbe",
"groups.add_member" to "Mitglied hinzufügen", "groups.view" to "Gruppenansicht",
"groups.calendar" to "Gruppenkalender", "group.event" to "Gruppentermin",
"group.switch.personal" to "Mein Kalender",
// Auth screens
"auth.server_title" to "Server verbinden", "auth.server_url" to "Server-URL",
"auth.server_hint" to "Gib die Adresse deines Calendarr-Servers ein.",
"auth.continue" to "Weiter", "auth.checking" to "Verbinde…",
"auth.login_title" to "Anmelden", "auth.username" to "Benutzername",
"auth.password" to "Passwort", "auth.login" to "Anmelden",
"auth.totp" to "2FA-Code", "auth.remember" to "Angemeldet bleiben",
"auth.back" to "Zurück",
// Single sign-on (OpenID Connect)
"sso.or" to "oder", "sso.login_with" to "Mit %s anmelden",
"sso.err.generic" to "Anmeldung fehlgeschlagen",
"sso.err.oidc_failed" to "Anmeldung fehlgeschlagen",
"sso.err.oidc_provider_unreachable" to "Anmeldedienst nicht erreichbar",
"sso.err.oidc_no_browser" to "Kein Browser installiert — für die Anmeldung wird einer benötigt",
"sso.err.oidc_account_not_linked" to "Kein verknüpftes Calendarr-Konto. Bitte lokal anmelden und in den Einstellungen verknüpfen.",
"sso.err.oidc_signup_disabled" to "Automatische Kontoerstellung ist deaktiviert",
"sso.err.oidc_domain_not_allowed" to "Diese E-Mail-Domain ist nicht zugelassen",
"sso.err.oidc_email_conflict" to "Diese E-Mail-Adresse gehört bereits zu einem anderen Konto",
"sso.err.oidc_email_not_verified" to "E-Mail-Adresse beim Anbieter nicht bestätigt",
"sso.err.oidc_no_email" to "Der Anbieter hat keine E-Mail-Adresse übermittelt",
"sso.err.oidc_unknown_client" to "Diese App ist beim Anbieter nicht registriert",
"sso.err.oidc_invalid_token" to "Das Anmelde-Token ist ungültig",
"sso.err.oidc_no_id_token" to "Der Anbieter hat kein ID-Token geliefert",
),
"en" to mapOf(
"nav.today" to "Today", "nav.menu" to "Menu", "nav.done" to "Done",
"view.month" to "Month", "view.week" to "Week", "view.day" to "Day",
"view.quarter" to "Quarter", "view.agenda" to "Agenda", "view.change" to "View",
"cal.cw" to "W", "cal.allday" to "All-day",
"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",
"menu.sync" to "Sync with server", "menu.sync.section" to "Synchronization",
"settings.title" to "Appearance", "settings.loading" to "Loading settings…",
"settings.save" to "Save", "settings.saved" to "Saved",
"settings.cache.title" to "Preloading", "settings.cache.range" to "Range",
"settings.cache.1m" to "±1 month", "settings.cache.3m" to "±3 months",
"settings.cache.6m" to "±6 months", "settings.cache.1y" to "±1 year",
"settings.language" to "Language", "lang.system" to "System default",
"lang.german" to "Deutsch", "lang.english" to "English",
"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",
"settings.linecontrast.barely" to "Barely", "settings.linecontrast.subtle" to "Subtle",
"settings.linecontrast.normal" to "Normal", "settings.linecontrast.strong" to "Strong",
"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",
"common.cancel" to "Cancel", "common.close" to "Close",
"common.ok" to "OK", "common.error" to "Error", "common.delete" to "Delete",
"common.save" to "Save", "common.retry" to "Retry",
"server.title" to "Server", "server.connected" to "Connected server",
"server.switch" to "Switch server", "server.logout_title" to "Sign out",
"server.version" to "Version",
"profile.title" to "Profile", "profile.loading" to "Loading profile…",
"profile.account" to "Account", "profile.username" to "Username",
"profile.role" to "Role", "profile.role.admin" to "Administrator",
"profile.role.user" to "User", "profile.email" to "Email",
"profile.no_email" to "No email", "profile.save_email" to "Save email",
"profile.email_saved" to "Email saved", "profile.change_password" to "Change password",
"profile.current_password" to "Current password", "profile.new_password" to "New password",
"profile.new_password_repeat" to "Repeat new password",
"profile.password_mismatch" to "Passwords don't match",
"profile.password_changed" to "Password changed",
"profile.twofa" to "Two-factor authentication",
"profile.twofa.active" to "2FA is enabled", "profile.twofa.inactive" to "2FA is disabled",
"profile.twofa.enable" to "Set up 2FA", "profile.twofa.disable" to "Disable 2FA",
"twofa.setup_title" to "Set up 2FA",
"twofa.scan_hint" to "Scan the QR code with your authenticator app.",
"twofa.code_placeholder" to "6-digit code", "twofa.activate" to "Activate",
"twofa.disable_title" to "Disable 2FA", "twofa.password_placeholder" to "Password",
"twofa.disable" to "Disable",
"event.title_placeholder" to "Title", "event.allday" to "All-day", "event.private" to "Private", "event.created_by" to "Created by",
"event.start" to "Start", "event.end" to "End", "event.location" to "Location",
"event.description" to "Description", "event.calendar_section" to "Calendar",
"event.no_writable" to "No writable calendars available",
"event.calendar_picker" to "Calendar", "event.color_section" to "Color",
"event.color" to "Event color", "event.reset_color" to "Reset",
"event.edit_title" to "Edit event", "event.new_title" to "New event",
"event.copy_title" to "Copy event", "event.copy_to" to "Copy to calendar",
"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.attachments" to "Attachments", "event.attachment_add" to "Add file",
"event.attachment_hint" to "Max. 10 MB per file, 10 files. PDF, images, text.",
"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",
"accounts.local.empty" to "No local calendars", "accounts.local.add" to "Create local calendar",
"accounts.ical.header" to "iCal subscriptions", "accounts.ical.empty" to "No subscriptions",
"accounts.ical.add" to "Subscribe to iCal URL", "accounts.google.header" to "Google accounts",
"accounts.google.empty" to "No Google accounts",
"accounts.google.hint" to "Google accounts are linked via the browser",
"accounts.ha.header" to "Home Assistant", "accounts.ha.empty" to "No Home Assistant accounts",
"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",
"ical.refresh.1h" to "Hourly", "ical.refresh.6h" to "Every 6 hours", "ical.refresh.1d" to "Daily",
"ha.display_name" to "Display name", "ha.url_placeholder" to "URL (e.g. http://homeassistant.local:8123)",
"ha.token" to "Long-Lived Access Token", "ha.connect" to "Connect",
// Collaboration: profile / privacy / sharing / groups / import-export
"settings.nav.profile" to "Profile",
"profile.display_name" to "Display name", "profile.login_name" to "Login name",
"settings.privacy" to "Privacy", "settings.private_visibility" to "Private events",
"settings.private.busy" to "Show as busy", "settings.private.hidden" to "Hidden",
"settings.private_visibility.desc" to "Controls how your private events appear to other group members.",
"settings.group_visible" to "Shared calendar",
"settings.group_visible.desc" to "This calendar is shown to others in the group view.",
"group.visible.none" to "None",
"share.title" to "Share", "share.with" to "Shared with", "share.add" to "Add person",
"share.permission.read" to "Read", "share.permission.read_write" to "Read & write",
"share.none" to "Not shared yet", "share.search" to "Search users",
"accounts.shared.header" to "Calendars from others", "accounts.shared_by" to "shared by %@",
"accounts.color" to "Change color", "accounts.action.share" to "Share",
"accounts.action.import" to "Import", "accounts.action.export" to "Export",
"import.title" to "Import iCal", "import.result" to "%d imported, %d skipped",
"import.failed" to "Import failed", "export.failed" to "Export failed",
"menu.groups" to "Groups", "groups.title" to "Groups", "groups.empty" to "No groups yet",
"groups.create" to "Create group", "groups.name" to "Group name",
"groups.members" to "Members", "groups.member_count" to "%d members",
"groups.icon" to "Icon", "groups.manage" to "Manage",
"groups.delete" to "Delete group", "groups.delete_confirm" to "Delete this group?",
"groups.leave" to "Leave group", "groups.member_color" to "Color",
"groups.add_member" to "Add member", "groups.view" to "Group view",
"groups.calendar" to "Group calendar", "group.event" to "Group event",
"group.switch.personal" to "My calendar",
// Auth screens
"auth.server_title" to "Connect server", "auth.server_url" to "Server URL",
"auth.server_hint" to "Enter the address of your Calendarr server.",
"auth.continue" to "Continue", "auth.checking" to "Connecting…",
"auth.login_title" to "Sign in", "auth.username" to "Username",
"auth.password" to "Password", "auth.login" to "Sign in",
"auth.totp" to "2FA code", "auth.remember" to "Stay signed in",
"auth.back" to "Back",
// Single sign-on (OpenID Connect)
"sso.or" to "or", "sso.login_with" to "Sign in with %s",
"sso.err.generic" to "Sign-in failed",
"sso.err.oidc_failed" to "Sign-in failed",
"sso.err.oidc_provider_unreachable" to "Sign-in service unreachable",
"sso.err.oidc_no_browser" to "No browser installed — signing in requires one",
"sso.err.oidc_account_not_linked" to "No linked Calendarr account. Sign in locally and link it in Settings.",
"sso.err.oidc_signup_disabled" to "Automatic account creation is disabled",
"sso.err.oidc_domain_not_allowed" to "This email domain is not allowed",
"sso.err.oidc_email_conflict" to "That email address already belongs to another account",
"sso.err.oidc_email_not_verified" to "Email address not verified at the provider",
"sso.err.oidc_no_email" to "The provider did not supply an email address",
"sso.err.oidc_unknown_client" to "This app is not registered with the provider",
"sso.err.oidc_invalid_token" to "The sign-in token is invalid",
"sso.err.oidc_no_id_token" to "The provider did not return an ID token",
),
)
}
@@ -0,0 +1,22 @@
package com.scarriffle.calendarr.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.staticCompositionLocalOf
import com.scarriffle.calendarr.domain.model.AppSettings
/** Resolved UI language ("de" / "en"), provided at the root. */
val LocalLang = compositionLocalOf { "de" }
/** Current appearance settings, provided at the root. */
val LocalAppSettings = staticCompositionLocalOf { AppSettings() }
/** Convenience translation lookup that reads the ambient language. */
@Composable
@ReadOnlyComposable
fun tr(key: String): String = L10n.t(key, LocalLang.current)
@Composable
@ReadOnlyComposable
fun tr(key: String, vararg args: Any): String = L10n.t(key, LocalLang.current, *args)
@@ -0,0 +1,144 @@
package com.scarriffle.calendarr.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.scarriffle.calendarr.data.CalendarRepository
import com.scarriffle.calendarr.data.CredentialStore
import com.scarriffle.calendarr.data.OidcAuthenticator
import com.scarriffle.calendarr.data.SettingsStore
import com.scarriffle.calendarr.data.remote.ApiProvider
import com.scarriffle.calendarr.domain.model.AppSettings
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
enum class AppRoute { SETUP, LOGIN, MAIN }
@HiltViewModel
class MainViewModel @Inject constructor(
private val credentialStore: CredentialStore,
private val settingsStore: SettingsStore,
private val repository: CalendarRepository,
private val apiProvider: ApiProvider,
private val oidc: OidcAuthenticator,
) : ViewModel() {
private val _route = MutableStateFlow(computeRoute())
val route: StateFlow<AppRoute> = _route.asStateFlow()
private val _settings = MutableStateFlow(settingsStore.loadSettings())
val settings: StateFlow<AppSettings> = _settings.asStateFlow()
val serverUrl: String get() = credentialStore.serverUrl ?: ""
val username: String get() = credentialStore.username ?: ""
val isAdmin: Boolean get() = credentialStore.isAdmin
init {
if (_route.value == AppRoute.MAIN) {
refreshSettings()
renewSsoSessionIfNeeded()
}
}
/**
* Keep an SSO session alive without another trip through the browser.
*
* Calendarr's own token simply expires; what `offline_access` buys is a
* refresh token at the *provider*. So renewal means: fresh ID token from
* the provider, then trade it for a fresh Calendarr token.
*
* Best effort — on any failure the existing token stays in place and the
* user notices nothing until it genuinely expires.
*/
private fun renewSsoSessionIfNeeded() {
val state = credentialStore.oidcState ?: return
val providerKey = credentialStore.oidcProvider ?: return
val clientId = credentialStore.oidcClientId ?: return
val base = credentialStore.serverUrl ?: return
val token = credentialStore.token ?: return
if (!expiresSoon(token)) return
viewModelScope.launch {
runCatching {
val renewed = oidc.renewIdToken(state) ?: return@runCatching
repository.exchangeOidc(
baseUrl = base,
provider = providerKey,
clientId = clientId,
idToken = renewed.idToken,
accessToken = renewed.accessToken,
nonce = renewed.nonce,
)
credentialStore.saveOidcSession(renewed.serialisedState, providerKey, clientId)
}
}
}
private fun computeRoute(): AppRoute = when {
!credentialStore.isConfigured -> AppRoute.SETUP
!credentialStore.isLoggedIn -> AppRoute.LOGIN
else -> AppRoute.MAIN
}
fun onServerConfigured() { _route.value = computeRoute() }
fun onLoggedIn() {
_route.value = AppRoute.MAIN
refreshSettings()
}
fun logout() {
credentialStore.clearToken()
_route.value = computeRoute()
}
fun switchServer() {
credentialStore.clearAll()
apiProvider.invalidate()
_route.value = computeRoute()
}
/** 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 ->
_settings.value = settingsStore.applyServerPull(s)
}
}
}
fun applyLocalSettings(s: AppSettings) {
settingsStore.saveSettings(s)
_settings.value = s
}
/**
* True when the Calendarr JWT expires within [RENEW_WITHIN_MS].
*
* The payload is read without verifying the signature — this only decides
* *whether* to renew, and the server validates the token on every request
* regardless. A token we cannot parse is left alone.
*/
private fun expiresSoon(jwt: String): Boolean {
val parts = jwt.split(".")
if (parts.size != 3) return false
return runCatching {
val payload = android.util.Base64.decode(
parts[1],
android.util.Base64.URL_SAFE or android.util.Base64.NO_PADDING or
android.util.Base64.NO_WRAP,
)
val exp = org.json.JSONObject(String(payload)).optLong("exp", 0L)
exp > 0L && (exp * 1000L) - System.currentTimeMillis() < RENEW_WITHIN_MS
}.getOrDefault(false)
}
private companion object {
/** Renew when less than a day of the Calendarr token's life is left. */
const val RENEW_WITHIN_MS = 24L * 60 * 60 * 1000
}
}
@@ -0,0 +1,57 @@
package com.scarriffle.calendarr.ui
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.scarriffle.calendarr.R
/**
* Branded startup screen shown right after the system splash hands off (same
* black background + same centred logo, so the logo doesn't jump); the app name
* and copyright fade in. Stays until the first events have loaded.
*/
@Composable
fun SplashScreen() {
// Fade the texts in so the handoff from the (text-less) system splash is smooth.
val textAlpha by animateFloatAsState(targetValue = 1f, animationSpec = tween(350), label = "splashText")
Box(Modifier.fillMaxSize().background(Color.Black)) {
Text(
"Calendarr",
modifier = Modifier.align(Alignment.TopCenter).padding(top = 110.dp).alpha(textAlpha),
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.SemiBold,
color = Color.White,
)
// Match the system splash icon size (~150dp) so it stays put across the handoff.
Image(
painter = painterResource(R.drawable.ic_splash_logo),
contentDescription = null,
modifier = Modifier.align(Alignment.Center).size(150.dp).clip(RoundedCornerShape(34.dp)),
)
Text(
"© Scarriffle",
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 40.dp).alpha(textAlpha),
style = MaterialTheme.typography.bodySmall,
color = Color.White.copy(alpha = 0.6f),
)
}
}
@@ -0,0 +1,558 @@
package com.scarriffle.calendarr.ui.accounts
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.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.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.People
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Divider
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
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.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.platform.LocalContext
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.LocalCalendar
import com.scarriffle.calendarr.ui.L10n
import com.scarriffle.calendarr.ui.LocalLang
import com.scarriffle.calendarr.ui.components.ColorPickerDialog
import com.scarriffle.calendarr.ui.components.PasswordField
import com.scarriffle.calendarr.ui.tr
import com.scarriffle.calendarr.util.colorFromHex
private enum class AddType { LOCAL, CALDAV, ICAL, HA }
/** What colour the picker dialog is currently editing. */
private sealed interface ColorTarget {
val current: String
data class Local(val id: Int, override val current: String) : ColorTarget
data class ICal(val id: Int, override val current: String) : ColorTarget
data class Source(val source: String, val id: Int, override val current: String) : ColorTarget
}
private fun safeFileName(name: String): String {
val cleaned = name.map { if (it.isLetterOrDigit()) it else '_' }.joinToString("")
return (cleaned.ifBlank { "calendar" }) + ".ics"
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AccountsScreen(
onClose: () -> Unit,
onChanged: () -> Unit,
vm: AccountsViewModel = hiltViewModel(),
) {
val context = LocalContext.current
val lang = LocalLang.current
var addDialog by remember { mutableStateOf<AddType?>(null) }
var editingColor by remember { mutableStateOf<ColorTarget?>(null) }
var shareCalId by remember { mutableStateOf<Int?>(null) }
// Import: pick a document, read its bytes, upload to importTarget.
var importTarget by remember { mutableStateOf<Int?>(null) }
val openDoc = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
val calId = importTarget
importTarget = null
if (uri != null && calId != null) {
val bytes = runCatching { context.contentResolver.openInputStream(uri)?.use { it.readBytes() } }.getOrNull()
if (bytes != null) {
vm.importIcs(
calId, bytes, "import.ics",
result = { imported, skipped -> L10n.t("import.result", lang, imported, skipped) },
onChanged = onChanged,
)
}
}
}
// Export: fetch bytes first, then let the user save them to a chosen location.
var pendingExport by remember { mutableStateOf<ByteArray?>(null) }
val createDoc = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/calendar")) { uri ->
val bytes = pendingExport
pendingExport = null
if (uri != null && bytes != null) {
runCatching { context.contentResolver.openOutputStream(uri)?.use { it.write(bytes) } }
}
}
// 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 = {
androidx.compose.material3.TopAppBar(
title = { Text(tr("accounts.title")) },
navigationIcon = {
IconButton(onClick = onClose) { Icon(Icons.Filled.Close, contentDescription = tr("common.close")) }
},
)
},
) { padding ->
if (vm.loading) {
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
return@Scaffold
}
LazyColumn(Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp)) {
// Local (incl. shared-with-me and group calendars)
item { SectionHeader(tr("accounts.local.header"), tr("accounts.local.add")) { addDialog = AddType.LOCAL } }
if (vm.local.isEmpty()) item { EmptyRow(tr("accounts.local.empty")) }
items(vm.local, key = { "l${it.id}" }) { cal ->
LocalCalendarRow(
cal = cal,
onColor = { editingColor = ColorTarget.Local(cal.id, cal.color) },
onShare = { shareCalId = cal.id },
onImport = { importTarget = cal.id; openDoc.launch(arrayOf("*/*")) },
onExport = { vm.exportIcs(cal.id) { bytes -> pendingExport = bytes; createDoc.launch(safeFileName(cal.name)) } },
onDelete = { vm.deleteLocal(cal.id, onChanged) },
)
}
// CalDAV
item { SectionHeader(tr("accounts.caldav.header"), tr("accounts.caldav.add")) { addDialog = AddType.CALDAV } }
if (vm.caldav.isEmpty()) item { EmptyRow(tr("accounts.caldav.empty")) }
items(vm.caldav, key = { "c${it.id}" }) { acc ->
Column {
AccountHeaderRow("${acc.name} (${acc.username})", acc.color) { vm.deleteCalDAV(acc.id, onChanged) }
acc.calendars.orEmpty().forEach { cal ->
ChildCalendarRow(cal.name, cal.color ?: acc.color) {
editingColor = ColorTarget.Source("caldav", cal.id, cal.color ?: acc.color)
}
}
}
}
// iCal
item { SectionHeader(tr("accounts.ical.header"), tr("accounts.ical.add")) { addDialog = AddType.ICAL } }
if (vm.ical.isEmpty()) item { EmptyRow(tr("accounts.ical.empty")) }
items(vm.ical, key = { "i${it.id}" }) { sub ->
EditableColorRow(sub.name, sub.color, onColor = { editingColor = ColorTarget.ICal(sub.id, sub.color) }) {
vm.deleteICal(sub.id, onChanged)
}
}
// Google
item { SectionHeaderNoAdd(tr("accounts.google.header")) }
if (vm.google.isEmpty()) item { EmptyRow(tr("accounts.google.hint")) }
items(vm.google, key = { "g${it.id}" }) { acc ->
Column {
AccountHeaderRow(acc.email, "#4285f4") { vm.deleteGoogle(acc.id, onChanged) }
acc.calendars.orEmpty().forEach { cal ->
ChildCalendarRow(cal.name, cal.color ?: "#4285f4") {
editingColor = ColorTarget.Source("google", cal.id, cal.color ?: "#4285f4")
}
}
}
}
// Home Assistant
item { SectionHeader(tr("accounts.ha.header"), tr("accounts.ha.add")) { addDialog = AddType.HA } }
if (vm.homeAssistant.isEmpty()) item { EmptyRow(tr("accounts.ha.empty")) }
items(vm.homeAssistant, key = { "h${it.id}" }) { acc ->
Column {
AccountHeaderRow(acc.name, "#46bdc6") { vm.deleteHA(acc.id, onChanged) }
acc.calendars.orEmpty().forEach { cal ->
ChildCalendarRow(cal.name, cal.color ?: "#46bdc6") {
editingColor = ColorTarget.Source("homeassistant", cal.id, cal.color ?: "#46bdc6")
}
}
}
}
// 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)) }
}
}
}
when (addDialog) {
AddType.LOCAL -> LocalDialog(onDismiss = { addDialog = null }) { name, color ->
vm.addLocal(name, color, onChanged); addDialog = null
}
AddType.CALDAV -> CalDAVDialog(onDismiss = { addDialog = null }) { n, u, us, p, c ->
vm.addCalDAV(n, u, us, p, c, onChanged); addDialog = null
}
AddType.ICAL -> ICalDialog(onDismiss = { addDialog = null }) { n, u, c, r ->
vm.addICal(n, u, c, r, onChanged); addDialog = null
}
AddType.HA -> HADialog(onDismiss = { addDialog = null }) { n, u, t ->
vm.addHA(n, u, t, onChanged); addDialog = null
}
null -> Unit
}
editingColor?.let { target ->
ColorPickerDialog(
initial = target.current,
title = tr("accounts.color"),
onDismiss = { editingColor = null },
onConfirm = { hex ->
when (target) {
is ColorTarget.Local -> vm.setLocalColor(target.id, hex, onChanged)
is ColorTarget.ICal -> vm.setICalColor(target.id, hex, onChanged)
is ColorTarget.Source -> vm.setSourceColor(target.source, target.id, hex, onChanged)
}
editingColor = null
},
)
}
shareCalId?.let { id ->
SharingSheet(vm = vm, calendarId = id, onDismiss = { shareCalId = null })
}
vm.infoMessage?.let { msg ->
AlertDialog(
onDismissRequest = { vm.clearInfo() },
confirmButton = { TextButton(onClick = { vm.clearInfo() }) { Text(tr("common.ok")) } },
text = { Text(msg) },
)
}
}
@Composable
private fun SectionHeader(title: String, addLabel: String, onAdd: () -> Unit) {
Row(
Modifier.fillMaxWidth().padding(top = 20.dp, bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
TextButton(onClick = onAdd) {
Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(4.dp))
Text(addLabel)
}
}
}
@Composable
private fun SectionHeaderNoAdd(title: String) {
Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(top = 20.dp, bottom = 6.dp))
}
@Composable
private fun EmptyRow(text: String) {
Text(text, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(vertical = 6.dp))
}
/** Tappable colour swatch. */
@Composable
private fun ColorDot(hex: String, editable: Boolean, onClick: () -> Unit) {
val base = Modifier.size(if (editable) 18.dp else 14.dp).clip(CircleShape).background(colorFromHex(hex))
Box(if (editable) base.clickable(onClick = onClick) else base)
}
/** Account header (CalDAV/Google/HA) with a delete button; colour edited on the child rows. */
@Composable
private fun AccountHeaderRow(name: String, color: String, onDelete: () -> Unit) {
Row(
Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(Modifier.size(14.dp).clip(CircleShape).background(colorFromHex(color)))
Text(name, modifier = Modifier.weight(1f).padding(start = 12.dp), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium)
IconButton(onClick = onDelete) {
Icon(Icons.Filled.Delete, contentDescription = tr("common.delete"), tint = MaterialTheme.colorScheme.error)
}
}
}
/** A child calendar of a server-managed account: editable colour + name. */
@Composable
private fun ChildCalendarRow(name: String, color: String, onColor: () -> Unit) {
Row(
Modifier.fillMaxWidth().padding(start = 16.dp, top = 4.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
ColorDot(color, editable = true, onClick = onColor)
Text(name, modifier = Modifier.padding(start = 12.dp), style = MaterialTheme.typography.bodyMedium)
}
}
/** 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) {
Row(Modifier.fillMaxWidth().padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) {
ColorDot(color, editable = true, onClick = onColor)
Text(name, modifier = Modifier.weight(1f).padding(start = 12.dp), style = MaterialTheme.typography.bodyLarge)
IconButton(onClick = onDelete) {
Icon(Icons.Filled.Delete, contentDescription = tr("common.delete"), tint = MaterialTheme.colorScheme.error)
}
}
}
/** Local calendar row: colour, group marker, "shared by", and an actions menu. */
@Composable
private fun LocalCalendarRow(
cal: LocalCalendar,
onColor: () -> Unit,
onShare: () -> Unit,
onImport: () -> Unit,
onExport: () -> Unit,
onDelete: () -> Unit,
) {
var menu by remember { mutableStateOf(false) }
Row(Modifier.fillMaxWidth().padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) {
// 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)
if (cal.group) {
Spacer(Modifier.size(6.dp))
Icon(Icons.Filled.People, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(16.dp))
}
}
val by = cal.sharedBy
if (!cal.owned && by != null) {
Text(tr("accounts.shared_by", by), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
Box {
IconButton(onClick = { menu = true }) {
Icon(Icons.Filled.MoreVert, contentDescription = tr("accounts.action.share"), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
if (cal.owned && !cal.group) {
DropdownMenuItem(text = { Text(tr("accounts.action.share")) }, onClick = { menu = false; onShare() })
}
if (cal.owned || cal.permission == "read_write") {
DropdownMenuItem(text = { Text(tr("accounts.action.import")) }, onClick = { menu = false; onImport() })
}
DropdownMenuItem(text = { Text(tr("accounts.action.export")) }, onClick = { menu = false; onExport() })
if (cal.owned) {
DropdownMenuItem(text = { Text(tr("common.delete")) }, onClick = { menu = false; onDelete() })
}
}
}
}
}
/** Manage who a local calendar is shared with (owner only). */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SharingSheet(vm: AccountsViewModel, calendarId: Int, onDismiss: () -> Unit) {
LaunchedEffect(calendarId) { vm.loadSharing(calendarId) }
var permission by remember { mutableStateOf("read") }
var search by remember { mutableStateOf("") }
val sharedIds = vm.shares.map { it.userId }.toSet()
val candidates = vm.directory.filter {
it.id !in sharedIds && (search.isBlank() || it.displayName.contains(search, ignoreCase = true))
}
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(
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))
Text(tr("share.with"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
if (vm.shares.isEmpty()) {
Text(tr("share.none"), color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(vertical = 6.dp))
} else {
vm.shares.forEach { s ->
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
Text(s.displayName, modifier = Modifier.weight(1f))
Text(
if (s.permission == "read_write") tr("share.permission.read_write") else tr("share.permission.read"),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
IconButton(onClick = { vm.removeShare(calendarId, s.userId) }) {
Icon(Icons.Filled.Delete, contentDescription = tr("common.delete"), tint = MaterialTheme.colorScheme.error)
}
}
}
}
Divider(Modifier.padding(vertical = 16.dp))
Text(tr("share.add"), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.size(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(selected = permission == "read", onClick = { permission = "read" }, label = { Text(tr("share.permission.read")) })
FilterChip(selected = permission == "read_write", onClick = { permission = "read_write" }, label = { Text(tr("share.permission.read_write")) })
}
Spacer(Modifier.size(8.dp))
OutlinedTextField(
value = search,
onValueChange = { search = it },
label = { Text(tr("share.search")) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.size(4.dp))
candidates.forEach { u ->
Row(
Modifier.fillMaxWidth().clickable { vm.addShare(calendarId, u.id, permission) }.padding(vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(u.displayName, modifier = Modifier.weight(1f))
Icon(Icons.Filled.Add, contentDescription = tr("share.add"), tint = MaterialTheme.colorScheme.primary)
}
}
}
}
}
// ---- Add dialogs ----
@Composable
private fun LocalDialog(onDismiss: () -> Unit, onConfirm: (String, String) -> Unit) {
var name by remember { mutableStateOf("") }
val color = "#34a853"
FormDialog(tr("accounts.local.add"), onDismiss, confirmEnabled = name.isNotBlank(), onConfirm = { onConfirm(name.trim(), color) }) {
OutlinedTextField(name, { name = it }, label = { Text(tr("local.name")) }, singleLine = true, modifier = Modifier.fillMaxWidth())
}
}
@Composable
private fun CalDAVDialog(onDismiss: () -> Unit, onConfirm: (String, String, String, String, String) -> Unit) {
var name by remember { mutableStateOf("") }
var url by remember { mutableStateOf("") }
var user by remember { mutableStateOf("") }
var pass by remember { mutableStateOf("") }
val color = "#4285f4"
FormDialog(tr("accounts.caldav.add"), onDismiss, confirmEnabled = url.isNotBlank() && user.isNotBlank(), onConfirm = { onConfirm(name.trim(), url.trim(), user.trim(), pass, color) }) {
OutlinedTextField(name, { name = it }, label = { Text(tr("caldav.display_name")) }, singleLine = true, modifier = Modifier.fillMaxWidth())
Spacer(Modifier.size(8.dp))
OutlinedTextField(url, { url = it }, label = { Text(tr("caldav.url")) }, singleLine = true, modifier = Modifier.fillMaxWidth())
Spacer(Modifier.size(8.dp))
OutlinedTextField(user, { user = it }, label = { Text(tr("caldav.username")) }, singleLine = true, modifier = Modifier.fillMaxWidth())
Spacer(Modifier.size(8.dp))
PasswordField(pass, { pass = it }, label = tr("caldav.password"), modifier = Modifier.fillMaxWidth())
}
}
@Composable
private fun ICalDialog(onDismiss: () -> Unit, onConfirm: (String, String, String, Int) -> Unit) {
var name by remember { mutableStateOf("") }
var url by remember { mutableStateOf("") }
val color = "#46bdc6"
FormDialog(tr("accounts.ical.add"), onDismiss, confirmEnabled = url.isNotBlank(), onConfirm = { onConfirm(name.trim(), url.trim(), color, 60) }) {
OutlinedTextField(name, { name = it }, label = { Text(tr("ical.name")) }, singleLine = true, modifier = Modifier.fillMaxWidth())
Spacer(Modifier.size(8.dp))
OutlinedTextField(url, { url = it }, label = { Text(tr("ical.url")) }, singleLine = true, modifier = Modifier.fillMaxWidth())
}
}
@Composable
private fun HADialog(onDismiss: () -> Unit, onConfirm: (String, String, String) -> Unit) {
var name by remember { mutableStateOf("") }
var url by remember { mutableStateOf("") }
var token by remember { mutableStateOf("") }
FormDialog(tr("accounts.ha.add"), onDismiss, confirmEnabled = url.isNotBlank() && token.isNotBlank(), onConfirm = { onConfirm(name.trim(), url.trim(), token.trim()) }) {
OutlinedTextField(name, { name = it }, label = { Text(tr("ha.display_name")) }, singleLine = true, modifier = Modifier.fillMaxWidth())
Spacer(Modifier.size(8.dp))
OutlinedTextField(url, { url = it }, label = { Text(tr("ha.url_placeholder")) }, singleLine = true, modifier = Modifier.fillMaxWidth())
Spacer(Modifier.size(8.dp))
OutlinedTextField(token, { token = it }, label = { Text(tr("ha.token")) }, singleLine = true, modifier = Modifier.fillMaxWidth())
}
}
@Composable
private fun FormDialog(
title: String,
onDismiss: () -> Unit,
confirmEnabled: Boolean,
onConfirm: () -> Unit,
content: @Composable () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = { Column { content() } },
confirmButton = { TextButton(onClick = onConfirm, enabled = confirmEnabled) { Text(tr("caldav.connect")) } },
dismissButton = { TextButton(onClick = onDismiss) { Text(tr("common.cancel")) } },
)
}
@@ -0,0 +1,166 @@
package com.scarriffle.calendarr.ui.accounts
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.CalDAVAccount
import com.scarriffle.calendarr.domain.model.CalendarShareEntry
import com.scarriffle.calendarr.domain.model.DirectoryUser
import com.scarriffle.calendarr.domain.model.GoogleAccount
import com.scarriffle.calendarr.domain.model.HomeAssistantAccount
import com.scarriffle.calendarr.domain.model.ICalSubscription
import com.scarriffle.calendarr.domain.model.LocalCalendar
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class AccountsViewModel @Inject constructor(
private val repository: CalendarRepository,
) : ViewModel() {
var loading by mutableStateOf(true)
private set
var error by mutableStateOf<String?>(null)
private set
var caldav by mutableStateOf<List<CalDAVAccount>>(emptyList())
private set
var local by mutableStateOf<List<LocalCalendar>>(emptyList())
private set
var ical by mutableStateOf<List<ICalSubscription>>(emptyList())
private set
var google by mutableStateOf<List<GoogleAccount>>(emptyList())
private set
var homeAssistant by mutableStateOf<List<HomeAssistantAccount>>(emptyList())
private set
init { load() }
fun load() {
viewModelScope.launch {
loading = true
error = null
caldav = runCatching { repository.getCalDAVAccounts() }.getOrDefault(emptyList())
local = runCatching { repository.getLocalCalendars() }.getOrDefault(emptyList())
ical = runCatching { repository.getICalSubscriptions() }.getOrDefault(emptyList())
google = runCatching { repository.getGoogleAccounts() }.getOrDefault(emptyList())
homeAssistant = runCatching { repository.getHomeAssistantAccounts() }.getOrDefault(emptyList())
loading = false
}
}
private fun mutate(onChanged: () -> Unit, block: suspend () -> Unit) {
viewModelScope.launch {
runCatching { block() }
.onSuccess { load(); onChanged() }
.onFailure { error = it.message }
}
}
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) }
fun addCalDAV(name: String, url: String, user: String, pass: String, color: String, onChanged: () -> Unit) =
mutate(onChanged) { repository.addCalDAVAccount(name, url, user, pass, color) }
fun deleteCalDAV(id: Int, onChanged: () -> Unit) =
mutate(onChanged) { repository.deleteCalDAVAccount(id) }
fun addICal(name: String, url: String, color: String, refreshMinutes: Int, onChanged: () -> Unit) =
mutate(onChanged) { repository.addICalSubscription(name, url, color, refreshMinutes) }
fun deleteICal(id: Int, onChanged: () -> Unit) =
mutate(onChanged) { repository.deleteICalSubscription(id) }
fun addHA(name: String, url: String, token: String, onChanged: () -> Unit) =
mutate(onChanged) { repository.addHomeAssistantAccount(name, url, token) }
fun deleteHA(id: Int, onChanged: () -> Unit) =
mutate(onChanged) { repository.deleteHomeAssistantAccount(id) }
fun deleteGoogle(id: Int, onChanged: () -> Unit) =
mutate(onChanged) { repository.deleteGoogleAccount(id) }
// ---- Calendar colour ----
fun setLocalColor(id: Int, color: String, onChanged: () -> Unit) =
mutate(onChanged) { repository.updateLocalCalendarColor(id, color) }
fun setICalColor(id: Int, color: String, onChanged: () -> Unit) =
mutate(onChanged) { repository.updateICalColor(id, color) }
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())
private set
var directory by mutableStateOf<List<DirectoryUser>>(emptyList())
private set
fun loadSharing(calendarId: Int) {
viewModelScope.launch {
shares = runCatching { repository.getShares(calendarId) }.getOrDefault(emptyList())
directory = runCatching { repository.getUserDirectory() }.getOrDefault(emptyList())
}
}
fun addShare(calendarId: Int, userId: Int, permission: String) {
viewModelScope.launch {
runCatching { repository.addShare(calendarId, userId, permission) }
.onSuccess { loadSharing(calendarId) }
.onFailure { error = it.message }
}
}
fun removeShare(calendarId: Int, userId: Int) {
viewModelScope.launch {
runCatching { repository.removeShare(calendarId, userId) }
.onSuccess { loadSharing(calendarId) }
.onFailure { error = it.message }
}
}
// ---- Import / export ----
var infoMessage by mutableStateOf<String?>(null)
private set
fun clearInfo() { infoMessage = null }
fun clearError() { error = null }
fun importIcs(calendarId: Int, bytes: ByteArray, filename: String, result: (Int, Int) -> String, onChanged: () -> Unit) {
viewModelScope.launch {
runCatching { repository.importIcsFile(calendarId, bytes, filename) }
.onSuccess { (imported, skipped, _) -> infoMessage = result(imported, skipped); load(); onChanged() }
.onFailure { error = it.message }
}
}
fun exportIcs(calendarId: Int, onBytes: (ByteArray) -> Unit) {
viewModelScope.launch {
runCatching { repository.exportIcs(calendarId) }
.onSuccess { onBytes(it) }
.onFailure { error = it.message }
}
}
}
@@ -0,0 +1,222 @@
package com.scarriffle.calendarr.ui.auth
import android.content.ActivityNotFoundException
import android.content.Intent
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.data.CredentialStore
import com.scarriffle.calendarr.data.OidcAuthenticator
import com.scarriffle.calendarr.data.remote.ApiProvider
import com.scarriffle.calendarr.data.remote.OidcException
import com.scarriffle.calendarr.data.remote.TwoFactorRequiredException
import com.scarriffle.calendarr.domain.model.OidcProvider
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import net.openid.appauth.AuthorizationException
import javax.inject.Inject
@HiltViewModel
class AuthViewModel @Inject constructor(
private val repository: CalendarRepository,
private val credentialStore: CredentialStore,
private val oidc: OidcAuthenticator,
) : ViewModel() {
// Server setup
var serverUrl by mutableStateOf(credentialStore.serverUrl ?: "")
private set
var checking by mutableStateOf(false)
private set
var setupError by mutableStateOf<String?>(null)
private set
// Login
var username by mutableStateOf("")
private set
var password by mutableStateOf("")
private set
var totpCode by mutableStateOf("")
private set
var showTotp by mutableStateOf(false)
private set
var rememberMe by mutableStateOf(true)
private set
var loggingIn by mutableStateOf(false)
private set
var loginError by mutableStateOf<String?>(null)
private set
// Single sign-on
var ssoProviders by mutableStateOf<List<OidcProvider>>(emptyList())
private set
/** Key of the provider whose flow is running, or null. Per-provider so a
* second button does not also show a spinner. */
var ssoBusyKey by mutableStateOf<String?>(null)
private set
/** Stable server error slug (e.g. `oidc_account_not_linked`). The screen
* turns it into a message; the ViewModel cannot call the translator. */
var ssoErrorSlug by mutableStateOf<String?>(null)
private set
private var loadingProviders = false
fun onServerUrlChange(v: String) { serverUrl = v; setupError = null }
fun onUsernameChange(v: String) { username = v; loginError = null; ssoErrorSlug = null }
fun onPasswordChange(v: String) { password = v; loginError = null; ssoErrorSlug = null }
fun onTotpChange(v: String) { totpCode = v; loginError = null; ssoErrorSlug = null }
fun onRememberChange(v: Boolean) { rememberMe = v }
fun checkServer(onConfigured: () -> Unit) {
val url = serverUrl.trim()
if (url.isEmpty()) { setupError = "URL erforderlich"; return }
viewModelScope.launch {
checking = true
setupError = null
runCatching { repository.setupRequired(url) }
.onSuccess {
credentialStore.serverUrl = ApiProvider.normalize(url)
onConfigured()
}
.onFailure { setupError = it.message ?: "Verbindung fehlgeschlagen" }
checking = false
}
}
/** Load the configured providers. Silently yields an empty list when SSO
* is off or the server is older — the password form stays as it is. */
fun loadSsoProviders() {
val base = credentialStore.serverUrl ?: return
if (ssoProviders.isNotEmpty() || loadingProviders) return
loadingProviders = true
viewModelScope.launch {
runCatching { repository.oidcProviders(base) }
.onSuccess { ssoProviders = it }
loadingProviders = false
}
}
/**
* Start an SSO login: fetch the provider's configuration, build the PKCE
* request and open the Custom Tab.
*
* The provider key and client id are persisted before launching, because
* the browser runs in its own task and this process may well be killed
* while it is in the foreground.
*/
fun startSso(provider: OidcProvider, launch: (Intent) -> Unit) {
val clientId = provider.mobileClientId
if (clientId == null) { ssoErrorSlug = "oidc_unknown_client"; return }
viewModelScope.launch {
ssoBusyKey = provider.key
loginError = null
ssoErrorSlug = null
// `launch` has to stay inside runCatching: the result launcher is
// unregistered if the user leaves the screen while discovery is in
// flight, and the resulting IllegalStateException would otherwise
// escape viewModelScope and take the app down.
runCatching {
val config = oidc.discover(provider)
val request = oidc.buildRequest(config, provider)
credentialStore.oidcProvider = provider.key
credentialStore.oidcClientId = clientId
launch(oidc.authorizationIntent(request))
}.onFailure { e ->
ssoBusyKey = null
ssoErrorSlug = when (e) {
// No browser at all needs a different remedy than "the
// login service is down".
is ActivityNotFoundException -> "oidc_no_browser"
else -> "oidc_provider_unreachable"
}
}
}
}
/** Handle the browser's return: exchange the code, then trade the ID token. */
fun finishSso(data: Intent?, onSuccess: () -> Unit) {
if (data == null) { ssoBusyKey = null; return }
// AppAuth reports a user-cancelled flow as a normal result carrying
// USER_CANCELED_AUTH_FLOW — backing out of the browser is not an error.
val failure = AuthorizationException.fromIntent(data)
if (failure == AuthorizationException.GeneralErrors.USER_CANCELED_AUTH_FLOW) {
ssoBusyKey = null
return
}
val providerKey = credentialStore.oidcProvider
val clientId = credentialStore.oidcClientId
if (providerKey == null || clientId == null) {
ssoBusyKey = null
ssoErrorSlug = "oidc_failed"
return
}
viewModelScope.launch {
runCatching {
val (response, tokens) = oidc.exchangeCode(data)
val idToken = tokens.idToken ?: throw OidcException("oidc_no_id_token")
val result = repository.exchangeOidc(
baseUrl = credentialStore.serverUrl ?: error("no server"),
provider = providerKey,
clientId = clientId,
idToken = idToken,
accessToken = tokens.accessToken,
// AppAuth round-trips the original request through the
// redirect, so the nonce survives process death.
nonce = response.request.nonce,
)
// Keep the provider's refresh token so the session can be
// renewed later without another trip through the browser.
credentialStore.saveOidcSession(
state = oidc.stateFor(response, tokens),
provider = providerKey,
clientId = clientId,
)
result
}.onSuccess {
ssoBusyKey = null
onSuccess()
}.onFailure { e ->
ssoBusyKey = null
ssoErrorSlug = if (e is OidcException) e.slug else "oidc_failed"
}
}
}
fun login(onSuccess: () -> Unit) {
val base = credentialStore.serverUrl ?: return
if (username.isBlank() || password.isBlank()) {
loginError = "Benutzername und Passwort erforderlich"
return
}
viewModelScope.launch {
loggingIn = true
loginError = null
runCatching {
repository.login(
baseUrl = base,
username = username.trim(),
password = password,
totpCode = totpCode.trim().takeIf { it.isNotEmpty() },
rememberMe = rememberMe,
)
}.onSuccess { onSuccess() }
.onFailure { e ->
if (e is TwoFactorRequiredException) {
showTotp = true
loginError = null
} else {
loginError = e.message ?: "Anmeldung fehlgeschlagen"
}
}
loggingIn = false
}
}
}
@@ -0,0 +1,167 @@
package com.scarriffle.calendarr.ui.auth
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material3.Divider
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.scarriffle.calendarr.data.CredentialStore
import com.scarriffle.calendarr.ui.components.PasswordField
import com.scarriffle.calendarr.ui.tr
@Composable
fun LoginScreen(
serverUrl: String,
onLoggedIn: () -> Unit,
onBack: () -> Unit,
vm: AuthViewModel = hiltViewModel(),
) {
// Browser round-trip for SSO. AppAuth returns the authorization response
// as an Activity result, so nothing has to be wired into MainActivity.
val ssoLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result -> vm.finishSso(result.data, onLoggedIn) }
LaunchedEffect(Unit) { vm.loadSsoProviders() }
Column(modifier = Modifier.fillMaxSize().padding(horizontal = 28.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 12.dp)) {
IconButton(onClick = onBack) {
Icon(Icons.Filled.ArrowBack, contentDescription = tr("auth.back"))
}
Spacer(Modifier.height(0.dp))
Text(serverUrl, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(tr("auth.login_title"), style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(24.dp))
OutlinedTextField(
value = vm.username,
onValueChange = vm::onUsernameChange,
label = { Text(tr("auth.username")) },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(12.dp))
PasswordField(
value = vm.password,
onValueChange = vm::onPasswordChange,
label = tr("auth.password"),
modifier = Modifier.fillMaxWidth(),
)
AnimatedVisibility(vm.showTotp) {
Column {
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = vm.totpCode,
onValueChange = vm::onTotpChange,
label = { Text(tr("auth.totp")) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
modifier = Modifier.fillMaxWidth(),
)
}
}
Spacer(Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(tr("auth.remember"), style = MaterialTheme.typography.bodyMedium)
Switch(checked = vm.rememberMe, onCheckedChange = vm::onRememberChange)
}
vm.loginError?.let {
Spacer(Modifier.height(8.dp))
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
vm.ssoErrorSlug?.let { slug ->
// Unknown slugs fall through to the generic message rather than
// showing the raw key.
val key = "sso.err.$slug"
val msg = tr(key).takeIf { it != key } ?: tr("sso.err.generic")
Spacer(Modifier.height(8.dp))
Text(msg, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
Spacer(Modifier.height(20.dp))
Button(
onClick = { vm.login(onLoggedIn) },
enabled = !vm.loggingIn,
modifier = Modifier.fillMaxWidth(),
) {
if (vm.loggingIn) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
} else {
Text(tr("auth.login"))
}
}
if (vm.ssoProviders.isNotEmpty()) {
Spacer(Modifier.height(20.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Divider(modifier = Modifier.weight(1f))
Text(
tr("sso.or"),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 12.dp),
)
Divider(modifier = Modifier.weight(1f))
}
Spacer(Modifier.height(12.dp))
vm.ssoProviders.forEach { provider ->
OutlinedButton(
onClick = { vm.startSso(provider) { intent -> ssoLauncher.launch(intent) } },
enabled = vm.ssoBusyKey == null && !vm.loggingIn,
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
) {
if (vm.ssoBusyKey == provider.key) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
} else {
Text(tr("sso.login_with", provider.name))
}
}
}
}
TextButton(onClick = onBack) { Text(tr("server.switch")) }
}
}
}
/** Read the configured server URL for display. */
@Composable
fun rememberServerUrl(credentialStore: CredentialStore): String =
credentialStore.serverUrl ?: ""
@@ -0,0 +1,88 @@
package com.scarriffle.calendarr.ui.auth
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.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
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.scarriffle.calendarr.ui.tr
@Composable
fun ServerSetupScreen(
onConfigured: () -> Unit,
vm: AuthViewModel = hiltViewModel(),
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 28.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
Icons.Filled.CalendarMonth,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.height(64.dp),
)
Spacer(Modifier.height(16.dp))
Text("Calendarr", style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(8.dp))
Text(
tr("auth.server_hint"),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(28.dp))
OutlinedTextField(
value = vm.serverUrl,
onValueChange = vm::onServerUrlChange,
label = { Text(tr("auth.server_url")) },
placeholder = { Text("https://cal.example.com") },
singleLine = true,
isError = vm.setupError != null,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Go,
),
modifier = Modifier.fillMaxWidth(),
)
vm.setupError?.let {
Spacer(Modifier.height(8.dp))
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
Spacer(Modifier.height(20.dp))
Button(
onClick = { vm.checkServer(onConfigured) },
enabled = !vm.checking && vm.serverUrl.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) {
if (vm.checking) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
} else {
Text(tr("auth.continue"))
}
}
}
}
@@ -0,0 +1,118 @@
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
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.CalEvent
import com.scarriffle.calendarr.ui.LocalLang
import com.scarriffle.calendarr.ui.tr
import com.scarriffle.calendarr.util.colorFromHex
import java.time.LocalDate
import java.time.format.DateTimeFormatter
@Composable
fun AgendaView(
state: CalendarUiState,
vm: CalendarViewModel,
onEventClick: (CalEvent) -> Unit,
) {
val lang = LocalLang.current
val dimPast = com.scarriffle.calendarr.ui.LocalAppSettings.current.dimPastEvents
val now = java.time.Instant.now()
val today = LocalDate.now()
val days = (0 until 90).map { today.plusDays(it.toLong()) }
.map { it to vm.eventsOn(it, state.events) }
.filter { it.second.isNotEmpty() }
if (days.isEmpty()) {
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(tr("cal.no_events_title"), style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(6.dp))
Text(
tr("cal.no_events_body"),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
return
}
val headerFmt = DateTimeFormatter.ofPattern("EEEE, d. MMMM", com.scarriffle.calendarr.ui.L10n.locale(lang))
LazyColumn(Modifier.fillMaxSize().padding(horizontal = 12.dp)) {
days.forEach { (date, events) ->
item(key = "h-$date") {
Text(
headerFmt.format(date).replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = if (date == today) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onBackground,
modifier = Modifier.padding(top = 16.dp, bottom = 6.dp),
)
}
items(events, key = { "${date}-${it.id}" }) { ev ->
AgendaRow(ev, lang, dimmed = dimPast && ev.endDate.isBefore(now), onClick = { onEventClick(ev) })
}
}
item { Spacer(Modifier.height(80.dp)) }
}
}
@Composable
private fun AgendaRow(event: CalEvent, lang: String, dimmed: Boolean, onClick: () -> Unit) {
Row(
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.alpha(if (dimmed) 0.45f else 1f)
.clip(RoundedCornerShape(10.dp))
.background(MaterialTheme.colorScheme.surface)
.clickable(onClick = onClick)
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(Modifier.size(10.dp).clip(CircleShape).background(colorFromHex(event.effectiveColor)))
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
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)
}
}
Spacer(Modifier.width(8.dp))
Text(
eventTimeRange(event, lang),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@@ -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)
@@ -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() },
)
}
}
}
@@ -0,0 +1,168 @@
package com.scarriffle.calendarr.ui.calendar
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.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
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
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, val source: String = "", val readOnly: Boolean = false)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CalendarFilterSheet(
events: List<CalendarFilterEntry>,
vm: CalendarViewModel,
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()
.verticalScroll(rememberScrollState())
.navigationBarsPadding()
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(tr("filter.title"), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
Row {
TextButton(onClick = {
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 (rows.isEmpty()) {
Text(
tr("filter.empty"),
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 24.dp),
)
}
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 = {
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))
}
}
}
@@ -0,0 +1,122 @@
package com.scarriffle.calendarr.ui.calendar
import com.scarriffle.calendarr.domain.model.CalEvent
import com.scarriffle.calendarr.domain.model.CalViewType
import com.scarriffle.calendarr.ui.L10n
import java.time.Instant
import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.TextStyle
private val zone: ZoneId = ZoneId.systemDefault()
/** Localized month name with a hard fallback (guards against empty/numeric results). */
private fun monthName(date: LocalDate, style: TextStyle, loc: java.util.Locale): String {
val name = date.month.getDisplayName(style, loc)
val safe = if (name.isBlank() || name.all { it.isDigit() }) {
date.month.getDisplayName(TextStyle.FULL, java.util.Locale.ENGLISH)
} else {
name
}
return safe.replaceFirstChar { it.uppercase(loc) }
}
fun titleForView(viewType: CalViewType, date: LocalDate, lang: String): String {
val loc = L10n.locale(lang)
return when (viewType) {
CalViewType.MONTH -> {
// Use the FORMAT month style (TextStyle.FULL). The standalone style
// (FULL_STANDALONE / the "LLLL" pattern) drops the name for some
// months under desugared java.time (e.g. December showed only "2026").
val month = monthName(date, TextStyle.FULL, loc)
"$month ${date.year}"
}
CalViewType.QUARTER -> {
val endM = date.plusMonths(2)
"${monthName(date, TextStyle.SHORT, loc)} ${date.year} – ${monthName(endM, TextStyle.SHORT, loc)} ${endM.year}"
}
CalViewType.WEEK -> {
val start = date
val fmt = DateTimeFormatter.ofPattern("d. MMM", loc)
val endFmt = DateTimeFormatter.ofPattern("d. MMM yyyy", loc)
// Two lines for the compact top bar.
"${fmt.format(start)} –\n${endFmt.format(start.plusDays(6))}"
}
CalViewType.DAY -> {
val weekday = DateTimeFormatter.ofPattern("EEEE", loc).format(date)
val rest = DateTimeFormatter.ofPattern("d. MMMM yyyy", loc).format(date)
"$weekday\n$rest"
}
CalViewType.AGENDA -> L10n.t("view.agenda", lang)
}
}
fun weekdayLabels(mondayFirst: Boolean, lang: String): List<String> {
val loc = L10n.locale(lang)
val order = if (mondayFirst) {
listOf(1, 2, 3, 4, 5, 6, 7)
} else {
listOf(7, 1, 2, 3, 4, 5, 6)
}
return order.map {
java.time.DayOfWeek.of(it).getDisplayName(TextStyle.SHORT, loc).take(2)
}
}
fun timeLabel(instant: Instant, lang: String): String =
DateTimeFormatter.ofPattern("HH:mm", L10n.locale(lang)).withZone(zone).format(instant)
/** Opacity for secondary text (week number, overflow count) per text_contrast 1..4. */
fun secondaryTextOpacity(contrast: Int): Float = when (contrast.coerceIn(1, 4)) {
1 -> 0.40f
2 -> 0.55f
3 -> 0.72f
else -> 0.92f
}
/** Opacity for regular grid lines per line_contrast 1..4. */
fun gridLineOpacity(contrast: Int): Float = when (contrast.coerceIn(1, 4)) {
1 -> 0.15f
2 -> 0.30f
3 -> 0.50f
else -> 0.80f
}
fun localTime(instant: Instant): LocalTime = LocalTime.ofInstant(instant, zone)
fun localDate(instant: Instant): LocalDate = LocalDate.ofInstant(instant, zone)
/** Minutes-from-midnight of the instant in the local zone. */
fun minutesOfDay(instant: Instant): Int {
val t = localTime(instant)
return t.hour * 60 + t.minute
}
fun eventTimeRange(event: CalEvent, lang: String): String {
if (event.isAllDay) return L10n.t("cal.allday", lang)
return "${timeLabel(event.startDate, lang)} – ${timeLabel(event.endDate, lang)}"
}
/** Full human-readable date (+time) range for the detail sheet. */
fun eventDateRange(event: CalEvent, lang: String): String {
val loc = L10n.locale(lang)
val dateFmt = DateTimeFormatter.ofPattern("EEE, d. MMM yyyy", loc)
val startDate = localDate(event.startDate)
val endDate = localDate(event.endDate)
if (event.isAllDay) {
// All-day end is exclusive on the server; show the inclusive last day.
val lastDay = endDate.minusDays(1)
return if (lastDay.isAfter(startDate)) {
"${dateFmt.format(startDate)} – ${dateFmt.format(lastDay)}"
} else {
"${dateFmt.format(startDate)} · ${L10n.t("cal.allday", lang)}"
}
}
return if (startDate == endDate) {
"${dateFmt.format(startDate)}\n${timeLabel(event.startDate, lang)} – ${timeLabel(event.endDate, lang)}"
} else {
"${dateFmt.format(startDate)} ${timeLabel(event.startDate, lang)}\n– ${dateFmt.format(endDate)} ${timeLabel(event.endDate, lang)}"
}
}
@@ -0,0 +1,623 @@
package com.scarriffle.calendarr.ui.calendar
import androidx.compose.animation.AnimatedVisibility
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.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
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
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.LaunchedEffect
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
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import com.scarriffle.calendarr.domain.model.AppSettings
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.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, MENU, PROFILE, SETTINGS, ACCOUNTS, GROUPS }
data class EditorRequest(val existing: CalEvent?, val date: LocalDate, val prefill: CalEvent? = null)
@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 detailEvent by remember { mutableStateOf<CalEvent?>(null) }
// Downloading an attachment is two steps, like the .ics export: fetch the
// bytes, then let the user choose where to put them. Going through SAF
// means no FileProvider entry and no storage permission.
var pendingAttachment by remember { mutableStateOf<ByteArray?>(null) }
val saveAttachment = androidx.activity.compose.rememberLauncherForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.CreateDocument("*/*")
) { uri ->
val bytes = pendingAttachment
pendingAttachment = null
if (uri != null && bytes != null) {
runCatching { context.contentResolver.openOutputStream(uri)?.use { it.write(bytes) } }
}
}
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()
var todaySignal by remember { mutableIntStateOf(0) }
var monthJumpSignal by remember { mutableIntStateOf(0) }
var monthJumpTarget by remember { mutableStateOf<LocalDate?>(null) }
var visibleMonth by remember { mutableStateOf(state.currentDate) }
val isMonth = state.viewType == CalViewType.MONTH
val barTitle = if (isMonth) titleForView(CalViewType.MONTH, visibleMonth, lang)
else titleForView(state.viewType, state.currentDate, lang)
fun goPrev() {
if (isMonth) { monthJumpTarget = visibleMonth.minusMonths(1); monthJumpSignal++ } else vm.navigatePrev()
}
fun goNext() {
if (isMonth) { monthJumpTarget = visibleMonth.plusMonths(1); monthJumpSignal++ } else vm.navigateNext()
}
fun goToday() {
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(
title = barTitle,
viewType = state.viewType,
loading = state.isLoading || state.isBackgroundCaching,
viewMenuOpen = viewMenuOpen,
showMenuButton = !state.hideMenuButton,
onMenu = { drawerScope.launch { drawerState.open() } },
onPrev = { goPrev() },
onToday = { goToday() },
onNext = { goNext() },
onViewMenuToggle = { viewMenuOpen = it },
onSelectView = { vm.setViewType(it) },
)
},
floatingActionButton = {
// 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).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,
monthListState = monthListState,
scrollToTodaySignal = todaySignal,
monthJumpSignal = monthJumpSignal,
monthJumpTarget = monthJumpTarget,
onVisibleMonthChange = remember { { visibleMonth = it } },
onEventClick = remember { { detailEvent = it } },
onDayClick = remember(vm) { { date -> vm.goToDate(date, CalViewType.DAY) } },
onDayLongPress = remember { { date -> dayPreview = date } },
)
}
}
}
}
// ---- Sheets ----
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 (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
},
)
}
// Keep the last event during the close animation.
var lastDetail by remember { mutableStateOf<CalEvent?>(null) }
detailEvent?.let { lastDetail = it }
AnimatedVisibility(
visible = detailEvent != null,
enter = slideInVertically(initialOffsetY = { it / 6 }) + fadeIn(),
exit = slideOutVertically(targetOffsetY = { it / 6 }) + fadeOut(),
) {
val ev = lastDetail
if (ev != null) {
val attachments by vm.attachments.collectAsState()
val attachmentThumbs by vm.attachmentThumbs.collectAsState()
LaunchedEffect(ev.id) { vm.loadAttachments(ev) }
EventDetailScreen(
event = ev,
currentUserId = vm.currentUserId,
attachments = attachments,
attachmentThumbs = attachmentThumbs,
onOpenAttachment = { att ->
// Fetch the bytes, then let the user pick where to save
// them — the same two-step the .ics export already uses,
// which needs no FileProvider entry in the manifest.
vm.downloadAttachment(att) { bytes ->
pendingAttachment = bytes
saveAttachment.launch(att.filename)
}
},
onClose = { detailEvent = null },
onEdit = {
detailEvent = null
editor = EditorRequest(ev, localDate(ev.startDate))
},
onCopy = {
detailEvent = null
editor = EditorRequest(existing = null, date = localDate(ev.startDate), prefill = ev)
},
onDelete = {
vm.deleteEvent(ev) {}
detailEvent = null
},
)
}
}
editor?.let { req ->
// The editor needs the event's current attachments to offer removing
// them; loading is the same call the detail screen makes.
LaunchedEffect(req.existing?.id) { req.existing?.let { vm.loadAttachments(it) } }
val editorAttachments by vm.attachments.collectAsState()
EventEditorSheet(
request = req,
writableCalendars = state.writableCalendars,
existingAttachments = if (req.existing?.source == "local") editorAttachments else emptyList(),
onDismiss = { editor = null },
defaultDurationMinutes = vm.defaultEventDurationMinutes,
reminderDisabledKeys = state.reminderDisabledKeys,
onSave = { cal, title, start, end, allDay, location, desc, color, isPrivate, reminders, staged, removed ->
vm.saveEvent(cal, req.existing, title, start, end, allDay, location, desc, color, isPrivate, reminders, staged, removed) { error ->
if (error == null) editor = null
}
},
)
}
when (overlay) {
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.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
}
}
@Composable
private fun CalendarBody(
state: CalendarUiState,
vm: CalendarViewModel,
monthListState: androidx.compose.foundation.lazy.LazyListState,
scrollToTodaySignal: Int,
monthJumpSignal: Int,
monthJumpTarget: LocalDate?,
onVisibleMonthChange: (LocalDate) -> Unit,
onEventClick: (CalEvent) -> Unit,
onDayClick: (LocalDate) -> Unit,
onDayLongPress: (LocalDate) -> Unit,
) {
when (state.viewType) {
CalViewType.MONTH -> MonthView(
state = state,
vm = vm,
listState = monthListState,
scrollToTodaySignal = scrollToTodaySignal,
monthJumpSignal = monthJumpSignal,
monthJumpTarget = monthJumpTarget,
onVisibleMonthChange = onVisibleMonthChange,
onDayClick = onDayClick,
onDayLongPress = onDayLongPress,
onEventClick = onEventClick,
)
CalViewType.WEEK -> WeekView(state, vm, onEventClick)
CalViewType.DAY -> DayView(state, vm, onEventClick)
CalViewType.QUARTER -> QuarterView(state, onDayClick)
CalViewType.AGENDA -> AgendaView(state, vm, onEventClick)
}
}
@Composable
private fun ErrorBanner(message: String, onRetry: () -> Unit, onDismiss: () -> Unit) {
androidx.compose.material3.Surface(
color = MaterialTheme.colorScheme.errorContainer,
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.padding(12.dp)) {
Text(message, color = MaterialTheme.colorScheme.onErrorContainer, style = MaterialTheme.typography.bodySmall)
androidx.compose.foundation.layout.Row {
TextButton(onClick = onRetry) { Text(tr("common.retry")) }
TextButton(onClick = onDismiss) { Text(tr("common.close")) }
}
}
}
}
/**
* 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) {
CircularProgressIndicator()
}
}
@Composable
private fun CompactTopBar(
title: String,
viewType: CalViewType,
loading: Boolean,
viewMenuOpen: Boolean,
showMenuButton: Boolean,
onMenu: () -> Unit,
onPrev: () -> Unit,
onToday: () -> Unit,
onNext: () -> 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)
}
CompactIcon(Icons.Filled.ChevronRight, onNext)
Text(
title,
modifier = Modifier.weight(1f).padding(horizontal = 4.dp),
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
fontSize = if (twoLine) 13.sp else 16.sp,
lineHeight = if (twoLine) 15.sp else 18.sp,
fontWeight = FontWeight.Medium,
)
if (loading) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp).padding(end = 2.dp),
strokeWidth = 2.dp,
)
}
Box {
CompactIcon(viewType.icon, { onViewMenuToggle(true) }, tr("view.change"))
DropdownMenu(expanded = viewMenuOpen, onDismissRequest = { onViewMenuToggle(false) }) {
CalViewType.entries.forEach { type ->
DropdownMenuItem(
text = { Text(tr("view.${type.key}")) },
leadingIcon = { Icon(type.icon, contentDescription = null) },
onClick = { onViewMenuToggle(false); onSelectView(type) },
)
}
}
}
}
Divider(
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.7f),
)
}
}
}
@Composable
private fun CompactIcon(
icon: androidx.compose.ui.graphics.vector.ImageVector,
onClick: () -> Unit,
contentDescription: String? = null,
) {
IconButton(onClick = onClick, modifier = Modifier.size(40.dp)) {
Icon(icon, contentDescription = contentDescription, modifier = Modifier.size(22.dp))
}
}
@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 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() }
}
@@ -0,0 +1,873 @@
package com.scarriffle.calendarr.ui.calendar
import android.graphics.BitmapFactory
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
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.EventAttachment
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.StagedAttachment
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
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import javax.inject.Inject
/** Build the "source:id" visibility key, stripping any "<source>-" prefix. */
fun calendarKey(source: String, calendarId: String): String {
val prefix = "$source-"
val id = if (calendarId.startsWith(prefix)) calendarId.removePrefix(prefix) else calendarId
return "$source:$id"
}
data class CalendarUiState(
val viewType: CalViewType = CalViewType.MONTH,
val currentDate: LocalDate = LocalDate.now(),
val events: List<CalEvent> = emptyList(),
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,
private val settingsStore: SettingsStore,
) : ViewModel() {
private val zone: ZoneId = ZoneId.systemDefault()
/** Current user id (for creator/owner comparisons in the UI). */
val currentUserId: Int get() = repository.currentUserId
/** Serializes network loads so overlapping fetches don't thrash the UI. */
private val loadMutex = Mutex()
private val _state = MutableStateFlow(initialState())
val state: StateFlow<CalendarUiState> = _state.asStateFlow()
/** True once the first event load has completed — used to gate the splash. */
private val _ready = MutableStateFlow(false)
val ready: StateFlow<Boolean> = _ready.asStateFlow()
// Cache bookkeeping
private var cachedStart: Instant? = null
private var cachedEnd: Instant? = null
private var allCachedEvents: List<CalEvent> = emptyList()
init {
loadWritableCalendars()
loadGroups()
initialLoad(reconcile = true)
}
/**
* Load the full ±cacheMonths window once, behind the splash, and only then
* 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(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()
}
}
private fun initialState(): CalendarUiState {
val s = settingsStore.loadSettings()
return CalendarUiState(
viewType = CalViewType.fromKey(s.defaultView),
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) {
_state.update { it.copy(viewType = type) }
loadVisible()
}
fun moveToToday() {
_state.update { it.copy(currentDate = LocalDate.now()) }
loadVisible()
}
fun navigatePrev() = navigate(-1)
fun navigateNext() = navigate(+1)
private fun navigate(direction: Int) {
val st = _state.value
val date = st.currentDate
val newDate = when (st.viewType) {
CalViewType.WEEK -> date.plusWeeks(direction.toLong())
CalViewType.DAY -> date.plusDays(direction.toLong())
CalViewType.QUARTER -> date.plusMonths((3 * direction).toLong())
else -> date.plusMonths(direction.toLong())
}
_state.update { it.copy(currentDate = newDate) }
loadVisible()
}
fun goToDate(date: LocalDate, viewType: CalViewType? = null) {
_state.update { it.copy(currentDate = date, viewType = viewType ?: it.viewType) }
loadVisible()
}
// ---- Range ----
fun rangeForCurrentView(): Pair<Instant, Instant> {
val st = _state.value
val date = st.currentDate
return when (st.viewType) {
CalViewType.MONTH -> {
val start = date.withDayOfMonth(1)
instant(start.minusMonths(1)) to instant(start.plusMonths(2))
}
CalViewType.QUARTER -> {
val start = date.withDayOfMonth(1)
instant(start) to instant(start.plusMonths(4))
}
CalViewType.WEEK -> {
val weekStart = startOfWeek(date, st.weekStartsOnMonday)
instant(weekStart) to instant(weekStart.plusDays(8))
}
CalViewType.DAY -> instant(date) to instant(date.plusDays(1))
CalViewType.AGENDA -> {
val today = LocalDate.now()
instant(today) to instant(today.plusDays(90))
}
}
}
private fun instant(date: LocalDate): Instant = date.atStartOfDay(zone).toInstant()
private fun startOfWeek(date: LocalDate, mondayFirst: Boolean): LocalDate {
val dow = date.dayOfWeek.value // Mon=1 .. Sun=7
val offset = if (mondayFirst) dow - 1 else dow % 7
return date.minusDays(offset.toLong())
}
// ---- Loading ----
fun loadVisible(force: Boolean = false) {
val (start, end) = rangeForCurrentView()
if (!force && isCached(start, end)) {
refreshFromCache()
markReady()
return
}
viewModelScope.launch {
loadRange(start, end, background = false, force = force)
markReady()
}
}
/** On-demand load for a month the user scrolled to in the continuous calendar. */
fun ensureMonthLoaded(monthAnchor: LocalDate) {
val first = monthAnchor.withDayOfMonth(1)
val start = instant(first.minusMonths(1))
val end = instant(first.plusMonths(2))
if (isCached(start, end)) return
viewModelScope.launch { loadRange(start, end, background = false) }
}
/** Single serialized loader: avoids overlapping fetches that cause scroll jank. */
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.
// `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 {
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
}
private fun isCached(start: Instant, end: Instant): Boolean {
val cs = cachedStart ?: return false
val ce = cachedEnd ?: return false
return !cs.isAfter(start) && !ce.isBefore(end)
}
/**
* 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
// 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 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) }
}
private fun invalidateCache() {
cachedStart = null
cachedEnd = null
allCachedEvents = emptyList()
}
fun syncWithServer() {
invalidateCache()
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) {
val next = _state.value.hiddenKeys.toMutableSet().apply {
if (hidden) add(key) else remove(key)
}
settingsStore.hiddenCalendarKeys = next
_state.update { it.copy(hiddenKeys = next) }
refreshFromCache()
}
fun setHiddenCalendars(keys: Set<String>) {
settingsStore.hiddenCalendarKeys = keys
_state.update { it.copy(hiddenKeys = keys) }
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)
}
val nextHidden = _state.value.hiddenKeys.toMutableSet().apply {
if (banished) remove(key)
}
settingsStore.banishedCalendarKeys = nextBanished
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 ----
fun loadWritableCalendars() {
viewModelScope.launch {
runCatching { repository.getWritableCalendars() }
.onSuccess { cals -> _state.update { it.copy(writableCalendars = cals) } }
}
}
// ---- 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() {
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(
calendar: WritableCalendar,
existing: CalEvent?,
title: String,
start: Instant,
end: Instant,
isAllDay: Boolean,
location: String,
description: String,
color: String?,
isPrivate: Boolean,
reminders: List<Int> = emptyList(),
stagedAttachments: List<StagedAttachment> = emptyList(),
removedAttachmentIds: Set<Int> = emptySet(),
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, reminders)
applyAttachmentChanges(existing.id, stagedAttachments, removedAttachmentIds)
}
"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, reminders, stagedAttachments)
}
} else {
createForSource(calendar, title, start, end, isAllDay, location, description, color, isPrivate, reminders, stagedAttachments)
}
}
result.onSuccess { afterMutation(); onResult(null) }
.onFailure { onResult(it.message ?: "Fehler") }
}
}
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(),
stagedAttachments: List<StagedAttachment> = emptyList(),
) {
when (calendar.source) {
"local" -> {
// Only now does a uid exist to attach the staged files to.
val uid = repository.createLocalEvent(calendar.numericId, title, start, end, isAllDay, location, description, color, isPrivate, reminders)
if (uid != null) applyAttachmentChanges(uid, stagedAttachments, emptySet())
}
"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)
}
}
// ---- Attachments ----
// The event payload carries only a count, so the list is fetched when the
// detail screen opens. Thumbnails are small server-rendered JPEGs, decoded
// here rather than pulling in an image-loading dependency for them.
private val _attachments = MutableStateFlow<List<EventAttachment>>(emptyList())
val attachments: StateFlow<List<EventAttachment>> = _attachments.asStateFlow()
private val _attachmentThumbs = MutableStateFlow<Map<Int, ImageBitmap>>(emptyMap())
val attachmentThumbs: StateFlow<Map<Int, ImageBitmap>> = _attachmentThumbs.asStateFlow()
fun loadAttachments(event: CalEvent) {
clearAttachments()
if (event.source != "local" || event.attachmentCount <= 0) return
viewModelScope.launch {
val loaded = runCatching { repository.listAttachments(event.id) }.getOrDefault(emptyList())
_attachments.value = loaded
val thumbs = mutableMapOf<Int, ImageBitmap>()
for (att in loaded.filter { it.hasThumbnail }) {
val bytes = repository.attachmentThumbnail(att.id) ?: continue
val bitmap = withContext(Dispatchers.IO) {
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
} ?: continue
thumbs[att.id] = bitmap.asImageBitmap()
}
_attachmentThumbs.value = thumbs
}
}
/** Applied after the event itself was saved; a failure leaves the event. */
private suspend fun applyAttachmentChanges(
uid: String,
staged: List<StagedAttachment>,
removedIds: Set<Int>,
) {
for (id in removedIds) runCatching { repository.deleteAttachment(id) }
for (file in staged) {
runCatching { repository.uploadAttachment(uid, file.bytes, file.filename, file.mimeType) }
}
}
fun clearAttachments() {
_attachments.value = emptyList()
_attachmentThumbs.value = emptyMap()
}
/** Download the bytes and hand them to the caller (which writes them via SAF). */
fun downloadAttachment(att: EventAttachment, onReady: (ByteArray) -> Unit) {
viewModelScope.launch {
runCatching { repository.downloadAttachment(att.id) }.onSuccess(onReady)
}
}
fun deleteEvent(event: CalEvent, onResult: (String?) -> Unit) {
viewModelScope.launch {
val result = runCatching {
when (event.source) {
"local" -> repository.deleteLocalEvent(event.id)
"caldav" -> repository.deleteCalDAVEvent(event.id, event.url, calendarNumericId(event))
"homeassistant" -> repository.deleteHAEvent(calendarNumericId(event) ?: 0, event.id)
"google" -> repository.deleteGoogleEvent(calendarNumericId(event) ?: 0, event.id)
else -> Unit
}
}
result.onSuccess {
removeCachedEvent(event.id)
onResult(null)
}.onFailure { onResult(it.message ?: "Fehler") }
}
}
private fun calendarNumericId(event: CalEvent): Int? {
val key = calendarKey(event.source, event.calendarId)
return key.substringAfter(":").toIntOrNull()
}
private fun removeCachedEvent(id: String) {
allCachedEvents = allCachedEvents.filterNot { it.id == id }
refreshFromCache()
}
/** Events overlapping a single day, sorted by start. */
fun eventsOn(date: LocalDate, events: List<CalEvent>): List<CalEvent> {
val dayStart = instant(date)
val dayEnd = instant(date.plusDays(1))
return events.filter { it.startDate.isBefore(dayEnd) && it.endDate.isAfter(dayStart) }
.sortedBy { it.startDate }
}
}
@@ -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)
}
}
@@ -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),
)
}
}
@@ -0,0 +1,512 @@
package com.scarriffle.calendarr.ui.calendar
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.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.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
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.scarriffle.calendarr.domain.model.CalEvent
import com.scarriffle.calendarr.ui.LocalAppSettings
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.DayOfWeek
import java.time.LocalDate
import java.time.format.TextStyle
import java.time.temporal.ChronoUnit
import java.time.temporal.IsoFields
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
private const val MONTHS_BACK = 120L // 10 years back
private const val MONTHS_AHEAD = 600L // 50 years ahead
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
// 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 }
/** A placed event bar within a week (with colours resolved once, not per frame). */
private data class PlacedBar(
val event: CalEvent,
val lane: Int,
val startCol: Int,
val span: Int,
val color: Color,
val textColor: Color,
)
private class WeekLayout(val bars: List<PlacedBar>, val overflowPerCol: IntArray)
/** 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,
vm: CalendarViewModel,
listState: LazyListState,
scrollToTodaySignal: Int,
monthJumpSignal: Int,
monthJumpTarget: LocalDate?,
onVisibleMonthChange: (LocalDate) -> Unit,
onDayClick: (LocalDate) -> Unit,
onDayLongPress: (LocalDate) -> Unit,
onEventClick: (CalEvent) -> Unit,
) {
val lang = LocalLang.current
val settings = LocalAppSettings.current
val mondayFirst = state.weekStartsOnMonday
val today = LocalDate.now()
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
val fallbackCellW = LocalConfiguration.current.screenWidthDp.dp / 7
var gridWidthPx by remember { mutableIntStateOf(0) }
val cellW: Dp = if (gridWidthPx > 0) with(density) { (gridWidthPx / 7f).toDp() } else fallbackCellW
val firstVisible = remember(mondayFirst) {
startOfWeek(today.withDayOfMonth(1).minusMonths(MONTHS_BACK), mondayFirst)
}
val end = remember(mondayFirst) {
startOfWeek(today.withDayOfMonth(1).plusMonths(MONTHS_AHEAD), mondayFirst)
}
val weekCount = remember(firstVisible, end) {
(ChronoUnit.WEEKS.between(firstVisible, end).toInt() + 1).coerceAtLeast(1)
}
fun weekIndexOf(date: LocalDate): Int =
ChronoUnit.WEEKS.between(firstVisible, startOfWeek(date, mondayFirst)).toInt().coerceIn(0, weekCount - 1)
val todayIndex = remember(firstVisible) { weekIndexOf(today) }
// 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 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)) {
weekdayLabels(mondayFirst, lang).forEach { label ->
Text(
label,
modifier = Modifier.weight(1f),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
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,
)
}
}
}
}
}
@Composable
private fun WeekRow(
rowModifier: Modifier,
weekStart: LocalDate,
today: LocalDate,
weekEvents: List<CalEvent>,
cellW: Dp,
dimPast: Boolean,
now: java.time.Instant,
lang: String,
cwLabel: String,
dividerColor: Color,
gridColor: Color,
labelColor: Color,
secondaryText: Color,
todayColor: Color,
onDayClick: (LocalDate) -> Unit,
onDayLongPress: (LocalDate) -> Unit,
onEventClick: (CalEvent) -> Unit,
) {
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 packed = remember(weekStart, weekEvents) { packEvents(weekStart, weekEvents) }
// 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 {
boundaryCol != null -> if (idx < boundaryCol) DividerEdge.BOTTOM else DividerEdge.TOP
rowStartsNewMonth -> DividerEdge.TOP
else -> DividerEdge.NONE
}
DayCellBackground(
day = day,
isToday = day == today,
isMonday = day.dayOfWeek == DayOfWeek.MONDAY,
weekNumber = day.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR),
cwLabel = cwLabel,
overflow = packed.overflowPerCol[idx],
lang = lang,
edge = edge,
dividerColor = dividerColor,
gridColor = gridColor,
labelColor = labelColor,
secondaryText = secondaryText,
todayColor = todayColor,
onClick = { onDayClick(day) },
onLongClick = { onDayLongPress(day) },
modifier = Modifier.weight(1f).fillMaxHeight(),
)
}
}
packed.bars.forEach { bar ->
val past = dimPast && bar.event.endDate.isBefore(now)
EventBar(bar = bar, cellW = cellW, dimmed = past, onClick = { onEventClick(bar.event) })
}
if (boundaryCol != null) {
Box(
Modifier
.offset(x = cellW * boundaryCol)
.width(1.5.dp)
.fillMaxHeight()
.background(dividerColor),
)
}
}
}
@Composable
private fun EventBar(bar: PlacedBar, cellW: Dp, dimmed: Boolean, onClick: () -> Unit) {
Box(
Modifier
.offset(
x = cellW * bar.startCol + 1.dp,
y = DAY_NUM_H + (LANE_H + LANE_SPACE) * bar.lane,
)
.width(cellW * bar.span - 2.dp)
.height(LANE_H)
.alpha(if (dimmed) 0.45f else 1f)
.clip(RoundedCornerShape(3.dp))
.background(bar.color)
.clickable(onClick = onClick)
.padding(horizontal = 4.dp),
contentAlignment = Alignment.CenterStart,
) {
EventLabel(
event = bar.event,
color = bar.textColor,
fontSize = 10.sp,
fontWeight = FontWeight.Medium,
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun DayCellBackground(
day: LocalDate,
isToday: Boolean,
isMonday: Boolean,
weekNumber: Int,
cwLabel: String,
overflow: Int,
lang: String,
edge: DividerEdge,
dividerColor: Color,
gridColor: Color,
labelColor: Color,
secondaryText: Color,
todayColor: Color,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val isFirst = day.dayOfMonth == 1
val onBg = MaterialTheme.colorScheme.onBackground
Box(
modifier = modifier
.drawBehind {
val gridW = 0.5.dp.toPx()
val divW = 1.5.dp.toPx()
if (edge == DividerEdge.TOP) {
drawLine(dividerColor, Offset(0f, 0f), Offset(size.width, 0f), divW)
} else {
drawLine(gridColor, Offset(0f, 0f), Offset(size.width, 0f), gridW)
}
if (edge == DividerEdge.BOTTOM) {
drawLine(dividerColor, Offset(0f, size.height), Offset(size.width, size.height), divW)
}
drawLine(gridColor, Offset(size.width, 0f), Offset(size.width, size.height), gridW)
}
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
.padding(horizontal = 1.dp),
) {
Row(
Modifier.fillMaxWidth().height(DAY_NUM_H).padding(top = 3.dp),
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
if (isFirst) {
Text(
day.month.getDisplayName(TextStyle.SHORT, com.scarriffle.calendarr.ui.L10n.locale(lang)).uppercase(),
fontSize = 8.sp,
fontWeight = FontWeight.SemiBold,
color = labelColor,
modifier = Modifier.padding(end = 2.dp),
)
}
Box(contentAlignment = Alignment.Center) {
if (isToday) {
Box(Modifier.height(18.dp).width(18.dp).clip(RoundedCornerShape(9.dp)).background(todayColor))
}
Text(
"${day.dayOfMonth}",
fontSize = 11.sp,
fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal,
color = if (isToday) todayColor.contrastingTextColor() else onBg,
)
}
}
if (overflow > 0) {
Text(
"+$overflow",
fontSize = 8.sp,
color = secondaryText,
modifier = Modifier.align(Alignment.BottomStart).padding(start = 3.dp, bottom = 1.dp),
)
}
if (isMonday) {
Text(
"$cwLabel $weekNumber",
fontSize = 8.sp,
color = secondaryText,
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 3.dp, bottom = 1.dp),
)
}
}
}
private fun startOfWeek(date: LocalDate, mondayFirst: Boolean): LocalDate {
val dow = date.dayOfWeek.value
val offset = if (mondayFirst) dow - 1 else dow % 7
return date.minusDays(offset.toLong())
}
/** Columns [startCol, startCol+span-1] this event occupies within the given week. */
private fun columnRange(weekStart: LocalDate, ev: CalEvent): Pair<Int, Int> {
val weekEnd = weekStart.plusDays(7)
val evStartDay = maxOf(localDate(ev.startDate), weekStart)
val lastDay = localDate(ev.endDate.minusSeconds(1))
val evEndDay = minOf(lastDay, weekEnd.minusDays(1))
val sc = ChronoUnit.DAYS.between(weekStart, evStartDay).toInt().coerceIn(0, 6)
val ec = ChronoUnit.DAYS.between(weekStart, evEndDay).toInt().coerceIn(0, 6)
return sc to (ec - sc + 1).coerceAtLeast(1)
}
/** Greedy first-fit lane packing (mirrors iOS packEvents); colours resolved here. */
private fun packEvents(weekStart: LocalDate, weekEvents: List<CalEvent>): WeekLayout {
val sorted = weekEvents.sortedWith(compareBy<CalEvent> { it.startDate }.thenByDescending { it.endDate })
val laneLastEnd = ArrayList<Int>()
val bars = ArrayList<PlacedBar>()
val overflow = IntArray(7)
for (ev in sorted) {
val (sc, span) = columnRange(weekStart, ev)
val lastCol = (sc + span - 1).coerceAtMost(6)
var assigned = -1
for (i in laneLastEnd.indices) {
if (laneLastEnd[i] < sc) { laneLastEnd[i] = lastCol; assigned = i; break }
}
if (assigned == -1 && laneLastEnd.size < MAX_LANES) {
laneLastEnd.add(lastCol); assigned = laneLastEnd.size - 1
}
if (assigned >= 0) {
val color = colorFromHex(ev.effectiveColor)
bars.add(PlacedBar(ev, assigned, sc, span, color, color.contrastingTextColor()))
} else {
for (c in sc..lastCol) overflow[c]++
}
}
return WeekLayout(bars, overflow)
}
/** Bucket events into every week (week-start key) they overlap. */
private fun buildEventsByWeek(events: List<CalEvent>, mondayFirst: Boolean): Map<LocalDate, List<CalEvent>> {
val map = HashMap<LocalDate, MutableList<CalEvent>>()
for (ev in events) {
val startD = localDate(ev.startDate)
val firstWeek = startOfWeek(startD, mondayFirst)
val lastDay = localDate(ev.endDate.minusSeconds(1)).let { if (it.isBefore(startD)) startD else it }
val lastWeek = startOfWeek(lastDay, mondayFirst)
var w = firstWeek
var guard = 0
while (!w.isAfter(lastWeek) && guard < 120) {
map.getOrPut(w) { mutableListOf() }.add(ev)
w = w.plusWeeks(1)
guard++
}
}
return map
}
@@ -0,0 +1,104 @@
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.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.scarriffle.calendarr.ui.LocalAppSettings
import com.scarriffle.calendarr.ui.LocalLang
import com.scarriffle.calendarr.util.colorFromHex
import com.scarriffle.calendarr.util.contrastingTextColor
import java.time.LocalDate
import java.time.format.DateTimeFormatter
@Composable
fun QuarterView(state: CalendarUiState, onDayClick: (LocalDate) -> Unit) {
val lang = LocalLang.current
val months = (0 until 3).map { state.currentDate.withDayOfMonth(1).plusMonths(it.toLong()) }
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(12.dp)) {
months.forEach { month ->
MiniMonth(month, state, lang, onDayClick)
}
}
}
@Composable
private fun MiniMonth(month: LocalDate, state: CalendarUiState, lang: String, onDayClick: (LocalDate) -> Unit) {
val mondayFirst = state.weekStartsOnMonday
val today = LocalDate.now()
val titleFmt = DateTimeFormatter.ofPattern("LLLL yyyy", com.scarriffle.calendarr.ui.L10n.locale(lang))
val dow = month.dayOfWeek.value
val offset = if (mondayFirst) dow - 1 else dow % 7
val firstVisible = month.minusDays(offset.toLong())
val cells = (0 until 42).map { firstVisible.plusDays(it.toLong()) }
Column(Modifier.padding(bottom = 16.dp)) {
Text(
titleFmt.format(month).replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(bottom = 4.dp),
)
Row(Modifier.fillMaxWidth()) {
weekdayLabels(mondayFirst, lang).forEach {
Text(it, Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 9.sp, color = MaterialTheme.colorScheme.outline)
}
}
cells.chunked(7).forEach { week ->
Row(Modifier.fillMaxWidth()) {
week.forEach { day ->
val hasEvents = state.events.any {
localDate(it.startDate) <= day && localDate(it.endDate) >= day
}
val firstColor = state.events.firstOrNull {
localDate(it.startDate) <= day && localDate(it.endDate) >= day
}?.effectiveColor
Box(
Modifier.weight(1f).aspectRatio(1f).clickable { onDayClick(day) },
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
val isToday = day == today
Box(contentAlignment = Alignment.Center) {
if (isToday) {
Box(
Modifier.clip(CircleShape)
.background(colorFromHex(LocalAppSettings.current.todayColor))
.padding(horizontal = 5.dp, vertical = 2.dp),
) { Text("${day.dayOfMonth}", fontSize = 10.sp, color = colorFromHex(LocalAppSettings.current.todayColor).contrastingTextColor()) }
} else {
Text(
"${day.dayOfMonth}",
fontSize = 10.sp,
color = if (day.month == month.month) MaterialTheme.colorScheme.onBackground else MaterialTheme.colorScheme.outline,
)
}
}
if (hasEvents) {
Box(Modifier.padding(top = 1.dp).clip(CircleShape).background(colorFromHex(firstColor)).padding(2.dp))
}
}
}
}
}
}
}
}
@@ -0,0 +1,267 @@
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.Spacer
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
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
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.layout
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.scarriffle.calendarr.domain.model.CalEvent
import com.scarriffle.calendarr.ui.LocalAppSettings
import com.scarriffle.calendarr.ui.LocalLang
import com.scarriffle.calendarr.util.colorFromHex
import com.scarriffle.calendarr.util.contrastingTextColor
import java.time.LocalDate
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>,
state: CalendarUiState,
vm: CalendarViewModel,
onEventClick: (CalEvent) -> Unit,
) {
val hourHeight = LocalAppSettings.current.hourHeight.coerceIn(28, 100).dp
val dimPast = LocalAppSettings.current.dimPastEvents
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 = remember(lang) {
DateTimeFormatter.ofPattern("EEE d", com.scarriffle.calendarr.ui.L10n.locale(lang))
}
days.forEach { day ->
Text(
dayFmt.format(day),
modifier = Modifier.weight(1f).padding(vertical = 4.dp),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelSmall,
fontWeight = if (day == today) FontWeight.Bold else FontWeight.Normal,
color = if (day == today) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
// 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
}
}
// 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()))
}
}
}
Divider()
}
// Scrollable hour grid
Box(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {
Box(Modifier.fillMaxWidth().height(hourHeight * 24)) {
// Hour lines + labels
for (h in 0..24) {
val y = hourHeight * h.toFloat()
Divider(
modifier = Modifier
.fillMaxWidth()
.padding(start = GUTTER)
.offset(y = y),
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
)
if (h < 24) {
Text(
"%02d:00".format(h),
modifier = Modifier.width(GUTTER).offset(y = y + 2.dp).padding(end = 4.dp),
textAlign = TextAlign.End,
fontSize = 9.sp,
color = MaterialTheme.colorScheme.outline,
)
}
}
// Timed events per day column
days.forEachIndexed { index, day ->
val timed = vm.eventsOn(day, state.events).filter { !it.isAllDay }
timed.forEach { ev ->
TimedEvent(
event = ev,
day = day,
index = index,
dayCount = days.size,
hourHeight = hourHeight,
dimmed = dimPast && ev.endDate.isBefore(now),
onClick = { onEventClick(ev) },
)
}
}
}
}
}
}
@Composable
private fun TimedEvent(
event: CalEvent,
day: LocalDate,
index: Int,
dayCount: Int,
hourHeight: androidx.compose.ui.unit.Dp,
dimmed: Boolean,
onClick: () -> Unit,
) {
val startMin = if (localDate(event.startDate).isBefore(day)) 0 else minutesOfDay(event.startDate)
val endMin = if (localDate(event.endDate).isAfter(day)) 24 * 60 else minutesOfDay(event.endDate)
val duration = (endMin - startMin).coerceAtLeast(30)
val top = hourHeight * (startMin / 60f)
val height = (hourHeight * (duration / 60f))
val color = colorFromHex(event.effectiveColor)
val lang = LocalLang.current
Box(
modifier = Modifier
.offset(y = top)
.columnSlot(index, dayCount)
.height(height)
.padding(horizontal = 1.dp, vertical = 0.5.dp)
.alpha(if (dimmed) 0.45f else 1f)
.clip(RoundedCornerShape(4.dp))
.background(color)
.clickable(onClick = onClick)
.padding(horizontal = 4.dp, vertical = 2.dp),
) {
Column {
EventLabel(
event = event,
color = color.contrastingTextColor(),
fontSize = 10.sp,
fontWeight = FontWeight.Medium,
maxLines = 2,
)
if (height > 36.dp) {
Text(
eventTimeRange(event, lang),
fontSize = 8.sp,
color = color.contrastingTextColor().copy(alpha = 0.85f),
)
}
}
}
}
/** Position a child in column [index] of [dayCount] columns, after the gutter. */
private fun Modifier.columnSlot(index: Int, dayCount: Int): Modifier = layout { measurable, constraints ->
val gutterPx = GUTTER.roundToPx()
val available = (constraints.maxWidth - gutterPx).coerceAtLeast(0)
val colWidth = available / dayCount
val placeable = measurable.measure(
constraints.copy(minWidth = colWidth, maxWidth = colWidth)
)
layout(constraints.maxWidth, placeable.height) {
placeable.place(gutterPx + colWidth * index, 0)
}
}
@Composable
private fun AllDayChip(event: CalEvent, dimmed: Boolean, onClick: () -> Unit) {
val color = colorFromHex(event.effectiveColor)
Box(
Modifier
.fillMaxWidth()
.padding(vertical = 1.dp)
.alpha(if (dimmed) 0.45f else 1f)
.clip(RoundedCornerShape(3.dp))
.background(color)
.clickable(onClick = onClick)
.padding(horizontal = 3.dp, vertical = 1.dp),
) {
EventLabel(event = event, color = color.contrastingTextColor(), fontSize = 9.sp)
}
}
@Composable
fun DayView(state: CalendarUiState, vm: CalendarViewModel, onEventClick: (CalEvent) -> Unit) {
TimeGridView(listOf(state.currentDate), state, vm, onEventClick)
}
@Composable
fun WeekView(state: CalendarUiState, vm: CalendarViewModel, onEventClick: (CalEvent) -> Unit) {
val mondayFirst = state.weekStartsOnMonday
val dow = state.currentDate.dayOfWeek.value
val offset = if (mondayFirst) dow - 1 else dow % 7
val weekStart = state.currentDate.minusDays(offset.toLong())
val days = (0 until 7).map { weekStart.plusDays(it.toLong()) }
TimeGridView(days, state, vm, onEventClick)
}
@@ -0,0 +1,152 @@
package com.scarriffle.calendarr.ui.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
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.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
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.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
import com.scarriffle.calendarr.util.colorFromHex
import com.scarriffle.calendarr.util.toHex
/**
* Full HSV colour picker: saturation/value field + hue slider + hex input.
* Returns a "#RRGGBB" string via [onConfirm].
*/
@Composable
fun ColorPickerDialog(
initial: String,
title: String,
onDismiss: () -> Unit,
onConfirm: (String) -> Unit,
) {
val initHsv = remember(initial) {
FloatArray(3).also { android.graphics.Color.colorToHSV(colorFromHex(initial).toArgb(), it) }
}
var hue by remember { mutableFloatStateOf(initHsv[0]) }
var sat by remember { mutableFloatStateOf(initHsv[1]) }
var value by remember { mutableFloatStateOf(initHsv[2]) }
var hexText by remember { mutableStateOf(colorFromHex(initial).toHex()) }
val current = Color.hsv(hue, sat, value)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column(Modifier.fillMaxWidth()) {
// Saturation / Value field
Box(
Modifier
.fillMaxWidth()
.height(170.dp)
.clip(RoundedCornerShape(10.dp))
.background(Brush.horizontalGradient(listOf(Color.White, Color.hsv(hue, 1f, 1f))))
.background(Brush.verticalGradient(listOf(Color.Transparent, Color.Black)))
.pointerInput(Unit) {
detectTapGestures { o ->
sat = (o.x / size.width).coerceIn(0f, 1f)
value = (1f - o.y / size.height).coerceIn(0f, 1f)
hexText = Color.hsv(hue, sat, value).toHex()
}
}
.pointerInput(Unit) {
detectDragGestures { change, _ ->
change.consume()
sat = (change.position.x / size.width).coerceIn(0f, 1f)
value = (1f - change.position.y / size.height).coerceIn(0f, 1f)
hexText = Color.hsv(hue, sat, value).toHex()
}
},
) {
Canvas(Modifier.matchParentSize()) {
val cx = sat * size.width
val cy = (1f - value) * size.height
drawCircle(Color.White, radius = 9f, center = Offset(cx, cy), style = Stroke(width = 3f))
drawCircle(Color.Black, radius = 12f, center = Offset(cx, cy), style = Stroke(width = 1f))
}
}
Spacer(Modifier.height(14.dp))
// Hue slider
Box(
Modifier
.fillMaxWidth()
.height(26.dp)
.clip(RoundedCornerShape(13.dp))
.background(Brush.horizontalGradient((0..6).map { Color.hsv(it * 60f, 1f, 1f) }))
.pointerInput(Unit) {
detectTapGestures { o ->
hue = (o.x / size.width * 360f).coerceIn(0f, 360f)
hexText = Color.hsv(hue, sat, value).toHex()
}
}
.pointerInput(Unit) {
detectDragGestures { change, _ ->
change.consume()
hue = (change.position.x / size.width * 360f).coerceIn(0f, 360f)
hexText = Color.hsv(hue, sat, value).toHex()
}
},
) {
Canvas(Modifier.matchParentSize()) {
val cx = (hue / 360f) * size.width
drawCircle(Color.White, radius = 10f, center = Offset(cx, size.height / 2f), style = Stroke(width = 3f))
}
}
Spacer(Modifier.height(14.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(40.dp).clip(CircleShape).background(current).border(1.dp, MaterialTheme.colorScheme.outline, CircleShape))
Spacer(Modifier.size(12.dp))
OutlinedTextField(
value = hexText,
onValueChange = { raw ->
hexText = raw
val parsed = colorFromHex(raw, Color.Unspecified)
if (parsed != Color.Unspecified) {
val hsv = FloatArray(3)
android.graphics.Color.colorToHSV(parsed.toArgb(), hsv)
hue = hsv[0]; sat = hsv[1]; value = hsv[2]
}
},
label = { Text("Hex") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
}
},
confirmButton = { TextButton(onClick = { onConfirm(current.toHex()) }) { Text("OK") } },
dismissButton = { TextButton(onClick = onDismiss) { Text("Abbrechen") } },
)
}
@@ -0,0 +1,88 @@
package com.scarriffle.calendarr.ui.components
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
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.Modifier
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
import kotlinx.coroutines.delay
/**
* Password input with two affordances the plain field lacks:
* - an eye toggle to reveal the full value, and
* - a brief reveal of the most recently typed character (~1.2s) so the user
* can tell whether a keystroke landed as intended.
*/
@Composable
fun PasswordField(
value: String,
onValueChange: (String) -> Unit,
label: String,
modifier: Modifier = Modifier,
imeAction: ImeAction = ImeAction.Done,
) {
var fullyVisible by remember { mutableStateOf(false) }
var revealLast by remember { mutableStateOf(false) }
// Reveal the last character briefly after each change.
LaunchedEffect(value) {
if (value.isNotEmpty() && !fullyVisible) {
revealLast = true
delay(1200)
revealLast = false
} else {
revealLast = false
}
}
val transformation = when {
fullyVisible -> VisualTransformation.None
revealLast -> LastCharVisibleTransformation
else -> PasswordVisualTransformation()
}
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(label) },
singleLine = true,
visualTransformation = transformation,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = imeAction),
trailingIcon = {
IconButton(onClick = { fullyVisible = !fullyVisible }) {
Icon(
imageVector = if (fullyVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
contentDescription = null,
)
}
},
modifier = modifier,
)
}
/** Masks every character except the last with a bullet. 1:1 char mapping → identity offsets. */
private val LastCharVisibleTransformation = object : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
val masked = buildString {
for (i in text.indices) append(if (i == text.lastIndex) text[i] else '•')
}
return TransformedText(AnnotatedString(masked), OffsetMapping.Identity)
}
}
@@ -0,0 +1,239 @@
package com.scarriffle.calendarr.ui.event
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Dns
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.Lock
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.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
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.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 com.scarriffle.calendarr.domain.model.CalEvent
import com.scarriffle.calendarr.domain.model.EventAttachment
import com.scarriffle.calendarr.ui.LocalLang
import com.scarriffle.calendarr.ui.calendar.eventDateRange
import com.scarriffle.calendarr.ui.tr
import com.scarriffle.calendarr.util.colorFromHex
/** Full-screen event detail (mirrors the iOS detail page). */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EventDetailScreen(
event: CalEvent,
currentUserId: Int = 0,
onClose: () -> Unit,
onEdit: () -> Unit,
onCopy: () -> Unit,
onDelete: () -> Unit,
attachments: List<EventAttachment> = emptyList(),
attachmentThumbs: Map<Int, ImageBitmap> = emptyMap(),
onOpenAttachment: (EventAttachment) -> Unit = {},
) {
val lang = LocalLang.current
var confirmDelete by remember { mutableStateOf(false) }
val canEdit = event.source in setOf("local", "caldav", "homeassistant", "google")
val canDelete = canEdit
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(tr("event.detail_title")) },
navigationIcon = {
IconButton(onClick = onClose) { Icon(Icons.Filled.Close, contentDescription = tr("common.close")) }
},
actions = {
if (canEdit) {
TextButton(onClick = onEdit) { Text(tr("event.edit_title")) }
}
},
)
},
) { padding ->
Column(
Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(20.dp),
) {
Row(verticalAlignment = Alignment.Top) {
Box(
Modifier.width(6.dp).height(46.dp).clip(RoundedCornerShape(3.dp))
.background(colorFromHex(event.effectiveColor)),
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
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)
}
}
}
Spacer(Modifier.height(20.dp))
DetailRow(Icons.Filled.Schedule, eventDateRange(event, lang))
if (event.location.isNotBlank()) DetailRow(Icons.Filled.LocationOn, event.location)
if (event.notes.isNotBlank()) DetailRow(Icons.Filled.Notes, event.notes)
if (event.calendarName.isNotBlank()) DetailRow(Icons.Filled.CalendarMonth, event.calendarName)
DetailRow(Icons.Filled.Dns, event.source.replaceFirstChar { it.uppercase() })
event.creator?.let { c ->
if (c.id != currentUserId) {
DetailRow(Icons.Filled.Person, "${tr("event.created_by")}: ${c.displayName}")
}
}
if (event.isPrivate) DetailRow(Icons.Filled.Lock, tr("event.private"))
// Attachments exist on local events only; the payload carries
// just the count, so the list arrives once the screen is open.
if (event.source == "local" && event.attachmentCount > 0) {
Spacer(Modifier.height(12.dp))
Text(
tr("event.attachments"),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(4.dp))
attachments.forEach { att ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { onOpenAttachment(att) }
.padding(vertical = 8.dp),
) {
val thumb = attachmentThumbs[att.id]
if (thumb != null) {
Image(
bitmap = thumb,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(32.dp)
.clip(RoundedCornerShape(4.dp)),
)
} else {
Icon(
Icons.Filled.AttachFile,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(32.dp),
)
}
Spacer(Modifier.width(12.dp))
Text(
att.filename,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(8.dp))
Text(
formatAttachmentSize(att.sizeBytes),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
Spacer(Modifier.height(28.dp))
OutlinedButton(onClick = onCopy, modifier = Modifier.fillMaxWidth()) {
Icon(Icons.Filled.ContentCopy, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text(tr("event.copy_title"))
}
if (canDelete) {
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.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(tr("common.delete"))
}
}
Spacer(Modifier.height(24.dp))
}
}
}
if (confirmDelete) {
AlertDialog(
onDismissRequest = { confirmDelete = false },
title = { Text(tr("common.delete")) },
text = { Text(tr("event.delete_confirm")) },
confirmButton = {
TextButton(onClick = { confirmDelete = false; onDelete() }) {
Text(tr("common.delete"), color = MaterialTheme.colorScheme.error)
}
},
dismissButton = { TextButton(onClick = { confirmDelete = false }) { Text(tr("common.cancel")) } },
)
}
}
@Composable
private fun DetailRow(icon: ImageVector, text: String) {
Row(Modifier.fillMaxWidth().padding(vertical = 8.dp), verticalAlignment = Alignment.Top) {
Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(22.dp))
Spacer(Modifier.width(14.dp))
Text(text, style = MaterialTheme.typography.bodyLarge)
}
}
/** Human-readable size, matching what the web and iOS show. */
private fun formatAttachmentSize(bytes: Int): String = when {
bytes < 1024 -> "$bytes B"
bytes < 1024 * 1024 -> "${bytes / 1024} KB"
else -> String.format(java.util.Locale.US, "%.1f MB", bytes / (1024.0 * 1024.0))
}
@@ -0,0 +1,492 @@
package com.scarriffle.calendarr.ui.event
import android.app.DatePickerDialog
import android.app.TimePickerDialog
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.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.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.AttachFile
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
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
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.text.style.TextOverflow
import androidx.compose.ui.Modifier
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.EventAttachment
import com.scarriffle.calendarr.domain.model.ReminderOptions
import com.scarriffle.calendarr.domain.model.StagedAttachment
import com.scarriffle.calendarr.domain.model.WritableCalendar
import com.scarriffle.calendarr.ui.L10n
import com.scarriffle.calendarr.ui.LocalLang
import com.scarriffle.calendarr.ui.calendar.EditorRequest
import com.scarriffle.calendarr.ui.calendar.calendarKey
import com.scarriffle.calendarr.ui.tr
import com.scarriffle.calendarr.util.colorFromHex
import java.time.Instant
import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
private val PRESET_COLORS = listOf(
"#4285f4", "#ea4335", "#34a853", "#fbbc05",
"#46bdc6", "#9c27b0", "#ff7043", "#7090c0",
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EventEditorSheet(
request: EditorRequest,
writableCalendars: List<WritableCalendar>,
onDismiss: () -> Unit,
defaultDurationMinutes: Int = 60,
reminderDisabledKeys: Set<String> = emptySet(),
existingAttachments: List<EventAttachment> = emptyList(),
onSave: (WritableCalendar, String, Instant, Instant, Boolean, String, String, String?, Boolean, List<Int>, List<StagedAttachment>, Set<Int>) -> Unit,
) {
val zone = ZoneId.systemDefault()
val context = LocalContext.current
val lang = LocalLang.current
val existing = request.existing
// Fields are prefilled from the event being edited OR copied.
val template = existing ?: request.prefill
val isCopy = existing == null && request.prefill != null
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var title by remember { mutableStateOf(template?.title ?: "") }
var allDay by remember { mutableStateOf(template?.isAllDay ?: false) }
var location by remember { mutableStateOf(template?.location ?: "") }
var description by remember { mutableStateOf(template?.notes ?: "") }
var color by remember { mutableStateOf(template?.color) }
var isPrivate by remember { mutableStateOf(template?.isPrivate ?: false) }
val initialStart = template?.startDate
val initialEnd = template?.endDate
var startDate by remember {
mutableStateOf(initialStart?.let { LocalDate.ofInstant(it, zone) } ?: request.date)
}
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
} ?: defaultEnd.toLocalDate()
)
}
var endTime by remember {
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()
writableCalendars.firstOrNull { it.source == ev.source && it.numericId == id }
}
var calendar by remember { mutableStateOf(preselected ?: writableCalendars.firstOrNull()) }
var calMenuOpen by remember { mutableStateOf(false) }
// Files picked before the event exists are staged and uploaded after it is
// created; removals of existing ones are applied on save, so Cancel really
// does cancel.
var stagedAttachments by remember { mutableStateOf(emptyList<StagedAttachment>()) }
var removedAttachmentIds by remember { mutableStateOf(emptySet<Int>()) }
val attachmentSlotsUsed =
existingAttachments.count { it.id !in removedAttachmentIds } + stagedAttachments.size
val pickAttachment = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri ->
if (uri != null && attachmentSlotsUsed < 10) {
// Read the bytes now: the content URI may not outlive this screen.
val bytes = runCatching {
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
}.getOrNull()
val name = queryDisplayName(context, uri) ?: "anhang"
val mime = context.contentResolver.getType(uri)
if (bytes != null && bytes.size <= 10 * 1024 * 1024) {
stagedAttachments = stagedAttachments + StagedAttachment(bytes, name, mime)
}
}
}
var error by remember { mutableStateOf<String?>(null) }
val dateFmt = DateTimeFormatter.ofPattern("EEE, d. MMM yyyy")
val timeFmt = DateTimeFormatter.ofPattern("HH:mm")
fun pickDate(current: LocalDate, onPicked: (LocalDate) -> Unit) {
DatePickerDialog(
context,
{ _, y, m, d -> onPicked(LocalDate.of(y, m + 1, d)) },
current.year, current.monthValue - 1, current.dayOfMonth,
).show()
}
fun pickTime(current: LocalTime, onPicked: (LocalTime) -> Unit) {
TimePickerDialog(
context,
{ _, h, min -> onPicked(LocalTime.of(h, min)) },
current.hour, current.minute, true,
).show()
}
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
Column(
Modifier
.fillMaxWidth()
.navigationBarsPadding()
.imePadding()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp)
.padding(bottom = 32.dp),
) {
Text(
when {
existing != null -> tr("event.edit_title")
isCopy -> tr("event.copy_title")
else -> tr("event.new_title")
},
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(vertical = 8.dp),
)
OutlinedTextField(
value = title,
onValueChange = { title = it; error = null },
label = { Text(tr("event.title_placeholder")) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.size(12.dp))
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
Text(tr("event.allday"), style = MaterialTheme.typography.bodyLarge)
Switch(checked = allDay, onCheckedChange = { allDay = it })
}
Spacer(Modifier.size(8.dp))
// Start
Text(tr("event.start"), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = { pickDate(startDate) { startDate = it; if (endDate.isBefore(it)) endDate = it } }) {
Text(dateFmt.format(startDate))
}
if (!allDay) {
OutlinedButton(onClick = { pickTime(startTime) { startTime = it } }) { Text(timeFmt.format(startTime)) }
}
}
Spacer(Modifier.size(8.dp))
// End
Text(tr("event.end"), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = { pickDate(endDate) { endDate = it } }) { Text(dateFmt.format(endDate)) }
if (!allDay) {
OutlinedButton(onClick = { pickTime(endTime) { endTime = it } }) { Text(timeFmt.format(endTime)) }
}
}
Spacer(Modifier.size(12.dp))
OutlinedTextField(
value = location,
onValueChange = { location = it },
label = { Text(tr("event.location")) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.size(12.dp))
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text(tr("event.description")) },
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.size(12.dp))
// Calendar picker
Text(tr("event.calendar_section"), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
if (writableCalendars.isEmpty()) {
Text(tr("event.no_writable"), color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
} else {
Box {
OutlinedButton(onClick = { calMenuOpen = true }, modifier = Modifier.fillMaxWidth()) {
Box(Modifier.size(12.dp).clip(CircleShape).background(colorFromHex(calendar?.color)))
Spacer(Modifier.size(8.dp))
Text(calendar?.name ?: tr("event.calendar_picker"), modifier = Modifier.weight(1f))
Icon(Icons.Filled.ArrowDropDown, contentDescription = null)
}
DropdownMenu(expanded = calMenuOpen, onDismissRequest = { calMenuOpen = false }) {
writableCalendars.forEach { c ->
DropdownMenuItem(
text = { Text(c.name) },
leadingIcon = { Box(Modifier.size(12.dp).clip(CircleShape).background(colorFromHex(c.color))) },
onClick = { calendar = c; calMenuOpen = false },
)
}
}
}
}
Spacer(Modifier.size(12.dp))
// 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))
Text(tr("event.attachments"), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
existingAttachments.filter { it.id !in removedAttachmentIds }.forEach { att ->
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.AttachFile, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.size(8.dp))
Text(att.filename, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
androidx.compose.material3.IconButton(onClick = {
removedAttachmentIds = removedAttachmentIds + att.id
}) {
Icon(Icons.Filled.Close, contentDescription = tr("common.delete"))
}
}
}
stagedAttachments.forEach { file ->
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.AttachFile, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
Spacer(Modifier.size(8.dp))
Text(file.filename, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
androidx.compose.material3.IconButton(onClick = {
stagedAttachments = stagedAttachments - file
}) {
Icon(Icons.Filled.Close, contentDescription = tr("common.delete"))
}
}
}
androidx.compose.material3.TextButton(
enabled = attachmentSlotsUsed < 10,
onClick = { pickAttachment.launch(ATTACHMENT_MIME_TYPES) },
) { Text(tr("event.attachment_add")) }
Text(
tr("event.attachment_hint"),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.size(12.dp))
}
// Color
Text(tr("event.color_section"), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Row(Modifier.padding(top = 6.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
PRESET_COLORS.forEach { hex ->
val selected = color?.equals(hex, ignoreCase = true) == true
Box(
Modifier
.size(28.dp)
.clip(CircleShape)
.background(colorFromHex(hex))
.then(if (selected) Modifier.border(2.dp, Color.White, CircleShape) else Modifier)
.clickable { color = hex },
contentAlignment = Alignment.Center,
) {
if (selected) Icon(Icons.Filled.Check, contentDescription = null, tint = Color.White, modifier = Modifier.size(16.dp))
}
}
}
if (color != null) {
androidx.compose.material3.TextButton(onClick = { color = null }) { Text(tr("event.reset_color")) }
}
error?.let {
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(top = 8.dp))
}
Spacer(Modifier.size(16.dp))
Button(
onClick = {
val cal = calendar
if (title.isBlank()) { error = L10n.t("event.title_placeholder", lang); return@Button }
if (cal == null) { error = L10n.t("event.no_writable", lang); return@Button }
val start: Instant
val end: Instant
if (allDay) {
start = startDate.atStartOfDay(zone).toInstant()
end = endDate.plusDays(1).atStartOfDay(zone).toInstant()
} else {
start = startDate.atTime(startTime).atZone(zone).toInstant()
end = endDate.atTime(endTime).atZone(zone).toInstant()
}
val rem = if (cal.source == "local") reminders else emptyList()
val staged = if (cal.source == "local") stagedAttachments else emptyList()
val removed = if (cal.source == "local") removedAttachmentIds else emptySet()
onSave(cal, title.trim(), start, end, allDay, location.trim(), description.trim(), color, isPrivate && cal.source == "local", rem, staged, removed)
},
enabled = writableCalendars.isNotEmpty(),
modifier = Modifier.fillMaxWidth(),
) {
Text(if (existing == null) tr("event.add") else tr("event.save"))
}
}
}
}
/** 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)
}
}
}
}
/** Mirrors the server allowlist. "*/*" is deliberately absent — the picker
* should not offer files the upload would reject. */
private val ATTACHMENT_MIME_TYPES = arrayOf(
"application/pdf",
"image/png", "image/jpeg", "image/webp", "image/gif",
"text/plain", "text/markdown", "text/csv", "text/comma-separated-values",
)
/** The user-visible name behind a content URI; the last path segment is an
* opaque id and would end up as the attachment's filename. */
private fun queryDisplayName(context: android.content.Context, uri: android.net.Uri): String? =
runCatching {
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val idx = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
if (idx >= 0 && cursor.moveToFirst()) cursor.getString(idx) else null
}
}.getOrNull()
@@ -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")) } },
)
}
}
@@ -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 }
}
}
}
@@ -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)
}
}
@@ -0,0 +1,163 @@
package com.scarriffle.calendarr.ui.profile
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Button
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.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.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.scarriffle.calendarr.ui.components.PasswordField
import com.scarriffle.calendarr.ui.tr
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProfileScreen(onClose: () -> Unit, vm: ProfileViewModel = hiltViewModel()) {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(tr("profile.title")) },
navigationIcon = {
IconButton(onClick = onClose) { Icon(Icons.Filled.Close, contentDescription = tr("common.close")) }
},
)
},
) { padding ->
if (vm.loading) {
Column(Modifier.fillMaxSize().padding(padding), verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator()
}
return@Scaffold
}
Column(
Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(20.dp),
) {
val p = vm.profile
SectionTitle(tr("profile.account"))
InfoRow(tr("profile.username"), p?.username ?: "—")
InfoRow(tr("profile.role"), if (p?.isAdmin == true) tr("profile.role.admin") else tr("profile.role.user"))
Spacer(Modifier.size(20.dp))
SectionTitle(tr("profile.email"))
OutlinedTextField(
value = vm.email,
onValueChange = vm::onEmailChange,
label = { Text(tr("profile.email")) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.size(8.dp))
Button(onClick = vm::saveEmail) { Text(tr("profile.save_email")) }
Spacer(Modifier.size(24.dp))
Divider()
Spacer(Modifier.size(16.dp))
PasswordSection(vm)
Spacer(Modifier.size(24.dp))
Divider()
Spacer(Modifier.size(16.dp))
TwoFactorSection(vm)
vm.message?.let {
Spacer(Modifier.size(16.dp))
Text(it, color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.bodySmall)
}
}
}
}
}
@Composable
private fun PasswordSection(vm: ProfileViewModel) {
val mismatchMsg = tr("profile.password_mismatch")
var current by remember { mutableStateOf("") }
var new1 by remember { mutableStateOf("") }
var new2 by remember { mutableStateOf("") }
var localError by remember { mutableStateOf<String?>(null) }
SectionTitle(tr("profile.change_password"))
PasswordField(value = current, onValueChange = { current = it }, label = tr("profile.current_password"), modifier = Modifier.fillMaxWidth())
Spacer(Modifier.size(8.dp))
PasswordField(value = new1, onValueChange = { new1 = it }, label = tr("profile.new_password"), modifier = Modifier.fillMaxWidth())
Spacer(Modifier.size(8.dp))
PasswordField(value = new2, onValueChange = { new2 = it }, label = tr("profile.new_password_repeat"), modifier = Modifier.fillMaxWidth())
localError?.let { Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) }
Spacer(Modifier.size(8.dp))
Button(onClick = {
if (new1 != new2) { localError = mismatchMsg; return@Button }
localError = null
vm.changePassword(current, new1) { ok -> if (ok) { current = ""; new1 = ""; new2 = "" } }
}) { Text(tr("profile.change_password")) }
}
@Composable
private fun TwoFactorSection(vm: ProfileViewModel) {
val enabled = vm.profile?.totpEnabled == true
var disablePw by remember { mutableStateOf("") }
SectionTitle(tr("profile.twofa"))
Text(if (enabled) tr("profile.twofa.active") else tr("profile.twofa.inactive"), color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.size(8.dp))
if (enabled) {
PasswordField(value = disablePw, onValueChange = { disablePw = it }, label = tr("twofa.password_placeholder"), modifier = Modifier.fillMaxWidth())
Spacer(Modifier.size(8.dp))
OutlinedButton(onClick = { vm.disable2fa(disablePw); disablePw = "" }) { Text(tr("profile.twofa.disable")) }
} else {
val setup = vm.totpSetup
if (setup == null) {
Button(onClick = vm::begin2fa) { Text(tr("profile.twofa.enable")) }
} else {
Text(tr("twofa.scan_hint"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.size(8.dp))
Text(setup.secret, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Spacer(Modifier.size(8.dp))
OutlinedTextField(value = vm.totpCode, onValueChange = vm::onTotpChange, label = { Text(tr("twofa.code_placeholder")) }, singleLine = true, modifier = Modifier.fillMaxWidth())
Spacer(Modifier.size(8.dp))
Row(horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp)) {
Button(onClick = vm::confirm2fa) { Text(tr("twofa.activate")) }
OutlinedButton(onClick = vm::cancel2fa) { Text(tr("common.cancel")) }
}
}
}
}
@Composable
private fun SectionTitle(text: String) {
Text(text, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(bottom = 8.dp))
}
@Composable
private fun InfoRow(label: String, value: String) {
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Text(label, modifier = Modifier.weight(1f), color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(value, fontWeight = FontWeight.Medium)
}
}
@@ -0,0 +1,91 @@
package com.scarriffle.calendarr.ui.profile
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.data.TotpSetup
import com.scarriffle.calendarr.domain.model.UserProfile
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class ProfileViewModel @Inject constructor(
private val repository: CalendarRepository,
) : ViewModel() {
var profile by mutableStateOf<UserProfile?>(null)
private set
var email by mutableStateOf("")
private set
var loading by mutableStateOf(true)
private set
var message by mutableStateOf<String?>(null)
private set
// 2FA enrolment
var totpSetup by mutableStateOf<TotpSetup?>(null)
private set
var totpCode by mutableStateOf("")
private set
init { load() }
fun load() {
viewModelScope.launch {
loading = true
runCatching { repository.getProfile() }
.onSuccess { p -> profile = p; email = p.email ?: "" }
.onFailure { message = it.message }
loading = false
}
}
fun onEmailChange(v: String) { email = v }
fun onTotpChange(v: String) { totpCode = v }
fun saveEmail() {
viewModelScope.launch {
runCatching { repository.updateEmail(email.trim()) }
.onSuccess { message = "✓"; load() }
.onFailure { message = it.message }
}
}
fun changePassword(current: String, new: String, onDone: (Boolean) -> Unit) {
viewModelScope.launch {
runCatching { repository.changePassword(current, new) }
.onSuccess { message = "✓"; onDone(true) }
.onFailure { message = it.message; onDone(false) }
}
}
fun begin2fa() {
viewModelScope.launch {
runCatching { repository.setup2fa() }
.onSuccess { totpSetup = it }
.onFailure { message = it.message }
}
}
fun confirm2fa() {
viewModelScope.launch {
runCatching { repository.enable2fa(totpCode.trim()) }
.onSuccess { totpSetup = null; totpCode = ""; load() }
.onFailure { message = it.message }
}
}
fun disable2fa(password: String) {
viewModelScope.launch {
runCatching { repository.disable2fa(password) }
.onSuccess { load() }
.onFailure { message = it.message }
}
}
fun cancel2fa() { totpSetup = null; totpCode = "" }
}
@@ -0,0 +1,432 @@
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.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.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.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.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
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
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 androidx.hilt.navigation.compose.hiltViewModel
import com.scarriffle.calendarr.domain.model.AppSettings
import com.scarriffle.calendarr.domain.model.CalViewType
import com.scarriffle.calendarr.ui.LocalAppSettings
import com.scarriffle.calendarr.ui.components.ColorPickerDialog
import com.scarriffle.calendarr.ui.tr
import com.scarriffle.calendarr.util.colorFromHex
import com.scarriffle.calendarr.util.toHex
// 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)
@Composable
fun SettingsScreen(
onClose: () -> Unit,
onSettingsChanged: (AppSettings) -> Unit,
onSettingsSynced: () -> Unit,
vm: SettingsViewModel = hiltViewModel(),
) {
val initialSettings = LocalAppSettings.current
var settings by remember { mutableStateOf(initialSettings) }
fun update(newSettings: AppSettings) {
settings = newSettings
onSettingsChanged(newSettings)
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 = {
TopAppBar(
title = { Text(tr("settings.title")) },
navigationIcon = {
IconButton(onClick = onClose) { Icon(Icons.Filled.Close, contentDescription = tr("common.close")) }
},
)
},
) { padding ->
Column(Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(16.dp)) {
// 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))
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"))
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))
// ---- 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))
// ---- 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))
}
/** 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")
Section(tr("settings.nav.profile"))
OutlinedTextField(
value = vm.displayName,
onValueChange = vm::onDisplayNameChange,
label = { Text(tr("profile.display_name")) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.size(8.dp))
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Text(tr("profile.login_name"), modifier = Modifier.weight(1f), color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(vm.loginName, fontWeight = FontWeight.Medium)
}
Spacer(Modifier.size(8.dp))
OutlinedTextField(
value = vm.email,
onValueChange = vm::onEmailChange,
label = { Text(tr("profile.email")) },
singleLine = true,
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))
Text(it, color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.bodySmall)
}
Divider(Modifier.padding(vertical = 16.dp))
Section(tr("settings.privacy"))
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))
Section(tr("settings.group_visible"))
CalendarDropdown(vm)
Spacer(Modifier.size(6.dp))
Text(tr("settings.group_visible.desc"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CalendarDropdown(vm: SettingsViewModel) {
var expanded by remember { mutableStateOf(false) }
val noneLabel = tr("group.visible.none")
val selectedLabel = vm.ownLocalCalendars.firstOrNull { it.id == vm.groupVisibleId }?.name ?: noneLabel
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
OutlinedTextField(
value = selectedLabel,
onValueChange = {},
readOnly = true,
label = { Text(tr("settings.group_visible")) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier.menuAnchor().fillMaxWidth(),
)
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
DropdownMenuItem(text = { Text(noneLabel) }, onClick = { vm.changeGroupVisible(0); expanded = false })
vm.ownLocalCalendars.forEach { cal ->
DropdownMenuItem(text = { Text(cal.name) }, onClick = { vm.changeGroupVisible(cal.id); expanded = false })
}
}
}
}
@@ -0,0 +1,124 @@
package com.scarriffle.calendarr.ui.settings
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.data.SettingsStore
import com.scarriffle.calendarr.domain.model.AppSettings
import com.scarriffle.calendarr.domain.model.LocalCalendar
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val repository: CalendarRepository,
private val settingsStore: SettingsStore,
) : ViewModel() {
/** 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, settingsStore.loadSyncFlags()) }
onSynced()
}
}
/** 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) ----
var displayName by mutableStateOf("")
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
private set
var ownLocalCalendars by mutableStateOf<List<LocalCalendar>>(emptyList())
private set
var profileMessage by mutableStateOf<String?>(null)
private set
init { loadProfile() }
fun onDisplayNameChange(v: String) { displayName = v }
fun onEmailChange(v: String) { email = v }
fun onDirectoryHiddenChange(v: Boolean) { directoryHidden = v }
private fun loadProfile() {
viewModelScope.launch {
runCatching { repository.getProfile() }.onSuccess { p ->
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 ->
// 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 }
}
}
}
fun saveProfile(savedLabel: String) {
viewModelScope.launch {
runCatching {
repository.updateProfile(
displayName = displayName.trim().ifEmpty { null },
username = null,
email = email.trim(),
directoryHidden = directoryHidden,
)
}
.onSuccess { profileMessage = savedLabel }
.onFailure { profileMessage = it.message }
}
}
fun changePrivateVisibility(value: String) {
privateVisibility = value
viewModelScope.launch { runCatching { repository.updatePrivateVisibility(value) } }
}
fun changeGroupVisible(id: Int) {
groupVisibleId = id
viewModelScope.launch { runCatching { repository.updateGroupVisibleCalendar(id.takeIf { it != 0 }) } }
}
}
@@ -0,0 +1,13 @@
package com.scarriffle.calendarr.ui.theme
import androidx.compose.ui.graphics.Color
val md_theme_light_primary = Color(0xFF4285F4)
val md_theme_light_onPrimary = Color(0xFFFFFFFF)
val md_theme_light_background = Color(0xFFFFFFFF)
val md_theme_light_onBackground = Color(0xFF000000)
val md_theme_dark_primary = Color(0xFF8AB4F8)
val md_theme_dark_onPrimary = Color(0xFF000000)
val md_theme_dark_background = Color(0xFF000000)
val md_theme_dark_onBackground = Color(0xFFFFFFFF)
@@ -0,0 +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
/** 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 = 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 colors = darkColorScheme(
primary = primary,
onPrimary = primary.contrastingTextColor(),
primaryContainer = container,
onPrimaryContainer = onContainer,
secondary = accent,
onSecondary = accent.contrastingTextColor(),
secondaryContainer = container,
onSecondaryContainer = onContainer,
tertiary = accent,
onTertiary = accent.contrastingTextColor(),
tertiaryContainer = container,
onTertiaryContainer = onContainer,
surfaceTint = primary,
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,
)
}
@@ -0,0 +1,51 @@
package com.scarriffle.calendarr.util
import androidx.compose.ui.graphics.Color
/** Parse a "#RRGGBB" (or "RRGGBB") hex string into a Compose [Color]. */
fun colorFromHex(hex: String?, fallback: Color = Color(0xFF4285F4)): Color {
if (hex.isNullOrBlank()) return fallback
val clean = hex.trim().removePrefix("#").filter { it.isLetterOrDigit() }
return when (clean.length) {
3 -> runCatching {
// #RGB shorthand -> expand each nibble
val r = clean[0].digitToInt(16) * 17
val g = clean[1].digitToInt(16) * 17
val b = clean[2].digitToInt(16) * 17
Color(r / 255f, g / 255f, b / 255f, 1f)
}.getOrDefault(fallback)
6 -> runCatching {
val v = clean.toLong(16)
Color(
red = ((v shr 16) and 0xFF) / 255f,
green = ((v shr 8) and 0xFF) / 255f,
blue = (v and 0xFF) / 255f,
alpha = 1f,
)
}.getOrDefault(fallback)
8 -> runCatching {
val v = clean.toLong(16)
Color(
alpha = ((v shr 24) and 0xFF) / 255f,
red = ((v shr 16) and 0xFF) / 255f,
green = ((v shr 8) and 0xFF) / 255f,
blue = (v and 0xFF) / 255f,
)
}.getOrDefault(fallback)
else -> fallback
}
}
/** Convert a Compose [Color] back to a "#RRGGBB" string. */
fun Color.toHex(): String {
val r = (red * 255).toInt().coerceIn(0, 255)
val g = (green * 255).toInt().coerceIn(0, 255)
val b = (blue * 255).toInt().coerceIn(0, 255)
return String.format("#%02X%02X%02X", r, g, b)
}
/** Pick black or white text for legibility on a given background color. */
fun Color.contrastingTextColor(): Color {
val luminance = 0.299 * red + 0.587 * green + 0.114 * blue
return if (luminance > 0.6) Color.Black else Color.White
}
@@ -0,0 +1,77 @@
package com.scarriffle.calendarr.util
import java.time.Instant
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
/**
* Date parsing/formatting that mirrors the iOS client (CalEvent.swift).
*
* The backend may produce any of these forms:
* "2026-05-17" (all-day, date only)
* "2026-05-17T10:00:00Z"
* "2026-05-17T10:00:00+02:00"
* "2026-05-17T10:00:00.000Z"
* "2026-05-17T10:00:00" (no timezone -> treat as UTC)
* "2026-05-17 10:00:00+00:00" (Python isoformat, space separator)
*/
object Dates {
private val zone: ZoneId get() = ZoneId.systemDefault()
private val spaceSep: DateTimeFormatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ssXXX")
private val noTz: DateTimeFormatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss")
/** Parse a server date string into an [Instant]. Returns null if unparseable. */
fun parse(raw: String, allDay: Boolean): Instant? {
val s = raw.trim()
if (s.isEmpty()) return null
if (allDay || (s.length == 10 && !s.contains('T'))) {
return runCatching {
LocalDate.parse(s.take(10)).atStartOfDay(zone).toInstant()
}.getOrNull()
}
// ISO with offset / Z
runCatching { return OffsetDateTime.parse(s).toInstant() }
runCatching { return Instant.parse(s) }
// Python isoformat with a space
runCatching { return OffsetDateTime.parse(s, spaceSep).toInstant() }
// No timezone -> UTC
runCatching {
return LocalDateTime.parse(s.take(19), noTz).toInstant(ZoneOffset.UTC)
}
// Last resort: just the date part
return runCatching {
LocalDate.parse(s.take(10)).atStartOfDay(zone).toInstant()
}.getOrNull()
}
/** Format an [Instant] for sending to the server. */
fun format(instant: Instant, allDay: Boolean): String {
return if (allDay) {
LocalDate.ofInstant(instant, zone).toString() // yyyy-MM-dd
} else {
// ISO8601 with Z, no fractional seconds (matches iOS isoBasic)
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'")
.withZone(ZoneOffset.UTC)
.format(instant)
}
}
/** ISO8601 UTC string used for the events query range (`?start=&end=`). */
fun isoUtc(instant: Instant): String =
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'")
.withZone(ZoneOffset.UTC)
.format(instant)
fun localDate(instant: Instant): LocalDate = LocalDate.ofInstant(instant, zone)
fun startOfDay(date: LocalDate): Instant = date.atStartOfDay(zone).toInstant()
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- High-res logo inset so it sits comfortably inside the round splash mask. -->
<inset xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/ic_splash_logo"
android:inset="18%" />
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Invisible system-splash icon: the system splash is just black, so the
branded in-app splash (icon + name + copyright) is the only thing seen. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@android:color/transparent" />
</shape>
@@ -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>
@@ -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: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Some files were not shown because too many files have changed in this diff Show More