| 1 | import RastrilloApple |
| 2 | import SwiftUI |
| 3 | |
| 4 | struct QuickDraft: Codable { |
| 5 | let calendar_id: Int64 |
| 6 | let title, location, start_local, end_local, tz: String |
| 7 | let all_day: Bool |
| 8 | let `repeat`: String |
| 9 | let attendees: [String] |
| 10 | } |
| 11 | struct QuickPreview: Decodable { |
| 12 | let draft: QuickDraft |
| 13 | let when, repeat_label, calendar_name: String |
| 14 | let assumptions: [String] |
| 15 | } |
| 16 | struct QuickAddView: View { |
| 17 | let client: NativeHTTP |
| 18 | @ObservedObject var model: CalendarModel |
| 19 | @State private var preview: QuickPreview? |
| 20 | @State private var error = "" |
| 21 | @State private var parsing = false |
| 22 | @FocusState private var focused: Bool |
| 23 | private var requestKey: String { model.zone + "\n" + model.quickText } |
| 24 | var body: some View { |
| 25 | ScrollView { |
| 26 | VStack(alignment: .leading, spacing: 12) { |
| 27 | TextField("Lunch tomorrow at 1pm for an hour", text: $model.quickText, axis: .vertical) |
| 28 | .lineLimit(2...4) |
| 29 | #if os(macOS) |
| 30 | .textFieldStyle(.plain) |
| 31 | .padding(10) |
| 32 | .background(.background, in: RoundedRectangle(cornerRadius: 8)) |
| 33 | .overlay { |
| 34 | RoundedRectangle(cornerRadius: 8) |
| 35 | .strokeBorder(focused ? Color.accentColor : Color.secondary.opacity(0.3), lineWidth: 1) |
| 36 | } |
| 37 | #else |
| 38 | .textFieldStyle(.roundedBorder) |
| 39 | #endif |
| 40 | .accessibilityLabel("Describe an event").accessibilityIdentifier("quickAddText") |
| 41 | .focused($focused).disabled(model.quickSaving) |
| 42 | Text(model.zone.replacingOccurrences(of: "_", with: " ")).font(.footnote).foregroundStyle(.secondary) |
| 43 | if parsing { ProgressView("Understanding your event…") } |
| 44 | if let preview { |
| 45 | VStack(alignment: .leading, spacing: 8) { |
| 46 | Text(preview.draft.title).font(.headline) |
| 47 | Label(preview.when, systemImage: "clock") |
| 48 | Label(preview.calendar_name, systemImage: "calendar") |
| 49 | if !preview.draft.location.isEmpty { Label(preview.draft.location, systemImage: "mappin") } |
| 50 | if !preview.repeat_label.isEmpty { Label(preview.repeat_label, systemImage: "repeat") } |
| 51 | if !preview.draft.attendees.isEmpty { |
| 52 | Label(preview.draft.attendees.joined(separator: ", "), systemImage: "person.2") |
| 53 | Text("Attendees are recorded, not invited. No invitations are sent.").font(.footnote) |
| 54 | } |
| 55 | ForEach(preview.assumptions, id: \.self) { Text($0).font(.footnote).foregroundStyle(.secondary) } |
| 56 | }.accessibilityIdentifier("quickAddPreview") |
| 57 | Button(model.quickSaving ? "Adding…" : "Add event", systemImage: "plus") { save(preview) } |
| 58 | .buttonStyle(.borderedProminent).disabled(model.quickSaving || parsing) |
| 59 | .accessibilityIdentifier("quickAddSave").keyboardShortcut(.return, modifiers: .command) |
| 60 | } |
| 61 | if !error.isEmpty { Label(error, systemImage: "exclamationmark.circle").foregroundStyle(.red) } |
| 62 | if !model.quickSavedNotice.isEmpty { Label(model.quickSavedNotice, systemImage: "checkmark.circle").accessibilityIdentifier("quickAddSaved") } |
| 63 | }.frame(maxWidth: .infinity, alignment: .leading) |
| 64 | } |
| 65 | .task(id: requestKey) { |
| 66 | preview = nil; error = "" |
| 67 | guard !model.quickText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { parsing = false; return } |
| 68 | parsing = true |
| 69 | let key = requestKey |
| 70 | do { |
| 71 | try await Task.sleep(for: .milliseconds(350)) |
| 72 | let result: QuickPreview = try await client.request("/api/native/quick/parse", method: "POST", |
| 73 | body: JSONEncoder().encode(["text": model.quickText, "zone": model.zone])) |
| 74 | guard !Task.isCancelled, requestKey == key else { return } |
| 75 | preview = result; parsing = false |
| 76 | } catch { |
| 77 | guard !Task.isCancelled, requestKey == key else { return } |
| 78 | self.error = error.localizedDescription; parsing = false |
| 79 | } |
| 80 | } |
| 81 | .onChange(of: model.quickText) { _, text in preview = nil; if !text.isEmpty { model.quickSavedNotice = "" } } |
| 82 | .onChange(of: model.zone) { _, _ in preview = nil } |
| 83 | } |
| 84 | private func save(_ preview: QuickPreview) { |
| 85 | error = "" |
| 86 | Task { |
| 87 | do { try await model.saveQuick(preview, client: client); self.preview = nil } |
| 88 | catch is CancellationError { } |
| 89 | catch { self.error = error.localizedDescription } |
| 90 | } |
| 91 | } |
| 92 | } |
| 93 | |