Sources/OrgstarMobile/AgendaScreen.swift
885 lines · 39931 bytes
37 symbols in this file
1#if os(iOS)
2import OrgApp
3import OrgCore
4import OrgDocument
5import OrgPresentation
6import SwiftUI
7
8/// The agenda over the workspace's agenda files: the day/week view, the TODO list, tag and
9/// property matches, text search and the views in `views.toml`, with Org's filters and the
10/// agenda's actions on each entry.
11struct AgendaScreen: View {
12 let workspace: WorkspaceModel
13 let session: DocumentSession
14 @Bindable var agenda: AgendaModel
15 let scheduler: ReminderScheduler
16 @State private var message: String?
17 @Environment(ClockModel.self) private var clock
18 @Environment(MobileModel.self) private var model
19 @Environment(\.colorScheme) private var colorScheme
20 @Environment(\.openURL) private var openURL
21 @State private var tick = 0
22 /// The time the now line shows, kept to the minute while the agenda is open.
23 @State private var now = Date()
24 @State private var views = AgendaViewLoader.builtIn
25 @State private var viewName = "Agenda"
26 @State private var filter = AgendaFilter()
27 @State private var showingFilter = false
28 @State private var asking: Asking?
29 @State private var prompt: EditorScreen.PromptRequest?
30 @State private var refiling: AgendaItem?
31 @State private var archiving: AgendaItem?
32 /// The day, week or month shown; nil for `org-agenda-span` and `org-agenda-start-day`.
33 @State private var range: SpanRange?
34 /// The mode `range` set, to notice when something else changes the view.
35 @State private var rangeMode: AgendaMode?
36 @State private var jumping = false
37 @State private var rescheduling: AgendaRow?
38 @AppStorage("agendaOverdueExpanded") private var overdueExpanded = true
39 @State private var done = 0
40 @State private var moved = 0
41 /// `org-agenda-log-mode`: closed entries and clocked time on each day.
42 @State private var logMode = false
43
44 /// A match typed for the tag and property view, or a search string.
45 private enum Asking: Identifiable {
46 case match, search
47 var id: Self { self }
48 }
49
50 enum SpanRange: String, CaseIterable, Identifiable {
51 case day, week, month
52 var id: String { rawValue }
53 var title: String { rawValue.capitalized }
54 }
55
56 private struct Key: Equatable {
57 var files: Int
58 var changed: Int?
59 var edits: Int
60 var tick: Int
61 var mode: AgendaMode
62 var start: Int?
63 var span: Int
64 var startOffset: Int
65 var subfolders: Bool
66 var settings: Int
67 var logMode: Bool
68 }
69
70 private var isDark: Bool { colorScheme == .dark }
71 private var theme: ThemeSpec { model.theme }
72
73 var body: some View {
74 List {
75 // With no folders the empty state stands alone, not over a column of empty days.
76 if workspace.rootPaths.isEmpty {
77 } else if case .agenda = agenda.mode {
78 let _ = model.settingsVersion
79 ForEach(agenda.groups(showAllDates: OrgPreferences.agendaShowAllDates(), keep: filter.keeps)) { group in
80 Section {
81 day(group)
82 } header: {
83 header(group)
84 }
85 }
86 } else {
87 let rows = agenda.list.filter(filter.keeps).compactMap { AgendaRow(item: $0, day: agenda.today, today: agenda.today) }
88 if rows.isEmpty, !agenda.isComputing { Text("No entries").foregroundStyle(.secondary) }
89 ForEach(rows) { row($0, day: agenda.today) }
90 }
91 if let reminderStatus {
92 Section { } footer: { Text(reminderStatus) }
93 }
94 }
95 .listStyle(.insetGrouped)
96 .listSectionSpacing(.compact)
97 // The now line is shorter than a row.
98 .environment(\.defaultMinListRowHeight, 16)
99 .navigationTitle(title)
100 .navigationBarTitleDisplayMode(.inline)
101 .sensoryFeedback(.success, trigger: done)
102 .sensoryFeedback(.impact(weight: .light), trigger: moved)
103 .onChange(of: agenda.start) {
104 // Days a followed timestamp asked for.
105 if agenda.start != nil, case .agenda = agenda.mode { viewName = "Agenda" }
106 }
107 .onChange(of: agenda.mode) {
108 if agenda.mode != rangeMode { range = nil }
109 }
110 .overlay {
111 if workspace.rootPaths.isEmpty {
112 ContentUnavailableView("No folders", systemImage: "folder", description: Text("Add a folder of org files under Folders."))
113 }
114 }
115 .safeAreaInset(edge: .bottom) {
116 if !workspace.rootPaths.isEmpty { bottomBar }
117 }
118 .toolbar {
119 ToolbarItem(placement: .topBarLeading) {
120 Menu {
121 ForEach(views) { definition in
122 Button(definition.name) { show(definition) }
123 }
124 Button("Tags and Properties…") { asking = .match }
125 Button("Search…") { asking = .search }
126 } label: {
127 Label("Views", systemImage: "list.bullet.rectangle")
128 }
129 }
130 }
131 .sheet(isPresented: $showingFilter) {
132 FilterSheet(filter: $filter, items: shownItems)
133 }
134 .sheet(isPresented: $jumping) {
135 DateSheet(title: "Go to Date", action: "Go", initial: date(firstDay)) { picked in
136 jumping = false
137 if let picked { jump(to: Days.absolute(picked, calendar: .current)) }
138 }
139 }
140 .sheet(item: $rescheduling) { row in
141 DateSheet(title: row.icon == .deadline ? "Deadline" : "Schedule", action: "Set", initial: date(row.planningDay ?? agenda.today)) { picked in
142 rescheduling = nil
143 if let picked, let item = row.item { reschedule(row, item, to: Days.absolute(picked, calendar: .current)) }
144 }
145 }
146 .sheet(item: $asking) { kind in
147 if kind == .search {
148 PromptSheet(prompt: Prompt(key: "search", message: "Search: a phrase, or +word -word {regexp}; * for headlines, ! for TODO entries")) { answer in
149 asking = nil
150 guard let answer, answer.contains(where: { !$0.isWhitespace }) else { return }
151 agenda.mode = .search(answer, todoOnly: false)
152 viewName = answer
153 }
154 } else {
155 PromptSheet(prompt: Prompt(key: "match", message: "Match: tags and properties, as +work-home or TODO=\"WAIT\"")) { answer in
156 asking = nil
157 guard let answer, !answer.isEmpty else { return }
158 agenda.mode = .match(answer, todoOnly: false)
159 viewName = answer
160 }
161 }
162 }
163 .sheet(item: $prompt) { request in
164 PromptSheet(prompt: request.prompt) { answer in
165 prompt = nil
166 request.reply(answer)
167 }
168 }
169 .sheet(item: $refiling) { item in
170 RefileSheet(targets: workspace.refileTargets(openPath: session.path, document: session.document)) { target in
171 refiling = nil
172 if let target { refile(item, to: target) }
173 }
174 }
175 .confirmationDialog("Archive this entry?", isPresented: Binding { archiving != nil } set: { if !$0 { archiving = nil } }, titleVisibility: .visible) {
176 Button("Archive", role: .destructive) {
177 if let item = archiving { archive(item) }
178 archiving = nil
179 }
180 } message: {
181 Text(archiving?.text ?? "")
182 }
183 .refreshable {
184 await workspace.refresh()
185 await agenda.refresh(open: session.openTexts)
186 }
187 .task {
188 let loaded = AgendaViewLoader.load()
189 views = loaded.views
190 if let problem = loaded.problems.first { message = problem }
191 }
192 // The now line moves each minute; a new day refreshes the statuses.
193 .task {
194 while !Task.isCancelled {
195 let next = Calendar.current.nextDate(after: Date(), matching: DateComponents(second: 0), matchingPolicy: .nextTime) ?? Date().addingTimeInterval(60)
196 try? await Task.sleep(for: .seconds(next.timeIntervalSinceNow))
197 now = Date()
198 if Days.absolute(now, calendar: .current) != agenda.today { tick += 1 }
199 }
200 }
201 // Calendar access is asked for when calendar-events is turned on.
202 .task(id: model.settingsVersion) {
203 guard OrgPreferences.calendarEvents(), !CalendarEvents.isAuthorized else { return }
204 if await model.calendarEvents.requestAccess() { tick += 1 }
205 }
206 .task(id: Key(files: workspace.files.count, changed: workspace.changedFile?.version, edits: session.editVersion, tick: tick,
207 mode: agenda.mode, start: agenda.start, span: agenda.span, startOffset: agenda.startOffset,
208 subfolders: agenda.includeSubfolders, settings: model.settingsVersion, logMode: logMode)) {
209 agenda.options.logMode = logMode
210 await agenda.refresh(open: session.openTexts)
211 }
212 }
213
214 private var shownItems: [AgendaItem] {
215 if case .agenda = agenda.mode { return agenda.days.flatMap(\.items).filter(\.isEntry) }
216 return agenda.list
217 }
218
219 private func show(_ definition: AgendaViewDefinition) {
220 viewName = definition.name
221 agenda.mode = definition.mode
222 }
223
224 /// How far ahead notifications are set, as the design asks the agenda to show: iOS keeps
225 /// at most 64, and sets no more while the app isn't opened.
226 private var reminderStatus: String? {
227 guard OrgPreferences.reminders() else { return nil }
228 if !scheduler.isAuthorized { return "Notifications are off for Orgstar in Settings." }
229 guard let through = scheduler.scheduledThrough else { return nil }
230 return "Reminders are set through " + through.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated).hour().minute())
231 + ". Open Orgstar to set later ones."
232 }
233
234 // MARK: - Days
235
236 private func day(_ group: AgendaDayGroup) -> some View {
237 Group {
238 if group.isToday, !group.overdue.isEmpty {
239 Button {
240 withAnimation { overdueExpanded.toggle() }
241 } label: {
242 HStack(spacing: 6) {
243 Image(systemName: "chevron.right")
244 .font(.caption.weight(.semibold))
245 .rotationEffect(.degrees(overdueExpanded ? 90 : 0))
246 Text("Overdue").font(.subheadline.weight(.semibold))
247 Text("\(group.overdue.count)").font(.subheadline.monospacedDigit())
248 Spacer()
249 }
250 .foregroundStyle(themed(theme.statusColor(.overdue, dark: isDark), .red))
251 .contentShape(Rectangle())
252 }
253 .buttonStyle(.plain)
254 if overdueExpanded {
255 ForEach(group.overdue) { row($0, day: group.day) }
256 }
257 }
258 if group.isEmpty {
259 Text("Nothing scheduled").font(.subheadline).foregroundStyle(.secondary)
260 }
261 let nowAt = nowIndex(group)
262 ForEach(Array(group.rows.enumerated()), id: \.element.id) { index, item in
263 if index == nowAt { NowLine(time: now, color: todayColor) }
264 row(item, day: group.day)
265 }
266 if let nowAt, nowAt == group.rows.count { NowLine(time: now, color: todayColor) }
267 }
268 }
269
270 private func nowIndex(_ group: AgendaDayGroup) -> Int? {
271 let c = Calendar.current.dateComponents([.hour, .minute], from: now)
272 return group.nowIndex(now: (c.hour ?? 0) * 100 + (c.minute ?? 0))
273 }
274
275 private var todayColor: Color { themed(theme.color("agenda-today", dark: isDark), .accentColor) }
276
277 private func header(_ group: AgendaDayGroup) -> some View {
278 let day = date(group.day).formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated))
279 let color = group.isToday ? todayColor : group.day < agenda.today ? Color.secondary : themed(theme.color("agenda-date", dark: isDark), .primary)
280 return HStack(alignment: .firstTextBaseline) {
281 Text(group.relativeName(today: agenda.today).map { $0 + " · " + day } ?? day)
282 .font(.subheadline.weight(group.isToday ? .bold : .semibold))
283 .foregroundStyle(color)
284 Spacer()
285 Button("Add to \(day)", systemImage: "plus.circle") { model.captureRequest = CaptureRequest(day: group.day) }
286 .labelStyle(.iconOnly)
287 .font(.body)
288 .foregroundStyle(group.isToday ? todayColor : .secondary)
289 }
290 .textCase(nil)
291 }
292
293 // MARK: - Span
294
295 /// The first day shown, as `Days` numbers it, and how many.
296 private var shown: (first: Int, span: Int) {
297 var span = agenda.span
298 var offset = agenda.startOffset
299 if case .agenda(let viewSpan, let viewStart) = agenda.mode {
300 span = viewSpan ?? span
301 offset = viewStart ?? offset
302 }
303 return (agenda.start ?? agenda.today + offset, span)
304 }
305
306 private var firstDay: Int { shown.first }
307
308 /// The span: `Thu 8 Oct`, `Week of 5 Oct`, `October 2026`, or the days, `5 – 14 Oct`.
309 private var title: String {
310 guard case .agenda = agenda.mode else { return viewName }
311 if viewName != "Agenda", range == nil, agenda.start == nil { return viewName }
312 let (first, span) = shown
313 let start = date(first)
314 if span == 1 { return start.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated)) }
315 if span == 7, first == weekStart(first) { return "Week of " + start.formatted(.dateTime.day().month(.abbreviated)) }
316 let d = Days.date(first)
317 if d.day == 1, span == Days.absolute(year: d.year, month: d.month + 1, day: 1) - first {
318 return start.formatted(.dateTime.month(.wide).year())
319 }
320 return start.formatted(.dateTime.day().month(.abbreviated)) + " – " + date(first + span - 1).formatted(.dateTime.day().month(.abbreviated))
321 }
322
323 private func weekStart(_ day: Int) -> Int {
324 day - (Days.weekday(day) + 6) % 7
325 }
326
327 /// Shows `range` around `day`; nil returns to the configured span.
328 private func show(_ range: SpanRange?, around day: Int) {
329 self.range = range
330 let mode: AgendaMode
331 let start: Int?
332 switch range {
333 case .day:
334 (mode, start) = (.agenda(span: 1, startOffset: 0), day)
335 case .week:
336 (mode, start) = (.agenda(span: 7, startOffset: 0), weekStart(day))
337 case .month:
338 let d = Days.date(day)
339 let first = Days.absolute(year: d.year, month: d.month, day: 1)
340 (mode, start) = (.agenda(span: Days.absolute(year: d.year, month: d.month + 1, day: 1) - first, startOffset: 0), first)
341 case nil:
342 (mode, start) = (.agenda(span: nil, startOffset: nil), day == agenda.today ? nil : day)
343 }
344 rangeMode = mode
345 viewName = "Agenda"
346 agenda.mode = mode
347 agenda.start = start
348 }
349
350 private func step(forward: Bool) {
351 if range == nil, case .agenda = agenda.mode {
352 agenda.shift(forward: forward)
353 } else {
354 let (first, span) = shown
355 show(range, around: forward ? first + span : first - 1)
356 }
357 }
358
359 private func jump(to day: Int) {
360 show(range, around: day)
361 }
362
363 private func goToday() {
364 if range == nil { show(nil, around: agenda.today) } else { show(range, around: agenda.today) }
365 }
366
367 private var bottomBar: some View {
368 VStack(spacing: 8) {
369 if let status = message ?? (filter.isEmpty ? nil : "Filter: " + filter.description) {
370 Text(status).font(.footnote).padding(.horizontal, 12).padding(.vertical, 6)
371 .glassEffect(.regular, in: Capsule())
372 .onTapGesture { message = nil }
373 }
374 HStack(spacing: 2) {
375 let isAgenda = if case .agenda = agenda.mode { true } else { false }
376 if isAgenda {
377 barButton("Earlier", "chevron.left") { step(forward: false) }
378 }
379 barButton("Filter", filter.isEmpty ? "line.3.horizontal.decrease" : "line.3.horizontal.decrease.circle.fill") { showingFilter = true }
380 if isAgenda {
381 barButton("Today", "smallcircle.filled.circle") { goToday() }
382 barButton("Go to Date", "calendar") { jumping = true }
383 Menu {
384 Picker("Span", selection: Binding { range } set: { show($0, around: $0 == nil ? agenda.today : firstDay) }) {
385 ForEach(SpanRange.allCases) { Text($0.title).tag(Optional($0)) }
386 Text("\(agenda.span) Days").tag(SpanRange?.none)
387 }
388 Toggle("Log Mode", systemImage: "clock.arrow.circlepath", isOn: $logMode)
389 } label: {
390 Text(range?.title ?? "\(agenda.span) Days")
391 .font(.footnote.weight(.semibold).monospacedDigit())
392 .lineLimit(1)
393 .frame(minWidth: 52, minHeight: 44)
394 .contentShape(Rectangle())
395 }
396 .accessibilityLabel("Span")
397 barButton("Later", "chevron.right") { step(forward: true) }
398 } else {
399 barButton("Agenda", "calendar") { show(nil, around: agenda.today) }
400 }
401 }
402 .padding(.horizontal, 6)
403 .glassEffect(.regular.interactive(), in: Capsule())
404 }
405 .padding(.bottom, 8)
406 .frame(maxWidth: .infinity)
407 }
408
409 private func barButton(_ title: String, _ image: String, action: @escaping () -> Void) -> some View {
410 Button(title, systemImage: image, action: action)
411 .labelStyle(.iconOnly)
412 .font(.body.weight(.medium))
413 .frame(width: 44, height: 44)
414 .contentShape(Rectangle())
415 }
416
417 // MARK: - Rows
418
419 private func row(_ row: AgendaRow, day: Int) -> some View {
420 Group {
421 if let item = row.item, let path = item.path, let offset = item.headingOffset ?? item.markerOffset {
422 NavigationLink(value: ReaderRoute.offset(path, offset)) { AgendaRowView(row: row, day: day, theme: theme, dark: isDark) }
423 } else if let event = row.event {
424 // Calendar can't be asked for one event by URL; it opens on the event's day.
425 Button {
426 if let url = URL(string: "calshow:\(event.start.timeIntervalSinceReferenceDate)") { openURL(url) }
427 } label: {
428 AgendaRowView(row: row, day: day, theme: theme, dark: isDark)
429 }
430 .tint(.primary)
431 } else {
432 AgendaRowView(row: row, day: day, theme: theme, dark: isDark)
433 }
434 }
435 .swipeActions(edge: .trailing) {
436 if let item = row.item, item.todo != nil, !item.isDone, doneKeyword(for: item) != nil {
437 Button("Done", systemImage: "checkmark") { markDone(item) }.tint(.green)
438 Button("Next State", systemImage: "arrow.right.circle") { run("org.todo.next", on: item) }.tint(.blue)
439 }
440 }
441 .swipeActions(edge: .leading) {
442 if canReschedule(row), let item = row.item {
443 Button("Tomorrow", systemImage: "sunrise") { reschedule(row, item, to: agenda.today + 1) }.tint(.orange)
444 Button("Next Week", systemImage: "calendar.badge.plus") { reschedule(row, item, to: weekStart(agenda.today) + 7) }.tint(.indigo)
445 Button("Pick Date", systemImage: "calendar") { rescheduling = row }.tint(.gray)
446 }
447 }
448 .contextMenu {
449 if let item = row.item, item.path != nil {
450 Button("TODO State…", systemImage: "checkmark.circle") { run("org.todo.cycle", on: item) }
451 if canReschedule(row) {
452 Menu("Reschedule", systemImage: "calendar.badge.clock") {
453 Button("Tomorrow") { reschedule(row, item, to: agenda.today + 1) }
454 Button("Next Week") { reschedule(row, item, to: weekStart(agenda.today) + 7) }
455 Button("Pick Date…") { rescheduling = row }
456 }
457 }
458 Button("Schedule…", systemImage: "calendar") { run("org.schedule", on: item) }
459 Button("Deadline…", systemImage: "calendar.badge.exclamationmark") { run("org.deadline", on: item) }
460 Button("Tags…", systemImage: "tag") { run("org.tags.set", on: item) }
461 Button("Priority…", systemImage: "exclamationmark.circle") { run("org.priority.set", on: item) }
462 Button("Clock In", systemImage: "stopwatch") {
463 if let (path, offset) = locate(item) { clockIn(path, offset) }
464 }
465 Divider()
466 Button("Refile…", systemImage: "arrow.turn.down.right") { refiling = item }
467 Button("Archive…", systemImage: "archivebox") { archiving = item }
468 }
469 }
470 }
471
472 // MARK: - Actions
473
474 /// Entries with a heading, not done, that a schedule or deadline can move.
475 private func canReschedule(_ row: AgendaRow) -> Bool {
476 guard let item = row.item, item.path != nil, item.headingLine != nil, !row.isDone else { return false }
477 return item.todo != nil || row.icon == .scheduled || row.icon == .deadline || row.icon == .habit
478 }
479
480 /// Moves the row's deadline (on a deadline row) or schedule to `day`, keeping its times,
481 /// as `org-agenda-schedule` and `org-agenda-deadline` with a date typed.
482 private func reschedule(_ row: AgendaRow, _ item: AgendaItem, to day: Int) {
483 let d = Days.date(day)
484 var answer = String(format: "%04d-%02d-%02d", d.year, d.month, d.day)
485 if let start = row.start {
486 answer += String(format: " %02d:%02d", start / 100 % 24, start % 100)
487 if let end = row.end { answer += String(format: "-%02d:%02d", end / 100 % 24, end % 100) }
488 }
489 run(row.icon == .deadline ? "org.deadline" : "org.schedule", on: item, answers: ["date": answer])
490 moved += 1
491 }
492
493 /// The heading of `item` now, found again by its line in case the file changed.
494 private func locate(_ item: AgendaItem) -> (path: String, offset: Int)? {
495 guard let path = item.path, let line = item.headingLine, let near = item.headingOffset,
496 let offset = session.locateHeading(path: path, line: line, near: near) else {
497 message = "The entry changed; the agenda has been refreshed."
498 tick += 1
499 return nil
500 }
501 return (path, offset)
502 }
503
504 /// `org-agenda-clock-in`: `org-clock-in` at the entry, dangling clocks resolved first.
505 private func clockIn(_ path: String, _ offset: Int) {
506 let paths = workspace.files.filter { $0.kind == .org }.map(\.path)
507 Task {
508 let outcome = await clock.clockIn(session, target: .entry(path: path, offset: offset), paths: paths, ask: model.clockPrompts.ask)
509 if let jump = outcome.jump { model.openRequest = .offset(jump.path, jump.offset) }
510 message = outcome.messages.last { !$0.isEmpty }
511 }
512 }
513
514 /// Runs a command at the entry's heading, asking what it asks.
515 private func run(_ id: String, on item: AgendaItem, answers: [String: String] = [:]) {
516 guard let command = Commands.command(id) else { return }
517 run(command, on: item, answers: answers)
518 }
519
520 private func run(_ command: any OrgCommand, on item: AgendaItem, answers: [String: String] = [:]) {
521 guard let (path, offset) = locate(item), let line = item.headingLine else { return }
522 switch session.perform(command, path: path, at: offset, expect: (offset, line), answers: answers) {
523 case .prompt(let request):
524 prompt = EditorScreen.PromptRequest(prompt: request) { answer in
525 guard let answer else { return }
526 run(command, on: item, answers: answers.merging([request.key: answer]) { $1 })
527 }
528 case .failed(let text):
529 message = text
530 case .external(let request):
531 message = MobileEditor.unavailable(request, title: command.title)
532 case .commit:
533 message = nil
534 }
535 tick += 1
536 }
537
538 /// The first done keyword of the item's TODO sequence.
539 private func doneKeyword(for item: AgendaItem) -> String? {
540 guard let todo = item.todo, let path = item.path, let settings = session.settings(for: path) else { return nil }
541 return settings.todoSequences.first { ($0.active + $0.done).contains { $0.name == todo } }?.done.first?.name
542 }
543
544 private func markDone(_ item: AgendaItem) {
545 guard let keyword = doneKeyword(for: item) else { return }
546 run(SetTodo(keyword: keyword), on: item)
547 done += 1
548 }
549
550 private func refile(_ item: AgendaItem, to target: RefileTarget) {
551 guard let (path, offset) = locate(item) else { return }
552 // Targets in the entry's own file come from its text now.
553 let document = session.openDocuments.first { $0.path == path }?.document
554 ?? FileManager.default.contents(atPath: path).map { DocumentState(bytes: [UInt8]($0)) }
555 guard let current = workspace.refileTargets(openPath: path, document: document).first(where: { $0.label == target.label }) else {
556 message = "Can't find \(target.label) any more."
557 return
558 }
559 message = session.refile(to: current, from: (path, offset))
560 tick += 1
561 }
562
563 private func archive(_ item: AgendaItem) {
564 guard let (path, offset) = locate(item) else { return }
565 message = session.archive(from: (path, offset))
566 tick += 1
567 }
568
569 private func date(_ day: Int) -> Date {
570 let d = Days.date(day)
571 return Calendar.current.date(from: DateComponents(year: d.year, month: d.month, day: d.day)) ?? Date()
572 }
573
574 private func themed(_ color: RGBA?, _ fallback: Color) -> Color { color?.swiftUI ?? fallback }
575}
576
577extension RGBA {
578 var swiftUI: Color { Color(.sRGB, red: red, green: green, blue: blue, opacity: alpha) }
579}
580
581/// One agenda line: the time or status with an icon in the gutter; the category, title,
582/// progress and tags; the TODO keyword and priority on the right.
583struct AgendaRowView: View {
584 let row: AgendaRow
585 /// The day the row is on, for habit graphs.
586 let day: Int
587 let theme: ThemeSpec
588 let dark: Bool
589
590 var body: some View {
591 HStack(alignment: .top, spacing: 10) {
592 // TODO and match lists have no times or statuses.
593 if !row.gutter.isEmpty || row.icon != nil { gutter.frame(width: 82, alignment: .leading) }
594 VStack(alignment: .leading, spacing: 2) {
595 HStack(spacing: 5) {
596 if !row.category.isEmpty {
597 Circle().fill(categoryColor).frame(width: 7, height: 7)
598 Text(row.category).lineLimit(1).layoutPriority(1)
599 }
600 ForEach([row.progress, row.detail].compactMap { $0 }, id: \.self) { progress in
601 Text(progress).monospacedDigit()
602 .padding(.horizontal, 5)
603 .background(.quaternary.opacity(0.7), in: Capsule())
604 }
605 ForEach(row.tags.prefix(3), id: \.self) { tag in
606 Text(tag).lineLimit(1)
607 .padding(.horizontal, 5)
608 .background(.quaternary.opacity(0.7), in: Capsule())
609 }
610 Spacer(minLength: 4)
611 marks
612 }
613 .font(.caption)
614 .foregroundStyle(.secondary)
615 Text(row.title.isEmpty ? " " : row.title)
616 .font(.body)
617 .strikethrough(row.isDone)
618 .foregroundStyle(row.isDone ? .secondary : .primary)
619 if let location = row.event?.location {
620 Label(location, systemImage: "mappin").font(.caption).foregroundStyle(.secondary).lineLimit(1)
621 }
622 if let habit = row.item?.habit {
623 HabitStrip(cells: habit.graph(today: day), theme: theme, dark: dark).padding(.top, 3)
624 }
625 }
626 }
627 .padding(.vertical, 2)
628 // Blocked entries dim, as `org-agenda-dim-blocked-tasks`.
629 .opacity(row.isBlocked ? 0.5 : row.isDone ? 0.75 : 1)
630 }
631
632 /// The TODO keyword and the priority, on the category's line.
633 private var marks: some View {
634 HStack(spacing: 5) {
635 if let todo = row.todo {
636 let color = theme.todoColor(todo, isDone: row.isDone, dark: dark)?.swiftUI ?? .secondary
637 Text(todo)
638 .font(.caption2.weight(.semibold))
639 .foregroundStyle(color)
640 .padding(.horizontal, 6).padding(.vertical, 2)
641 .background(color.opacity(0.14), in: Capsule())
642 }
643 if let priority = row.priority {
644 let color = theme.priorityColor(priority, dark: dark)?.swiftUI ?? .orange
645 Text(priority)
646 .font(.caption2.weight(.bold))
647 .foregroundStyle(color)
648 .frame(width: 18, height: 18)
649 .background(color.opacity(0.14), in: Circle())
650 .overlay(Circle().strokeBorder(color, lineWidth: 1.2))
651 .accessibilityLabel("Priority \(priority)")
652 }
653 }
654 .fixedSize()
655 }
656
657 private var statusColor: Color {
658 theme.statusColor(row.status, dark: dark)?.swiftUI ?? .secondary
659 }
660
661 private var categoryColor: Color {
662 if let event = row.event { return theme.eventColor(event, dark: dark)?.swiftUI ?? .purple }
663 return theme.categoryColor(row.category, dark: dark).swiftUI
664 }
665
666 /// The start over the end for timed rows, else the status; the icon under them.
667 private var gutter: some View {
668 HStack(alignment: .firstTextBaseline, spacing: 4) {
669 Image(systemName: icon ?? "circle").font(.caption2.weight(.semibold)).foregroundStyle(iconColor)
670 .frame(width: 14)
671 .opacity(icon == nil ? 0 : 1)
672 VStack(alignment: .leading, spacing: 1) {
673 if row.start != nil {
674 let parts = row.gutter.split(separator: "–", maxSplits: 1)
675 Text(parts.first.map(String.init) ?? row.gutter)
676 .font(.subheadline.monospacedDigit().weight(.semibold))
677 .foregroundStyle(row.status == .normal ? Color.primary : row.status == .done ? Color.secondary : statusColor)
678 if parts.count > 1 {
679 Text(parts[1]).font(.caption.monospacedDigit()).foregroundStyle(.secondary)
680 }
681 } else if !row.gutter.isEmpty {
682 Text(row.gutter)
683 .font(.caption.weight(.medium).monospacedDigit())
684 .foregroundStyle(statusColor)
685 .lineLimit(1)
686 .allowsTightening(true)
687 .minimumScaleFactor(0.8)
688 }
689 }
690 }
691 }
692
693 private var icon: String? {
694 switch row.icon {
695 case .scheduled: row.status == .overdue ? "exclamationmark.circle.fill" : "calendar"
696 case .deadline: row.status == .overdue ? "exclamationmark.triangle.fill" : "flag.fill"
697 case .habit: "repeat"
698 case .event: "calendar"
699 case .timestamp: "clock"
700 case nil: nil
701 }
702 }
703
704 private var iconColor: Color {
705 if row.isEvent { return categoryColor }
706 return row.status == .normal ? Color(.tertiaryLabel) : statusColor
707 }
708}
709
710/// The current time between timed rows, as Org's `now - - -` line.
711struct NowLine: View {
712 let time: Date
713 let color: Color
714
715 var body: some View {
716 HStack(spacing: 6) {
717 Text(time.formatted(date: .omitted, time: .shortened))
718 .font(.caption2.weight(.semibold).monospacedDigit())
719 .foregroundStyle(color)
720 Circle().fill(color).frame(width: 6, height: 6)
721 Rectangle().fill(color).frame(height: 1.5)
722 }
723 .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
724 .listRowSeparator(.hidden)
725 .frame(height: 16)
726 .accessibilityLabel("Now")
727 }
728}
729
730/// org-habit's consistency graph, compact: a bar per day, a dot where the habit was done,
731/// today outlined.
732struct HabitStrip: View {
733 let cells: [Habit.Cell]
734 let theme: ThemeSpec
735 let dark: Bool
736
737 var body: some View {
738 HStack(spacing: 1) {
739 ForEach(cells, id: \.day) { cell in
740 RoundedRectangle(cornerRadius: 1.5)
741 .fill(color(cell.face))
742 .frame(width: 4, height: 12)
743 .overlay {
744 if cell.glyph == "*" { Circle().fill(.white.opacity(0.9)).frame(width: 3, height: 3) }
745 }
746 .overlay {
747 if cell.glyph == "!" { RoundedRectangle(cornerRadius: 1.5).strokeBorder(Color.primary, lineWidth: 1) }
748 }
749 }
750 }
751 .accessibilityElement(children: .ignore)
752 .accessibilityLabel("Done \(cells.filter { $0.glyph == "*" }.count) of the last \(Habit.precedingDays) days")
753 }
754
755 /// Due-state colors, lighter for days to come, as org-habit's faces.
756 private func color(_ face: Habit.Face) -> Color {
757 func themed(_ key: String, _ fallback: Color) -> Color { theme.color(key, dark: dark)?.swiftUI ?? fallback }
758 switch face {
759 case .clear: return themed("habit-clear", .blue).opacity(0.7)
760 case .clearFuture: return themed("habit-clear", .blue).opacity(0.3)
761 case .ready: return themed("habit-ready", .green).opacity(0.8)
762 case .readyFuture: return themed("habit-ready", .green).opacity(0.3)
763 case .alert: return themed("habit-alert", .yellow).opacity(0.85)
764 case .alertFuture: return themed("habit-alert", .yellow).opacity(0.35)
765 case .overdue: return themed("habit-overdue", .red).opacity(0.8)
766 case .overdueFuture: return themed("habit-overdue", .red).opacity(0.3)
767 }
768 }
769}
770
771/// A date to go to or to schedule on.
772struct DateSheet: View {
773 let title: String
774 let action: String
775 let initial: Date
776 let done: (Date?) -> Void
777 @State private var date = Date()
778
779 var body: some View {
780 NavigationStack {
781 DatePicker(title, selection: $date, displayedComponents: .date)
782 .datePickerStyle(.graphical)
783 .padding(.horizontal)
784 .navigationTitle(title)
785 .navigationBarTitleDisplayMode(.inline)
786 .toolbar {
787 ToolbarItem(placement: .cancellationAction) { Button("Cancel") { done(nil) } }
788 ToolbarItem(placement: .confirmationAction) { Button(action) { done(date) } }
789 }
790 Spacer()
791 }
792 .presentationDetents([.medium, .large])
793 .onAppear { date = initial }
794 }
795}
796
797extension AgendaItem: @retroactive Identifiable {
798 public var id: String { "\(path ?? "")|\(headingOffset ?? -1)|\(kind.rawValue)|\(text)" }
799
800 /// An entry, not a time-grid line.
801 var isEntry: Bool { kind != .timeGrid && kind != .currentTime }
802}
803
804/// Org's agenda filter: tags and categories of the entries shown, kept or left out, or the
805/// filter typed as `/` takes it.
806struct FilterSheet: View {
807 @Binding var filter: AgendaFilter
808 let items: [AgendaItem]
809 @State private var typed = ""
810 @Environment(\.dismiss) private var dismiss
811
812 var body: some View {
813 let tags = Set(items.flatMap(\.tags)).sorted()
814 let categories = Set(items.filter { $0.path != nil }.map(\.category)).sorted()
815 NavigationStack {
816 Form {
817 Section {
818 TextField("+work-home", text: $typed)
819 .autocorrectionDisabled()
820 .textInputAutocapitalization(.never)
821 .onSubmit(apply)
822 } footer: {
823 Text("As Org's / filter: +keep and -drop tags or categories, <0:30 for effort, /regexp/.")
824 }
825 if !tags.isEmpty {
826 Section("Tags") { ForEach(tags, id: \.self) { term($0, in: \.tags) } }
827 }
828 if !categories.isEmpty {
829 Section("Categories") { ForEach(categories, id: \.self) { term($0, in: \.categories) } }
830 }
831 }
832 .navigationTitle("Filter")
833 .navigationBarTitleDisplayMode(.inline)
834 .toolbar {
835 ToolbarItem(placement: .cancellationAction) { Button("Clear") { filter = AgendaFilter(); dismiss() } }
836 ToolbarItem(placement: .confirmationAction) { Button("Done") { apply(); dismiss() } }
837 }
838 }
839 .onAppear { typed = filter.description }
840 }
841
842 /// Keep, leave out, or no filter on `name`.
843 private func term(_ name: String, in list: WritableKeyPath<AgendaFilter, [String]>) -> some View {
844 let state = filter[keyPath: list].contains("+" + name) ? 1 : filter[keyPath: list].contains("-" + name) ? -1 : 0
845 return Button {
846 var terms = filter[keyPath: list].filter { $0.dropFirst() != Substring(name) }
847 if state == 0 { terms.append("+" + name) } else if state == 1 { terms.append("-" + name) }
848 filter[keyPath: list] = terms
849 typed = filter.description
850 } label: {
851 HStack {
852 Text(name).foregroundStyle(.primary)
853 Spacer()
854 Text(state == 1 ? "Keep" : state == -1 ? "Leave out" : "").foregroundStyle(.secondary)
855 }
856 }
857 }
858
859 private func apply() {
860 guard typed != filter.description else { return }
861 let tags = Set(items.flatMap(\.tags))
862 let categories = Set(items.map(\.category))
863 filter = AgendaFilter().parse(typed, tags: tags, categories: categories).filter
864 }
865}
866
867/// Where to refile: the targets `org-refile` offers, searchable.
868struct RefileSheet: View {
869 let targets: [RefileTarget]
870 let choose: (RefileTarget?) -> Void
871 @State private var query = ""
872
873 var body: some View {
874 NavigationStack {
875 List(targets.filter { query.isEmpty || $0.label.localizedCaseInsensitiveContains(query) }, id: \.self) { target in
876 Button(target.label) { choose(target) }.foregroundStyle(.primary)
877 }
878 .searchable(text: $query, placement: .navigationBarDrawer(displayMode: .always))
879 .navigationTitle("Refile to")
880 .navigationBarTitleDisplayMode(.inline)
881 .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { choose(nil) } } }
882 }
883 }
884}
885#endif