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