Commit 3c3aea2b9d
Verified · cmc
Layout: unified · split
Sources/OrgstarMobile/AgendaScreen.swift +505 −50
| @@ -2,6 +2,7 @@ | |||
| 2 | import OrgApp | 2 | import OrgApp |
| 3 | import OrgCore | 3 | import OrgCore |
| 4 | import OrgDocument | 4 | import OrgDocument |
| 5 | import OrgPresentation | ||
| 5 | import SwiftUI | 6 | import SwiftUI |
| 6 | 7 | ||
| 7 | /// The agenda over the workspace's agenda files: the day/week view, the TODO list, tag and | 8 | /// The agenda over the workspace's agenda files: the day/week view, the TODO list, tag and |
| @@ -15,6 +16,7 @@ struct AgendaScreen: View { | |||
| 15 | @State private var message: String? | 16 | @State private var message: String? |
| 16 | @Environment(ClockModel.self) private var clock | 17 | @Environment(ClockModel.self) private var clock |
| 17 | @Environment(MobileModel.self) private var model | 18 | @Environment(MobileModel.self) private var model |
| 19 | @Environment(\.colorScheme) private var colorScheme | ||
| 18 | @State private var tick = 0 | 20 | @State private var tick = 0 |
| 19 | @State private var views = AgendaViewLoader.builtIn | 21 | @State private var views = AgendaViewLoader.builtIn |
| 20 | @State private var viewName = "Agenda" | 22 | @State private var viewName = "Agenda" |
| @@ -24,6 +26,15 @@ struct AgendaScreen: View { | |||
| 24 | @State private var prompt: EditorScreen.PromptRequest? | 26 | @State private var prompt: EditorScreen.PromptRequest? |
| 25 | @State private var refiling: AgendaItem? | 27 | @State private var refiling: AgendaItem? |
| 26 | @State private var archiving: AgendaItem? | 28 | @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 | ||
| 27 | 38 | ||
| 28 | /// A match typed for the tag and property view. | 39 | /// A match typed for the tag and property view. |
| 29 | private enum Asking: Identifiable { | 40 | private enum Asking: Identifiable { |
| @@ -31,6 +42,12 @@ struct AgendaScreen: View { | |||
| 31 | var id: Int { 0 } | 42 | var id: Int { 0 } |
| 32 | } | 43 | } |
| 33 | 44 | ||
| 45 | enum SpanRange: String, CaseIterable, Identifiable { | ||
| 46 | case day, week, month | ||
| 47 | var id: String { rawValue } | ||
| 48 | var title: String { rawValue.capitalized } | ||
| 49 | } | ||
| 50 | |||
| 34 | private struct Key: Equatable { | 51 | private struct Key: Equatable { |
| 35 | var files: Int | 52 | var files: Int |
| 36 | var changed: Int? | 53 | var changed: Int? |
| @@ -41,47 +58,56 @@ struct AgendaScreen: View { | |||
| 41 | var span: Int | 58 | var span: Int |
| 42 | var startOffset: Int | 59 | var startOffset: Int |
| 43 | var subfolders: Bool | 60 | var subfolders: Bool |
| 61 | var settings: Int | ||
| 44 | } | 62 | } |
| 45 | 63 | ||
| 64 | private var isDark: Bool { colorScheme == .dark } | ||
| 65 | private var theme: ThemeSpec { model.theme } | ||
| 66 | |||
| 46 | var body: some View { | 67 | var body: some View { |
| 47 | List { | 68 | List { |
| 48 | // With no folders the empty state stands alone, not over a column of empty days. | 69 | // With no folders the empty state stands alone, not over a column of empty days. |
| 49 | if workspace.rootPaths.isEmpty { | 70 | if workspace.rootPaths.isEmpty { |
| 50 | } else if case .agenda = agenda.mode { | 71 | } 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 | ||
| 52 | Section { | 74 | Section { |
| 53 | let items = day.items.filter(\.isEntry).filter(filter.keeps) | 75 | day(group) |
| 54 | ForEach(items.indices, id: \.self) { i in row(items[i]) } | ||
| 55 | } header: { | 76 | } header: { |
| 56 | Text(day.header.split(separator: " ", omittingEmptySubsequences: true).prefix(4).joined(separator: " ")) | 77 | header(group) |
| 57 | .foregroundStyle(day.isToday ? Color.accentColor : .secondary) | ||
| 58 | } | 78 | } |
| 59 | } | 79 | } |
| 60 | } else { | 80 | } else { |
| 61 | let items = agenda.list.filter(filter.keeps) | 81 | let rows = agenda.list.filter(filter.keeps).compactMap { AgendaRow(item: $0, day: agenda.today, today: agenda.today) } |
| 62 | if items.isEmpty, !agenda.isComputing { Text("No entries").foregroundStyle(.secondary) } | 82 | if rows.isEmpty, !agenda.isComputing { Text("No entries").foregroundStyle(.secondary) } |
| 63 | ForEach(items.indices, id: \.self) { i in row(items[i]) } | 83 | ForEach(rows) { row($0, day: agenda.today) } |
| 64 | } | 84 | } |
| 65 | if let reminderStatus { | 85 | if let reminderStatus { |
| 66 | Section { } footer: { Text(reminderStatus) } | 86 | Section { } footer: { Text(reminderStatus) } |
| 67 | } | 87 | } |
| 68 | } | 88 | } |
| 69 | .listStyle(.insetGrouped) | 89 | .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) | ||
| 71 | .onChange(of: agenda.start) { | 97 | .onChange(of: agenda.start) { |
| 72 | // Days a followed timestamp asked for. | 98 | // Days a followed timestamp asked for. |
| 73 | if agenda.start != nil, case .agenda = agenda.mode { viewName = "Agenda" } | 99 | if agenda.start != nil, case .agenda = agenda.mode { viewName = "Agenda" } |
| 74 | } | 100 | } |
| 101 | .onChange(of: agenda.mode) { | ||
| 102 | if agenda.mode != rangeMode { range = nil } | ||
| 103 | } | ||
| 75 | .overlay { | 104 | .overlay { |
| 76 | if workspace.rootPaths.isEmpty { | 105 | if workspace.rootPaths.isEmpty { |
| 77 | ContentUnavailableView("No folders", systemImage: "folder", description: Text("Add a folder of org files under Folders.")) | 106 | ContentUnavailableView("No folders", systemImage: "folder", description: Text("Add a folder of org files under Folders.")) |
| 78 | } | 107 | } |
| 79 | } | 108 | } |
| 80 | .safeAreaInset(edge: .bottom) { | 109 | .safeAreaInset(edge: .bottom) { |
| 81 | if let status = message ?? (filter.isEmpty ? nil : "Filter: " + filter.description) { | 110 | if !workspace.rootPaths.isEmpty { bottomBar } |
| 82 | Text(status).font(.callout).padding(8).frame(maxWidth: .infinity).background(.bar) | ||
| 83 | .onTapGesture { message = nil } | ||
| 84 | } | ||
| 85 | } | 111 | } |
| 86 | .toolbar { | 112 | .toolbar { |
| 87 | ToolbarItem(placement: .topBarLeading) { | 113 | ToolbarItem(placement: .topBarLeading) { |
| @@ -90,25 +116,26 @@ struct AgendaScreen: View { | |||
| 90 | Button(definition.name) { show(definition) } | 116 | Button(definition.name) { show(definition) } |
| 91 | } | 117 | } |
| 92 | Button("Tags and Properties…") { asking = .match } | 118 | 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 | } | ||
| 99 | } label: { | 119 | } label: { |
| 100 | Label("Views", systemImage: "calendar") | 120 | Label("Views", systemImage: "list.bullet.rectangle") |
| 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 | ||
| 106 | } | 121 | } |
| 107 | } | 122 | } |
| 108 | } | 123 | } |
| 109 | .sheet(isPresented: $showingFilter) { | 124 | .sheet(isPresented: $showingFilter) { |
| 110 | FilterSheet(filter: $filter, items: shownItems) | 125 | FilterSheet(filter: $filter, items: shownItems) |
| 111 | } | 126 | } |
| 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 | } | ||
| 112 | .sheet(item: $asking) { _ in | 139 | .sheet(item: $asking) { _ in |
| 113 | PromptSheet(prompt: Prompt(key: "match", message: "Match: tags and properties, as +work-home or TODO=\"WAIT\"")) { answer in | 140 | PromptSheet(prompt: Prompt(key: "match", message: "Match: tags and properties, as +work-home or TODO=\"WAIT\"")) { answer in |
| 114 | asking = nil | 141 | asking = nil |
| @@ -146,9 +173,14 @@ struct AgendaScreen: View { | |||
| 146 | views = loaded.views | 173 | views = loaded.views |
| 147 | if let problem = loaded.problems.first { message = problem } | 174 | if let problem = loaded.problems.first { message = problem } |
| 148 | } | 175 | } |
| 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 | } | ||
| 149 | .task(id: Key(files: workspace.files.count, changed: workspace.changedFile?.version, edits: session.editVersion, tick: tick, | 181 | .task(id: Key(files: workspace.files.count, changed: workspace.changedFile?.version, edits: session.editVersion, tick: tick, |
| 150 | mode: agenda.mode, start: agenda.start, span: agenda.span, startOffset: agenda.startOffset, | 182 | mode: agenda.mode, start: agenda.start, span: agenda.span, startOffset: agenda.startOffset, |
| 151 | subfolders: agenda.includeSubfolders)) { | 183 | subfolders: agenda.includeSubfolders, settings: model.settingsVersion)) { |
| 152 | await agenda.refresh(open: session.openTexts) | 184 | await agenda.refresh(open: session.openTexts) |
| 153 | } | 185 | } |
| 154 | } | 186 | } |
| @@ -173,24 +205,220 @@ struct AgendaScreen: View { | |||
| 173 | + ". Open Orgstar to set later ones." | 205 | + ". Open Orgstar to set later ones." |
| 174 | } | 206 | } |
| 175 | 207 | ||
| 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 | |||
| 176 | // MARK: - Rows | 389 | // MARK: - Rows |
| 177 | 390 | ||
| 178 | private func row(_ item: AgendaItem) -> some View { | 391 | private func row(_ row: AgendaRow, day: Int) -> some View { |
| 179 | Group { | 392 | Group { |
| 180 | if let path = item.path, let offset = item.headingOffset ?? item.markerOffset { | 393 | if let item = row.item, let path = item.path, let offset = item.headingOffset ?? item.markerOffset { |
| 181 | NavigationLink(value: ReaderRoute.offset(path, offset)) { entry(item) } | 394 | NavigationLink(value: ReaderRoute.offset(path, offset)) { AgendaRowView(row: row, day: day, theme: theme, dark: isDark) } |
| 182 | } else { | 395 | } else { |
| 183 | entry(item) | 396 | AgendaRowView(row: row, day: day, theme: theme, dark: isDark) |
| 184 | } | 397 | } |
| 185 | } | 398 | } |
| 186 | .swipeActions(edge: .trailing) { | 399 | .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 { |
| 188 | Button("Done", systemImage: "checkmark") { markDone(item) }.tint(.green) | 401 | 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) | ||
| 189 | } | 410 | } |
| 190 | } | 411 | } |
| 191 | .contextMenu { | 412 | .contextMenu { |
| 192 | if item.path != nil { | 413 | if let item = row.item, item.path != nil { |
| 193 | Button("TODO State…", systemImage: "checkmark.circle") { run("org.todo.cycle", on: item) } | 414 | 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 | } | ||
| 194 | Button("Schedule…", systemImage: "calendar") { run("org.schedule", on: item) } | 422 | Button("Schedule…", systemImage: "calendar") { run("org.schedule", on: item) } |
| 195 | Button("Deadline…", systemImage: "calendar.badge.exclamationmark") { run("org.deadline", on: item) } | 423 | Button("Deadline…", systemImage: "calendar.badge.exclamationmark") { run("org.deadline", on: item) } |
| 196 | Button("Tags…", systemImage: "tag") { run("org.tags.set", on: item) } | 424 | Button("Tags…", systemImage: "tag") { run("org.tags.set", on: item) } |
| @@ -205,26 +433,26 @@ struct AgendaScreen: View { | |||
| 205 | } | 433 | } |
| 206 | } | 434 | } |
| 207 | 435 | ||
| 208 | private func entry(_ item: AgendaItem) -> some View { | 436 | // MARK: - Actions |
| 209 | VStack(alignment: .leading, spacing: 2) { | 437 | |
| 210 | HStack(alignment: .firstTextBaseline, spacing: 6) { | 438 | /// Entries with a heading, not done, that a schedule or deadline can move. |
| 211 | if let time = item.time.firstMatch(of: /[0-9]{1,2}:[0-9]{2}(-[0-9]{1,2}:[0-9]{2})?/) { | 439 | private func canReschedule(_ row: AgendaRow) -> Bool { |
| 212 | Text(time.output.0).font(.callout.monospacedDigit()).foregroundStyle(.secondary) | 440 | guard let item = row.item, item.path != nil, item.headingLine != nil, !row.isDone else { return false } |
| 213 | } | 441 | return item.todo != nil || row.icon == .scheduled || row.icon == .deadline || row.icon == .habit |
| 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 | } | ||
| 225 | } | 442 | } |
| 226 | 443 | ||
| 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 | } | ||
| 228 | 456 | ||
| 229 | /// The heading of `item` now, found again by its line in case the file changed. | 457 | /// The heading of `item` now, found again by its line in case the file changed. |
| 230 | private func locate(_ item: AgendaItem) -> (path: String, offset: Int)? { | 458 | private func locate(_ item: AgendaItem) -> (path: String, offset: Int)? { |
| @@ -280,6 +508,7 @@ struct AgendaScreen: View { | |||
| 280 | private func markDone(_ item: AgendaItem) { | 508 | private func markDone(_ item: AgendaItem) { |
| 281 | guard let keyword = doneKeyword(for: item) else { return } | 509 | guard let keyword = doneKeyword(for: item) else { return } |
| 282 | run(SetTodo(keyword: keyword), on: item) | 510 | run(SetTodo(keyword: keyword), on: item) |
| 511 | done += 1 | ||
| 283 | } | 512 | } |
| 284 | 513 | ||
| 285 | private func refile(_ item: AgendaItem, to target: RefileTarget) { | 514 | private func refile(_ item: AgendaItem, to target: RefileTarget) { |
| @@ -300,6 +529,232 @@ struct AgendaScreen: View { | |||
| 300 | message = session.archive(from: (path, offset)) | 529 | message = session.archive(from: (path, offset)) |
| 301 | tick += 1 | 530 | tick += 1 |
| 302 | } | 531 | } |
| 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 | |||
| 541 | extension 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. | ||
| 547 | struct 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. | ||
| 675 | struct 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. | ||
| 695 | struct 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. | ||
| 735 | struct 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 | } | ||
| 303 | } | 758 | } |
| 304 | 759 | ||
| 305 | extension AgendaItem: @retroactive Identifiable { | 760 | extension AgendaItem: @retroactive Identifiable { |