import SwiftUI private enum NativeDesign { static let accent = Color(red: 108 / 255, green: 58 / 255, blue: 237 / 255) static func background(_ dark: Bool) -> Color { dark ? Color(red: 25 / 255, green: 24 / 255, blue: 22 / 255) : Color(red: 245 / 255, green: 243 / 255, blue: 237 / 255) } static func surface(_ dark: Bool) -> Color { dark ? Color(red: 43 / 255, green: 40 / 255, blue: 37 / 255) : .white } } private struct NativeDraft: Identifiable { let id = UUID() let recordID: Int? let page: NativePage var title: String var detail: String var priority = "none" var date = "" } struct NativeRootView: View { @StateObject private var store = NativeStore() @Environment(\.colorScheme) private var scheme @Environment(\.scenePhase) private var phase @State private var draft: NativeDraft? @State private var forget = false @State private var deletePath: String? var body: some View { ZStack { NativeDesign.background(scheme == .dark).ignoresSafeArea() if store.user == nil { authentication } else { application } if phase != .active { NativeDesign.background(scheme == .dark).ignoresSafeArea() NativeBrandMark().frame(width: 64, height: 64) } } .tint(NativeDesign.accent) .task { await store.restore() } .sheet(item: $draft) { entry in NativeEditor(draft: entry, busy: store.busy, error: store.error) { body in let path: String let method: String switch entry.page { case .tasks: path = entry.recordID.map { "/tasks/\($0)" } ?? "/tasks" method = entry.recordID == nil ? "POST" : "PUT" case .notes: path = entry.recordID.map { "/notes/\($0)" } ?? "/notes" method = entry.recordID == nil ? "POST" : "PUT" case .shopping: path = entry.recordID.map { "/shopping/items/\($0)" } ?? store.selectedList.map { "/shopping/\($0.id)/items" } ?? "/shopping" method = entry.recordID == nil ? "POST" : "PATCH" default: return false } return await store.mutate(path, method: method, body: body) } } .alert("Server entfernen?", isPresented: $forget) { Button("Lokal entfernen", role: .destructive) { Task { await store.forget() } } Button("Abbrechen", role: .cancel) {} } message: { Text( "Die native Sitzung und geladene Inhalte werden lokal gelöscht. Melde dich möglichst zuerst ab, um die Sitzung auch auf dem Server zu widerrufen." ) } .alert( "Eintrag löschen?", isPresented: Binding(get: { deletePath != nil }, set: { if !$0 { deletePath = nil } }) ) { Button("Löschen", role: .destructive) { if let path = deletePath { Task { _ = await store.mutate(path, method: "DELETE") } } deletePath = nil } Button("Abbrechen", role: .cancel) { deletePath = nil } } message: { Text("Der Eintrag wird auf deinem Server gelöscht.") } } private var authentication: some View { NativeAuthenticationScreen(store: store, forget: { forget = true }) } private var application: some View { NavigationStack { ScrollView { VStack(alignment: .leading, spacing: 16) { if store.busy { ProgressView().frame(maxWidth: .infinity) } if let error = store.error { Text(error).foregroundStyle(.red).accessibilityIdentifier("native-error") } switch store.page { case .home: dashboard case .tasks: taskList case .notes: noteList case .shopping: shopping case .more: more } } .padding(16) .frame(maxWidth: 760) .frame(maxWidth: .infinity) } .background(NativeDesign.background(scheme == .dark)) .navigationTitle(store.selectedList?.name ?? store.page.title) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("Aktualisieren", systemImage: "arrow.clockwise") { Task { await store.refresh() } }.disabled( store.busy) } if [.tasks, .notes, .shopping].contains(store.page), store.permissions?.access(store.page.rawValue) == "write" { ToolbarItem(placement: .topBarTrailing) { Button("Hinzufügen", systemImage: "plus") { draft = NativeDraft(recordID: nil, page: store.page, title: "", detail: "") }.disabled(store.busy) } } } .safeAreaInset(edge: .bottom) { HStack(spacing: 0) { ForEach( NativePage.allCases.filter { [.home, .more].contains($0) || store.permissions?.access($0.rawValue) != "none" } ) { page in Button { Task { await store.navigate(page) } } label: { VStack(spacing: 4) { Image(systemName: page.symbol).font(.system(size: 21)) Text(page.title).font(.system(size: 11)) } .foregroundStyle(store.page == page ? NativeDesign.accent : .secondary) .frame(maxWidth: .infinity).padding(.vertical, 10) } .disabled(store.busy).accessibilityIdentifier("nav-\(page.rawValue)") } } .padding(6).background(.regularMaterial, in: Capsule()).shadow( color: .black.opacity(0.08), radius: 12, y: 4 ).padding(.horizontal, 12).padding(.bottom, 8) } } } @ViewBuilder private var dashboard: some View { VStack(alignment: .leading, spacing: 6) { Text("Hallo, \(store.user?.displayName ?? "")").font(.title2.bold()) Text(Date.now, format: .dateTime.weekday(.wide).day().month(.wide)).foregroundStyle(.secondary) } if let data = store.dashboard { if store.permissions?.access("tasks") != "none", store.permissions?.showsWidget("tasks") == true { NativeCard("Aufgaben", subtitle: "\(data.openTaskCount.map(String.init) ?? "—") offen") { if data.urgentTasks.isEmpty { empty("Keine dringenden Aufgaben") } ForEach(data.urgentTasks) { item in row(item.title, detail: item.dueDate ?? "") } Button("Alle Aufgaben") { Task { await store.navigate(.tasks) } } } } if store.permissions?.access("calendar") != "none", store.permissions?.showsWidget("calendar") == true { NativeCard("Kalender", subtitle: "Nächste Termine") { if data.upcomingEvents.isEmpty { empty("Keine anstehenden Termine") } ForEach(data.upcomingEvents) { item in row( item.title, detail: String(item.startDatetime.replacingOccurrences(of: "T", with: " ").prefix(16))) } } } if store.permissions?.access("shopping") != "none", store.permissions?.showsWidget("shopping") == true { NativeCard("Einkauf", subtitle: "\(data.shoppingOpenCount.map(String.init) ?? "—") offene Artikel") { if data.shoppingLists.isEmpty { empty("Alles eingekauft") } ForEach(data.shoppingLists) { item in row(item.name, detail: "\(item.openCount ?? 0) offen") } Button("Alle Listen") { Task { await store.navigate(.shopping) } } } } if store.permissions?.access("notes") != "none", store.permissions?.showsWidget("notes") == true { NativeCard("Notizen", subtitle: "Angeheftet") { if data.pinnedNotes.isEmpty { empty("Noch keine Notizen") } ForEach(data.pinnedNotes) { item in row(item.title ?? "Notiz", detail: String(item.content.prefix(140))) } Button("Alle Notizen") { Task { await store.navigate(.notes) } } } } } } @State private var search = "" @State private var taskStatus = "open" private var canWrite: Bool { !store.busy && store.permissions?.access(store.page.rawValue) == "write" } @ViewBuilder private var taskList: some View { TextField("Suchen", text: $search).textFieldStyle(.roundedBorder) Picker("Status", selection: $taskStatus) { Text("Offen").tag("open") Text("Erledigt").tag("done") Text("Alle").tag("all") }.pickerStyle(.segmented) let filtered = store.tasks.filter { (search.isEmpty || ($0.title + ($0.description ?? "")).localizedCaseInsensitiveContains(search)) && (taskStatus == "all" || (taskStatus == "done" ? $0.status == "done" : $0.status != "done")) } if filtered.isEmpty && !store.busy { empty("Hier gibt es noch keine passenden Aufgaben.") } ForEach(filtered) { item in NativeCard { HStack { Button { Task { _ = await store.mutate( "/tasks/\(item.id)/status", method: "PATCH", body: ["status": .string(item.status == "done" ? "open" : "done")]) } } label: { Image(systemName: item.status == "done" ? "checkmark.circle.fill" : "circle").font(.title2) }.disabled(!canWrite).accessibilityLabel(item.status == "done" ? "Wieder öffnen" : "Erledigen") Button { draft = NativeDraft( recordID: item.id, page: .tasks, title: item.title, detail: item.description ?? "", priority: item.priority, date: item.dueDate ?? "") } label: { row( item.title, detail: [item.category, item.dueDate].compactMap { $0 }.joined(separator: " · ")) }.buttonStyle(.plain).disabled(!canWrite) Spacer() deleteButton("/tasks/\(item.id)") } } } } @ViewBuilder private var noteList: some View { TextField("Suchen", text: $search).textFieldStyle(.roundedBorder) let filtered = store.notes.filter { search.isEmpty || (($0.title ?? "") + $0.content).localizedCaseInsensitiveContains(search) } if filtered.isEmpty && !store.busy { empty("Hier gibt es noch keine passenden Notizen.") } ForEach(filtered) { item in NativeCard { Button { draft = NativeDraft(recordID: item.id, page: .notes, title: item.title ?? "", detail: item.content) } label: { row(item.title ?? "Notiz", detail: item.content) }.buttonStyle(.plain).disabled(!canWrite) HStack { Button(item.pinned == 1 ? "Lösen" : "Anheften") { Task { _ = await store.mutate("/notes/\(item.id)/pin", method: "PATCH") } }.disabled(!canWrite) Spacer() deleteButton("/notes/\(item.id)") } } } } @ViewBuilder private var shopping: some View { if store.selectedList != nil { Button("Alle Listen") { Task { await store.navigate(.shopping) } }.disabled(store.busy) if store.items.isEmpty && !store.busy { empty("Diese Liste ist noch leer.") } ForEach(store.items) { item in NativeCard { HStack { Button { Task { _ = await store.mutate( "/shopping/items/\(item.id)", method: "PATCH", body: ["is_checked": .number(item.isChecked == 1 ? 0 : 1)]) } } label: { Image(systemName: item.isChecked == 1 ? "checkmark.circle.fill" : "circle").font(.title2) }.disabled(!canWrite).accessibilityLabel(item.isChecked == 1 ? "Wieder öffnen" : "Abhaken") Button { draft = NativeDraft( recordID: item.id, page: .shopping, title: item.name, detail: item.quantity ?? "") } label: { row(item.name, detail: item.quantity ?? "") }.buttonStyle(.plain).disabled(!canWrite) Spacer() deleteButton("/shopping/items/\(item.id)") } } } } else { if store.lists.isEmpty && !store.busy { empty("Hier gibt es noch keine Einkaufslisten.") } ForEach(store.lists) { list in NativeCard { HStack { Button { Task { await store.open(list) } } label: { row(list.name, detail: "\(list.itemTotal ?? 0) Artikel") }.buttonStyle(.plain).disabled(store.busy) Spacer() deleteButton("/shopping/\(list.id)") } } } } } private var more: some View { VStack(spacing: 16) { NativeCard("Dein Haushalt", subtitle: store.appName) { Text(store.user?.displayName ?? "") Text(store.server).foregroundStyle(.secondary) } NativeCard("Konto") { Button("Abmelden") { Task { await store.logout() } }.disabled(store.busy) Button("Server und lokale Sitzung entfernen", role: .destructive) { forget = true }.disabled(store.busy) } NativeCard("Nativer Neubau", subtitle: "Noch keine vollständige PWA-Parität") { Text( "Die weiteren Module und Einstellungen werden separat nativ nachgebaut. Es gibt keinen Browser-Fallback." ) } } } private func deleteButton(_ path: String) -> some View { Button("Löschen", systemImage: "trash", role: .destructive) { deletePath = path }.labelStyle(.iconOnly) .disabled(!canWrite) } private func row(_ title: String, detail: String) -> some View { VStack(alignment: .leading, spacing: 4) { Text(title).fontWeight(.semibold) if !detail.isEmpty { Text(detail).font(.subheadline).foregroundStyle(.secondary) } }.frame(maxWidth: .infinity, alignment: .leading) } private func empty(_ text: String) -> some View { Text(text).foregroundStyle(.secondary).padding(.vertical, 16).frame(maxWidth: .infinity) } } private struct NativeAuthenticationScreen: View { @ObservedObject var store: NativeStore let forget: () -> Void @State private var address = "" @State private var username = "" @State private var password = "" @State private var name = "" @State private var code = "" @State private var invalidAddress = false @State private var sso = false var body: some View { ScrollView { VStack(spacing: 16) { NativeCard { VStack(spacing: 16) { NativeBrandMark().frame(width: 64, height: 64).accessibilityHidden(true) Text(store.appName).font(.system(size: 34, weight: .bold)) Text( store.server.isEmpty ? "Dein Zuhause. Dein Server." : "Alles für euren Alltag an einem Ort." ).foregroundStyle(.secondary).multilineTextAlignment(.center) if store.server.isEmpty { TextField("Server-Adresse", text: $address).keyboardType(.URL).textContentType(.URL) .textInputAutocapitalization(.never).autocorrectionDisabled().textFieldStyle( .roundedBorder ).accessibilityIdentifier("server-url") if invalidAddress { Text("Bitte eine gültige HTTPS-Adresse ohne Unterpfad eingeben.").foregroundStyle(.red) .accessibilityIdentifier("invalid-server") } Text("HTTPS · eigener Server · keine zentrale Cloud").font(.caption).foregroundStyle( .secondary) Button("Verbinden") { guard (try? ServerAddress(address)) != nil else { invalidAddress = true return } invalidAddress = false Task { await store.connect(address) } }.buttonStyle(.borderedProminent).disabled(store.busy || address.isEmpty) .accessibilityIdentifier("connect") } else if store.twoFactor { TextField("Code oder Wiederherstellungscode", text: $code).textContentType(.oneTimeCode) .textFieldStyle(.roundedBorder) Button("Bestätigen") { let value = code code = "" Task { await store.verify(value) } }.buttonStyle(.borderedProminent).disabled(store.busy || code.isEmpty) Button("Zurück zur Anmeldung") { store.cancelTwoFactor() }.disabled(store.busy) } else { if store.setup { TextField("Anzeigename", text: $name).textFieldStyle(.roundedBorder) } if store.passwordAllowed || store.setup { TextField("Benutzername", text: $username).textContentType(.username) .textInputAutocapitalization(.never).autocorrectionDisabled().textFieldStyle( .roundedBorder ).accessibilityIdentifier("username") SecureField("Passwort", text: $password).textContentType(.password).textFieldStyle( .roundedBorder ).accessibilityIdentifier("password") Button(store.setup ? "Haushalt einrichten" : "Anmelden") { let submitted = password password = "" Task { await store.login(username: username, password: submitted, displayName: name) } }.buttonStyle(.borderedProminent).disabled( store.busy || username.isEmpty || password.isEmpty ).accessibilityIdentifier("login") } if store.oidc { Button("SSO – noch in Umsetzung") { sso = true } } Button("Anderen Server verwenden", action: forget).disabled(store.busy) } if store.busy { ProgressView() } if let error = store.error { Text(error).foregroundStyle(.red).accessibilityIdentifier("auth-error") } } .padding(8) } .frame(maxWidth: 412) } .padding(16).frame(maxWidth: .infinity) .containerRelativeFrame(.vertical) { height, _ in max(height, 500) } } .alert("Native Anmeldung", isPresented: $sso) { Button("Verstanden", role: .cancel) {} } message: { Text( "SSO wird für den nativen API-Client noch nicht unterstützt. Es wird keine Browser-Sitzung als native Anmeldung vorgetäuscht." ) } } } private struct NativeEditor: View { @Environment(\.dismiss) private var dismiss @State var draft: NativeDraft let busy: Bool let error: String? let save: ([String: NativeValue]) async -> Bool var body: some View { NavigationStack { Form { TextField(draft.page == .shopping ? "Name" : "Titel", text: $draft.title) TextField(draft.page == .shopping ? "Menge" : "Inhalt", text: $draft.detail, axis: .vertical).lineLimit( 3...10) if draft.page == .tasks { Picker("Priorität", selection: $draft.priority) { Text("Keine").tag("none") Text("Niedrig").tag("low") Text("Mittel").tag("medium") Text("Hoch").tag("high") Text("Dringend").tag("urgent") } TextField("Fällig am (JJJJ-MM-TT)", text: $draft.date) } if let error { Text(error).foregroundStyle(.red) } } .navigationTitle(draft.recordID == nil ? "Hinzufügen" : "Bearbeiten").navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Abbrechen") { dismiss() }.disabled(busy) } ToolbarItem(placement: .confirmationAction) { Button("Speichern") { var body: [String: NativeValue] = [ draft.page == .shopping ? "name" : "title": .string(draft.title) ] if draft.page == .notes { body["content"] = .string(draft.detail) } if draft.page == .tasks { body["description"] = .string(draft.detail) body["priority"] = .string(draft.priority) body["due_date"] = draft.date.isEmpty ? .null : .string(draft.date) } if draft.page == .shopping { body["quantity"] = .string(draft.detail) } Task { if await save(body) { dismiss() } } }.disabled( busy || (draft.title.isEmpty && draft.page != .notes) || (draft.page == .notes && draft.detail.isEmpty)) } } }.interactiveDismissDisabled(busy) } } private struct NativeCard: View { @Environment(\.colorScheme) private var scheme let title: String? let subtitle: String? let content: Content init(_ title: String? = nil, subtitle: String? = nil, @ViewBuilder content: () -> Content) { self.title = title self.subtitle = subtitle self.content = content() } var body: some View { VStack(alignment: .leading, spacing: 12) { if let title { Text(title).font(.title3.weight(.semibold)) } if let subtitle { Text(subtitle).font(.subheadline).foregroundStyle(.secondary) } content }.frame(maxWidth: .infinity, alignment: .leading).padding(16).background( NativeDesign.surface(scheme == .dark), in: RoundedRectangle(cornerRadius: 16)) } } private struct NativeBrandMark: View { var body: some View { Canvas { context, size in let shape = Path(roundedRect: CGRect(origin: .zero, size: size), cornerRadius: size.width * 0.225) context.fill( shape, with: .linearGradient( Gradient(colors: [Color(red: 139 / 255, green: 92 / 255, blue: 246 / 255), NativeDesign.accent]), startPoint: .zero, endPoint: CGPoint(x: size.width, y: size.height))) for (x, y, radius) in [(64.0, 72.0, 27.0), (100.0, 78.0, 25.0), (80.0, 106.0, 24.0)] { let rect = CGRect( x: (x - radius) * size.width / 160, y: (y - radius) * size.height / 160, width: radius * 2 * size.width / 160, height: radius * 2 * size.height / 160) context.fill(Path(ellipseIn: rect), with: .color(.white.opacity(0.82))) } } } }