From 6dc8724a9a2a581a1b76cd46f675a82a71f01648 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 31 May 2026 20:55:28 +0200 Subject: [PATCH] feat: iOS Gruppenansicht direkt im Kalender (Umschalter + Banner) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gruppen sind nicht mehr nur im Menü versteckt: im Top-Bar gibt es einen Gruppen-Umschalter (Persönlich / ). Beim Wählen einer Gruppe zeigt der echte Monats-/Wochen-/Tagesansicht die kombinierte Überlagerung (GET /groups/{id}/combined) mit server-definierten Farben und Besitzer-Präfix; ein Banner "Gruppenansicht: " mit "Verlassen". CalendarStore.activeGroup steuert den Modus. Co-Authored-By: Claude Opus 4.8 --- Calendarr iOS/Models/CalendarStore.swift | 32 +++++++++++++ Calendarr iOS/Models/Localization.swift | 6 +++ .../Views/Calendar/CalendarHostView.swift | 48 +++++++++++++++++++ 3 files changed, 86 insertions(+) diff --git a/Calendarr iOS/Models/CalendarStore.swift b/Calendarr iOS/Models/CalendarStore.swift index 46b29fb..8832d73 100644 --- a/Calendarr iOS/Models/CalendarStore.swift +++ b/Calendarr iOS/Models/CalendarStore.swift @@ -56,6 +56,9 @@ class CalendarStore { var lastError: String? = nil var weekStartsOnMonday = true var writableCalendars: [WritableCalendar] = [] + // When set, the calendar shows the group's combined overlay instead of the + // user's own events. nil = personal view. + var activeGroup: CalGroup? = nil /// Set of `"source:calendarId"` keys the user has chosen to hide from the /// calendar views. Persisted in UserDefaults as a JSON array. Events whose @@ -239,6 +242,20 @@ class CalendarStore { /// unless `force` is set (used after create/edit to pull fresh server data /// for the visible range, bypassing the cache). func loadEvents(api: CalendarrAPI, start: Date, end: Date, force: Bool = false) async { + // Group overlay mode: always fetch the combined view for the range + // (no personal cache), decorating each event with its owner. + if let g = activeGroup { + isLoading = true + lastError = nil + defer { isLoading = false } + do { + let fetched = try await api.fetchGroupCombined(groupId: g.id, start: start, end: end) + events = fetched.map { decorateGroupEvent($0) } + } catch { + lastError = error.localizedDescription + } + return + } if !force, isCached(start: start, end: end) { refreshFromCache(start: start, end: end) return @@ -255,6 +272,21 @@ class CalendarStore { } } + /// Prefix a combined-view event with its owner (others) or 👥 + creator + /// (group calendar). Colour comes from the server's display_color. + private func decorateGroupEvent(_ ev: CalEvent) -> CalEvent { + var e = ev + let me = UserDefaults.standard.integer(forKey: "userId") + func first(_ s: String) -> String { s.split(separator: " ").first.map(String.init) ?? s } + if ev.isGroupEvent { + if let c = ev.creator, c.id != me { e.title = "👥 \(first(c.displayName)): \(ev.title)" } + else { e.title = "👥 \(ev.title)" } + } else if let o = ev.owner, o.id != me { + e.title = "\(first(o.displayName)): \(ev.title)" + } + return e + } + /// Background prefetch for ±months around today – called once on startup. func prefetchBackground(api: CalendarrAPI, months: Int) async { let cal = userCalendar diff --git a/Calendarr iOS/Models/Localization.swift b/Calendarr iOS/Models/Localization.swift index 71f98e5..1be1469 100644 --- a/Calendarr iOS/Models/Localization.swift +++ b/Calendarr iOS/Models/Localization.swift @@ -154,6 +154,9 @@ private let strings: [String: [String: String]] = [ "common.info": "Info", "common.done": "Fertig", "groups.title": "Gruppen", + "groups.personal": "Persönlich", + "groups.view_label": "Gruppenansicht", + "groups.exit": "Verlassen", "groups.none": "Noch keine Gruppen", "groups.combined_empty": "Keine Termine in diesem Zeitraum", "group.create": "Gruppe erstellen", @@ -452,6 +455,9 @@ private let strings: [String: [String: String]] = [ "common.info": "Info", "common.done": "Done", "groups.title": "Groups", + "groups.personal": "Personal", + "groups.view_label": "Group view", + "groups.exit": "Exit", "groups.none": "No groups yet", "groups.combined_empty": "No events in this period", "group.create": "Create group", diff --git a/Calendarr iOS/Views/Calendar/CalendarHostView.swift b/Calendarr iOS/Views/Calendar/CalendarHostView.swift index 19aaf48..0ac3616 100644 --- a/Calendarr iOS/Views/Calendar/CalendarHostView.swift +++ b/Calendarr iOS/Views/Calendar/CalendarHostView.swift @@ -30,6 +30,7 @@ struct CalendarHostView: View { @State private var visibleMonth: Date = .now @State private var showFilter = false @State private var didApplyDefaultView = false + @State private var groups: [CalGroup] = [] private var titleString: String { if store.viewType == .month { @@ -67,6 +68,7 @@ struct CalendarHostView: View { private var flatVariant: some View { VStack(spacing: 0) { topBar + groupBanner Divider() errorBanner calendarContent @@ -127,6 +129,7 @@ struct CalendarHostView: View { } ToolbarItem(placement: .navigationBarTrailing) { HStack(spacing: 8) { + groupMenu viewPickerMenu Button { showFilter = true } label: { Image(systemName: "line.3.horizontal.decrease.circle") @@ -137,6 +140,7 @@ struct CalendarHostView: View { } } } + .safeAreaInset(edge: .top) { groupBanner } } .overlay(alignment: .bottomTrailing) { glassFAB } .modifier(calendarSheets) @@ -182,6 +186,7 @@ struct CalendarHostView: View { .lineLimit(1) .minimumScaleFactor(0.7) Spacer(minLength: 8) + groupMenu viewPickerMenu filterButton Button { showMenu = true } label: { @@ -205,6 +210,48 @@ struct CalendarHostView: View { .accessibilityLabel(L10n.t("filter.button", appLang)) } + // Group switcher: "Persönlich" + each group. Selecting a group flips the + // calendar into the combined overlay (like the web's group view). + private var groupMenu: some View { + 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.icon ?? "👥") \(g.name)", + systemImage: store.activeGroup?.id == g.id ? "checkmark" : "person.2") + } + } + } label: { + Image(systemName: store.activeGroup == nil ? "person.2" : "person.2.fill") + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(store.activeGroup == nil ? .primary : Color.accentColor) + .frame(width: 40, height: 40) + } + .accessibilityLabel(L10n.t("groups.title", appLang)) + } + + @ViewBuilder private var groupBanner: some View { + if let g = store.activeGroup { + HStack(spacing: 8) { + Text("\(L10n.t("groups.view_label", appLang)): \(g.icon ?? "👥") \(g.name)") + .font(.subheadline).lineLimit(1) + Spacer() + Button(L10n.t("groups.exit", appLang)) { switchGroup(nil) } + .font(.callout) + } + .padding(.horizontal, 12).padding(.vertical, 7) + .background(Color.accentColor.opacity(0.18)) + } + } + + private func switchGroup(_ g: CalGroup?) { + store.activeGroup = g + Task { await onNavigate() } + } + private var viewPickerMenu: some View { Menu { ForEach(CalViewType.allCases, id: \.self) { vt in @@ -352,6 +399,7 @@ struct CalendarHostView: View { applyServerDrivenSettings(initial: true) await store.loadWritableCalendars(api: api) + groups = (try? await api.getGroups()) ?? [] // 1. Load current view immediately (visible) let (s, e) = store.rangeForCurrentView() await store.loadEvents(api: api, start: s, end: e)