SHA256
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:
1 parent
11bf4ca620
commit
97570b7fdf
15 files changed
+1182
-11
No files matched your search
@@ -0,0 +1,525 @@
|
||||
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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user