From 9fac13f99c571f6e8ddcc8cccf03a16737ff468d Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 31 May 2026 20:08:53 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20iOS=20Gruppen=20=E2=80=93=20Liste,=20Er?= =?UTF-8?q?stellen/Verwalten,=20kombinierte=20Ansicht?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Menรผ-Eintrag "Gruppen" -> GroupsView (Liste, Erstellen mit Icon-Auswahl + Mitglieder, Verwalten: umbenennen/Icon/Mitglieder/Mitglieder-Farben/lรถschen). - GroupCombinedView: monatsweise Agenda der รผberlagerten Mitglieder-Kalender + Gruppenkalender; Termine mit Besitzer-Vorname bzw. ๐Ÿ‘ฅ + Ersteller, server-definierte Farben (display_color). Co-Authored-By: Claude Opus 4.8 --- Calendarr iOS/Models/Localization.swift | 20 ++ Calendarr iOS/Views/GroupsView.swift | 365 ++++++++++++++++++++++++ Calendarr iOS/Views/MenuSheet.swift | 6 + 3 files changed, 391 insertions(+) create mode 100644 Calendarr iOS/Views/GroupsView.swift diff --git a/Calendarr iOS/Models/Localization.swift b/Calendarr iOS/Models/Localization.swift index 4dfed8f..ecd47d8 100644 --- a/Calendarr iOS/Models/Localization.swift +++ b/Calendarr iOS/Models/Localization.swift @@ -154,6 +154,16 @@ private let strings: [String: [String: String]] = [ "ics.import_result": "%d importiert, %d รผbersprungen", "common.info": "Info", "common.done": "Fertig", + "groups.title": "Gruppen", + "groups.none": "Noch keine Gruppen", + "groups.combined_empty": "Keine Termine in diesem Zeitraum", + "group.create": "Gruppe erstellen", + "group.manage": "Gruppe verwalten", + "group.name": "Name", + "group.icon": "Icon", + "group.members": "Mitglieder", + "group.member_colors": "Farben der Mitglieder", + "group.delete": "Gruppe lรถschen", "settings.hourheight": "Stundenhรถhe", "settings.hourheight.desc": "Platz pro Stunde in der Wochen- & Tagesansicht", @@ -443,6 +453,16 @@ private let strings: [String: [String: String]] = [ "ics.import_result": "%d imported, %d skipped", "common.info": "Info", "common.done": "Done", + "groups.title": "Groups", + "groups.none": "No groups yet", + "groups.combined_empty": "No events in this period", + "group.create": "Create group", + "group.manage": "Manage group", + "group.name": "Name", + "group.icon": "Icon", + "group.members": "Members", + "group.member_colors": "Member colours", + "group.delete": "Delete group", "settings.hourheight": "Hour height", "settings.hourheight.desc": "Space per hour in week & day view", diff --git a/Calendarr iOS/Views/GroupsView.swift b/Calendarr iOS/Views/GroupsView.swift new file mode 100644 index 0000000..7fd018e --- /dev/null +++ b/Calendarr iOS/Views/GroupsView.swift @@ -0,0 +1,365 @@ +import SwiftUI + +// MARK: - Groups list + +struct GroupsView: View { + let api: CalendarrAPI + @AppStorage("appLanguage") private var appLang = "system" + @State private var groups: [CalGroup] = [] + @State private var isLoading = true + @State private var showCreate = false + + var body: some View { + Group { + if isLoading { + ProgressView() + } else { + List { + if groups.isEmpty { + Text(L10n.t("groups.none", appLang)).foregroundStyle(.secondary) + } + ForEach(groups) { g in + NavigationLink { + GroupCombinedView(api: api, group: g) + } label: { + HStack { + Text(g.icon ?? "๐Ÿ‘ฅ") + Text(g.name) + Spacer() + if let n = g.memberCount { + Text("\(n)").font(.caption).foregroundStyle(.secondary) + } + } + } + .swipeActions { + NavigationLink { + GroupManageSheet(api: api, groupId: g.id) { await load() } + } label: { + Label(L10n.t("group.manage", appLang), systemImage: "slider.horizontal.3") + }.tint(.gray) + } + } + } + } + } + .navigationTitle(L10n.t("groups.title", appLang)) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { showCreate = true } label: { Image(systemName: "plus") } + } + } + .sheet(isPresented: $showCreate) { + GroupEditSheet(api: api, existing: nil) { await load() } + } + .task { await load() } + } + + private func load() async { + isLoading = true + groups = (try? await api.getGroups()) ?? [] + isLoading = false + } +} + +// MARK: - Create / edit a group (name + icon + members) + +struct GroupEditSheet: View { + let api: CalendarrAPI + let existing: CalGroup? + let onDone: () async -> Void + @Environment(\.dismiss) var dismiss + @AppStorage("appLanguage") private var appLang = "system" + + @State private var name = "" + @State private var icon = "๐Ÿ‘ฅ" + @State private var directory: [DirectoryUser] = [] + @State private var selected: Set = [] + @State private var error = "" + + private let icons = ["๐Ÿ‘ฅ", "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ง", "๐Ÿ ", "โค๏ธ", "๐Ÿง‘โ€๐Ÿคโ€๐Ÿง‘", "โšฝ", "๐ŸŽ“", "๐Ÿ’ผ", "๐ŸŽ‰", "๐Ÿถ", "โœˆ๏ธ", "๐ŸŽต", "๐Ÿ•", "๐Ÿ“š", "๐ŸŒณ", "โญ"] + private let cols = [GridItem(.adaptive(minimum: 46))] + + var body: some View { + NavigationStack { + Form { + Section(L10n.t("group.name", appLang)) { + TextField(L10n.t("group.name", appLang), text: $name) + } + Section(L10n.t("group.icon", appLang)) { + LazyVGrid(columns: cols, spacing: 8) { + ForEach(icons, id: \.self) { ic in + Text(ic).font(.title2) + .frame(width: 44, height: 44) + .background(ic == icon ? Color.accentColor.opacity(0.25) : Color(.systemGray6)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .onTapGesture { icon = ic } + } + } + } + Section(L10n.t("group.members", appLang)) { + ForEach(directory) { u in + Button { + if selected.contains(u.id) { selected.remove(u.id) } else { selected.insert(u.id) } + } label: { + HStack { + Image(systemName: selected.contains(u.id) ? "checkmark.square.fill" : "square") + .foregroundStyle(selected.contains(u.id) ? Color.accentColor : .secondary) + Text(u.displayName).foregroundStyle(.primary) + } + } + } + } + if !error.isEmpty { Section { Text(error).foregroundStyle(.red) } } + } + .navigationTitle(existing == nil ? L10n.t("group.create", appLang) : L10n.t("group.manage", appLang)) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button(L10n.t("common.cancel", appLang)) { dismiss() } } + ToolbarItem(placement: .primaryAction) { + Button(L10n.t("event.save", appLang)) { Task { await save() } } + .bold().disabled(name.isEmpty) + } + } + } + .task { directory = (try? await api.getUserDirectory()) ?? [] } + } + + private func save() async { + do { + _ = try await api.createGroup(name: name, memberIds: Array(selected), icon: icon) + await onDone() + dismiss() + } catch { self.error = error.localizedDescription } + } +} + +// MARK: - Manage existing group (rename, icon, members, colors, delete) + +struct GroupManageSheet: View { + let api: CalendarrAPI + let groupId: Int + let onDone: () async -> Void + @Environment(\.dismiss) var dismiss + @AppStorage("appLanguage") private var appLang = "system" + + @State private var group: CalGroup? + @State private var name = "" + @State private var icon = "๐Ÿ‘ฅ" + @State private var directory: [DirectoryUser] = [] + @State private var memberIds: Set = [] + @State private var showDeleteConfirm = false + @State private var error = "" + + private let icons = ["๐Ÿ‘ฅ", "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ง", "๐Ÿ ", "โค๏ธ", "๐Ÿง‘โ€๐Ÿคโ€๐Ÿง‘", "โšฝ", "๐ŸŽ“", "๐Ÿ’ผ", "๐ŸŽ‰", "๐Ÿถ", "โœˆ๏ธ", "๐ŸŽต", "๐Ÿ•", "๐Ÿ“š", "๐ŸŒณ", "โญ"] + private let cols = [GridItem(.adaptive(minimum: 46))] + + var body: some View { + NavigationStack { + Form { + Section(L10n.t("group.name", appLang)) { + TextField(L10n.t("group.name", appLang), text: $name) + } + Section(L10n.t("group.icon", appLang)) { + LazyVGrid(columns: cols, spacing: 8) { + ForEach(icons, id: \.self) { ic in + Text(ic).font(.title2) + .frame(width: 44, height: 44) + .background(ic == icon ? Color.accentColor.opacity(0.25) : Color(.systemGray6)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .onTapGesture { icon = ic } + } + } + } + Section(L10n.t("group.members", appLang)) { + ForEach(directory) { u in + Button { + if memberIds.contains(u.id) { memberIds.remove(u.id) } else { memberIds.insert(u.id) } + } label: { + HStack { + Image(systemName: memberIds.contains(u.id) ? "checkmark.square.fill" : "square") + .foregroundStyle(memberIds.contains(u.id) ? Color.accentColor : .secondary) + Text(u.displayName).foregroundStyle(.primary) + } + } + } + } + if let members = group?.members { + Section(L10n.t("group.member_colors", appLang)) { + ForEach(members) { m in + MemberColorRow(api: api, groupId: groupId, member: m) + } + } + } + Section { + Button(role: .destructive) { showDeleteConfirm = true } label: { + Label(L10n.t("group.delete", appLang), systemImage: "trash") + } + } + if !error.isEmpty { Section { Text(error).foregroundStyle(.red) } } + } + .navigationTitle(L10n.t("group.manage", appLang)) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button(L10n.t("common.cancel", appLang)) { dismiss() } } + ToolbarItem(placement: .primaryAction) { + Button(L10n.t("event.save", appLang)) { Task { await save() } }.bold() + } + } + .alert(L10n.t("group.delete", appLang), isPresented: $showDeleteConfirm) { + Button(L10n.t("group.delete", appLang), role: .destructive) { Task { await deleteGroup() } } + Button(L10n.t("common.cancel", appLang), role: .cancel) {} + } + } + .task { await load() } + } + + private func load() async { + directory = (try? await api.getUserDirectory()) ?? [] + if let g = try? await api.getGroup(id: groupId) { + group = g + name = g.name + icon = g.icon ?? "๐Ÿ‘ฅ" + let me = UserDefaults.standard.integer(forKey: "userId") + memberIds = Set((g.members ?? []).map { $0.id }.filter { $0 != me }) + } + } + + private func save() async { + do { + try await api.updateGroup(id: groupId, name: name, icon: icon) + let me = UserDefaults.standard.integer(forKey: "userId") + let current = Set((group?.members ?? []).map { $0.id }.filter { $0 != me }) + for id in memberIds where !current.contains(id) { try await api.addGroupMember(groupId: groupId, userId: id) } + for id in current where !memberIds.contains(id) { try await api.removeGroupMember(groupId: groupId, userId: id) } + await onDone() + dismiss() + } catch { self.error = error.localizedDescription } + } + + private func deleteGroup() async { + do { try await api.deleteGroup(id: groupId); await onDone(); dismiss() } + catch { self.error = error.localizedDescription } + } +} + +struct MemberColorRow: View { + let api: CalendarrAPI + let groupId: Int + let member: GroupMember + @State private var color: Color + + init(api: CalendarrAPI, groupId: Int, member: GroupMember) { + self.api = api; self.groupId = groupId; self.member = member + _color = State(initialValue: Color(hex: member.color ?? "#4285f4")) + } + + var body: some View { + HStack { + Text(member.displayName ?? "โ€”") + Spacer() + ColorPicker("", selection: $color, supportsOpacity: false) + .labelsHidden() + .onChange(of: color) { _, c in + Task { try? await api.setGroupMemberColor(groupId: groupId, userId: member.id, color: c.toHex()) } + } + } + } +} + +// MARK: - Combined (overlay) agenda view + +struct GroupCombinedView: View { + let api: CalendarrAPI + let group: CalGroup + @AppStorage("appLanguage") private var appLang = "system" + + @State private var anchor = Date() + @State private var events: [CalEvent] = [] + @State private var isLoading = false + + private var monthRange: (Date, Date) { + let cal = Calendar.current + let start = cal.date(from: cal.dateComponents([.year, .month], from: anchor)) ?? anchor + let end = cal.date(byAdding: .month, value: 1, to: start) ?? anchor + return (start, end) + } + + private var grouped: [(day: Date, items: [CalEvent])] { + let cal = Calendar.current + let dict = Dictionary(grouping: events.sorted { $0.startDate < $1.startDate }) { + cal.startOfDay(for: $0.startDate) + } + return dict.keys.sorted().map { ($0, dict[$0] ?? []) } + } + + private let monthFmt: DateFormatter = { + let f = DateFormatter(); f.dateFormat = "MMMM yyyy"; return f + }() + private let dayFmt: DateFormatter = { + let f = DateFormatter(); f.dateFormat = "EEEE, d. MMM"; return f + }() + private let timeFmt: DateFormatter = { + let f = DateFormatter(); f.timeStyle = .short; f.dateStyle = .none; return f + }() + + var body: some View { + List { + ForEach(grouped, id: \.day) { section in + Section(dayFmt.string(from: section.day)) { + ForEach(section.items) { ev in + HStack(spacing: 10) { + RoundedRectangle(cornerRadius: 3) + .fill(Color(hex: ev.effectiveColor)) + .frame(width: 5, height: 34) + VStack(alignment: .leading, spacing: 2) { + Text(displayTitle(ev)).font(.body) + Text(ev.isAllDay ? L10n.t("event.allday", appLang) : timeFmt.string(from: ev.startDate)) + .font(.caption).foregroundStyle(.secondary) + } + } + } + } + } + if !isLoading && events.isEmpty { + Text(L10n.t("groups.combined_empty", appLang)).foregroundStyle(.secondary) + } + } + .navigationTitle("\(group.icon ?? "๐Ÿ‘ฅ") \(group.name)") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + HStack { + Button { shift(-1) } label: { Image(systemName: "chevron.left") } + Button { shift(1) } label: { Image(systemName: "chevron.right") } + } + } + ToolbarItem(placement: .principal) { + Text(monthFmt.string(from: anchor)).font(.headline) + } + } + .task(id: anchor) { await load() } + } + + // Prefix others' events with their first name; group events with ๐Ÿ‘ฅ + creator. + private func displayTitle(_ ev: CalEvent) -> String { + let me = UserDefaults.standard.integer(forKey: "userId") + if ev.isGroupEvent { + if let c = ev.creator, c.id != me { return "๐Ÿ‘ฅ \(firstName(c.displayName)): \(ev.title)" } + return "๐Ÿ‘ฅ \(ev.title)" + } + if let o = ev.owner, o.id != me { return "\(firstName(o.displayName)): \(ev.title)" } + return ev.title + } + private func firstName(_ s: String) -> String { s.split(separator: " ").first.map(String.init) ?? s } + + private func shift(_ months: Int) { + anchor = Calendar.current.date(byAdding: .month, value: months, to: anchor) ?? anchor + } + + private func load() async { + isLoading = true + let (s, e) = monthRange + events = (try? await api.fetchGroupCombined(groupId: group.id, start: s, end: e)) ?? [] + isLoading = false + } +} diff --git a/Calendarr iOS/Views/MenuSheet.swift b/Calendarr iOS/Views/MenuSheet.swift index 032bd5f..16b6881 100644 --- a/Calendarr iOS/Views/MenuSheet.swift +++ b/Calendarr iOS/Views/MenuSheet.swift @@ -62,6 +62,12 @@ struct MenuSheet: View { Label(L10n.t("menu.accounts", appLang), systemImage: "tray.2") } + NavigationLink { + GroupsView(api: api) + } label: { + Label(L10n.t("groups.title", appLang), systemImage: "person.2") + } + NavigationLink { ServerView() } label: {