Sources/Orgstar/DatePickerView.swift
113 lines · 5520 bytes
1import OrgCore
2import SwiftUI
3
4/// What `org-read-date` shows beside its prompt: the date the answer reads as, and a month
5/// calendar to pick one by clicking.
6struct DatePickerView: View {
7 let answer: String
8 let read: Prompt.DateRead
9 let pick: (String) -> Void
10 @State private var month: Date?
11 private let calendar = Calendar.current
12
13 var body: some View {
14 let result = Self.result(answer, read)
15 let selected = calendar.date(from: result.components(calendar))!
16 let shown = month ?? selected
17 VStack(alignment: .leading, spacing: 6) {
18 HStack {
19 Button { month = calendar.date(byAdding: .month, value: -1, to: shown) } label: { Image(systemName: "chevron.left") }
20 .accessibilityLabel("Previous month")
21 Text(shown.formatted(.dateTime.month(.wide).year())).frame(minWidth: 140)
22 Button { month = calendar.date(byAdding: .month, value: 1, to: shown) } label: { Image(systemName: "chevron.right") }
23 .accessibilityLabel("Next month")
24 Spacer()
25 Text("=> " + Self.stamp(result, calendar, inactive: read.inactive)).font(.system(.callout, design: .monospaced))
26 }
27 .buttonStyle(.plain)
28 Grid(horizontalSpacing: 4, verticalSpacing: 2) {
29 GridRow {
30 ForEach(weekdaySymbols.indices, id: \.self) { Text(weekdaySymbols[$0]).foregroundStyle(.secondary) }
31 }
32 ForEach(weeks(of: shown), id: \.self) { week in
33 GridRow {
34 ForEach(week.indices, id: \.self) { i in
35 if let day = week[i] {
36 dayButton(day, selected: calendar.isDate(day, inSameDayAs: selected), result: result)
37 } else {
38 Color.clear.frame(width: 1, height: 1)
39 }
40 }
41 }
42 }
43 }
44 .font(.system(.callout, design: .monospaced))
45 }
46 .padding(10)
47 .onChange(of: selected) { month = nil }
48 }
49
50 private func dayButton(_ day: Date, selected: Bool, result: ReadDate.Answer) -> some View {
51 Button {
52 pick(Self.answer(for: day, keeping: result, calendar))
53 } label: {
54 Text("\(calendar.component(.day, from: day))")
55 .frame(width: 26, height: 20)
56 .background(selected ? Color.accentColor.opacity(0.35) : .clear, in: RoundedRectangle(cornerRadius: 4))
57 .overlay {
58 if calendar.isDateInToday(day) { RoundedRectangle(cornerRadius: 4).stroke(Color.accentColor) }
59 }
60 }
61 .buttonStyle(.plain)
62 .accessibilityLabel(day.formatted(date: .complete, time: .omitted))
63 }
64
65 private var weekdaySymbols: [String] {
66 let symbols = calendar.veryShortWeekdaySymbols
67 let first = calendar.firstWeekday - 1
68 return Array(symbols[first...] + symbols[..<first])
69 }
70
71 /// The month's days in weeks, nil before the first and after the last.
72 private func weeks(of date: Date) -> [[Date?]] {
73 let start = calendar.date(from: calendar.dateComponents([.year, .month], from: date))!
74 let count = calendar.range(of: .day, in: .month, for: start)!.count
75 let lead = (calendar.component(.weekday, from: start) - calendar.firstWeekday + 7) % 7
76 var days: [Date?] = Array(repeating: nil, count: lead)
77 days += (0..<count).map { calendar.date(byAdding: .day, value: $0, to: start) }
78 days += Array(repeating: nil, count: (7 - days.count % 7) % 7)
79 return stride(from: 0, to: days.count, by: 7).map { Array(days[$0..<$0 + 7]) }
80 }
81
82 static func result(_ answer: String, _ read: Prompt.DateRead) -> ReadDate.Answer {
83 ReadDate.read(answer, default: read.defaultDate, now: Date(), calendar: .current)
84 }
85
86 /// The answer for `day`, with the time `result` had.
87 static func answer(for day: Date, keeping result: ReadDate.Answer, _ calendar: Calendar) -> String {
88 let c = calendar.dateComponents([.year, .month, .day], from: day)
89 var text = String(format: "%04d-%02d-%02d", c.year!, c.month!, c.day!)
90 if result.timeGiven {
91 text += String(format: " %02d:%02d", result.hour, result.minute)
92 if let end = result.endTime { text += "-" + end }
93 }
94 return text
95 }
96
97 /// The answer moved by `days`, as S-arrows move `org-read-date`'s calendar.
98 static func shifted(_ answer: String, _ read: Prompt.DateRead, by days: Int) -> String {
99 let calendar = Calendar.current
100 let result = result(answer, read)
101 let day = calendar.date(byAdding: .day, value: days, to: calendar.date(from: result.components(calendar))!)!
102 return Self.answer(for: day, keeping: result, calendar)
103 }
104
105 static func stamp(_ result: ReadDate.Answer, _ calendar: Calendar, inactive: Bool) -> String {
106 let formatter = DateFormatter()
107 formatter.locale = Locale(identifier: "en_US_POSIX")
108 formatter.dateFormat = result.timeGiven ? "yyyy-MM-dd EEE HH:mm" : "yyyy-MM-dd EEE"
109 let end = result.timeGiven ? result.endTime.map { "-" + $0 } ?? "" : ""
110 let stamp = formatter.string(from: calendar.date(from: result.components(calendar))!) + end
111 return inactive ? "[" + stamp + "]" : "<" + stamp + ">"
112 }
113}