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:
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AppIcon-1024.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "watchos",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.scarriffleservices.calendarr</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,19 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct CalendarrWatchApp: App {
|
||||
@State private var model = WatchSnapshotModel()
|
||||
|
||||
init() {
|
||||
// The session has to be live before the first payload arrives, and a
|
||||
// complication push can wake this app with no UI on screen at all.
|
||||
WatchSnapshotReceiver.shared.activate()
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
WatchAgendaView()
|
||||
.environment(model)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import Foundation
|
||||
import WidgetKit
|
||||
import CalendarrCore
|
||||
|
||||
/// What the watch views read. Holds the last received snapshot, the calendar
|
||||
/// list, and this watch's filter.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class WatchSnapshotModel {
|
||||
private(set) var result: SnapshotReadResult = .neverWritten
|
||||
private(set) var calendars: [SnapshotCalendar] = []
|
||||
private(set) var hiddenKeys: Set<String> = WatchFilterStore.load()
|
||||
|
||||
private let store = SnapshotStore()
|
||||
private var changeToken: SnapshotChangeNotifier.ObservationToken?
|
||||
|
||||
init() {
|
||||
reload()
|
||||
// The receiver writes through SnapshotStore, which broadcasts. Listening
|
||||
// here means the UI does not have to be notified separately.
|
||||
changeToken = SnapshotChangeNotifier.observe { [weak self] in
|
||||
Task { @MainActor in self?.reload() }
|
||||
}
|
||||
}
|
||||
|
||||
func reload() {
|
||||
result = store.read()
|
||||
calendars = store.readCalendars()
|
||||
}
|
||||
|
||||
/// The snapshot with this watch's filter applied, or nil when there is
|
||||
/// nothing usable to show.
|
||||
var visible: CalendarrSnapshot? {
|
||||
result.snapshot?.filtered(excludingCalendarKeys: hiddenKeys)
|
||||
}
|
||||
|
||||
var language: String { result.snapshot?.language ?? "system" }
|
||||
|
||||
func isHidden(_ key: String) -> Bool { hiddenKeys.contains(key) }
|
||||
|
||||
/// The display name for an event's calendar. Events carry only a
|
||||
/// `calendarKey`; the names travel separately, in the calendar list.
|
||||
func calendarName(for key: String) -> String? {
|
||||
calendars.first { $0.id == key }?.name
|
||||
}
|
||||
|
||||
func setHidden(_ key: String, _ hidden: Bool) {
|
||||
if hidden { hiddenKeys.insert(key) } else { hiddenKeys.remove(key) }
|
||||
WatchFilterStore.save(hiddenKeys)
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import Foundation
|
||||
import WatchConnectivity
|
||||
import WidgetKit
|
||||
@_spi(Writer) import CalendarrCore
|
||||
|
||||
/// Takes what the phone sends and writes it into this watch's own App Group
|
||||
/// container, using the same `SnapshotStore` the phone writes with. One wire
|
||||
/// format, one reader, no drift.
|
||||
///
|
||||
/// Only the app runs a `WCSession`; a widget extension cannot. The complications
|
||||
/// always read what this wrote.
|
||||
@MainActor
|
||||
final class WatchSnapshotReceiver: NSObject {
|
||||
static let shared = WatchSnapshotReceiver()
|
||||
|
||||
private let store = SnapshotStore()
|
||||
private let sessions = SharedSessionStore()
|
||||
|
||||
/// Shown in the agenda footer, because a silent failure here looks exactly
|
||||
/// like an empty calendar.
|
||||
private(set) var lastError: String?
|
||||
|
||||
func activate() {
|
||||
guard WCSession.isSupported() else { return }
|
||||
WCSession.default.delegate = self
|
||||
WCSession.default.activate()
|
||||
}
|
||||
|
||||
/// Ask the phone for the current state. Covers "the app is open in front of
|
||||
/// me and looks stale"; the other two paths are push-driven.
|
||||
func requestRefresh() {
|
||||
let session = WCSession.default
|
||||
guard session.activationState == .activated, session.isReachable else { return }
|
||||
|
||||
session.sendMessage(["request": "snapshot"],
|
||||
replyHandler: { reply in
|
||||
Task { @MainActor in self.apply(reply) }
|
||||
},
|
||||
errorHandler: { error in
|
||||
Task { @MainActor in self.lastError = error.localizedDescription }
|
||||
})
|
||||
}
|
||||
|
||||
func apply(_ dict: [String: Any]) {
|
||||
guard !dict.isEmpty else { return }
|
||||
do {
|
||||
let payload = try WatchTransportPayload.decode(dict)
|
||||
// This write posts SnapshotChangeNotifier, which is what the read
|
||||
// model listens for — so the UI updates without being told twice.
|
||||
try store.write(payload.snapshot)
|
||||
try store.writeCalendars(payload.calendars)
|
||||
if let session = payload.session { try sessions.write(session) }
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
lastError = nil
|
||||
} catch {
|
||||
lastError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension WatchSnapshotReceiver: WCSessionDelegate {
|
||||
nonisolated func session(_ session: WCSession,
|
||||
activationDidCompleteWith state: WCSessionActivationState,
|
||||
error: Error?) {
|
||||
if let error {
|
||||
Task { @MainActor in self.lastError = error.localizedDescription }
|
||||
}
|
||||
}
|
||||
|
||||
/// The main path: latest state only, delivered when this app launches or wakes.
|
||||
nonisolated func session(_ session: WCSession,
|
||||
didReceiveApplicationContext applicationContext: [String: Any]) {
|
||||
Task { @MainActor in self.apply(applicationContext) }
|
||||
}
|
||||
|
||||
/// The complication push, which is the only path that wakes this app while
|
||||
/// it is not running.
|
||||
nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
|
||||
Task { @MainActor in self.apply(userInfo) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user