krz/orgstar

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

Commit 3c3aea2b9d

3c3aea2b9d5dad82c0d748c8df5c4648123863ee

parent: 86bed886a4

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-09 05:16 UTC

iOS agenda: rows, day sections and floating navigation

Rows from AgendaRow: time or status with an icon in the gutter, category dot,
tags and progress, title, keyword and priority badges, compact habit graph.
Days from AgendaModel.groups: empty days hidden, today highlighted with a
collapsible Overdue group and the now line, an add button per day. A glass
bar moves by span, filters, returns to today, jumps to a date and switches
between day, week, month and the configured span. Swipes reschedule
(tomorrow, next week, a date), mark done or move to the next state.

Layout: unified · split

Sources/OrgstarMobile/AgendaScreen.swift +505 −50
@@ -2,6 +2,7 @@
22import OrgApp
33import OrgCore
44import OrgDocument
5import OrgPresentation
56import SwiftUI
67
78/// The agenda over the workspace's agenda files: the day/week view, the TODO list, tag and
@@ -15,6 +16,7 @@ struct AgendaScreen: View {
1516 @State private var message: String?
1617 @Environment(ClockModel.self) private var clock
1718 @Environment(MobileModel.self) private var model
19 @Environment(\.colorScheme) private var colorScheme
1820 @State private var tick = 0
1921 @State private var views = AgendaViewLoader.builtIn
2022 @State private var viewName = "Agenda"
@@ -24,6 +26,15 @@ struct AgendaScreen: View {
2426 @State private var prompt: EditorScreen.PromptRequest?
2527 @State private var refiling: AgendaItem?
2628 @State private var archiving: AgendaItem?
29 /// The day, week or month shown; nil for `org-agenda-span` and `org-agenda-start-day`.
30 @State private var range: SpanRange?
31 /// The mode `range` set, to notice when something else changes the view.
32 @State private var rangeMode: AgendaMode?
33 @State private var jumping = false
34 @State private var rescheduling: AgendaRow?
35 @AppStorage("agendaOverdueExpanded") private var overdueExpanded = true
36 @State private var done = 0
37 @State private var moved = 0
2738
2839 /// A match typed for the tag and property view.
2940 private enum Asking: Identifiable {
@@ -31,6 +42,12 @@ struct AgendaScreen: View {
3142 var id: Int { 0 }
3243 }
3344
45 enum SpanRange: String, CaseIterable, Identifiable {
46 case day, week, month
47 var id: String { rawValue }
48 var title: String { rawValue.capitalized }
49 }
50
3451 private struct Key: Equatable {
3552 var files: Int
3653 var changed: Int?
@@ -41,47 +58,56 @@ struct AgendaScreen: View {
4158 var span: Int
4259 var startOffset: Int
4360 var subfolders: Bool
61 var settings: Int
4462 }
4563
64 private var isDark: Bool { colorScheme == .dark }
65 private var theme: ThemeSpec { model.theme }
66
4667 var body: some View {
4768 List {
4869 // With no folders the empty state stands alone, not over a column of empty days.
4970 if workspace.rootPaths.isEmpty {
5071 } else if case .agenda = agenda.mode {
51 ForEach(agenda.days, id: \.day) { day in
72 let _ = model.settingsVersion
73 ForEach(agenda.groups(showAllDates: OrgPreferences.agendaShowAllDates(), keep: filter.keeps)) { group in
5274 Section {
53 let items = day.items.filter(\.isEntry).filter(filter.keeps)
54 ForEach(items.indices, id: \.self) { i in row(items[i]) }
75 day(group)
5576 } header: {
56 Text(day.header.split(separator: " ", omittingEmptySubsequences: true).prefix(4).joined(separator: " "))
57 .foregroundStyle(day.isToday ? Color.accentColor : .secondary)
77 header(group)
5878 }
5979 }
6080 } else {
61 let items = agenda.list.filter(filter.keeps)
62 if items.isEmpty, !agenda.isComputing { Text("No entries").foregroundStyle(.secondary) }
63 ForEach(items.indices, id: \.self) { i in row(items[i]) }
81 let rows = agenda.list.filter(filter.keeps).compactMap { AgendaRow(item: $0, day: agenda.today, today: agenda.today) }
82 if rows.isEmpty, !agenda.isComputing { Text("No entries").foregroundStyle(.secondary) }
83 ForEach(rows) { row($0, day: agenda.today) }
6484 }
6585 if let reminderStatus {
6686 Section { } footer: { Text(reminderStatus) }
6787 }
6888 }
6989 .listStyle(.insetGrouped)
70 .navigationTitle(viewName)
90 .listSectionSpacing(.compact)
91 // The now line is shorter than a row.
92 .environment(\.defaultMinListRowHeight, 16)
93 .navigationTitle(title)
94 .navigationBarTitleDisplayMode(.inline)
95 .sensoryFeedback(.success, trigger: done)
96 .sensoryFeedback(.impact(weight: .light), trigger: moved)
7197 .onChange(of: agenda.start) {
7298 // Days a followed timestamp asked for.
7399 if agenda.start != nil, case .agenda = agenda.mode { viewName = "Agenda" }
74100 }
101 .onChange(of: agenda.mode) {
102 if agenda.mode != rangeMode { range = nil }
103 }
75104 .overlay {
76105 if workspace.rootPaths.isEmpty {
77106 ContentUnavailableView("No folders", systemImage: "folder", description: Text("Add a folder of org files under Folders."))
78107 }
79108 }
80109 .safeAreaInset(edge: .bottom) {
81 if let status = message ?? (filter.isEmpty ? nil : "Filter: " + filter.description) {
82 Text(status).font(.callout).padding(8).frame(maxWidth: .infinity).background(.bar)
83 .onTapGesture { message = nil }
84 }
110 if !workspace.rootPaths.isEmpty { bottomBar }
85111 }
86112 .toolbar {
87113 ToolbarItem(placement: .topBarLeading) {
@@ -90,25 +116,26 @@ struct AgendaScreen: View {
90116 Button(definition.name) { show(definition) }
91117 }
92118 Button("Tags and Properties…") { asking = .match }
93 if case .agenda = agenda.mode {
94 Divider()
95 Button("Today", systemImage: "calendar.circle") { agenda.showToday() }
96 Button("Earlier", systemImage: "chevron.left") { agenda.shift(forward: false) }
97 Button("Later", systemImage: "chevron.right") { agenda.shift(forward: true) }
98 }
99119 } label: {
100 Label("Views", systemImage: "calendar")
101 }
102 }
103 ToolbarItem(placement: .topBarLeading) {
104 Button("Filter", systemImage: filter.isEmpty ? "line.3.horizontal.decrease.circle" : "line.3.horizontal.decrease.circle.fill") {
105 showingFilter = true
120 Label("Views", systemImage: "list.bullet.rectangle")
106121 }
107122 }
108123 }
109124 .sheet(isPresented: $showingFilter) {
110125 FilterSheet(filter: $filter, items: shownItems)
111126 }
127 .sheet(isPresented: $jumping) {
128 DateSheet(title: "Go to Date", action: "Go", initial: date(firstDay)) { picked in
129 jumping = false
130 if let picked { jump(to: Days.absolute(picked, calendar: .current)) }
131 }
132 }
133 .sheet(item: $rescheduling) { row in
134 DateSheet(title: row.icon == .deadline ? "Deadline" : "Schedule", action: "Set", initial: date(row.planningDay ?? agenda.today)) { picked in
135 rescheduling = nil
136 if let picked, let item = row.item { reschedule(row, item, to: Days.absolute(picked, calendar: .current)) }
137 }
138 }
112139 .sheet(item: $asking) { _ in
113140 PromptSheet(prompt: Prompt(key: "match", message: "Match: tags and properties, as +work-home or TODO=\"WAIT\"")) { answer in
114141 asking = nil
@@ -146,9 +173,14 @@ struct AgendaScreen: View {
146173 views = loaded.views
147174 if let problem = loaded.problems.first { message = problem }
148175 }
176 // Calendar access is asked for when calendar-events is turned on.
177 .task(id: model.settingsVersion) {
178 guard OrgPreferences.calendarEvents(), !CalendarEvents.isAuthorized else { return }
179 if await model.calendarEvents.requestAccess() { tick += 1 }
180 }
149181 .task(id: Key(files: workspace.files.count, changed: workspace.changedFile?.version, edits: session.editVersion, tick: tick,
150182 mode: agenda.mode, start: agenda.start, span: agenda.span, startOffset: agenda.startOffset,
151 subfolders: agenda.includeSubfolders)) {
183 subfolders: agenda.includeSubfolders, settings: model.settingsVersion)) {
152184 await agenda.refresh(open: session.openTexts)
153185 }
154186 }
@@ -173,24 +205,220 @@ struct AgendaScreen: View {
173205 + ". Open Orgstar to set later ones."
174206 }
175207
208 // MARK: - Days
209
210 private func day(_ group: AgendaDayGroup) -> some View {
211 Group {
212 if group.isToday, !group.overdue.isEmpty {
213 Button {
214 withAnimation { overdueExpanded.toggle() }
215 } label: {
216 HStack(spacing: 6) {
217 Image(systemName: "chevron.right")
218 .font(.caption.weight(.semibold))
219 .rotationEffect(.degrees(overdueExpanded ? 90 : 0))
220 Text("Overdue").font(.subheadline.weight(.semibold))
221 Text("\(group.overdue.count)").font(.subheadline.monospacedDigit())
222 Spacer()
223 }
224 .foregroundStyle(themed(theme.statusColor(.overdue, dark: isDark), .red))
225 .contentShape(Rectangle())
226 }
227 .buttonStyle(.plain)
228 if overdueExpanded {
229 ForEach(group.overdue) { row($0, day: group.day) }
230 }
231 }
232 if group.isEmpty {
233 Text("Nothing scheduled").font(.subheadline).foregroundStyle(.secondary)
234 }
235 let now = nowIndex(group)
236 ForEach(Array(group.rows.enumerated()), id: \.element.id) { index, item in
237 if index == now { NowLine(color: todayColor) }
238 row(item, day: group.day)
239 }
240 if let now, now == group.rows.count { NowLine(color: todayColor) }
241 }
242 }
243
244 private func nowIndex(_ group: AgendaDayGroup) -> Int? {
245 let c = Calendar.current.dateComponents([.hour, .minute], from: Date())
246 return group.nowIndex(now: (c.hour ?? 0) * 100 + (c.minute ?? 0))
247 }
248
249 private var todayColor: Color { themed(theme.color("agenda-today", dark: isDark), .accentColor) }
250
251 private func header(_ group: AgendaDayGroup) -> some View {
252 let day = date(group.day).formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated))
253 return HStack(alignment: .firstTextBaseline) {
254 Text(group.isToday ? "Today · " + day : day)
255 .font(.subheadline.weight(group.isToday ? .bold : .semibold))
256 .foregroundStyle(group.isToday ? todayColor : .primary)
257 Spacer()
258 Button("Add to \(day)", systemImage: "plus.circle") { model.captureRequest = CaptureRequest(day: group.day) }
259 .labelStyle(.iconOnly)
260 .font(.body)
261 .foregroundStyle(group.isToday ? todayColor : .secondary)
262 }
263 .textCase(nil)
264 }
265
266 // MARK: - Span
267
268 /// The first day shown, as `Days` numbers it, and how many.
269 private var shown: (first: Int, span: Int) {
270 var span = agenda.span
271 var offset = agenda.startOffset
272 if case .agenda(let viewSpan, let viewStart) = agenda.mode {
273 span = viewSpan ?? span
274 offset = viewStart ?? offset
275 }
276 return (agenda.start ?? agenda.today + offset, span)
277 }
278
279 private var firstDay: Int { shown.first }
280
281 /// The span: `Thu 8 Oct`, `Week of 5 Oct`, `October 2026`, or the days, `5 – 14 Oct`.
282 private var title: String {
283 guard case .agenda = agenda.mode else { return viewName }
284 if viewName != "Agenda", range == nil, agenda.start == nil { return viewName }
285 let (first, span) = shown
286 let start = date(first)
287 if span == 1 { return start.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated)) }
288 if span == 7, first == weekStart(first) { return "Week of " + start.formatted(.dateTime.day().month(.abbreviated)) }
289 let d = Days.date(first)
290 if d.day == 1, span == Days.absolute(year: d.year, month: d.month + 1, day: 1) - first {
291 return start.formatted(.dateTime.month(.wide).year())
292 }
293 return start.formatted(.dateTime.day().month(.abbreviated)) + " – " + date(first + span - 1).formatted(.dateTime.day().month(.abbreviated))
294 }
295
296 private func weekStart(_ day: Int) -> Int {
297 day - (Days.weekday(day) - (Calendar.current.firstWeekday - 1) + 7) % 7
298 }
299
300 /// Shows `range` around `day`; nil returns to the configured span.
301 private func show(_ range: SpanRange?, around day: Int) {
302 self.range = range
303 let mode: AgendaMode
304 let start: Int?
305 switch range {
306 case .day:
307 (mode, start) = (.agenda(span: 1, startOffset: 0), day)
308 case .week:
309 (mode, start) = (.agenda(span: 7, startOffset: 0), weekStart(day))
310 case .month:
311 let d = Days.date(day)
312 let first = Days.absolute(year: d.year, month: d.month, day: 1)
313 (mode, start) = (.agenda(span: Days.absolute(year: d.year, month: d.month + 1, day: 1) - first, startOffset: 0), first)
314 case nil:
315 (mode, start) = (.agenda(span: nil, startOffset: nil), day == agenda.today ? nil : day)
316 }
317 rangeMode = mode
318 viewName = "Agenda"
319 agenda.mode = mode
320 agenda.start = start
321 }
322
323 private func step(forward: Bool) {
324 if range == nil, case .agenda = agenda.mode {
325 agenda.shift(forward: forward)
326 } else {
327 let (first, span) = shown
328 show(range, around: forward ? first + span : first - 1)
329 }
330 }
331
332 private func jump(to day: Int) {
333 show(range, around: day)
334 }
335
336 private func goToday() {
337 if range == nil { show(nil, around: agenda.today) } else { show(range, around: agenda.today) }
338 }
339
340 private var bottomBar: some View {
341 VStack(spacing: 8) {
342 if let status = message ?? (filter.isEmpty ? nil : "Filter: " + filter.description) {
343 Text(status).font(.footnote).padding(.horizontal, 12).padding(.vertical, 6)
344 .glassEffect(.regular, in: Capsule())
345 .onTapGesture { message = nil }
346 }
347 HStack(spacing: 2) {
348 let isAgenda = if case .agenda = agenda.mode { true } else { false }
349 if isAgenda {
350 barButton("Earlier", "chevron.left") { step(forward: false) }
351 }
352 barButton("Filter", filter.isEmpty ? "line.3.horizontal.decrease" : "line.3.horizontal.decrease.circle.fill") { showingFilter = true }
353 if isAgenda {
354 barButton("Today", "smallcircle.filled.circle") { goToday() }
355 barButton("Go to Date", "calendar") { jumping = true }
356 Menu {
357 Picker("Span", selection: Binding { range } set: { show($0, around: $0 == nil ? agenda.today : firstDay) }) {
358 ForEach(SpanRange.allCases) { Text($0.title).tag(Optional($0)) }
359 Text("\(agenda.span) Days").tag(SpanRange?.none)
360 }
361 } label: {
362 Text(range?.title ?? "\(agenda.span) Days")
363 .font(.footnote.weight(.semibold).monospacedDigit())
364 .lineLimit(1)
365 .frame(minWidth: 52, minHeight: 44)
366 .contentShape(Rectangle())
367 }
368 .accessibilityLabel("Span")
369 barButton("Later", "chevron.right") { step(forward: true) }
370 } else {
371 barButton("Agenda", "calendar") { show(nil, around: agenda.today) }
372 }
373 }
374 .padding(.horizontal, 6)
375 .glassEffect(.regular.interactive(), in: Capsule())
376 }
377 .padding(.bottom, 8)
378 .frame(maxWidth: .infinity)
379 }
380
381 private func barButton(_ title: String, _ image: String, action: @escaping () -> Void) -> some View {
382 Button(title, systemImage: image, action: action)
383 .labelStyle(.iconOnly)
384 .font(.body.weight(.medium))
385 .frame(width: 44, height: 44)
386 .contentShape(Rectangle())
387 }
388
176389 // MARK: - Rows
177390
178 private func row(_ item: AgendaItem) -> some View {
391 private func row(_ row: AgendaRow, day: Int) -> some View {
179392 Group {
180 if let path = item.path, let offset = item.headingOffset ?? item.markerOffset {
181 NavigationLink(value: ReaderRoute.offset(path, offset)) { entry(item) }
393 if let item = row.item, let path = item.path, let offset = item.headingOffset ?? item.markerOffset {
394 NavigationLink(value: ReaderRoute.offset(path, offset)) { AgendaRowView(row: row, day: day, theme: theme, dark: isDark) }
182395 } else {
183 entry(item)
396 AgendaRowView(row: row, day: day, theme: theme, dark: isDark)
184397 }
185398 }
186399 .swipeActions(edge: .trailing) {
187 if item.todo != nil, !item.isDone, doneKeyword(for: item) != nil {
400 if let item = row.item, item.todo != nil, !item.isDone, doneKeyword(for: item) != nil {
188401 Button("Done", systemImage: "checkmark") { markDone(item) }.tint(.green)
402 Button("Next State", systemImage: "arrow.right.circle") { run("org.todo.next", on: item) }.tint(.blue)
403 }
404 }
405 .swipeActions(edge: .leading) {
406 if canReschedule(row), let item = row.item {
407 Button("Tomorrow", systemImage: "sunrise") { reschedule(row, item, to: agenda.today + 1) }.tint(.orange)
408 Button("Next Week", systemImage: "calendar.badge.plus") { reschedule(row, item, to: agenda.today + 7) }.tint(.indigo)
409 Button("Pick Date", systemImage: "calendar") { rescheduling = row }.tint(.gray)
189410 }
190411 }
191412 .contextMenu {
192 if item.path != nil {
413 if let item = row.item, item.path != nil {
193414 Button("TODO State…", systemImage: "checkmark.circle") { run("org.todo.cycle", on: item) }
415 if canReschedule(row) {
416 Menu("Reschedule", systemImage: "calendar.badge.clock") {
417 Button("Tomorrow") { reschedule(row, item, to: agenda.today + 1) }
418 Button("Next Week") { reschedule(row, item, to: agenda.today + 7) }
419 Button("Pick Date…") { rescheduling = row }
420 }
421 }
194422 Button("Schedule…", systemImage: "calendar") { run("org.schedule", on: item) }
195423 Button("Deadline…", systemImage: "calendar.badge.exclamationmark") { run("org.deadline", on: item) }
196424 Button("Tags…", systemImage: "tag") { run("org.tags.set", on: item) }
@@ -205,26 +433,26 @@ struct AgendaScreen: View {
205433 }
206434 }
207435
208 private func entry(_ item: AgendaItem) -> some View {
209 VStack(alignment: .leading, spacing: 2) {
210 HStack(alignment: .firstTextBaseline, spacing: 6) {
211 if let time = item.time.firstMatch(of: /[0-9]{1,2}:[0-9]{2}(-[0-9]{1,2}:[0-9]{2})?/) {
212 Text(time.output.0).font(.callout.monospacedDigit()).foregroundStyle(.secondary)
213 }
214 Text(item.text).strikethrough(item.isDone)
215 }
216 HStack(spacing: 6) {
217 Text(item.category)
218 let extra = item.extra.split(whereSeparator: \.isWhitespace).joined(separator: " ")
219 if !extra.isEmpty { Text(extra) }
220 if !item.tags.isEmpty { Text(item.tags.map { ":" + $0 }.joined() + ":") }
221 }
222 .font(.caption)
223 .foregroundStyle(.secondary)
224 }
436 // MARK: - Actions
437
438 /// Entries with a heading, not done, that a schedule or deadline can move.
439 private func canReschedule(_ row: AgendaRow) -> Bool {
440 guard let item = row.item, item.path != nil, item.headingLine != nil, !row.isDone else { return false }
441 return item.todo != nil || row.icon == .scheduled || row.icon == .deadline || row.icon == .habit
225442 }
226443
227 // MARK: - Actions
444 /// Moves the row's deadline (on a deadline row) or schedule to `day`, keeping its times,
445 /// as `org-agenda-schedule` and `org-agenda-deadline` with a date typed.
446 private func reschedule(_ row: AgendaRow, _ item: AgendaItem, to day: Int) {
447 let d = Days.date(day)
448 var answer = String(format: "%04d-%02d-%02d", d.year, d.month, d.day)
449 if let start = row.start {
450 answer += String(format: " %02d:%02d", start / 100 % 24, start % 100)
451 if let end = row.end { answer += String(format: "-%02d:%02d", end / 100 % 24, end % 100) }
452 }
453 run(row.icon == .deadline ? "org.deadline" : "org.schedule", on: item, answers: ["date": answer])
454 moved += 1
455 }
228456
229457 /// The heading of `item` now, found again by its line in case the file changed.
230458 private func locate(_ item: AgendaItem) -> (path: String, offset: Int)? {
@@ -280,6 +508,7 @@ struct AgendaScreen: View {
280508 private func markDone(_ item: AgendaItem) {
281509 guard let keyword = doneKeyword(for: item) else { return }
282510 run(SetTodo(keyword: keyword), on: item)
511 done += 1
283512 }
284513
285514 private func refile(_ item: AgendaItem, to target: RefileTarget) {
@@ -300,6 +529,232 @@ struct AgendaScreen: View {
300529 message = session.archive(from: (path, offset))
301530 tick += 1
302531 }
532
533 private func date(_ day: Int) -> Date {
534 let d = Days.date(day)
535 return Calendar.current.date(from: DateComponents(year: d.year, month: d.month, day: d.day)) ?? Date()
536 }
537
538 private func themed(_ color: RGBA?, _ fallback: Color) -> Color { color?.swiftUI ?? fallback }
539}
540
541extension RGBA {
542 var swiftUI: Color { Color(.sRGB, red: red, green: green, blue: blue, opacity: alpha) }
543}
544
545/// One agenda line: the time or status with an icon in the gutter; the category, title,
546/// progress and tags; the TODO keyword and priority on the right.
547struct AgendaRowView: View {
548 let row: AgendaRow
549 /// The day the row is on, for habit graphs.
550 let day: Int
551 let theme: ThemeSpec
552 let dark: Bool
553
554 var body: some View {
555 HStack(alignment: .top, spacing: 10) {
556 // TODO and match lists have no times or statuses.
557 if !row.gutter.isEmpty || row.icon != nil { gutter.frame(width: 82, alignment: .leading) }
558 VStack(alignment: .leading, spacing: 2) {
559 HStack(spacing: 5) {
560 if !row.category.isEmpty {
561 Circle().fill(categoryColor).frame(width: 7, height: 7)
562 Text(row.category).lineLimit(1).layoutPriority(1)
563 }
564 if let progress = row.progress {
565 Text(progress).monospacedDigit()
566 .padding(.horizontal, 5)
567 .background(.quaternary.opacity(0.7), in: Capsule())
568 }
569 ForEach(row.tags.prefix(3), id: \.self) { tag in
570 Text(tag).lineLimit(1)
571 .padding(.horizontal, 5)
572 .background(.quaternary.opacity(0.7), in: Capsule())
573 }
574 Spacer(minLength: 4)
575 marks
576 }
577 .font(.caption)
578 .foregroundStyle(.secondary)
579 Text(row.title.isEmpty ? " " : row.title)
580 .font(.body)
581 .strikethrough(row.isDone)
582 .foregroundStyle(row.isDone ? .secondary : .primary)
583 if let location = row.event?.location {
584 Label(location, systemImage: "mappin").font(.caption).foregroundStyle(.secondary).lineLimit(1)
585 }
586 if let habit = row.item?.habit {
587 HabitStrip(cells: habit.graph(today: day), theme: theme, dark: dark).padding(.top, 3)
588 }
589 }
590 }
591 .padding(.vertical, 2)
592 .opacity(row.isDone ? 0.75 : 1)
593 }
594
595 /// The TODO keyword and the priority, on the category's line.
596 private var marks: some View {
597 HStack(spacing: 5) {
598 if let todo = row.todo {
599 let color = theme.todoColor(todo, isDone: row.isDone, dark: dark)?.swiftUI ?? .secondary
600 Text(todo)
601 .font(.caption2.weight(.semibold))
602 .foregroundStyle(color)
603 .padding(.horizontal, 6).padding(.vertical, 2)
604 .background(color.opacity(0.14), in: Capsule())
605 }
606 if let priority = row.priority {
607 let color = theme.priorityColor(priority, dark: dark)?.swiftUI ?? .orange
608 Text(priority)
609 .font(.caption2.weight(.bold))
610 .foregroundStyle(color)
611 .frame(width: 18, height: 18)
612 .background(color.opacity(0.14), in: Circle())
613 .overlay(Circle().strokeBorder(color, lineWidth: 1.2))
614 .accessibilityLabel("Priority \(priority)")
615 }
616 }
617 .fixedSize()
618 }
619
620 private var statusColor: Color {
621 theme.statusColor(row.status, dark: dark)?.swiftUI ?? .secondary
622 }
623
624 private var categoryColor: Color {
625 if let event = row.event { return theme.eventColor(event, dark: dark)?.swiftUI ?? .purple }
626 return theme.categoryColor(row.category, dark: dark).swiftUI
627 }
628
629 /// The start over the end for timed rows, else the status; the icon under them.
630 private var gutter: some View {
631 HStack(alignment: .firstTextBaseline, spacing: 4) {
632 Image(systemName: icon ?? "circle").font(.caption2.weight(.semibold)).foregroundStyle(iconColor)
633 .frame(width: 14)
634 .opacity(icon == nil ? 0 : 1)
635 VStack(alignment: .leading, spacing: 1) {
636 if row.start != nil {
637 let parts = row.gutter.split(separator: "–", maxSplits: 1)
638 Text(parts.first.map(String.init) ?? row.gutter)
639 .font(.subheadline.monospacedDigit().weight(.semibold))
640 .foregroundStyle(row.status == .normal ? Color.primary : row.status == .done ? Color.secondary : statusColor)
641 if parts.count > 1 {
642 Text(parts[1]).font(.caption.monospacedDigit()).foregroundStyle(.secondary)
643 }
644 } else if !row.gutter.isEmpty {
645 Text(row.gutter)
646 .font(.caption.weight(.medium).monospacedDigit())
647 .foregroundStyle(statusColor)
648 .lineLimit(2)
649 .allowsTightening(true)
650 .minimumScaleFactor(0.85)
651 .fixedSize(horizontal: false, vertical: true)
652 }
653 }
654 }
655 }
656
657 private var icon: String? {
658 switch row.icon {
659 case .scheduled: row.status == .overdue ? "exclamationmark.circle.fill" : "calendar"
660 case .deadline: row.status == .overdue ? "exclamationmark.triangle.fill" : "flag.fill"
661 case .habit: "repeat"
662 case .event: "calendar"
663 case .timestamp: "clock"
664 case nil: nil
665 }
666 }
667
668 private var iconColor: Color {
669 if row.isEvent { return categoryColor }
670 return row.status == .normal ? Color(.tertiaryLabel) : statusColor
671 }
672}
673
674/// The current time between timed rows, as Org's `now - - -` line.
675struct NowLine: View {
676 let color: Color
677
678 var body: some View {
679 HStack(spacing: 6) {
680 Text(Date.now.formatted(date: .omitted, time: .shortened))
681 .font(.caption2.weight(.semibold).monospacedDigit())
682 .foregroundStyle(color)
683 Circle().fill(color).frame(width: 6, height: 6)
684 Rectangle().fill(color).frame(height: 1.5)
685 }
686 .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
687 .listRowSeparator(.hidden)
688 .frame(height: 16)
689 .accessibilityLabel("Now")
690 }
691}
692
693/// org-habit's consistency graph, compact: a bar per day, a dot where the habit was done,
694/// today outlined.
695struct HabitStrip: View {
696 let cells: [Habit.Cell]
697 let theme: ThemeSpec
698 let dark: Bool
699
700 var body: some View {
701 HStack(spacing: 1) {
702 ForEach(cells, id: \.day) { cell in
703 RoundedRectangle(cornerRadius: 1.5)
704 .fill(color(cell.face))
705 .frame(width: 4, height: 12)
706 .overlay {
707 if cell.glyph == "*" { Circle().fill(.white.opacity(0.9)).frame(width: 3, height: 3) }
708 }
709 .overlay {
710 if cell.glyph == "!" { RoundedRectangle(cornerRadius: 1.5).strokeBorder(Color.primary, lineWidth: 1) }
711 }
712 }
713 }
714 .accessibilityElement(children: .ignore)
715 .accessibilityLabel("Done \(cells.filter { $0.glyph == "*" }.count) of the last \(Habit.precedingDays) days")
716 }
717
718 /// Due-state colors, lighter for days to come, as org-habit's faces.
719 private func color(_ face: Habit.Face) -> Color {
720 func themed(_ key: String, _ fallback: Color) -> Color { theme.color(key, dark: dark)?.swiftUI ?? fallback }
721 switch face {
722 case .clear: return themed("habit-clear", .blue).opacity(0.7)
723 case .clearFuture: return themed("habit-clear", .blue).opacity(0.3)
724 case .ready: return themed("habit-ready", .green).opacity(0.8)
725 case .readyFuture: return themed("habit-ready", .green).opacity(0.3)
726 case .alert: return themed("habit-alert", .yellow).opacity(0.85)
727 case .alertFuture: return themed("habit-alert", .yellow).opacity(0.35)
728 case .overdue: return themed("habit-overdue", .red).opacity(0.8)
729 case .overdueFuture: return themed("habit-overdue", .red).opacity(0.3)
730 }
731 }
732}
733
734/// A date to go to or to schedule on.
735struct DateSheet: View {
736 let title: String
737 let action: String
738 let initial: Date
739 let done: (Date?) -> Void
740 @State private var date = Date()
741
742 var body: some View {
743 NavigationStack {
744 DatePicker(title, selection: $date, displayedComponents: .date)
745 .datePickerStyle(.graphical)
746 .padding(.horizontal)
747 .navigationTitle(title)
748 .navigationBarTitleDisplayMode(.inline)
749 .toolbar {
750 ToolbarItem(placement: .cancellationAction) { Button("Cancel") { done(nil) } }
751 ToolbarItem(placement: .confirmationAction) { Button(action) { done(date) } }
752 }
753 Spacer()
754 }
755 .presentationDetents([.medium, .large])
756 .onAppear { date = initial }
757 }
303758}
304759
305760extension AgendaItem: @retroactive Identifiable {