krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Sources/OrgstarMobile/AgendaScreen.swift

2fa201330f61c801f777baa3f2943d49d8758cda
orgstar/Sources/OrgstarMobile/AgendaScreen.swift history · blame · raw

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