Files
Yuvomi-App/ios/Sources/NativeRootView.swift
T
Yuvomi Mobile Contributors 97570b7fdf feat(ios): replace embedded website with initial native SwiftUI API client
Introduce real SwiftUI auth/dashboard/task/note/shopping screens, a bounded origin-bound URLSession transport and Keychain-backed native sessions. Archive the rejected WebKit renderer rather than embedding it for unfinished modules. Sixteen portable URL/contract/transport tests and Swift format/parsing pass on Linux; Apple SDK application and UI validation remains mandatory. This is an incomplete native slice, not full PWA parity or a release.
2026-10-21 08:57:31 +02:00

526 lines
25 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Content: View>: 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)))
}
}
}
}