import SwiftUI import WebKit struct ContentView: View { @StateObject private var browser = BrowserModel() @State private var input = UserDefaults.standard.string(forKey: "origin") ?? "" @State private var server: ServerAddress? @State private var connecting = false @State private var message: String? @State private var settings = false @State private var erasing = false @Environment(\.scenePhase) private var scenePhase var body: some View { NavigationStack { Group { if server != nil { ZStack { BrowserView(model: browser) if let error = browser.error { VStack(spacing: 18) { Image(systemName: "wifi.exclamationmark").font(.largeTitle) Text("Connection interrupted").font(.title2).bold() Text(error) Button("Retry") { browser.error = nil; browser.web.reload() } Button("Keep viewing") { browser.error = nil } Button("Server settings") { settings = true } } .padding(24).frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(uiColor: .systemGroupedBackground)) .accessibilityElement(children: .contain) } if browser.loading { ProgressView("Loading Yuvomi").padding().background(.regularMaterial).cornerRadius(12) } } .toolbar { ToolbarItem(placement: .navigationBarLeading) { Button { browser.web.goBack() } label: { Image(systemName: "chevron.backward") } .disabled(!browser.canGoBack).accessibilityLabel("Back") } ToolbarItem(placement: .navigationBarTrailing) { Button("Server settings") { settings = true } } } } else { ScrollView { VStack(alignment: .leading, spacing: 20) { Image(systemName: "house.fill").font(.system(size: 48)).foregroundStyle(.tint) .accessibilityHidden(true) Text("Your home, connected").font(.largeTitle).bold() Text("Connect to your own Yuvomi server. Your data stays with your household.") TextField("https://family.example.com", text: $input) .keyboardType(.URL).textContentType(.URL) .textInputAutocapitalization(.never).autocorrectionDisabled() .textFieldStyle(.roundedBorder).accessibilityLabel("Yuvomi server URL") Text("HTTPS required. Custom ports supported. Use a dedicated hostname, not a subpath.") .font(.callout).foregroundStyle(.secondary) if let message { Text(message).accessibilityIdentifier("connectionError") } if connecting { ProgressView("Checking your server") } Button("Connect securely") { connect() } .buttonStyle(.borderedProminent).disabled(connecting || erasing) .frame(minHeight: 48) Text("Independent community app. No telemetry or mandatory cloud service.") .font(.footnote).foregroundStyle(.secondary) }.padding(24) }.background(Color(uiColor: .systemGroupedBackground)) } } .navigationTitle("Yuvomi Mobile").navigationBarTitleDisplayMode(.inline) .tint(Color(red: 108 / 255, green: 58 / 255, blue: 237 / 255)) } .task { if server == nil, let existing = try? ServerAddress(input) { server = existing; browser.open(existing) } } .onChange(of: scenePhase) { phase in if phase == .background { browser.rememberPath() } } .overlay { if scenePhase != .active { Color(uiColor: .systemBackground).ignoresSafeArea() .overlay(Text("Yuvomi Mobile").font(.title).bold()) } } .sheet(isPresented: $settings) { NavigationStack { Form { Section("Your server") { Text(server?.url.absoluteString ?? "") if let url = server?.url { ShareLink("Share server address", item: url) Link("Open server in system browser", destination: url) } } Section { Text("Changing servers erases cookies, storage and cached household data. Exported files remain. Sign out in Yuvomi first to revoke the server session.") Button("Erase local data and change server", role: .destructive) { settings = false; erasing = true browser.erase { UserDefaults.standard.removeObject(forKey: "origin") UserDefaults.standard.removeObject(forKey: "path") server = nil; input = ""; erasing = false } } } Section("About") { Text("Independent community contribution. MIT licensed. Not endorsed by upstream Yuvomi. No mobile telemetry. Your server's own privacy settings still apply.") } }.navigationTitle("Server settings") .toolbar { Button("Done") { settings = false } } } } .alert("External destination", isPresented: Binding( get: { browser.externalURL != nil }, set: { if !$0 { browser.externalURL = nil } } ), presenting: browser.externalURL) { url in Button("Open in browser") { UIApplication.shared.open(url); browser.externalURL = nil } Button("Isolated sign-in window") { browser.startAuthentication(url); browser.externalURL = nil } Button("Cancel", role: .cancel) { browser.externalURL = nil } } message: { url in Text("Destination: \(url.host ?? "")\nFor SSO, use the isolated window only if you trust this provider. Browser sessions do not transfer into the app.") } .sheet(item: $browser.authentication) { destination in AuthenticationView(destination: destination.url, model: browser) } .sheet(item: $browser.download) { file in ExportView(url: file.url) { browser.removeDownload() } } } private func connect() { do { let candidate = try ServerAddress(input) connecting = true; message = nil Task { @MainActor in defer { connecting = false } do { try await ServerProbe.check(candidate) UserDefaults.standard.set(candidate.url.absoluteString, forKey: "origin") server = candidate; browser.open(candidate) } catch { message = ConnectionError.unavailable.localizedDescription } } } catch { message = ConnectionError.invalidAddress.localizedDescription } } }