Sources/OrgstarMobile/CaptureScreen.swift
183 lines · 7718 bytes
7 symbols in this file
1#if os(iOS)
2import OrgApp
3import OrgCore
4import SwiftUI
5
6/// Capture with a template: its questions (`%^{…}`, `%^g`, `%^t`…), then its text to edit,
7/// filed where the template says, as `org-capture` does.
8struct CaptureScreen: View {
9 let workspace: WorkspaceModel
10 let session: DocumentSession
11 let request: CaptureRequest
12 @Environment(\.dismiss) private var dismiss
13 @Environment(ClockModel.self) private var clock
14 @Environment(MobileModel.self) private var model
15 @State private var templates: [CaptureTemplate] = []
16 @State private var key: String?
17 @State private var context = CaptureContext()
18 @State private var questions: [Capture.Question] = []
19 @State private var answers: [String] = []
20 @State private var editing = false
21 @State private var text = ""
22 @State private var selection: TextSelection?
23 @State private var problem: String?
24 @FocusState private var focused: Bool
25
26 private var template: CaptureTemplate? { templates.first { $0.key == key } }
27
28 var body: some View {
29 NavigationStack {
30 Form {
31 Picker("Template", selection: $key) {
32 ForEach(templates) { Text($0.name).tag(Optional($0.key)) }
33 }
34 if editing {
35 Section {
36 TextEditor(text: $text, selection: $selection)
37 .font(.body.monospaced())
38 .frame(minHeight: 200)
39 .focused($focused)
40 } footer: {
41 if let problem { Text(problem).foregroundStyle(.red) }
42 }
43 } else {
44 ForEach(questions.indices, id: \.self) { index in
45 QuestionField(question: questions[index], answer: $answers[index])
46 }
47 if let problem { Text(problem).foregroundStyle(.red) }
48 }
49 }
50 .navigationTitle("Capture")
51 .navigationBarTitleDisplayMode(.inline)
52 .toolbar {
53 ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } }
54 ToolbarItem(placement: .confirmationAction) {
55 if editing {
56 Button("File", action: file).disabled(template == nil)
57 } else {
58 Button("Continue", action: fill)
59 }
60 }
61 }
62 }
63 .onAppear {
64 let loaded = CaptureTemplates.load()
65 templates = loaded.templates
66 problem = loaded.problems.first
67 key = request.template.flatMap { wanted in templates.first { $0.key == wanted }?.key } ?? templates.first?.key
68 if let wanted = request.template, !templates.contains(where: { $0.key == wanted }) { problem = "No capture template \"\(wanted)\"" }
69 }
70 .onChange(of: key, initial: true) { choose() }
71 }
72
73 /// The template's questions, asked before its text; none, and the text shows at once.
74 private func choose() {
75 guard let template else { return }
76 context = CaptureContext(
77 initial: request.properties["initial"] ?? "", annotation: request.properties["annotation"] ?? "",
78 allTags: (try? workspace.index.tags()) ?? []
79 )
80 context.linkProperties = request.properties
81 // `%^g` offers the target file's tags.
82 let path = DocumentSession.resolve(template.target.file, root: workspace.rootPaths.first)
83 if !template.target.file.isEmpty,
84 let source = session.openTexts[path] ?? (try? String(contentsOfFile: path, encoding: .utf8)) {
85 var tags: [String] = []
86 for heading in DocumentModel(tree: OrgParser.parse(source)).headings { for tag in heading.localTags where !tags.contains(tag) { tags.append(tag) } }
87 context.fileTags = tags
88 }
89 questions = Capture.questions(template.template, context: context)
90 answers = questions.map { _ in "" }
91 editing = false
92 if questions.isEmpty { fill() }
93 }
94
95 /// The template expanded with the answers; the caret where `%?` was.
96 private func fill() {
97 guard let template else { return }
98 context.now = Date()
99 switch Capture.fill(template.template, context: context, answers: answers) {
100 case .success(let filled):
101 text = filled.text
102 selection = TextSelection(insertionPoint: String.Index(utf16Offset: filled.cursor, in: filled.text))
103 problem = nil
104 editing = true
105 focused = true
106 if template.options.immediateFinish { file() }
107 case .failure(.message(let message)):
108 problem = message
109 }
110 }
111
112 private func file() {
113 guard let template else { return }
114 let now = Date()
115 let placed = template.resolved(workspace: workspace, session: session, clock: clock).flatMap { resolved in
116 session.capture(text, template: resolved.template, root: workspace.rootPaths.first, file: resolved.file, now: now)
117 }
118 switch placed {
119 case .failure(let error):
120 problem = error.message
121 editing = true
122 case .success(let entry):
123 // Clocked from when capture began.
124 _ = clock.captured(entry, options: template.options, session: session, opened: context.now, now: now)
125 if template.options.jumpToCaptured { model.openRequest = .offset(entry.path, entry.offset) }
126 dismiss()
127 }
128 }
129}
130
131/// One of a template's questions: text with its choices, tags, or a date.
132struct QuestionField: View {
133 let question: Capture.Question
134 @Binding var answer: String
135 @State private var date = Date()
136
137 var body: some View {
138 Section {
139 TextField(placeholder, text: $answer)
140 .autocorrectionDisabled()
141 .textInputAutocapitalization(.never)
142 if case .date(let withTime, _) = question.kind {
143 DatePicker("Date", selection: $date, displayedComponents: withTime ? [.date, .hourAndMinute] : [.date])
144 .onChange(of: date) { answer = Self.stamp(date, withTime: withTime) }
145 }
146 if !question.choices.isEmpty {
147 ScrollView(.horizontal, showsIndicators: false) {
148 HStack {
149 ForEach(question.choices.prefix(30), id: \.self) { choice in
150 Button(choice) { pick(choice) }.buttonStyle(.bordered)
151 }
152 }
153 }
154 }
155 } header: {
156 Text(question.prompt)
157 }
158 }
159
160 /// What an empty answer means.
161 private var placeholder: String {
162 switch question.kind {
163 case .text, .property, .clipboard: question.defaultValue
164 case .tags: "tag:tag"
165 case .date(let withTime, _): withTime ? "now; or +2d, fri 14:00" : "today; or +2d, fri"
166 }
167 }
168
169 /// Tags add to what's typed; other choices replace it.
170 private func pick(_ choice: String) {
171 guard question.kind == .tags else { return answer = choice }
172 let tags = answer.split(separator: ":").map(String.init)
173 if !tags.contains(choice) { answer = (tags + [choice]).joined(separator: ":") }
174 }
175
176 static func stamp(_ date: Date, withTime: Bool) -> String {
177 let formatter = DateFormatter()
178 formatter.locale = Locale(identifier: "en_US_POSIX")
179 formatter.dateFormat = withTime ? "yyyy-MM-dd HH:mm" : "yyyy-MM-dd"
180 return formatter.string(from: date)
181 }
182}
183#endif