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