iOS capture: the template's questions !115

merged merged by cmc on 2026-10-07 00:03 UTC · krz/orgstar:ios-capture-prompts into main

1 file changed, +107 −16

Layout: unified · split

Sources/OrgstarMobile/CaptureScreen.swift +107 −16
@@ -3,7 +3,8 @@ import OrgApp
33import OrgCore
44import SwiftUI
55
6/// Capture with a template: its text to edit, filed where the template says.
6/// Capture with a template: its questions (`%^{…}`, `%^g`, `%^t`…), then its text to edit,
7/// filed where the template says, as `org-capture` does.
78struct CaptureScreen: View {
89 let workspace: WorkspaceModel
910 let session: DocumentSession
@@ -11,6 +12,10 @@ struct CaptureScreen: View {
1112 @Environment(\.dismiss) private var dismiss
1213 @State private var templates: [CaptureTemplate] = []
1314 @State private var key: String?
15 @State private var context = CaptureContext()
16 @State private var questions: [Capture.Question] = []
17 @State private var answers: [String] = []
18 @State private var editing = false
1419 @State private var text = ""
1520 @State private var selection: TextSelection?
1621 @State private var problem: String?
@@ -24,12 +29,19 @@ struct CaptureScreen: View {
2429 Picker("Template", selection: $key) {
2530 ForEach(templates) { Text($0.name).tag(Optional($0.key)) }
2631 }
27 Section {
28 TextEditor(text: $text, selection: $selection)
29 .font(.body.monospaced())
30 .frame(minHeight: 200)
31 .focused($focused)
32 } footer: {
32 if editing {
33 Section {
34 TextEditor(text: $text, selection: $selection)
35 .font(.body.monospaced())
36 .frame(minHeight: 200)
37 .focused($focused)
38 } footer: {
39 if let problem { Text(problem).foregroundStyle(.red) }
40 }
41 } else {
42 ForEach(questions.indices, id: \.self) { index in
43 QuestionField(question: questions[index], answer: $answers[index])
44 }
3345 if let problem { Text(problem).foregroundStyle(.red) }
3446 }
3547 }
@@ -37,7 +49,13 @@ struct CaptureScreen: View {
3749 .navigationBarTitleDisplayMode(.inline)
3850 .toolbar {
3951 ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } }
40 ToolbarItem(placement: .confirmationAction) { Button("File", action: file).disabled(template == nil) }
52 ToolbarItem(placement: .confirmationAction) {
53 if editing {
54 Button("File", action: file).disabled(template == nil)
55 } else {
56 Button("Continue", action: fill)
57 }
58 }
4159 }
4260 }
4361 .onAppear {
@@ -45,26 +63,46 @@ struct CaptureScreen: View {
4563 templates = loaded.templates
4664 problem = loaded.problems.first
4765 key = request.template.flatMap { wanted in templates.first { $0.key == wanted }?.key } ?? templates.first?.key
48 focused = true
4966 }
50 .onChange(of: key, initial: true) { fill() }
67 .onChange(of: key, initial: true) { choose() }
5168 }
5269
53 /// The template expanded, its prompts taking their defaults.
54 private func fill() {
70 /// The template's questions, asked before its text; none, and the text shows at once.
71 private func choose() {
5572 guard let template else { return }
56 var context = CaptureContext(
73 context = CaptureContext(
5774 initial: request.properties["initial"] ?? "", annotation: request.properties["annotation"] ?? "",
5875 allTags: (try? workspace.index.tags()) ?? []
5976 )
6077 context.linkProperties = request.properties
61 let answers = Capture.questions(template.template, context: context).map { _ in "" }
78 // `%^g` offers the target file's tags.
79 let path = DocumentSession.resolve(template.target.file, root: workspace.rootPaths.first)
80 if !template.target.file.isEmpty,
81 let source = session.openTexts[path] ?? (try? String(contentsOfFile: path, encoding: .utf8)) {
82 var tags: [String] = []
83 for heading in DocumentModel(tree: OrgParser.parse(source)).headings { for tag in heading.localTags where !tags.contains(tag) { tags.append(tag) } }
84 context.fileTags = tags
85 }
86 questions = Capture.questions(template.template, context: context)
87 answers = questions.map { _ in "" }
88 editing = false
89 if questions.isEmpty { fill() }
90 }
91
92 /// The template expanded with the answers; the caret where `%?` was.
93 private func fill() {
94 guard let template else { return }
95 context.now = Date()
6296 switch Capture.fill(template.template, context: context, answers: answers) {
6397 case .success(let filled):
6498 text = filled.text
65 // Where the template's `%?` was.
6699 selection = TextSelection(insertionPoint: String.Index(utf16Offset: filled.cursor, in: filled.text))
67 case .failure(.message(let message)): problem = message
100 problem = nil
101 editing = true
102 focused = true
103 if template.options.immediateFinish { file() }
104 case .failure(.message(let message)):
105 problem = message
68106 }
69107 }
70108
@@ -90,4 +128,57 @@ struct CaptureScreen: View {
90128 }
91129 }
92130}
131
132/// One of a template's questions: text with its choices, tags, or a date.
133struct QuestionField: View {
134 let question: Capture.Question
135 @Binding var answer: String
136 @State private var date = Date()
137
138 var body: some View {
139 Section {
140 TextField(placeholder, text: $answer)
141 .autocorrectionDisabled()
142 .textInputAutocapitalization(.never)
143 if case .date(let withTime, _) = question.kind {
144 DatePicker("Date", selection: $date, displayedComponents: withTime ? [.date, .hourAndMinute] : [.date])
145 .onChange(of: date) { answer = Self.stamp(date, withTime: withTime) }
146 }
147 if !question.choices.isEmpty {
148 ScrollView(.horizontal, showsIndicators: false) {
149 HStack {
150 ForEach(question.choices.prefix(30), id: \.self) { choice in
151 Button(choice) { pick(choice) }.buttonStyle(.bordered)
152 }
153 }
154 }
155 }
156 } header: {
157 Text(question.prompt)
158 }
159 }
160
161 /// What an empty answer means.
162 private var placeholder: String {
163 switch question.kind {
164 case .text, .property, .clipboard: question.defaultValue
165 case .tags: "tag:tag"
166 case .date(let withTime, _): withTime ? "now; or +2d, fri 14:00" : "today; or +2d, fri"
167 }
168 }
169
170 /// Tags add to what's typed; other choices replace it.
171 private func pick(_ choice: String) {
172 guard question.kind == .tags else { return answer = choice }
173 let tags = answer.split(separator: ":").map(String.init)
174 if !tags.contains(choice) { answer = (tags + [choice]).joined(separator: ":") }
175 }
176
177 static func stamp(_ date: Date, withTime: Bool) -> String {
178 let formatter = DateFormatter()
179 formatter.locale = Locale(identifier: "en_US_POSIX")
180 formatter.dateFormat = withTime ? "yyyy-MM-dd HH:mm" : "yyyy-MM-dd"
181 return formatter.string(from: date)
182 }
183}
93184#endif