| @@ -3,9 +3,173 @@ import OrgApp |
| 3 | import OrgCore |
3 | import OrgCore |
| 4 | import SwiftUI |
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 | /// The running clock over the tab bar: the entry and time so far, and clocking out. |
169 | /// The running clock over the tab bar: the entry and time so far, and clocking out. |
| 7 | struct ClockAccessory: View { |
170 | struct ClockAccessory: View { |
| 8 | let running: ClockModel.Running |
171 | let running: ClockModel.Running |
| |
172 | let clock: ClockModel |
| 9 | let perform: (String) -> Void |
173 | let perform: (String) -> Void |
| 10 | |
174 | |
| 11 | var body: some View { |
175 | var body: some View { |
| @@ -13,6 +177,7 @@ struct ClockAccessory: View { |
| 13 | Button("Clock Out", systemImage: "stop.circle") { perform("app.clock.out") } |
177 | Button("Clock Out", systemImage: "stop.circle") { perform("app.clock.out") } |
| 14 | Button("Cancel Clock", systemImage: "xmark.circle") { perform("app.clock.cancel") } |
178 | Button("Cancel Clock", systemImage: "xmark.circle") { perform("app.clock.cancel") } |
| 15 | Button("Go to Clocked Entry", systemImage: "arrow.right.circle") { perform("app.clock.goto") } |
179 | Button("Go to Clocked Entry", systemImage: "arrow.right.circle") { perform("app.clock.goto") } |
| |
180 | RecentClockMenu(clock: clock, perform: perform) |
| 16 | Button("Clock Report", systemImage: "list.bullet.rectangle") { perform("app.clock.report") } |
181 | Button("Clock Report", systemImage: "list.bullet.rectangle") { perform("app.clock.report") } |
| 17 | } label: { |
182 | } label: { |
| 18 | HStack { |
183 | HStack { |
| @@ -38,14 +203,14 @@ struct ClockAccessory: View { |
| 38 | extension View { |
203 | extension View { |
| 39 | /// The running clock over the tab bar, while there is one. |
204 | /// The running clock over the tab bar, while there is one. |
| 40 | @ViewBuilder |
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 | if #available(iOS 26.1, *) { |
207 | if #available(iOS 26.1, *) { |
| 43 | tabViewBottomAccessory(isEnabled: running != nil) { |
208 | tabViewBottomAccessory(isEnabled: clock.running != nil) { |
| 44 | if let running { ClockAccessory(running: running, perform: perform) } |
209 | if let running = clock.running { ClockAccessory(running: running, clock: clock, perform: perform) } |
| 45 | } |
210 | } |
| 46 | } else { |
211 | } else { |
| 47 | safeAreaInset(edge: .bottom) { |
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 | } |