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.
This commit is contained in:
Yuvomi Mobile Contributors committed 2026-10-21 08:57:31 +02:00
1 parent 11bf4ca620
commit 97570b7fdf
15 files changed
+1182 -11

No files matched your search

+219
View File
@@ -0,0 +1,219 @@
import SwiftUI
@MainActor
final class NativeStore: ObservableObject {
@Published private(set) var server = ""
@Published private(set) var appName = "Yuvomi"
@Published private(set) var user: NativeUser?
@Published private(set) var permissions: NativePermissions?
@Published private(set) var setup = false
@Published private(set) var passwordAllowed = true
@Published private(set) var oidc = false
@Published private(set) var twoFactor = false
@Published private(set) var busy = false
@Published var error: String?
@Published private(set) var page = NativePage.home
@Published private(set) var dashboard: NativeDashboard?
@Published private(set) var tasks: [NativeTask] = []
@Published private(set) var notes: [NativeNote] = []
@Published private(set) var lists: [NativeShoppingList] = []
@Published private(set) var items: [NativeShoppingItem] = []
@Published private(set) var selectedList: NativeShoppingList?
private var api: NativeAPI?
private var vault: NativeSessionVault?
private var generation = 0
func restore() async {
guard server.isEmpty, let saved = UserDefaults.standard.string(forKey: "native-origin") else { return }
await connect(saved)
}
@discardableResult
private func run(_ operation: () async throws -> Void) async -> Bool {
guard !busy else { return false }
let current = generation
busy = true
error = nil
defer { if generation == current { busy = false } }
do {
try await operation()
return true
} catch {
guard current == generation else { return false }
if case NativeAPIError.response(401) = error, user != nil {
clearLoadedData()
self.error = "Deine Sitzung ist abgelaufen. Bitte erneut anmelden."
} else {
self.error = (error as? NativeAPIError)?.errorDescription ?? NativeAPIError.unavailable.errorDescription
}
return false
}
}
func connect(_ input: String) async {
await run {
let address = try ServerAddress(input)
let storage = NativeSessionVault(origin: address.url.absoluteString)
let client = try NativeAPI(server: address, saved: storage.load(), persist: storage.save)
let metadata = try await client.request("/version", as: NativeBootstrap.self)
let authentication = try await client.request("/auth/oidc/config", as: NativeAuthentication.self)
api = client
vault = storage
server = address.url.absoluteString
appName = metadata.appName
setup = metadata.setupRequired
oidc = authentication.enabled
passwordAllowed = authentication.passwordLoginEnabled || authentication.guestPasswordLoginEnabled == true
UserDefaults.standard.set(server, forKey: "native-origin")
do { try await accept(client.request("/auth/me", as: NativeIdentity.self)) } catch NativeAPIError.response(
401)
{ return }
}
}
func login(username: String, password: String, displayName: String) async {
await run {
guard let api else { throw NativeAPIError.closed }
if setup {
let _: NativeEmpty = try await api.request(
"/auth/setup", method: "POST",
body: [
"username": .string(username.trimmingCharacters(in: .whitespaces)),
"password": .string(password),
"display_name": .string(displayName.isEmpty ? username : displayName),
], as: NativeEmpty.self)
}
let response = try await api.request(
"/auth/login", method: "POST",
body: [
"username": .string(username.trimmingCharacters(in: .whitespaces)), "password": .string(password),
], as: NativeIdentity.self)
if response.twoFactorRequired == true {
twoFactor = true
setup = false
} else {
try await accept(response)
}
}
}
func verify(_ code: String) async {
await run {
guard let api else { throw NativeAPIError.closed }
try await accept(
api.request("/auth/2fa/verify", method: "POST", body: ["code": .string(code)], as: NativeIdentity.self))
}
}
func cancelTwoFactor() {
if !busy {
twoFactor = false
error = nil
}
}
private func accept(_ identity: NativeIdentity) async throws {
guard let user = identity.user, let permissions = identity.permissions else {
throw NativeAPIError.invalidResponse
}
self.user = user
self.permissions = permissions
setup = false
twoFactor = false
page = .home
try await reload()
}
func navigate(_ selected: NativePage) async {
await run {
page = selected
selectedList = nil
try await reload()
}
}
func open(_ list: NativeShoppingList) async {
await run {
page = .shopping
selectedList = list
try await reload()
}
}
func refresh() async { await run { try await reload() } }
private func reload() async throws {
guard let api else { throw NativeAPIError.closed }
switch page {
case .home: dashboard = try await api.request("/dashboard", as: NativeDashboard.self)
case .tasks: tasks = try await api.request("/tasks", as: NativeEnvelope<[NativeTask]>.self).data
case .notes: notes = try await api.request("/notes", as: NativeEnvelope<[NativeNote]>.self).data
case .shopping:
if let selectedList {
items = try await api.request(
"/shopping/\(selectedList.id)/items", as: NativeEnvelope<[NativeShoppingItem]>.self
).data
} else {
lists = try await api.request("/shopping", as: NativeEnvelope<[NativeShoppingList]>.self).data
}
case .more: break
}
}
func mutate(_ path: String, method: String, body: [String: NativeValue] = [:]) async -> Bool {
await run {
guard let api else { throw NativeAPIError.closed }
let _: NativeEmpty = try await api.request(path, method: method, body: body, as: NativeEmpty.self)
try await reload()
}
}
func logout() async {
await run {
let revoked: Bool
do {
if let api {
let _: NativeEmpty = try await api.request("/auth/logout", method: "POST", as: NativeEmpty.self)
}
revoked = true
} catch { revoked = false }
await api?.close()
try vault?.clear()
if let address = try? ServerAddress(server), let storage = vault {
api = try NativeAPI(server: address, saved: nil, persist: storage.save)
}
clearLoadedData()
if !revoked { error = "Lokal abgemeldet. Die Abmeldung auf dem Server konnte nicht bestätigt werden." }
}
}
func forget() async {
guard !busy else { return }
generation += 1
await api?.close()
do { try vault?.clear() } catch {
self.error = NativeAPIError.unavailable.errorDescription
return
}
api = nil
vault = nil
clearLoadedData()
server = ""
appName = "Yuvomi"
setup = false
passwordAllowed = true
oidc = false
error = nil
UserDefaults.standard.removeObject(forKey: "native-origin")
}
private func clearLoadedData() {
user = nil
permissions = nil
dashboard = nil
tasks = []
notes = []
lists = []
items = []
selectedList = nil
page = .home
twoFactor = false
}
}