Calendarr-IOS wird mit vollstaendiger Historie als Unterverzeichnis ios/ eingebunden. Damit liegen Server, Web, Android und iOS in einem gemeinsamen Repo. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
115 lines
5.1 KiB
Swift
115 lines
5.1 KiB
Swift
import SwiftUI
|
|
import CalendarrCore
|
|
|
|
/// The whole app: a scrolling agenda grouped by day, plus a way to reach the
|
|
/// calendar filter. Read-only by design — the snapshot carries neither
|
|
/// recurrence rules nor permissions, so it is deliberately not enough to edit.
|
|
///
|
|
/// Deliberately does **not** apply `snapshot.theme` to the background. The
|
|
/// always-on display and OLED make black the right choice here; the theme only
|
|
/// contributes the per-event accent colours.
|
|
struct WatchAgendaView: View {
|
|
@Environment(WatchSnapshotModel.self) private var model
|
|
@State private var showingFilter = false
|
|
|
|
private var calendar: Calendar {
|
|
var calendar = Calendar(identifier: .gregorian)
|
|
calendar.locale = WatchL10n.locale(model.language)
|
|
return calendar
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
content
|
|
.navigationTitle(WatchL10n.t("watch.title", model.language))
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
Button { showingFilter = true } label: {
|
|
Image(systemName: "line.3.horizontal.decrease.circle")
|
|
}
|
|
.accessibilityLabel(WatchL10n.t("watch.calendars", model.language))
|
|
}
|
|
}
|
|
.sheet(isPresented: $showingFilter) { WatchCalendarFilterView() }
|
|
}
|
|
.onAppear { WatchSnapshotReceiver.shared.requestRefresh() }
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var content: some View {
|
|
if let snapshot = model.visible, !groups(in: snapshot).isEmpty {
|
|
List {
|
|
ForEach(groups(in: snapshot), id: \.day) { group in
|
|
Section(WatchEventFormat.dayHeader(group.day, language: model.language,
|
|
calendar: calendar)) {
|
|
ForEach(group.events) { event in
|
|
NavigationLink {
|
|
WatchEventDetailView(event: event,
|
|
calendarName: model.calendarName(for: event.calendarKey),
|
|
language: model.language)
|
|
} label: {
|
|
WatchEventRow(event: event, language: model.language)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Section {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("\(WatchL10n.t("watch.as_of", model.language)) \(WatchEventFormat.time(snapshot.writtenAt, language: model.language))")
|
|
// Beyond the coverage window the snapshot carries no
|
|
// information, which is not the same as "nothing
|
|
// scheduled". Saying so is what keeps the empty tail of
|
|
// the list from reading as a free afternoon.
|
|
Text("\(WatchL10n.t("watch.no_information", model.language)) \(coverageEndLine(snapshot))")
|
|
if let error = WatchSnapshotReceiver.shared.lastError {
|
|
Text(error).foregroundStyle(.orange)
|
|
}
|
|
}
|
|
.font(.system(size: 10))
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
}
|
|
.listStyle(.carousel)
|
|
} else {
|
|
WatchStatusView(result: model.result, language: model.language)
|
|
}
|
|
}
|
|
|
|
private struct DayGroup {
|
|
let day: Date
|
|
let events: [SnapshotEvent]
|
|
}
|
|
|
|
/// The last day the snapshot can speak for, formatted for the footer.
|
|
private func coverageEndLine(_ snapshot: CalendarrSnapshot) -> String {
|
|
let formatter = DateFormatter()
|
|
formatter.locale = WatchL10n.locale(model.language)
|
|
formatter.setLocalizedDateFormatFromTemplate("d MMM")
|
|
let lastCovered = calendar.date(byAdding: .day, value: -1, to: snapshot.coverageEnd)
|
|
return formatter.string(from: lastCovered ?? snapshot.coverageEnd)
|
|
}
|
|
|
|
/// Group the covered window by day. Days the snapshot cannot speak for are
|
|
/// simply absent — never rendered as "nothing scheduled", which would look
|
|
/// correct and be false.
|
|
private func groups(in snapshot: CalendarrSnapshot) -> [DayGroup] {
|
|
let calendar = self.calendar
|
|
let from = max(snapshot.coverageStart, calendar.startOfDay(for: Date()))
|
|
var buckets: [Date: [SnapshotEvent]] = [:]
|
|
|
|
for event in snapshot.events where event.end > from {
|
|
var day = calendar.startOfDay(for: max(event.start, from))
|
|
// A multi-day event appears on each day it covers.
|
|
while day < event.end && day < snapshot.coverageEnd {
|
|
buckets[day, default: []].append(event)
|
|
guard let next = calendar.date(byAdding: .day, value: 1, to: day) else { break }
|
|
day = next
|
|
}
|
|
}
|
|
|
|
return buckets
|
|
.map { DayGroup(day: $0.key, events: $0.value.sorted { $0.start < $1.start }) }
|
|
.sorted { $0.day < $1.day }
|
|
}
|
|
}
|