Sources/OrgstarMobile/ClockScreens.swift
294 lines · 12056 bytes
18 symbols in this file
1#if os(iOS)
2import OrgApp
3import OrgCore
4import SwiftUI
5
6/// Clock commands that ask questions, and their answers in `ClockPromptSheet`.
7@MainActor
8@Observable
9final class ClockPrompter {
10 struct Pending: Identifiable {
11 let id = UUID()
12 let prompt: Prompt
13 let reply: (String?) -> Void
14 }
15
16 var pending: Pending?
17
18 var ask: ClockModel.Ask {
19 { [self] prompt in
20 await withCheckedContinuation { continuation in
21 pending = Pending(prompt: prompt) { continuation.resume(returning: $0) }
22 }
23 }
24 }
25
26 func answer(_ text: String?) {
27 guard let request = pending else { return }
28 pending = nil
29 request.reply(text)
30 }
31}
32
33/// The clock commands both the editor and the clock accessory run.
34@MainActor
35enum MobileClock {
36 /// Runs a clock command; false for other ids. Messages go to `show`; entries to show open.
37 static func run(_ id: String, model: MobileModel, workspace: WorkspaceModel, show: @escaping (String) -> Void) -> Bool {
38 let clock = model.clock
39 let session = model.session
40 let ask = model.clockPrompts.ask
41 let paths = workspace.files.filter { $0.kind == .org }.map(\.path)
42 func open(_ location: (path: String, offset: Int)?) {
43 if let location { model.openRequest = .offset(location.path, location.offset) }
44 }
45 func report(_ outcome: ClockModel.Outcome) {
46 open(outcome.jump)
47 if let message = outcome.messages.last(where: { !$0.isEmpty }) { show(message) }
48 }
49 func clockIn(_ target: ClockModel.Target) {
50 Task { report(await clock.clockIn(session, target: target, paths: paths, ask: ask)) }
51 }
52 switch id {
53 case "app.clock.in": clockIn(.caret)
54 case "app.clock.in-recent": clockIn(.select)
55 case "app.clock.in-last": clockIn(.last)
56 case let id where id.hasPrefix("app.clock.history."):
57 guard let index = Int(id.dropFirst("app.clock.history.".count)), index < clock.history.count else { return true }
58 clockIn(.task(clock.history[index]))
59 case "app.clock.out": show(clock.clockOut(session))
60 case "app.clock.cancel": show(clock.clockOut(session, cancel: true))
61 case "app.clock.goto":
62 let found = clock.goto(session)
63 open(found.location)
64 if let message = found.message { show(message) }
65 case "app.clock.goto-recent":
66 Task {
67 let found = await clock.gotoRecent(session, ask: ask)
68 open(found.location)
69 if let message = found.message { show(message) }
70 }
71 case "app.clock.default": show(clock.markDefault(session))
72 case "app.clock.resolve":
73 Task { report(await clock.resolveOpenClocks(session, paths: paths, ask: ask)) }
74 default:
75 return false
76 }
77 return true
78 }
79}
80
81/// Recent entries to clock in to, and the commands that choose among them.
82struct RecentClockMenu: View {
83 let clock: ClockModel
84 let perform: (String) -> Void
85
86 var body: some View {
87 Menu("Recent Entries", systemImage: "clock.arrow.circlepath") {
88 ForEach(Array(clock.history.enumerated()), id: \.offset) { index, task in
89 Button(task.title) { perform("app.clock.history.\(index)") }
90 }
91 Divider()
92 Button("Clock In to Recent Entry…") { perform("app.clock.in-recent") }
93 Button("Go to Recent Clocked Entry…") { perform("app.clock.goto-recent") }
94 }
95 .disabled(clock.history.isEmpty)
96 }
97}
98
99/// A clock question: keys to choose from under their sections, or a number or date to type.
100struct ClockPromptSheet: View {
101 let prompt: Prompt
102 let answer: (String?) -> Void
103 @State private var text = ""
104
105 var body: some View {
106 NavigationStack {
107 Form {
108 if let fast = prompt.fast {
109 ForEach(Array(sections(fast).enumerated()), id: \.offset) { _, section in
110 Section(section.title) {
111 ForEach(Array(section.options.enumerated()), id: \.offset) { _, option in
112 Button {
113 answer(String(option.key))
114 } label: {
115 HStack(alignment: .firstTextBaseline) {
116 Text(String(option.key)).font(.body.monospaced()).foregroundStyle(.tint)
117 Text(option.name).foregroundStyle(.primary)
118 }
119 }
120 }
121 }
122 }
123 } else {
124 Section {
125 TextField(prompt.message, text: $text)
126 .autocorrectionDisabled()
127 .textInputAutocapitalization(.never)
128 .keyboardType(prompt.date == nil ? .numbersAndPunctuation : .default)
129 .onSubmit { answer(text) }
130 } footer: {
131 Text(prompt.date == nil ? "Empty takes the default." : "Org's date syntax: 14:00, -1h, today 9:30")
132 }
133 }
134 }
135 .navigationTitle(prompt.message.trimmingCharacters(in: CharacterSet(charactersIn: ":?[] ")))
136 .navigationBarTitleDisplayMode(.inline)
137 .toolbar {
138 ToolbarItem(placement: .cancellationAction) { Button("Cancel") { answer(nil) } }
139 if prompt.fast == nil {
140 ToolbarItem(placement: .confirmationAction) { Button("Done") { answer(text) } }
141 }
142 }
143 }
144 .onAppear { text = prompt.initial }
145 .interactiveDismissDisabled()
146 }
147
148 private struct Group {
149 var title: String
150 var options: [(key: Character, name: String)]
151 }
152
153 /// Options under the labels before them.
154 private func sections(_ fast: FastSelection) -> [Group] {
155 var groups: [Group] = []
156 for item in fast.items {
157 switch item {
158 case .label(let title): groups.append(Group(title: title, options: []))
159 case .option(let key, let name):
160 if groups.isEmpty { groups.append(Group(title: "", options: [])) }
161 groups[groups.count - 1].options.append((key, name))
162 default: break
163 }
164 }
165 return groups
166 }
167}
168
169/// The running clock over the tab bar: the entry and time so far, and clocking out.
170struct ClockAccessory: View {
171 let running: ClockModel.Running
172 let clock: ClockModel
173 let perform: (String) -> Void
174
175 var body: some View {
176 Menu {
177 Button("Clock Out", systemImage: "stop.circle") { perform("app.clock.out") }
178 Button("Cancel Clock", systemImage: "xmark.circle") { perform("app.clock.cancel") }
179 Button("Go to Clocked Entry", systemImage: "arrow.right.circle") { perform("app.clock.goto") }
180 RecentClockMenu(clock: clock, perform: perform)
181 Button("Clock Report", systemImage: "list.bullet.rectangle") { perform("app.clock.report") }
182 } label: {
183 HStack {
184 Image(systemName: "stopwatch")
185 Text(running.heading).lineLimit(1)
186 Spacer()
187 TimelineView(.periodic(from: .now, by: 30)) { context in
188 Text(Self.elapsed(running, now: context.date)).monospacedDigit()
189 }
190 }
191 .padding(.horizontal)
192 }
193 .accessibilityLabel("Clocked in on \(running.heading)")
194 }
195
196 /// Elapsed time of the running clock, `H:MM`.
197 static func elapsed(_ running: ClockModel.Running, now: Date) -> String {
198 let minutes = max(0, Int(now.timeIntervalSince(running.startedAt) / 60))
199 return String(format: "%d:%02d", minutes / 60, minutes % 60)
200 }
201}
202
203extension View {
204 /// The running clock over the tab bar, while there is one.
205 @ViewBuilder
206 func clockAccessory(_ clock: ClockModel, perform: @escaping (String) -> Void) -> some View {
207 if #available(iOS 26.1, *) {
208 tabViewBottomAccessory(isEnabled: clock.running != nil) {
209 if let running = clock.running { ClockAccessory(running: running, clock: clock, perform: perform) }
210 }
211 } else {
212 safeAreaInset(edge: .bottom) {
213 if let running = clock.running { ClockAccessory(running: running, clock: clock, perform: perform).padding(.vertical, 8).background(.bar) }
214 }
215 }
216 }
217}
218
219/// Time per day and heading, as `tr/generate-report` writes it, for chosen files and dates.
220struct ClockReportScreen: View {
221 let workspace: WorkspaceModel
222 @State private var start = Calendar.current.date(byAdding: .day, value: -6, to: Date()) ?? Date()
223 @State private var end = Date()
224 @State private var limit = false
225 @State private var selected: Set<String> = []
226 @State private var report = ""
227 @Environment(\.dismiss) private var dismiss
228
229 private var candidates: [FileEntry] { workspace.files.filter { $0.kind == .org } }
230
231 var body: some View {
232 NavigationStack {
233 Form {
234 Section("Files") {
235 ForEach(candidates) { file in
236 Toggle(file.relativePath, isOn: Binding {
237 selected.contains(file.path)
238 } set: { on in
239 if on { selected.insert(file.path) } else { selected.remove(file.path) }
240 })
241 }
242 }
243 Section {
244 Toggle("Limit dates", isOn: $limit)
245 if limit {
246 DatePicker("From", selection: $start, displayedComponents: .date)
247 DatePicker("To", selection: $end, displayedComponents: .date)
248 }
249 }
250 Section("Report") {
251 ScrollView(.horizontal) {
252 Text(report.isEmpty ? "Choose files with clocked time." : report)
253 .font(.footnote.monospaced())
254 .textSelection(.enabled)
255 .fixedSize()
256 }
257 }
258 }
259 .navigationTitle("Clock Report")
260 .navigationBarTitleDisplayMode(.inline)
261 .toolbar {
262 ToolbarItem(placement: .cancellationAction) { Button("Close") { dismiss() } }
263 ToolbarItem(placement: .primaryAction) {
264 ShareLink(item: report, preview: SharePreview("Time report")).disabled(report.isEmpty)
265 }
266 }
267 }
268 .onAppear {
269 if selected.isEmpty {
270 selected = Set(candidates.filter { (try? String(contentsOfFile: $0.path, encoding: .utf8))?.contains("CLOCK: [") ?? false }.map(\.path))
271 }
272 compute()
273 }
274 .onChange(of: selected) { compute() }
275 .onChange(of: start) { compute() }
276 .onChange(of: end) { compute() }
277 .onChange(of: limit) { compute() }
278 }
279
280 private func day(_ date: Date) -> String {
281 let c = Calendar.current.dateComponents([.year, .month, .day], from: date)
282 return String(format: "%04d-%02d-%02d", c.year!, c.month!, c.day!)
283 }
284
285 private func compute() {
286 var entries: [ClockReport.Entry] = []
287 for path in selected.sorted() {
288 guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { continue }
289 entries += ClockReport.entries(text, settings: workspace.settings.org)
290 }
291 report = selected.isEmpty ? "" : ClockReport.timeReport(entries, start: limit ? day(start) : nil, end: limit ? day(end) : nil)
292 }
293}
294#endif