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 | import OrgCore | 3 | import OrgCore |
| 4 | import SwiftUI | 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 | struct CaptureScreen: View { | 8 | struct CaptureScreen: View { |
| 8 | let workspace: WorkspaceModel | 9 | let workspace: WorkspaceModel |
| 9 | let session: DocumentSession | 10 | let session: DocumentSession |
| @@ -11,6 +12,10 @@ struct CaptureScreen: View { | |||
| 11 | @Environment(\.dismiss) private var dismiss | 12 | @Environment(\.dismiss) private var dismiss |
| 12 | @State private var templates: [CaptureTemplate] = [] | 13 | @State private var templates: [CaptureTemplate] = [] |
| 13 | @State private var key: String? | 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 | @State private var text = "" | 19 | @State private var text = "" |
| 15 | @State private var selection: TextSelection? | 20 | @State private var selection: TextSelection? |
| 16 | @State private var problem: String? | 21 | @State private var problem: String? |
| @@ -24,12 +29,19 @@ struct CaptureScreen: View { | |||
| 24 | Picker("Template", selection: $key) { | 29 | Picker("Template", selection: $key) { |
| 25 | ForEach(templates) { Text($0.name).tag(Optional($0.key)) } | 30 | ForEach(templates) { Text($0.name).tag(Optional($0.key)) } |
| 26 | } | 31 | } |
| 27 | Section { | 32 | if editing { |
| 28 | TextEditor(text: $text, selection: $selection) | 33 | Section { |
| 29 | .font(.body.monospaced()) | 34 | TextEditor(text: $text, selection: $selection) |
| 30 | .frame(minHeight: 200) | 35 | .font(.body.monospaced()) |
| 31 | .focused($focused) | 36 | .frame(minHeight: 200) |
| 32 | } footer: { | 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 | if let problem { Text(problem).foregroundStyle(.red) } | 45 | if let problem { Text(problem).foregroundStyle(.red) } |
| 34 | } | 46 | } |
| 35 | } | 47 | } |
| @@ -37,7 +49,13 @@ struct CaptureScreen: View { | |||
| 37 | .navigationBarTitleDisplayMode(.inline) | 49 | .navigationBarTitleDisplayMode(.inline) |
| 38 | .toolbar { | 50 | .toolbar { |
| 39 | ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } | 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 | .onAppear { | 61 | .onAppear { |
| @@ -45,26 +63,46 @@ struct CaptureScreen: View { | |||
| 45 | templates = loaded.templates | 63 | templates = loaded.templates |
| 46 | problem = loaded.problems.first | 64 | problem = loaded.problems.first |
| 47 | key = request.template.flatMap { wanted in templates.first { $0.key == wanted }?.key } ?? templates.first?.key | 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. | 70 | /// The template's questions, asked before its text; none, and the text shows at once. |
| 54 | private func fill() { | 71 | private func choose() { |
| 55 | guard let template else { return } | 72 | guard let template else { return } |
| 56 | var context = CaptureContext( | 73 | context = CaptureContext( |
| 57 | initial: request.properties["initial"] ?? "", annotation: request.properties["annotation"] ?? "", | 74 | initial: request.properties["initial"] ?? "", annotation: request.properties["annotation"] ?? "", |
| 58 | allTags: (try? workspace.index.tags()) ?? [] | 75 | allTags: (try? workspace.index.tags()) ?? [] |
| 59 | ) | 76 | ) |
| 60 | context.linkProperties = request.properties | 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 | switch Capture.fill(template.template, context: context, answers: answers) { | 96 | switch Capture.fill(template.template, context: context, answers: answers) { |
| 63 | case .success(let filled): | 97 | case .success(let filled): |
| 64 | text = filled.text | 98 | text = filled.text |
| 65 | // Where the template's `%?` was. | ||
| 66 | selection = TextSelection(insertionPoint: String.Index(utf16Offset: filled.cursor, in: filled.text)) | 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 | #endif | 184 | #endif |