iOS: clocking !117

merged merged by cmc on 2026-10-07 00:23 UTC · krz/orgstar:ios-clocking into main

11 files changed, +237 −47

Layout: unified · split

Sources/OrgApp/CaptureModel.swift +21
@@ -147,3 +147,24 @@ struct CapturePlacement: OrgCommand {
147147 }
148148 }
149149}
150
151extension 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 {
8383 }
8484 }
8585
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
86104 static func text(_ path: String, _ session: DocumentSession) -> String? {
87105 session.openDocuments.first { $0.path == path }?.document.text ?? (try? String(contentsOfFile: path, encoding: .utf8))
88106 }
Sources/Orgstar/CaptureIntent.swift +1 −1
@@ -41,7 +41,7 @@ struct CaptureIntent: AppIntent {
4141 guard case .success(let filled) = Capture.fill(chosen.template, context: context, answers: answers) else {
4242 throw Failure(message: "Can't fill template \"\(chosen.key)\"")
4343 }
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
4545 session.capture(filled.text, template: resolved.template, root: workspace.rootPaths.first, file: resolved.file)
4646 }
4747 switch placed {
Sources/Orgstar/CaptureView.swift +2 −30
@@ -181,29 +181,11 @@ struct CaptureView: View {
181181 }
182182 }
183183
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
202184 private func finish() {
203185 guard let template else { return }
204186 let opened = context.now
205187 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
207189 session.capture(text, template: resolved.template, root: workspace.rootPaths.first, file: resolved.file, now: now)
208190 }
209191 switch placed {
@@ -214,17 +196,7 @@ struct CaptureView: View {
214196 case .success(let entry):
215197 commands.show("Captured to \(Self.target(template))")
216198 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) }
228200 if options.jumpToCaptured { commands.jump(path: entry.path, offset: entry.offset) }
229201 }
230202 close()
Sources/OrgstarMobile/AgendaScreen.swift +4
@@ -13,6 +13,7 @@ struct AgendaScreen: View {
1313 @Bindable var agenda: AgendaModel
1414 let scheduler: ReminderScheduler
1515 @State private var message: String?
16 @Environment(ClockModel.self) private var clock
1617 @State private var tick = 0
1718 @State private var views = AgendaViewLoader.builtIn
1819 @State private var viewName = "Agenda"
@@ -187,6 +188,9 @@ struct AgendaScreen: View {
187188 Button("Deadline…", systemImage: "calendar.badge.exclamationmark") { run("org.deadline", on: item) }
188189 Button("Tags…", systemImage: "tag") { run("org.tags.set", on: item) }
189190 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 }
190194 Divider()
191195 Button("Refile…", systemImage: "arrow.turn.down.right") { refiling = item }
192196 Button("Archive…", systemImage: "archivebox") { archiving = item }
Sources/OrgstarMobile/CaptureScreen.swift +14 −16
@@ -10,6 +10,8 @@ struct CaptureScreen: View {
1010 let session: DocumentSession
1111 let request: CaptureRequest
1212 @Environment(\.dismiss) private var dismiss
13 @Environment(ClockModel.self) private var clock
14 @Environment(MobileModel.self) private var model
1315 @State private var templates: [CaptureTemplate] = []
1416 @State private var key: String?
1517 @State private var context = CaptureContext()
@@ -108,23 +110,19 @@ struct CaptureScreen: View {
108110
109111 private func file() {
110112 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)
124116 }
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()
128126 }
129127 }
130128}
Sources/OrgstarMobile/ClockScreens.swift added +129
@@ -0,0 +1,129 @@
1#if os(iOS)
2import OrgApp
3import OrgCore
4import SwiftUI
5
6/// The running clock over the tab bar: the entry and time so far, and clocking out.
7struct 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
38extension 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.
55struct 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 {
1717 @State private var prompt: PromptRequest?
1818 @State private var message: String?
1919 @State private var sheet: Sheet?
20 @Environment(ClockModel.self) private var clock
21 @Environment(MobileModel.self) private var model
2022 @Environment(\.openURL) private var openURL
2123
2224 struct PromptRequest: Identifiable {
@@ -50,6 +52,7 @@ struct EditorScreen: View {
5052 Button("Undo", systemImage: "arrow.uturn.backward") { editor?.run("edit.undo") }
5153 Menu("More", systemImage: "ellipsis.circle") {
5254 Button("Commands", systemImage: "command") { sheet = .palette }
55 Button("Clock In", systemImage: "stopwatch") { editor?.run("app.clock.in") }
5356 Button("Sync Conflict Copies", systemImage: "doc.on.doc") { sheet = .syncCopies }
5457 Button("Recovered Versions", systemImage: "clock.arrow.circlepath") { sheet = .recovery }
5558 }
@@ -111,6 +114,16 @@ struct EditorScreen: View {
111114 case .message(let text): message = text
112115 }
113116 }
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 }
114127 self.editor = editor
115128 }
116129 if let editor { session.attach(editor) }
@@ -306,6 +319,8 @@ struct CommandPalette: View {
306319 private static let viewCommands: [(id: String, title: String)] = [
307320 ("org.cycle", "Cycle Visibility"), ("org.cycle-global", "Cycle Global Visibility"), ("org.link.open", "Open Link"),
308321 ("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"),
309324 ]
310325
311326 var body: some View {
Sources/OrgstarMobile/MobileEditor.swift +5
@@ -60,6 +60,8 @@ final class MobileEditor: NSObject, DocumentBuffer {
6060 var onMessage: ((String) -> Void)?
6161 var onPrompt: ((Prompt, @escaping (String?) -> Void) -> Void)?
6262 var onLink: ((OrgLink) -> Void)?
63 /// Commands the app runs, as `app.clock.in`.
64 var onAppCommand: ((String) -> Void)?
6365 /// Tags used across the workspace, for completion.
6466 var knownTags: @Sendable () -> [String] = { [] }
6567
@@ -183,6 +185,8 @@ final class MobileEditor: NSObject, DocumentBuffer {
183185 case "org.link.open": openLinkAtCaret()
184186 case "edit.undo": textView.undoManager?.undo()
185187 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") }
186190 default:
187191 if let command = Commands.command(id) {
188192 _ = perform(command)
@@ -198,6 +202,7 @@ final class MobileEditor: NSObject, DocumentBuffer {
198202 case "org.cycle":
199203 return Presentation.heading(containing: context.caret, in: context.tree) != nil || Wrappers.toggleable(at: context.caret, in: context.tree) != nil
200204 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
201206 case "org.link.open": return Links.link(at: context.caret, text: context.text, tree: context.tree) != nil
202207 default: return false
203208 }
Sources/OrgstarMobile/MobileModel.swift +1
@@ -16,6 +16,7 @@ final class MobileModel {
1616 let session = DocumentSession()
1717 let agenda: AgendaModel?
1818 let scheduler = ReminderScheduler(center: SystemReminderCenter())
19 let clock = ClockModel()
1920 private(set) var configFolder: URL?
2021 private(set) var configProblems: [String] = []
2122 /// An entry to show, from a tapped reminder.
Sources/OrgstarMobile/OrgstarMobileApp.swift +27
@@ -14,6 +14,8 @@ struct OrgstarMobileApp: App {
1414 WindowGroup {
1515 if let workspace = model.workspace, let agenda = model.agenda {
1616 RootView(model: model, workspace: workspace, agenda: agenda)
17 .environment(model)
18 .environment(model.clock)
1719 .onAppear { delegate.model = model }
1820 } else {
1921 ContentUnavailableView("Can't open the index", systemImage: "exclamationmark.triangle", description: Text(model.failure ?? ""))
@@ -71,6 +73,8 @@ struct RootView: View {
7173 @State private var tab = AppTab.agenda
7274 @State private var agendaPath = NavigationPath()
7375 @State private var hour = 0
76 @State private var showingClockReport = false
77 @State private var message: String?
7478 @Environment(\.scenePhase) private var scenePhase
7579
7680 private var session: DocumentSession { model.session }
@@ -110,6 +114,18 @@ struct RootView: View {
110114 }
111115 }
112116 }
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) }
113129 .onChange(of: model.openRequest) {
114130 guard let route = model.openRequest else { return }
115131 model.openRequest = nil
@@ -160,6 +176,17 @@ struct RootView: View {
160176 }
161177 }
162178
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
163190 private var captureButton: some ToolbarContent {
164191 ToolbarItem(placement: .primaryAction) {
165192 Button("Capture", systemImage: "square.and.pencil") { capture = CaptureRequest() }