| @@ -3,9 +3,173 @@ import OrgApp |
| 3 | 3 | import OrgCore |
| 4 | 4 | import SwiftUI |
| 5 | 5 | |
| 6 | /// Clock commands that ask questions, and their answers in `ClockPromptSheet`. |
| 7 | @MainActor |
| 8 | @Observable |
| 9 | final 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 |
| 35 | enum 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. |
| 82 | struct 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. |
| 100 | struct 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 | |
| 6 | 169 | /// The running clock over the tab bar: the entry and time so far, and clocking out. |
| 7 | 170 | struct ClockAccessory: View { |
| 8 | 171 | let running: ClockModel.Running |
| 172 | let clock: ClockModel |
| 9 | 173 | let perform: (String) -> Void |
| 10 | 174 | |
| 11 | 175 | var body: some View { |
| @@ -13,6 +177,7 @@ struct ClockAccessory: View { |
| 13 | 177 | Button("Clock Out", systemImage: "stop.circle") { perform("app.clock.out") } |
| 14 | 178 | Button("Cancel Clock", systemImage: "xmark.circle") { perform("app.clock.cancel") } |
| 15 | 179 | Button("Go to Clocked Entry", systemImage: "arrow.right.circle") { perform("app.clock.goto") } |
| 180 | RecentClockMenu(clock: clock, perform: perform) |
| 16 | 181 | Button("Clock Report", systemImage: "list.bullet.rectangle") { perform("app.clock.report") } |
| 17 | 182 | } label: { |
| 18 | 183 | HStack { |
| @@ -38,14 +203,14 @@ struct ClockAccessory: View { |
| 38 | 203 | extension View { |
| 39 | 204 | /// The running clock over the tab bar, while there is one. |
| 40 | 205 | @ViewBuilder |
| 41 | | func clockAccessory(_ running: ClockModel.Running?, perform: @escaping (String) -> Void) -> some View { |
| 206 | func clockAccessory(_ clock: ClockModel, perform: @escaping (String) -> Void) -> some View { |
| 42 | 207 | if #available(iOS 26.1, *) { |
| 43 | | tabViewBottomAccessory(isEnabled: running != nil) { |
| 44 | | if let running { ClockAccessory(running: running, perform: perform) } |
| 208 | tabViewBottomAccessory(isEnabled: clock.running != nil) { |
| 209 | if let running = clock.running { ClockAccessory(running: running, clock: clock, perform: perform) } |
| 45 | 210 | } |
| 46 | 211 | } else { |
| 47 | 212 | safeAreaInset(edge: .bottom) { |
| 48 | | if let running { ClockAccessory(running: running, perform: perform).padding(.vertical, 8).background(.bar) } |
| 213 | if let running = clock.running { ClockAccessory(running: running, clock: clock, perform: perform).padding(.vertical, 8).background(.bar) } |
| 49 | 214 | } |
| 50 | 215 | } |
| 51 | 216 | } |