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 | 104 | static func text(_ path: String, _ session: DocumentSession) -> String? { |
| 87 | 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 | 41 | guard case .success(let filled) = Capture.fill(chosen.template, context: context, answers: answers) else { |
| 42 | 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 | 45 | session.capture(filled.text, template: resolved.template, root: workspace.rootPaths.first, file: resolved.file) |
| 46 | 46 | } |
| 47 | 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 | 184 | private func finish() { |
| 203 | 185 | guard let template else { return } |
| 204 | 186 | let opened = context.now |
| 205 | 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 | 189 | session.capture(text, template: resolved.template, root: workspace.rootPaths.first, file: resolved.file, now: now) |
| 208 | 190 | } |
| 209 | 191 | switch placed { |
| @@ -214,17 +196,7 @@ struct CaptureView: View { | ||
| 214 | 196 | case .success(let entry): |
| 215 | 197 | commands.show("Captured to \(Self.target(template))") |
| 216 | 198 | let options = template.options |
| 217 | if options.clockIn { | |
| 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 | } | |
| 199 | for message in clock.captured(entry, options: options, session: session, opened: opened, now: now) { commands.show(message) } | |
| 228 | 200 | if options.jumpToCaptured { commands.jump(path: entry.path, offset: entry.offset) } |
| 229 | 201 | } |
| 230 | 202 | close() |
Sources/OrgstarMobile/AgendaScreen.swift +4
| @@ -13,6 +13,7 @@ struct AgendaScreen: View { | ||
| 13 | 13 | @Bindable var agenda: AgendaModel |
| 14 | 14 | let scheduler: ReminderScheduler |
| 15 | 15 | @State private var message: String? |
| 16 | @Environment(ClockModel.self) private var clock | |
| 16 | 17 | @State private var tick = 0 |
| 17 | 18 | @State private var views = AgendaViewLoader.builtIn |
| 18 | 19 | @State private var viewName = "Agenda" |
| @@ -187,6 +188,9 @@ struct AgendaScreen: View { | ||
| 187 | 188 | Button("Deadline…", systemImage: "calendar.badge.exclamationmark") { run("org.deadline", on: item) } |
| 188 | 189 | Button("Tags…", systemImage: "tag") { run("org.tags.set", on: item) } |
| 189 | 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 | 194 | Divider() |
| 191 | 195 | Button("Refile…", systemImage: "arrow.turn.down.right") { refiling = item } |
| 192 | 196 | Button("Archive…", systemImage: "archivebox") { archiving = item } |
Sources/OrgstarMobile/CaptureScreen.swift +14 −16
| @@ -10,6 +10,8 @@ struct CaptureScreen: View { | ||
| 10 | 10 | let session: DocumentSession |
| 11 | 11 | let request: CaptureRequest |
| 12 | 12 | @Environment(\.dismiss) private var dismiss |
| 13 | @Environment(ClockModel.self) private var clock | |
| 14 | @Environment(MobileModel.self) private var model | |
| 13 | 15 | @State private var templates: [CaptureTemplate] = [] |
| 14 | 16 | @State private var key: String? |
| 15 | 17 | @State private var context = CaptureContext() |
| @@ -108,23 +110,19 @@ struct CaptureScreen: View { | ||
| 108 | 110 | |
| 109 | 111 | private func file() { |
| 110 | 112 | guard let template else { return } |
| 111 | var file: String? | |
| 112 | switch template.target { | |
| 113 | case .id(let id): | |
| 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 | |
| 113 | let now = Date() | |
| 114 | let placed = template.resolved(workspace: workspace, session: session, clock: clock).flatMap { resolved in | |
| 115 | session.capture(text, template: resolved.template, root: workspace.rootPaths.first, file: resolved.file, now: now) | |
| 124 | 116 | } |
| 125 | switch session.capture(text, template: template, root: workspace.rootPaths.first, file: file) { | |
| 126 | case .failure(let error): problem = error.message | |
| 127 | case .success: dismiss() | |
| 117 | switch placed { | |
| 118 | case .failure(let error): | |
| 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 | 17 | @State private var prompt: PromptRequest? |
| 18 | 18 | @State private var message: String? |
| 19 | 19 | @State private var sheet: Sheet? |
| 20 | @Environment(ClockModel.self) private var clock | |
| 21 | @Environment(MobileModel.self) private var model | |
| 20 | 22 | @Environment(\.openURL) private var openURL |
| 21 | 23 | |
| 22 | 24 | struct PromptRequest: Identifiable { |
| @@ -50,6 +52,7 @@ struct EditorScreen: View { | ||
| 50 | 52 | Button("Undo", systemImage: "arrow.uturn.backward") { editor?.run("edit.undo") } |
| 51 | 53 | Menu("More", systemImage: "ellipsis.circle") { |
| 52 | 54 | Button("Commands", systemImage: "command") { sheet = .palette } |
| 55 | Button("Clock In", systemImage: "stopwatch") { editor?.run("app.clock.in") } | |
| 53 | 56 | Button("Sync Conflict Copies", systemImage: "doc.on.doc") { sheet = .syncCopies } |
| 54 | 57 | Button("Recovered Versions", systemImage: "clock.arrow.circlepath") { sheet = .recovery } |
| 55 | 58 | } |
| @@ -111,6 +114,16 @@ struct EditorScreen: View { | ||
| 111 | 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 | 127 | self.editor = editor |
| 115 | 128 | } |
| 116 | 129 | if let editor { session.attach(editor) } |
| @@ -306,6 +319,8 @@ struct CommandPalette: View { | ||
| 306 | 319 | private static let viewCommands: [(id: String, title: String)] = [ |
| 307 | 320 | ("org.cycle", "Cycle Visibility"), ("org.cycle-global", "Cycle Global Visibility"), ("org.link.open", "Open Link"), |
| 308 | 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 | 326 | var body: some View { |
Sources/OrgstarMobile/MobileEditor.swift +5
| @@ -60,6 +60,8 @@ final class MobileEditor: NSObject, DocumentBuffer { | ||
| 60 | 60 | var onMessage: ((String) -> Void)? |
| 61 | 61 | var onPrompt: ((Prompt, @escaping (String?) -> Void) -> Void)? |
| 62 | 62 | var onLink: ((OrgLink) -> Void)? |
| 63 | /// Commands the app runs, as `app.clock.in`. | |
| 64 | var onAppCommand: ((String) -> Void)? | |
| 63 | 65 | /// Tags used across the workspace, for completion. |
| 64 | 66 | var knownTags: @Sendable () -> [String] = { [] } |
| 65 | 67 | |
| @@ -183,6 +185,8 @@ final class MobileEditor: NSObject, DocumentBuffer { | ||
| 183 | 185 | case "org.link.open": openLinkAtCaret() |
| 184 | 186 | case "edit.undo": textView.undoManager?.undo() |
| 185 | 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 | 190 | default: |
| 187 | 191 | if let command = Commands.command(id) { |
| 188 | 192 | _ = perform(command) |
| @@ -198,6 +202,7 @@ final class MobileEditor: NSObject, DocumentBuffer { | ||
| 198 | 202 | case "org.cycle": |
| 199 | 203 | return Presentation.heading(containing: context.caret, in: context.tree) != nil || Wrappers.toggleable(at: context.caret, in: context.tree) != nil |
| 200 | 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 | 206 | case "org.link.open": return Links.link(at: context.caret, text: context.text, tree: context.tree) != nil |
| 202 | 207 | default: return false |
| 203 | 208 | } |
Sources/OrgstarMobile/MobileModel.swift +1
| @@ -16,6 +16,7 @@ final class MobileModel { | ||
| 16 | 16 | let session = DocumentSession() |
| 17 | 17 | let agenda: AgendaModel? |
| 18 | 18 | let scheduler = ReminderScheduler(center: SystemReminderCenter()) |
| 19 | let clock = ClockModel() | |
| 19 | 20 | private(set) var configFolder: URL? |
| 20 | 21 | private(set) var configProblems: [String] = [] |
| 21 | 22 | /// An entry to show, from a tapped reminder. |
Sources/OrgstarMobile/OrgstarMobileApp.swift +27
| @@ -14,6 +14,8 @@ struct OrgstarMobileApp: App { | ||
| 14 | 14 | WindowGroup { |
| 15 | 15 | if let workspace = model.workspace, let agenda = model.agenda { |
| 16 | 16 | RootView(model: model, workspace: workspace, agenda: agenda) |
| 17 | .environment(model) | |
| 18 | .environment(model.clock) | |
| 17 | 19 | .onAppear { delegate.model = model } |
| 18 | 20 | } else { |
| 19 | 21 | ContentUnavailableView("Can't open the index", systemImage: "exclamationmark.triangle", description: Text(model.failure ?? "")) |
| @@ -71,6 +73,8 @@ struct RootView: View { | ||
| 71 | 73 | @State private var tab = AppTab.agenda |
| 72 | 74 | @State private var agendaPath = NavigationPath() |
| 73 | 75 | @State private var hour = 0 |
| 76 | @State private var showingClockReport = false | |
| 77 | @State private var message: String? | |
| 74 | 78 | @Environment(\.scenePhase) private var scenePhase |
| 75 | 79 | |
| 76 | 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 | 129 | .onChange(of: model.openRequest) { |
| 114 | 130 | guard let route = model.openRequest else { return } |
| 115 | 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 | 190 | private var captureButton: some ToolbarContent { |
| 164 | 191 | ToolbarItem(placement: .primaryAction) { |
| 165 | 192 | Button("Capture", systemImage: "square.and.pencil") { capture = CaptureRequest() } |