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:
@@ -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",
|
||||
|
||||
365
Calendarr iOS/Views/GroupsView.swift
Normal file
365
Calendarr iOS/Views/GroupsView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user