Compare commits

...

36 Commits

Author SHA1 Message Date
Scarriffle
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
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
Scarriffle
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
Scarriffle
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
Scarriffle
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
Scarriffle
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
Scarriffle
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
Scarriffle
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
Scarriffle
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
Scarriffle
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
Scarriffle
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
Scarriffle
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
Scarriffle
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
Scarriffle
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
Scarriffle
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
Scarriffle
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
Scarriffle
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
Scarriffle
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
Scarriffle
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
Scarriffle
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
Scarriffle
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
Scarriffle
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
Scarriffle
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
Scarriffle
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
Scarriffle
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
Scarriffle
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
Scarriffle
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
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
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
Scarriffle
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
Scarriffle
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
Scarriffle
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
Scarriffle
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
Scarriffle
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
Scarriffle
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
Scarriffle
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
37 changed files with 2876 additions and 1065 deletions

View File

@@ -8,6 +8,8 @@
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
D0001A0CABCDEF0100AB5001 /* CalendarrWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D0001A02ABCDEF0100AB5001 /* CalendarrWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; D0001A0CABCDEF0100AB5001 /* CalendarrWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D0001A02ABCDEF0100AB5001 /* CalendarrWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
CA1E4D0A0002000000000001 /* CalendarrCore in Frameworks */ = {isa = PBXBuildFile; productRef = CA1E4D0A0001000000000001 /* CalendarrCore */; };
CA1E4D0A0002000000000002 /* CalendarrCore in Frameworks */ = {isa = PBXBuildFile; productRef = CA1E4D0A0001000000000002 /* CalendarrCore */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -95,6 +97,7 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
CA1E4D0A0002000000000001 /* CalendarrCore in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -102,6 +105,7 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
CA1E4D0A0002000000000002 /* CalendarrCore in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -175,6 +179,7 @@
); );
name = "Calendarr iOS"; name = "Calendarr iOS";
packageProductDependencies = ( packageProductDependencies = (
CA1E4D0A0001000000000001 /* CalendarrCore */,
); );
productName = "Calendarr iOS"; productName = "Calendarr iOS";
productReference = C0000B01FC4E10100AB5001 /* Calendarr iOS.app */; productReference = C0000B01FC4E10100AB5001 /* Calendarr iOS.app */;
@@ -198,6 +203,7 @@
); );
name = CalendarrWidgets; name = CalendarrWidgets;
packageProductDependencies = ( packageProductDependencies = (
CA1E4D0A0001000000000002 /* CalendarrCore */,
); );
productName = CalendarrWidgets; productName = CalendarrWidgets;
productReference = D0001A02ABCDEF0100AB5001 /* CalendarrWidgets.appex */; productReference = D0001A02ABCDEF0100AB5001 /* CalendarrWidgets.appex */;
@@ -235,6 +241,9 @@
); );
mainGroup = C0000201FB4E10100AB5001; mainGroup = C0000201FB4E10100AB5001;
minimizedProjectReferenceProxies = 1; minimizedProjectReferenceProxies = 1;
packageReferences = (
39450EFC302A266A0090DDD7 /* XCLocalSwiftPackageReference "../CalendarrKit" */,
);
preferredProjectObjectVersion = 77; preferredProjectObjectVersion = 77;
productRefGroup = C0000C01FB4E10100AB5001 /* Products */; productRefGroup = C0000C01FB4E10100AB5001 /* Products */;
projectDirPath = ""; projectDirPath = "";
@@ -314,12 +323,10 @@
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = PP34X97WS3; DEVELOPMENT_TEAM = PP34X97WS3;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.5; IPHONEOS_DEPLOYMENT_TARGET = 26.5;
MACOSX_DEPLOYMENT_TARGET = 26.5; MACOSX_DEPLOYMENT_TARGET = 26.5;
MARKETING_VERSION = 1.0.1;
PRODUCT_BUNDLE_IDENTIFIER = "com.local.scarriffle.Calendarr-iOSTests"; PRODUCT_BUNDLE_IDENTIFIER = "com.local.scarriffle.Calendarr-iOSTests";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto; SDKROOT = auto;
@@ -341,12 +348,10 @@
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = PP34X97WS3; DEVELOPMENT_TEAM = PP34X97WS3;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.5; IPHONEOS_DEPLOYMENT_TARGET = 26.5;
MACOSX_DEPLOYMENT_TARGET = 26.5; MACOSX_DEPLOYMENT_TARGET = 26.5;
MARKETING_VERSION = 1.0.1;
PRODUCT_BUNDLE_IDENTIFIER = "com.local.scarriffle.Calendarr-iOSTests"; PRODUCT_BUNDLE_IDENTIFIER = "com.local.scarriffle.Calendarr-iOSTests";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto; SDKROOT = auto;
@@ -397,6 +402,7 @@
CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 7;
DEBUG_INFORMATION_FORMAT = dwarf; DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES; ENABLE_TESTABILITY = YES;
@@ -415,8 +421,9 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0; IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 3.5;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
@@ -461,6 +468,7 @@
CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 7;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO; ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
@@ -473,8 +481,9 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0; IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 3.5;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -490,33 +499,33 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "Calendarr iOS/Calendarr iOS.entitlements"; CODE_SIGN_ENTITLEMENTS = "Calendarr iOS/Calendarr iOS.entitlements";
"CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "Calendarr iOS/Calendarr iOS-Catalyst.entitlements";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = PP34X97WS3; DEVELOPMENT_TEAM = PP34X97WS3;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Calendarr; INFOPLIST_KEY_CFBundleDisplayName = Calendarr;
INFOPLIST_KEY_CFBundleName = Calendarr; INFOPLIST_KEY_CFBundleName = Calendarr;
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsArbitraryLoads = YES; INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsLocalNetworking = YES;
INFOPLIST_KEY_NSContactsUsageDescription = "Calendarr liest Geburtstage aus deinen Kontakten, um sie in deinen Geburtstagskalender zu übernehmen.";
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices"; INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices";
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDarkContent; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDarkContent;
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait";
IPHONEOS_DEPLOYMENT_TARGET = 26.0; IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 2.5;
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios;
PRODUCT_NAME = "Calendarr iOS"; PRODUCT_NAME = "Calendarr iOS";
STRING_CATALOG_GENERATE_SYMBOLS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO; SUPPORTS_MACCATALYST = YES;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES;
@@ -525,6 +534,7 @@
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1; TARGETED_DEVICE_FAMILY = 1;
"TARGETED_DEVICE_FAMILY[sdk=macosx*]" = 2;
}; };
name = Debug; name = Debug;
}; };
@@ -534,33 +544,33 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "Calendarr iOS/Calendarr iOS.entitlements"; CODE_SIGN_ENTITLEMENTS = "Calendarr iOS/Calendarr iOS.entitlements";
"CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "Calendarr iOS/Calendarr iOS-Catalyst.entitlements";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = PP34X97WS3; DEVELOPMENT_TEAM = PP34X97WS3;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Calendarr; INFOPLIST_KEY_CFBundleDisplayName = Calendarr;
INFOPLIST_KEY_CFBundleName = Calendarr; INFOPLIST_KEY_CFBundleName = Calendarr;
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsArbitraryLoads = YES; INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsLocalNetworking = YES;
INFOPLIST_KEY_NSContactsUsageDescription = "Calendarr liest Geburtstage aus deinen Kontakten, um sie in deinen Geburtstagskalender zu übernehmen.";
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices"; INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices";
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDarkContent; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDarkContent;
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait";
IPHONEOS_DEPLOYMENT_TARGET = 26.0; IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 2.5;
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios;
PRODUCT_NAME = "Calendarr iOS"; PRODUCT_NAME = "Calendarr iOS";
STRING_CATALOG_GENERATE_SYMBOLS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO; SUPPORTS_MACCATALYST = YES;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES;
@@ -569,6 +579,7 @@
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1; TARGETED_DEVICE_FAMILY = 1;
"TARGETED_DEVICE_FAMILY[sdk=macosx*]" = 2;
}; };
name = Release; name = Release;
}; };
@@ -577,26 +588,25 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CODE_SIGN_ENTITLEMENTS = CalendarrWidgets/CalendarrWidgets.entitlements; CODE_SIGN_ENTITLEMENTS = CalendarrWidgets/CalendarrWidgets.entitlements;
"CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "CalendarrWidgets/CalendarrWidgets-Catalyst.entitlements";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = PP34X97WS3; DEVELOPMENT_TEAM = PP34X97WS3;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = CalendarrWidgets/Info.plist; INFOPLIST_FILE = CalendarrWidgets/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Calendarr Widgets"; INFOPLIST_KEY_CFBundleDisplayName = "Calendarr Widgets";
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices"; INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices";
IPHONEOS_DEPLOYMENT_TARGET = 17.0; IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 1.0.1;
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios.CalendarrWidgets; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios.CalendarrWidgets;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO; SUPPORTS_MACCATALYST = YES;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
@@ -604,6 +614,7 @@
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1; TARGETED_DEVICE_FAMILY = 1;
"TARGETED_DEVICE_FAMILY[sdk=macosx*]" = 2;
}; };
name = Debug; name = Debug;
}; };
@@ -612,32 +623,32 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CODE_SIGN_ENTITLEMENTS = CalendarrWidgets/CalendarrWidgets.entitlements; CODE_SIGN_ENTITLEMENTS = CalendarrWidgets/CalendarrWidgets.entitlements;
"CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "CalendarrWidgets/CalendarrWidgets-Catalyst.entitlements";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = PP34X97WS3; DEVELOPMENT_TEAM = PP34X97WS3;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = CalendarrWidgets/Info.plist; INFOPLIST_FILE = CalendarrWidgets/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Calendarr Widgets"; INFOPLIST_KEY_CFBundleDisplayName = "Calendarr Widgets";
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices"; INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices";
IPHONEOS_DEPLOYMENT_TARGET = 17.0; IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 1.0.1;
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios.CalendarrWidgets; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios.CalendarrWidgets;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO; SUPPORTS_MACCATALYST = YES;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_COMPILATION_MODE = wholemodule; SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1; TARGETED_DEVICE_FAMILY = 1;
"TARGETED_DEVICE_FAMILY[sdk=macosx*]" = 2;
}; };
name = Release; name = Release;
}; };
@@ -681,6 +692,24 @@
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
/* End XCConfigurationList section */ /* End XCConfigurationList section */
/* Begin XCSwiftPackageProductDependency section */
CA1E4D0A0001000000000001 /* CalendarrCore */ = {
isa = XCSwiftPackageProductDependency;
productName = CalendarrCore;
};
CA1E4D0A0001000000000002 /* CalendarrCore */ = {
isa = XCSwiftPackageProductDependency;
productName = CalendarrCore;
};
/* End XCSwiftPackageProductDependency section */
/* Begin XCLocalSwiftPackageReference section */
39450EFC302A266A0090DDD7 /* XCLocalSwiftPackageReference "../CalendarrKit" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = ../CalendarrKit;
};
/* End XCLocalSwiftPackageReference section */
}; };
rootObject = C0000301FB4E10100AB5001 /* Project object */; rootObject = C0000301FB4E10100AB5001 /* Project object */;
} }

View File

@@ -12,7 +12,7 @@
<key>CalendarrWidgets.xcscheme_^#shared#^_</key> <key>CalendarrWidgets.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>1</integer> <integer>2</integer>
</dict> </dict>
</dict> </dict>
<key>SuppressBuildableAutocreation</key> <key>SuppressBuildableAutocreation</key>

View File

@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!--
Mac Catalyst entitlements. A separate file is required because the App
Group value differs per platform: macOS demands the Team ID prefix, iOS
forbids it, and listing both in one file breaks iOS provisioning.
Selected via CODE_SIGN_ENTITLEMENTS[sdk=macosx*].
This one file serves both distribution paths. app-sandbox is required by
the Mac App Store and desirable for Developer ID; ENABLE_HARDENED_RUNTIME
is required for notarization and ignored by the App Store. Only the
signing identity differs, and that is chosen at export time.
-->
<key>com.apple.security.app-sandbox</key>
<true/>
<!-- Talks to the user's own Calendarr server. -->
<key>com.apple.security.network.client</key>
<true/>
<!-- BirthdaysImporter reads Contacts to sync birthdays. -->
<key>com.apple.security.personal-information.addressbook</key>
<true/>
<!-- .ics import (fileImporter) and calendar export (fileExporter). -->
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<!-- Must match CalendarrAppGroup.current on this platform, byte for byte. -->
<key>com.apple.security.application-groups</key>
<array>
<string>PP34X97WS3.group.com.scarriffleservices.calendarr</string>
</array>
<!-- Own group first: it stays the default access group, so tokens written
before this entitlement existed keep resolving. The .shared group is
what a second app from this team reads the auth token from. -->
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.scarriffleservices.calendarr.ios</string>
<string>$(AppIdentifierPrefix)com.scarriffleservices.calendarr.shared</string>
</array>
</dict>
</plist>

View File

@@ -6,5 +6,15 @@
<array> <array>
<string>group.com.scarriffleservices.calendarr</string> <string>group.com.scarriffleservices.calendarr</string>
</array> </array>
<!-- Order matters. Without this entitlement the default access group is
<AppIdentifierPrefix><bundle id>, which is where every existing token
lives. Adding the entitlement makes the FIRST entry the new default, so
listing our own group first keeps those tokens resolving and nobody
gets signed out. The .shared group is the one the Mac app also reads. -->
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.scarriffleservices.calendarr.ios</string>
<string>$(AppIdentifierPrefix)com.scarriffleservices.calendarr.shared</string>
</array>
</dict> </dict>
</plist> </plist>

View File

@@ -24,11 +24,45 @@ class AppState {
init() { init() {
serverURL = UserDefaults.standard.string(forKey: "serverURL") ?? "" serverURL = UserDefaults.standard.string(forKey: "serverURL") ?? ""
authToken = UserDefaults.standard.string(forKey: "authToken") ?? "" authToken = Self.loadToken()
username = UserDefaults.standard.string(forKey: "username") ?? "" username = UserDefaults.standard.string(forKey: "username") ?? ""
isAdmin = UserDefaults.standard.bool(forKey: "isAdmin") isAdmin = UserDefaults.standard.bool(forKey: "isAdmin")
} }
/// Find the stored token, migrating it forward from wherever an older build
/// left it. Runs on every launch but does real work only once.
///
/// Step 2 is the one that keeps existing users signed in. Before the
/// `keychain-access-groups` entitlement existed, items landed in the app's
/// own default group. Adding the entitlement makes the *first* array entry
/// the new default and that entry is deliberately the app's own group, so
/// an unqualified query still resolves those items and we can copy them
/// across instead of stranding them.
private static func loadToken() -> String {
let key = "authToken"
// 1. Already in the shared group the steady state.
if let token = try? KeychainStore.get(key), !token.isEmpty {
return token
}
// 2. In the app's own default group, written before the entitlement.
if let token = try? KeychainStore.get(key, accessGroup: nil), !token.isEmpty {
try? KeychainStore.set(token, for: key)
try? KeychainStore.set(nil, for: key, accessGroup: nil)
return token
}
// 3. In UserDefaults, written before secrets moved to the Keychain.
if let legacy = UserDefaults.standard.string(forKey: key), !legacy.isEmpty {
try? KeychainStore.set(legacy, for: key)
UserDefaults.standard.removeObject(forKey: key)
return legacy
}
return ""
}
func saveServer(url: String) { func saveServer(url: String) {
serverURL = url.trimmingCharacters(in: .whitespacesAndNewlines) serverURL = url.trimmingCharacters(in: .whitespacesAndNewlines)
if serverURL.hasSuffix("/") { serverURL = String(serverURL.dropLast()) } if serverURL.hasSuffix("/") { serverURL = String(serverURL.dropLast()) }
@@ -39,23 +73,45 @@ class AppState {
authToken = token authToken = token
username = user username = user
isAdmin = admin isAdmin = admin
UserDefaults.standard.set(token, forKey: "authToken") try? KeychainStore.set(token, for: "authToken") // secret Keychain, not UserDefaults
UserDefaults.standard.set(user, forKey: "username") UserDefaults.standard.set(user, forKey: "username")
UserDefaults.standard.set(admin, forKey: "isAdmin") UserDefaults.standard.set(admin, forKey: "isAdmin")
publishSession()
}
/// Mirror the non-secret session facts into the shared container. Apps in
/// another sandbox cannot read our UserDefaults, so this is how they learn
/// which server we point at and whether anyone is signed in. The token
/// itself stays in the shared keychain group, never in a plain file.
private func publishSession() {
WidgetStore.writeSession(baseURL: serverURL,
username: username,
isLoggedIn: isLoggedIn)
} }
func logout() { func logout() {
authToken = "" authToken = ""
username = "" username = ""
isAdmin = false isAdmin = false
UserDefaults.standard.removeObject(forKey: "authToken") try? KeychainStore.set(nil, for: "authToken")
try? KeychainStore.set(nil, for: "authToken", accessGroup: nil) // pre-entitlement copy
UserDefaults.standard.removeObject(forKey: "authToken") // pre-Keychain copy
UserDefaults.standard.removeObject(forKey: "username") UserDefaults.standard.removeObject(forKey: "username")
UserDefaults.standard.removeObject(forKey: "isAdmin") UserDefaults.standard.removeObject(forKey: "isAdmin")
// The shared container outlives the session, so it has to be cleared
// explicitly otherwise widgets keep showing the signed-out user's data.
// The session record stays behind, flagged signed-out, so a reader can
// say "sign in to Calendarr" rather than "open Calendarr once".
WidgetStore.clear()
publishSession()
} }
func resetServer() { func resetServer() {
logout() logout()
serverURL = "" serverURL = ""
UserDefaults.standard.removeObject(forKey: "serverURL") UserDefaults.standard.removeObject(forKey: "serverURL")
// Back to unconfigured: there is no server left to name, so drop the
// session record rather than leaving a stale URL in the container.
WidgetStore.clearSession()
} }
} }

View File

@@ -15,10 +15,14 @@ struct AppSettings: Codable {
var monthLabelColor: String = "#7090c0" var monthLabelColor: String = "#7090c0"
var textColor: String = "#FFFFFF" var textColor: String = "#FFFFFF"
var backgroundColor: String = "#000000" var backgroundColor: String = "#000000"
var lineColor: String = "#3A3A3C" var lineColor: String = "#3A3A52"
var privateEventVisibility: String = "busy" // 'hidden' | 'busy' var privateEventVisibility: String = "busy" // 'hidden' | 'busy'
var groupVisibleCalendarId: Int? = nil var groupVisibleCalendarId: Int? = nil
var defaultReminderMinutes: Int? = nil // minutes before start; nil = off var defaultReminderMinutes: Int? = nil // minutes before start; nil = off
var defaultEventDurationMinutes: Int = 60 // applied to a new event's end time
var cacheMonths: Int = 3 // preloaded month range (device-local by default)
var monthViewPaged: Bool = false // month view swipe-paging (device-local by default)
var syncFlags: [String: Bool]? = nil // server-resolved per-setting sync flags
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case defaultView = "default_view" case defaultView = "default_view"
@@ -34,20 +38,24 @@ struct AppSettings: Codable {
case monthDividerColor = "month_divider_color" case monthDividerColor = "month_divider_color"
case monthLabelColor = "month_label_color" case monthLabelColor = "month_label_color"
case textColor = "text_color" case textColor = "text_color"
case backgroundColor = "background_color" case backgroundColor = "bg_color"
case lineColor = "line_color" case lineColor = "line_color"
case privateEventVisibility = "private_event_visibility" case privateEventVisibility = "private_event_visibility"
case groupVisibleCalendarId = "group_visible_calendar_id" case groupVisibleCalendarId = "group_visible_calendar_id"
case defaultReminderMinutes = "default_reminder_minutes" case defaultReminderMinutes = "default_reminder_minutes"
case defaultEventDurationMinutes = "default_event_duration_minutes"
case cacheMonths = "cache_months"
case monthViewPaged = "month_view_paged"
case syncFlags = "sync_flags"
} }
init() {} init() {}
/// Resilient decoding: the server only stores a subset of these fields /// Resilient decoding: a server may omit some keys. Using `decodeIfPresent`
/// (e.g. it has no `text_color`/`background_color`/`line_color`, which are /// with the property defaults means a missing key no longer aborts the whole
/// iOS-only). Using `decodeIfPresent` with the property defaults means a /// decode otherwise the entire settings sync silently breaks. (Note:
/// missing key no longer aborts the whole decode otherwise the entire /// `background_color` was previously mis-mapped; the server column is
/// settings sync silently breaks. /// `bg_color`, now corrected above.)
init(from decoder: Decoder) throws { init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self) let c = try decoder.container(keyedBy: CodingKeys.self)
let d = AppSettings() let d = AppSettings()
@@ -69,6 +77,10 @@ struct AppSettings: Codable {
privateEventVisibility = try c.decodeIfPresent(String.self, forKey: .privateEventVisibility) ?? d.privateEventVisibility privateEventVisibility = try c.decodeIfPresent(String.self, forKey: .privateEventVisibility) ?? d.privateEventVisibility
groupVisibleCalendarId = try c.decodeIfPresent(Int.self, forKey: .groupVisibleCalendarId) groupVisibleCalendarId = try c.decodeIfPresent(Int.self, forKey: .groupVisibleCalendarId)
defaultReminderMinutes = try c.decodeIfPresent(Int.self, forKey: .defaultReminderMinutes) defaultReminderMinutes = try c.decodeIfPresent(Int.self, forKey: .defaultReminderMinutes)
defaultEventDurationMinutes = try c.decodeIfPresent(Int.self, forKey: .defaultEventDurationMinutes) ?? d.defaultEventDurationMinutes
cacheMonths = try c.decodeIfPresent(Int.self, forKey: .cacheMonths) ?? d.cacheMonths
monthViewPaged = try c.decodeIfPresent(Bool.self, forKey: .monthViewPaged) ?? d.monthViewPaged
syncFlags = try c.decodeIfPresent([String: Bool].self, forKey: .syncFlags)
} }
} }
@@ -111,11 +123,17 @@ struct LocalCalendar: Codable, Identifiable {
var permission: String? = nil var permission: String? = nil
var group: Bool = false var group: Bool = false
var remindersEnabled: Bool = true var remindersEnabled: Bool = true
// Birthday calendar: events are all-day yearly; server renders age + cake icon.
var isBirthday: Bool = false
// Days before a birthday to remind (0 = on the day). nil = no reminder.
var birthdayNotifyDaysBefore: Int? = nil
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id, name, color, enabled, owned, permission, group case id, name, color, enabled, owned, permission, group
case sharedBy = "shared_by" case sharedBy = "shared_by"
case remindersEnabled = "reminders_enabled" case remindersEnabled = "reminders_enabled"
case isBirthday = "is_birthday"
case birthdayNotifyDaysBefore = "birthday_notify_days_before"
} }
init(from decoder: Decoder) throws { init(from decoder: Decoder) throws {
@@ -129,6 +147,8 @@ struct LocalCalendar: Codable, Identifiable {
permission = try c.decodeIfPresent(String.self, forKey: .permission) permission = try c.decodeIfPresent(String.self, forKey: .permission)
group = try c.decodeIfPresent(Bool.self, forKey: .group) ?? false group = try c.decodeIfPresent(Bool.self, forKey: .group) ?? false
remindersEnabled = try c.decodeIfPresent(Bool.self, forKey: .remindersEnabled) ?? true remindersEnabled = try c.decodeIfPresent(Bool.self, forKey: .remindersEnabled) ?? true
isBirthday = try c.decodeIfPresent(Bool.self, forKey: .isBirthday) ?? false
birthdayNotifyDaysBefore = try c.decodeIfPresent(Int.self, forKey: .birthdayNotifyDaysBefore)
} }
} }
@@ -220,6 +240,7 @@ struct UserProfile: Codable {
let isAdmin: Bool let isAdmin: Bool
let hasAvatar: Bool let hasAvatar: Bool
let totpEnabled: Bool let totpEnabled: Bool
var directoryHidden: Bool = false
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id, username, email case id, username, email
@@ -227,6 +248,19 @@ struct UserProfile: Codable {
case isAdmin = "is_admin" case isAdmin = "is_admin"
case hasAvatar = "has_avatar" case hasAvatar = "has_avatar"
case totpEnabled = "totp_enabled" case totpEnabled = "totp_enabled"
case directoryHidden = "directory_hidden"
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(Int.self, forKey: .id)
username = try c.decode(String.self, forKey: .username)
displayName = try c.decodeIfPresent(String.self, forKey: .displayName)
email = try c.decodeIfPresent(String.self, forKey: .email)
isAdmin = try c.decodeIfPresent(Bool.self, forKey: .isAdmin) ?? false
hasAvatar = try c.decodeIfPresent(Bool.self, forKey: .hasAvatar) ?? false
totpEnabled = try c.decodeIfPresent(Bool.self, forKey: .totpEnabled) ?? false
directoryHidden = try c.decodeIfPresent(Bool.self, forKey: .directoryHidden) ?? false
} }
} }
@@ -255,9 +289,20 @@ struct GroupMember: Codable, Identifiable {
let displayName: String? let displayName: String?
var role: String var role: String
var color: String? var color: String?
var sharesCalendar: Bool = true
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id, role, color case id, role, color
case displayName = "display_name" case displayName = "display_name"
case sharesCalendar = "shares_calendar"
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(Int.self, forKey: .id)
displayName = try c.decodeIfPresent(String.self, forKey: .displayName)
role = try c.decodeIfPresent(String.self, forKey: .role) ?? "member"
color = try c.decodeIfPresent(String.self, forKey: .color)
sharesCalendar = try c.decodeIfPresent(Bool.self, forKey: .sharesCalendar) ?? true
} }
} }

View File

@@ -18,6 +18,28 @@ struct EventPerson: Hashable {
} }
} }
/// A partial-sync failure reported alongside an otherwise-successful
/// `/api/caldav/events` response: one specific calendar didn't sync (e.g.
/// expired credentials) even though it's still enabled. Distinct from a
/// hard fetch failure (`CalendarStore.lastError`) the request itself
/// succeeded, just not every source within it.
struct SyncError: Hashable {
let source: String
let calendarId: String? // nil on older server responses without calendar_id
let name: String
let message: String
static func from(json: [String: Any]) -> SyncError? {
guard
let source = json["source"] as? String,
let name = json["name"] as? String,
let message = json["message"] as? String
else { return nil }
let calendarId = json["calendar_id"].map { "\($0)" }
return SyncError(source: source, calendarId: calendarId, name: name, message: message)
}
}
struct CalEvent: Identifiable, Hashable { struct CalEvent: Identifiable, Hashable {
let id: String let id: String
let url: String let url: String
@@ -43,10 +65,19 @@ struct CalEvent: Identifiable, Hashable {
var displayTitle: String? = nil var displayTitle: String? = nil
// Reminder offsets in minutes-before-start (0 = at start). Local events only. // Reminder offsets in minutes-before-start (0 = at start). Local events only.
var reminders: [Int] = [] var reminders: [Int] = []
// True for events from a calendar shared with the user read-only.
var readOnly: Bool = false
// True for events from a birthday calendar clients show a cake icon and the
// server bakes the age into `displayTitle`.
var isBirthday: Bool = false
// Group view supplies a server-resolved colour; otherwise per-event then calendar colour. // Group view supplies a server-resolved colour; otherwise per-event then calendar colour.
var effectiveColor: String { displayColor ?? color ?? calendarColor } var effectiveColor: String { displayColor ?? color ?? calendarColor }
// Title to render: the server-decorated one (birthday age, group prefix) wins
// over the raw title, which is kept for editing.
var renderTitle: String { displayTitle ?? title }
static func from(json: [String: Any]) -> CalEvent? { static func from(json: [String: Any]) -> CalEvent? {
guard guard
let title = json["title"] as? String, let title = json["title"] as? String,
@@ -85,7 +116,9 @@ struct CalEvent: Identifiable, Hashable {
isGroupEvent: json["is_group_event"] as? Bool ?? false, isGroupEvent: json["is_group_event"] as? Bool ?? false,
displayColor: (json["display_color"] as? String).flatMap { $0.isEmpty ? nil : $0 }, displayColor: (json["display_color"] as? String).flatMap { $0.isEmpty ? nil : $0 },
displayTitle: (json["display_title"] as? String).flatMap { $0.isEmpty ? nil : $0 }, displayTitle: (json["display_title"] as? String).flatMap { $0.isEmpty ? nil : $0 },
reminders: (json["reminders"] as? [Int]) ?? (json["reminders"] as? [Any])?.compactMap { ($0 as? Int) ?? Int("\($0)") } ?? [] reminders: (json["reminders"] as? [Int]) ?? (json["reminders"] as? [Any])?.compactMap { ($0 as? Int) ?? Int("\($0)") } ?? [],
readOnly: json["read_only"] as? Bool ?? false,
isBirthday: json["is_birthday"] as? Bool ?? false
) )
} }
} }
@@ -149,3 +182,21 @@ func formatISO(_ date: Date, allDay: Bool) -> String {
} }
return isoBasic.string(from: date) return isoBasic.string(from: date)
} }
/// Inline label for an event in a calendar grid: a leading birthday (cake) icon
/// when the event is a birthday, followed by the render title (which carries the
/// server-computed age). Icon and text inherit the surrounding font/colour, so
/// the label matches whatever bar it's dropped into.
struct EventLabel: View {
let event: CalEvent
var body: some View {
if event.isBirthday {
HStack(spacing: 3) {
Image(systemName: "birthday.cake.fill").imageScale(.small)
Text(event.renderTitle)
}
} else {
Text(event.renderTitle)
}
}
}

