Commit 86bed886a4
Verified · cmc
Layout: unified · split
Sources/OrgstarMobile/CaptureScreen.swift +43 −6
| @@ -42,7 +42,7 @@ struct CaptureScreen: View { | |||
| 42 | } | 42 | } |
| 43 | } else { | 43 | } else { |
| 44 | ForEach(questions.indices, id: \.self) { index in | 44 | ForEach(questions.indices, id: \.self) { index in |
| 45 | QuestionField(question: questions[index], answer: $answers[index]) | 45 | QuestionField(question: questions[index], answer: $answers[index], defaultDate: request.day.map(Self.date)) |
| 46 | } | 46 | } |
| 47 | if let problem { Text(problem).foregroundStyle(.red) } | 47 | if let problem { Text(problem).foregroundStyle(.red) } |
| 48 | } | 48 | } |
| @@ -95,11 +95,12 @@ struct CaptureScreen: View { | |||
| 95 | /// The template expanded with the answers; the caret where `%?` was. | 95 | /// The template expanded with the answers; the caret where `%?` was. |
| 96 | private func fill() { | 96 | private func fill() { |
| 97 | guard let template else { return } | 97 | guard let template else { return } |
| 98 | context.now = Date() | 98 | context.now = request.day.map(Self.date) ?? Date() |
| 99 | switch Capture.fill(template.template, context: context, answers: answers) { | 99 | switch Capture.fill(template.template, context: context, answers: answers) { |
| 100 | case .success(let filled): | 100 | case .success(let filled): |
| 101 | text = filled.text | 101 | let (filledText, cursor) = request.day.map { Self.scheduling(filled.text, cursor: filled.cursor, on: $0) } ?? (filled.text, filled.cursor) |
| 102 | selection = TextSelection(insertionPoint: String.Index(utf16Offset: filled.cursor, in: filled.text)) | 102 | text = filledText |
| 103 | selection = TextSelection(insertionPoint: String.Index(utf16Offset: cursor, in: filledText)) | ||
| 103 | problem = nil | 104 | problem = nil |
| 104 | editing = true | 105 | editing = true |
| 105 | focused = true | 106 | focused = true |
| @@ -109,6 +110,31 @@ struct CaptureScreen: View { | |||
| 109 | } | 110 | } |
| 110 | } | 111 | } |
| 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 | /// An entry captured from an agenda day is scheduled on it: a SCHEDULED line goes under | ||
| 124 | /// the heading unless the template already gave the entry an active timestamp. The caret | ||
| 125 | /// keeps its place in the text. | ||
| 126 | static func scheduling(_ text: String, cursor: Int, on day: Int) -> (String, Int) { | ||
| 127 | guard text.hasPrefix("*"), text.firstMatch(of: /<[0-9]{4}-[0-9]{2}-[0-9]{2}/) == nil else { return (text, cursor) } | ||
| 128 | let d = Days.date(day) | ||
| 129 | let weekday = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][Days.weekday(day)] | ||
| 130 | let line = String(format: "SCHEDULED: <%04d-%02d-%02d ", d.year, d.month, d.day) + weekday + ">" | ||
| 131 | let utf16 = Array(text.utf16) | ||
| 132 | let end = utf16.firstIndex(of: 10) ?? utf16.count | ||
| 133 | let inserted = "\n" + line | ||
| 134 | let result = String(decoding: utf16[..<end], as: UTF16.self) + inserted + String(decoding: utf16[end...], as: UTF16.self) | ||
| 135 | return (result, cursor > end ? cursor + inserted.utf16.count : cursor) | ||
| 136 | } | ||
| 137 | |||
| 112 | private func file() { | 138 | private func file() { |
| 113 | guard let template else { return } | 139 | guard let template else { return } |
| 114 | let now = Date() | 140 | let now = Date() |
| @@ -135,7 +161,16 @@ struct CaptureScreen: View { | |||
| 135 | struct QuestionField: View { | 161 | struct QuestionField: View { |
| 136 | let question: Capture.Question | 162 | let question: Capture.Question |
| 137 | @Binding var answer: String | 163 | @Binding var answer: String |
| 138 | @State private var date = Date() | 164 | /// What an empty date answer means: now, or the agenda day capture started from. |
| 165 | let defaultDate: Date? | ||
| 166 | @State private var date: Date | ||
| 167 | |||
| 168 | init(question: Capture.Question, answer: Binding<String>, defaultDate: Date? = nil) { | ||
| 169 | self.question = question | ||
| 170 | _answer = answer | ||
| 171 | self.defaultDate = defaultDate | ||
| 172 | _date = State(initialValue: defaultDate ?? Date()) | ||
| 173 | } | ||
| 139 | 174 | ||
| 140 | var body: some View { | 175 | var body: some View { |
| 141 | Section { | 176 | Section { |
| @@ -165,7 +200,9 @@ struct QuestionField: View { | |||
| 165 | switch question.kind { | 200 | switch question.kind { |
| 166 | case .text, .property, .clipboard: question.defaultValue | 201 | case .text, .property, .clipboard: question.defaultValue |
| 167 | case .tags: "tag:tag" | 202 | case .tags: "tag:tag" |
| 168 | case .date(let withTime, _): withTime ? "now; or +2d, fri 14:00" : "today; or +2d, fri" | 203 | case .date(let withTime, _): |
| 204 | defaultDate.map { Self.stamp($0, withTime: withTime) + "; or +2d, fri" } | ||
| 205 | ?? (withTime ? "now; or +2d, fri 14:00" : "today; or +2d, fri") | ||
| 169 | } | 206 | } |
| 170 | } | 207 | } |
| 171 | 208 | ||
Sources/OrgstarMobile/MobileModel.swift +27
| @@ -32,6 +32,10 @@ final class MobileModel { | |||
| 32 | var openRequest: ReaderRoute? | 32 | var openRequest: ReaderRoute? |
| 33 | /// Agenda days to show, from a followed timestamp: the first (a `Days` number) and how many. | 33 | /// Agenda days to show, from a followed timestamp: the first (a `Days` number) and how many. |
| 34 | var agendaRequest: AgendaDays? | 34 | var agendaRequest: AgendaDays? |
| 35 | /// A capture to show, from the agenda. | ||
| 36 | var captureRequest: CaptureRequest? | ||
| 37 | /// Calendar's events for the agenda, with `calendar-events`. | ||
| 38 | let calendarEvents = CalendarEvents() | ||
| 35 | 39 | ||
| 36 | struct AgendaDays: Equatable { | 40 | struct AgendaDays: Equatable { |
| 37 | let start: Int | 41 | let start: Int |
| @@ -115,10 +119,33 @@ final class MobileModel { | |||
| 115 | agenda.startOffset = OrgPreferences.agendaStartOffset() | 119 | agenda.startOffset = OrgPreferences.agendaStartOffset() |
| 116 | agenda.includeSubfolders = OrgPreferences.agendaSubfolders() | 120 | agenda.includeSubfolders = OrgPreferences.agendaSubfolders() |
| 117 | agenda.options = OrgPreferences.agendaOptions(agenda.options) | 121 | agenda.options = OrgPreferences.agendaOptions(agenda.options) |
| 122 | agenda.calendarEvents = OrgPreferences.calendarEvents() ? calendarEvents : nil | ||
| 123 | agenda.calendarNames = OrgPreferences.calendarEventCalendars() | ||
| 118 | } | 124 | } |
| 119 | settingsVersion += 1 | 125 | settingsVersion += 1 |
| 120 | } | 126 | } |
| 121 | 127 | ||
| 128 | /// Changes a setting from the app: in the folder's config.toml when there is a folder, as | ||
| 129 | /// the Mac's Settings write it, else in this device's settings. | ||
| 130 | func set(_ defaultsKey: String, to value: TOML.Value) { | ||
| 131 | guard let setting = UserConfig.settings.first(where: { $0.defaultsKey == defaultsKey }) else { return } | ||
| 132 | if let configFolder { | ||
| 133 | let file = configFolder.appendingPathComponent("config.toml") | ||
| 134 | var error: NSError? | ||
| 135 | NSFileCoordinator(filePresenter: nil).coordinate(writingItemAt: file, options: [], error: &error) { url in | ||
| 136 | let text = (try? String(contentsOf: url, encoding: .utf8)) ?? "" | ||
| 137 | try? UserConfig.setting(text, section: setting.section, key: setting.key, to: value).write(to: url, atomically: true, encoding: .utf8) | ||
| 138 | } | ||
| 139 | } else { | ||
| 140 | switch value { | ||
| 141 | case .bool(let b): UserDefaults.standard.set(b, forKey: defaultsKey) | ||
| 142 | case .string(let s): UserDefaults.standard.set(s, forKey: defaultsKey) | ||
| 143 | case .integer(let i): UserDefaults.standard.set(i, forKey: defaultsKey) | ||
| 144 | } | ||
| 145 | } | ||
| 146 | reloadConfig() | ||
| 147 | } | ||
| 148 | |||
| 122 | /// The capture templates, for the share extension to offer. | 149 | /// The capture templates, for the share extension to offer. |
| 123 | private func shareTemplates() { | 150 | private func shareTemplates() { |
| 124 | try? inbox?.setTemplates(CaptureTemplates.load().templates.map { CaptureInbox.Template(key: $0.key, name: $0.name) }) | 151 | try? inbox?.setTemplates(CaptureTemplates.load().templates.map { CaptureInbox.Template(key: $0.key, name: $0.name) }) |
Sources/OrgstarMobile/OrgstarMobileApp.swift +7
| @@ -64,6 +64,8 @@ struct CaptureRequest: Identifiable { | |||
| 64 | let id = UUID() | 64 | let id = UUID() |
| 65 | var template: String? | 65 | var template: String? |
| 66 | var properties: [String: String] = [:] | 66 | var properties: [String: String] = [:] |
| 67 | /// An agenda day (a `Days` number) the entry is scheduled on. | ||
| 68 | var day: Int? | ||
| 67 | } | 69 | } |
| 68 | 70 | ||
| 69 | extension CaptureRequest { | 71 | extension CaptureRequest { |
| @@ -167,6 +169,11 @@ struct RootView: View { | |||
| 167 | // An open file takes in what changed on disk. | 169 | // An open file takes in what changed on disk. |
| 168 | if let path = workspace.changedFile?.path { session.fileChanged(path) } | 170 | if let path = workspace.changedFile?.path { session.fileChanged(path) } |
| 169 | } | 171 | } |
| 172 | .onChange(of: model.captureRequest?.id) { | ||
| 173 | guard let request = model.captureRequest else { return } | ||
| 174 | model.captureRequest = nil | ||
| 175 | capture = request | ||
| 176 | } | ||
| 170 | .sheet(item: $capture, onDismiss: showWaiting) { request in | 177 | .sheet(item: $capture, onDismiss: showWaiting) { request in |
| 171 | CaptureScreen(workspace: workspace, session: session, request: request) | 178 | CaptureScreen(workspace: workspace, session: session, request: request) |
| 172 | } | 179 | } |
Sources/OrgstarMobile/SettingsScreen.swift +68
| @@ -1,6 +1,7 @@ | |||
| 1 | #if os(iOS) | 1 | #if os(iOS) |
| 2 | import OrgApp | 2 | import OrgApp |
| 3 | import OrgCore | 3 | import OrgCore |
| 4 | import OrgPresentation | ||
| 4 | import SwiftUI | 5 | import SwiftUI |
| 5 | import UIKit | 6 | import UIKit |
| 6 | 7 | ||
| @@ -9,6 +10,13 @@ import UIKit | |||
| 9 | struct SettingsScreen: View { | 10 | struct SettingsScreen: View { |
| 10 | let model: MobileModel | 11 | let model: MobileModel |
| 11 | @State private var choosing = false | 12 | @State private var choosing = false |
| 13 | @State private var calendars: [CalendarChoice] = [] | ||
| 14 | |||
| 15 | private struct CalendarChoice { | ||
| 16 | let id: String | ||
| 17 | let title: String | ||
| 18 | let color: RGBA? | ||
| 19 | } | ||
| 12 | 20 | ||
| 13 | var body: some View { | 21 | var body: some View { |
| 14 | Form { | 22 | Form { |
| @@ -24,6 +32,7 @@ struct SettingsScreen: View { | |||
| 24 | } footer: { | 32 | } footer: { |
| 25 | Text("A folder with config.toml and capture.toml, such as a synced copy of ~/.config/orgstar from your Mac. Changes to them apply as they sync.") | 33 | Text("A folder with config.toml and capture.toml, such as a synced copy of ~/.config/orgstar from your Mac. Changes to them apply as they sync.") |
| 26 | } | 34 | } |
| 35 | calendarSection | ||
| 27 | Section("In use") { | 36 | Section("In use") { |
| 28 | let _ = model.settingsVersion | 37 | let _ = model.settingsVersion |
| 29 | LabeledContent("TODO keywords", value: todoKeywords) | 38 | LabeledContent("TODO keywords", value: todoKeywords) |
| @@ -40,6 +49,65 @@ struct SettingsScreen: View { | |||
| 40 | } | 49 | } |
| 41 | } | 50 | } |
| 42 | 51 | ||
| 52 | /// `calendar-events` and `calendar-event-calendars`, written to config.toml. | ||
| 53 | private var calendarSection: some View { | ||
| 54 | let _ = model.settingsVersion | ||
| 55 | let on = OrgPreferences.calendarEvents() | ||
| 56 | let chosen = OrgPreferences.calendarEventCalendars() | ||
| 57 | return Section { | ||
| 58 | Toggle("Show Calendar Events", isOn: Binding { | ||
| 59 | on | ||
| 60 | } set: { value in | ||
| 61 | model.set("calendarEvents", to: .bool(value)) | ||
| 62 | if value { Task { await requestCalendars() } } | ||
| 63 | }) | ||
| 64 | if on { | ||
| 65 | if !CalendarEvents.isAuthorized { | ||
| 66 | Button("Allow Access to Calendars…") { Task { await requestCalendars() } } | ||
| 67 | } | ||
| 68 | ForEach(calendars, id: \.id) { calendar in | ||
| 69 | let shown = CalendarEvents.chooses(id: calendar.id, title: calendar.title, chosen: chosen) | ||
| 70 | Button { | ||
| 71 | toggle(calendar.title, shown: shown, chosen: chosen) | ||
| 72 | } label: { | ||
| 73 | HStack { | ||
| 74 | Circle().fill(calendar.color?.swiftUI ?? .secondary).frame(width: 10, height: 10) | ||
| 75 | Text(calendar.title).foregroundStyle(.primary) | ||
| 76 | Spacer() | ||
| 77 | if shown { Image(systemName: "checkmark").foregroundStyle(Color.accentColor) } | ||
| 78 | } | ||
| 79 | } | ||
| 80 | .tint(.primary) | ||
| 81 | } | ||
| 82 | } | ||
| 83 | } header: { | ||
| 84 | Text("Calendar") | ||
| 85 | } footer: { | ||
| 86 | Text(on ? "Events from the checked calendars show in the agenda, read-only." : "Show events from Calendar in the agenda, read-only.") | ||
| 87 | } | ||
| 88 | .task(id: on) { loadCalendars() } | ||
| 89 | } | ||
| 90 | |||
| 91 | private func requestCalendars() async { | ||
| 92 | _ = await model.calendarEvents.requestAccess() | ||
| 93 | loadCalendars() | ||
| 94 | model.reloadConfig() | ||
| 95 | } | ||
| 96 | |||
| 97 | private func loadCalendars() { | ||
| 98 | calendars = model.calendarEvents.calendars() | ||
| 99 | .sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending } | ||
| 100 | .map { CalendarChoice(id: $0.id, title: $0.title, color: $0.color) } | ||
| 101 | } | ||
| 102 | |||
| 103 | /// Shows or hides a calendar's events; with every calendar checked the setting is empty. | ||
| 104 | private func toggle(_ title: String, shown: Bool, chosen: [String]) { | ||
| 105 | var titles = chosen.isEmpty ? calendars.map(\.title) : chosen | ||
| 106 | if shown { titles.removeAll { $0 == title } } else { titles.append(title) } | ||
| 107 | let value = Set(titles) == Set(calendars.map(\.title)) ? "" : titles.joined(separator: ", ") | ||
| 108 | model.set("calendarEventCalendars", to: .string(value)) | ||
| 109 | } | ||
| 110 | |||
| 43 | /// The span and start day, worded as the Mac's Settings word the start. | 111 | /// The span and start day, worded as the Mac's Settings word the start. |
| 44 | private var agenda: String { | 112 | private var agenda: String { |
| 45 | func days(_ count: Int) -> String { count == 1 ? "1 day" : "\(count) days" } | 113 | func days(_ count: Int) -> String { count == 1 ? "1 day" : "\(count) days" } |