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