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