Sources/OrgstarMobile/AgendaScreen.swift
391 lines · 17241 bytes
1#if os(iOS)
2import OrgApp
3import OrgCore
4import OrgDocument
5import SwiftUI
6
7/// The agenda over the workspace's agenda files: the day/week view, the TODO list, tag and
8/// property matches and the views in `views.toml`, with Org's filters and the agenda's
9/// actions on each entry.
10struct AgendaScreen: View {
11 let workspace: WorkspaceModel
12 let session: DocumentSession
13 @Bindable var agenda: AgendaModel
14 let scheduler: ReminderScheduler
15 @State private var message: String?
16 @Environment(ClockModel.self) private var clock
17 @Environment(MobileModel.self) private var model
18 @State private var tick = 0
19 @State private var views = AgendaViewLoader.builtIn
20 @State private var viewName = "Agenda"
21 @State private var filter = AgendaFilter()
22 @State private var showingFilter = false
23 @State private var asking: Asking?
24 @State private var prompt: EditorScreen.PromptRequest?
25 @State private var refiling: AgendaItem?
26 @State private var archiving: AgendaItem?
27
28 /// A match typed for the tag and property view.
29 private enum Asking: Identifiable {
30 case match
31 var id: Int { 0 }
32 }
33
34 private struct Key: Equatable {
35 var files: Int
36 var changed: Int?
37 var edits: Int
38 var tick: Int
39 var mode: AgendaMode
40 var start: Int?
41 var span: Int
42 var startOffset: Int
43 var subfolders: Bool
44 }
45
46 var body: some View {
47 List {
48 if case .agenda = agenda.mode {
49 ForEach(agenda.days, id: \.day) { day in
50 Section {
51 let items = day.items.filter(\.isEntry).filter(filter.keeps)
52 ForEach(items.indices, id: \.self) { i in row(items[i]) }
53 } header: {
54 Text(day.header.split(separator: " ", omittingEmptySubsequences: true).prefix(4).joined(separator: " "))
55 .foregroundStyle(day.isToday ? Color.accentColor : .secondary)
56 }
57 }
58 } else {
59 let items = agenda.list.filter(filter.keeps)
60 if items.isEmpty, !agenda.isComputing { Text("No entries").foregroundStyle(.secondary) }
61 ForEach(items.indices, id: \.self) { i in row(items[i]) }
62 }
63 if let reminderStatus {
64 Section { } footer: { Text(reminderStatus) }
65 }
66 }
67 .listStyle(.insetGrouped)
68 .navigationTitle(viewName)
69 .onChange(of: agenda.start) {
70 // Days a followed timestamp asked for.
71 if agenda.start != nil, case .agenda = agenda.mode { viewName = "Agenda" }
72 }
73 .overlay {
74 if workspace.rootPaths.isEmpty {
75 ContentUnavailableView("No folders", systemImage: "folder", description: Text("Add a folder of org files under Folders."))
76 }
77 }
78 .safeAreaInset(edge: .bottom) {
79 if let status = message ?? (filter.isEmpty ? nil : "Filter: " + filter.description) {
80 Text(status).font(.callout).padding(8).frame(maxWidth: .infinity).background(.bar)
81 .onTapGesture { message = nil }
82 }
83 }
84 .toolbar {
85 ToolbarItem(placement: .topBarLeading) {
86 Menu {
87 ForEach(views) { definition in
88 Button(definition.name) { show(definition) }
89 }
90 Button("Tags and Properties…") { asking = .match }
91 if case .agenda = agenda.mode {
92 Divider()
93 Button("Today", systemImage: "calendar.circle") { agenda.showToday() }
94 Button("Earlier", systemImage: "chevron.left") { agenda.shift(forward: false) }
95 Button("Later", systemImage: "chevron.right") { agenda.shift(forward: true) }
96 }
97 } label: {
98 Label("Views", systemImage: "calendar")
99 }
100 }
101 ToolbarItem(placement: .topBarLeading) {
102 Button("Filter", systemImage: filter.isEmpty ? "line.3.horizontal.decrease.circle" : "line.3.horizontal.decrease.circle.fill") {
103 showingFilter = true
104 }
105 }
106 }
107 .sheet(isPresented: $showingFilter) {
108 FilterSheet(filter: $filter, items: shownItems)
109 }
110 .sheet(item: $asking) { _ in
111 PromptSheet(prompt: Prompt(key: "match", message: "Match: tags and properties, as +work-home or TODO=\"WAIT\"")) { answer in
112 asking = nil
113 guard let answer, !answer.isEmpty else { return }
114 agenda.mode = .match(answer, todoOnly: false)
115 viewName = answer
116 }
117 }
118 .sheet(item: $prompt) { request in
119 PromptSheet(prompt: request.prompt) { answer in
120 prompt = nil
121 request.reply(answer)
122 }
123 }
124 .sheet(item: $refiling) { item in
125 RefileSheet(targets: workspace.refileTargets(openPath: session.path, document: session.document)) { target in
126 refiling = nil
127 if let target { refile(item, to: target) }
128 }
129 }
130 .confirmationDialog("Archive this entry?", isPresented: Binding { archiving != nil } set: { if !$0 { archiving = nil } }, titleVisibility: .visible) {
131 Button("Archive", role: .destructive) {
132 if let item = archiving { archive(item) }
133 archiving = nil
134 }
135 } message: {
136 Text(archiving?.text ?? "")
137 }
138 .refreshable {
139 await workspace.refresh()
140 await agenda.refresh(open: session.openTexts)
141 }
142 .task {
143 let loaded = AgendaViewLoader.load()
144 views = loaded.views
145 if let problem = loaded.problems.first { message = problem }
146 }
147 .task(id: Key(files: workspace.files.count, changed: workspace.changedFile?.version, edits: session.editVersion, tick: tick,
148 mode: agenda.mode, start: agenda.start, span: agenda.span, startOffset: agenda.startOffset,
149 subfolders: agenda.includeSubfolders)) {
150 await agenda.refresh(open: session.openTexts)
151 }
152 }
153
154 private var shownItems: [AgendaItem] {
155 if case .agenda = agenda.mode { return agenda.days.flatMap(\.items).filter(\.isEntry) }
156 return agenda.list
157 }
158
159 private func show(_ definition: AgendaViewDefinition) {
160 viewName = definition.name
161 agenda.mode = definition.mode
162 }
163
164 /// How far ahead notifications are set, as the design asks the agenda to show: iOS keeps
165 /// at most 64, and sets no more while the app isn't opened.
166 private var reminderStatus: String? {
167 guard OrgPreferences.reminders() else { return nil }
168 if !scheduler.isAuthorized { return "Notifications are off for Orgstar in Settings." }
169 guard let through = scheduler.scheduledThrough else { return nil }
170 return "Reminders are set through " + through.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated).hour().minute())
171 + ". Open Orgstar to set later ones."
172 }
173
174 // MARK: - Rows
175
176 private func row(_ item: AgendaItem) -> some View {
177 Group {
178 if let path = item.path, let offset = item.headingOffset ?? item.markerOffset {
179 NavigationLink(value: ReaderRoute.offset(path, offset)) { entry(item) }
180 } else {
181 entry(item)
182 }
183 }
184 .swipeActions(edge: .trailing) {
185 if item.todo != nil, !item.isDone, doneKeyword(for: item) != nil {
186 Button("Done", systemImage: "checkmark") { markDone(item) }.tint(.green)
187 }
188 }
189 .contextMenu {
190 if item.path != nil {
191 Button("TODO State…", systemImage: "checkmark.circle") { run("org.todo.cycle", on: item) }
192 Button("Schedule…", systemImage: "calendar") { run("org.schedule", on: item) }
193 Button("Deadline…", systemImage: "calendar.badge.exclamationmark") { run("org.deadline", on: item) }
194 Button("Tags…", systemImage: "tag") { run("org.tags.set", on: item) }
195 Button("Priority…", systemImage: "exclamationmark.circle") { run("org.priority.set", on: item) }
196 Button("Clock In", systemImage: "stopwatch") {
197 if let (path, offset) = locate(item) { clockIn(path, offset) }
198 }
199 Divider()
200 Button("Refile…", systemImage: "arrow.turn.down.right") { refiling = item }
201 Button("Archive…", systemImage: "archivebox") { archiving = item }
202 }
203 }
204 }
205
206 private func entry(_ item: AgendaItem) -> some View {
207 VStack(alignment: .leading, spacing: 2) {
208 HStack(alignment: .firstTextBaseline, spacing: 6) {
209 if let time = item.time.firstMatch(of: /[0-9]{1,2}:[0-9]{2}(-[0-9]{1,2}:[0-9]{2})?/) {
210 Text(time.output.0).font(.callout.monospacedDigit()).foregroundStyle(.secondary)
211 }
212 Text(item.text).strikethrough(item.isDone)
213 }
214 HStack(spacing: 6) {
215 Text(item.category)
216 let extra = item.extra.split(whereSeparator: \.isWhitespace).joined(separator: " ")
217 if !extra.isEmpty { Text(extra) }
218 if !item.tags.isEmpty { Text(item.tags.map { ":" + $0 }.joined() + ":") }
219 }
220 .font(.caption)
221 .foregroundStyle(.secondary)
222 }
223 }
224
225 // MARK: - Actions
226
227 /// The heading of `item` now, found again by its line in case the file changed.
228 private func locate(_ item: AgendaItem) -> (path: String, offset: Int)? {
229 guard let path = item.path, let line = item.headingLine, let near = item.headingOffset,
230 let offset = session.locateHeading(path: path, line: line, near: near) else {
231 message = "The entry changed; the agenda has been refreshed."
232 tick += 1
233 return nil
234 }
235 return (path, offset)
236 }
237
238 /// `org-agenda-clock-in`: `org-clock-in` at the entry, dangling clocks resolved first.
239 private func clockIn(_ path: String, _ offset: Int) {
240 let paths = workspace.files.filter { $0.kind == .org }.map(\.path)
241 Task {
242 let outcome = await clock.clockIn(session, target: .entry(path: path, offset: offset), paths: paths, ask: model.clockPrompts.ask)
243 if let jump = outcome.jump { model.openRequest = .offset(jump.path, jump.offset) }
244 message = outcome.messages.last { !$0.isEmpty }
245 }
246 }
247
248 /// Runs a command at the entry's heading, asking what it asks.
249 private func run(_ id: String, on item: AgendaItem, answers: [String: String] = [:]) {
250 guard let command = Commands.command(id) else { return }
251 run(command, on: item, answers: answers)
252 }
253
254 private func run(_ command: any OrgCommand, on item: AgendaItem, answers: [String: String] = [:]) {
255 guard let (path, offset) = locate(item), let line = item.headingLine else { return }
256 switch session.perform(command, path: path, at: offset, expect: (offset, line), answers: answers) {
257 case .prompt(let request):
258 prompt = EditorScreen.PromptRequest(prompt: request) { answer in
259 guard let answer else { return }
260 run(command, on: item, answers: answers.merging([request.key: answer]) { $1 })
261 }
262 case .failed(let text):
263 message = text
264 case .external(let request):
265 message = MobileEditor.unavailable(request, title: command.title)
266 case .commit:
267 message = nil
268 }
269 tick += 1
270 }
271
272 /// The first done keyword of the item's TODO sequence.
273 private func doneKeyword(for item: AgendaItem) -> String? {
274 guard let todo = item.todo, let path = item.path, let settings = session.settings(for: path) else { return nil }
275 return settings.todoSequences.first { ($0.active + $0.done).contains { $0.name == todo } }?.done.first?.name
276 }
277
278 private func markDone(_ item: AgendaItem) {
279 guard let keyword = doneKeyword(for: item) else { return }
280 run(SetTodo(keyword: keyword), on: item)
281 }
282
283 private func refile(_ item: AgendaItem, to target: RefileTarget) {
284 guard let (path, offset) = locate(item) else { return }
285 // Targets in the entry's own file come from its text now.
286 let document = session.openDocuments.first { $0.path == path }?.document
287 ?? FileManager.default.contents(atPath: path).map { DocumentState(bytes: [UInt8]($0)) }
288 guard let current = workspace.refileTargets(openPath: path, document: document).first(where: { $0.label == target.label }) else {
289 message = "Can't find \(target.label) any more."
290 return
291 }
292 message = session.refile(to: current, from: (path, offset))
293 tick += 1
294 }
295
296 private func archive(_ item: AgendaItem) {
297 guard let (path, offset) = locate(item) else { return }
298 message = session.archive(from: (path, offset))
299 tick += 1
300 }
301}
302
303extension AgendaItem: Identifiable {
304 public var id: String { "\(path ?? "")|\(headingOffset ?? -1)|\(kind.rawValue)|\(text)" }
305
306 /// An entry, not a time-grid line.
307 var isEntry: Bool { kind != .timeGrid && kind != .currentTime }
308}
309
310/// Org's agenda filter: tags and categories of the entries shown, kept or left out, or the
311/// filter typed as `/` takes it.
312struct FilterSheet: View {
313 @Binding var filter: AgendaFilter
314 let items: [AgendaItem]
315 @State private var typed = ""
316 @Environment(\.dismiss) private var dismiss
317
318 var body: some View {
319 let tags = Set(items.flatMap(\.tags)).sorted()
320 let categories = Set(items.filter { $0.path != nil }.map(\.category)).sorted()
321 NavigationStack {
322 Form {
323 Section {
324 TextField("+work-home", text: $typed)
325 .autocorrectionDisabled()
326 .textInputAutocapitalization(.never)
327 .onSubmit(apply)
328 } footer: {
329 Text("As Org's / filter: +keep and -drop tags or categories, <0:30 for effort, /regexp/.")
330 }
331 if !tags.isEmpty {
332 Section("Tags") { ForEach(tags, id: \.self) { term($0, in: \.tags) } }
333 }
334 if !categories.isEmpty {
335 Section("Categories") { ForEach(categories, id: \.self) { term($0, in: \.categories) } }
336 }
337 }
338 .navigationTitle("Filter")
339 .navigationBarTitleDisplayMode(.inline)
340 .toolbar {
341 ToolbarItem(placement: .cancellationAction) { Button("Clear") { filter = AgendaFilter(); dismiss() } }
342 ToolbarItem(placement: .confirmationAction) { Button("Done") { apply(); dismiss() } }
343 }
344 }
345 .onAppear { typed = filter.description }
346 }
347
348 /// Keep, leave out, or no filter on `name`.
349 private func term(_ name: String, in list: WritableKeyPath<AgendaFilter, [String]>) -> some View {
350 let state = filter[keyPath: list].contains("+" + name) ? 1 : filter[keyPath: list].contains("-" + name) ? -1 : 0
351 return Button {
352 var terms = filter[keyPath: list].filter { $0.dropFirst() != Substring(name) }
353 if state == 0 { terms.append("+" + name) } else if state == 1 { terms.append("-" + name) }
354 filter[keyPath: list] = terms
355 typed = filter.description
356 } label: {
357 HStack {
358 Text(name).foregroundStyle(.primary)
359 Spacer()
360 Text(state == 1 ? "Keep" : state == -1 ? "Leave out" : "").foregroundStyle(.secondary)
361 }
362 }
363 }
364
365 private func apply() {
366 guard typed != filter.description else { return }
367 let tags = Set(items.flatMap(\.tags))
368 let categories = Set(items.map(\.category))
369 filter = AgendaFilter().parse(typed, tags: tags, categories: categories).filter
370 }
371}
372
373/// Where to refile: the targets `org-refile` offers, searchable.
374struct RefileSheet: View {
375 let targets: [RefileTarget]
376 let choose: (RefileTarget?) -> Void
377 @State private var query = ""
378
379 var body: some View {
380 NavigationStack {
381 List(targets.filter { query.isEmpty || $0.label.localizedCaseInsensitiveContains(query) }, id: \.self) { target in
382 Button(target.label) { choose(target) }.foregroundStyle(.primary)
383 }
384 .searchable(text: $query, placement: .navigationBarDrawer(displayMode: .always))
385 .navigationTitle("Refile to")
386 .navigationBarTitleDisplayMode(.inline)
387 .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { choose(nil) } } }
388 }
389 }
390}
391#endif