eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1import 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.
14public 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)
434import 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).
438private 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.
468private 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
487struct 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