iOS capture: the template's questions !115
1 file changed, +107 −16
Layout: unified · split
Sources/OrgstarMobile/CaptureScreen.swift +107 −16
| @@ -3,7 +3,8 @@ import OrgApp | ||
| 3 | 3 | import OrgCore |
| 4 | 4 | import SwiftUI |
| 5 | 5 | |
| 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. | |
| 7 | 8 | struct CaptureScreen: View { |
| 8 | 9 | let workspace: WorkspaceModel |
| 9 | 10 | let session: DocumentSession |
| @@ -11,6 +12,10 @@ struct CaptureScreen: View { | ||
| 11 | 12 | @Environment(\.dismiss) private var dismiss |
| 12 | 13 | @State private var templates: [CaptureTemplate] = [] |
| 13 | 14 | @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 | |
| 14 | 19 | @State private var text = "" |
| 15 | 20 | @State private var selection: TextSelection? |
| 16 | 21 | @State private var problem: String? |
| @@ -24,12 +29,19 @@ struct CaptureScreen: View { | ||
| 24 | 29 | Picker("Template", selection: $key) { |
| 25 | 30 | ForEach(templates) { Text($0.name).tag(Optional($0.key)) } |
| 26 | 31 | } |
| 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 | } | |
| 33 | 45 | if let problem { Text(problem).foregroundStyle(.red) } |
| 34 | 46 | } |
| 35 | 47 | } |
| @@ -37,7 +49,13 @@ struct CaptureScreen: View { | ||
| 37 | 49 | .navigationBarTitleDisplayMode(.inline) |
| 38 | 50 | .toolbar { |
| 39 | 51 | 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 | } | |
| 41 | 59 | } |
| 42 | 60 | } |
| 43 | 61 | .onAppear { |
| @@ -45,26 +63,46 @@ struct CaptureScreen: View { | ||
| 45 | 63 | templates = loaded.templates |
| 46 | 64 | problem = loaded.problems.first |
| 47 | 65 | key = request.template.flatMap { wanted in templates.first { $0.key == wanted }?.key } ?? templates.first?.key |
| 48 | focused = true | |
| 49 | 66 | } |
| 50 | .onChange(of: key, initial: true) { fill() } | |
| 67 | .onChange(of: key, initial: true) { choose() } | |
| 51 | 68 | } |
| 52 | 69 | |
| 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() { | |
| 55 | 72 | guard let template else { return } |
| 56 | var context = CaptureContext( | |
| 73 | context = CaptureContext( | |
| 57 | 74 | initial: request.properties["initial"] ?? "", annotation: request.properties["annotation"] ?? "", |
| 58 | 75 | allTags: (try? workspace.index.tags()) ?? [] |
| 59 | 76 | ) |
| 60 | 77 | 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() | |
| 62 | 96 | switch Capture.fill(template.template, context: context, answers: answers) { |
| 63 | 97 | case .success(let filled): |
| 64 | 98 | text = filled.text |
| 65 | // Where the template's `%?` was. | |
| 66 | 99 | 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 | |
| 68 | 106 | } |
| 69 | 107 | } |
| 70 | 108 | |
| @@ -90,4 +128,57 @@ struct CaptureScreen: View { | ||
| 90 | 128 | } |
| 91 | 129 | } |
| 92 | 130 | } |
| 131 | ||
| 132 | /// One of a template's questions: text with its choices, tags, or a date. | |
| 133 | struct 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 | } | |
| 93 | 184 | #endif |