chore(repo): iOS-App als ios/ ins Hauptrepo uebernehmen

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>
This commit is contained in:
Scarriffle
2026-09-14 09:20:12 +02:00
co-authored by Claude Opus 5
107 changed files with 19373 additions and 0 deletions
@@ -0,0 +1,114 @@
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 }
}
}
@@ -0,0 +1,49 @@
import SwiftUI
import CalendarrCore
/// Narrows what this watch shows, on top of what the phone already filtered.
/// One toggle here affects the agenda and every complication at once.
struct WatchCalendarFilterView: View {
@Environment(WatchSnapshotModel.self) private var model
var body: some View {
NavigationStack {
List {
if model.calendars.isEmpty {
Text(WatchL10n.t("watch.waiting", model.language))
.font(.footnote)
.foregroundStyle(.secondary)
} else {
ForEach(model.calendars) { calendar in
row(calendar)
}
}
}
.navigationTitle(WatchL10n.t("watch.calendars", model.language))
}
}
private func row(_ calendar: SnapshotCalendar) -> some View {
let shown = !model.isHidden(calendar.id)
return Button {
model.setHidden(calendar.id, shown)
} label: {
HStack(spacing: 8) {
// A checkbox, not a Toggle: a switch reads as a state indicator
// you wait on rather than a control you tap.
Image(systemName: shown ? "checkmark.square.fill" : "square")
.font(.system(size: 16))
.foregroundStyle(shown ? Color.accentColor : Color.secondary)
Circle()
.fill(Color(watchHex: calendar.colorHex))
.frame(width: 7, height: 7)
Text(calendar.name)
.font(.system(size: 14))
.lineLimit(1)
Spacer(minLength: 0)
}
}
.buttonStyle(.plain)
.accessibilityAddTraits(shown ? [.isSelected] : [])
}
}
@@ -0,0 +1,60 @@
import SwiftUI
import CalendarrCore
/// Everything the snapshot carries about one event, which is deliberately not
/// everything the server knows: notes, recurrence and permissions are dropped
/// on the way out, so there is nothing here to edit.
struct WatchEventDetailView: View {
let event: SnapshotEvent
/// Nil when the calendar list has not arrived yet, which is possible right
/// after a first install.
let calendarName: String?
let language: String
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top, spacing: 6) {
Circle()
.fill(Color(watchHex: event.colorHex))
.frame(width: 8, height: 8)
.padding(.top, 5)
Text(event.title)
.font(.headline)
.fixedSize(horizontal: false, vertical: true)
}
Label(WatchEventFormat.timeRange(event, language: language),
systemImage: "clock")
.font(.footnote)
Label(dateLine, systemImage: "calendar")
.font(.footnote)
.foregroundStyle(.secondary)
if !event.location.isEmpty {
Label(event.location, systemImage: "mappin.and.ellipse")
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
if let calendarName {
Label(calendarName, systemImage: "list.bullet.rectangle")
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 4)
}
}
private var dateLine: String {
let formatter = DateFormatter()
formatter.locale = WatchL10n.locale(language)
formatter.setLocalizedDateFormatFromTemplate("EEEE d MMMM")
return formatter.string(from: event.start)
}
}
@@ -0,0 +1,89 @@
import SwiftUI
import CalendarrCore
/// One event: a colour bar for the calendar, the time range, the title.
struct WatchEventRow: View {
let event: SnapshotEvent
let language: String
var body: some View {
HStack(alignment: .top, spacing: 7) {
RoundedRectangle(cornerRadius: 2)
.fill(Color(watchHex: event.colorHex))
.frame(width: 3)
VStack(alignment: .leading, spacing: 1) {
Text(WatchEventFormat.timeRange(event, language: language))
.font(.system(size: 11))
.foregroundStyle(.secondary)
Text(event.title)
.font(.system(size: 14))
.lineLimit(2)
}
Spacer(minLength: 0)
}
.padding(.vertical, 2)
.fixedSize(horizontal: false, vertical: true)
}
}
extension Color {
/// Local copy of the hex initialiser: `CalendarrCore` is Foundation-only by
/// design, so colour parsing cannot live there.
init(watchHex hex: String) {
let cleaned = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var value: UInt64 = 0
Scanner(string: cleaned).scanHexInt64(&value)
guard cleaned.count == 6 else { self = .gray; return }
self.init(red: Double((value >> 16) & 0xFF) / 255,
green: Double((value >> 8) & 0xFF) / 255,
blue: Double(value & 0xFF) / 255)
}
}
enum WatchEventFormat {
static func timeRange(_ event: SnapshotEvent, language: String,
calendar: Calendar = Calendar(identifier: .gregorian)) -> String {
if event.isAllDay { return WatchL10n.t("watch.allday", language) }
let time = DateFormatter()
time.locale = WatchL10n.locale(language)
time.dateFormat = "HH:mm"
let start = time.string(from: event.start)
guard event.end > event.start else { return start }
// Without the weekday, an event running from Wednesday to Monday reads
// as a one-hour slot: "00:00 – 01:00" is true and useless.
if calendar.startOfDay(for: event.end) > calendar.startOfDay(for: event.start) {
let day = DateFormatter()
day.locale = WatchL10n.locale(language)
day.setLocalizedDateFormatFromTemplate("EEE")
return "\(day.string(from: event.start)) \(start) – "
+ "\(day.string(from: event.end)) \(time.string(from: event.end))"
}
return "\(start) – \(time.string(from: event.end))"
}
static func time(_ date: Date, language: String) -> String {
let formatter = DateFormatter()
formatter.locale = WatchL10n.locale(language)
formatter.dateFormat = "HH:mm"
return formatter.string(from: date)
}
/// "Heute", "Morgen", else "Do 11. Sep".
static func dayHeader(_ day: Date, language: String,
calendar: Calendar, now: Date = Date()) -> String {
if calendar.isDate(day, inSameDayAs: now) {
return WatchL10n.t("watch.today", language)
}
if let tomorrow = calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: now)),
calendar.isDate(day, inSameDayAs: tomorrow) {
return WatchL10n.t("watch.tomorrow", language)
}
let formatter = DateFormatter()
formatter.locale = WatchL10n.locale(language)
formatter.setLocalizedDateFormatFromTemplate("EEE d MMM")
return formatter.string(from: day)
}
}
@@ -0,0 +1,44 @@
import SwiftUI
import CalendarrCore
/// Every reason there is nothing to show, named. "Never received", "signed out"
/// and "genuinely empty" call for different sentences, and showing the wrong one
/// sends the user looking in the wrong place.
struct WatchStatusView: View {
let result: SnapshotReadResult
let language: String
var body: some View {
VStack(spacing: 8) {
Image(systemName: symbol)
.font(.system(size: 22))
.foregroundStyle(.secondary)
Text(message)
.font(.footnote)
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 6)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var symbol: String {
switch result {
case .ok: return "calendar"
case .loggedOut: return "person.crop.circle.badge.xmark"
case .neverWritten: return "iphone.and.arrow.forward"
case .incompatible: return "arrow.down.circle"
case .unreadable: return "exclamationmark.triangle"
}
}
private var message: String {
switch result {
case .ok: return WatchL10n.t("watch.no_events", language)
case .loggedOut: return WatchL10n.t("watch.sign_in", language)
case .neverWritten: return WatchL10n.t("watch.waiting", language)
case .incompatible: return WatchL10n.t("watch.update", language)
case .unreadable: return WatchL10n.t("watch.unreadable", language)
}
}
}