Sources/OrgstarMobile/CaptureScreen.swift
208 lines · 9132 bytes
8 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], defaultDate: request.day.map(Self.date))
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 = request.day.map(Self.date) ?? Date()
99 switch Capture.fill(template.template, context: context, answers: answers) {
100 case .success(let filled):
101 let (filledText, cursor) = request.day.map { Capture.scheduling(filled.text, cursor: filled.cursor, on: $0) } ?? (filled.text, filled.cursor)
102 text = filledText
103 selection = TextSelection(insertionPoint: String.Index(utf16Offset: cursor, in: filledText))
104 problem = nil
105 editing = true
106 focused = true
107 if template.options.immediateFinish { file() }
108 case .failure(.message(let message)):
109 problem = message
110 }
111 }
112
113 /// Now's time of day on `day` (a `Days` number): the default time of an agenda day's
114 /// capture, as `org-agenda-capture` sets `org-overriding-default-time`.
115 static func date(_ day: Int) -> Date {
116 let calendar = Calendar.current
117 let d = Days.date(day)
118 var parts = calendar.dateComponents([.hour, .minute, .second], from: Date())
119 (parts.year, parts.month, parts.day) = (d.year, d.month, d.day)
120 return calendar.date(from: parts) ?? Date()
121 }
122
123 private func file() {
124 guard let template else { return }
125 let now = Date()
126 let placed = template.resolved(workspace: workspace, session: session, clock: clock).flatMap { resolved in
127 session.capture(text, template: resolved.template, root: workspace.rootPaths.first, file: resolved.file, now: request.day.map(Self.date) ?? now)
128 }
129 switch placed {
130 case .failure(let error):
131 problem = error.message
132 editing = true
133 case .success(let entry):
134 // Clocked from when capture began.
135 let paths = workspace.files.filter { $0.kind == .org }.map(\.path)
136 Task { [clock, session, opened = context.now] in
137 _ = await clock.captured(entry, options: template.options, session: session, paths: paths, ask: model.clockPrompts.ask, opened: opened, now: now)
138 }
139 if template.options.jumpToCaptured { model.openRequest = .offset(entry.path, entry.offset) }
140 dismiss()
141 }
142 }
143}
144
145/// One of a template's questions: text with its choices, tags, or a date.
146struct QuestionField: View {
147 let question: Capture.Question
148 @Binding var answer: String
149 /// What an empty date answer means: now, or the agenda day capture started from.
150 let defaultDate: Date?
151 @State private var date: Date
152
153 init(question: Capture.Question, answer: Binding<String>, defaultDate: Date? = nil) {
154 self.question = question
155 _answer = answer
156 self.defaultDate = defaultDate
157 _date = State(initialValue: defaultDate ?? Date())
158 }
159
160 var body: some View {
161 Section {
162 TextField(placeholder, text: $answer)
163 .autocorrectionDisabled()
164 .textInputAutocapitalization(.never)
165 if case .date(let withTime, _) = question.kind {
166 DatePicker("Date", selection: $date, displayedComponents: withTime ? [.date, .hourAndMinute] : [.date])
167 .onChange(of: date) { answer = Self.stamp(date, withTime: withTime) }
168 }
169 if !question.choices.isEmpty {
170 ScrollView(.horizontal, showsIndicators: false) {
171 HStack {
172 ForEach(question.choices.prefix(30), id: \.self) { choice in
173 Button(choice) { pick(choice) }.buttonStyle(.bordered)
174 }
175 }
176 }
177 }
178 } header: {
179 Text(question.prompt)
180 }
181 }
182
183 /// What an empty answer means.
184 private var placeholder: String {
185 switch question.kind {
186 case .text, .property, .clipboard: question.defaultValue
187 case .tags: "tag:tag"
188 case .date(let withTime, _):
189 defaultDate.map { Self.stamp($0, withTime: withTime) + "; or +2d, fri" }
190 ?? (withTime ? "now; or +2d, fri 14:00" : "today; or +2d, fri")
191 }
192 }
193
194 /// Tags add to what's typed; other choices replace it.
195 private func pick(_ choice: String) {
196 guard question.kind == .tags else { return answer = choice }
197 let tags = answer.split(separator: ":").map(String.init)
198 if !tags.contains(choice) { answer = (tags + [choice]).joined(separator: ":") }
199 }
200
201 static func stamp(_ date: Date, withTime: Bool) -> String {
202 let formatter = DateFormatter()
203 formatter.locale = Locale(identifier: "en_US_POSIX")
204 formatter.dateFormat = withTime ? "yyyy-MM-dd HH:mm" : "yyyy-MM-dd"
205 return formatter.string(from: date)
206 }
207}
208#endif