Sources/OrgstarWidgets/TodayWidget.swift
168 lines · 6483 bytes
1import OrgApp
2import SwiftUI
3import WidgetKit
4
5/// Today's agenda on the Home Screen: what's next (small), today's list (medium), today and
6/// tomorrow (large).
7struct TodayWidget: Widget {
8 var body: some WidgetConfiguration {
9 StaticConfiguration(kind: "sh.krz.orgstar.today", provider: AgendaProvider()) { entry in
10 TodayView(entry: entry)
11 }
12 .configurationDisplayName("Today")
13 .description("What's left of today's agenda.")
14 .supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
15 }
16}
17
18struct TodayView: View {
19 let entry: AgendaEntry
20 @Environment(\.widgetFamily) private var family
21 @Environment(\.colorScheme) private var scheme
22
23 var body: some View {
24 Group {
25 if let snapshot = entry.snapshot {
26 content(snapshot)
27 } else {
28 Text("Open Orgstar to show your agenda here.")
29 .font(.caption)
30 .foregroundStyle(.secondary)
31 .frame(maxWidth: .infinity, maxHeight: .infinity)
32 }
33 }
34 .widgetURL(AppLink.agenda.url)
35 .containerBackground(for: .widget) {
36 entry.snapshot?.color("agenda-background", scheme) ?? Color(uiColor: .systemBackground)
37 }
38 }
39
40 @ViewBuilder
41 private func content(_ snapshot: AgendaSnapshot) -> some View {
42 let upcoming = snapshot.upcoming(at: entry.date)
43 VStack(alignment: .leading, spacing: family == .systemSmall ? 4 : 6) {
44 header(snapshot)
45 switch family {
46 case .systemSmall:
47 rows(Array(upcoming.prefix(3)), snapshot, compact: true, empty: "Nothing left today")
48 case .systemLarge:
49 rows(Array(upcoming.prefix(6)), snapshot, compact: false, empty: "Nothing left today")
50 if let tomorrow = snapshot.day(after: entry.date) {
51 let next = (tomorrow.rows + tomorrow.overdue).filter { !$0.isDone }
52 Divider()
53 Text("Tomorrow").font(.caption.weight(.semibold)).foregroundStyle(.secondary)
54 rows(Array(next.prefix(max(2, 9 - min(upcoming.count, 6)))), snapshot, compact: false, empty: "Nothing scheduled")
55 }
56 default:
57 rows(Array(upcoming.prefix(4)), snapshot, compact: false, empty: "Nothing left today")
58 }
59 Spacer(minLength: 0)
60 }
61 .frame(maxWidth: .infinity, alignment: .leading)
62 }
63
64 private func header(_ snapshot: AgendaSnapshot) -> some View {
65 let open = snapshot.openCount(at: entry.date)
66 let overdue = snapshot.overdueCount(at: entry.date)
67 return HStack(alignment: .firstTextBaseline, spacing: 6) {
68 Text(entry.date, format: family == .systemSmall ? .dateTime.weekday(.abbreviated).day() : .dateTime.weekday(.wide).day().month())
69 .font(.subheadline.weight(.semibold))
70 .foregroundStyle(snapshot.color("agenda-today", scheme) ?? .accentColor)
71 Spacer(minLength: 0)
72 if overdue > 0, family != .systemSmall {
73 Text("\(overdue) overdue")
74 .font(.caption2.weight(.semibold))
75 .foregroundStyle(snapshot.day(at: entry.date)?.overdue.first?.statusColor?.color(scheme) ?? .red)
76 }
77 Text(family == .systemSmall ? "\(open)" : "\(open) open")
78 .font(.caption2.weight(.semibold))
79 .foregroundStyle(.secondary)
80 }
81 }
82
83 @ViewBuilder
84 private func rows(_ rows: [AgendaSnapshot.Row], _ snapshot: AgendaSnapshot, compact: Bool, empty: String) -> some View {
85 if rows.isEmpty {
86 Text(empty).font(.caption).foregroundStyle(.secondary)
87 }
88 ForEach(rows) { row in
89 if compact {
90 SmallRow(row: row)
91 } else if let link = row.link {
92 Link(destination: link.url) { WideRow(row: row) }
93 } else {
94 WideRow(row: row)
95 }
96 }
97 }
98}
99
100/// The gutter over the title.
101struct SmallRow: View {
102 let row: AgendaSnapshot.Row
103 @Environment(\.colorScheme) private var scheme
104
105 var body: some View {
106 VStack(alignment: .leading, spacing: 0) {
107 Text(row.gutter)
108 .font(.caption2.weight(.medium).monospacedDigit())
109 .foregroundStyle(row.statusColor?.color(scheme) ?? .secondary)
110 Text(row.title).font(.caption).lineLimit(1)
111 }
112 }
113}
114
115/// The gutter, keyword and priority badges, the title and the category's dot.
116struct WideRow: View {
117 let row: AgendaSnapshot.Row
118 @Environment(\.colorScheme) private var scheme
119
120 var body: some View {
121 HStack(spacing: 6) {
122 Text(row.gutter)
123 .font(.caption2.weight(.medium).monospacedDigit())
124 .foregroundStyle(row.statusColor?.color(scheme) ?? .secondary)
125 .lineLimit(1)
126 .frame(width: 72, alignment: .leading)
127 if let todo = row.todo {
128 let color = row.todoColor?.color(scheme) ?? .secondary
129 Text(todo)
130 .font(.system(size: 9, weight: .bold))
131 .foregroundStyle(color)
132 .padding(.horizontal, 4)
133 .padding(.vertical, 1)
134 .background(color.opacity(0.14), in: Capsule())
135 }
136 if let priority = row.priority {
137 let color = row.priorityColor?.color(scheme) ?? .orange
138 Text(priority)
139 .font(.system(size: 9, weight: .bold))
140 .foregroundStyle(color)
141 .frame(width: 14, height: 14)
142 .overlay(Circle().strokeBorder(color, lineWidth: 1))
143 }
144 Text(row.title).font(.caption).lineLimit(1)
145 Spacer(minLength: 0)
146 Circle().fill(row.categoryColor?.color(scheme) ?? .secondary).frame(width: 6, height: 6)
147 }
148 }
149}
150
151#Preview("Small", as: .systemSmall) {
152 TodayWidget()
153} timeline: {
154 AgendaEntry(date: .now, snapshot: .sample(.now))
155}
156
157#Preview("Medium", as: .systemMedium) {
158 TodayWidget()
159} timeline: {
160 AgendaEntry(date: .now, snapshot: .sample(.now))
161}
162
163#Preview("Large", as: .systemLarge) {
164 TodayWidget()
165} timeline: {
166 AgendaEntry(date: .now, snapshot: .sample(.now))
167 AgendaEntry(date: .now, snapshot: nil)
168}