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