krz/orgstar

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

Sources/OrgstarWidgets/TodayWidget.swift

322a87d47526d505d903e05d14e65a5464e1468f
orgstar/Sources/OrgstarWidgets/TodayWidget.swift history · blame · raw

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}