feat: iOS Gruppen – Liste, Erstellen/Verwalten, kombinierte Ansicht

- Menü-Eintrag "Gruppen" -> GroupsView (Liste, Erstellen mit Icon-Auswahl +
  Mitglieder, Verwalten: umbenennen/Icon/Mitglieder/Mitglieder-Farben/löschen).
- GroupCombinedView: monatsweise Agenda der überlagerten Mitglieder-Kalender
  + Gruppenkalender; Termine mit Besitzer-Vorname bzw. 👥 + Ersteller,
  server-definierte Farben (display_color).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-05-31 20:08:53 +02:00
parent da2e39911c
commit 9fac13f99c
3 changed files with 391 additions and 0 deletions

View File

@@ -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",

View File

@@ -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<Int> = []
@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<Int> = []
@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
}
}

View File

@@ -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: {