Commit 414cb44451
Verified · cmc
Layout: unified · split
Sources/OrgApp/CaptureModel.swift +21
| @@ -147,3 +147,24 @@ struct CapturePlacement: OrgCommand { | |||
| 147 | } | 147 | } |
| 148 | } | 148 | } |
| 149 | } | 149 | } |
| 150 | |||
| 151 | extension CaptureTemplate { | ||
| 152 | /// `id` and `clock` targets as a file and a heading. | ||
| 153 | @MainActor | ||
| 154 | public func resolved( | ||
| 155 | workspace: WorkspaceModel, session: DocumentSession, clock: ClockModel | ||
| 156 | ) -> Result<(template: CaptureTemplate, file: String?), CaptureError> { | ||
| 157 | switch target { | ||
| 158 | case .id(let id): | ||
| 159 | guard let location = try? workspace.index.headings(withID: id).first else { | ||
| 160 | return .failure(CaptureError(message: "Cannot find target ID \"\(id)\"")) | ||
| 161 | } | ||
| 162 | return .success((self, location.path)) | ||
| 163 | case .clock: | ||
| 164 | guard let entry = clock.entry(session) else { return .failure(CaptureError(message: "No running clock")) } | ||
| 165 | return .success((retargeted(.heading(file: entry.path, offset: entry.offset)), entry.path)) | ||
| 166 | default: | ||
| 167 | return .success((self, nil)) | ||
| 168 | } | ||
| 169 | } | ||
| 170 | } | ||
Sources/OrgApp/ClockModel.swift +18
| @@ -83,6 +83,24 @@ public final class ClockModel { | |||
| 83 | } | 83 | } |
| 84 | } | 84 | } |
| 85 | 85 | ||
| 86 | /// A template's `clock-in` once its entry is filed: clocked from when capture began and, | ||
| 87 | /// without `clock-keep`, clocked out now, the clock it interrupted resuming with | ||
| 88 | /// `clock-resume`. The messages, in order. | ||
| 89 | public func captured( | ||
| 90 | _ entry: (path: String, offset: Int), options: CaptureTemplate.Options, session: DocumentSession, opened: Date, now: Date = Date() | ||
| 91 | ) -> [String] { | ||
| 92 | guard options.clockIn else { return [] } | ||
| 93 | let previous = self.entry(session) | ||
| 94 | var messages = [clockIn(session, path: entry.path, at: entry.offset, now: opened)] | ||
| 95 | if !options.clockKeep { | ||
| 96 | messages.append(clockOut(session, now: now)) | ||
| 97 | if options.clockResume, let previous { | ||
| 98 | messages.append(clockIn(session, path: previous.path, at: previous.offset, now: now)) | ||
| 99 | } | ||
| 100 | } | ||
| 101 | return messages | ||
| 102 | } | ||
| 103 | |||
| 86 | static func text(_ path: String, _ session: DocumentSession) -> String? { | 104 | static func text(_ path: String, _ session: DocumentSession) -> String? { |
| 87 | session.openDocuments.first { $0.path == path }?.document.text ?? (try? String(contentsOfFile: path, encoding: .utf8)) | 105 | session.openDocuments.first { $0.path == path }?.document.text ?? (try? String(contentsOfFile: path, encoding: .utf8)) |
| 88 | } | 106 | } |
Sources/Orgstar/CaptureIntent.swift +1 −1
| @@ -41,7 +41,7 @@ struct CaptureIntent: AppIntent { | |||
| 41 | guard case .success(let filled) = Capture.fill(chosen.template, context: context, answers: answers) else { | 41 | guard case .success(let filled) = Capture.fill(chosen.template, context: context, answers: answers) else { |
| 42 | throw Failure(message: "Can't fill template \"\(chosen.key)\"") | 42 | throw Failure(message: "Can't fill template \"\(chosen.key)\"") |
| 43 | } | 43 | } |
| 44 | let placed = CaptureView.resolved(chosen, workspace: workspace, session: session, clock: clock).flatMap { resolved in | 44 | let placed = chosen.resolved(workspace: workspace, session: session, clock: clock).flatMap { resolved in |
| 45 | session.capture(filled.text, template: resolved.template, root: workspace.rootPaths.first, file: resolved.file) | 45 | session.capture(filled.text, template: resolved.template, root: workspace.rootPaths.first, file: resolved.file) |
| 46 | } | 46 | } |
| 47 | switch placed { | 47 | switch placed { |
Sources/Orgstar/CaptureView.swift +2 −30
| @@ -181,29 +181,11 @@ struct CaptureView: View { | |||
| 181 | } | 181 | } |
| 182 | } | 182 | } |
| 183 | 183 | ||
| 184 | /// `id` and `clock` targets as a file and a heading. | ||
| 185 | static func resolved( | ||
| 186 | _ template: CaptureTemplate, workspace: WorkspaceModel, session: DocumentSession, clock: ClockModel | ||
| 187 | ) -> Result<(template: CaptureTemplate, file: String?), CaptureError> { | ||
| 188 | switch template.target { | ||
| 189 | case .id(let id): | ||
| 190 | guard let location = try? workspace.index.headings(withID: id).first else { | ||
| 191 | return .failure(CaptureError(message: "Cannot find target ID \"\(id)\"")) | ||
| 192 | } | ||
| 193 | return .success((template, location.path)) | ||
| 194 | case .clock: | ||
| 195 | guard let entry = clock.entry(session) else { return .failure(CaptureError(message: "No running clock")) } | ||
| 196 | return .success((template.retargeted(.heading(file: entry.path, offset: entry.offset)), entry.path)) | ||
| 197 | default: | ||
| 198 | return .success((template, nil)) | ||
| 199 | } | ||
| 200 | } | ||
| 201 | |||
| 202 | private func finish() { | 184 | private func finish() { |
| 203 | guard let template else { return } | 185 | guard let template else { return } |
| 204 | let opened = context.now | 186 | let opened = context.now |
| 205 | let now = Date() | 187 | let now = Date() |
| 206 | let placed = Self.resolved(template, workspace: workspace, session: session, clock: clock).flatMap { resolved in | 188 | let placed = template.resolved(workspace: workspace, session: session, clock: clock).flatMap { resolved in |
| 207 | session.capture(text, template: resolved.template, root: workspace.rootPaths.first, file: resolved.file, now: now) | 189 | session.capture(text, template: resolved.template, root: workspace.rootPaths.first, file: resolved.file, now: now) |
| 208 | } | 190 | } |
| 209 | switch placed { | 191 | switch placed { |
| @@ -214,17 +196,7 @@ struct CaptureView: View { | |||
| 214 | case .success(let entry): | 196 | case .success(let entry): |
| 215 | commands.show("Captured to \(Self.target(template))") | 197 | commands.show("Captured to \(Self.target(template))") |
| 216 | let options = template.options | 198 | let options = template.options |
| 217 | if options.clockIn { | 199 | for message in clock.captured(entry, options: options, session: session, opened: opened, now: now) { commands.show(message) } |
| 218 | // Clocked from when capture began; the clock it interrupted resumes after. | ||
| 219 | let previous = clock.entry(session) | ||
| 220 | commands.show(clock.clockIn(session, path: entry.path, at: entry.offset, now: opened)) | ||
| 221 | if !options.clockKeep { | ||
| 222 | commands.show(clock.clockOut(session, now: now)) | ||
| 223 | if options.clockResume, let previous { | ||
| 224 | commands.show(clock.clockIn(session, path: previous.path, at: previous.offset, now: now)) | ||
| 225 | } | ||
| 226 | } | ||
| 227 | } | ||
| 228 | if options.jumpToCaptured { commands.jump(path: entry.path, offset: entry.offset) } | 200 | if options.jumpToCaptured { commands.jump(path: entry.path, offset: entry.offset) } |
| 229 | } | 201 | } |
| 230 | close() | 202 | close() |
Sources/OrgstarMobile/AgendaScreen.swift +4
| @@ -13,6 +13,7 @@ struct AgendaScreen: View { | |||
| 13 | @Bindable var agenda: AgendaModel | 13 | @Bindable var agenda: AgendaModel |
| 14 | let scheduler: ReminderScheduler | 14 | let scheduler: ReminderScheduler |
| 15 | @State private var message: String? | 15 | @State private var message: String? |
| 16 | @Environment(ClockModel.self) private var clock | ||
| 16 | @State private var tick = 0 | 17 | @State private var tick = 0 |
| 17 | @State private var views = AgendaViewLoader.builtIn | 18 | @State private var views = AgendaViewLoader.builtIn |
| 18 | @State private var viewName = "Agenda" | 19 | @State private var viewName = "Agenda" |
| @@ -187,6 +188,9 @@ struct AgendaScreen: View { | |||
| 187 | Button("Deadline…", systemImage: "calendar.badge.exclamationmark") { run("org.deadline", on: item) } | 188 | Button("Deadline…", systemImage: "calendar.badge.exclamationmark") { run("org.deadline", on: item) } |
| 188 | Button("Tags…", systemImage: "tag") { run("org.tags.set", on: item) } | 189 | Button("Tags…", systemImage: "tag") { run("org.tags.set", on: item) } |
| 189 | Button("Priority…", systemImage: "exclamationmark.circle") { run("org.priority.set", on: item) } | 190 | Button("Priority…", systemImage: "exclamationmark.circle") { run("org.priority.set", on: item) } |
| 191 | Button("Clock In", systemImage: "stopwatch") { | ||
| 192 | if let (path, offset) = locate(item) { message = clock.clockIn(session, path: path, at: offset) } | ||
| 193 | } | ||
| 190 | Divider() | 194 | Divider() |
| 191 | Button("Refile…", systemImage: "arrow.turn.down.right") { refiling = item } | 195 | Button("Refile…", systemImage: "arrow.turn.down.right") { refiling = item } |
| 192 | Button("Archive…", systemImage: "archivebox") { archiving = item } | 196 | Button("Archive…", systemImage: "archivebox") { archiving = item } |
Sources/OrgstarMobile/CaptureScreen.swift +14 −16
| @@ -10,6 +10,8 @@ struct CaptureScreen: View { | |||
| 10 | let session: DocumentSession | 10 | let session: DocumentSession |
| 11 | let request: CaptureRequest | 11 | let request: CaptureRequest |
| 12 | @Environment(\.dismiss) private var dismiss | 12 | @Environment(\.dismiss) private var dismiss |
| 13 | @Environment(ClockModel.self) private var clock | ||
| 14 | @Environment(MobileModel.self) private var model | ||
| 13 | @State private var templates: [CaptureTemplate] = [] | 15 | @State private var templates: [CaptureTemplate] = [] |
| 14 | @State private var key: String? | 16 | @State private var key: String? |
| 15 | @State private var context = CaptureContext() | 17 | @State private var context = CaptureContext() |
| @@ -108,23 +110,19 @@ struct CaptureScreen: View { | |||
| 108 | 110 | ||
| 109 | private func file() { | 111 | private func file() { |
| 110 | guard let template else { return } | 112 | guard let template else { return } |
| 111 | var file: String? | 113 | let now = Date() |
| 112 | switch template.target { | 114 | let placed = template.resolved(workspace: workspace, session: session, clock: clock).flatMap { resolved in |
| 113 | case .id(let id): | 115 | session.capture(text, template: resolved.template, root: workspace.rootPaths.first, file: resolved.file, now: now) |
| 114 | guard let location = try? workspace.index.headings(withID: id).first else { | ||
| 115 | problem = "Cannot find target ID \"\(id)\"" | ||
| 116 | return | ||
| 117 | } | ||
| 118 | file = location.path | ||
| 119 | case .clock: | ||
| 120 | problem = "There's no running clock on iOS" | ||
| 121 | return | ||
| 122 | default: | ||
| 123 | break | ||
| 124 | } | 116 | } |
| 125 | switch session.capture(text, template: template, root: workspace.rootPaths.first, file: file) { | 117 | switch placed { |
| 126 | case .failure(let error): problem = error.message | 118 | case .failure(let error): |
| 127 | case .success: dismiss() | 119 | problem = error.message |
| 120 | editing = true | ||
| 121 | case .success(let entry): | ||
| 122 | // Clocked from when capture began. | ||
| 123 | _ = clock.captured(entry, options: template.options, session: session, opened: context.now, now: now) | ||
| 124 | if template.options.jumpToCaptured { model.openRequest = .offset(entry.path, entry.offset) } | ||
| 125 | dismiss() | ||
| 128 | } | 126 | } |
| 129 | } | 127 | } |
| 130 | } | 128 | } |
Sources/OrgstarMobile/ClockScreens.swift added +129
| @@ -0,0 +1,129 @@ | |||
| 1 | #if os(iOS) | ||
| 2 | import OrgApp | ||
| 3 | import OrgCore | ||
| 4 | import SwiftUI | ||
| 5 | |||
| 6 | /// The running clock over the tab bar: the entry and time so far, and clocking out. | ||
| 7 | struct ClockAccessory: View { | ||
| 8 | let running: ClockModel.Running | ||
| 9 | let perform: (String) -> Void | ||
| 10 | |||
| 11 | var body: some View { | ||
| 12 | Menu { | ||
| 13 | Button("Clock Out", systemImage: "stop.circle") { perform("app.clock.out") } | ||
| 14 | Button("Cancel Clock", systemImage: "xmark.circle") { perform("app.clock.cancel") } | ||
| 15 | Button("Go to Clocked Entry", systemImage: "arrow.right.circle") { perform("app.clock.goto") } | ||
| 16 | Button("Clock Report", systemImage: "list.bullet.rectangle") { perform("app.clock.report") } | ||
| 17 | } label: { | ||
| 18 | HStack { | ||
| 19 | Image(systemName: "stopwatch") | ||
| 20 | Text(running.heading).lineLimit(1) | ||
| 21 | Spacer() | ||
| 22 | TimelineView(.periodic(from: .now, by: 30)) { context in | ||
| 23 | Text(Self.elapsed(running, now: context.date)).monospacedDigit() | ||
| 24 | } | ||
| 25 | } | ||
| 26 | .padding(.horizontal) | ||
| 27 | } | ||
| 28 | .accessibilityLabel("Clocked in on \(running.heading)") | ||
| 29 | } | ||
| 30 | |||
| 31 | /// Elapsed time of the running clock, `H:MM`. | ||
| 32 | static func elapsed(_ running: ClockModel.Running, now: Date) -> String { | ||
| 33 | let minutes = max(0, Int(now.timeIntervalSince(running.startedAt) / 60)) | ||
| 34 | return String(format: "%d:%02d", minutes / 60, minutes % 60) | ||
| 35 | } | ||
| 36 | } | ||
| 37 | |||
| 38 | extension View { | ||
| 39 | /// The running clock over the tab bar, while there is one. | ||
| 40 | @ViewBuilder | ||
| 41 | func clockAccessory(_ running: ClockModel.Running?, perform: @escaping (String) -> Void) -> some View { | ||
| 42 | if #available(iOS 26.1, *) { | ||
| 43 | tabViewBottomAccessory(isEnabled: running != nil) { | ||
| 44 | if let running { ClockAccessory(running: running, perform: perform) } | ||
| 45 | } | ||
| 46 | } else { | ||
| 47 | safeAreaInset(edge: .bottom) { | ||
| 48 | if let running { ClockAccessory(running: running, perform: perform).padding(.vertical, 8).background(.bar) } | ||
| 49 | } | ||
| 50 | } | ||
| 51 | } | ||
| 52 | } | ||
| 53 | |||
| 54 | /// Time per day and heading, as `tr/generate-report` writes it, for chosen files and dates. | ||
| 55 | struct ClockReportScreen: View { | ||
| 56 | let workspace: WorkspaceModel | ||
| 57 | @State private var start = Calendar.current.date(byAdding: .day, value: -6, to: Date()) ?? Date() | ||
| 58 | @State private var end = Date() | ||
| 59 | @State private var limit = false | ||
| 60 | @State private var selected: Set<String> = [] | ||
| 61 | @State private var report = "" | ||
| 62 | @Environment(\.dismiss) private var dismiss | ||
| 63 | |||
| 64 | private var candidates: [FileEntry] { workspace.files.filter { $0.kind == .org } } | ||
| 65 | |||
| 66 | var body: some View { | ||
| 67 | NavigationStack { | ||
| 68 | Form { | ||
| 69 | Section("Files") { | ||
| 70 | ForEach(candidates) { file in | ||
| 71 | Toggle(file.relativePath, isOn: Binding { | ||
| 72 | selected.contains(file.path) | ||
| 73 | } set: { on in | ||
| 74 | if on { selected.insert(file.path) } else { selected.remove(file.path) } | ||
| 75 | }) | ||
| 76 | } | ||
| 77 | } | ||
| 78 | Section { | ||
| 79 | Toggle("Limit dates", isOn: $limit) | ||
| 80 | if limit { | ||
| 81 | DatePicker("From", selection: $start, displayedComponents: .date) | ||
| 82 | DatePicker("To", selection: $end, displayedComponents: .date) | ||
| 83 | } | ||
| 84 | } | ||
| 85 | Section("Report") { | ||
| 86 | ScrollView(.horizontal) { | ||
| 87 | Text(report.isEmpty ? "Choose files with clocked time." : report) | ||
| 88 | .font(.footnote.monospaced()) | ||
| 89 | .textSelection(.enabled) | ||
| 90 | .fixedSize() | ||
| 91 | } | ||
| 92 | } | ||
| 93 | } | ||
| 94 | .navigationTitle("Clock Report") | ||
| 95 | .navigationBarTitleDisplayMode(.inline) | ||
| 96 | .toolbar { | ||
| 97 | ToolbarItem(placement: .cancellationAction) { Button("Close") { dismiss() } } | ||
| 98 | ToolbarItem(placement: .primaryAction) { | ||
| 99 | ShareLink(item: report, preview: SharePreview("Time report")).disabled(report.isEmpty) | ||
| 100 | } | ||
| 101 | } | ||
| 102 | } | ||
| 103 | .onAppear { | ||
| 104 | if selected.isEmpty { | ||
| 105 | selected = Set(candidates.filter { (try? String(contentsOfFile: $0.path, encoding: .utf8))?.contains("CLOCK: [") ?? false }.map(\.path)) | ||
| 106 | } | ||
| 107 | compute() | ||
| 108 | } | ||
| 109 | .onChange(of: selected) { compute() } | ||
| 110 | .onChange(of: start) { compute() } | ||
| 111 | .onChange(of: end) { compute() } | ||
| 112 | .onChange(of: limit) { compute() } | ||
| 113 | } | ||
| 114 | |||
| 115 | private func day(_ date: Date) -> String { | ||
| 116 | let c = Calendar.current.dateComponents([.year, .month, .day], from: date) | ||
| 117 | return String(format: "%04d-%02d-%02d", c.year!, c.month!, c.day!) | ||
| 118 | } | ||
| 119 | |||
| 120 | private func compute() { | ||
| 121 | var entries: [ClockReport.Entry] = [] | ||
| 122 | for path in selected.sorted() { | ||
| 123 | guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { continue } | ||
| 124 | entries += ClockReport.entries(text, settings: workspace.settings.org) | ||
| 125 | } | ||
| 126 | report = selected.isEmpty ? "" : ClockReport.timeReport(entries, start: limit ? day(start) : nil, end: limit ? day(end) : nil) | ||
| 127 | } | ||
| 128 | } | ||
| 129 | #endif | ||
Sources/OrgstarMobile/EditorScreen.swift +15
| @@ -17,6 +17,8 @@ struct EditorScreen: View { | |||
| 17 | @State private var prompt: PromptRequest? | 17 | @State private var prompt: PromptRequest? |
| 18 | @State private var message: String? | 18 | @State private var message: String? |
| 19 | @State private var sheet: Sheet? | 19 | @State private var sheet: Sheet? |
| 20 | @Environment(ClockModel.self) private var clock | ||
| 21 | @Environment(MobileModel.self) private var model | ||
| 20 | @Environment(\.openURL) private var openURL | 22 | @Environment(\.openURL) private var openURL |
| 21 | 23 | ||
| 22 | struct PromptRequest: Identifiable { | 24 | struct PromptRequest: Identifiable { |
| @@ -50,6 +52,7 @@ struct EditorScreen: View { | |||
| 50 | Button("Undo", systemImage: "arrow.uturn.backward") { editor?.run("edit.undo") } | 52 | Button("Undo", systemImage: "arrow.uturn.backward") { editor?.run("edit.undo") } |
| 51 | Menu("More", systemImage: "ellipsis.circle") { | 53 | Menu("More", systemImage: "ellipsis.circle") { |
| 52 | Button("Commands", systemImage: "command") { sheet = .palette } | 54 | Button("Commands", systemImage: "command") { sheet = .palette } |
| 55 | Button("Clock In", systemImage: "stopwatch") { editor?.run("app.clock.in") } | ||
| 53 | Button("Sync Conflict Copies", systemImage: "doc.on.doc") { sheet = .syncCopies } | 56 | Button("Sync Conflict Copies", systemImage: "doc.on.doc") { sheet = .syncCopies } |
| 54 | Button("Recovered Versions", systemImage: "clock.arrow.circlepath") { sheet = .recovery } | 57 | Button("Recovered Versions", systemImage: "clock.arrow.circlepath") { sheet = .recovery } |
| 55 | } | 58 | } |
| @@ -111,6 +114,16 @@ struct EditorScreen: View { | |||
| 111 | case .message(let text): message = text | 114 | case .message(let text): message = text |
| 112 | } | 115 | } |
| 113 | } | 116 | } |
| 117 | editor.onAppCommand = { id in | ||
| 118 | switch id { | ||
| 119 | case "app.clock.in": message = clock.clockIn(session) | ||
| 120 | case "app.clock.out": message = clock.clockOut(session) | ||
| 121 | case "app.clock.cancel": message = clock.clockOut(session, cancel: true) | ||
| 122 | case "app.clock.goto": | ||
| 123 | if let location = clock.location(session) { model.openRequest = .offset(location.path, location.offset) } else { message = "No active clock" } | ||
| 124 | default: message = "Not available on iOS yet" | ||
| 125 | } | ||
| 126 | } | ||
| 114 | self.editor = editor | 127 | self.editor = editor |
| 115 | } | 128 | } |
| 116 | if let editor { session.attach(editor) } | 129 | if let editor { session.attach(editor) } |
| @@ -306,6 +319,8 @@ struct CommandPalette: View { | |||
| 306 | private static let viewCommands: [(id: String, title: String)] = [ | 319 | private static let viewCommands: [(id: String, title: String)] = [ |
| 307 | ("org.cycle", "Cycle Visibility"), ("org.cycle-global", "Cycle Global Visibility"), ("org.link.open", "Open Link"), | 320 | ("org.cycle", "Cycle Visibility"), ("org.cycle-global", "Cycle Global Visibility"), ("org.link.open", "Open Link"), |
| 308 | ("edit.undo", "Undo"), ("edit.redo", "Redo"), | 321 | ("edit.undo", "Undo"), ("edit.redo", "Redo"), |
| 322 | ("app.clock.in", "Clock In"), ("app.clock.out", "Clock Out"), ("app.clock.cancel", "Cancel Clock"), | ||
| 323 | ("app.clock.goto", "Go to Clocked Entry"), | ||
| 309 | ] | 324 | ] |
| 310 | 325 | ||
| 311 | var body: some View { | 326 | var body: some View { |
Sources/OrgstarMobile/MobileEditor.swift +5
| @@ -60,6 +60,8 @@ final class MobileEditor: NSObject, DocumentBuffer { | |||
| 60 | var onMessage: ((String) -> Void)? | 60 | var onMessage: ((String) -> Void)? |
| 61 | var onPrompt: ((Prompt, @escaping (String?) -> Void) -> Void)? | 61 | var onPrompt: ((Prompt, @escaping (String?) -> Void) -> Void)? |
| 62 | var onLink: ((OrgLink) -> Void)? | 62 | var onLink: ((OrgLink) -> Void)? |
| 63 | /// Commands the app runs, as `app.clock.in`. | ||
| 64 | var onAppCommand: ((String) -> Void)? | ||
| 63 | /// Tags used across the workspace, for completion. | 65 | /// Tags used across the workspace, for completion. |
| 64 | var knownTags: @Sendable () -> [String] = { [] } | 66 | var knownTags: @Sendable () -> [String] = { [] } |
| 65 | 67 | ||
| @@ -183,6 +185,8 @@ final class MobileEditor: NSObject, DocumentBuffer { | |||
| 183 | case "org.link.open": openLinkAtCaret() | 185 | case "org.link.open": openLinkAtCaret() |
| 184 | case "edit.undo": textView.undoManager?.undo() | 186 | case "edit.undo": textView.undoManager?.undo() |
| 185 | case "edit.redo": textView.undoManager?.redo() | 187 | case "edit.redo": textView.undoManager?.redo() |
| 188 | case _ where id.hasPrefix("app."): | ||
| 189 | if let onAppCommand { onAppCommand(id) } else { onMessage?("Not available on iOS yet") } | ||
| 186 | default: | 190 | default: |
| 187 | if let command = Commands.command(id) { | 191 | if let command = Commands.command(id) { |
| 188 | _ = perform(command) | 192 | _ = perform(command) |
| @@ -198,6 +202,7 @@ final class MobileEditor: NSObject, DocumentBuffer { | |||
| 198 | case "org.cycle": | 202 | case "org.cycle": |
| 199 | return Presentation.heading(containing: context.caret, in: context.tree) != nil || Wrappers.toggleable(at: context.caret, in: context.tree) != nil | 203 | return Presentation.heading(containing: context.caret, in: context.tree) != nil || Wrappers.toggleable(at: context.caret, in: context.tree) != nil |
| 200 | case "org.cycle-global", "edit.undo", "edit.redo": return true | 204 | case "org.cycle-global", "edit.undo", "edit.redo": return true |
| 205 | case "app.clock.in", "app.clock.out", "app.clock.cancel", "app.clock.goto": return onAppCommand != nil | ||
| 201 | case "org.link.open": return Links.link(at: context.caret, text: context.text, tree: context.tree) != nil | 206 | case "org.link.open": return Links.link(at: context.caret, text: context.text, tree: context.tree) != nil |
| 202 | default: return false | 207 | default: return false |
| 203 | } | 208 | } |
Sources/OrgstarMobile/MobileModel.swift +1
| @@ -16,6 +16,7 @@ final class MobileModel { | |||
| 16 | let session = DocumentSession() | 16 | let session = DocumentSession() |
| 17 | let agenda: AgendaModel? | 17 | let agenda: AgendaModel? |
| 18 | let scheduler = ReminderScheduler(center: SystemReminderCenter()) | 18 | let scheduler = ReminderScheduler(center: SystemReminderCenter()) |
| 19 | let clock = ClockModel() | ||
| 19 | private(set) var configFolder: URL? | 20 | private(set) var configFolder: URL? |
| 20 | private(set) var configProblems: [String] = [] | 21 | private(set) var configProblems: [String] = [] |
| 21 | /// An entry to show, from a tapped reminder. | 22 | /// An entry to show, from a tapped reminder. |
Sources/OrgstarMobile/OrgstarMobileApp.swift +27
| @@ -14,6 +14,8 @@ struct OrgstarMobileApp: App { | |||
| 14 | WindowGroup { | 14 | WindowGroup { |
| 15 | if let workspace = model.workspace, let agenda = model.agenda { | 15 | if let workspace = model.workspace, let agenda = model.agenda { |
| 16 | RootView(model: model, workspace: workspace, agenda: agenda) | 16 | RootView(model: model, workspace: workspace, agenda: agenda) |
| 17 | .environment(model) | ||
| 18 | .environment(model.clock) | ||
| 17 | .onAppear { delegate.model = model } | 19 | .onAppear { delegate.model = model } |
| 18 | } else { | 20 | } else { |
| 19 | ContentUnavailableView("Can't open the index", systemImage: "exclamationmark.triangle", description: Text(model.failure ?? "")) | 21 | ContentUnavailableView("Can't open the index", systemImage: "exclamationmark.triangle", description: Text(model.failure ?? "")) |
| @@ -71,6 +73,8 @@ struct RootView: View { | |||
| 71 | @State private var tab = AppTab.agenda | 73 | @State private var tab = AppTab.agenda |
| 72 | @State private var agendaPath = NavigationPath() | 74 | @State private var agendaPath = NavigationPath() |
| 73 | @State private var hour = 0 | 75 | @State private var hour = 0 |
| 76 | @State private var showingClockReport = false | ||
| 77 | @State private var message: String? | ||
| 74 | @Environment(\.scenePhase) private var scenePhase | 78 | @Environment(\.scenePhase) private var scenePhase |
| 75 | 79 | ||
| 76 | private var session: DocumentSession { model.session } | 80 | private var session: DocumentSession { model.session } |
| @@ -110,6 +114,18 @@ struct RootView: View { | |||
| 110 | } | 114 | } |
| 111 | } | 115 | } |
| 112 | } | 116 | } |
| 117 | .clockAccessory(model.clock.running, perform: clock) | ||
| 118 | .overlay(alignment: .top) { | ||
| 119 | if let message { | ||
| 120 | Text(message).font(.callout).padding(8).frame(maxWidth: .infinity).background(.bar) | ||
| 121 | .onTapGesture { self.message = nil } | ||
| 122 | .task(id: message) { | ||
| 123 | try? await Task.sleep(for: .seconds(4)) | ||
| 124 | if self.message == message { self.message = nil } | ||
| 125 | } | ||
| 126 | } | ||
| 127 | } | ||
| 128 | .sheet(isPresented: $showingClockReport) { ClockReportScreen(workspace: workspace) } | ||
| 113 | .onChange(of: model.openRequest) { | 129 | .onChange(of: model.openRequest) { |
| 114 | guard let route = model.openRequest else { return } | 130 | guard let route = model.openRequest else { return } |
| 115 | model.openRequest = nil | 131 | model.openRequest = nil |
| @@ -160,6 +176,17 @@ struct RootView: View { | |||
| 160 | } | 176 | } |
| 161 | } | 177 | } |
| 162 | 178 | ||
| 179 | private func clock(_ id: String) { | ||
| 180 | switch id { | ||
| 181 | case "app.clock.out": message = model.clock.clockOut(session) | ||
| 182 | case "app.clock.cancel": message = model.clock.clockOut(session, cancel: true) | ||
| 183 | case "app.clock.goto": | ||
| 184 | if let location = model.clock.location(session) { model.openRequest = .offset(location.path, location.offset) } | ||
| 185 | case "app.clock.report": showingClockReport = true | ||
| 186 | default: break | ||
| 187 | } | ||
| 188 | } | ||
| 189 | |||
| 163 | private var captureButton: some ToolbarContent { | 190 | private var captureButton: some ToolbarContent { |
| 164 | ToolbarItem(placement: .primaryAction) { | 191 | ToolbarItem(placement: .primaryAction) { |
| 165 | Button("Capture", systemImage: "square.and.pencil") { capture = CaptureRequest() } | 192 | Button("Capture", systemImage: "square.and.pencil") { capture = CaptureRequest() } |