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 } }