krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Sources/OrgstarMobile/ClockScreens.swift

3a3dedba062790e1580c66d38606eda4b02cb501
orgstar/Sources/OrgstarMobile/ClockScreens.swift history · blame · raw

294 lines · 12056 bytes

  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