| 1 | import SwiftUI |
| 2 | |
| 3 | // π€ The native renderer for schema-described apps: assembles the schema's |
| 4 | // main list view (composer, summary, search, sections, rows) and its detail |
| 5 | // ("show") view from stock SwiftUI components, styled to the |
| 6 | // platform's HIG idiom β inset-grouped lists, pull-down search, swipe and |
| 7 | // context-menu actions, ContentUnavailableView empty states on iOS; a hover |
| 8 | // pane on the Mac. One renderer for every schema β the app-specific part is |
| 9 | // ALL in the JSON (docs/app-schema.md). |
| 10 | // |
| 11 | // Destructive actions (role "destructive") always confirm first, via a |
| 12 | // confirmation dialog naming the item β whether triggered by swipe, context |
| 13 | // menu, a row button, or a detail-view button. |
| 14 | public struct SchemaAppView: View { |
| 15 | @ObservedObject var store: SchemaAppStore |
| 16 | @State private var input = "" |
| 17 | @FocusState private var inputFocused: Bool |
| 18 | @State private var query = "" |
| 19 | @State private var collapsed: [String: Bool] = [:] |
| 20 | @State private var detail: DetailTarget? |
| 21 | @State private var sending = false |
| 22 | @State private var pendingConfirm: PendingConfirm? |
| 23 | #if os(macOS) |
| 24 | @State private var hoveredRow: String? |
| 25 | #endif |
| 26 | |
| 27 | private struct DetailTarget: Identifiable { |
| 28 | let id: String |
| 29 | let item: AppJSON |
| 30 | } |
| 31 | |
| 32 | // A destructive action awaiting its confirm. `verb` is the action's own |
| 33 | // label ("Remove", "Cancel reminder") so the dialog button says exactly |
| 34 | // what will happen. |
| 35 | struct PendingConfirm: Identifiable { |
| 36 | let id = UUID() |
| 37 | let verb: String |
| 38 | let itemTitle: String |
| 39 | let action: String |
| 40 | let item: AppJSON |
| 41 | let closesDetail: Bool |
| 42 | } |
| 43 | |
| 44 | public init(store: SchemaAppStore) { |
| 45 | self.store = store |
| 46 | } |
| 47 | |
| 48 | private var view: AppJSON { store.schema["views"]["main"] } |
| 49 | private var hasSearch: Bool { !view["search"].isNull } |
| 50 | private var refreshSeconds: Double { max(SchemaEngine.num(view["refreshSeconds"]), 0) } |
| 51 | |
| 52 | public var body: some View { |
| 53 | Group { |
| 54 | if refreshSeconds > 0 { |
| 55 | // A periodic timeline keeps relative labels ("in 2h") honest. |
| 56 | TimelineView(.periodic(from: .now, by: refreshSeconds)) { context in |
| 57 | content(now: context.date.timeIntervalSince1970 * 1000) |
| 58 | } |
| 59 | } else { |
| 60 | content(now: Date().timeIntervalSince1970 * 1000) |
| 61 | } |
| 62 | } |
| 63 | .sheet(item: $detail) { target in |
| 64 | SchemaDetailView(store: store, item: target.item) |
| 65 | } |
| 66 | .confirmationDialog( |
| 67 | confirmTitle, |
| 68 | isPresented: Binding(get: { pendingConfirm != nil }, |
| 69 | set: { if !$0 { pendingConfirm = nil } }), |
| 70 | titleVisibility: .visible |
| 71 | ) { |
| 72 | if let pending = pendingConfirm { |
| 73 | Button(pending.verb, role: .destructive) { |
| 74 | let item = pending.item |
| 75 | let action = pending.action |
| 76 | if pending.closesDetail { detail = nil } |
| 77 | pendingConfirm = nil |
| 78 | Task { await store.run(action, item: item) } |
| 79 | } |
| 80 | Button("Cancel", role: .cancel) { pendingConfirm = nil } |
| 81 | } |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | private var confirmTitle: String { |
| 86 | guard let pending = pendingConfirm else { return "" } |
| 87 | return pending.itemTitle.isEmpty |
| 88 | ? "\(pending.verb)?" |
| 89 | : "\(pending.verb) β\(pending.itemTitle)β?" |
| 90 | } |
| 91 | |
| 92 | /// Every destructive trigger funnels through here; non-destructive actions |
| 93 | /// run straight away. |
| 94 | private func run(_ action: String, label: String, destructive: Bool, |
| 95 | item: AppJSON, now: Double, closesDetail: Bool = false) { |
| 96 | guard destructive else { |
| 97 | Task { await store.run(action, item: item) } |
| 98 | return |
| 99 | } |
| 100 | pendingConfirm = PendingConfirm( |
| 101 | verb: label.isEmpty ? "Remove" : label, |
| 102 | itemTitle: SchemaEngine.rowModel(store.schema, item: item, now: now).title, |
| 103 | action: action, item: item, closesDetail: closesDetail) |
| 104 | } |
| 105 | |
| 106 | @ViewBuilder private func content(now: Double) -> some View { |
| 107 | let model = SchemaEngine.listModel(store.schema, state: store.state, now: now, |
| 108 | query: query, collapsed: collapsed) |
| 109 | list(model: model, now: now) |
| 110 | #if os(iOS) |
| 111 | .listStyle(.insetGrouped) |
| 112 | #else |
| 113 | .listStyle(.inset) |
| 114 | #endif |
| 115 | .animation(.default, value: store.state) |
| 116 | .modifier(SearchModifier(enabled: hasSearch, query: $query)) |
| 117 | } |
| 118 | |
| 119 | @ViewBuilder private func list(model: SchemaEngine.ListModel, now: Double) -> some View { |
| 120 | List { |
| 121 | composerSection(summary: model.summary) |
| 122 | #if os(macOS) |
| 123 | if hasSearch { |
| 124 | searchField |
| 125 | .listRowSeparator(.hidden) |
| 126 | } |
| 127 | #endif |
| 128 | ForEach(model.sections) { section in |
| 129 | self.section(section, now: now) |
| 130 | } |
| 131 | if !model.empty.isEmpty { |
| 132 | emptyState(model.empty) |
| 133 | } |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | // MARK: composer |
| 138 | |
| 139 | @ViewBuilder private func composerSection(summary: String) -> some View { |
| 140 | if case .object = view["composer"] { |
| 141 | Section { |
| 142 | HStack(spacing: 10) { |
| 143 | TextField(view["composer"]["placeholder"].stringValue ?? "", text: $input) |
| 144 | .textFieldStyle(.plain) |
| 145 | .focused($inputFocused) |
| 146 | .onSubmit(submit) |
| 147 | .onChange(of: input) { store.hintText = nil } |
| 148 | // Emits fold optimistically, so `sending` is normally too |
| 149 | // brief to see β the spinner covers actions with a real |
| 150 | // round-trip before the fold (Reminders' schedule step). |
| 151 | if sending { |
| 152 | ProgressView() |
| 153 | .controlSize(.small) |
| 154 | .frame(width: 24, height: 24) |
| 155 | } else { |
| 156 | Button(action: submit) { |
| 157 | Image(systemName: "plus.circle.fill") |
| 158 | .font(.title2) |
| 159 | .symbolRenderingMode(.hierarchical) |
| 160 | } |
| 161 | .buttonStyle(.borderless) |
| 162 | .disabled(input.trimmingCharacters(in: .whitespaces).isEmpty) |
| 163 | .accessibilityLabel(view["composer"]["button"].stringValue ?? "Add") |
| 164 | } |
| 165 | } |
| 166 | .padding(.vertical, 2) |
| 167 | } footer: { |
| 168 | // The hint explains the grammar; a failed parse replaces it |
| 169 | // with direction (inline, in layout β never a floating toast). |
| 170 | if let problem = store.hintText { |
| 171 | Text(problem).foregroundStyle(.orange) |
| 172 | } else { |
| 173 | composerFooter(summary: summary) |
| 174 | } |
| 175 | } |
| 176 | } else if !summary.isEmpty { |
| 177 | Section { EmptyView() } footer: { Text(summary) } |
| 178 | } |
| 179 | } |
| 180 | |
| 181 | @ViewBuilder private func composerFooter(summary: String) -> some View { |
| 182 | let hint = view["composer"]["hint"].stringValue ?? "" |
| 183 | if !hint.isEmpty && !summary.isEmpty { |
| 184 | Text(hint + "\n" + summary) |
| 185 | } else if !hint.isEmpty { |
| 186 | Text(hint) |
| 187 | } else if !summary.isEmpty { |
| 188 | Text(summary) |
| 189 | } |
| 190 | } |
| 191 | |
| 192 | private func submit() { |
| 193 | let text = input.trimmingCharacters(in: .whitespacesAndNewlines) |
| 194 | guard !text.isEmpty, let action = view["composer"]["action"].stringValue else { return } |
| 195 | if case .number(let maxLength) = view["composer"]["maxLength"], text.count > Int(maxLength) { return } |
| 196 | sending = true |
| 197 | Task { |
| 198 | await store.run(action, input: text) |
| 199 | // Keep unparseable text in place for correction; clear on success. |
| 200 | if store.hintText == nil { input = "" } |
| 201 | sending = false |
| 202 | // Adding is a burst activity β keep the caret in the field so the |
| 203 | // next one types straight in (Return submits without a re-click; |
| 204 | // the web composer refocuses the same way). |
| 205 | inputFocused = true |
| 206 | } |
| 207 | } |
| 208 | |
| 209 | #if os(macOS) |
| 210 | // The platform's own search field β magnifier, clear button, and a stable |
| 211 | // focused appearance for free (a hand-drawn capsule around a plain |
| 212 | // TextField flashed the field-editor's white background on focus). |
| 213 | private var searchField: some View { |
| 214 | MacSearchField(text: $query) |
| 215 | } |
| 216 | #endif |
| 217 | |
| 218 | // MARK: empty state |
| 219 | |
| 220 | // A quiet inline line, exactly the web's .remind-empty register β never a |
| 221 | // full-pane empty state: other sections (Previous reminders) may still |
| 222 | // have content, and the pane shouldn't shout about an empty list. |
| 223 | private func emptyState(_ text: String) -> some View { |
| 224 | Text(text) |
| 225 | .font(.callout) |
| 226 | .foregroundStyle(.secondary) |
| 227 | .multilineTextAlignment(.center) |
| 228 | .frame(maxWidth: .infinity, alignment: .center) |
| 229 | .padding(.vertical, 16) |
| 230 | .listRowSeparator(.hidden) |
| 231 | #if os(iOS) |
| 232 | .listRowBackground(Color.clear) |
| 233 | #endif |
| 234 | } |
| 235 | |
| 236 | // MARK: sections |
| 237 | |
| 238 | @ViewBuilder private func section(_ section: SchemaEngine.ListSection, now: Double) -> some View { |
| 239 | if section.rows.isEmpty { |
| 240 | EmptyView() |
| 241 | } else if section.collapsible { |
| 242 | Section { |
| 243 | if !section.collapsed { |
| 244 | ForEach(section.rows) { row in |
| 245 | self.row(row, now: now) |
| 246 | } |
| 247 | } |
| 248 | } header: { |
| 249 | Button { |
| 250 | withAnimation(.easeInOut(duration: 0.2)) { |
| 251 | collapsed[section.id] = !section.collapsed |
| 252 | } |
| 253 | } label: { |
| 254 | HStack(spacing: 5) { |
| 255 | Text(section.title) |
| 256 | Image(systemName: "chevron.right") |
| 257 | .font(.caption2.weight(.semibold)) |
| 258 | .rotationEffect(.degrees(section.collapsed ? 0 : 90)) |
| 259 | } |
| 260 | } |
| 261 | .buttonStyle(.plain) |
| 262 | .foregroundStyle(.secondary) |
| 263 | .textCase(nil) |
| 264 | .accessibilityLabel("\(section.title), \(section.collapsed ? "collapsed" : "expanded")") |
| 265 | } |
| 266 | } else { |
| 267 | Section { |
| 268 | ForEach(section.rows) { row in |
| 269 | self.row(row, now: now) |
| 270 | } |
| 271 | .onMove(perform: section.reorder.isEmpty ? nil : { from, to in |
| 272 | moveRows(section, from: from, to: to) |
| 273 | }) |
| 274 | } header: { |
| 275 | if !section.title.isEmpty { |
| 276 | Text(section.title).textCase(nil) |
| 277 | } |
| 278 | } |
| 279 | } |
| 280 | } |
| 281 | |
| 282 | /// A drag lands as a visual move; the event wire wants an array anchor β |
| 283 | /// the id of the item the moved one now FOLLOWS in the collection. In a |
| 284 | /// descending (newest-first) section that anchor is the row now *below* |
| 285 | /// the drop; ascending, the row above. Visually first-in-a-descending / |
| 286 | /// last-in-an-ascending section = the array's start (""). |
| 287 | private func moveRows(_ section: SchemaEngine.ListSection, from: IndexSet, to: Int) { |
| 288 | guard let src = from.first.map({ section.rows[$0] }) else { return } |
| 289 | var rows = section.rows |
| 290 | rows.move(fromOffsets: from, toOffset: to) |
| 291 | guard let i = rows.firstIndex(where: { $0.id == src.id }) else { return } |
| 292 | let anchor = section.descending |
| 293 | ? (i + 1 < rows.count ? rows[i + 1].id : "") |
| 294 | : (i > 0 ? rows[i - 1].id : "") |
| 295 | Task { await store.run(section.reorder, item: src.item, extra: ["after": .string(anchor)]) } |
| 296 | } |
| 297 | |
| 298 | // MARK: rows |
| 299 | |
| 300 | @ViewBuilder private func row(_ listRow: SchemaEngine.ListRow, now: Double) -> some View { |
| 301 | let item = listRow.item |
| 302 | let model = SchemaEngine.rowModel(store.schema, item: item, now: now) |
| 303 | let rowSpec = view["row"] |
| 304 | let tapAction = rowSpec["tap"]["action"].stringValue |
| 305 | let tapDetail = SchemaEngine.truthy(rowSpec["tap"]["detail"]) |
| 306 | let hasDetail = !store.schema["views"]["detail"].isNull |
| 307 | |
| 308 | HStack(alignment: .center, spacing: 12) { |
| 309 | if case .object = rowSpec["leading"] { |
| 310 | leading(rowSpec["leading"], item: item, grace: listRow.grace, now: now) |
| 311 | } |
| 312 | VStack(alignment: .leading, spacing: 2) { |
| 313 | Text(model.title) |
| 314 | .strikethrough(model.strike) |
| 315 | .foregroundStyle(model.muted ? Color.secondary : Color.primary) |
| 316 | if !model.subtitle.isEmpty { |
| 317 | Text(model.subtitle) |
| 318 | .font(.subheadline) |
| 319 | .foregroundStyle(model.accent ? Color.accentColor : Color.secondary) |
| 320 | .fontWeight(model.accent ? .medium : .regular) |
| 321 | } |
| 322 | if !model.note.isEmpty { |
| 323 | Text(model.note) |
| 324 | .font(.caption) |
| 325 | .foregroundStyle(.tertiary) |
| 326 | } |
| 327 | } |
| 328 | Spacer(minLength: 0) |
| 329 | #if os(macOS) |
| 330 | // The Mac reveals row actions on hover (there's no swipe); iOS |
| 331 | // rows stay clean β swipe and the context menu carry them. |
| 332 | ForEach(Array(rowSpec["trailing"].arrayValue.enumerated()), id: \.offset) { _, button in |
| 333 | trailingButton(button, item: item, now: now) |
| 334 | .opacity(hoveredRow == listRow.id ? 1 : 0) |
| 335 | } |
| 336 | #endif |
| 337 | } |
| 338 | .padding(.vertical, 4) |
| 339 | .contentShape(Rectangle()) |
| 340 | #if os(macOS) |
| 341 | .onHover { inside in |
| 342 | hoveredRow = inside ? listRow.id : (hoveredRow == listRow.id ? nil : hoveredRow) |
| 343 | } |
| 344 | #endif |
| 345 | .onTapGesture { |
| 346 | if let tapAction { |
| 347 | Task { await store.run(tapAction, item: item) } |
| 348 | } else if tapDetail { |
| 349 | detail = DetailTarget(id: listRow.id, item: item) |
| 350 | } |
| 351 | } |
| 352 | .contextMenu { |
| 353 | if hasDetail, !tapDetail { |
| 354 | Button { |
| 355 | detail = DetailTarget(id: listRow.id, item: item) |
| 356 | } label: { |
| 357 | Label("Details", systemImage: "info.circle") |
| 358 | } |
| 359 | } |
| 360 | ForEach(Array(rowSpec["trailing"].arrayValue.enumerated()), id: \.offset) { _, button in |
| 361 | if let action = button["action"].stringValue { |
| 362 | let destructive = button["role"].stringValue == "destructive" |
| 363 | Button(role: destructive ? .destructive : nil) { |
| 364 | run(action, label: button["label"].stringValue ?? "", |
| 365 | destructive: destructive, item: item, now: now) |
| 366 | } label: { |
| 367 | Label(button["label"].stringValue ?? "", |
| 368 | systemImage: destructive ? "trash" : "circle") |
| 369 | } |
| 370 | } |
| 371 | } |
| 372 | } |
| 373 | #if os(iOS) |
| 374 | .swipeActions(edge: .trailing, allowsFullSwipe: false) { |
| 375 | ForEach(Array(rowSpec["trailing"].arrayValue.enumerated()), id: \.offset) { _, button in |
| 376 | if button["role"].stringValue == "destructive", let action = button["action"].stringValue { |
| 377 | // Swipe reveals the verb; the tap still confirms before |
| 378 | // anything is destroyed (allowsFullSwipe off for the same |
| 379 | // reason). |
| 380 | Button(role: .destructive) { |
| 381 | run(action, label: button["label"].stringValue ?? "", |
| 382 | destructive: true, item: item, now: now) |
| 383 | } label: { |
| 384 | Label(button["label"].stringValue ?? "Remove", systemImage: "trash") |
| 385 | } |
| 386 | } |
| 387 | } |
| 388 | } |
| 389 | #endif |
| 390 | } |
| 391 | |
| 392 | // The checkbox convention (Reminders-style completion flourish): the |
| 393 | // circle fills accent the instant it's checked β during the grace beat |
| 394 | // too, when the row lingers, still undoable, before tucking away. |
| 395 | @ViewBuilder private func leading(_ spec: AppJSON, item: AppJSON, grace: Bool, now: Double) -> some View { |
| 396 | if spec["kind"].stringValue == "check" { |
| 397 | let on = SchemaEngine.truthy(SchemaEngine.evalValue(spec["on"], .init(["item": item]))) |
| 398 | Button { |
| 399 | if let action = spec["action"].stringValue { |
| 400 | Task { await store.run(action, item: item) } |
| 401 | } |
| 402 | } label: { |
| 403 | Image(systemName: on ? "checkmark.circle.fill" : "circle") |
| 404 | .font(.title3) |
| 405 | .foregroundStyle(on ? Color.accentColor : Color.secondary.opacity(0.6)) |
| 406 | .contentTransition(.symbolEffect(.replace)) |
| 407 | } |
| 408 | .buttonStyle(.borderless) |
| 409 | .frame(width: 24, height: 24) |
| 410 | .accessibilityLabel(on ? "Reopen" : "Mark done") |
| 411 | } |
| 412 | } |
| 413 | |
| 414 | #if os(macOS) |
| 415 | @ViewBuilder private func trailingButton(_ spec: AppJSON, item: AppJSON, now: Double) -> some View { |
| 416 | if let action = spec["action"].stringValue { |
| 417 | let destructive = spec["role"].stringValue == "destructive" |
| 418 | Button { |
| 419 | run(action, label: spec["label"].stringValue ?? "", |
| 420 | destructive: destructive, item: item, now: now) |
| 421 | } label: { |
| 422 | Image(systemName: destructive ? "xmark.circle.fill" : "circle") |
| 423 | .symbolRenderingMode(.hierarchical) |
| 424 | .foregroundStyle(.secondary) |
| 425 | } |
| 426 | .buttonStyle(.borderless) |
| 427 | .accessibilityLabel(spec["label"].stringValue ?? "") |
| 428 | } |
| 429 | } |
| 430 | #endif |
| 431 | } |
| 432 | |
| 433 | #if os(macOS) |
| 434 | import AppKit |
| 435 | |
| 436 | // NSSearchField, bridged: SwiftUI has no in-content search field on macOS |
| 437 | // (.searchable wants the window toolbar, and this one lives inside a pane). |
| 438 | private struct MacSearchField: NSViewRepresentable { |
| 439 | @Binding var text: String |
| 440 | |
| 441 | func makeNSView(context: Context) -> NSSearchField { |
| 442 | let field = NSSearchField() |
| 443 | field.placeholderString = "Search" |
| 444 | field.delegate = context.coordinator |
| 445 | field.focusRingType = .none |
| 446 | return field |
| 447 | } |
| 448 | |
| 449 | func updateNSView(_ field: NSSearchField, context: Context) { |
| 450 | if field.stringValue != text { field.stringValue = text } |
| 451 | } |
| 452 | |
| 453 | func makeCoordinator() -> Coordinator { Coordinator(text: $text) } |
| 454 | |
| 455 | final class Coordinator: NSObject, NSSearchFieldDelegate { |
| 456 | var text: Binding<String> |
| 457 | init(text: Binding<String>) { self.text = text } |
| 458 | func controlTextDidChange(_ obj: Notification) { |
| 459 | guard let field = obj.object as? NSSearchField else { return } |
| 460 | text.wrappedValue = field.stringValue |
| 461 | } |
| 462 | } |
| 463 | } |
| 464 | #endif |
| 465 | |
| 466 | // Pull-down search on iOS (the platform idiom inside a navigation stack); the |
| 467 | // Mac pane draws its own compact field instead, so this is a no-op there. |
| 468 | private struct SearchModifier: ViewModifier { |
| 469 | let enabled: Bool |
| 470 | @Binding var query: String |
| 471 | |
| 472 | func body(content: Content) -> some View { |
| 473 | #if os(iOS) |
| 474 | if enabled { |
| 475 | content.searchable(text: $query, placement: .navigationBarDrawer(displayMode: .automatic)) |
| 476 | } else { |
| 477 | content |
| 478 | } |
| 479 | #else |
| 480 | content |
| 481 | #endif |
| 482 | } |
| 483 | } |
| 484 | |
| 485 | // MARK: - the detail ("show") view |
| 486 | |
| 487 | struct SchemaDetailView: View { |
| 488 | @ObservedObject var store: SchemaAppStore |
| 489 | let item: AppJSON |
| 490 | @Environment(\.dismiss) private var dismiss |
| 491 | // The sheet owns its own confirm β a dialog on the presenting view can't |
| 492 | // appear over a presented sheet. |
| 493 | @State private var pending: SchemaAppView.PendingConfirm? |
| 494 | |
| 495 | var body: some View { |
| 496 | let now = Date().timeIntervalSince1970 * 1000 |
| 497 | let model = SchemaEngine.detailModel(store.schema, item: item, now: now) |
| 498 | NavigationStack { |
| 499 | List { |
| 500 | ForEach(model?.sections ?? []) { section in |
| 501 | switch section { |
| 502 | case .definitions(let rows): |
| 503 | Section { |
| 504 | ForEach(rows) { row in |
| 505 | LabeledContent(row.term, value: row.value) |
| 506 | } |
| 507 | } |
| 508 | case .text(let body): |
| 509 | Section { |
| 510 | Text(body) |
| 511 | } |
| 512 | case .buttons(let buttons): |
| 513 | Section { |
| 514 | ForEach(buttons) { button in |
| 515 | Button(button.label, role: button.destructive ? .destructive : nil) { |
| 516 | if button.destructive { |
| 517 | pending = .init(verb: button.label, |
| 518 | itemTitle: model?.title ?? "", |
| 519 | action: button.action, item: item, |
| 520 | closesDetail: button.dismiss) |
| 521 | } else { |
| 522 | Task { |
| 523 | await store.run(button.action, item: item) |
| 524 | if button.dismiss { dismiss() } |
| 525 | } |
| 526 | } |
| 527 | } |
| 528 | .frame(maxWidth: .infinity, alignment: .center) |
| 529 | } |
| 530 | } |
| 531 | } |
| 532 | } |
| 533 | } |
| 534 | #if os(iOS) |
| 535 | .listStyle(.insetGrouped) |
| 536 | #endif |
| 537 | .navigationTitle(model?.title ?? "") |
| 538 | #if os(iOS) |
| 539 | .navigationBarTitleDisplayMode(.inline) |
| 540 | #endif |
| 541 | .toolbar { |
| 542 | ToolbarItem(placement: .cancellationAction) { |
| 543 | Button("Done") { dismiss() } |
| 544 | } |
| 545 | } |
| 546 | #if os(macOS) |
| 547 | .frame(minWidth: 340, minHeight: 320) |
| 548 | #endif |
| 549 | } |
| 550 | #if os(iOS) |
| 551 | .presentationDetents([.medium, .large]) |
| 552 | #endif |
| 553 | .confirmationDialog( |
| 554 | pending.map { p in p.itemTitle.isEmpty ? "\(p.verb)?" : "\(p.verb) β\(p.itemTitle)β?" } ?? "", |
| 555 | isPresented: Binding(get: { pending != nil }, |
| 556 | set: { if !$0 { pending = nil } }), |
| 557 | titleVisibility: .visible |
| 558 | ) { |
| 559 | if let p = pending { |
| 560 | Button(p.verb, role: .destructive) { |
| 561 | let action = p.action |
| 562 | let closes = p.closesDetail |
| 563 | pending = nil |
| 564 | Task { |
| 565 | await store.run(action, item: item) |
| 566 | if closes { dismiss() } |
| 567 | } |
| 568 | } |
| 569 | Button("Cancel", role: .cancel) { pending = nil } |
| 570 | } |
| 571 | } |
| 572 | } |
| 573 | } |
| 574 | |