View File

@@ -1,5 +1,6 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
import CalendarrCore
extension Notification.Name { extension Notification.Name {
/// Posted whenever the persistent "banished calendars" set is mutated from /// Posted whenever the persistent "banished calendars" set is mutated from
@@ -58,6 +59,13 @@ class CalendarStore {
var isLoading = false var isLoading = false
var isCachingBackground = false var isCachingBackground = false
var lastError: String? = nil var lastError: String? = nil
/// Per-calendar sync failures reported alongside the last successful
/// personal `/events` fetch (e.g. expired CalDAV credentials on one
/// account) distinct from `lastError`, which means the whole fetch
/// failed. Not populated in group-overlay mode. Left untouched on a hard
/// fetch failure (see `loadEvents`), so a stale-but-real warning doesn't
/// get wiped by an unrelated network hiccup.
var syncErrors: [SyncError] = []
var weekStartsOnMonday = true var weekStartsOnMonday = true
var writableCalendars: [WritableCalendar] = [] var writableCalendars: [WritableCalendar] = []
// When set, the calendar shows the group's combined overlay instead of the // When set, the calendar shows the group's combined overlay instead of the
@@ -158,6 +166,49 @@ class CalendarStore {
return "\(source):\(id)" return "\(source):\(id)"
} }
// MARK: Calendar order (device-local, mirrors the web `cal_order`)
private static let orderDefaultsKey = "calendarOrder"
/// Persisted display order of calendar keys ("source:id"). Device-local; the
/// drawer's flat calendar list is sorted by this.
private(set) var calendarOrder: [String] = CalendarStore.loadOrder()
private static func loadOrder() -> [String] {
guard let raw = UserDefaults.standard.string(forKey: orderDefaultsKey),
let data = raw.data(using: .utf8),
let arr = try? JSONDecoder().decode([String].self, from: data)
else { return [] }
return arr
}
private func saveOrder() {
if let data = try? JSONEncoder().encode(calendarOrder),
let s = String(data: data, encoding: .utf8) {
UserDefaults.standard.set(s, forKey: Self.orderDefaultsKey)
}
}
/// Replace the stored order (after a drag-reorder).
func setCalendarOrder(_ keys: [String]) {
calendarOrder = keys
saveOrder()
}
/// Sort the given keys by the stored order (unknown keys end), then persist
/// the normalized order so newly-added calendars stick (mirrors the web).
func ordered(_ keys: [String]) -> [String] {
let current = calendarOrder
func idx(_ key: String) -> Int { current.firstIndex(of: key) ?? Int.max }
let sorted = keys.sorted { a, b in
let ia = idx(a), ib = idx(b)
return ia != ib ? ia < ib : a < b
}
calendarOrder = sorted
saveOrder()
return sorted
}
// MARK: Banished-calendar persistence // MARK: Banished-calendar persistence
private static let banishedKeysDefaultsKey = "banishedCalendarKeys" private static let banishedKeysDefaultsKey = "banishedCalendarKeys"
@@ -218,6 +269,38 @@ class CalendarStore {
publishWidgetSnapshot() publishWidgetSnapshot()
} }
/// Reconcile the local "banished" set with the server's per-calendar
/// `sidebar_hidden` flags (server wins for CalDAV / Google / HA). Returns
/// `true` if the set changed, so the caller can force a refetch.
///
/// This closes the sync gap where hiding/showing a calendar on the web (or
/// another device) was only ever picked up when the filter sheet or the
/// accounts screen happened to be opened not on app launch / resume. A
/// calendar re-enabled on the web has NO events in the cache (the server
/// excludes a hidden calendar's events entirely), so a plain
/// `refreshFromCache` can't bring them back: the caller must force a reload.
func reconcileCalendarVisibility(api: CalendarrAPI) async -> Bool {
async let c = (try? await api.getCalDAVAccounts()) ?? []
async let g = (try? await api.getGoogleAccounts()) ?? []
async let h = (try? await api.getHomeAssistantAccounts()) ?? []
let (caldav, google, ha) = await (c, g, h)
var b = banishedCalendarKeys
func applyServerHidden(_ source: String, _ id: Int, _ hidden: Bool) {
let key = Self.calendarKey(source: source, calendarId: "\(id)")
if hidden { b.insert(key) } else { b.remove(key) }
}
for acc in caldav { for cal in acc.calendars ?? [] { applyServerHidden("caldav", cal.id, cal.sidebarHidden) } }
for acc in google { for cal in acc.calendars ?? [] { applyServerHidden("google", cal.id, cal.sidebarHidden) } }
for acc in ha { for cal in acc.calendars ?? [] { applyServerHidden("homeassistant", cal.id, cal.sidebarHidden) } }
guard b != banishedCalendarKeys else { return false }
banishedCalendarKeys = b
Self.saveBanishedKeys(b)
NotificationCenter.default.post(name: .banishedCalendarsChanged, object: nil)
return true
}
// MARK: Reminder-disabled-calendar persistence // MARK: Reminder-disabled-calendar persistence
private static let reminderDisabledKeysDefaultsKey = "reminderDisabledCalendarKeys" private static let reminderDisabledKeysDefaultsKey = "reminderDisabledCalendarKeys"
@@ -340,10 +423,16 @@ class CalendarStore {
lastError = nil lastError = nil
defer { isLoading = false } defer { isLoading = false }
do { do {
let fetched = try await fetchForMode(api: api, start: start, end: end) let (fetched, errors) = try await fetchForMode(api: api, start: start, end: end)
mergeIntoCache(fetched, rangeStart: start, rangeEnd: end) syncErrors = errors
let failed = failedCalendarKeys(from: errors)
mergeIntoCache(fetched, rangeStart: start, rangeEnd: end,
keepKeysInRange: failed.keys, keepSourcesInRange: failed.sources)
refreshFromCache(start: start, end: end) refreshFromCache(start: start, end: end)
} catch { } catch {
// Hard failure leave `syncErrors` as-is; it reflects the last
// *successful* fetch and shouldn't be wiped by an unrelated
// network error on this attempt.
lastError = error.localizedDescription lastError = error.localizedDescription
} }
} }
@@ -351,10 +440,12 @@ class CalendarStore {
/// Fetch events for the current mode (personal vs. group overlay). Group /// Fetch events for the current mode (personal vs. group overlay). Group
/// events go through the same cache/prefetch/refresh path as personal ones, /// events go through the same cache/prefetch/refresh path as personal ones,
/// so the whole visible grid is covered (no "only the middle weeks" gaps). /// so the whole visible grid is covered (no "only the middle weeks" gaps).
private func fetchForMode(api: CalendarrAPI, start: Date, end: Date) async throws -> [CalEvent] { /// Per-calendar sync errors are only reported by the personal `/events`
/// endpoint; group overlays always report none.
private func fetchForMode(api: CalendarrAPI, start: Date, end: Date) async throws -> (events: [CalEvent], errors: [SyncError]) {
if let g = activeGroup { if let g = activeGroup {
let combined = try await api.fetchGroupCombined(groupId: g.id, start: start, end: end) let combined = try await api.fetchGroupCombined(groupId: g.id, start: start, end: end)
return combined.map { decorateGroupEvent($0) } return (combined.map { decorateGroupEvent($0) }, [])
} }
return try await api.fetchEvents(start: start, end: end) return try await api.fetchEvents(start: start, end: end)
} }
@@ -394,8 +485,11 @@ class CalendarStore {
isCachingBackground = true isCachingBackground = true
defer { isCachingBackground = false } defer { isCachingBackground = false }
do { do {
let fetched = try await fetchForMode(api: api, start: start, end: end) let (fetched, errors) = try await fetchForMode(api: api, start: start, end: end)
mergeIntoCache(fetched, rangeStart: start, rangeEnd: end) syncErrors = errors
let failed = failedCalendarKeys(from: errors)
mergeIntoCache(fetched, rangeStart: start, rangeEnd: end,
keepKeysInRange: failed.keys, keepSourcesInRange: failed.sources)
// Refresh visible range from newly expanded cache // Refresh visible range from newly expanded cache
let (vs, ve) = rangeForCurrentView() let (vs, ve) = rangeForCurrentView()
refreshFromCache(start: vs, end: ve) refreshFromCache(start: vs, end: ve)
@@ -414,14 +508,54 @@ class CalendarStore {
allCachedEvents = [] allCachedEvents = []
} }
private func mergeIntoCache(_ newEvents: [CalEvent], rangeStart: Date, rangeEnd: Date) { /// Calendars / sources that reported a sync error on the last fetch. Events
// Remove old events that overlap with the newly fetched range (avoid duplicates) /// matching either must NOT be evicted from the cache on a partial sync
/// stale data beats a completely empty calendar.
///
/// - `keys`: per-calendar errors carrying a `calendar_id` (e.g. one CalDAV
/// calendar with bad credentials) protect just that calendar.
/// - `sources`: account-level errors the server couldn't pin to a single
/// calendar protect every cached calendar of that source. The
/// key example is a Home Assistant / Google token-refresh
/// failure: it aborts the whole account fetch, so the error
/// arrives with no `calendar_id` and every HA calendar would
/// otherwise be wiped.
private func failedCalendarKeys(from errors: [SyncError]) -> (keys: Set<String>, sources: Set<String>) {
var keys = Set<String>()
var sources = Set<String>()
for err in errors {
if let cid = err.calendarId {
keys.insert(Self.calendarKey(source: err.source, calendarId: cid))
} else {
sources.insert(err.source)
}
}
return (keys, sources)
}
private func mergeIntoCache(_ newEvents: [CalEvent], rangeStart: Date, rangeEnd: Date,
keepKeysInRange: Set<String> = [],
keepSourcesInRange: Set<String> = []) {
// Remove old events in the fetched range to avoid duplicates but
// PRESERVE events from calendars / sources that had sync errors so that a
// transient CalDAV / Google / HA failure doesn't wipe the visible calendar.
let retained = allCachedEvents.filter { ev in let retained = allCachedEvents.filter { ev in
ev.startDate >= rangeEnd || ev.endDate <= rangeStart let outsideRange = ev.startDate >= rangeEnd || ev.endDate <= rangeStart
if outsideRange { return true }
if keepSourcesInRange.contains(ev.source) { return true }
guard !keepKeysInRange.isEmpty else { return false }
let key = Self.calendarKey(source: ev.source, calendarId: ev.calendarId)
return keepKeysInRange.contains(key)
} }
allCachedEvents = retained + newEvents allCachedEvents = retained + newEvents
// Extend cached range // Only extend the cached range when the fetch was completely clean.
// When some calendars/sources had sync errors, leave cachedStart/End
// unchanged: this keeps isCached() returning false for the next
// loadEvents call so the failed calendars are retried automatically
// rather than being silently treated as "done" with empty data
// (especially important on first launch or after forceReload).
guard keepKeysInRange.isEmpty, keepSourcesInRange.isEmpty else { return }
if let cs = cachedStart, let ce = cachedEnd { if let cs = cachedStart, let ce = cachedEnd {
cachedStart = min(cs, rangeStart) cachedStart = min(cs, rangeStart)
cachedEnd = max(ce, rangeEnd) cachedEnd = max(ce, rangeEnd)
@@ -438,12 +572,35 @@ class CalendarStore {
/// covers the worst-case month grid (6 rows × 7 cols) for the calendar /// covers the worst-case month grid (6 rows × 7 cols) for the calendar
/// widget. Also asks the system to refresh the widget timeline. /// widget. Also asks the system to refresh the widget timeline.
private func publishWidgetSnapshot() { private func publishWidgetSnapshot() {
// Never write group-view data into the widget; it would show other
// people's calendar colours and decorated event titles.
guard activeGroup == nil else { return }
let cal = userCalendar let cal = userCalendar
let now = Date() let now = Date()
// Include the week before today so widgets that show the current week // Include the week before today so widgets that show the current week
// (e.g. "This Week", "Up Next + Calendar") have data for Mondaytoday. // (e.g. "This Week", "Up Next + Calendar") have data for Mondaytoday.
let from = cal.date(byAdding: .day, value: -7, to: cal.startOfDay(for: now)) ?? now // The window is published in the snapshot as coverageStart/coverageEnd:
let to = cal.date(byAdding: .day, value: 42, to: cal.startOfDay(for: now)) ?? from // outside it a reader has no information, which is not the same as
// having no events, and only the writer knows where the edge is.
let dayStart = cal.startOfDay(for: now)
let from = cal.date(byAdding: .day, value: -SnapshotCoverage.daysBehind, to: dayStart) ?? now
let to = cal.date(byAdding: .day, value: SnapshotCoverage.daysAhead, to: dayStart) ?? from
// Build the calendar list from all cached events (not just the window)
// so every calendar appears in the widget configuration picker.
var calendarMap: [String: WidgetCalendar] = [:]
for ev in allCachedEvents {
let key = Self.calendarKey(source: ev.source, calendarId: ev.calendarId)
guard !banishedCalendarKeys.contains(key) else { continue }
if calendarMap[key] == nil {
calendarMap[key] = WidgetCalendar(id: key,
name: ev.calendarName.isEmpty ? ev.calendarId : ev.calendarName,
colorHex: ev.calendarColor)
}
}
WidgetStore.writeCalendars(Array(calendarMap.values).sorted { $0.name < $1.name })
let visible = allCachedEvents let visible = allCachedEvents
.filter { ev in .filter { ev in
let key = Self.calendarKey(source: ev.source, calendarId: ev.calendarId) let key = Self.calendarKey(source: ev.source, calendarId: ev.calendarId)
@@ -452,7 +609,7 @@ class CalendarStore {
&& !banishedCalendarKeys.contains(key) && !banishedCalendarKeys.contains(key)
} }
.sorted { $0.startDate < $1.startDate } .sorted { $0.startDate < $1.startDate }
.prefix(500) .prefix(SnapshotCoverage.maxEvents)
.map { ev in .map { ev in
WidgetEvent(id: ev.id, WidgetEvent(id: ev.id,
title: ev.title, title: ev.title,
@@ -460,21 +617,12 @@ class CalendarStore {
end: ev.endDate, end: ev.endDate,
isAllDay: ev.isAllDay, isAllDay: ev.isAllDay,
colorHex: ev.effectiveColor, colorHex: ev.effectiveColor,
location: ev.location) location: ev.location,
calendarKey: Self.calendarKey(source: ev.source, calendarId: ev.calendarId))
} }
let defaults = UserDefaults.standard WidgetStore.write(WidgetStore.makeSnapshot(events: Array(visible),
let snap = WidgetSnapshot( coverageStart: from,
writtenAt: now, coverageEnd: to))
events: Array(visible),
todayColorHex: defaults.string(forKey: "todayColor") ?? "#4285f4",
textColorHex: defaults.string(forKey: "textColor") ?? "#FFFFFF",
backgroundColorHex: defaults.string(forKey: "backgroundColor") ?? "#000000",
lineColorHex: defaults.string(forKey: "lineColor") ?? "#3A3A3C",
primaryColorHex: defaults.string(forKey: "primaryColor") ?? "#4285f4",
accentColorHex: defaults.string(forKey: "accentColor") ?? "#ea4335",
language: defaults.string(forKey: "appLanguage") ?? "system"
)
WidgetStore.write(snap)
WidgetTimelineNotifier.reload() WidgetTimelineNotifier.reload()
} }
@@ -487,7 +635,9 @@ class CalendarStore {
async let haCals = (try? await api.getHACalendars()) ?? [] async let haCals = (try? await api.getHACalendars()) ?? []
var result: [WritableCalendar] = [] var result: [WritableCalendar] = []
for cal in await localCals { // Skip read-only shared calendars offering them in the event editor
// only leads to a 403 on save. Own + read_write (incl. group) stay.
for cal in await localCals where cal.owned || cal.permission == "read_write" {
result.append(WritableCalendar(id: "local-\(cal.id)", name: cal.name, color: cal.color, source: "local", numericId: cal.id)) result.append(WritableCalendar(id: "local-\(cal.id)", name: cal.name, color: cal.color, source: "local", numericId: cal.id))
} }
for acc in await caldavAccs where acc.enabled { for acc in await caldavAccs where acc.enabled {

View File

@@ -81,6 +81,16 @@ private let strings: [String: [String: String]] = [
"settings.sync": "Einstellungen synchronisieren", "settings.sync": "Einstellungen synchronisieren",
"settings.sync.desc": "Darstellung mit dem Server abgleichen", "settings.sync.desc": "Darstellung mit dem Server abgleichen",
"settings.sync.footer": "Wenn aktiv, werden Farben, Kontraste und Stundenhöhe mit dem Server abgeglichen (der Server hat Vorrang). Ansicht, erster Wochentag und das Ausgrauen vergangener Termine werden immer synchronisiert auch wenn der Schalter aus ist.", "settings.sync.footer": "Wenn aktiv, werden Farben, Kontraste und Stundenhöhe mit dem Server abgeglichen (der Server hat Vorrang). Ansicht, erster Wochentag und das Ausgrauen vergangener Termine werden immer synchronisiert auch wenn der Schalter aus ist.",
"settings.sync_all": "Alle synchronisieren",
"settings.sync_all.desc": "Diese Einstellungen zwischen deinen Geräten teilen",
"settings.sync_this": "Zwischen Geräten synchronisieren",
"settings.reset": "Zurücksetzen",
"settings.appearance": "Ansicht",
"settings.color.surface": "Topbar-/Oberflächenfarbe",
"settings.surface.auto": "Auto (Milchglas)",
"settings.device": "Nur auf diesem Gerät",
"settings.hide_menu_button": "Menü-Button ausblenden",
"settings.device.footer": "Diese Einstellungen gelten nur auf diesem Gerät und werden nicht synchronisiert.",
"settings.cache.header": "Vorladen", "settings.cache.header": "Vorladen",
"settings.cache.title": "Vorladen", "settings.cache.title": "Vorladen",
@@ -127,10 +137,17 @@ private let strings: [String: [String: String]] = [
"settings.monday": "Montag", "settings.monday": "Montag",
"settings.sunday": "Sonntag", "settings.sunday": "Sonntag",
"settings.dimpast": "Vergangene Termine ausgrauen", "settings.dimpast": "Vergangene Termine ausgrauen",
"settings.month_paged": "Monatsansicht seitenweise wischen",
"settings.month_mode": "Monatswechsel",
"settings.month_mode.scroll": "Fortlaufend scrollen",
"settings.month_mode.paged": "Seitenweise blättern",
"settings.default_duration": "Standard-Termindauer",
"settings.nav.profile": "Profil", "settings.nav.profile": "Profil",
"settings.privacy": "Privatsphäre", "settings.privacy": "Privatsphäre",
"settings.private_visibility": "Private Termine für Gruppen", "settings.private_visibility": "Private Termine für Gruppen",
"settings.private_visibility.desc": "Wie private Termine für andere Gruppenmitglieder erscheinen", "settings.private_visibility.desc": "Wie private Termine für andere Gruppenmitglieder erscheinen",
"settings.directory_hidden": "Profil verbergen",
"settings.directory_hidden.desc": "Nicht in Teilen-/Gruppen-Auswahl anzeigen. In der Admin-Benutzerverwaltung bleibst du sichtbar.",
"settings.private.busy": "Als „Beschäftigt“", "settings.private.busy": "Als „Beschäftigt“",
"settings.private.hidden": "Ausblenden", "settings.private.hidden": "Ausblenden",
"settings.calendars": "Geteilter Kalender", "settings.calendars": "Geteilter Kalender",
@@ -288,6 +305,7 @@ private let strings: [String: [String: String]] = [
"accounts.loading": "Lade Konten…", "accounts.loading": "Lade Konten…",
"accounts.add.caldav": "CalDAV-Konto", "accounts.add.caldav": "CalDAV-Konto",
"accounts.add.local": "Lokaler Kalender", "accounts.add.local": "Lokaler Kalender",
"accounts.add.birthday": "Geburtstagskalender",
"accounts.add.ical": "iCal-URL abonnieren", "accounts.add.ical": "iCal-URL abonnieren",
"accounts.add.ha": "Home Assistant", "accounts.add.ha": "Home Assistant",
"accounts.caldav.header": "CalDAV-Konten", "accounts.caldav.header": "CalDAV-Konten",
@@ -314,6 +332,8 @@ private let strings: [String: [String: String]] = [
"filter.empty": "Keine Kalender vorhanden", "filter.empty": "Keine Kalender vorhanden",
"filter.show_all": "Alle anzeigen", "filter.show_all": "Alle anzeigen",
"filter.hide_all": "Alle ausblenden", "filter.hide_all": "Alle ausblenden",
"filter.sort": "Sortieren",
"filter.done": "Fertig",
"filter.button": "Kalender ein-/ausblenden", "filter.button": "Kalender ein-/ausblenden",
"filter.banish": "Dauerhaft ausblenden", "filter.banish": "Dauerhaft ausblenden",
"filter.reminders_on": "Benachrichtigungen an", "filter.reminders_on": "Benachrichtigungen an",
@@ -341,6 +361,32 @@ private let strings: [String: [String: String]] = [
"local.color": "Farbe", "local.color": "Farbe",
"local.create": "Erstellen", "local.create": "Erstellen",
// Birthdays
"birthday.new": "Neuer Geburtstag",
"birthday.calendar_name": "Geburtstage",
"birthday.activate": "Geburtstagskalender aktivieren",
"birthday.activate_hint": "Aktiviere den Geburtstagskalender, um Geburtstage anzulegen. Er erscheint als eigener Kalender in der Seitenleiste.",
"birthday.person": "Name",
"birthday.person_placeholder": "Name der Person",
"birthday.date": "Geburtstag",
"birthday.year_unknown": "Jahr unbekannt",
"birthday.no_calendars": "Kein Geburtstagskalender vorhanden. Erstelle zuerst einen unter „Konten & Kalender“.",
"birthday.is_calendar": "Geburtstagskalender",
"birthday.is_calendar.desc": "Ganztägige, jährliche Termine mit Alter und Geburtstags-Icon.",
"birthday.notify": "Erinnerung",
"birthday.notify.off": "Aus",
"birthday.notify.same_day": "Am Tag",
"birthday.notify.one_day": "1 Tag vorher",
"birthday.notify.days": "%d Tage vorher",
"birthday.contacts.header": "Geburtstage aus Kontakten",
"birthday.contacts.sync": "Aus Kontakten synchronisieren",
"birthday.contacts.target": "Zielkalender",
"birthday.contacts.sync_now": "Jetzt synchronisieren",
"birthday.contacts.synced": "Geburtstage synchronisiert",
"birthday.contacts.hint": "Überträgt Geburtstage aus deinen Kontakten in den gewählten Geburtstagskalender sichtbar auf allen Geräten.",
"birthday.contacts.denied": "Kein Zugriff auf Kontakte. Bitte in den iOS-Einstellungen erlauben.",
"birthday.contacts.need_calendar": "Erstelle zuerst einen Geburtstagskalender.",
// iCal add sheet // iCal add sheet
"ical.title": "iCal abonnieren", "ical.title": "iCal abonnieren",
"ical.subscription": "Abonnement", "ical.subscription": "Abonnement",
@@ -409,6 +455,16 @@ private let strings: [String: [String: String]] = [
"settings.sync": "Sync settings", "settings.sync": "Sync settings",
"settings.sync.desc": "Keep appearance in sync with the server", "settings.sync.desc": "Keep appearance in sync with the server",
"settings.sync.footer": "When on, colors, contrasts and hour height sync with the server (the server wins). View, first weekday and dimming past events always sync even when the switch is off.", "settings.sync.footer": "When on, colors, contrasts and hour height sync with the server (the server wins). View, first weekday and dimming past events always sync even when the switch is off.",
"settings.sync_all": "Sync all",
"settings.sync_all.desc": "Share these settings across your devices",
"settings.sync_this": "Sync across devices",
"settings.reset": "Reset",
"settings.appearance": "View",
"settings.color.surface": "Top bar / surface color",
"settings.surface.auto": "Auto (translucent)",
"settings.device": "This device only",
"settings.hide_menu_button": "Hide menu button",
"settings.device.footer": "These settings apply to this device only and are not synced.",
"settings.cache.header": "Preloading", "settings.cache.header": "Preloading",
"settings.cache.title": "Preloading", "settings.cache.title": "Preloading",
@@ -455,10 +511,17 @@ private let strings: [String: [String: String]] = [
"settings.monday": "Monday", "settings.monday": "Monday",
"settings.sunday": "Sunday", "settings.sunday": "Sunday",
"settings.dimpast": "Dim past events", "settings.dimpast": "Dim past events",
"settings.month_paged": "Swipe month view as pages",
"settings.month_mode": "Month switching",
"settings.month_mode.scroll": "Continuous scroll",
"settings.month_mode.paged": "Page by page",
"settings.default_duration": "Default event duration",
"settings.nav.profile": "Profile", "settings.nav.profile": "Profile",
"settings.privacy": "Privacy", "settings.privacy": "Privacy",
"settings.private_visibility": "Private events for groups", "settings.private_visibility": "Private events for groups",
"settings.private_visibility.desc": "How your private events appear to other group members", "settings.private_visibility.desc": "How your private events appear to other group members",
"settings.directory_hidden": "Hide my profile",
"settings.directory_hidden.desc": "Don't show me in share/group pickers. You stay visible in the admin user management.",
"settings.private.busy": "Show as \"Busy\"", "settings.private.busy": "Show as \"Busy\"",
"settings.private.hidden": "Hide", "settings.private.hidden": "Hide",
"settings.calendars": "Shared calendar", "settings.calendars": "Shared calendar",
@@ -616,6 +679,7 @@ private let strings: [String: [String: String]] = [
"accounts.loading": "Loading accounts…", "accounts.loading": "Loading accounts…",
"accounts.add.caldav": "CalDAV account", "accounts.add.caldav": "CalDAV account",
"accounts.add.local": "Local calendar", "accounts.add.local": "Local calendar",
"accounts.add.birthday": "Birthday calendar",
"accounts.add.ical": "Subscribe to iCal URL", "accounts.add.ical": "Subscribe to iCal URL",
"accounts.add.ha": "Home Assistant", "accounts.add.ha": "Home Assistant",
"accounts.caldav.header": "CalDAV accounts", "accounts.caldav.header": "CalDAV accounts",
@@ -642,6 +706,8 @@ private let strings: [String: [String: String]] = [
"filter.empty": "No calendars available", "filter.empty": "No calendars available",
"filter.show_all": "Show all", "filter.show_all": "Show all",
"filter.hide_all": "Hide all", "filter.hide_all": "Hide all",
"filter.sort": "Sort",
"filter.done": "Done",
"filter.button": "Show/hide calendars", "filter.button": "Show/hide calendars",
"filter.banish": "Hide permanently", "filter.banish": "Hide permanently",
"filter.reminders_on": "Reminders on", "filter.reminders_on": "Reminders on",
@@ -669,6 +735,32 @@ private let strings: [String: [String: String]] = [
"local.color": "Color", "local.color": "Color",
"local.create": "Create", "local.create": "Create",
// Birthdays
"birthday.new": "New birthday",
"birthday.calendar_name": "Birthdays",
"birthday.activate": "Enable birthday calendar",
"birthday.activate_hint": "Enable the birthday calendar to add birthdays. It appears as its own calendar in the sidebar.",
"birthday.person": "Name",
"birthday.person_placeholder": "Person's name",
"birthday.date": "Birthday",
"birthday.year_unknown": "Year unknown",
"birthday.no_calendars": "No birthday calendar yet. Create one first under “Accounts & Calendars”.",
"birthday.is_calendar": "Birthday calendar",
"birthday.is_calendar.desc": "All-day, yearly events with age and a birthday icon.",
"birthday.notify": "Reminder",
"birthday.notify.off": "Off",
"birthday.notify.same_day": "On the day",
"birthday.notify.one_day": "1 day before",
"birthday.notify.days": "%d days before",
"birthday.contacts.header": "Birthdays from Contacts",
"birthday.contacts.sync": "Sync from Contacts",
"birthday.contacts.target": "Target calendar",
"birthday.contacts.sync_now": "Sync now",
"birthday.contacts.synced": "Birthdays synced",
"birthday.contacts.hint": "Copies birthdays from your contacts into the chosen birthday calendar visible on all devices.",
"birthday.contacts.denied": "No access to Contacts. Please allow it in iOS Settings.",
"birthday.contacts.need_calendar": "Create a birthday calendar first.",
// iCal add sheet // iCal add sheet
"ical.title": "Subscribe to iCal", "ical.title": "Subscribe to iCal",
"ical.subscription": "Subscription", "ical.subscription": "Subscription",

View File

@@ -5,7 +5,64 @@ import Foundation
/// picker, and the notification scheduler so the choices stay consistent. /// picker, and the notification scheduler so the choices stay consistent.
enum ReminderOptions { enum ReminderOptions {
/// Selectable offsets in minutes-before-start. /// Selectable offsets in minutes-before-start.
static let all: [Int] = [0, 5, 10, 15, 30, 60, 120, 1440, 2880] static let all: [Int] = [0, 5, 15, 30, 60, 1440, 10080]
/// Quick presets shown in the picker; everything else is entered as a
/// custom number + unit. Default for a freshly-switched custom row.
static let presets: [Int] = [0, 30, 1440] // at start, 30 min, 1 day
static let customDefault = 120 // 2 hours (deliberately not a preset)
/// Time units for the custom picker (value × mult = minutes-before-start).
enum Unit: Int, CaseIterable, Identifiable {
case minutes, hours, days, weeks
var id: Int { rawValue }
var mult: Int {
switch self {
case .minutes: return 1
case .hours: return 60
case .days: return 1440
case .weeks: return 10080
}
}
}
/// Split a minutes value into the largest exact {value, unit} for the custom picker.
static func split(_ minutes: Int) -> (value: Int, unit: Unit) {
for u in Unit.allCases.reversed() where minutes > 0 && minutes % u.mult == 0 {
return (minutes / u.mult, u)
}
return (max(1, minutes), .minutes)
}
static func unitLabel(_ u: Unit, _ l: String) -> String {
let en = isEnglish(l)
switch u {
case .minutes: return en ? "minutes" : "Minuten"
case .hours: return en ? "hours" : "Stunden"
case .days: return en ? "days" : "Tage"
case .weeks: return en ? "weeks" : "Wochen"
}
}
/// Human label for a duration in minutes (no "before" suffix), e.g. "1 h", "30 min".
static func durationLabel(_ minutes: Int, _ l: String) -> String {
let en = isEnglish(l)
if minutes % 60 == 0 {
let h = minutes / 60
return en ? "\(h) h" : "\(h) Std."
}
if minutes > 60 {
let h = minutes / 60, m = minutes % 60
return "\(h):\(String(format: "%02d", m)) h"
}
return en ? "\(minutes) min" : "\(minutes) Min."
}
static func customLabel(_ l: String) -> String { isEnglish(l) ? "Custom…" : "Benutzerdefiniert…" }
static func beforeLabel(_ l: String) -> String { isEnglish(l) ? "before" : "vorher" }
static func disabledNote(_ l: String) -> String {
isEnglish(l)
? "Reminders are disabled for this calendar they will not fire."
: "Für diesen Kalender sind Benachrichtigungen deaktiviert Erinnerungen werden nicht ausgeführt."
}
private static func isEnglish(_ appLang: String) -> Bool { private static func isEnglish(_ appLang: String) -> Bool {
if appLang == "en" { return true } if appLang == "en" { return true }
@@ -16,13 +73,22 @@ enum ReminderOptions {
static func label(_ minutes: Int, _ appLang: String) -> String { static func label(_ minutes: Int, _ appLang: String) -> String {
let en = isEnglish(appLang) let en = isEnglish(appLang)
if minutes <= 0 { return en ? "At start time" : "Zur Startzeit" } if minutes <= 0 { return en ? "At start time" : "Zur Startzeit" }
if minutes < 60 { return en ? "\(minutes) min before" : "\(minutes) Min. vorher" } if minutes < 60 {
return en ? "\(minutes) minutes before" : "\(minutes) Minuten vorher"
}
if minutes < 1440 { if minutes < 1440 {
let h = minutes / 60 let h = minutes / 60
return en ? "\(h) h before" : "\(h) Std. vorher" if en { return h == 1 ? "1 hour before" : "\(h) hours before" }
return h == 1 ? "1 Stunde vorher" : "\(h) Stunden vorher"
} }
let d = minutes / 1440 if minutes < 10080 {
return en ? "\(d) day\(d == 1 ? "" : "s") before" : "\(d) Tag\(d == 1 ? "" : "e") vorher" let d = minutes / 1440
if en { return d == 1 ? "1 day before" : "\(d) days before" }
return d == 1 ? "1 Tag vorher" : "\(d) Tage vorher"
}
let w = minutes / 10080
if en { return w == 1 ? "1 week before" : "\(w) weeks before" }
return w == 1 ? "1 Woche vorher" : "\(w) Wochen vorher"
} }
static func sectionTitle(_ l: String) -> String { isEnglish(l) ? "Reminders" : "Benachrichtigungen" } static func sectionTitle(_ l: String) -> String { isEnglish(l) ? "Reminders" : "Benachrichtigungen" }

View File

@@ -0,0 +1,204 @@
import Foundation
import Contacts
import UIKit
/// One stored birthday row as returned by GET /api/local/calendars/{id}/birthdays.
/// Contact-sourced rows carry an `externalUid`; manually added ones have `nil`.
struct BirthdayEntry: Codable, Identifiable {
let uid: String
let externalUid: String?
let title: String
let month: Int?
let day: Int?
let birthYear: Int?
var id: String { uid }
enum CodingKeys: String, CodingKey {
case uid, title, month, day
case externalUid = "external_uid"
case birthYear = "birth_year"
}
}
/// Reads birthdays from the system Contacts and mirrors them into the user's
/// single birthday calendar on the backend, so they show on every client.
///
/// The sync is a **mirror**, scoped to THIS device: it reconciles rows whose
/// `external_uid` starts with `contact:<deviceId>:` (adds new, updates changed,
/// deletes removed) and never touches other devices' rows or manually added
/// birthdays. Age suffix, cake icon and "notify N days before" are done
/// server-side; this only uploads name + date + birth year, and reports the
/// device so the web can list "birthdays come from these devices".
enum BirthdaysImporter {
// MARK: Persisted state
enum Key {
static let enabled = "birthdaysSyncEnabled" // Bool
static let deviceId = "birthdaysDeviceId" // stable per-install UUID
}
static var isEnabled: Bool { UserDefaults.standard.bool(forKey: Key.enabled) }
static func setEnabled(_ on: Bool) { UserDefaults.standard.set(on, forKey: Key.enabled) }
static var deviceId: String {
if let id = UserDefaults.standard.string(forKey: Key.deviceId) { return id }
let id = UUID().uuidString
UserDefaults.standard.set(id, forKey: Key.deviceId)
return id
}
@MainActor static var deviceName: String {
let n = UIDevice.current.name
return n.isEmpty ? "iPhone" : n
}
private static var appLang: String {
UserDefaults.standard.string(forKey: "appLanguage") ?? "system"
}
// MARK: Contacts access
static var isAuthorized: Bool {
let s = CNContactStore.authorizationStatus(for: .contacts)
if s == .authorized { return true }
if #available(iOS 18.0, *), s == .limited { return true }
return false
}
@discardableResult
static func requestAccess() async -> Bool {
let status = CNContactStore.authorizationStatus(for: .contacts)
if status == .authorized { return true }
if #available(iOS 18.0, *), status == .limited { return true }
guard status == .notDetermined else { return false }
return await withCheckedContinuation { cont in
CNContactStore().requestAccess(for: .contacts) { granted, _ in
cont.resume(returning: granted)
}
}
}
// MARK: Reading contact birthdays
struct ContactBirthday {
let contactId: String
let name: String
let month: Int
let day: Int
let year: Int?
}
static func readContactBirthdays() throws -> [ContactBirthday] {
let store = CNContactStore()
let keys: [CNKeyDescriptor] = [
CNContactFormatter.descriptorForRequiredKeys(for: .fullName),
CNContactGivenNameKey as CNKeyDescriptor,
CNContactFamilyNameKey as CNKeyDescriptor,
CNContactOrganizationNameKey as CNKeyDescriptor,
CNContactBirthdayKey as CNKeyDescriptor,
]
let req = CNContactFetchRequest(keysToFetch: keys)
var out: [ContactBirthday] = []
try store.enumerateContacts(with: req) { contact, _ in
guard let bday = contact.birthday, let m = bday.month, let d = bday.day else { return }
var name = CNContactFormatter.string(from: contact, style: .fullName) ?? ""
if name.isEmpty {
name = [contact.givenName, contact.familyName]
.filter { !$0.isEmpty }.joined(separator: " ")
}
if name.isEmpty { name = contact.organizationName }
guard !name.isEmpty else { return }
let year = bday.year.flatMap { $0 > 0 ? $0 : nil }
out.append(ContactBirthday(
contactId: contact.identifier, name: name, month: m, day: d, year: year
))
}
return out
}
// MARK: The single birthday calendar
/// The user's birthday calendar, creating the single "Geburtstage" calendar
/// if none exists yet.
static func ensureBirthdayCalendar(api: CalendarrAPI) async -> LocalCalendar? {
let cals = (try? await api.getLocalCalendars()) ?? []
if let existing = cals.first(where: { $0.isBirthday && $0.owned }) { return existing }
let name = L10n.t("birthday.calendar_name", appLang)
return try? await api.addLocalCalendar(name: name, color: "#E0407F", isBirthday: true)
}
static func birthdayCalendar(api: CalendarrAPI) async -> LocalCalendar? {
let cals = (try? await api.getLocalCalendars()) ?? []
return cals.first(where: { $0.isBirthday && $0.owned })
}
// MARK: Sync
/// Mirror the address book into the birthday calendar (creating it if
/// needed). No-op unless enabled and Contacts access is granted.
static func sync(api: CalendarrAPI) async {
guard isEnabled else { return }
guard await requestAccess() else { return }
guard let contacts = try? readContactBirthdays() else { return }
// Only fill an EXISTING birthday calendar never auto-create it. The
// calendar is created explicitly; deleting it means sync has nowhere to
// go (and must not resurrect it).
guard let cal = await birthdayCalendar(api: api) else { return }
guard let existing = try? await api.getBirthdayEntries(calendarId: cal.id) else { return }
// Only reconcile THIS device's contact rows; leave other devices'
// rows and manual entries untouched.
let prefix = "contact:\(deviceId):"
var byExt: [String: BirthdayEntry] = [:]
for e in existing {
if let ext = e.externalUid, ext.hasPrefix(prefix) { byExt[ext] = e }
}
var seen = Set<String>()
for c in contacts {
let ext = prefix + c.contactId
seen.insert(ext)
let (start, end) = allDayRange(month: c.month, day: c.day, year: c.year)
if let match = byExt[ext] {
let changed = match.title != c.name || match.month != c.month
|| match.day != c.day || match.birthYear != c.year
if changed {
try? await api.updateLocalEvent(
uid: match.uid, title: c.name, start: start, end: end,
isAllDay: true, location: "", description: "", color: nil,
rrule: "FREQ=YEARLY", externalUid: ext, birthYear: c.year ?? -1
)
}
} else {
_ = try? await api.createLocalEvent(
calendarId: cal.id, title: c.name, start: start, end: end,
isAllDay: true, location: "", description: "", color: nil,
rrule: "FREQ=YEARLY", externalUid: ext, birthYear: c.year
)
}
}
for (ext, entry) in byExt where !seen.contains(ext) {
try? await api.deleteLocalEvent(uid: entry.uid)
}
// Report this device so the web can list where birthdays come from.
let name = await deviceName
try? await api.reportBirthdaySync(deviceId: deviceId, deviceName: name, count: contacts.count)
}
/// All-day [start, end) for a birthday. Anchor year = birth year when known,
/// else 1970 so FREQ=YEARLY expands across any queried range. Built at local
/// noon so day-only formatting can't roll to an adjacent day.
private static func allDayRange(month: Int, day: Int, year: Int?) -> (Date, Date) {
var comp = DateComponents()
comp.year = year ?? 1970
comp.month = month
comp.day = day
comp.hour = 12
let cal = Calendar.current
let start = cal.date(from: comp) ?? Date()
let end = cal.date(byAdding: .day, value: 1, to: start) ?? start
return (start, end)
}
}

View File

@@ -135,8 +135,13 @@ class CalendarrAPI {
return (try? JSONDecoder().decode([LocalCalendar].self, from: data)) ?? [] return (try? JSONDecoder().decode([LocalCalendar].self, from: data)) ?? []
} }
func addLocalCalendar(name: String, color: String) async throws -> LocalCalendar { func addLocalCalendar(name: String, color: String,
let data = try await request("/api/local/calendars", method: "POST", body: ["name": name, "color": color]) isBirthday: Bool = false,
birthdayNotifyDaysBefore: Int? = nil) async throws -> LocalCalendar {
var body: [String: Any] = ["name": name, "color": color]
if isBirthday { body["is_birthday"] = true }
if let d = birthdayNotifyDaysBefore { body["birthday_notify_days_before"] = d }
let data = try await request("/api/local/calendars", method: "POST", body: body)
guard let cal = try? JSONDecoder().decode(LocalCalendar.self, from: data) else { throw APIError.decodingError } guard let cal = try? JSONDecoder().decode(LocalCalendar.self, from: data) else { throw APIError.decodingError }
return cal return cal
} }
@@ -145,6 +150,32 @@ class CalendarrAPI {
_ = try await request("/api/local/calendars/\(id)", method: "DELETE") _ = try await request("/api/local/calendars/\(id)", method: "DELETE")
} }
/// Update a local calendar's birthday settings. `is_birthday` marks it as a
/// birthday calendar; `notifyDaysBefore` sets the reminder (nil sends the -1
/// sentinel to clear it server-side).
func updateLocalCalendarBirthday(id: Int, isBirthday: Bool? = nil, notifyDaysBefore: Int? = nil) async throws {
var body: [String: Any] = [:]
if let b = isBirthday { body["is_birthday"] = b }
if let n = notifyDaysBefore { body["birthday_notify_days_before"] = n } // -1 clears server-side
guard !body.isEmpty else { return }
_ = try await request("/api/local/calendars/\(id)", method: "PUT", body: body)
}
/// Raw (unexpanded) rows of a birthday calendar, used by the Contacts
/// importer to reconcile by `external_uid`.
func getBirthdayEntries(calendarId: Int) async throws -> [BirthdayEntry] {
let data = try await request("/api/local/calendars/\(calendarId)/birthdays")
return (try? JSONDecoder().decode([BirthdayEntry].self, from: data)) ?? []
}
/// Report that this device just synced its Contacts birthdays, so the web
/// can show "birthdays come from these devices".
func reportBirthdaySync(deviceId: String, deviceName: String, count: Int) async throws {
_ = try await request("/api/birthdays/sync-report", method: "POST", body: [
"device_id": deviceId, "device_name": deviceName, "count": count,
])
}
func getICalSubscriptions() async throws -> [ICalSubscription] { func getICalSubscriptions() async throws -> [ICalSubscription] {
let data = try await request("/api/ical/subscriptions") let data = try await request("/api/ical/subscriptions")
return (try? JSONDecoder().decode([ICalSubscription].self, from: data)) ?? [] return (try? JSONDecoder().decode([ICalSubscription].self, from: data)) ?? []
@@ -206,7 +237,12 @@ class CalendarrAPI {
// MARK: Events // MARK: Events
func fetchEvents(start: Date, end: Date) async throws -> [CalEvent] { /// Fetches events for the personal calendar view. The server also reports
/// per-calendar sync failures (e.g. expired credentials) alongside an
/// otherwise-successful response, via the `errors` array surfaced
/// separately from a hard fetch failure so the UI can distinguish
/// "everything failed" from "fetched fine, but calendar X didn't sync".
func fetchEvents(start: Date, end: Date) async throws -> (events: [CalEvent], errors: [SyncError]) {
// Use UTC with Z suffix avoids '+' character which breaks URL query params // Use UTC with Z suffix avoids '+' character which breaks URL query params
let iso = ISO8601DateFormatter() let iso = ISO8601DateFormatter()
iso.formatOptions = [.withInternetDateTime] iso.formatOptions = [.withInternetDateTime]
@@ -224,12 +260,16 @@ class CalendarrAPI {
let preview = String(data: data, encoding: .utf8).map { String($0.prefix(200)) } ?? "no data" let preview = String(data: data, encoding: .utf8).map { String($0.prefix(200)) } ?? "no data"
throw APIError.serverError("Unerwartete Antwort: \(preview)") throw APIError.serverError("Unerwartete Antwort: \(preview)")
} }
return arr.compactMap { CalEvent.from(json: $0) } let events = arr.compactMap { CalEvent.from(json: $0) }
let errors = (root["errors"] as? [[String: Any]])?.compactMap { SyncError.from(json: $0) } ?? []
return (events, errors)
} }
func createLocalEvent(calendarId: Int, title: String, start: Date, end: Date, func createLocalEvent(calendarId: Int, title: String, start: Date, end: Date,
isAllDay: Bool, location: String, description: String, color: String?, isAllDay: Bool, location: String, description: String, color: String?,
isPrivate: Bool = false, reminders: [Int]? = nil) async throws -> CalEvent { isPrivate: Bool = false, reminders: [Int]? = nil,
rrule: String? = nil, externalUid: String? = nil,
birthYear: Int? = nil) async throws -> CalEvent {
var body: [String: Any] = [ var body: [String: Any] = [
"calendar_id": calendarId, "calendar_id": calendarId,
"title": title, "title": title,
@@ -242,6 +282,9 @@ class CalendarrAPI {
] ]
if let c = color, !c.isEmpty { body["color"] = c } if let c = color, !c.isEmpty { body["color"] = c }
if let reminders { body["reminders"] = reminders } if let reminders { body["reminders"] = reminders }
if let rrule, !rrule.isEmpty { body["rrule"] = rrule }
if let externalUid { body["external_uid"] = externalUid }
if let birthYear { body["birth_year"] = birthYear }
let data = try await request("/api/local/events", method: "POST", body: body) let data = try await request("/api/local/events", method: "POST", body: body)
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let ev = CalEvent.from(json: json) else { throw APIError.decodingError } let ev = CalEvent.from(json: json) else { throw APIError.decodingError }
@@ -250,7 +293,9 @@ class CalendarrAPI {
func updateLocalEvent(uid: String, title: String, start: Date, end: Date, func updateLocalEvent(uid: String, title: String, start: Date, end: Date,
isAllDay: Bool, location: String, description: String, color: String?, isAllDay: Bool, location: String, description: String, color: String?,
isPrivate: Bool = false, reminders: [Int]? = nil) async throws { isPrivate: Bool = false, reminders: [Int]? = nil,
rrule: String? = nil, externalUid: String? = nil,
birthYear: Int? = nil) async throws {
var body: [String: Any] = [ var body: [String: Any] = [
"title": title, "title": title,
"start": formatISO(start, allDay: isAllDay), "start": formatISO(start, allDay: isAllDay),
@@ -262,6 +307,9 @@ class CalendarrAPI {
] ]
if let c = color { body["color"] = c } if let c = color { body["color"] = c }
if let reminders { body["reminders"] = reminders } if let reminders { body["reminders"] = reminders }
if let rrule { body["rrule"] = rrule }
if let externalUid { body["external_uid"] = externalUid }
if let birthYear { body["birth_year"] = birthYear }
_ = try await request("/api/local/events/\(uid)", method: "PUT", body: body) _ = try await request("/api/local/events/\(uid)", method: "PUT", body: body)
} }
@@ -419,8 +467,11 @@ class CalendarrAPI {
// MARK: Calendar colour // MARK: Calendar colour
/// Colour-only update: works for owners AND share recipients (recipients get
/// their own per-user colour; owners change the calendar's colour) unlike
/// the owner-only PUT /calendars/{id}, and never touches the name.
func updateLocalCalendarColor(id: Int, color: String) async throws { func updateLocalCalendarColor(id: Int, color: String) async throws {
_ = try await request("/api/local/calendars/\(id)", method: "PUT", body: ["color": color]) _ = try await request("/api/local/calendars/\(id)/color", method: "PUT", body: ["color": color])
} }
func updateICalColor(id: Int, color: String) async throws { func updateICalColor(id: Int, color: String) async throws {
@@ -443,11 +494,13 @@ class CalendarrAPI {
/// Update profile fields. A login-name change returns a fresh token (the old /// Update profile fields. A login-name change returns a fresh token (the old
/// one becomes invalid) the caller must store the returned token. /// one becomes invalid) the caller must store the returned token.
func updateProfile(displayName: String?, username: String?, email: String?) async throws -> String? { func updateProfile(displayName: String?, username: String?, email: String?,
directoryHidden: Bool? = nil) async throws -> String? {
var body: [String: Any] = [:] var body: [String: Any] = [:]
if let d = displayName { body["display_name"] = d } if let d = displayName { body["display_name"] = d }
if let u = username { body["username"] = u } if let u = username { body["username"] = u }
if let e = email { body["email"] = e } else { body["email"] = NSNull() } if let e = email { body["email"] = e } else { body["email"] = NSNull() }
if let h = directoryHidden { body["directory_hidden"] = h }
let data = try await request("/api/profile/", method: "PUT", body: body) let data = try await request("/api/profile/", method: "PUT", body: body)
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
return json?["access_token"] as? String return json?["access_token"] as? String

View File

@@ -0,0 +1,131 @@
import Foundation
import Security
/// Keychain access for secrets (currently just the auth bearer token).
///
/// This replaces the earlier `enum Keychain`, which discarded every `OSStatus`.
/// That mattered more than it looks: on Mac Catalyst a missing
/// `keychain-access-groups` entitlement makes `SecItemAdd`/`SecItemCopyMatching`
/// fail with `errSecMissingEntitlement`, and because nothing checked the status
/// the result was indistinguishable from "no token stored" the user was
/// silently signed out on every launch.
///
/// Items live in a shared access group so the Mac build and other apps from the
/// same team can read the token. Reading requires `keychain-access-groups` in
/// the entitlements listing `sharedAccessGroup`.
enum KeychainStore {
/// Access group shared across our apps. Must match an entry in the
/// `keychain-access-groups` entitlement of every app that uses it.
/// The `PP34X97WS3.` prefix is this account's Team ID the same value
/// `$(AppIdentifierPrefix)` expands to at build time.
static let sharedAccessGroup = "PP34X97WS3.com.scarriffleservices.calendarr.shared"
private static let service = "Calendarr"
/// Set when a keychain call failed because the entitlement was missing and
/// we fell back to the app's own default access group. Signing is a build
/// configuration concern, so in DEBUG we make it loud; in release we keep
/// working rather than locking the user out of their own account.
private(set) nonisolated(unsafe) static var didFallBackToDefaultGroup = false
// MARK: - Public API
/// Store or delete a value. Passing `nil` deletes it.
static func set(_ value: String?, for key: String,
accessGroup: String? = sharedAccessGroup) throws {
do {
try write(value, key: key, accessGroup: accessGroup)
} catch KeychainError.status(errSecMissingEntitlement, _) where accessGroup != nil {
noteEntitlementFallback()
try write(value, key: key, accessGroup: nil)
}
}
/// Read a value. Returns `nil` when the item simply is not there;
/// throws when the keychain itself refused the request.
static func get(_ key: String, accessGroup: String? = sharedAccessGroup) throws -> String? {
do {
return try read(key: key, accessGroup: accessGroup)
} catch KeychainError.status(errSecMissingEntitlement, _) where accessGroup != nil {
noteEntitlementFallback()
return try read(key: key, accessGroup: nil)
}
}
// MARK: - Implementation
private static func baseQuery(key: String, accessGroup: String?) -> [String: Any] {
var q: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
// Selects the modern, entitlement-gated keychain on macOS/Catalyst
// instead of the legacy file-based login keychain. Documented no-op
// on iOS 13+, so it is safe to set unconditionally.
kSecUseDataProtectionKeychain as String: true,
]
if let accessGroup { q[kSecAttrAccessGroup as String] = accessGroup }
return q
}
private static func write(_ value: String?, key: String, accessGroup: String?) throws {
let base = baseQuery(key: key, accessGroup: accessGroup)
let deleteStatus = SecItemDelete(base as CFDictionary)
guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else {
throw KeychainError.status(deleteStatus, operation: "delete \(key)")
}
guard let value, let data = value.data(using: .utf8) else { return }
var add = base
add[kSecValueData as String] = data
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
let addStatus = SecItemAdd(add as CFDictionary, nil)
guard addStatus == errSecSuccess else {
throw KeychainError.status(addStatus, operation: "add \(key)")
}
}
private static func read(key: String, accessGroup: String?) throws -> String? {
var query = baseQuery(key: key, accessGroup: accessGroup)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var out: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &out)
switch status {
case errSecSuccess:
guard let data = out as? Data else { return nil }
return String(data: data, encoding: .utf8)
case errSecItemNotFound:
return nil
default:
throw KeychainError.status(status, operation: "read \(key)")
}
}
private static func noteEntitlementFallback() {
guard !didFallBackToDefaultGroup else { return }
didFallBackToDefaultGroup = true
assertionFailure("""
Keychain access group \(sharedAccessGroup) was refused \
(errSecMissingEntitlement). Add it to keychain-access-groups and \
enable Keychain Sharing on the App ID. Falling back to the app's \
own default group — the Mac app will not see this token.
""")
}
}
enum KeychainError: Error, LocalizedError {
case status(OSStatus, operation: String)
var errorDescription: String? {
switch self {
case let .status(status, operation):
let detail = SecCopyErrorMessageString(status, nil) as String? ?? "OSStatus \(status)"
return "Keychain \(operation) failed: \(detail)"
}
}
}

View File

@@ -50,7 +50,10 @@ enum NotificationScheduler {
for item in limited { for item in limited {
let content = UNMutableNotificationContent() let content = UNMutableNotificationContent()
content.title = item.event.title content.title = item.event.title
content.body = bodyText(item.event) let minutes = Int(item.event.startDate.timeIntervalSince(item.fire) / 60)
let rel = relativeText(minutes)
let detail = bodyText(item.event)
content.body = detail.isEmpty ? rel : "\(rel) · \(detail)"
content.sound = .default content.sound = .default
let comps = Calendar.current.dateComponents( let comps = Calendar.current.dateComponents(
[.year, .month, .day, .hour, .minute, .second], from: item.fire) [.year, .month, .day, .hour, .minute, .second], from: item.fire)
@@ -60,6 +63,14 @@ enum NotificationScheduler {
} }
} }
private static func relativeText(_ minutes: Int) -> String {
if minutes < 60 { return "in \(minutes) Min." }
if minutes == 60 { return "in 1 Std." }
if minutes < 1440 { return "in \(minutes / 60) Std." }
if minutes == 1440 { return "morgen" }
return "in \(minutes / 1440) Tagen"
}
private static func bodyText(_ ev: CalEvent) -> String { private static func bodyText(_ ev: CalEvent) -> String {
var parts: [String] = [] var parts: [String] = []
if !ev.isAllDay { if !ev.isAllDay {

View File

@@ -6,108 +6,185 @@ extension Notification.Name {
static let settingsDidChange = Notification.Name("settingsDidChange") static let settingsDidChange = Notification.Name("settingsDidChange")
} }
/// Two-way synchronisation of appearance/behaviour settings between the app and /// Per-setting cross-device synchronisation. The server is the sole authority for
/// the Calendarr server. The server is treated as the source of truth on pull; /// WHICH settings sync (its `sync_flags` map); this client keeps a cached copy of
/// local edits are pushed immediately so the server then holds the newest value. /// that map and only sends/applies the keys whose flag is on. See
/// backend/SETTINGS_SYNC.md for the shared contract.
/// ///
/// Two groups: /// - Pull: for every syncable key whose flag is on, the server value wins.
/// - **optional** (colors, contrasts, hour height) only sync when the user has /// - Push (debounced, read-modify-write): start from the server snapshot,
/// enabled the `settingsSync` toggle. /// overwrite only the on-flagged keys with the local value, PUT.
/// - **always** (default view, week start, dim past events) sync regardless of
/// the toggle, because they describe how the user expects the calendar to be
/// computed/presented everywhere.
enum SettingsSync { enum SettingsSync {
// MARK: UserDefaults keys /// Server keys this iOS client can sync, mapped to their UserDefaults key.
/// Intentionally excluded: `language` (iOS "system" has no server value),
/// `share_calendar_icon` (no iOS UI), text/line contrast (iOS-only opacity
/// controls kept device-local), `liquid_glass` (iOS-only).
static let keyToDefaults: [String: String] = [
"default_view": "defaultView",
"week_start_day": "weekStartDay",
"dim_past_events": "dimPastEvents",
"hour_height": "hourHeight",
"default_event_duration_minutes": "defaultEventDurationMinutes",
"default_reminder_minutes": "defaultReminderMinutes",
"primary_color": "primaryColor",
"accent_color": "accentColor",
"today_color": "todayColor",
"text_color": "textColor",
"bg_color": "backgroundColor",
"line_color": "lineColor",
"month_divider_color": "monthDividerColor",
"month_label_color": "monthLabelColor",
"cache_months": "cacheMonths",
"month_view_paged": "monthViewPaged",
]
enum Key { static let syncableKeys: [String] = Array(keyToDefaults.keys)
// optional group
static let primaryColor = "primaryColor" /// Fallback flags used only until the first server pull populates the real map
static let accentColor = "accentColor" /// (mirrors the server's DEFAULT_SYNC for the iOS-managed keys).
static let todayColor = "todayColor" static let defaultSync: [String: Bool] = [
static let textColor = "textColor" "default_view": true, "week_start_day": true, "dim_past_events": true,
static let backgroundColor = "backgroundColor" "hour_height": true, "default_event_duration_minutes": true,
static let lineColor = "lineColor" "default_reminder_minutes": true, "primary_color": true, "accent_color": true,
static let monthDividerColor = "monthDividerColor" "today_color": true, "text_color": true, "bg_color": true, "line_color": true,
static let monthLabelColor = "monthLabelColor" "month_divider_color": true, "month_label_color": true,
static let textContrast = "textContrast" "cache_months": false, "month_view_paged": false,
static let lineContrast = "lineContrast" ]
static let hourHeight = "hourHeight"
// always group // MARK: Flag storage (UserDefaults JSON, mirrors the server's resolved map)
static let defaultView = "defaultView"
static let weekStartDay = "weekStartDay" private static let flagsKey = "settingsSyncFlags"
static let dimPastEvents = "dimPastEvents"
static let defaultReminder = "defaultReminderMinutes" // Int, -1 = off /// Effective flags: stored overrides on top of the fallback defaults.
// master switch static func flags() -> [String: Bool] {
static let enabled = "settingsSync" var result = defaultSync
if let data = UserDefaults.standard.data(forKey: flagsKey),
let stored = try? JSONDecoder().decode([String: Bool].self, from: data) {
for (k, v) in stored { result[k] = v }
}
return result
} }
static var isEnabled: Bool { UserDefaults.standard.bool(forKey: Key.enabled) } static func isSynced(_ key: String) -> Bool { flags()[key] ?? false }
// MARK: Defaults (mirror the historical hard-coded values) private static func writeFlags(_ f: [String: Bool]) {
if let data = try? JSONEncoder().encode(f) {
private static func int(_ key: String, _ fallback: Int) -> Int { UserDefaults.standard.set(data, forKey: flagsKey)
let v = UserDefaults.standard.object(forKey: key) as? Int }
return v ?? fallback
} }
/// Replace the cached flags with the server's resolved map (known keys only).
static func storeServerFlags(_ server: [String: Bool]?) {
guard let server else { return }
var f = flags()
for key in syncableKeys { if let v = server[key] { f[key] = v } }
writeFlags(f)
}
/// Toggle one setting's sync flag. Turning it on pushes this device's current
/// value up (it becomes the shared value); turning off keeps the value local.
static func setSynced(_ key: String, _ on: Bool, api: CalendarrAPI) {
var f = flags(); f[key] = on; writeFlags(f)
push(api: api)
}
/// The global "sync everything" switch.
static func setAllSynced(_ on: Bool, api: CalendarrAPI) {
var f = flags(); for key in syncableKeys { f[key] = on }; writeFlags(f)
push(api: api)
}
// MARK: Local AppSettings field mapping
private static func str(_ key: String, _ fallback: String) -> String { private static func str(_ key: String, _ fallback: String) -> String {
UserDefaults.standard.string(forKey: key) ?? fallback UserDefaults.standard.string(forKey: key) ?? fallback
} }
private static func int(_ key: String, _ fallback: Int) -> Int {
UserDefaults.standard.object(forKey: key) as? Int ?? fallback
}
// MARK: Build AppSettings from local UserDefaults /// Build an AppSettings snapshot from local UserDefaults.
static func currentSettings() -> AppSettings { static func currentSettings() -> AppSettings {
var s = AppSettings() var s = AppSettings()
s.primaryColor = str(Key.primaryColor, "#4285f4") s.primaryColor = str("primaryColor", "#4285f4")
s.accentColor = str(Key.accentColor, "#ea4335") s.accentColor = str("accentColor", "#ea4335")
s.todayColor = str(Key.todayColor, "#4285f4") s.todayColor = str("todayColor", "#4285f4")
s.textColor = str(Key.textColor, "#FFFFFF") s.textColor = str("textColor", "#FFFFFF")
s.backgroundColor = str(Key.backgroundColor, "#000000") s.backgroundColor = str("backgroundColor", "#000000")
s.lineColor = str(Key.lineColor, "#3A3A3C") s.lineColor = str("lineColor", "#3A3A52")
s.monthDividerColor = str(Key.monthDividerColor, "#7090c0") s.monthDividerColor = str("monthDividerColor", "#7090c0")
s.monthLabelColor = str(Key.monthLabelColor, "#7090c0") s.monthLabelColor = str("monthLabelColor", "#7090c0")
s.textContrast = int(Key.textContrast, 3) s.hourHeight = int("hourHeight", 60)
s.lineContrast = int(Key.lineContrast, 3) s.defaultView = str("defaultView", "month")
s.hourHeight = int(Key.hourHeight, 60) s.weekStartDay = str("weekStartDay", "monday")
s.defaultView = str(Key.defaultView, "month") s.dimPastEvents = UserDefaults.standard.bool(forKey: "dimPastEvents")
s.weekStartDay = str(Key.weekStartDay, "monday") s.cacheMonths = int("cacheMonths", 3)
s.dimPastEvents = UserDefaults.standard.bool(forKey: Key.dimPastEvents) s.monthViewPaged = UserDefaults.standard.bool(forKey: "monthViewPaged")
let rem = int(Key.defaultReminder, -1) let rem = int("defaultReminderMinutes", -1)
s.defaultReminderMinutes = rem < 0 ? nil : rem s.defaultReminderMinutes = rem < 0 ? nil : rem
s.defaultEventDurationMinutes = int("defaultEventDurationMinutes", 60)
return s return s
} }
// MARK: Apply a server snapshot to local UserDefaults /// Copy one synced field from a source snapshot into a destination snapshot.
private static func copyField(_ key: String, from src: AppSettings, into dst: inout AppSettings) {
switch key {
case "default_view": dst.defaultView = src.defaultView
case "week_start_day": dst.weekStartDay = src.weekStartDay
case "dim_past_events": dst.dimPastEvents = src.dimPastEvents
case "hour_height": dst.hourHeight = src.hourHeight
case "default_event_duration_minutes": dst.defaultEventDurationMinutes = src.defaultEventDurationMinutes
case "default_reminder_minutes": dst.defaultReminderMinutes = src.defaultReminderMinutes
case "primary_color": dst.primaryColor = src.primaryColor
case "accent_color": dst.accentColor = src.accentColor
case "today_color": dst.todayColor = src.todayColor
case "text_color": dst.textColor = src.textColor
case "bg_color": dst.backgroundColor = src.backgroundColor
case "line_color": dst.lineColor = src.lineColor
case "month_divider_color": dst.monthDividerColor = src.monthDividerColor
case "month_label_color": dst.monthLabelColor = src.monthLabelColor
case "cache_months": dst.cacheMonths = src.cacheMonths
case "month_view_paged": dst.monthViewPaged = src.monthViewPaged
default: break
}
}
/// Always writes the "always" trio. Writes the optional group only when /// Write one synced field from a server snapshot into local UserDefaults.
/// `includeOptional` is true. private static func applyField(_ key: String, from s: AppSettings) {
static func apply(_ s: AppSettings, includeOptional: Bool) {
let d = UserDefaults.standard let d = UserDefaults.standard
// always group switch key {
d.set(s.defaultView, forKey: Key.defaultView) case "default_view": d.set(s.defaultView, forKey: "defaultView")
d.set(s.weekStartDay, forKey: Key.weekStartDay) case "week_start_day": d.set(s.weekStartDay, forKey: "weekStartDay")
d.set(s.dimPastEvents, forKey: Key.dimPastEvents) case "dim_past_events": d.set(s.dimPastEvents, forKey: "dimPastEvents")
d.set(s.defaultReminderMinutes ?? -1, forKey: Key.defaultReminder) case "hour_height": d.set(s.hourHeight, forKey: "hourHeight")
guard includeOptional else { return } case "default_event_duration_minutes": d.set(s.defaultEventDurationMinutes, forKey: "defaultEventDurationMinutes")
// NOTE: textColor / backgroundColor / lineColor are intentionally NOT case "default_reminder_minutes": d.set(s.defaultReminderMinutes ?? -1, forKey: "defaultReminderMinutes")
// synced the server has no columns for them (iOS-only). Writing the case "primary_color": d.set(s.primaryColor, forKey: "primaryColor")
// resilient-decoded defaults here would wipe the user's local choices. case "accent_color": d.set(s.accentColor, forKey: "accentColor")
d.set(s.primaryColor, forKey: Key.primaryColor) case "today_color": d.set(s.todayColor, forKey: "todayColor")
d.set(s.accentColor, forKey: Key.accentColor) case "text_color": d.set(s.textColor, forKey: "textColor")
d.set(s.todayColor, forKey: Key.todayColor) case "bg_color": d.set(s.backgroundColor, forKey: "backgroundColor")
d.set(s.monthDividerColor, forKey: Key.monthDividerColor) case "line_color": d.set(s.lineColor, forKey: "lineColor")
d.set(s.monthLabelColor, forKey: Key.monthLabelColor) case "month_divider_color": d.set(s.monthDividerColor, forKey: "monthDividerColor")
d.set(s.textContrast, forKey: Key.textContrast) case "month_label_color": d.set(s.monthLabelColor, forKey: "monthLabelColor")
d.set(s.lineContrast, forKey: Key.lineContrast) case "cache_months": d.set(s.cacheMonths, forKey: "cacheMonths")
d.set(s.hourHeight, forKey: Key.hourHeight) case "month_view_paged": d.set(s.monthViewPaged, forKey: "monthViewPaged")
default: break
}
} }
// MARK: Pull // MARK: Pull
/// Fetch the server's settings and apply them locally (server wins). /// Fetch the server's settings, refresh the flag cache, and apply the value of
/// every on-flagged key locally (server wins).
static func pull(api: CalendarrAPI) async { static func pull(api: CalendarrAPI) async {
guard let server = try? await api.getSettings() else { return } guard let server = try? await api.getSettings() else { return }
apply(server, includeOptional: isEnabled) storeServerFlags(server.syncFlags)
let f = flags()
for key in syncableKeys where f[key] == true {
applyField(key, from: server)
}
await MainActor.run { await MainActor.run {
NotificationCenter.default.post(name: .settingsDidChange, object: nil) NotificationCenter.default.post(name: .settingsDidChange, object: nil)
} }
@@ -128,30 +205,17 @@ enum SettingsSync {
} }
} }
/// Read-modify-write: start from the server's current settings so that, /// Read-modify-write: start from the server's current settings so unsynced
/// when the optional group is NOT being synced, the server's colours stay /// fields stay intact, overwrite only the on-flagged keys with local values,
/// intact. Overwrite the trio always, the optional group only if enabled. /// and send the current flag map so the account-wide config stays in sync.
private static func performPush(api: CalendarrAPI) async { private static func performPush(api: CalendarrAPI) async {
guard var merged = try? await api.getSettings() else { return } guard var merged = try? await api.getSettings() else { return }
let local = currentSettings() let local = currentSettings()
// always group let f = flags()
merged.defaultView = local.defaultView for key in syncableKeys where f[key] == true {
merged.weekStartDay = local.weekStartDay copyField(key, from: local, into: &merged)
merged.dimPastEvents = local.dimPastEvents
merged.defaultReminderMinutes = local.defaultReminderMinutes
if isEnabled {
merged.primaryColor = local.primaryColor
merged.accentColor = local.accentColor
merged.todayColor = local.todayColor
merged.textColor = local.textColor
merged.backgroundColor = local.backgroundColor
merged.lineColor = local.lineColor
merged.monthDividerColor = local.monthDividerColor
merged.monthLabelColor = local.monthLabelColor
merged.textContrast = local.textContrast
merged.lineContrast = local.lineContrast
merged.hourHeight = local.hourHeight
} }
merged.syncFlags = f
try? await api.updateSettings(merged) try? await api.updateSettings(merged)
} }
} }

View File

@@ -24,6 +24,11 @@ struct AccountsView: View {
@State private var exportDoc: ExportedICS? @State private var exportDoc: ExportedICS?
@State private var infoMessage: String? @State private var infoMessage: String?
// Contacts birthday-calendar sync (opt-in, bound to one birthday calendar).
@AppStorage("birthdaysSyncEnabled") private var birthdaysSyncEnabled = false
@State private var isSyncingBirthdays = false
@State private var birthdayNotify = -1
@AppStorage("appLanguage") private var appLang = "system" @AppStorage("appLanguage") private var appLang = "system"
var body: some View { var body: some View {
@@ -36,10 +41,16 @@ struct AccountsView: View {
if !banishedKeys.isEmpty { banishedSection } if !banishedKeys.isEmpty { banishedSection }
caldavSection caldavSection
localSection localSection
birthdayContactsSection
icalSection icalSection
googleSection googleSection
haSection haSection
} }
.onChange(of: birthdaysSyncEnabled) { _, on in
// Enabling only asks for Contacts access it must NOT create
// the birthday calendar. That's an explicit action.
if on { Task { _ = await BirthdaysImporter.requestAccess() } }
}
} }
} }
.navigationTitle(L10n.t("accounts.title", appLang)) .navigationTitle(L10n.t("accounts.title", appLang))
@@ -49,6 +60,12 @@ struct AccountsView: View {
Menu { Menu {
Button(L10n.t("accounts.add.caldav", appLang)) { showAddCalDAV = true } Button(L10n.t("accounts.add.caldav", appLang)) { showAddCalDAV = true }
Button(L10n.t("accounts.add.local", appLang)) { showAddLocal = true } Button(L10n.t("accounts.add.local", appLang)) { showAddLocal = true }
// Only one birthday calendar per account.
if birthdayCalendar == nil {
Button(L10n.t("accounts.add.birthday", appLang)) {
Task { await createBirthdayCalendar() }
}
}
Button(L10n.t("accounts.add.ical", appLang)) { showAddICal = true } Button(L10n.t("accounts.add.ical", appLang)) { showAddICal = true }
Button(L10n.t("accounts.add.ha", appLang)) { showAddHA = true } Button(L10n.t("accounts.add.ha", appLang)) { showAddHA = true }
} label: { } label: {
@@ -169,7 +186,9 @@ struct AccountsView: View {
} else { } else {
ForEach(localCalendars) { cal in ForEach(localCalendars) { cal in
HStack { HStack {
CalendarColorDot(hex: cal.color, editable: cal.owned) { hex in // Recipients of a shared calendar may recolour it (their
// own per-user colour); renaming stays owner-only.
CalendarColorDot(hex: cal.color, editable: true) { hex in
try? await api.updateLocalCalendarColor(id: cal.id, color: hex) try? await api.updateLocalCalendarColor(id: cal.id, color: hex)
} }
Text(cal.name) Text(cal.name)
@@ -211,6 +230,63 @@ struct AccountsView: View {
} }
} }
/// The user's single birthday calendar (created on first sync/activation).
private var birthdayCalendar: LocalCalendar? {
localCalendars.first { $0.isBirthday && $0.owned }
}
@ViewBuilder var birthdayContactsSection: some View {
Section {
if let cal = birthdayCalendar {
Toggle(L10n.t("birthday.contacts.sync", appLang), isOn: $birthdaysSyncEnabled)
if birthdaysSyncEnabled {
BirthdayNotifyPicker(days: $birthdayNotify, appLang: appLang)
.onChange(of: birthdayNotify) { _, v in
Task { try? await api.updateLocalCalendarBirthday(id: cal.id, notifyDaysBefore: v) }
}
Button {
Task { await syncBirthdays() }
} label: {
HStack {
Text(L10n.t("birthday.contacts.sync_now", appLang))
if isSyncingBirthdays { Spacer(); ProgressView() }
}
}
.disabled(isSyncingBirthdays)
}
} else {
// No birthday calendar yet sync has nothing to fill. Create one
// first via the + menu (never auto-created by sync).
Text(L10n.t("birthday.contacts.need_calendar", appLang))
.font(.caption).foregroundStyle(.secondary)
}
} header: {
Text(L10n.t("birthday.contacts.header", appLang))
} footer: {
Text(L10n.t("birthday.contacts.hint", appLang))
}
}
/// Explicitly create the single birthday calendar (from the + menu).
private func createBirthdayCalendar() async {
_ = await BirthdaysImporter.ensureBirthdayCalendar(api: api)
await load()
}
private func syncBirthdays() async {
isSyncingBirthdays = true
defer { isSyncingBirthdays = false }
guard await BirthdaysImporter.requestAccess() else {
errorAlert = L10n.t("birthday.contacts.denied", appLang)
return
}
await BirthdaysImporter.sync(api: api)
await load() // pick up the (possibly newly created) birthday calendar
infoMessage = L10n.t("birthday.contacts.synced", appLang)
// Let the calendar refresh so imported birthdays show up right away.
NotificationCenter.default.post(name: .manualSyncRequested, object: nil)
}
var icalSection: some View { var icalSection: some View {
Section { Section {
if icalSubs.isEmpty { if icalSubs.isEmpty {
@@ -417,6 +493,8 @@ struct AccountsView: View {
CalendarStore.saveBanishedKeys(b) CalendarStore.saveBanishedKeys(b)
NotificationCenter.default.post(name: .banishedCalendarsChanged, object: nil) NotificationCenter.default.post(name: .banishedCalendarsChanged, object: nil)
} }
// Reflect the birthday calendar's current reminder setting in the picker.
birthdayNotify = birthdayCalendar?.birthdayNotifyDaysBefore ?? -1
isLoading = false isLoading = false
} }
@@ -574,6 +652,24 @@ struct AddLocalCalSheet: View {
} }
} }
/// Reusable "notify N days before" picker for birthday calendars.
/// -1 = off, 0 = on the day, N = N days before.
struct BirthdayNotifyPicker: View {
@Binding var days: Int
let appLang: String
var body: some View {
Picker(L10n.t("birthday.notify", appLang), selection: $days) {
Text(L10n.t("birthday.notify.off", appLang)).tag(-1)
Text(L10n.t("birthday.notify.same_day", appLang)).tag(0)
Text(L10n.t("birthday.notify.one_day", appLang)).tag(1)
ForEach([2, 3, 7], id: \.self) { d in
Text(String(format: L10n.t("birthday.notify.days", appLang), d)).tag(d)
}
}
}
}
struct AddICalSheet: View { struct AddICalSheet: View {
let api: CalendarrAPI let api: CalendarrAPI
let onDone: () async -> Void let onDone: () async -> Void

View File

@@ -79,7 +79,7 @@ private struct AgendaEventRow: View {
.frame(width: 4, height: 40) .frame(width: 4, height: 40)
VStack(alignment: .leading, spacing: 3) { VStack(alignment: .leading, spacing: 3) {
Text(event.title) EventLabel(event: event)
.font(.body.weight(.medium)) .font(.body.weight(.medium))
.foregroundStyle(.primary) .foregroundStyle(.primary)
HStack(spacing: 6) { HStack(spacing: 6) {

View File

@@ -0,0 +1,101 @@
import SwiftUI
/// Minimal "new birthday" mask for the single birthday calendar: enter a name
/// and a date (optionally "year unknown"). Saves an all-day, yearly-recurring
/// local event; the server adds the age suffix and cake icon on read. If no
/// birthday calendar exists yet, offers to activate one.
struct BirthdayEditorSheet: View {
let api: CalendarrAPI
var onDone: () async -> Void
@AppStorage("appLanguage") private var appLang = "system"
@Environment(\.dismiss) private var dismiss
@State private var calendar: LocalCalendar? = nil
@State private var name = ""
@State private var date = Date()
@State private var yearUnknown = false
@State private var loading = true
@State private var saving = false
@State private var activating = false
private var canSave: Bool {
!saving && calendar != nil
&& !name.trimmingCharacters(in: .whitespaces).isEmpty
}
var body: some View {
NavigationStack {
Form {
if loading {
HStack { Spacer(); ProgressView(); Spacer() }
} else if calendar == nil {
Section {
Text(L10n.t("birthday.activate_hint", appLang))
.foregroundStyle(.secondary)
Button(L10n.t("birthday.activate", appLang)) { Task { await activate() } }
.disabled(activating)
}
} else {
Section(L10n.t("birthday.person", appLang)) {
TextField(L10n.t("birthday.person_placeholder", appLang), text: $name)
}
Section(L10n.t("birthday.date", appLang)) {
DatePicker(L10n.t("birthday.date", appLang), selection: $date,
displayedComponents: [.date])
Toggle(L10n.t("birthday.year_unknown", appLang), isOn: $yearUnknown)
}
}
}
.navigationTitle(L10n.t("birthday.new", appLang))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(L10n.t("common.cancel", appLang)) { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button(L10n.t("event.save", appLang)) { Task { await save() } }
.disabled(!canSave)
}
}
.task { await load() }
}
}
private func load() async {
calendar = await BirthdaysImporter.birthdayCalendar(api: api)
loading = false
}
private func activate() async {
activating = true
calendar = await BirthdaysImporter.ensureBirthdayCalendar(api: api)
activating = false
}
private func save() async {
guard let cal = calendar else { return }
saving = true
let calc = Calendar.current
let comps = calc.dateComponents([.year, .month, .day], from: date)
let month = comps.month ?? 1
let day = comps.day ?? 1
let year = yearUnknown ? nil : comps.year
// All-day anchor at local noon; anchor year = birth year (else 1970) so
// the yearly rule expands across any queried range.
var anchor = DateComponents()
anchor.year = year ?? 1970
anchor.month = month
anchor.day = day
anchor.hour = 12
let start = calc.date(from: anchor) ?? date
let end = calc.date(byAdding: .day, value: 1, to: start) ?? start
_ = try? await api.createLocalEvent(
calendarId: cal.id, title: name.trimmingCharacters(in: .whitespaces),
start: start, end: end, isAllDay: true, location: "", description: "",
color: nil, rrule: "FREQ=YEARLY", birthYear: year
)
await onDone()
dismiss()
}
}

View File

@@ -0,0 +1,125 @@
import SwiftUI
/// The left side drawer: calendar visibility + group/view switching, plus a
/// single entry into the full menu (Settings/Accounts/Profile//Sync/Logout).
struct CalendarDrawer: View {
let api: CalendarrAPI
let store: CalendarStore
let groups: [CalGroup]
let onSwitchGroup: (CalGroup?) -> Void
let onSelectView: (CalViewType) -> Void
let onOpenMenu: () -> Void
let onSync: () -> Void
let onClose: () -> Void
@Environment(AppState.self) private var appState
@AppStorage("appLanguage") private var appLang = "system"
var body: some View {
VStack(spacing: 0) {
header
Divider()
viewSwitcher
if !groups.isEmpty {
Divider()
groupSwitcher
}
Divider()
CalendarFilterContent(api: api, store: store)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.background(Color(.systemBackground))
}
// MARK: Header
private var header: some View {
HStack(spacing: 14) {
Circle()
.fill(Color.accentColor)
.frame(width: 48, height: 48)
.overlay {
Text(appState.username.prefix(1).uppercased())
.font(.title3.bold()).foregroundStyle(.white)
}
VStack(alignment: .leading, spacing: 2) {
Text(appState.username).font(.title3.weight(.semibold)).lineLimit(1)
Text(appState.serverURL
.replacingOccurrences(of: "https://", with: "")
.replacingOccurrences(of: "http://", with: ""))
.font(.footnote).foregroundStyle(.secondary).lineLimit(1)
}
Spacer()
HStack(spacing: 20) {
Button { onSync() } label: {
Image(systemName: "arrow.triangle.2.circlepath").font(.system(size: 19, weight: .medium))
}
.buttonStyle(.plain).foregroundStyle(Color.accentColor)
.accessibilityLabel(L10n.t("menu.sync", appLang))
Button { onOpenMenu() } label: {
Image(systemName: "gearshape").font(.system(size: 19, weight: .medium))
}
.buttonStyle(.plain).foregroundStyle(Color.accentColor)
.accessibilityLabel(L10n.t("menu.section.settings", appLang))
Button { onClose() } label: {
Image(systemName: "xmark").font(.system(size: 17, weight: .semibold))
}
.buttonStyle(.plain).foregroundStyle(.secondary)
}
}
.padding(.horizontal, 18).padding(.top, 20).padding(.bottom, 16)
}
// MARK: View switcher
private var viewSwitcher: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(CalViewType.allCases, id: \.self) { vt in
let selected = store.viewType == vt
Button { onSelectView(vt) } label: {
Label(vt.label(appLang), systemImage: vt.systemImage)
.font(.caption.weight(.medium))
.padding(.horizontal, 12).padding(.vertical, 7)
.background(selected ? Color.accentColor : Color(.secondarySystemBackground))
.foregroundStyle(selected ? .white : .primary)
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 16).padding(.vertical, 13)
}
}
// MARK: Group switcher
private var groupSwitcher: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
chip(label: L10n.t("groups.personal", appLang),
systemImage: "person",
selected: store.activeGroup == nil) { onSwitchGroup(nil) }
ForEach(groups) { g in
chip(label: g.name,
systemImage: GroupIcons.symbol(g.icon),
selected: store.activeGroup?.id == g.id) { onSwitchGroup(g) }
}
}
.padding(.horizontal, 16).padding(.vertical, 13)
}
}
private func chip(label: String, systemImage: String, selected: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
Label(label, systemImage: systemImage)
.font(.caption.weight(.medium))
.padding(.horizontal, 12).padding(.vertical, 7)
.background(selected ? Color.accentColor : Color(.secondarySystemBackground))
.foregroundStyle(selected ? .white : .primary)
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
}

View File

@@ -21,6 +21,10 @@ struct CalendarHostView: View {
@AppStorage("backgroundColor") private var bgHex = "#000000" @AppStorage("backgroundColor") private var bgHex = "#000000"
@AppStorage("weekStartDay") private var weekStartDay = "monday" @AppStorage("weekStartDay") private var weekStartDay = "monday"
@AppStorage("defaultView") private var defaultView = "month" @AppStorage("defaultView") private var defaultView = "month"
// Opt-in: empty keeps the translucent `.bar` material; a hex tints the top bar.
@AppStorage("surfaceColor") private var surfaceHex = ""
// Device-local: hide the hamburger (drawer then opens only via edge-swipe).
@AppStorage("hideMenuButton") private var hideMenuButton = false
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
@@ -30,6 +34,8 @@ struct CalendarHostView: View {
@State private var showFilter = false @State private var showFilter = false
@State private var didApplyDefaultView = false @State private var didApplyDefaultView = false
@State private var groups: [CalGroup] = [] @State private var groups: [CalGroup] = []
@State private var showNewBirthday = false
@State private var showDrawer = false
private var titleString: String { private var titleString: String {
if store.viewType == .month { if store.viewType == .month {
@@ -42,11 +48,67 @@ struct CalendarHostView: View {
} }
var body: some View { var body: some View {
if liquidGlass { ZStack(alignment: .leading) {
glassVariant Group {
} else { if liquidGlass { glassVariant } else { flatVariant }
flatVariant }
// Dim scrim behind the open drawer.
if showDrawer {
Color.black.opacity(0.35)
.ignoresSafeArea()
.transition(.opacity)
.onTapGesture { closeDrawer() }
}
// Off-canvas left drawer.
CalendarDrawer(
api: api, store: store, groups: groups,
onSwitchGroup: { g in closeDrawer(); switchGroup(g) },
onSelectView: { vt in store.viewType = vt; closeDrawer() },
onOpenMenu: { closeDrawer(); showMenu = true },
onSync: { closeDrawer(); Task { await syncFromServer(force: true) } },
onClose: { closeDrawer() }
)
.frame(width: drawerWidth)
.frame(maxHeight: .infinity, alignment: .top)
.shadow(color: .black.opacity(showDrawer ? 0.25 : 0), radius: 12, x: 4)
.offset(x: showDrawer ? 0 : -(drawerWidth + 60))
.animation(.easeInOut(duration: 0.25), value: showDrawer)
// Swipe the drawer toward the left edge to close it.
.simultaneousGesture(
DragGesture(minimumDistance: 20)
.onEnded { v in
if showDrawer, v.translation.width < -45,
abs(v.translation.width) > abs(v.translation.height) {
closeDrawer()
}
}
)
} }
// A narrow leading strip opens the drawer via edge-swipe (kept off the
// content area so month paging / week swipe stay free).
.overlay(alignment: .leading) {
if !showDrawer {
Color.clear
.frame(width: 18)
.frame(maxHeight: .infinity)
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 12, coordinateSpace: .local)
.onEnded { v in
if v.translation.width > 45,
abs(v.translation.width) > abs(v.translation.height) {
withAnimation(.easeInOut(duration: 0.25)) { showDrawer = true }
}
}
)
}
}
}
private var drawerWidth: CGFloat { min(UIScreen.main.bounds.width - 40, 360) }
private func closeDrawer() {
withAnimation(.easeInOut(duration: 0.25)) { showDrawer = false }
} }
// MARK: Loading indicator // MARK: Loading indicator
@@ -85,7 +147,7 @@ struct CalendarHostView: View {
.onChange(of: store.viewType) { _, _ in Task { await onNavigate() } } .onChange(of: store.viewType) { _, _ in Task { await onNavigate() } }
.onChange(of: cacheMonths) { _, _ in Task { await recache() } } .onChange(of: cacheMonths) { _, _ in Task { await recache() } }
.onChange(of: store.visibleMonth) { _, new in Task { await ensureLoaded(around: new) } } .onChange(of: store.visibleMonth) { _, new in Task { await ensureLoaded(around: new) } }
.onChange(of: scenePhase) { _, phase in if phase == .active { Task { await SettingsSync.pull(api: api) } } } .onChange(of: scenePhase) { _, phase in if phase == .active { Task { await syncFromServer() } } }
.onReceive(NotificationCenter.default.publisher(for: .banishedCalendarsChanged)) { _ in .onReceive(NotificationCenter.default.publisher(for: .banishedCalendarsChanged)) { _ in
store.syncBanishedFromDefaults() store.syncBanishedFromDefaults()
} }
@@ -120,15 +182,13 @@ struct CalendarHostView: View {
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarLeading) { ToolbarItem(placement: .navigationBarLeading) {
HStack(spacing: 2) { HStack(spacing: 2) {
if !hideMenuButton { menuButton }
Button { store.navigatePrev() } label: { Image(systemName: "chevron.left") } Button { store.navigatePrev() } label: { Image(systemName: "chevron.left") }
Button { store.navigateNext() } label: { Image(systemName: "chevron.right") } Button { store.navigateNext() } label: { Image(systemName: "chevron.right") }
} }
} }
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
HStack(spacing: 8) { Button(L10n.t("nav.today", appLang)) { store.moveToToday() }.font(.callout)
Button(L10n.t("nav.today", appLang)) { store.moveToToday() }.font(.callout)
menuButton
}
} }
} }
.safeAreaInset(edge: .top, spacing: 0) { .safeAreaInset(edge: .top, spacing: 0) {
@@ -152,7 +212,7 @@ struct CalendarHostView: View {
.onChange(of: store.viewType) { _, _ in Task { await onNavigate() } } .onChange(of: store.viewType) { _, _ in Task { await onNavigate() } }
.onChange(of: cacheMonths) { _, _ in Task { await recache() } } .onChange(of: cacheMonths) { _, _ in Task { await recache() } }
.onChange(of: store.visibleMonth) { _, new in Task { await ensureLoaded(around: new) } } .onChange(of: store.visibleMonth) { _, new in Task { await ensureLoaded(around: new) } }
.onChange(of: scenePhase) { _, phase in if phase == .active { Task { await SettingsSync.pull(api: api) } } } .onChange(of: scenePhase) { _, phase in if phase == .active { Task { await syncFromServer() } } }
.onReceive(NotificationCenter.default.publisher(for: .banishedCalendarsChanged)) { _ in .onReceive(NotificationCenter.default.publisher(for: .banishedCalendarsChanged)) { _ in
store.syncBanishedFromDefaults() store.syncBanishedFromDefaults()
} }
@@ -174,6 +234,8 @@ struct CalendarHostView: View {
/// updates reliably on month change is identical in both modes. /// updates reliably on month change is identical in both modes.
@ViewBuilder private var barContents: some View { @ViewBuilder private var barContents: some View {
HStack(spacing: 0) { HStack(spacing: 0) {
// Menu (hamburger) on the left the drawer opens from the left.
if !hideMenuButton { menuButton.padding(.leading, 2) }
HStack(spacing: 2) { HStack(spacing: 2) {
Button { store.navigatePrev() } label: { Button { store.navigatePrev() } label: {
Image(systemName: "chevron.left") Image(systemName: "chevron.left")
@@ -186,7 +248,7 @@ struct CalendarHostView: View {
.frame(width: 36, height: 36) .frame(width: 36, height: 36)
} }
} }
.padding(.leading, 6) .padding(.leading, hideMenuButton ? 6 : 0)
Spacer(minLength: 6) Spacer(minLength: 6)
Text(titleString) Text(titleString)
.font(.headline) .font(.headline)
@@ -197,14 +259,15 @@ struct CalendarHostView: View {
Button(L10n.t("nav.today", appLang)) { store.moveToToday() } Button(L10n.t("nav.today", appLang)) { store.moveToToday() }
.font(.callout).padding(.horizontal, 6) .font(.callout).padding(.horizontal, 6)
.lineLimit(1).fixedSize() .lineLimit(1).fixedSize()
menuButton .padding(.trailing, 4)
.padding(.trailing, 2)
} }
.frame(height: 48) .frame(height: 48)
} }
private var topBar: some View { private var topBar: some View {
barContents.background(.bar) barContents.background(
surfaceHex.isEmpty ? AnyShapeStyle(Material.bar) : AnyShapeStyle(Color(hex: surfaceHex))
)
} }
@ViewBuilder private var groupBanner: some View { @ViewBuilder private var groupBanner: some View {
@@ -230,51 +293,11 @@ struct CalendarHostView: View {
Task { await forceReload() } Task { await forceReload() }
} }
/// The single top-bar action: a compact popup holding view / filter / /// Opens the side drawer (calendars + groups + navigation). Tinted when a
/// groups / sync, plus an "Einstellungen" entry that opens the full menu. /// filter/group is active so the user sees state at a glance.
/// (Replaces the separate view / filter / group icons in the bar.)
private var menuButton: some View { private var menuButton: some View {
Menu { Button {
// View (fixed icon, not per-view) withAnimation(.easeInOut(duration: 0.25)) { showDrawer = true }
Menu {
ForEach(CalViewType.allCases, id: \.self) { vt in
Button { store.viewType = vt } label: {
Label(vt.label(appLang), systemImage: store.viewType == vt ? "checkmark" : vt.systemImage)
}
}
} label: {
Label(L10n.t("view.change", appLang), systemImage: "rectangle.3.group")
}
// Filter
Button { showFilter = true } label: {
Label(L10n.t("filter.button", appLang), systemImage: "line.3.horizontal.decrease.circle")
}
// Groups
if !groups.isEmpty {
Menu {
Button { switchGroup(nil) } label: {
Label(L10n.t("groups.personal", appLang),
systemImage: store.activeGroup == nil ? "checkmark" : "person")
}
ForEach(groups) { g in
Button { switchGroup(g) } label: {
Label(g.name,
systemImage: store.activeGroup?.id == g.id ? "checkmark" : GroupIcons.symbol(g.icon))
}
}
} label: {
Label(L10n.t("groups.title", appLang), systemImage: "person.2")
}
}
// Sync
Button { Task { await SettingsSync.pull(api: api); await forceReload() } } label: {
Label(L10n.t("menu.sync", appLang), systemImage: "arrow.triangle.2.circlepath")
}
Divider()
// Full settings menu
Button { showMenu = true } label: {
Label(L10n.t("menu.section.settings", appLang), systemImage: "gearshape")
}
} label: { } label: {
Image(systemName: "line.3.horizontal") Image(systemName: "line.3.horizontal")
.font(.system(size: 18, weight: .medium)) .font(.system(size: 18, weight: .medium))
@@ -284,10 +307,14 @@ struct CalendarHostView: View {
.accessibilityLabel(L10n.t("nav.menu", appLang)) .accessibilityLabel(L10n.t("nav.menu", appLang))
} }
// MARK: Error banner // MARK: Error banners
// `lastError` (whole fetch failed) and `syncErrors` (fetch succeeded, but
// individual calendars didn't sync) are independent conditions and can
// both be shown at once.
@ViewBuilder private var errorBanner: some View { @ViewBuilder private var errorBanner: some View {
if let err = store.lastError { errorBannerView(err) } if let err = store.lastError { errorBannerView(err) }
if !store.syncErrors.isEmpty { syncErrorBannerView(store.syncErrors) }
} }
private func errorBannerView(_ err: String) -> some View { private func errorBannerView(_ err: String) -> some View {
@@ -303,6 +330,23 @@ struct CalendarHostView: View {
.background(Color.red.opacity(0.85)) .background(Color.red.opacity(0.85))
} }
/// One or more calendars didn't sync on the last fetch (e.g. expired
/// credentials) even though they're still enabled. Same visual language
/// as `errorBannerView`, joined into a single compact banner.
private func syncErrorBannerView(_ errors: [SyncError]) -> some View {
let text = errors.map { "\($0.source) (\($0.name)): \($0.message)" }.joined(separator: "\n")
return HStack(alignment: .top, spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.yellow)
Text(text).font(.caption).foregroundStyle(.white).lineLimit(errors.count + 1)
Spacer()
Button { Task { await onNavigate() } } label: {
Image(systemName: "arrow.clockwise").foregroundStyle(.white)
}
}
.padding(.horizontal, 12).padding(.vertical, 8)
.background(Color.red.opacity(0.85))
}
// MARK: Calendar content (with swipe) // MARK: Calendar content (with swipe)
@ViewBuilder @ViewBuilder
@@ -359,6 +403,21 @@ struct CalendarHostView: View {
// MARK: FAB buttons // MARK: FAB buttons
/// Long-press menu on the create button: a plain new event, or a new birthday
/// (which opens a minimal name + date mask that only targets birthday calendars).
@ViewBuilder private var fabMenu: some View {
Button {
editorContext = .create(.now)
} label: {
Label(L10n.t("event.new_title", appLang), systemImage: "plus")
}
Button {
showNewBirthday = true
} label: {
Label(L10n.t("birthday.new", appLang), systemImage: "birthday.cake.fill")
}
}
/// Standard solid FAB (flat mode) /// Standard solid FAB (flat mode)
private var solidFAB: some View { private var solidFAB: some View {
Button { Button {
@@ -372,6 +431,7 @@ struct CalendarHostView: View {
.clipShape(Circle()) .clipShape(Circle())
.shadow(radius: 4, y: 2) .shadow(radius: 4, y: 2)
} }
.contextMenu { fabMenu }
.padding(.trailing, 20).padding(.bottom, 20) .padding(.trailing, 20).padding(.bottom, 20)
} }
@@ -389,6 +449,7 @@ struct CalendarHostView: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.glassEffect(in: Circle()) .glassEffect(in: Circle())
.contextMenu { fabMenu }
.padding(.trailing, 20).padding(.bottom, 20) .padding(.trailing, 20).padding(.bottom, 20)
} else { } else {
solidFAB solidFAB
@@ -400,6 +461,7 @@ struct CalendarHostView: View {
private var calendarSheets: CalendarSheets { private var calendarSheets: CalendarSheets {
CalendarSheets(store: store, editorContext: $editorContext, CalendarSheets(store: store, editorContext: $editorContext,
selectedEvent: $selectedEvent, showFilter: $showFilter, selectedEvent: $selectedEvent, showFilter: $showFilter,
showNewBirthday: $showNewBirthday,
api: api, api: api,
reload: { await onNavigate() }, reload: { await onNavigate() },
reloadForce: { await reloadVisible(force: true) }) reloadForce: { await reloadVisible(force: true) })
@@ -416,6 +478,10 @@ struct CalendarHostView: View {
applyServerDrivenSettings(initial: true) applyServerDrivenSettings(initial: true)
await store.loadWritableCalendars(api: api) await store.loadWritableCalendars(api: api)
// Reconcile per-calendar visibility with the server BEFORE the first
// load so a calendar hidden/shown on the web is honoured immediately
// (banished set correct before events are filtered).
_ = await store.reconcileCalendarVisibility(api: api)
groups = (try? await api.getGroups()) ?? [] groups = (try? await api.getGroups()) ?? []
// 1. Load current view immediately (visible) // 1. Load current view immediately (visible)
let (s, e) = store.rangeForCurrentView() let (s, e) = store.rangeForCurrentView()
@@ -424,11 +490,19 @@ struct CalendarHostView: View {
Task(priority: .background) { Task(priority: .background) {
await store.prefetchBackground(api: api, months: cacheMonths) await store.prefetchBackground(api: api, months: cacheMonths)
} }
// 3. Periodic settings pull (tied to this .task's lifetime). // 2b. Mirror Contacts birthdays into the bound birthday calendar, if the
// user enabled it, then refresh so new birthdays appear immediately.
if BirthdaysImporter.isEnabled {
Task(priority: .background) {
await BirthdaysImporter.sync(api: api)
await forceReload()
}
}
// 3. Periodic settings + visibility pull (tied to this .task's lifetime).
while !Task.isCancelled { while !Task.isCancelled {
try? await Task.sleep(for: .seconds(600)) try? await Task.sleep(for: .seconds(600))
if Task.isCancelled { break } if Task.isCancelled { break }
await SettingsSync.pull(api: api) await syncFromServer()
} }
} }
@@ -476,6 +550,20 @@ struct CalendarHostView: View {
} }
} }
/// Pull server-driven settings AND reconcile per-calendar visibility in one
/// step (used on launch, resume and the periodic loop). If the server
/// changed a calendar's `sidebar_hidden` e.g. hidden/shown on the web or
/// another device or `force` is set (manual sync), refetch so the change
/// shows up without the user opening the filter sheet.
private func syncFromServer(force: Bool = false) async {
await SettingsSync.pull(api: api)
// Mirror Contacts birthdays on every server sync (manual, resume,
// periodic) the user expects "sync with server" to include birthdays.
if BirthdaysImporter.isEnabled { await BirthdaysImporter.sync(api: api) }
let changed = await store.reconcileCalendarVisibility(api: api)
if changed || force { await forceReload() }
}
/// Called when the user scrolls into a new month refreshes the visible range /// Called when the user scrolls into a new month refreshes the visible range
/// immediately from cache, then fetches on demand if needed. /// immediately from cache, then fetches on demand if needed.
private func ensureLoaded(around month: Date) async { private func ensureLoaded(around month: Date) async {
@@ -502,12 +590,16 @@ private struct CalendarSheets: ViewModifier {
@Binding var editorContext: CalEditorContext? @Binding var editorContext: CalEditorContext?
@Binding var selectedEvent: CalEvent? @Binding var selectedEvent: CalEvent?
@Binding var showFilter: Bool @Binding var showFilter: Bool
@Binding var showNewBirthday: Bool
let api: CalendarrAPI let api: CalendarrAPI
let reload: () async -> Void let reload: () async -> Void
let reloadForce: () async -> Void let reloadForce: () async -> Void
func body(content: Content) -> some View { func body(content: Content) -> some View {
content content
.sheet(isPresented: $showNewBirthday) {
BirthdayEditorSheet(api: api) { await reloadForce() }
}
// Use sheet(item:) so the editing event is captured atomically // Use sheet(item:) so the editing event is captured atomically
// avoiding the race where sheet(isPresented:) evaluates its content // avoiding the race where sheet(isPresented:) evaluates its content
// before the editingEvent state update propagates. // before the editingEvent state update propagates.

View File

@@ -8,7 +8,7 @@ struct DayView: View {
@AppStorage("appLanguage") private var appLang = "system" @AppStorage("appLanguage") private var appLang = "system"
@AppStorage("todayColor") private var todayHex = "#4285f4" @AppStorage("todayColor") private var todayHex = "#4285f4"
@AppStorage("textColor") private var textHex = "#FFFFFF" @AppStorage("textColor") private var textHex = "#FFFFFF"
@AppStorage("lineColor") private var lineHex = "#3A3A3C" @AppStorage("lineColor") private var lineHex = "#3A3A52"
@AppStorage("textContrast") private var textContrast = 3 @AppStorage("textContrast") private var textContrast = 3
@AppStorage("hourHeight") private var hourHeightPref = 60 // observed for live re-layout @AppStorage("hourHeight") private var hourHeightPref = 60 // observed for live re-layout
@@ -81,7 +81,7 @@ struct DayView: View {
HStack(spacing: 6) { HStack(spacing: 6) {
ForEach(allDayEvents) { ev in ForEach(allDayEvents) { ev in
Button(action: { onEventTap(ev) }) { Button(action: { onEventTap(ev) }) {
Text(ev.title) EventLabel(event: ev)
.font(.caption.weight(.medium)) .font(.caption.weight(.medium))
.foregroundStyle(.white) .foregroundStyle(.white)
.padding(.horizontal, 8).padding(.vertical, 4) .padding(.horizontal, 8).padding(.vertical, 4)
@@ -137,7 +137,7 @@ private struct DayHourSlot: View {
let language: String let language: String
let onCreateEvent: (Date) -> Void let onCreateEvent: (Date) -> Void
@AppStorage("lineColor") private var lineHex = "#3A3A3C" @AppStorage("lineColor") private var lineHex = "#3A3A52"
@AppStorage("lineContrast") private var lineContrast = 3 @AppStorage("lineContrast") private var lineContrast = 3
private var date: Date { private var date: Date {

View File

@@ -62,7 +62,7 @@ struct EventDetailSheet: View {
.fill(Color(hex: event.effectiveColor)) .fill(Color(hex: event.effectiveColor))
.frame(width: 6, height: 44) .frame(width: 6, height: 44)
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(event.title) Text(event.renderTitle)
.font(.title3.bold()) .font(.title3.bold())
Text(event.calendarName) Text(event.calendarName)
.font(.caption) .font(.caption)
@@ -156,7 +156,7 @@ struct EventDetailSheet: View {
} }
Button(L10n.t("common.cancel", appLang), role: .cancel) {} Button(L10n.t("common.cancel", appLang), role: .cancel) {}
} message: { } message: {
Text("\"\(event.title)\" \(L10n.t("detail.delete_msg_suffix", appLang))") Text("\"\(event.renderTitle)\" \(L10n.t("detail.delete_msg_suffix", appLang))")
} }
.sheet(isPresented: $showCopySheet) { .sheet(isPresented: $showCopySheet) {
EventEditorSheet( EventEditorSheet(

View File

@@ -11,6 +11,7 @@ struct EventEditorSheet: View {
@Environment(\.dismiss) var dismiss @Environment(\.dismiss) var dismiss
@AppStorage("appLanguage") private var appLang = "system" @AppStorage("appLanguage") private var appLang = "system"
@AppStorage("defaultReminderMinutes") private var defaultReminderMinutes = -1 @AppStorage("defaultReminderMinutes") private var defaultReminderMinutes = -1
@AppStorage("defaultEventDurationMinutes") private var defaultEventDurationMinutes = 60
@State private var title = "" @State private var title = ""
@State private var isAllDay = false @State private var isAllDay = false
@State private var startDate = Date() @State private var startDate = Date()
@@ -31,6 +32,15 @@ struct EventEditorSheet: View {
store.writableCalendars.first { $0.id == selectedCalendarId } store.writableCalendars.first { $0.id == selectedCalendarId }
} }
/// True when the selected calendar has its reminders muted: we keep any
/// existing reminders on the event (never delete them) but grey out the
/// controls and explain that they won't fire.
private var remindersDisabled: Bool {
guard let cal = selectedCal else { return false }
let key = CalendarStore.calendarKey(source: cal.source, calendarId: String(cal.numericId))
return store.reminderDisabledKeys.contains(key)
}
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Form { Form {
@@ -84,26 +94,26 @@ struct EventEditorSheet: View {
.tint(Color.accentColor) .tint(Color.accentColor)
} }
Section(ReminderOptions.sectionTitle(appLang)) { Section {
ForEach(Array(reminders.enumerated()), id: \.offset) { idx, _ in ForEach(reminders.indices, id: \.self) { idx in
Picker(ReminderOptions.sectionTitle(appLang), selection: Binding( ReminderEditRow(minutes: $reminders[idx], appLang: appLang)
get: { reminders.indices.contains(idx) ? reminders[idx] : 0 },
set: { if reminders.indices.contains(idx) { reminders[idx] = $0 } }
)) {
ForEach(ReminderOptions.all, id: \.self) { opt in
Text(ReminderOptions.label(opt, appLang)).tag(opt)
}
}
.labelsHidden()
} }
.onDelete { reminders.remove(atOffsets: $0) } .onDelete { reminders.remove(atOffsets: $0) }
Button { Button {
let next = ReminderOptions.all.first { !reminders.contains($0) } ?? 15 let next = ReminderOptions.presets.first { !reminders.contains($0) }
?? ReminderOptions.customDefault
reminders.append(next) reminders.append(next)
} label: { } label: {
Label(ReminderOptions.addLabel(appLang), systemImage: "bell.badge.plus") Label(ReminderOptions.addLabel(appLang), systemImage: "bell.badge.plus")
} }
} header: {
Text(ReminderOptions.sectionTitle(appLang))
} footer: {
if remindersDisabled {
Text(ReminderOptions.disabledNote(appLang)).foregroundStyle(.orange)
}
} }
.disabled(remindersDisabled)
} }
Section(L10n.t("event.color_section", appLang)) { Section(L10n.t("event.color_section", appLang)) {
@@ -197,7 +207,8 @@ struct EventEditorSheet: View {
let cal = Calendar.current let cal = Calendar.current
startDate = cal.date(bySettingHour: cal.component(.hour, from: initialDate), startDate = cal.date(bySettingHour: cal.component(.hour, from: initialDate),
minute: 0, second: 0, of: initialDate) ?? initialDate minute: 0, second: 0, of: initialDate) ?? initialDate
endDate = startDate.addingTimeInterval(3600) let durMin = defaultEventDurationMinutes > 0 ? defaultEventDurationMinutes : 60
endDate = startDate.addingTimeInterval(Double(durMin) * 60)
selectedCalendarId = store.writableCalendars.first?.id ?? "" selectedCalendarId = store.writableCalendars.first?.id ?? ""
// New events inherit the user's default reminder (editable). // New events inherit the user's default reminder (editable).
if defaultReminderMinutes >= 0 { reminders = [defaultReminderMinutes] } if defaultReminderMinutes >= 0 { reminders = [defaultReminderMinutes] }
@@ -263,3 +274,54 @@ struct EventEditorSheet: View {
} }
} }
} }
/// One reminder row: a preset picker plus, when "Custom" is chosen, a number
/// stepper and a unit picker. The value is always stored as minutes-before-start.
private struct ReminderEditRow: View {
@Binding var minutes: Int
let appLang: String
private let customTag = -1
private var isPreset: Bool { ReminderOptions.presets.contains(minutes) }
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Picker("", selection: Binding(
get: { isPreset ? minutes : customTag },
set: { newVal in
if newVal == customTag {
if ReminderOptions.presets.contains(minutes) { minutes = ReminderOptions.customDefault }
} else {
minutes = newVal
}
}
)) {
ForEach(ReminderOptions.presets, id: \.self) { Text(ReminderOptions.label($0, appLang)).tag($0) }
Text(ReminderOptions.customLabel(appLang)).tag(customTag)
}
.labelsHidden()
if !isPreset {
HStack {
Stepper(value: Binding(
get: { ReminderOptions.split(minutes).value },
set: { minutes = max(1, $0) * ReminderOptions.split(minutes).unit.mult }
), in: 1...999) {
Text("\(ReminderOptions.split(minutes).value)")
.monospacedDigit()
}
Picker("", selection: Binding(
get: { ReminderOptions.split(minutes).unit },
set: { newUnit in minutes = ReminderOptions.split(minutes).value * newUnit.mult }
)) {
ForEach(ReminderOptions.Unit.allCases) { u in
Text(ReminderOptions.unitLabel(u, appLang)).tag(u)
}
}
.labelsHidden()
Text(ReminderOptions.beforeLabel(appLang)).foregroundStyle(.secondary)
}
}
}
}
}

View File

@@ -4,6 +4,9 @@ import SwiftUI
// past 2100 enough room for any vacation that's actually getting planned. // past 2100 enough room for any vacation that's actually getting planned.
private let weeksBack = 520 private let weeksBack = 520
private let weeksAhead = 4000 private let weeksAhead = 4000
// Paged mode uses one page per month over a bounded range (keeps the TabView light).
private let monthsBack = 120
private let monthsAhead = 600
private let weekdayHeaderHeight: CGFloat = 28 private let weekdayHeaderHeight: CGFloat = 28
private let dayNumberRowHeight: CGFloat = 22 private let dayNumberRowHeight: CGFloat = 22
private let laneHeight: CGFloat = 16 private let laneHeight: CGFloat = 16
@@ -24,14 +27,30 @@ struct MonthView: View {
@AppStorage("monthDividerColor") private var dividerHex = "#7090c0" @AppStorage("monthDividerColor") private var dividerHex = "#7090c0"
@AppStorage("monthLabelColor") private var labelHex = "#7090c0" @AppStorage("monthLabelColor") private var labelHex = "#7090c0"
@AppStorage("textColor") private var textHex = "#FFFFFF" @AppStorage("textColor") private var textHex = "#FFFFFF"
@AppStorage("lineColor") private var lineHex = "#3A3A3C" @AppStorage("lineColor") private var lineHex = "#3A3A52"
@AppStorage("textContrast") private var textContrast = 3 @AppStorage("textContrast") private var textContrast = 3
@AppStorage("monthViewPaged") private var monthPaged = false
@State private var scrolledWeek: Date? = nil @State private var scrolledWeek: Date? = nil
@State private var didInitialScroll = false @State private var didInitialScroll = false
@State private var pagedMonth: Date = Calendar.current.date(
from: Calendar.current.dateComponents([.year, .month], from: .now)) ?? .now
private var cal: Calendar { store.userCalendar } private var cal: Calendar { store.userCalendar }
/// First day of the month containing `date`.
private func monthStart(for date: Date) -> Date {
cal.date(from: cal.dateComponents([.year, .month], from: date)) ?? date
}
/// One entry per month across the bounded paged range.
private var monthStarts: [Date] {
let base = monthStart(for: .now)
return (-monthsBack...monthsAhead).compactMap {
cal.date(byAdding: .month, value: $0, to: base)
}
}
private var weekStarts: [Date] { private var weekStarts: [Date] {
let today = cal.startOfDay(for: .now) let today = cal.startOfDay(for: .now)
let thisWeek = cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: today))! let thisWeek = cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: today))!
@@ -51,46 +70,92 @@ struct MonthView: View {
VStack(spacing: 0) { VStack(spacing: 0) {
headerRow headerRow
Divider() Divider()
ScrollView { if monthPaged { pagedBody } else { scrollBody }
LazyVStack(spacing: 0) { ForEach(weekStarts, id: \.self) { ws in }
WeekRow(weekStart: ws, }
store: store,
dividerColor: Color(hex: dividerHex), // Continuous vertical scroll feed (default).
labelColor: Color(hex: labelHex), private var scrollBody: some View {
textColor: Color(hex: textHex), ScrollView {
lineColor: Color(hex: lineHex), LazyVStack(spacing: 0) {
language: appLang, ForEach(weekStarts, id: \.self) { ws in
onDayTap: onDayTap, weekRow(for: ws, fillHeight: false)
onEventTap: onEventTap, .id(ws)
onCreateEvent: onCreateEvent,
onShowWeek: onShowWeek,
onShowDay: onShowDay)
.id(ws)
}
}
.scrollTargetLayout()
}
.scrollIndicators(.hidden)
.scrollPosition(id: $scrolledWeek, anchor: .top)
.onAppear {
if !didInitialScroll {
didInitialScroll = true
scrolledWeek = weekStart(for: store.currentDate)
publishVisibleMonth(from: scrolledWeek)
} }
} }
.onChange(of: store.currentDate) { _, newDate in .scrollTargetLayout()
let target = weekStart(for: newDate) }
if scrolledWeek != target { .scrollIndicators(.hidden)
withAnimation(.easeInOut(duration: 0.25)) { .scrollPosition(id: $scrolledWeek, anchor: .top)
scrolledWeek = target .onAppear {
} if !didInitialScroll {
} didInitialScroll = true
} scrolledWeek = weekStart(for: store.currentDate)
.onChange(of: scrolledWeek) { _, newWeek in publishVisibleMonth(from: scrolledWeek)
publishVisibleMonth(from: newWeek)
} }
} }
.onChange(of: store.currentDate) { _, newDate in
let target = weekStart(for: newDate)
if scrolledWeek != target {
withAnimation(.easeInOut(duration: 0.25)) {
scrolledWeek = target
}
}
}
.onChange(of: scrolledWeek) { _, newWeek in
publishVisibleMonth(from: newWeek)
}
}
// One month per page, swipe left/right to change month.
private var pagedBody: some View {
TabView(selection: $pagedMonth) {
ForEach(monthStarts, id: \.self) { m in
monthGrid(for: m).tag(m)
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
.onAppear {
pagedMonth = monthStart(for: store.currentDate)
if store.visibleMonth != pagedMonth { store.visibleMonth = pagedMonth }
}
.onChange(of: pagedMonth) { _, m in
if store.visibleMonth != m { store.visibleMonth = m }
// Drive the shared date so events for the new month load; guard the
// month so we don't fight the reverse onChange below.
if monthStart(for: store.currentDate) != m { store.currentDate = m }
}
.onChange(of: store.currentDate) { _, d in
let m = monthStart(for: d)
if pagedMonth != m { withAnimation(.easeInOut(duration: 0.25)) { pagedMonth = m } }
}
}
/// A single month page: six height-filling week rows.
private func monthGrid(for month: Date) -> some View {
let firstWeek = cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: month)) ?? month
let weeks = (0..<6).compactMap { cal.date(byAdding: .weekOfYear, value: $0, to: firstWeek) }
return VStack(spacing: 0) {
ForEach(weeks, id: \.self) { ws in
weekRow(for: ws, fillHeight: true)
}
}
}
private func weekRow(for ws: Date, fillHeight: Bool) -> some View {
WeekRow(weekStart: ws,
fillHeight: fillHeight,
store: store,
dividerColor: Color(hex: dividerHex),
labelColor: Color(hex: labelHex),
textColor: Color(hex: textHex),
lineColor: Color(hex: lineHex),
language: appLang,
onDayTap: onDayTap,
onEventTap: onEventTap,
onCreateEvent: onCreateEvent,
onShowWeek: onShowWeek,
onShowDay: onShowDay)
} }
private var headerRow: some View { private var headerRow: some View {
@@ -126,6 +191,8 @@ struct MonthView: View {
private struct WeekRow: View { private struct WeekRow: View {
let weekStart: Date let weekStart: Date
// Scroll mode uses a fixed row height; paged mode fills the page evenly.
var fillHeight: Bool = false
let store: CalendarStore let store: CalendarStore
let dividerColor: Color let dividerColor: Color
let labelColor: Color let labelColor: Color
@@ -206,8 +273,10 @@ private struct WeekRow: View {
} }
var body: some View { var body: some View {
let weekEndExclusive = cal.date(byAdding: .day, value: 7, to: weekStart)!
let (placed, extras) = packEvents() let (placed, extras) = packEvents()
let rowHeight = dayNumberRowHeight + CGFloat(maxLanesPerWeek) * (laneHeight + laneSpacing) + 4 let allWeekEvents = store.events(in: weekStart, end: weekEndExclusive)
let rowHeight = dayNumberRowHeight + CGFloat(maxLanesPerWeek) * (laneHeight + laneSpacing) + 16
let mondayIdx = days.firstIndex(where: { cal.component(.weekday, from: $0) == 2 }) ?? 0 let mondayIdx = days.firstIndex(where: { cal.component(.weekday, from: $0) == 2 }) ?? 0
// Where in this row does a new month start? (col 1...6 = mid-row step; nil = no step) // Where in this row does a new month start? (col 1...6 = mid-row step; nil = no step)
@@ -229,6 +298,11 @@ private struct WeekRow: View {
} }
return rowStartsNewMonth ? .topHighlight : .none return rowStartsNewMonth ? .topHighlight : .none
}() }()
let dayStart = cal.startOfDay(for: day)
let dayEnd = cal.date(byAdding: .day, value: 1, to: dayStart)!
let eventsForDay = allWeekEvents.filter {
$0.startDate < dayEnd && $0.endDate > dayStart
}
DayCell(date: day, DayCell(date: day,
isToday: cal.isDateInToday(day), isToday: cal.isDateInToday(day),
monthLabelColor: labelColor, monthLabelColor: labelColor,
@@ -240,11 +314,12 @@ private struct WeekRow: View {
weekNumber: idx == mondayIdx ? weekNumber : nil, weekNumber: idx == mondayIdx ? weekNumber : nil,
cwLabel: L10n.t("cal.cw", language), cwLabel: L10n.t("cal.cw", language),
edge: edge, edge: edge,
dayEvents: eventsForDay,
onTap: { onDayTap(day) }, onTap: { onDayTap(day) },
onCreateEvent: { onCreateEvent(day) }, onCreateEvent: { onCreateEvent(day) },
onShowWeek: { onShowWeek(day) }, onShowWeek: { onShowWeek(day) },
onShowDay: { onShowDay(day) }) onShowDay: { onShowDay(day) })
.frame(width: cellW, height: rowHeight) .frame(width: cellW, height: geo.size.height)
} }
} }
@@ -263,12 +338,13 @@ private struct WeekRow: View {
if let b = midRowBoundaryCol { if let b = midRowBoundaryCol {
Rectangle() Rectangle()
.fill(dividerColor) .fill(dividerColor)
.frame(width: 1.5, height: rowHeight) .frame(width: 1.5, height: geo.size.height)
.offset(x: CGFloat(b) * cellW - 0.75, y: 0) .offset(x: CGFloat(b) * cellW - 0.75, y: 0)
} }
} }
} }
.frame(height: rowHeight) .frame(height: fillHeight ? nil : rowHeight)
.frame(maxHeight: fillHeight ? .infinity : nil)
} }
} }
@@ -286,6 +362,7 @@ private struct DayCell: View {
let weekNumber: Int? let weekNumber: Int?
let cwLabel: String let cwLabel: String
let edge: DividerEdge let edge: DividerEdge
let dayEvents: [CalEvent]
let onTap: () -> Void let onTap: () -> Void
let onCreateEvent: () -> Void let onCreateEvent: () -> Void
let onShowWeek: () -> Void let onShowWeek: () -> Void
@@ -372,10 +449,151 @@ private struct DayCell: View {
Button { onShowDay() } label: { Button { onShowDay() } label: {
Label(L10n.t("cal.show_in_day_view", language), systemImage: "sun.max") Label(L10n.t("cal.show_in_day_view", language), systemImage: "sun.max")
} }
} preview: {
DayContextPreviewView(date: date, events: dayEvents, language: language)
} }
} }
} }
// MARK: Day Context Menu Preview
private struct DayContextPreviewView: View {
let date: Date
let events: [CalEvent]
let language: String
private var cal: Calendar { .current }
private var sortedEvents: [CalEvent] {
events.sorted { a, b in
if a.isAllDay != b.isAllDay { return a.isAllDay }
return a.startDate < b.startDate
}
}
private var weekdayAbbr: String {
let fmt = DateFormatter()
fmt.locale = L10n.locale(language)
fmt.dateFormat = "EEE"
return fmt.string(from: date).uppercased()
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text(weekdayAbbr)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Text("\(cal.component(.day, from: date))")
.font(.title2.weight(.bold))
}
if !sortedEvents.isEmpty {
Divider()
ForEach(sortedEvents) { ev in
if ev.isAllDay {
DayPreviewAllDayBar(event: ev, date: date)
} else {
DayPreviewTimedRow(event: ev)
}
}
} else {
Text("")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(12)
.frame(minWidth: 220, maxWidth: 300)
}
}
private struct DayPreviewAllDayBar: View {
let event: CalEvent
let date: Date
private var cal: Calendar { .current }
var body: some View {
let color = Color(hex: event.effectiveColor)
let dayStart = cal.startOfDay(for: date)
let dayEnd = cal.date(byAdding: .day, value: 1, to: dayStart)!
let cLeft = event.startDate < dayStart
let cRight = event.endDate > dayEnd // endDate is exclusive for allDay
EventLabel(event: event)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.white)
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 3)
.padding(.leading, cLeft ? 14 : 8)
.padding(.trailing, cRight ? 14 : 8)
.background(color)
.clipShape(ChevronBarShape(continuesLeft: cLeft, continuesRight: cRight))
}
}
private struct ChevronBarShape: Shape {
var continuesLeft: Bool
var continuesRight: Bool
private let tip: CGFloat = 8
func path(in rect: CGRect) -> Path {
guard continuesLeft || continuesRight else {
return RoundedRectangle(cornerRadius: 4).path(in: rect)
}
var p = Path()
let t = min(tip, rect.width / 2)
if continuesLeft && continuesRight {
p.move(to: CGPoint(x: t, y: 0))
p.addLine(to: CGPoint(x: rect.maxX - t, y: 0))
p.addLine(to: CGPoint(x: rect.maxX, y: rect.midY))
p.addLine(to: CGPoint(x: rect.maxX - t, y: rect.maxY))
p.addLine(to: CGPoint(x: t, y: rect.maxY))
p.addLine(to: CGPoint(x: 0, y: rect.midY))
} else if continuesLeft {
p.move(to: CGPoint(x: t, y: 0))
p.addLine(to: CGPoint(x: rect.maxX, y: 0))
p.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
p.addLine(to: CGPoint(x: t, y: rect.maxY))
p.addLine(to: CGPoint(x: 0, y: rect.midY))
} else {
p.move(to: CGPoint(x: 0, y: 0))
p.addLine(to: CGPoint(x: rect.maxX - t, y: 0))
p.addLine(to: CGPoint(x: rect.maxX, y: rect.midY))
p.addLine(to: CGPoint(x: rect.maxX - t, y: rect.maxY))
p.addLine(to: CGPoint(x: 0, y: rect.maxY))
}
p.closeSubpath()
return p
}
}
private struct DayPreviewTimedRow: View {
let event: CalEvent
var body: some View {
HStack(spacing: 6) {
Circle()
.fill(Color(hex: event.effectiveColor))
.frame(width: 7, height: 7)
Text(timeString)
.font(.system(size: 11))
.foregroundStyle(.secondary)
.frame(width: 44, alignment: .leading)
EventLabel(event: event)
.font(.system(size: 11, weight: .medium))
.lineLimit(1)
Spacer(minLength: 0)
}
}
private var timeString: String {
let fmt = DateFormatter()
fmt.dateFormat = "HH:mm"
return fmt.string(from: event.startDate)
}
}
// MARK: Event Bar // MARK: Event Bar
private struct EventBar: View { private struct EventBar: View {
@@ -386,7 +604,7 @@ private struct EventBar: View {
var body: some View { var body: some View {
HStack(spacing: 3) { HStack(spacing: 3) {
Text(event.title) EventLabel(event: event)
.font(.system(size: 10, weight: .medium)) .font(.system(size: 10, weight: .medium))
.lineLimit(1) .lineLimit(1)
.foregroundStyle(.white) .foregroundStyle(.white)

View File

@@ -74,7 +74,7 @@ struct EventBlock: View {
.fill(Color(hex: event.effectiveColor).opacity(0.85)) .fill(Color(hex: event.effectiveColor).opacity(0.85))
.overlay(alignment: .topLeading) { .overlay(alignment: .topLeading) {
VStack(alignment: .leading, spacing: 1) { VStack(alignment: .leading, spacing: 1) {
Text(event.title) EventLabel(event: event)
.font(.system(size: 12, weight: .semibold)) .font(.system(size: 12, weight: .semibold))
.foregroundStyle(.white) .foregroundStyle(.white)
.lineLimit(2) .lineLimit(2)

View File

@@ -10,7 +10,7 @@ struct WeekView: View {
@AppStorage("appLanguage") private var appLang = "system" @AppStorage("appLanguage") private var appLang = "system"
@AppStorage("todayColor") private var todayHex = "#4285f4" @AppStorage("todayColor") private var todayHex = "#4285f4"
@AppStorage("textColor") private var textHex = "#FFFFFF" @AppStorage("textColor") private var textHex = "#FFFFFF"
@AppStorage("lineColor") private var lineHex = "#3A3A3C" @AppStorage("lineColor") private var lineHex = "#3A3A52"
@AppStorage("textContrast") private var textContrast = 3 @AppStorage("textContrast") private var textContrast = 3
@AppStorage("lineContrast") private var lineContrast = 3 @AppStorage("lineContrast") private var lineContrast = 3
@AppStorage("hourHeight") private var hourHeightPref = 60 // observed for live re-layout @AppStorage("hourHeight") private var hourHeightPref = 60 // observed for live re-layout
@@ -72,37 +72,66 @@ struct WeekView: View {
// MARK: All-day strip // MARK: All-day strip
private let allDayLaneHeight: CGFloat = 16
/// All-day / multi-day events laid out as continuous bars: each event spans
/// its startend columns (clamped to this week) and is packed into lanes so
/// overlapping bars stack mirrors the month view instead of repeating the
/// event once per day.
private func allDayBars() -> [(ev: CalEvent, start: Int, end: Int, lane: Int)] {
var spans: [(ev: CalEvent, start: Int, end: Int)] = []
for ev in allDayEvents {
var start: Int? = nil
var end = 0
for (i, day) in weekDays.enumerated() {
let ds = cal.startOfDay(for: day)
let de = cal.date(byAdding: .day, value: 1, to: ds)!
if ev.startDate < de && ev.endDate > ds {
if start == nil { start = i }
end = i
}
}
if let s = start { spans.append((ev, s, end)) }
}
// Longer / earlier bars first, then greedily pack into lanes.
spans.sort { a, b in a.start != b.start ? a.start < b.start : (a.end - a.start) > (b.end - b.start) }
var laneEnd: [Int] = [] // last occupied column index per lane
var out: [(ev: CalEvent, start: Int, end: Int, lane: Int)] = []
for s in spans {
var lane = 0
while lane < laneEnd.count && laneEnd[lane] >= s.start { lane += 1 }
if lane == laneEnd.count { laneEnd.append(s.end) } else { laneEnd[lane] = s.end }
out.append((s.ev, s.start, s.end, lane))
}
return out
}
private var allDayRow: some View { private var allDayRow: some View {
HStack(spacing: 0) { let bars = allDayBars()
Spacer().frame(width: timeColumnWidth) let laneCount = max(1, (bars.map { $0.lane }.max() ?? -1) + 1)
ForEach(weekDays, id: \.self) { day in return GeometryReader { geo in
let dayEvs = allDayEvents.filter { ev in let colW = (geo.size.width - timeColumnWidth) / 7
let ds = cal.startOfDay(for: day) ZStack(alignment: .topLeading) {
let de = cal.date(byAdding: .day, value: 1, to: ds)! ForEach(bars, id: \.ev.id) { bar in
return ev.startDate < de && ev.endDate > ds Button { onEventTap(bar.ev) } label: {
} EventLabel(event: bar.ev)
VStack(spacing: 1) { .font(.system(size: 9, weight: .medium))
ForEach(dayEvs.prefix(2)) { ev in .foregroundStyle(.white)
Button { onEventTap(ev) } label: { .lineLimit(1)
Text(ev.title) .frame(maxWidth: .infinity, alignment: .leading)
.font(.system(size: 9, weight: .medium)) .padding(.horizontal, 3)
.foregroundStyle(.white) .frame(height: allDayLaneHeight - 2)
.lineLimit(1) .background(Color(hex: bar.ev.effectiveColor))
.frame(maxWidth: .infinity) .clipShape(RoundedRectangle(cornerRadius: 2))
.padding(.vertical, 2)
.background(Color(hex: ev.effectiveColor))
.clipShape(RoundedRectangle(cornerRadius: 2))
}
.buttonStyle(.plain)
} }
} .buttonStyle(.plain)
.padding(.horizontal, 1) .frame(width: colW * CGFloat(bar.end - bar.start + 1) - 3)
.frame(maxWidth: .infinity) .offset(x: timeColumnWidth + colW * CGFloat(bar.start) + 1.5,
.overlay(alignment: .trailing) { y: CGFloat(bar.lane) * allDayLaneHeight)
Rectangle().fill(Color(hex: lineHex).opacity(gridLineOpacity(lineContrast))).frame(width: 0.5)
} }
} }
} }
.frame(height: CGFloat(laneCount) * allDayLaneHeight)
.padding(.vertical, 4) .padding(.vertical, 4)
.overlay(alignment: .bottom) { Divider() } .overlay(alignment: .bottom) { Divider() }
} }
@@ -213,7 +242,7 @@ struct HourSlot: View {
let onShowMonth: (Date) -> Void let onShowMonth: (Date) -> Void
let onShowDay: (Date) -> Void let onShowDay: (Date) -> Void
@AppStorage("lineColor") private var lineHex = "#3A3A3C" @AppStorage("lineColor") private var lineHex = "#3A3A52"
@AppStorage("lineContrast") private var lineContrast = 3 @AppStorage("lineContrast") private var lineContrast = 3
private var date: Date { private var date: Date {

View File

@@ -0,0 +1,259 @@
import SwiftUI
/// A single calendar row in the flat, reorderable list.
private struct CalRow: Identifiable {
let id: String // "source:id" key
let name: String
let colorHex: String
let readOnly: Bool
}
/// The calendar-visibility list, extracted so both the modal `CalendarFilterSheet`
/// and the side drawer render the same rows/logic. A single flat, drag-reorderable
/// list (no per-source grouping); visibility is client-side (`CalendarStore`),
/// order is device-local (`CalendarStore.calendarOrder`, mirrors the web).
struct CalendarFilterContent: View {
let api: CalendarrAPI
let store: CalendarStore
@AppStorage("appLanguage") private var appLang = "system"
@State private var caldavAccounts: [CalDAVAccount] = []
@State private var localCalendars: [LocalCalendar] = []
@State private var icalSubs: [ICalSubscription] = []
@State private var googleAccounts: [GoogleAccount] = []
@State private var haAccounts: [HomeAssistantAccount] = []
@State private var isLoading = true
@State private var hidden: Set<String> = []
@State private var banished: Set<String> = []
@State private var reminderDisabled: Set<String> = []
@State private var allKeys: Set<String> = []
@State private var rows: [CalRow] = []
@State private var isSorting = false
@State private var groupDetail: CalGroup? = nil
@State private var hiddenGroup: Set<String> = []
var body: some View {
Group {
if isLoading {
ProgressView(L10n.t("filter.loading", appLang))
} else if store.activeGroup != nil {
groupFilterList
} else if allKeys.isEmpty {
Text(L10n.t("filter.empty", appLang)).foregroundStyle(.secondary)
} else {
VStack(spacing: 0) {
controlBar
List {
ForEach(rows) { cal in row(cal) }
.onMove(perform: move)
if !banished.isEmpty {
Section {
Text(L10n.t("filter.banished_footer", appLang))
.font(.caption).foregroundStyle(.secondary)
}
}
}
.listStyle(.plain)
.environment(\.editMode, .constant(isSorting ? .active : .inactive))
}
}
}
.task { await load() }
}
// Section header + sort toggle, above the list so it stays tappable while
// the list is in edit mode. (No show/hide-all buttons rarely needed with a
// handful of calendars, and the web has none either.)
private var controlBar: some View {
HStack {
Text(L10n.t("filter.title", appLang))
.font(.subheadline.weight(.semibold))
.foregroundStyle(.secondary)
Spacer()
Button(isSorting ? L10n.t("filter.done", appLang) : L10n.t("filter.sort", appLang)) {
withAnimation { isSorting.toggle() }
}
.buttonStyle(.borderless)
.font(.subheadline)
}
.padding(.horizontal, 16).padding(.top, 6).padding(.bottom, 4)
}
private func move(from source: IndexSet, to destination: Int) {
rows.move(fromOffsets: source, toOffset: destination)
store.setCalendarOrder(rows.map(\.id))
}
@ViewBuilder
private func row(_ cal: CalRow) -> some View {
let isVisible = !hidden.contains(cal.id)
Button {
if isVisible { hidden.insert(cal.id) } else { hidden.remove(cal.id) }
store.setCalendarHidden(cal.id, hidden: isVisible)
} label: {
HStack(spacing: 12) {
Circle()
.fill(Color(hex: cal.colorHex))
.frame(width: 14, height: 14)
.opacity(isVisible ? 1.0 : 0.35)
Text(cal.name)
.foregroundStyle(isVisible ? .primary : .secondary)
.strikethrough(!isVisible, color: .secondary)
if cal.readOnly {
Image(systemName: "lock.fill").font(.caption2).foregroundStyle(.secondary)
}
Spacer()
if reminderDisabled.contains(cal.id) {
Image(systemName: "bell.slash").font(.caption).foregroundStyle(.secondary)
}
Image(systemName: isVisible ? "eye" : "eye.slash")
.foregroundStyle(isVisible ? Color.accentColor : .secondary)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.contextMenu {
Button {
toggleReminders(forKey: cal.id)
} label: {
let disabled = reminderDisabled.contains(cal.id)
Label(L10n.t(disabled ? "filter.reminders_on" : "filter.reminders_off", appLang),
systemImage: disabled ? "bell" : "bell.slash")
}
Button(role: .destructive) {
hidden.remove(cal.id)
banished.insert(cal.id)
store.setCalendarBanished(cal.id, banished: true)
pushBanishToServer(key: cal.id, hidden: true)
rows.removeAll { $0.id == cal.id }
} label: {
Label(L10n.t("filter.banish", appLang), systemImage: "archivebox")
}
}
}
private func toggleReminders(forKey key: String) {
let nowDisabled = !reminderDisabled.contains(key)
if nowDisabled { reminderDisabled.insert(key) } else { reminderDisabled.remove(key) }
store.setReminderDisabled(key, disabled: nowDisabled)
if let parsed = CalendarStore.parseCalendarKey(key) {
Task { try? await api.setCalendarRemindersEnabled(
source: parsed.source, calendarId: parsed.id, enabled: !nowDisabled) }
}
}
// MARK: Group overlay (unchanged: hide individual members / group calendar)
@ViewBuilder
private var groupFilterList: some View {
if let g = groupDetail {
List {
Section(header: Label(g.name, systemImage: GroupIcons.symbol(g.icon))) {
ForEach((g.members ?? []).filter { $0.sharesCalendar }) { m in
groupRow(name: m.displayName ?? "",
colorHex: m.color ?? "#4285f4",
key: CalendarStore.groupMemberKey(m.id))
}
groupRow(name: L10n.t("group.calendar", appLang),
colorHex: g.groupCalendarColor ?? "#4285f4",
key: CalendarStore.groupCalendarKey)
}
}
} else {
Text(L10n.t("filter.empty", appLang)).foregroundStyle(.secondary)
}
}
@ViewBuilder
private func groupRow(name: String, colorHex: String, key: String) -> some View {
let isVisible = !hiddenGroup.contains(key)
Button {
if isVisible { hiddenGroup.insert(key) } else { hiddenGroup.remove(key) }
store.setGroupKeyHidden(key, hidden: isVisible)
} label: {
HStack(spacing: 12) {
Circle()
.fill(Color(hex: colorHex))
.frame(width: 14, height: 14)
.opacity(isVisible ? 1.0 : 0.35)
Text(name)
.foregroundStyle(isVisible ? .primary : .secondary)
.strikethrough(!isVisible, color: .secondary)
Spacer()
Image(systemName: isVisible ? "eye" : "eye.slash")
.foregroundStyle(isVisible ? Color.accentColor : .secondary)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
private func load() async {
isLoading = true
if let g = store.activeGroup {
hiddenGroup = store.hiddenGroupKeys
groupDetail = try? await api.getGroup(id: g.id)
isLoading = false
return
}
hidden = store.hiddenCalendarKeys
banished = store.banishedCalendarKeys
async let c = (try? await api.getCalDAVAccounts()) ?? []
async let l = (try? await api.getLocalCalendars()) ?? []
async let i = (try? await api.getICalSubscriptions()) ?? []
async let g = (try? await api.getGoogleAccounts()) ?? []
async let h = (try? await api.getHomeAssistantAccounts()) ?? []
(caldavAccounts, localCalendars, icalSubs, googleAccounts, haAccounts) = await (c, l, i, g, h)
var b = store.banishedCalendarKeys
func applyServerHidden(_ source: String, _ id: Int, _ hidden: Bool) {
let key = CalendarStore.calendarKey(source: source, calendarId: "\(id)")
if hidden { b.insert(key) } else { b.remove(key) }
}
for acc in caldavAccounts { for cal in acc.calendars ?? [] { applyServerHidden("caldav", cal.id, cal.sidebarHidden) } }
for acc in googleAccounts { for cal in acc.calendars ?? [] { applyServerHidden("google", cal.id, cal.sidebarHidden) } }
for acc in haAccounts { for cal in acc.calendars ?? [] { applyServerHidden("homeassistant", cal.id, cal.sidebarHidden) } }
store.setBanishedCalendars(b)
banished = b
var rd = Set<String>()
func applyReminders(_ source: String, _ id: Int, _ enabled: Bool) {
if !enabled { rd.insert(CalendarStore.calendarKey(source: source, calendarId: "\(id)")) }
}
for cal in localCalendars { applyReminders("local", cal.id, cal.remindersEnabled) }
for acc in caldavAccounts { for cal in acc.calendars ?? [] { applyReminders("caldav", cal.id, cal.remindersEnabled ?? true) } }
for sub in icalSubs { applyReminders("ical", sub.id, sub.remindersEnabled ?? true) }
for acc in googleAccounts { for cal in acc.calendars ?? [] { applyReminders("google", cal.id, cal.remindersEnabled ?? true) } }
for acc in haAccounts { for cal in acc.calendars ?? [] { applyReminders("homeassistant", cal.id, cal.remindersEnabled) } }
store.setReminderDisabledKeys(rd)
reminderDisabled = rd
// Build the flat row list (banished excluded), then sort by stored order.
var combined: [CalRow] = []
func add(_ source: String, _ id: Int, _ name: String, _ colorHex: String, readOnly: Bool = false) {
let key = CalendarStore.calendarKey(source: source, calendarId: "\(id)")
if b.contains(key) { return }
combined.append(CalRow(id: key, name: name, colorHex: colorHex, readOnly: readOnly))
}
for cal in localCalendars {
add("local", cal.id, cal.owned ? cal.name : (cal.sharedBy ?? cal.name), cal.color,
readOnly: !cal.owned && cal.permission != "read_write")
}
for acc in caldavAccounts { for cal in acc.calendars ?? [] { add("caldav", cal.id, cal.name, cal.color ?? acc.color) } }
for sub in icalSubs { add("ical", sub.id, sub.name, sub.color) }
for acc in googleAccounts { for cal in acc.calendars ?? [] { add("google", cal.id, cal.name, cal.color ?? "#4285f4") } }
for acc in haAccounts { for cal in acc.calendars ?? [] { add("homeassistant", cal.id, cal.name, cal.color ?? "#46bdc6") } }
allKeys = Set(combined.map(\.id))
let orderedKeys = store.ordered(combined.map(\.id))
let byKey = Dictionary(uniqueKeysWithValues: combined.map { ($0.id, $0) })
rows = orderedKeys.compactMap { byKey[$0] }
isLoading = false
}
private func pushBanishToServer(key: String, hidden: Bool) {
guard let parsed = CalendarStore.parseCalendarKey(key),
CalendarStore.serverManagedSources.contains(parsed.source) else { return }
Task { try? await api.setCalendarSidebarHidden(source: parsed.source, calendarId: parsed.id, hidden: hidden) }
}
}

View File

@@ -1,326 +1,23 @@
import SwiftUI import SwiftUI
/// Lets the user toggle which calendars contribute events to the displayed /// Modal wrapper around `CalendarFilterContent` (kept for contexts that still
/// calendar views (and the home-screen widgets). Filtering is purely /// present the filter as a sheet). The drawer embeds the same content directly.
/// client-side: hidden keys live in UserDefaults via `CalendarStore`. No
/// server roundtrip is required to toggle visibility.
struct CalendarFilterSheet: View { struct CalendarFilterSheet: View {
let api: CalendarrAPI let api: CalendarrAPI
let store: CalendarStore let store: CalendarStore
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@AppStorage("appLanguage") private var appLang = "system" @AppStorage("appLanguage") private var appLang = "system"
@State private var caldavAccounts: [CalDAVAccount] = []
@State private var localCalendars: [LocalCalendar] = []
@State private var icalSubs: [ICalSubscription] = []
@State private var googleAccounts: [GoogleAccount] = []
@State private var haAccounts: [HomeAssistantAccount] = []
@State private var isLoading = true
@State private var hidden: Set<String> = []
@State private var banished: Set<String> = []
/// Calendars whose events do not generate reminder notifications.
@State private var reminderDisabled: Set<String> = []
/// All non-banished keys discovered during load used by bulk show/hide.
@State private var allKeys: Set<String> = []
/// Group-mode: the active group's full detail (members + colours) and the
/// per-member / group-calendar hidden keys.
@State private var groupDetail: CalGroup? = nil
@State private var hiddenGroup: Set<String> = []
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Group { CalendarFilterContent(api: api, store: store)
if isLoading { .navigationTitle(L10n.t("filter.title", appLang))
ProgressView(L10n.t("filter.loading", appLang)) .navigationBarTitleDisplayMode(.inline)
} else if store.activeGroup != nil { .toolbar {
groupFilterList ToolbarItem(placement: .primaryAction) {
} else if allKeys.isEmpty { Button(L10n.t("nav.done", appLang)) { dismiss() }
Text(L10n.t("filter.empty", appLang))
.foregroundStyle(.secondary)
} else {
List {
let visibleLocals = localCalendars.filter {
!banished.contains(CalendarStore.calendarKey(source: "local", calendarId: "\($0.id)"))
}
if !visibleLocals.isEmpty {
Section(L10n.t("accounts.local.header", appLang)) {
ForEach(visibleLocals) { cal in
row(name: cal.name, colorHex: cal.color,
key: CalendarStore.calendarKey(source: "local", calendarId: "\(cal.id)"))
}
}
}
ForEach(caldavAccounts) { acc in
let cals = (acc.calendars ?? []).filter {
!banished.contains(CalendarStore.calendarKey(source: "caldav", calendarId: "\($0.id)"))
}
if !cals.isEmpty {
Section(acc.name) {
ForEach(cals) { cal in
row(name: cal.name,
colorHex: cal.color ?? acc.color,
key: CalendarStore.calendarKey(source: "caldav", calendarId: "\(cal.id)"))
}
}
}
}
let visibleSubs = icalSubs.filter {
!banished.contains(CalendarStore.calendarKey(source: "ical", calendarId: "\($0.id)"))
}
if !visibleSubs.isEmpty {
Section(L10n.t("accounts.ical.header", appLang)) {
ForEach(visibleSubs) { sub in
row(name: sub.name, colorHex: sub.color,
key: CalendarStore.calendarKey(source: "ical", calendarId: "\(sub.id)"))
}
}
}
ForEach(googleAccounts) { acc in
let cals = (acc.calendars ?? []).filter {
!banished.contains(CalendarStore.calendarKey(source: "google", calendarId: "\($0.id)"))
}
if !cals.isEmpty {
Section(acc.email) {
ForEach(cals) { cal in
row(name: cal.name,
colorHex: cal.color ?? "#4285f4",
key: CalendarStore.calendarKey(source: "google", calendarId: "\(cal.id)"))
}
}
}
}
ForEach(haAccounts) { acc in
let cals = (acc.calendars ?? []).filter {
!banished.contains(CalendarStore.calendarKey(source: "homeassistant", calendarId: "\($0.id)"))
}
if !cals.isEmpty {
Section(acc.name) {
ForEach(cals) { cal in
row(name: cal.name,
colorHex: cal.color ?? "#46bdc6",
key: CalendarStore.calendarKey(source: "homeassistant", calendarId: "\(cal.id)"))
}
}
}
}
if !banished.isEmpty {
Section {
Text(L10n.t("filter.banished_footer", appLang))
.font(.caption)
.foregroundStyle(.secondary)
}
}
} }
} }
}
.navigationTitle(L10n.t("filter.title", appLang))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Menu {
Button(L10n.t("filter.show_all", appLang)) {
hidden = []
store.setHiddenCalendars(hidden)
}
Button(L10n.t("filter.hide_all", appLang)) {
hidden = allKeys
store.setHiddenCalendars(hidden)
}
} label: {
Image(systemName: "ellipsis.circle")
}
.disabled(allKeys.isEmpty)
}
ToolbarItem(placement: .primaryAction) {
Button(L10n.t("nav.done", appLang)) { dismiss() }
}
}
} }
.task { await load() }
}
@ViewBuilder
private func row(name: String, colorHex: String, key: String) -> some View {
let isVisible = !hidden.contains(key)
Button {
if isVisible { hidden.insert(key) } else { hidden.remove(key) }
// New hidden state == was-visible (flip). Previous code passed the
// inverse, which persisted the opposite of what the UI showed.
store.setCalendarHidden(key, hidden: isVisible)
} label: {
HStack(spacing: 12) {
Circle()
.fill(Color(hex: colorHex))
.frame(width: 14, height: 14)
.opacity(isVisible ? 1.0 : 0.35)
Text(name)
.foregroundStyle(isVisible ? .primary : .secondary)
.strikethrough(!isVisible, color: .secondary)
Spacer()
if reminderDisabled.contains(key) {
Image(systemName: "bell.slash")
.font(.caption)
.foregroundStyle(.secondary)
}
Image(systemName: isVisible ? "eye" : "eye.slash")
.foregroundStyle(isVisible ? Color.accentColor : .secondary)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.swipeActions(edge: .leading, allowsFullSwipe: false) {
let disabled = reminderDisabled.contains(key)
Button {
toggleReminders(forKey: key)
} label: {
Label(L10n.t(disabled ? "filter.reminders_on" : "filter.reminders_off", appLang),
systemImage: disabled ? "bell" : "bell.slash")
}
.tint(.orange)
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
hidden.remove(key)
banished.insert(key)
store.setCalendarBanished(key, banished: true)
pushBanishToServer(key: key, hidden: true)
} label: {
Label(L10n.t("filter.banish", appLang), systemImage: "archivebox")
}
}
}
/// Flip a calendar's reminder mute, persist locally + on the server, reschedule.
private func toggleReminders(forKey key: String) {
let nowDisabled = !reminderDisabled.contains(key)
if nowDisabled { reminderDisabled.insert(key) } else { reminderDisabled.remove(key) }
store.setReminderDisabled(key, disabled: nowDisabled)
if let parsed = CalendarStore.parseCalendarKey(key) {
Task { try? await api.setCalendarRemindersEnabled(
source: parsed.source, calendarId: parsed.id, enabled: !nowDisabled) }
}
}
// MARK: Group overlay filter (hide individual members / the group calendar)
@ViewBuilder
private var groupFilterList: some View {
if let g = groupDetail {
List {
Section(header: Label(g.name, systemImage: GroupIcons.symbol(g.icon))) {
ForEach(g.members ?? []) { m in
groupRow(name: m.displayName ?? "",
colorHex: m.color ?? "#4285f4",
key: CalendarStore.groupMemberKey(m.id))
}
groupRow(name: L10n.t("group.calendar", appLang),
colorHex: g.groupCalendarColor ?? "#4285f4",
key: CalendarStore.groupCalendarKey)
}
}
} else {
Text(L10n.t("filter.empty", appLang)).foregroundStyle(.secondary)
}
}
@ViewBuilder
private func groupRow(name: String, colorHex: String, key: String) -> some View {
let isVisible = !hiddenGroup.contains(key)
Button {
if isVisible { hiddenGroup.insert(key) } else { hiddenGroup.remove(key) }
store.setGroupKeyHidden(key, hidden: isVisible)
} label: {
HStack(spacing: 12) {
Circle()
.fill(Color(hex: colorHex))
.frame(width: 14, height: 14)
.opacity(isVisible ? 1.0 : 0.35)
Text(name)
.foregroundStyle(isVisible ? .primary : .secondary)
.strikethrough(!isVisible, color: .secondary)
Spacer()
Image(systemName: isVisible ? "eye" : "eye.slash")
.foregroundStyle(isVisible ? Color.accentColor : .secondary)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
private func load() async {
isLoading = true
// Group overlay: list members (+ the group calendar) to hide individually.
if let g = store.activeGroup {
hiddenGroup = store.hiddenGroupKeys
groupDetail = try? await api.getGroup(id: g.id)
isLoading = false
return
}
hidden = store.hiddenCalendarKeys
banished = store.banishedCalendarKeys
async let c = (try? await api.getCalDAVAccounts()) ?? []
async let l = (try? await api.getLocalCalendars()) ?? []
async let i = (try? await api.getICalSubscriptions()) ?? []
async let g = (try? await api.getGoogleAccounts()) ?? []
async let h = (try? await api.getHomeAssistantAccounts()) ?? []
(caldavAccounts, localCalendars, icalSubs, googleAccounts, haAccounts) = await (c, l, i, g, h)
// Reconcile banished state with the server's sidebar_hidden flags
// (server wins for CalDAV/Google/HA; local/ical keep their local state).
var b = store.banishedCalendarKeys
func applyServerHidden(_ source: String, _ id: Int, _ hidden: Bool) {
let key = CalendarStore.calendarKey(source: source, calendarId: "\(id)")
if hidden { b.insert(key) } else { b.remove(key) }
}
for acc in caldavAccounts { for cal in acc.calendars ?? [] { applyServerHidden("caldav", cal.id, cal.sidebarHidden) } }
for acc in googleAccounts { for cal in acc.calendars ?? [] { applyServerHidden("google", cal.id, cal.sidebarHidden) } }
for acc in haAccounts { for cal in acc.calendars ?? [] { applyServerHidden("homeassistant", cal.id, cal.sidebarHidden) } }
store.setBanishedCalendars(b)
banished = b
// Reconcile reminder-muted state from the server's reminders_enabled flags.
var rd = Set<String>()
func applyReminders(_ source: String, _ id: Int, _ enabled: Bool) {
if !enabled { rd.insert(CalendarStore.calendarKey(source: source, calendarId: "\(id)")) }
}
for cal in localCalendars { applyReminders("local", cal.id, cal.remindersEnabled) }
for acc in caldavAccounts { for cal in acc.calendars ?? [] { applyReminders("caldav", cal.id, cal.remindersEnabled ?? true) } }
for sub in icalSubs { applyReminders("ical", sub.id, sub.remindersEnabled ?? true) }
for acc in googleAccounts { for cal in acc.calendars ?? [] { applyReminders("google", cal.id, cal.remindersEnabled ?? true) } }
for acc in haAccounts { for cal in acc.calendars ?? [] { applyReminders("homeassistant", cal.id, cal.remindersEnabled) } }
store.setReminderDisabledKeys(rd)
reminderDisabled = rd
var keys = Set<String>()
for cal in localCalendars {
keys.insert(CalendarStore.calendarKey(source: "local", calendarId: "\(cal.id)"))
}
for acc in caldavAccounts {
for cal in acc.calendars ?? [] {
keys.insert(CalendarStore.calendarKey(source: "caldav", calendarId: "\(cal.id)"))
}
}
for sub in icalSubs {
keys.insert(CalendarStore.calendarKey(source: "ical", calendarId: "\(sub.id)"))
}
for acc in googleAccounts {
for cal in acc.calendars ?? [] {
keys.insert(CalendarStore.calendarKey(source: "google", calendarId: "\(cal.id)"))
}
}
for acc in haAccounts {
for cal in acc.calendars ?? [] {
keys.insert(CalendarStore.calendarKey(source: "homeassistant", calendarId: "\(cal.id)"))
}
}
allKeys = keys
isLoading = false
}
/// For server-backed sources, persist the banish on the server too.
private func pushBanishToServer(key: String, hidden: Bool) {
guard let parsed = CalendarStore.parseCalendarKey(key),
CalendarStore.serverManagedSources.contains(parsed.source) else { return }
Task { try? await api.setCalendarSidebarHidden(source: parsed.source, calendarId: parsed.id, hidden: hidden) }
} }
} }

View File

@@ -3,81 +3,172 @@ import SwiftUI
struct SettingsView: View { struct SettingsView: View {
let api: CalendarrAPI let api: CalendarrAPI
@AppStorage("liquidGlass") private var liquidGlass = false @AppStorage("liquidGlass") private var liquidGlass = false
@AppStorage("settingsSync") private var settingsSync = false @AppStorage("hideMenuButton") private var hideMenuButton = false
@AppStorage("cacheMonths") private var cacheMonths = 3 @AppStorage("cacheMonths") private var cacheMonths = 3
@AppStorage("appLanguage") private var appLang = "system" @AppStorage("appLanguage") private var appLang = "system"
@AppStorage("monthDividerColor") private var dividerHex = "#7090c0" @AppStorage("monthDividerColor") private var dividerHex = "#7090C0"
@AppStorage("monthLabelColor") private var labelHex = "#7090c0" @AppStorage("monthLabelColor") private var labelHex = "#7090C0"
@AppStorage("todayColor") private var todayHex = "#4285f4" @AppStorage("todayColor") private var todayHex = "#4285F4"
@AppStorage("textColor") private var textHex = "#FFFFFF" @AppStorage("textColor") private var textHex = "#FFFFFF"
@AppStorage("backgroundColor") private var bgHex = "#000000" @AppStorage("backgroundColor") private var bgHex = "#000000"
@AppStorage("lineColor") private var lineHex = "#3A3A3C" @AppStorage("lineColor") private var lineHex = "#3A3A52"
@AppStorage("primaryColor") private var primaryHex = "#4285f4" // Device-local top-bar/surface colour. Empty = translucent .bar material.
@AppStorage("accentColor") private var accentHex = "#ea4335" @AppStorage("surfaceColor") private var surfaceHex = ""
// Previously server-only; now AppStorage-backed so they persist and the @AppStorage("primaryColor") private var primaryHex = "#4285F4"
// calendar views actually apply them. @AppStorage("accentColor") private var accentHex = "#EA4335"
// iOS-only opacity controls (drive secondary text / grid-line opacity in the
// calendar views); kept device-local, not part of the cross-device sync.
@AppStorage("textContrast") private var textContrast = 3 @AppStorage("textContrast") private var textContrast = 3
@AppStorage("lineContrast") private var lineContrast = 3 @AppStorage("lineContrast") private var lineContrast = 3
@AppStorage("hourHeight") private var hourHeight = 60 @AppStorage("hourHeight") private var hourHeight = 60
@AppStorage("defaultView") private var defaultView = "month" @AppStorage("defaultView") private var defaultView = "month"
@AppStorage("weekStartDay") private var weekStartDay = "monday" @AppStorage("weekStartDay") private var weekStartDay = "monday"
@AppStorage("dimPastEvents") private var dimPastEvents = false @AppStorage("dimPastEvents") private var dimPastEvents = false
@AppStorage("monthViewPaged") private var monthViewPaged = false
@AppStorage("defaultReminderMinutes") private var defaultReminderMinutes = -1 @AppStorage("defaultReminderMinutes") private var defaultReminderMinutes = -1
@AppStorage("defaultEventDurationMinutes") private var defaultEventDurationMinutes = 60
// Profile chapter (server-backed; loaded on appear). // Profile chapter (server-backed; loaded on appear). Account settings are not
// part of the per-device sync they are always account-wide.
@State private var displayName = "" @State private var displayName = ""
@State private var loginName = "" @State private var loginName = ""
@State private var email = "" @State private var email = ""
@State private var privateVisibility = "busy" @State private var privateVisibility = "busy"
@State private var groupVisibleId = 0 // 0 = none @State private var groupVisibleId = 0 // 0 = none
@State private var ownLocalCals: [LocalCalendar] = [] @State private var ownLocalCals: [LocalCalendar] = []
@State private var directoryHidden = false
@State private var profileMsg = "" @State private var profileMsg = ""
// Per-setting sync flags (account-wide; refreshed from the server on pull).
@State private var syncFlags: [String: Bool] = SettingsSync.flags()
// Canonical default colours (single source for the reset buttons).
private static let defaultHex: [String: String] = [
"primaryColor": "#4285F4", "accentColor": "#EA4335", "todayColor": "#4285F4",
"textColor": "#FFFFFF", "backgroundColor": "#000000", "lineColor": "#3A3A52",
"monthDividerColor": "#7090C0", "monthLabelColor": "#7090C0",
]
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Form { Form {
globalSyncSection
profilSection profilSection
privatsphaereSection privatsphaereSection
benachrichtigungenSection
geteilterKalenderSection geteilterKalenderSection
liquidGlassSection termineSection
cacheSection
spracheSection
farbenSection
schriftSection
linienSection
ansichtSection ansichtSection
stundenSection farbenSection
cacheSection
geraetSection
} }
.navigationTitle(L10n.t("settings.title", appLang)) .navigationTitle(L10n.t("settings.title", appLang))
.navigationBarTitleDisplayMode(.large) .navigationBarTitleDisplayMode(.large)
} }
// Reflect the latest server values when opening the screen. // Reflect the latest server values (and flag map) when opening the screen.
.task { await SettingsSync.pull(api: api) } .task { await SettingsSync.pull(api: api); syncFlags = SettingsSync.flags() }
.task { await loadProfile() } .task { await loadProfile() }
// Appearance changes update widgets live; synced values are also pushed // Value edits update widgets live; a synced value is also pushed (debounced).
// to the server (debounced). `push` itself decides what actually gets // `push` decides what actually gets sent based on each key's flag.
// sent based on the sync toggle, so every change can simply call it.
.onChange(of: primaryHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) } .onChange(of: primaryHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
.onChange(of: accentHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) } .onChange(of: accentHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
.onChange(of: todayHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) } .onChange(of: todayHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
.onChange(of: textHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) } .onChange(of: textHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
.onChange(of: bgHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) } .onChange(of: bgHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
.onChange(of: lineHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) } .onChange(of: lineHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
.onChange(of: dividerHex) { _, _ in SettingsSync.push(api: api) } .onChange(of: dividerHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
.onChange(of: labelHex) { _, _ in SettingsSync.push(api: api) } .onChange(of: labelHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
.onChange(of: textContrast) { _, _ in SettingsSync.push(api: api) }
.onChange(of: lineContrast) { _, _ in SettingsSync.push(api: api) }
.onChange(of: hourHeight) { _, _ in SettingsSync.push(api: api) } .onChange(of: hourHeight) { _, _ in SettingsSync.push(api: api) }
.onChange(of: defaultView) { _, _ in SettingsSync.push(api: api) } .onChange(of: defaultView) { _, _ in SettingsSync.push(api: api) }
.onChange(of: weekStartDay) { _, _ in SettingsSync.push(api: api) } .onChange(of: weekStartDay) { _, _ in SettingsSync.push(api: api) }
.onChange(of: dimPastEvents) { _, _ in SettingsSync.push(api: api) } .onChange(of: dimPastEvents) { _, _ in SettingsSync.push(api: api) }
.onChange(of: monthViewPaged){ _, _ in SettingsSync.push(api: api) }
.onChange(of: cacheMonths) { _, _ in SettingsSync.push(api: api) }
.onChange(of: defaultEventDurationMinutes) { _, _ in SettingsSync.push(api: api) }
.onChange(of: appLang) { _, _ in WidgetStore.republishAppearanceOnly() } .onChange(of: appLang) { _, _ in WidgetStore.republishAppearanceOnly() }
// Enabling sync adopts the server's appearance (server wins).
.onChange(of: settingsSync) { _, on in if on { Task { await SettingsSync.pull(api: api) } } }
} }
// MARK: Profil // MARK: Reusable row builders
/// Leading per-row sync toggle: linked (accent) = synced across devices.
@ViewBuilder
private func syncIcon(_ key: String) -> some View {
let on = syncFlags[key] == true
Button {
SettingsSync.setSynced(key, !on, api: api)
syncFlags = SettingsSync.flags()
} label: {
Image(systemName: on ? "link.circle.fill" : "link.circle")
.imageScale(.large)
.foregroundStyle(on ? Color.accentColor : Color.secondary)
}
.buttonStyle(.plain)
.accessibilityLabel(L10n.t("settings.sync_this", appLang))
}
private func colorBinding(_ hex: Binding<String>) -> Binding<Color> {
Binding(get: { Color(hex: hex.wrappedValue) }, set: { hex.wrappedValue = $0.toHex() })
}
// Surface colour: empty string means "auto" (translucent .bar); the picker
// shows a neutral dark until the user chooses a concrete colour.
private var surfaceBinding: Binding<Color> {
Binding(
get: { Color(hex: surfaceHex.isEmpty ? "#1C1C1E" : surfaceHex) },
set: { surfaceHex = $0.toHex() }
)
}
@ViewBuilder
private func colorRow(_ syncKey: String, _ defaultsKey: String, _ label: String, _ hex: Binding<String>) -> some View {
HStack(spacing: 12) {
syncIcon(syncKey)
Text(label)
Spacer()
ColorPicker("", selection: colorBinding(hex), supportsOpacity: false)
.labelsHidden()
Text(hex.wrappedValue.uppercased())
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
.frame(width: 64, alignment: .trailing)
Button {
hex.wrappedValue = Self.defaultHex[defaultsKey] ?? "#000000"
} label: {
Image(systemName: "arrow.uturn.backward")
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
.accessibilityLabel(L10n.t("settings.reset", appLang))
}
}
// MARK: Global sync
var globalSyncSection: some View {
Section {
Toggle(isOn: allSyncedBinding) {
Label {
VStack(alignment: .leading, spacing: 2) {
Text(L10n.t("settings.sync_all", appLang))
Text(L10n.t("settings.sync_all.desc", appLang))
.font(.caption).foregroundStyle(.secondary)
}
} icon: {
Image(systemName: "arrow.triangle.2.circlepath").foregroundStyle(.teal)
}
}
.tint(Color.accentColor)
}
}
private var allSyncedBinding: Binding<Bool> {
Binding(
get: { SettingsSync.syncableKeys.allSatisfy { syncFlags[$0] == true } },
set: { on in SettingsSync.setAllSynced(on, api: api); syncFlags = SettingsSync.flags() }
)
}
// MARK: Profil (account identity not synced)
var profilSection: some View { var profilSection: some View {
Section(L10n.t("settings.nav.profile", appLang)) { Section(L10n.t("settings.nav.profile", appLang)) {
@@ -100,6 +191,13 @@ struct SettingsView: View {
.keyboardType(.emailAddress) .keyboardType(.emailAddress)
.autocapitalization(.none) .autocapitalization(.none)
} }
Toggle(isOn: $directoryHidden) {
VStack(alignment: .leading, spacing: 2) {
Text(L10n.t("settings.directory_hidden", appLang))
Text(L10n.t("settings.directory_hidden.desc", appLang))
.font(.caption).foregroundStyle(.secondary)
}
}
Button(L10n.t("event.save", appLang)) { Task { await saveProfile() } } Button(L10n.t("event.save", appLang)) { Task { await saveProfile() } }
if !profileMsg.isEmpty { if !profileMsg.isEmpty {
Text(profileMsg).font(.caption).foregroundStyle(.secondary) Text(profileMsg).font(.caption).foregroundStyle(.secondary)
@@ -107,28 +205,7 @@ struct SettingsView: View {
} }
} }
// MARK: Benachrichtigungen // MARK: Privatsphäre (account not synced)
var benachrichtigungenSection: some View {
Section {
Picker(ReminderOptions.defaultTitle(appLang), selection: $defaultReminderMinutes) {
Text(ReminderOptions.off(appLang)).tag(-1)
ForEach(ReminderOptions.all, id: \.self) { m in
Text(ReminderOptions.label(m, appLang)).tag(m)
}
}
.onChange(of: defaultReminderMinutes) { _, _ in
SettingsSync.push(api: api)
NotificationCenter.default.post(name: .rescheduleReminders, object: nil)
}
} header: {
Text(ReminderOptions.sectionTitle(appLang))
} footer: {
Text(ReminderOptions.defaultFooter(appLang)).font(.caption)
}
}
// MARK: Privatsphäre
var privatsphaereSection: some View { var privatsphaereSection: some View {
Section { Section {
@@ -146,7 +223,7 @@ struct SettingsView: View {
} }
} }
// MARK: Geteilter Kalender // MARK: Geteilter Kalender (account not synced)
var geteilterKalenderSection: some View { var geteilterKalenderSection: some View {
Section { Section {
@@ -166,18 +243,206 @@ struct SettingsView: View {
} }
} }
// MARK: Termine (synced)
var termineSection: some View {
Section(L10n.t("settings.calview", appLang)) {
HStack(spacing: 12) {
syncIcon("default_event_duration_minutes")
Text(L10n.t("settings.default_duration", appLang))
Spacer()
Picker("", selection: $defaultEventDurationMinutes) {
ForEach([15, 30, 45, 60, 90, 120, 240], id: \.self) { m in
Text(ReminderOptions.durationLabel(m, appLang)).tag(m)
}
}
.pickerStyle(.menu).labelsHidden()
}
HStack(spacing: 12) {
syncIcon("default_reminder_minutes")
Text(ReminderOptions.defaultTitle(appLang))
Spacer()
Picker("", selection: $defaultReminderMinutes) {
Text(ReminderOptions.off(appLang)).tag(-1)
ForEach(ReminderOptions.all, id: \.self) { m in
Text(ReminderOptions.label(m, appLang)).tag(m)
}
}
.pickerStyle(.menu).labelsHidden()
.onChange(of: defaultReminderMinutes) { _, _ in
SettingsSync.push(api: api)
NotificationCenter.default.post(name: .rescheduleReminders, object: nil)
}
}
}
}
// MARK: Ansicht (synced)
var ansichtSection: some View {
Section(L10n.t("settings.appearance", appLang)) {
HStack(spacing: 12) {
syncIcon("default_view")
Text(L10n.t("settings.defaultview", appLang))
Spacer()
Picker("", selection: $defaultView) {
Text(L10n.t("view.month", appLang)).tag("month")
Text(L10n.t("view.week", appLang)).tag("week")
Text(L10n.t("view.day", appLang)).tag("day")
Text(L10n.t("view.quarter", appLang)).tag("quarter")
Text(L10n.t("view.agenda", appLang)).tag("agenda")
}
.pickerStyle(.menu).labelsHidden()
}
HStack(spacing: 12) {
syncIcon("week_start_day")
Text(L10n.t("settings.firstweekday", appLang))
Spacer()
Picker("", selection: $weekStartDay) {
Text(L10n.t("settings.monday", appLang)).tag("monday")
Text(L10n.t("settings.sunday", appLang)).tag("sunday")
}
.pickerStyle(.menu).labelsHidden()
}
HStack(spacing: 12) {
syncIcon("dim_past_events")
Toggle(L10n.t("settings.dimpast", appLang), isOn: $dimPastEvents).tint(Color.accentColor)
}
HStack(spacing: 12) {
syncIcon("month_view_paged")
Text(L10n.t("settings.month_mode", appLang))
Spacer()
Picker("", selection: $monthViewPaged) {
Text(L10n.t("settings.month_mode.scroll", appLang)).tag(false)
Text(L10n.t("settings.month_mode.paged", appLang)).tag(true)
}
.pickerStyle(.menu).labelsHidden()
}
HStack(spacing: 12) {
syncIcon("hour_height")
Text(L10n.t("settings.hourheight", appLang))
Spacer()
Picker("", selection: $hourHeight) {
Text(L10n.t("settings.hourheight.compact", appLang)).tag(28)
Text(L10n.t("settings.hourheight.normal", appLang)).tag(44)
Text(L10n.t("settings.hourheight.comfort", appLang)).tag(60)
Text(L10n.t("settings.hourheight.large", appLang)).tag(80)
}
.pickerStyle(.menu).labelsHidden()
}
}
}
// MARK: Farben (synced)
var farbenSection: some View {
Section(L10n.t("settings.colors", appLang)) {
colorRow("primary_color", "primaryColor", L10n.t("settings.color.primary", appLang), $primaryHex)
colorRow("accent_color", "accentColor", L10n.t("settings.color.accent", appLang), $accentHex)
colorRow("today_color", "todayColor", L10n.t("settings.color.today", appLang), $todayHex)
colorRow("text_color", "textColor", L10n.t("settings.color.text", appLang), $textHex)
colorRow("bg_color", "backgroundColor", L10n.t("settings.color.background", appLang), $bgHex)
colorRow("line_color", "lineColor", L10n.t("settings.color.line", appLang), $lineHex)
colorRow("month_divider_color", "monthDividerColor", L10n.t("settings.color.divider", appLang), $dividerHex)
colorRow("month_label_color", "monthLabelColor", L10n.t("settings.color.label", appLang), $labelHex)
}
}
// MARK: Cache (synced)
var cacheSection: some View {
Section {
HStack(spacing: 12) {
syncIcon("cache_months")
Text(L10n.t("settings.cache.range", appLang))
Spacer()
Picker("", selection: $cacheMonths) {
Text(L10n.t("settings.cache.1m", appLang)).tag(1)
Text(L10n.t("settings.cache.3m", appLang)).tag(3)
Text(L10n.t("settings.cache.6m", appLang)).tag(6)
Text(L10n.t("settings.cache.1y", appLang)).tag(12)
}
.pickerStyle(.menu).labelsHidden()
}
} header: {
Text(L10n.t("settings.cache.header", appLang))
} footer: {
Text(L10n.t("settings.cache.footer", appLang)).font(.caption)
}
}
// MARK: Gerät (device-local: language, contrast, liquid glass)
var geraetSection: some View {
Section {
Picker(L10n.t("settings.language", appLang), selection: $appLang) {
Text(L10n.t("lang.system", appLang)).tag("system")
Text(L10n.t("lang.german", appLang)).tag("de")
Text(L10n.t("lang.english", appLang)).tag("en")
}
Picker(L10n.t("settings.textcontrast", appLang), selection: $textContrast) {
Text(L10n.t("settings.contrast.dark", appLang)).tag(1)
Text(L10n.t("settings.contrast.medium", appLang)).tag(2)
Text(L10n.t("settings.contrast.bright", appLang)).tag(3)
Text(L10n.t("settings.contrast.max", appLang)).tag(4)
}
Picker(L10n.t("settings.linecontrast", appLang), selection: $lineContrast) {
Text(L10n.t("settings.linecontrast.barely", appLang)).tag(1)
Text(L10n.t("settings.linecontrast.subtle", appLang)).tag(2)
Text(L10n.t("settings.linecontrast.normal", appLang)).tag(3)
Text(L10n.t("settings.linecontrast.strong", appLang)).tag(4)
}
Toggle(isOn: $liquidGlass) {
Label {
VStack(alignment: .leading, spacing: 2) {
Text(L10n.t("settings.liquidglass", appLang))
Text(L10n.t("settings.liquidglass.desc", appLang))
.font(.caption).foregroundStyle(.secondary)
}
} icon: {
Image(systemName: "sparkles").foregroundStyle(.blue)
}
}
.tint(Color.accentColor)
HStack(spacing: 12) {
Text(L10n.t("settings.color.surface", appLang))
Spacer()
Text(surfaceHex.isEmpty ? L10n.t("settings.surface.auto", appLang) : surfaceHex.uppercased())
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
ColorPicker("", selection: surfaceBinding, supportsOpacity: false)
.labelsHidden()
Button { surfaceHex = "" } label: { Image(systemName: "arrow.uturn.backward") }
.buttonStyle(.plain)
.foregroundStyle(.secondary)
.accessibilityLabel(L10n.t("settings.surface.auto", appLang))
}
Toggle(L10n.t("settings.hide_menu_button", appLang), isOn: $hideMenuButton)
.tint(Color.accentColor)
} header: {
Text(L10n.t("settings.device", appLang))
} footer: {
Text(L10n.t("settings.device.footer", appLang)).font(.caption)
}
}
// MARK: Profile load / save
private func loadProfile() async { private func loadProfile() async {
if let p = try? await api.getProfile() { if let p = try? await api.getProfile() {
displayName = p.displayName ?? p.username displayName = p.displayName ?? p.username
loginName = p.username loginName = p.username
email = p.email ?? "" email = p.email ?? ""
directoryHidden = p.directoryHidden
} }
if let s = try? await api.getSettings() { if let s = try? await api.getSettings() {
privateVisibility = s.privateEventVisibility privateVisibility = s.privateEventVisibility
groupVisibleId = s.groupVisibleCalendarId ?? 0 groupVisibleId = s.groupVisibleCalendarId ?? 0
} }
if let cals = try? await api.getLocalCalendars() { if let cals = try? await api.getLocalCalendars() {
ownLocalCals = cals.filter { $0.owned && !$0.group } // A birthday calendar may be shared directly, but never stand in as
// the group-visible personal calendar.
ownLocalCals = cals.filter { $0.owned && !$0.group && !$0.isBirthday }
} }
} }
@@ -185,257 +450,12 @@ struct SettingsView: View {
do { do {
_ = try await api.updateProfile(displayName: displayName.isEmpty ? nil : displayName, _ = try await api.updateProfile(displayName: displayName.isEmpty ? nil : displayName,
username: nil, username: nil,
email: email.isEmpty ? "" : email) email: email.isEmpty ? "" : email,
directoryHidden: directoryHidden)
UserDefaults.standard.set(displayName, forKey: "displayName") UserDefaults.standard.set(displayName, forKey: "displayName")
profileMsg = L10n.t("settings.saved", appLang) profileMsg = L10n.t("settings.saved", appLang)
} catch { } catch {
profileMsg = error.localizedDescription profileMsg = error.localizedDescription
} }
} }
// MARK: Liquid Glass
var liquidGlassSection: some View {
Section {
Toggle(isOn: $liquidGlass) {
Label {
VStack(alignment: .leading, spacing: 2) {
Text(L10n.t("settings.liquidglass", appLang))
Text(L10n.t("settings.liquidglass.desc", appLang))
.font(.caption)
.foregroundStyle(.secondary)
}
} icon: {
Image(systemName: "sparkles")
.foregroundStyle(.blue)
}
}
.tint(Color.accentColor)
Toggle(isOn: $settingsSync) {
Label {
VStack(alignment: .leading, spacing: 2) {
Text(L10n.t("settings.sync", appLang))
Text(L10n.t("settings.sync.desc", appLang))
.font(.caption)
.foregroundStyle(.secondary)
}
} icon: {
Image(systemName: "arrow.triangle.2.circlepath")
.foregroundStyle(.teal)
}
}
.tint(Color.accentColor)
} header: {
Text(L10n.t("settings.appdesign", appLang))
} footer: {
Text(L10n.t("settings.sync.footer", appLang))
.font(.caption)
}
}
// MARK: Cache
var cacheSection: some View {
Section {
VStack(alignment: .leading, spacing: 10) {
Label {
VStack(alignment: .leading, spacing: 2) {
Text(L10n.t("settings.cache.title", appLang))
Text(L10n.t("settings.cache.desc", appLang))
.font(.caption)
.foregroundStyle(.secondary)
}
} icon: {
Image(systemName: "arrow.down.circle")
.foregroundStyle(.green)
}
Picker(L10n.t("settings.cache.range", appLang), selection: $cacheMonths) {
Text(L10n.t("settings.cache.1m", appLang)).tag(1)
Text(L10n.t("settings.cache.3m", appLang)).tag(3)
Text(L10n.t("settings.cache.6m", appLang)).tag(6)
Text(L10n.t("settings.cache.1y", appLang)).tag(12)
}
.pickerStyle(.segmented)
}
.padding(.vertical, 4)
} header: {
Text(L10n.t("settings.cache.header", appLang))
} footer: {
Text(L10n.t("settings.cache.footer", appLang))
.font(.caption)
}
}
// MARK: Sprache
var spracheSection: some View {
Section(L10n.t("settings.language", appLang)) {
Picker(L10n.t("settings.language", appLang), selection: $appLang) {
Text(L10n.t("lang.system", appLang)).tag("system")
Text(L10n.t("lang.german", appLang)).tag("de")
Text(L10n.t("lang.english", appLang)).tag("en")
}
}
}
// MARK: Farben
var farbenSection: some View {
Section(L10n.t("settings.colors", appLang)) {
ColorPickerRow(label: L10n.t("settings.color.primary", appLang), hex: $primaryHex)
ColorPickerRow(label: L10n.t("settings.color.accent", appLang), hex: $accentHex)
ColorPickerRow(label: L10n.t("settings.color.today", appLang), hex: $todayHex)
ColorPickerRow(label: L10n.t("settings.color.text", appLang), hex: $textHex)
ColorPickerRow(label: L10n.t("settings.color.background", appLang), hex: $bgHex)
ColorPickerRow(label: L10n.t("settings.color.line", appLang), hex: $lineHex)
ColorPickerRow(label: L10n.t("settings.color.divider", appLang), hex: $dividerHex)
ColorPickerRow(label: L10n.t("settings.color.label", appLang), hex: $labelHex)
}
}
// MARK: Schriftkontrast
var schriftSection: some View {
Section {
VStack(alignment: .leading, spacing: 10) {
Text(L10n.t("settings.textcontrast", appLang))
.font(.headline)
Text(L10n.t("settings.textcontrast.desc", appLang))
.font(.caption)
.foregroundStyle(.secondary)
ContrastSelector(
value: $textContrast,
options: [
(1, L10n.t("settings.contrast.dark", appLang)),
(2, L10n.t("settings.contrast.medium", appLang)),
(3, L10n.t("settings.contrast.bright", appLang)),
(4, L10n.t("settings.contrast.max", appLang))
]
)
}
.padding(.vertical, 4)
}
}
// MARK: Linienkontrast
var linienSection: some View {
Section {
VStack(alignment: .leading, spacing: 10) {
Text(L10n.t("settings.linecontrast", appLang))
.font(.headline)
Text(L10n.t("settings.linecontrast.desc", appLang))
.font(.caption)
.foregroundStyle(.secondary)
ContrastSelector(
value: $lineContrast,
options: [
(1, L10n.t("settings.linecontrast.barely", appLang)),
(2, L10n.t("settings.linecontrast.subtle", appLang)),
(3, L10n.t("settings.linecontrast.normal", appLang)),
(4, L10n.t("settings.linecontrast.strong", appLang))
]
)
}
.padding(.vertical, 4)
}
}
// MARK: Ansicht
var ansichtSection: some View {
Section(L10n.t("settings.calview", appLang)) {
Picker(L10n.t("settings.defaultview", appLang), selection: $defaultView) {
Text(L10n.t("view.month", appLang)).tag("month")
Text(L10n.t("view.week", appLang)).tag("week")
Text(L10n.t("view.day", appLang)).tag("day")
Text(L10n.t("view.quarter", appLang)).tag("quarter")
Text(L10n.t("view.agenda", appLang)).tag("agenda")
}
Picker(L10n.t("settings.firstweekday", appLang), selection: $weekStartDay) {
Text(L10n.t("settings.monday", appLang)).tag("monday")
Text(L10n.t("settings.sunday", appLang)).tag("sunday")
}
Toggle(L10n.t("settings.dimpast", appLang), isOn: $dimPastEvents)
.tint(Color.accentColor)
}
}
// MARK: Stundenhöhe
var stundenSection: some View {
Section {
VStack(alignment: .leading, spacing: 10) {
Text(L10n.t("settings.hourheight", appLang))
.font(.headline)
Text(L10n.t("settings.hourheight.desc", appLang))
.font(.caption)
.foregroundStyle(.secondary)
ContrastSelector(
value: $hourHeight,
options: [
(28, L10n.t("settings.hourheight.compact", appLang)),
(44, L10n.t("settings.hourheight.normal", appLang)),
(60, L10n.t("settings.hourheight.comfort", appLang)),
(80, L10n.t("settings.hourheight.large", appLang))
]
)
}
.padding(.vertical, 4)
}
}
}
// MARK: Reusable Components
struct ColorPickerRow: View {
let label: String
@Binding var hex: String
var color: Binding<Color> {
Binding(
get: { Color(hex: hex) },
set: { hex = $0.toHex() }
)
}
var body: some View {
HStack {
Text(label)
Spacer()
ColorPicker("", selection: color, supportsOpacity: false)
.labelsHidden()
Text(hex.uppercased())
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
.frame(width: 68, alignment: .trailing)
}
}
}
struct ContrastSelector<T: Hashable & Equatable>: View {
@Binding var value: T
let options: [(T, String)]
var body: some View {
HStack(spacing: 8) {
ForEach(Array(options.enumerated()), id: \.offset) { _, opt in
Button {
value = opt.0
} label: {
Text(opt.1)
.font(.caption.weight(.medium))
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(value == opt.0 ? Color.accentColor : Color(.systemGray5))
.foregroundStyle(value == opt.0 ? .white : .primary)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
}
}
}
} }

View File

@@ -40,6 +40,7 @@ struct CalendarDayWidgetView: View {
let accent = Color(widgetHex: s.accentColorHex) let accent = Color(widgetHex: s.accentColorHex)
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
header(primary: primary) header(primary: primary)
.padding(.top, 6)
weekStrip(snapshot: s, primary: primary, accent: accent) weekStrip(snapshot: s, primary: primary, accent: accent)
.padding(.vertical, 3) .padding(.vertical, 3)
Rectangle() Rectangle()

View File

@@ -0,0 +1,47 @@
import AppIntents
import WidgetKit
/// An individual calendar option shown in the widget configuration picker.
struct CalendarAppEntity: AppEntity, Identifiable {
let id: String
let name: String
let colorHex: String
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Kalender"
static var defaultQuery = CalendarEntityQuery()
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(title: LocalizedStringResource(stringLiteral: name))
}
}
/// Reads available calendars from the App Group container so the widget
/// extension can offer options without a network call.
struct CalendarEntityQuery: EntityQuery {
func entities(for identifiers: [String]) async throws -> [CalendarAppEntity] {
let ids = Set(identifiers)
return WidgetStore.readCalendars()
.filter { ids.contains($0.id) }
.map { CalendarAppEntity(id: $0.id, name: $0.name, colorHex: $0.colorHex) }
}
func suggestedEntities() async throws -> [CalendarAppEntity] {
WidgetStore.readCalendars()
.map { CalendarAppEntity(id: $0.id, name: $0.name, colorHex: $0.colorHex) }
}
}
/// Widget configuration intent: lets the user pick which calendars to show.
/// No selection means "show all calendars" (the default).
///
/// The parameter must be optional: WidgetConfigurationIntent requires every
/// parameter type to be optional, and the macOS/Mac Catalyst SDK enforces that
/// where the iOS one lets a bare array through. `nil` and `[]` are treated
/// alike downstream, so this is behaviour-preserving.
struct CalendarSelectionIntent: WidgetConfigurationIntent {
static var title: LocalizedStringResource = "Kalender auswählen"
static var description = IntentDescription("Wähle welche Kalender im Widget angezeigt werden. Leer = alle Kalender.")
@Parameter(title: "Kalender")
var selectedCalendars: [CalendarAppEntity]?
}

View File

@@ -1,33 +1,58 @@
import WidgetKit import WidgetKit
import AppIntents
struct CalendarrEntry: TimelineEntry { struct CalendarrEntry: TimelineEntry {
let date: Date let date: Date
let snapshot: WidgetSnapshot? let snapshot: WidgetSnapshot?
} }
struct CalendarrTimelineProvider: TimelineProvider { struct CalendarrTimelineProvider: AppIntentTimelineProvider {
typealias Entry = CalendarrEntry
typealias Intent = CalendarSelectionIntent
func placeholder(in context: Context) -> CalendarrEntry { func placeholder(in context: Context) -> CalendarrEntry {
CalendarrEntry(date: .now, snapshot: WidgetStore.read()) CalendarrEntry(date: .now, snapshot: WidgetStore.read())
} }
func getSnapshot(in context: Context, completion: @escaping (CalendarrEntry) -> Void) { func snapshot(for configuration: CalendarSelectionIntent, in context: Context) async -> CalendarrEntry {
completion(CalendarrEntry(date: .now, snapshot: WidgetStore.read())) let raw = WidgetStore.read()
return CalendarrEntry(date: .now, snapshot: filtered(raw, by: configuration))
} }
func getTimeline(in context: Context, completion: @escaping (Timeline<CalendarrEntry>) -> Void) { func timeline(for configuration: CalendarSelectionIntent, in context: Context) async -> Timeline<CalendarrEntry> {
let snapshot = WidgetStore.read() let raw = WidgetStore.read()
let snap = filtered(raw, by: configuration)
let now = Date() let now = Date()
// Provide one entry per hour for the next 24h so the widget keeps // One entry per hour for 24 h so the widget re-renders as time advances.
// re-rendering as time progresses (past events drop off, "now" advances).
var entries: [CalendarrEntry] = [] var entries: [CalendarrEntry] = []
for h in 0..<24 { for h in 0..<24 {
let date = Calendar.current.date(byAdding: .hour, value: h, to: now) ?? now let date = Calendar.current.date(byAdding: .hour, value: h, to: now) ?? now
entries.append(CalendarrEntry(date: date, snapshot: snapshot)) entries.append(CalendarrEntry(date: date, snapshot: snap))
} }
// Ask iOS to refresh in 30 min to pick up any new data the app wrote.
let refreshAt = Calendar.current.date(byAdding: .minute, value: 30, to: now) ?? now let refreshAt = Calendar.current.date(byAdding: .minute, value: 30, to: now) ?? now
completion(Timeline(entries: entries, policy: .after(refreshAt))) return Timeline(entries: entries, policy: .after(refreshAt))
}
// MARK: Filtering
private func filtered(_ snapshot: WidgetSnapshot?, by config: CalendarSelectionIntent) -> WidgetSnapshot? {
guard let snapshot else { return nil }
let ids = (config.selectedCalendars ?? []).map { $0.id }
guard !ids.isEmpty else { return snapshot } // nothing selected = show all
let keep = Set(ids)
// Filtering narrows the events but not the window: the snapshot still
// speaks for the same range, it just has fewer calendars in it.
return WidgetSnapshot(
writtenAt: snapshot.writtenAt,
coverageStart: snapshot.coverageStart,
coverageEnd: snapshot.coverageEnd,
isLoggedIn: snapshot.isLoggedIn,
writerVersion: snapshot.writerVersion,
events: snapshot.events.filter { keep.contains($0.calendarKey) },
theme: snapshot.theme,
language: snapshot.language
)
} }
} }

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!--
Mac Catalyst entitlements for the widget extension. Deliberately minimal:
the widget only ever reads the snapshot from the App Group container. It
does no networking, touches no Contacts, and needs no keychain access.
-->
<key>com.apple.security.app-sandbox</key>
<true/>
<!-- Must match CalendarrAppGroup.current on this platform, byte for byte. -->
<key>com.apple.security.application-groups</key>
<array>
<string>PP34X97WS3.group.com.scarriffleservices.calendarr</string>
</array>
</dict>
</plist>

View File

@@ -47,7 +47,7 @@ struct TodayWidget: Widget {
let kind: String = "TodayWidget" let kind: String = "TodayWidget"
var body: some WidgetConfiguration { var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in
TodayWidgetView(entry: entry).calendarrChrome(entry.snapshot) TodayWidgetView(entry: entry).calendarrChrome(entry.snapshot)
} }
.configurationDisplayName(WidgetL10n.t("widget.display.today_title", "system")) .configurationDisplayName(WidgetL10n.t("widget.display.today_title", "system"))
@@ -62,7 +62,7 @@ struct TwoDaysWidget: Widget {
let kind: String = "TwoDaysWidget" let kind: String = "TwoDaysWidget"
var body: some WidgetConfiguration { var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in
TwoDaysWidgetView(entry: entry).calendarrChrome(entry.snapshot) TwoDaysWidgetView(entry: entry).calendarrChrome(entry.snapshot)
} }
.configurationDisplayName(WidgetL10n.t("widget.display.days_title", "system")) .configurationDisplayName(WidgetL10n.t("widget.display.days_title", "system"))
@@ -77,7 +77,7 @@ struct ThreeDaysWidget: Widget {
let kind: String = "ThreeDaysWidget" let kind: String = "ThreeDaysWidget"
var body: some WidgetConfiguration { var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in
ThreeDaysWidgetView(entry: entry).calendarrChrome(entry.snapshot) ThreeDaysWidgetView(entry: entry).calendarrChrome(entry.snapshot)
} }
.configurationDisplayName(WidgetL10n.t("widget.display.threedays_title", "system")) .configurationDisplayName(WidgetL10n.t("widget.display.threedays_title", "system"))
@@ -92,7 +92,7 @@ struct ThisWeekWidget: Widget {
let kind: String = "ThisWeekWidget" let kind: String = "ThisWeekWidget"
var body: some WidgetConfiguration { var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in
ThisWeekWidgetView(entry: entry).calendarrChrome(entry.snapshot) ThisWeekWidgetView(entry: entry).calendarrChrome(entry.snapshot)
} }
.configurationDisplayName(WidgetL10n.t("widget.display.thisweek_title", "system")) .configurationDisplayName(WidgetL10n.t("widget.display.thisweek_title", "system"))
@@ -107,7 +107,7 @@ struct TwoWeeksWidget: Widget {
let kind: String = "TwoWeeksWidget" let kind: String = "TwoWeeksWidget"
var body: some WidgetConfiguration { var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in
TwoWeeksWidgetView(entry: entry).calendarrChrome(entry.snapshot) TwoWeeksWidgetView(entry: entry).calendarrChrome(entry.snapshot)
} }
.configurationDisplayName(WidgetL10n.t("widget.display.twoweeks_title", "system")) .configurationDisplayName(WidgetL10n.t("widget.display.twoweeks_title", "system"))
@@ -122,7 +122,7 @@ struct UpcomingWidget: Widget {
let kind: String = "UpcomingWidget" let kind: String = "UpcomingWidget"
var body: some WidgetConfiguration { var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in
UpcomingWidgetView(entry: entry).calendarrChrome(entry.snapshot) UpcomingWidgetView(entry: entry).calendarrChrome(entry.snapshot)
} }
.configurationDisplayName(WidgetL10n.t("widget.display.upcoming_title", "system")) .configurationDisplayName(WidgetL10n.t("widget.display.upcoming_title", "system"))
@@ -137,7 +137,7 @@ struct UpNextWidget: Widget {
let kind: String = "UpNextWidget" let kind: String = "UpNextWidget"
var body: some WidgetConfiguration { var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in
UpNextWidgetView(entry: entry).calendarrChrome(entry.snapshot) UpNextWidgetView(entry: entry).calendarrChrome(entry.snapshot)
} }
.configurationDisplayName(WidgetL10n.t("widget.display.upnext_title", "system")) .configurationDisplayName(WidgetL10n.t("widget.display.upnext_title", "system"))
@@ -152,7 +152,7 @@ struct CalendarDayWidget: Widget {
let kind: String = "CalendarDayWidget" let kind: String = "CalendarDayWidget"
var body: some WidgetConfiguration { var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in
CalendarDayWidgetView(entry: entry).calendarrChrome(entry.snapshot) CalendarDayWidgetView(entry: entry).calendarrChrome(entry.snapshot)
} }
.configurationDisplayName(WidgetL10n.t("widget.display.calday_title", "system")) .configurationDisplayName(WidgetL10n.t("widget.display.calday_title", "system"))
@@ -167,7 +167,7 @@ struct TwoMonthWidget: Widget {
let kind: String = "TwoMonthWidget" let kind: String = "TwoMonthWidget"
var body: some WidgetConfiguration { var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in
TwoMonthWidgetView(entry: entry).calendarrChrome(entry.snapshot) TwoMonthWidgetView(entry: entry).calendarrChrome(entry.snapshot)
} }
.configurationDisplayName(WidgetL10n.t("widget.display.twomonth_title", "system")) .configurationDisplayName(WidgetL10n.t("widget.display.twomonth_title", "system"))
@@ -182,7 +182,7 @@ struct NowNextEventsWidget: Widget {
let kind: String = "NowNextEventsWidget" let kind: String = "NowNextEventsWidget"
var body: some WidgetConfiguration { var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in
NowNextWidgetView(entry: entry).calendarrChrome(entry.snapshot) NowNextWidgetView(entry: entry).calendarrChrome(entry.snapshot)
} }
.configurationDisplayName(WidgetL10n.t("widget.display.nownext_title", "system")) .configurationDisplayName(WidgetL10n.t("widget.display.nownext_title", "system"))
@@ -197,7 +197,7 @@ struct LockScreenWidget: Widget {
let kind: String = "LockScreenWidget" let kind: String = "LockScreenWidget"
var body: some WidgetConfiguration { var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in
LockScreenWidgetView(entry: entry) LockScreenWidgetView(entry: entry)
.containerBackground(for: .widget) { Color.clear } .containerBackground(for: .widget) { Color.clear }
.environment(\.locale, WidgetL10n.locale(entry.snapshot?.language ?? "system")) .environment(\.locale, WidgetL10n.locale(entry.snapshot?.language ?? "system"))
@@ -214,7 +214,7 @@ struct LockScreenCountWidget: Widget {
let kind: String = "LockScreenCountWidget" let kind: String = "LockScreenCountWidget"
var body: some WidgetConfiguration { var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in
LockScreenCountWidgetView(entry: entry) LockScreenCountWidgetView(entry: entry)
.containerBackground(for: .widget) { Color.clear } .containerBackground(for: .widget) { Color.clear }
.environment(\.locale, WidgetL10n.locale(entry.snapshot?.language ?? "system")) .environment(\.locale, WidgetL10n.locale(entry.snapshot?.language ?? "system"))
@@ -231,7 +231,7 @@ struct LockScreenCountdownWidget: Widget {
let kind: String = "LockScreenCountdownWidget" let kind: String = "LockScreenCountdownWidget"
var body: some WidgetConfiguration { var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in
LockScreenCountdownWidgetView(entry: entry) LockScreenCountdownWidgetView(entry: entry)
.containerBackground(for: .widget) { Color.clear } .containerBackground(for: .widget) { Color.clear }
.environment(\.locale, WidgetL10n.locale(entry.snapshot?.language ?? "system")) .environment(\.locale, WidgetL10n.locale(entry.snapshot?.language ?? "system"))

View File

@@ -89,11 +89,11 @@ struct ThisWeekWidgetView: View {
.frame(width: 16, height: 16) .frame(width: 16, height: 16)
.background(isToday ? primary : Color.clear) .background(isToday ? primary : Color.clear)
.clipShape(Circle()) .clipShape(Circle())
ForEach(evs.prefix(6)) { ev in ForEach(evs.prefix(8)) { ev in
eventPill(ev) eventPill(ev)
} }
if evs.count > 6 { if evs.count > 8 {
Text("+\(evs.count - 6)") Text("+\(evs.count - 8)")
.font(.system(size: 6.5)) .font(.system(size: 6.5))
.foregroundStyle(accent) .foregroundStyle(accent)
} }

View File

@@ -3,15 +3,20 @@ import WidgetKit
private let rowHeight: CGFloat = 16 private let rowHeight: CGFloat = 16
private let dayHeaderHeight: CGFloat = 14 private let dayHeaderHeight: CGFloat = 14
private let maxEventsPerDay: Int = 3 // Show all events of a day (the total-row cap below governs how much fits);
private let maxTotalRows: Int = 22 // a low per-day cap previously made busy days collapse to "+N" far too early.
private let maxEventsPerDay: Int = 25
struct UpcomingWidgetView: View { struct UpcomingWidgetView: View {
let entry: CalendarrEntry let entry: CalendarrEntry
@Environment(\.widgetFamily) private var family
private var snapshot: WidgetSnapshot? { entry.snapshot } private var snapshot: WidgetSnapshot? { entry.snapshot }
private var lang: String { snapshot?.language ?? "system" } private var lang: String { snapshot?.language ?? "system" }
// Fill the available height: extraLarge (iPad) fits far more rows.
private var maxTotalRows: Int { family == .systemExtraLarge ? 40 : 22 }
private var groupedWithLimits: [(Date, [WidgetEvent], Int)] { private var groupedWithLimits: [(Date, [WidgetEvent], Int)] {
guard let s = snapshot else { return [] } guard let s = snapshot else { return [] }
let cal = Calendar.current let cal = Calendar.current

View File

@@ -1,129 +1,136 @@
import Foundation import Foundation
@_spi(Writer) import CalendarrCore
#if canImport(WidgetKit) #if canImport(WidgetKit)
import WidgetKit import WidgetKit
#endif #endif
/// App-Group identifier shared between the main app and the widget extension. // The snapshot types and the App Group plumbing now live in CalendarrCore, so
/// IMPORTANT: This must match the App Group capability in BOTH targets // that a second app from this team reads the exact same format instead of
/// and the App Group ID registered in the Apple Developer Portal. // reimplementing it and drifting.
let widgetAppGroupID = "group.com.scarriffleservices.calendarr" //
// These aliases keep the ~70 existing call sites in the app and the widget
// compiling unchanged. They are a migration convenience, not a design: new code
// should use the CalendarrCore names directly.
typealias WidgetEvent = SnapshotEvent
typealias WidgetCalendar = SnapshotCalendar
typealias WidgetSnapshot = CalendarrSnapshot
/// Lightweight event representation that lives inside the widget cache. /// The App Group identifier for this platform. See `CalendarrAppGroup`.
/// We strip everything the widget doesn't need (notes, calendar IDs, URLs). let widgetAppGroupID = CalendarrAppGroup.current
struct WidgetEvent: Codable, Hashable, Identifiable {
let id: String extension CalendarrSnapshot {
let title: String // The six colours are stored flat on the wire for backwards compatibility
let start: Date // but exposed as `theme` in the package. These keep the widget views, which
let end: Date // read them individually, from having to change.
let isAllDay: Bool var todayColorHex: String { theme.today }
let colorHex: String var textColorHex: String { theme.text }
let location: String var backgroundColorHex: String { theme.background }
} var lineColorHex: String { theme.line }
var primaryColorHex: String { theme.primary }
/// Snapshot blob the app writes to the App-Group container and the widget reads. var accentColorHex: String { theme.accent }
struct WidgetSnapshot: Codable {
let writtenAt: Date
let events: [WidgetEvent]
/// Mirrors the user's chosen visual settings so the widget looks the same
/// as the app even when its own AppStorage in the extension is empty.
let todayColorHex: String
let textColorHex: String
let backgroundColorHex: String
let lineColorHex: String
let primaryColorHex: String
let accentColorHex: String
let language: String
init(writtenAt: Date,
events: [WidgetEvent],
todayColorHex: String,
textColorHex: String,
backgroundColorHex: String,
lineColorHex: String,
primaryColorHex: String,
accentColorHex: String,
language: String) {
self.writtenAt = writtenAt
self.events = events
self.todayColorHex = todayColorHex
self.textColorHex = textColorHex
self.backgroundColorHex = backgroundColorHex
self.lineColorHex = lineColorHex
self.primaryColorHex = primaryColorHex
self.accentColorHex = accentColorHex
self.language = language
}
/// Custom decoder so older caches without the new colour fields still load.
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
writtenAt = try c.decode(Date.self, forKey: .writtenAt)
events = try c.decode([WidgetEvent].self, forKey: .events)
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)
language = try c.decode(String.self, forKey: .language)
primaryColorHex = try c.decodeIfPresent(String.self, forKey: .primaryColorHex) ?? "#4285f4"
accentColorHex = try c.decodeIfPresent(String.self, forKey: .accentColorHex) ?? "#ea4335"
}
private enum CodingKeys: String, CodingKey {
case writtenAt, events, todayColorHex, textColorHex, backgroundColorHex
case lineColorHex, primaryColorHex, accentColorHex, language
}
} }
/// Thin facade over `CalendarrCore.SnapshotStore`, preserving the static API the
/// app and widget already use. Errors are swallowed here exactly as before
/// a failed widget cache write must never interrupt the user but the store now
/// reports *why* a read failed, which is what `read()` discards and callers that
/// need the distinction should use `SnapshotStore` directly for.
enum WidgetStore { enum WidgetStore {
private static let cacheFilename = "widget-cache.json" private static let store = SnapshotStore()
private static let sessions = SharedSessionStore()
private static var containerURL: URL? { /// Version of the app writing the cache, for diagnostics in the consumer.
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: widgetAppGroupID) private static var writerVersion: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
} }
private static var cacheURL: URL? {
containerURL?.appendingPathComponent(cacheFilename)
}
/// Called by the app whenever the event cache changes.
static func write(_ snapshot: WidgetSnapshot) { static func write(_ snapshot: WidgetSnapshot) {
guard let url = cacheURL else { return } try? store.write(snapshot)
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
if let data = try? encoder.encode(snapshot) {
try? data.write(to: url, options: .atomic)
}
} }
/// Called by the widget timeline provider to load the latest snapshot.
static func read() -> WidgetSnapshot? { static func read() -> WidgetSnapshot? {
guard let url = cacheURL, let data = try? Data(contentsOf: url) else { return nil } store.read().snapshot
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return try? decoder.decode(WidgetSnapshot.self, from: data)
} }
/// Rewrite the existing snapshot with the latest colour / language values static func writeCalendars(_ calendars: [WidgetCalendar]) {
/// from UserDefaults. Used when the user tweaks an appearance setting and try? store.writeCalendars(calendars)
/// we want the widgets to refresh immediately, without needing a new event }
/// sync. No-op if there's no cached snapshot yet.
static func readCalendars() -> [WidgetCalendar] {
store.readCalendars()
}
/// Record which server we are pointed at and whether anyone is signed in, so
/// a reading app can tell "signed out" from "never ran" without reaching into
/// this app's UserDefaults, which lives in another sandbox.
static func writeSession(baseURL: String, username: String, isLoggedIn: Bool) {
try? sessions.write(SharedSession(baseURL: baseURL,
username: username,
isLoggedIn: isLoggedIn,
writtenAt: Date()))
}
/// Forget the session entirely. Used on server reset, where the app returns
/// to unconfigured and there is no longer a server worth naming.
static func clearSession() {
sessions.clear()
}
/// Drop the cached calendar. Called on sign-out and server reset: the
/// container outlives the session, so without this every reader keeps
/// rendering the previous user's events.
static func clear() {
store.clear()
WidgetTimelineNotifier.reload()
}
/// Rewrite the existing snapshot with the latest colour / language values so
/// widgets pick up an appearance change immediately, without waiting for the
/// next event sync. No-op when nothing is cached yet.
static func republishAppearanceOnly() { static func republishAppearanceOnly() {
guard let existing = read() else { return } guard let existing = read() else { return }
let defaults = UserDefaults.standard let defaults = UserDefaults.standard
let updated = WidgetSnapshot( let updated = WidgetSnapshot(
writtenAt: Date(), writtenAt: Date(),
coverageStart: existing.coverageStart,
coverageEnd: existing.coverageEnd,
isLoggedIn: existing.isLoggedIn,
writerVersion: writerVersion,
events: existing.events, events: existing.events,
todayColorHex: defaults.string(forKey: "todayColor") ?? existing.todayColorHex, theme: SnapshotTheme(
textColorHex: defaults.string(forKey: "textColor") ?? existing.textColorHex, today: defaults.string(forKey: "todayColor") ?? existing.theme.today,
backgroundColorHex: defaults.string(forKey: "backgroundColor") ?? existing.backgroundColorHex, text: defaults.string(forKey: "textColor") ?? existing.theme.text,
lineColorHex: defaults.string(forKey: "lineColor") ?? existing.lineColorHex, background: defaults.string(forKey: "backgroundColor") ?? existing.theme.background,
primaryColorHex: defaults.string(forKey: "primaryColor") ?? existing.primaryColorHex, line: defaults.string(forKey: "lineColor") ?? existing.theme.line,
accentColorHex: defaults.string(forKey: "accentColor") ?? existing.accentColorHex, primary: defaults.string(forKey: "primaryColor") ?? existing.theme.primary,
language: defaults.string(forKey: "appLanguage") ?? existing.language accent: defaults.string(forKey: "accentColor") ?? existing.theme.accent),
) language: defaults.string(forKey: "appLanguage") ?? existing.language)
write(updated) write(updated)
WidgetTimelineNotifier.reload() WidgetTimelineNotifier.reload()
} }
/// Build a snapshot from the app's current state. Kept here so the coverage
/// window and the writer version are stamped in exactly one place.
static func makeSnapshot(events: [WidgetEvent],
coverageStart: Date,
coverageEnd: Date) -> WidgetSnapshot {
let defaults = UserDefaults.standard
return WidgetSnapshot(
writtenAt: Date(),
coverageStart: coverageStart,
coverageEnd: coverageEnd,
isLoggedIn: true, // only ever written while signed in
writerVersion: writerVersion,
events: events,
theme: SnapshotTheme(
today: defaults.string(forKey: "todayColor") ?? "#4285f4",
text: defaults.string(forKey: "textColor") ?? "#FFFFFF",
background: defaults.string(forKey: "backgroundColor") ?? "#000000",
line: defaults.string(forKey: "lineColor") ?? "#3A3A52",
primary: defaults.string(forKey: "primaryColor") ?? "#4285f4",
accent: defaults.string(forKey: "accentColor") ?? "#ea4335"),
language: defaults.string(forKey: "appLanguage") ?? "system")
}
} }
enum WidgetTimelineNotifier { enum WidgetTimelineNotifier {