feat: iOS Gruppenansicht direkt im Kalender (Umschalter + Banner)
Gruppen sind nicht mehr nur im Menü versteckt: im Top-Bar gibt es einen
Gruppen-Umschalter (Persönlich / <Gruppe>). Beim Wählen einer Gruppe zeigt
der echte Monats-/Wochen-/Tagesansicht die kombinierte Überlagerung
(GET /groups/{id}/combined) mit server-definierten Farben und
Besitzer-Präfix; ein Banner "Gruppenansicht: <Name>" mit "Verlassen".
CalendarStore.activeGroup steuert den Modus.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -56,6 +56,9 @@ class CalendarStore {
|
|||||||
var lastError: String? = nil
|
var lastError: String? = nil
|
||||||
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
|
||||||
|
// user's own events. nil = personal view.
|
||||||
|
var activeGroup: CalGroup? = nil
|
||||||
|
|
||||||
/// Set of `"source:calendarId"` keys the user has chosen to hide from the
|
/// Set of `"source:calendarId"` keys the user has chosen to hide from the
|
||||||
/// calendar views. Persisted in UserDefaults as a JSON array. Events whose
|
/// 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
|
/// unless `force` is set (used after create/edit to pull fresh server data
|
||||||
/// for the visible range, bypassing the cache).
|
/// for the visible range, bypassing the cache).
|
||||||
func loadEvents(api: CalendarrAPI, start: Date, end: Date, force: Bool = false) async {
|
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) {
|
if !force, isCached(start: start, end: end) {
|
||||||
refreshFromCache(start: start, end: end)
|
refreshFromCache(start: start, end: end)
|
||||||
return
|
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.
|
/// Background prefetch for ±months around today – called once on startup.
|
||||||
func prefetchBackground(api: CalendarrAPI, months: Int) async {
|
func prefetchBackground(api: CalendarrAPI, months: Int) async {
|
||||||
let cal = userCalendar
|
let cal = userCalendar
|
||||||
|
|||||||
@@ -154,6 +154,9 @@ private let strings: [String: [String: String]] = [
|
|||||||
"common.info": "Info",
|
"common.info": "Info",
|
||||||
"common.done": "Fertig",
|
"common.done": "Fertig",
|
||||||
"groups.title": "Gruppen",
|
"groups.title": "Gruppen",
|
||||||
|
"groups.personal": "Persönlich",
|
||||||
|
"groups.view_label": "Gruppenansicht",
|
||||||
|
"groups.exit": "Verlassen",
|
||||||
"groups.none": "Noch keine Gruppen",
|
"groups.none": "Noch keine Gruppen",
|
||||||
"groups.combined_empty": "Keine Termine in diesem Zeitraum",
|
"groups.combined_empty": "Keine Termine in diesem Zeitraum",
|
||||||
"group.create": "Gruppe erstellen",
|
"group.create": "Gruppe erstellen",
|
||||||
@@ -452,6 +455,9 @@ private let strings: [String: [String: String]] = [
|
|||||||
"common.info": "Info",
|
"common.info": "Info",
|
||||||
"common.done": "Done",
|
"common.done": "Done",
|
||||||
"groups.title": "Groups",
|
"groups.title": "Groups",
|
||||||
|
"groups.personal": "Personal",
|
||||||
|
"groups.view_label": "Group view",
|
||||||
|
"groups.exit": "Exit",
|
||||||
"groups.none": "No groups yet",
|
"groups.none": "No groups yet",
|
||||||
"groups.combined_empty": "No events in this period",
|
"groups.combined_empty": "No events in this period",
|
||||||
"group.create": "Create group",
|
"group.create": "Create group",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ struct CalendarHostView: View {
|
|||||||
@State private var visibleMonth: Date = .now
|
@State private var visibleMonth: Date = .now
|
||||||
@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] = []
|
||||||
|
|
||||||
private var titleString: String {
|
private var titleString: String {
|
||||||
if store.viewType == .month {
|
if store.viewType == .month {
|
||||||
@@ -67,6 +68,7 @@ struct CalendarHostView: View {
|
|||||||
private var flatVariant: some View {
|
private var flatVariant: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
topBar
|
topBar
|
||||||
|
groupBanner
|
||||||
Divider()
|
Divider()
|
||||||
errorBanner
|
errorBanner
|
||||||
calendarContent
|
calendarContent
|
||||||
@@ -127,6 +129,7 @@ struct CalendarHostView: View {
|
|||||||
}
|
}
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
|
groupMenu
|
||||||
viewPickerMenu
|
viewPickerMenu
|
||||||
Button { showFilter = true } label: {
|
Button { showFilter = true } label: {
|
||||||
Image(systemName: "line.3.horizontal.decrease.circle")
|
Image(systemName: "line.3.horizontal.decrease.circle")
|
||||||
@@ -137,6 +140,7 @@ struct CalendarHostView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.safeAreaInset(edge: .top) { groupBanner }
|
||||||
}
|
}
|
||||||
.overlay(alignment: .bottomTrailing) { glassFAB }
|
.overlay(alignment: .bottomTrailing) { glassFAB }
|
||||||
.modifier(calendarSheets)
|
.modifier(calendarSheets)
|
||||||
@@ -182,6 +186,7 @@ struct CalendarHostView: View {
|
|||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.minimumScaleFactor(0.7)
|
.minimumScaleFactor(0.7)
|
||||||
Spacer(minLength: 8)
|
Spacer(minLength: 8)
|
||||||
|
groupMenu
|
||||||
viewPickerMenu
|
viewPickerMenu
|
||||||
filterButton
|
filterButton
|
||||||
Button { showMenu = true } label: {
|
Button { showMenu = true } label: {
|
||||||
@@ -205,6 +210,48 @@ struct CalendarHostView: View {
|
|||||||
.accessibilityLabel(L10n.t("filter.button", appLang))
|
.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 {
|
private var viewPickerMenu: some View {
|
||||||
Menu {
|
Menu {
|
||||||
ForEach(CalViewType.allCases, id: \.self) { vt in
|
ForEach(CalViewType.allCases, id: \.self) { vt in
|
||||||
@@ -352,6 +399,7 @@ struct CalendarHostView: View {
|
|||||||
applyServerDrivenSettings(initial: true)
|
applyServerDrivenSettings(initial: true)
|
||||||
|
|
||||||
await store.loadWritableCalendars(api: api)
|
await store.loadWritableCalendars(api: api)
|
||||||
|
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()
|
||||||
await store.loadEvents(api: api, start: s, end: e)
|
await store.loadEvents(api: api, start: s, end: e)
|
||||||
|
|||||||
Reference in New Issue
Block a user