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,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,13 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "watchos",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"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,161 @@
import WidgetKit
import SwiftUI
@main
struct CalendarrWatchComplicationBundle: WidgetBundle {
var body: some Widget {
WatchNextEventWidget()
WatchCornerCountdownWidget()
WatchCornerTitleWidget()
WatchCornerTimeWidget()
WatchCountdownWidget()
WatchDateWidget()
WatchTodayCountWidget()
WatchNextTwoWidget()
WatchDatePlusEventWidget()
}
}
private extension View {
/// Accessory families paint their own background; ours must stay out of
/// the way so the watch face's tint comes through.
func complicationChrome(_ language: String) -> some View {
containerBackground(for: .widget) { Color.clear }
.environment(\.locale, WatchL10n.locale(language))
}
}
struct WatchNextEventWidget: Widget {
let kind = "WatchNextEvent"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind,
intent: WatchCalendarSelectionIntent.self,
provider: WatchComplicationProvider()) { entry in
NextEventComplicationView(entry: entry).complicationChrome(entry.language)
}
.configurationDisplayName("Nächster Termin")
.description("Zeit, Titel und Ort des nächsten Termins.")
.supportedFamilies([.accessoryRectangular, .accessoryInline])
}
}
struct WatchCornerCountdownWidget: Widget {
let kind = "WatchCornerCountdown"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind,
intent: WatchCalendarSelectionIntent.self,
provider: WatchComplicationProvider()) { entry in
CornerCountdownComplicationView(entry: entry).complicationChrome(entry.language)
}
.configurationDisplayName("Restzeit (Ecke)")
.description("Restzeit bis zum nächsten Termin, Name am Rand.")
.supportedFamilies([.accessoryCorner])
}
}
struct WatchCornerTitleWidget: Widget {
let kind = "WatchCornerTitle"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind,
intent: WatchCalendarSelectionIntent.self,
provider: WatchComplicationProvider()) { entry in
CornerTitleComplicationView(entry: entry).complicationChrome(entry.language)
}
.configurationDisplayName("Terminname (Ecke)")
.description("Name des nächsten Termins, Uhrzeit klein am Rand.")
.supportedFamilies([.accessoryCorner])
}
}
struct WatchCornerTimeWidget: Widget {
let kind = "WatchCornerTime"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind,
intent: WatchCalendarSelectionIntent.self,
provider: WatchComplicationProvider()) { entry in
CornerTimeComplicationView(entry: entry).complicationChrome(entry.language)
}
.configurationDisplayName("Uhrzeit (Ecke)")
.description("Uhrzeit des nächsten Termins, Name am Rand.")
.supportedFamilies([.accessoryCorner])
}
}
struct WatchCountdownWidget: Widget {
let kind = "WatchCountdown"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind,
intent: WatchCalendarSelectionIntent.self,
provider: WatchComplicationProvider()) { entry in
CountdownComplicationView(entry: entry).complicationChrome(entry.language)
}
.configurationDisplayName("Countdown")
.description("Ring, der bis zum nächsten Termin leerläuft.")
.supportedFamilies([.accessoryCircular, .accessoryInline])
}
}
struct WatchDateWidget: Widget {
let kind = "WatchDate"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind,
intent: WatchCalendarSelectionIntent.self,
provider: WatchComplicationProvider()) { entry in
DateComplicationView(entry: entry).complicationChrome(entry.language)
}
.configurationDisplayName("Datum")
.description("Tag und Monat.")
.supportedFamilies([.accessoryCircular, .accessoryInline])
}
}
struct WatchTodayCountWidget: Widget {
let kind = "WatchTodayCount"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind,
intent: WatchCalendarSelectionIntent.self,
provider: WatchComplicationProvider()) { entry in
TodayCountComplicationView(entry: entry).complicationChrome(entry.language)
}
.configurationDisplayName("Termine heute")
.description("Anzahl der heutigen Termine.")
.supportedFamilies([.accessoryCircular, .accessoryInline])
}
}
struct WatchNextTwoWidget: Widget {
let kind = "WatchNextTwo"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind,
intent: WatchCalendarSelectionIntent.self,
provider: WatchComplicationProvider()) { entry in
NextTwoComplicationView(entry: entry).complicationChrome(entry.language)
}
.configurationDisplayName("Die nächsten zwei")
.description("Die beiden nächsten Termine.")
.supportedFamilies([.accessoryRectangular])
}
}
struct WatchDatePlusEventWidget: Widget {
let kind = "WatchDatePlusEvent"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind,
intent: WatchCalendarSelectionIntent.self,
provider: WatchComplicationProvider()) { entry in
DatePlusEventComplicationView(entry: entry).complicationChrome(entry.language)
}
.configurationDisplayName("Datum + Termin")
.description("Datum und nächster Termin in einem Slot.")
.supportedFamilies([.accessoryRectangular])
}
}
@@ -0,0 +1,90 @@
import SwiftUI
import WidgetKit
import CalendarrCore
// A corner holds **one** value plus the curved bezel label. A second inner line
// runs into the curved text — do not add one. `.widgetLabel` is what makes
// WidgetKit lay the text along the edge; there is no arc to draw by hand.
/// Value: live countdown. Label: time and title.
struct CornerCountdownComplicationView: View {
let entry: WatchComplicationEntry
var body: some View {
let next = WatchComplicationSupport.nextEvent(in: entry.snapshot, at: entry.date)
Group {
if let next {
Text(WatchComplicationSupport.compactCountdown(
to: WatchComplicationSupport.countdownTarget(next, at: entry.date),
at: entry.date,
language: entry.language))
.lineLimit(1)
.minimumScaleFactor(0.6)
.widgetAccentable()
} else {
Image(systemName: "calendar")
}
}
.widgetLabel {
Text(WatchComplicationSupport.eventLabel(next, at: entry.date, language: entry.language))
}
}
}
/// Value: the appointment's clock time. Label: the title.
struct CornerTimeComplicationView: View {
let entry: WatchComplicationEntry
var body: some View {
let next = WatchComplicationSupport.nextEvent(in: entry.snapshot, at: entry.date)
Group {
if let next {
Text(next.isAllDay
? WatchL10n.t("watch.allday", entry.language)
: WatchComplicationSupport.time(next.start, language: entry.language))
.lineLimit(1)
.minimumScaleFactor(0.6)
.widgetAccentable()
} else {
Image(systemName: "calendar")
}
}
.widgetLabel {
Text(next?.title ?? WatchL10n.t("watch.no_events", entry.language))
}
}
}
/// Value: the appointment's name. Label: the time, small and curved.
///
/// This is the inverse of how the slot is shaped — the curved bezel is where a
/// corner has room for long text, and the inner area holds three or four
/// characters. The title therefore shrinks and truncates here. Built this way
/// on request; `CornerTimeComplicationView` is the same information with the
/// roles the other way round, so both can be compared on the face.
struct CornerTitleComplicationView: View {
let entry: WatchComplicationEntry
var body: some View {
let next = WatchComplicationSupport.nextEvent(in: entry.snapshot, at: entry.date)
Group {
if let next {
Text(next.title)
.font(.system(size: 13, weight: .medium))
.lineLimit(2)
.minimumScaleFactor(0.5)
.multilineTextAlignment(.center)
.widgetAccentable()
} else {
Image(systemName: "calendar")
}
}
.widgetLabel {
Text(next.map {
$0.isAllDay
? WatchL10n.t("watch.allday", entry.language)
: WatchComplicationSupport.time($0.start, language: entry.language)
} ?? WatchL10n.t("watch.no_events", entry.language))
}
}
}
@@ -0,0 +1,11 @@
<?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>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>
@@ -0,0 +1,113 @@
import SwiftUI
import WidgetKit
import CalendarrCore
/// Time, title, location. The variant that answers the actual question.
struct NextEventComplicationView: View {
let entry: WatchComplicationEntry
@Environment(\.widgetFamily) private var family
var body: some View {
let next = WatchComplicationSupport.nextEvent(in: entry.snapshot, at: entry.date)
switch family {
case .accessoryInline:
inline(next)
default:
rectangular(next)
}
}
@ViewBuilder
private func rectangular(_ next: SnapshotEvent?) -> some View {
VStack(alignment: .leading, spacing: 1) {
if let next {
Text(next.isAllDay
? WatchL10n.t("watch.allday", entry.language)
: WatchComplicationSupport.time(next.start, language: entry.language))
.font(.system(size: 12, weight: .semibold))
.widgetAccentable()
Text(next.title)
.font(.system(size: 15))
.lineLimit(1)
if !next.location.isEmpty {
Text(next.location)
.font(.system(size: 11))
.lineLimit(1)
}
} else {
Image(systemName: "calendar").font(.system(size: 13)).widgetAccentable()
Text(WatchL10n.t("watch.no_events", entry.language)).font(.system(size: 13))
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private func inline(_ next: SnapshotEvent?) -> some View {
let text: String = {
guard let next else { return WatchL10n.t("watch.no_events", entry.language) }
if next.isAllDay { return next.title }
return "\(WatchComplicationSupport.time(next.start, language: entry.language)) \(next.title)"
}()
return Label(text, systemImage: "calendar")
}
}
/// The next two, so back-to-back appointments are visible at once.
struct NextTwoComplicationView: View {
let entry: WatchComplicationEntry
var body: some View {
let events = WatchComplicationSupport.upcoming(in: entry.snapshot, at: entry.date, limit: 2)
VStack(alignment: .leading, spacing: 3) {
if events.isEmpty {
Image(systemName: "calendar").font(.system(size: 13)).widgetAccentable()
Text(WatchL10n.t("watch.no_events", entry.language)).font(.system(size: 13))
} else {
ForEach(events) { event in
HStack(spacing: 5) {
Text(event.isAllDay
? WatchL10n.t("watch.allday", entry.language)
: WatchComplicationSupport.time(event.start, language: entry.language))
.font(.system(size: 12, weight: .semibold))
.widgetAccentable()
Text(event.title)
.font(.system(size: 13))
.lineLimit(1)
}
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}
/// Weekday and date plus the next appointment, so one slot does both jobs.
struct DatePlusEventComplicationView: View {
let entry: WatchComplicationEntry
var body: some View {
let next = WatchComplicationSupport.nextEvent(in: entry.snapshot, at: entry.date)
VStack(alignment: .leading, spacing: 2) {
Text(dateLine)
.font(.system(size: 11, weight: .semibold))
.widgetAccentable()
if let next {
Text(next.isAllDay
? next.title
: "\(WatchComplicationSupport.time(next.start, language: entry.language)) \(next.title)")
.font(.system(size: 14))
.lineLimit(1)
} else {
Text(WatchL10n.t("watch.no_events", entry.language)).font(.system(size: 14))
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private var dateLine: String {
let formatter = DateFormatter()
formatter.locale = WatchL10n.locale(entry.language)
formatter.setLocalizedDateFormatFromTemplate("EEE d MMMM")
return formatter.string(from: entry.date).uppercased()
}
}
@@ -0,0 +1,156 @@
import SwiftUI
import WidgetKit
import CalendarrCore
/// Attaches the curved bezel label that a face with a bezel slot — Infograph —
/// renders across the top, where the tick marks otherwise are. Faces without
/// that slot ignore it, so it costs nothing to always provide.
private extension View {
func eventBezelLabel(_ entry: WatchComplicationEntry) -> some View {
let next = WatchComplicationSupport.nextEvent(in: entry.snapshot, at: entry.date)
return widgetLabel {
Text(WatchComplicationSupport.eventLabel(next, at: entry.date, language: entry.language))
}
}
}
/// A draining ring plus the remaining time.
struct CountdownComplicationView: View {
let entry: WatchComplicationEntry
@Environment(\.widgetFamily) private var family
var body: some View {
let next = WatchComplicationSupport.nextEvent(in: entry.snapshot, at: entry.date)
switch family {
case .accessoryInline:
inline(next)
default:
circular(next)
}
}
@ViewBuilder
private func circular(_ next: SnapshotEvent?) -> some View {
if let next {
let target = WatchComplicationSupport.countdownTarget(next, at: entry.date)
Gauge(value: WatchComplicationSupport.gaugeFraction(to: target, at: entry.date)) {
Image(systemName: "calendar")
} currentValueLabel: {
Text(WatchComplicationSupport.compactCountdown(to: target, at: entry.date,
language: entry.language))
.font(.system(size: 13))
.lineLimit(1)
.minimumScaleFactor(0.6)
}
.gaugeStyle(.accessoryCircularCapacity)
.widgetAccentable()
.eventBezelLabel(entry)
} else {
ZStack {
AccessoryWidgetBackground()
Image(systemName: "calendar").font(.system(size: 18))
}
}
}
@ViewBuilder
private func inline(_ next: SnapshotEvent?) -> some View {
if let next {
Label {
Text(WatchComplicationSupport.countdownTarget(next, at: entry.date), style: .timer)
} icon: {
Image(systemName: "timer")
}
} else {
Label(WatchL10n.t("watch.no_events", entry.language), systemImage: "calendar")
}
}
}
/// Weekday and day number, the way a date is actually read. The ring appears
/// only while an appointment is running, and then shows how far through it we
/// are — an empty track around an idle day is noise, so there is no Gauge at
/// all in that case.
struct DateComplicationView: View {
let entry: WatchComplicationEntry
@Environment(\.widgetFamily) private var family
private var day: Int {
Calendar(identifier: .gregorian).component(.day, from: entry.date)
}
private var weekday: String {
WatchComplicationSupport.weekdayAbbreviation(entry.date, language: entry.language)
}
var body: some View {
switch family {
case .accessoryInline:
Label("\(weekday) \(day)", systemImage: "calendar")
default:
circular
}
}
@ViewBuilder
private var circular: some View {
if let running = WatchComplicationSupport.runningEvent(in: entry.snapshot, at: entry.date) {
Gauge(value: WatchComplicationSupport.progress(of: running, at: entry.date)) {
Text(weekday)
} currentValueLabel: {
dateStack
}
.gaugeStyle(.accessoryCircularCapacity)
.eventBezelLabel(entry)
} else {
ZStack {
AccessoryWidgetBackground()
dateStack
}
.eventBezelLabel(entry)
}
}
private var dateStack: some View {
VStack(spacing: -2) {
Text(weekday)
.font(.system(size: 11, weight: .semibold))
.widgetAccentable()
Text("\(day)")
.font(.system(size: 23, weight: .semibold))
.lineLimit(1)
.minimumScaleFactor(0.7)
}
}
}
/// How many appointments today. Says "busy", not "what".
struct TodayCountComplicationView: View {
let entry: WatchComplicationEntry
@Environment(\.widgetFamily) private var family
private var count: Int {
WatchComplicationSupport.todayCount(in: entry.snapshot, at: entry.date)
}
var body: some View {
switch family {
case .accessoryInline:
Label("\(count) \(WatchL10n.t("watch.events_today", entry.language))",
systemImage: "calendar")
default:
Gauge(value: WatchComplicationSupport.todayRemainingFraction(in: entry.snapshot,
at: entry.date)) {
Image(systemName: "calendar")
} currentValueLabel: {
Text("\(count)")
.font(.system(size: 20, weight: .semibold))
.lineLimit(1)
.minimumScaleFactor(0.6)
}
.gaugeStyle(.accessoryCircularCapacity)
.widgetAccentable()
.eventBezelLabel(entry)
}
}
}
@@ -0,0 +1,44 @@
import AppIntents
import WidgetKit
import CalendarrCore
/// One calendar option in a complication's configuration.
struct WatchCalendarEntity: AppEntity, Identifiable {
let id: String
let name: String
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Kalender"
static var defaultQuery = WatchCalendarEntityQuery()
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(title: LocalizedStringResource(stringLiteral: name))
}
}
/// Reads the calendars from this watch's App Group container, so configuration
/// needs no network call and no phone.
struct WatchCalendarEntityQuery: EntityQuery {
func entities(for identifiers: [String]) async throws -> [WatchCalendarEntity] {
let wanted = Set(identifiers)
return SnapshotStore().readCalendars()
.filter { wanted.contains($0.id) }
.map { WatchCalendarEntity(id: $0.id, name: $0.name) }
}
func suggestedEntities() async throws -> [WatchCalendarEntity] {
SnapshotStore().readCalendars().map { WatchCalendarEntity(id: $0.id, name: $0.name) }
}
}
/// Per-complication override of the calendar selection. The in-app toggles are
/// the primary path — intent configuration on a watch face is awkward to
/// operate — so an empty selection means "whatever the app is set to".
struct WatchCalendarSelectionIntent: WidgetConfigurationIntent {
static var title: LocalizedStringResource = "Kalender auswählen"
static var description = IntentDescription("Leer = die Auswahl aus der Watch-App.")
// Must be optional: WidgetConfigurationIntent requires optional parameter
// types, and the watchOS SDK enforces it where iOS lets a bare array pass.
@Parameter(title: "Kalender")
var selectedCalendars: [WatchCalendarEntity]?
}
@@ -0,0 +1,62 @@
import WidgetKit
import AppIntents
import SwiftUI
import CalendarrCore
struct WatchComplicationEntry: TimelineEntry {
let date: Date
let snapshot: CalendarrSnapshot?
var language: String { snapshot?.language ?? "system" }
}
/// Builds every Calendarr complication's timeline.
///
/// Entries land on event boundaries rather than on a fixed schedule, so the
/// complication flips in the exact minute the next appointment changes without
/// a network call and without spending refresh budget. New *data* arrives by
/// push from the phone, which is why the refresh policy is `.atEnd`: when the
/// timeline simply runs out, regenerating from the same snapshot is cheap and
/// correct.
struct WatchComplicationProvider: AppIntentTimelineProvider {
typealias Entry = WatchComplicationEntry
typealias Intent = WatchCalendarSelectionIntent
func placeholder(in context: Context) -> WatchComplicationEntry {
WatchComplicationEntry(date: .now, snapshot: nil)
}
func snapshot(for configuration: WatchCalendarSelectionIntent,
in context: Context) async -> WatchComplicationEntry {
WatchComplicationEntry(date: .now, snapshot: resolved(configuration))
}
func timeline(for configuration: WatchCalendarSelectionIntent,
in context: Context) async -> Timeline<WatchComplicationEntry> {
let snapshot = resolved(configuration)
let now = Date()
let dates = snapshot.map { ComplicationTimeline.entryDates(from: now, in: $0) } ?? [now]
return Timeline(entries: dates.map { WatchComplicationEntry(date: $0, snapshot: snapshot) },
policy: .atEnd)
}
/// What the complication gallery offers before anyone configures anything.
/// Required on watchOS — iOS has a default implementation, the watch does
/// not, because the gallery has to be populated.
///
/// One entry, with nothing selected: that means "whatever the app is set
/// to", which is the filter the user already chose there.
func recommendations() -> [AppIntentRecommendation<WatchCalendarSelectionIntent>] {
[AppIntentRecommendation(intent: WatchCalendarSelectionIntent(),
description: Text("Calendarr"))]
}
/// The app-level filter applies first; a per-complication selection narrows
/// further. An empty selection means "whatever the app is set to".
private func resolved(_ configuration: WatchCalendarSelectionIntent) -> CalendarrSnapshot? {
guard case .ok(let snapshot) = SnapshotStore().read() else { return nil }
return snapshot
.filtered(excludingCalendarKeys: WatchFilterStore.load())
.filtered(toCalendarKeys: Set((configuration.selectedCalendars ?? []).map(\.id)))
}
}
@@ -0,0 +1,131 @@
import Foundation
import CalendarrCore
enum WatchComplicationSupport {
/// The first event that has not ended yet — including one running now,
/// which is what "what is next" means when you are already in a meeting.
/// Matches `WatchPushPolicy.nextEventIdentity`, deliberately.
static func nextEvent(in snapshot: CalendarrSnapshot?, at now: Date) -> SnapshotEvent? {
snapshot?.events.filter { $0.end > now }.min { $0.start < $1.start }
}
static func upcoming(in snapshot: CalendarrSnapshot?, at now: Date, limit: Int) -> [SnapshotEvent] {
guard let snapshot else { return [] }
return Array(snapshot.events.filter { $0.end > now }.sorted { $0.start < $1.start }.prefix(limit))
}
static func todayCount(in snapshot: CalendarrSnapshot?, at now: Date,
calendar: Calendar = Calendar(identifier: .gregorian)) -> Int {
guard let snapshot else { return 0 }
let dayStart = calendar.startOfDay(for: now)
guard let dayEnd = calendar.date(byAdding: .day, value: 1, to: dayStart) else { return 0 }
return snapshot.events.filter { $0.start < dayEnd && $0.end > dayStart }.count
}
/// The appointment happening right now, if there is one. All-day events are
/// excluded on purpose: one of those is "running" for twenty-four hours and
/// would hold the ring at some arbitrary fraction all day, which says
/// nothing.
static func runningEvent(in snapshot: CalendarrSnapshot?, at now: Date) -> SnapshotEvent? {
snapshot?.events
.filter { !$0.isAllDay && $0.start <= now && $0.end > now }
.min { $0.end < $1.end }
}
/// How far through an appointment we are, 0 at its start and 1 at its end.
static func progress(of event: SnapshotEvent, at now: Date) -> Double {
let total = event.end.timeIntervalSince(event.start)
guard total > 0 else { return 0 }
return min(1, max(0, now.timeIntervalSince(event.start) / total))
}
static func weekdayAbbreviation(_ date: Date, language: String) -> String {
let formatter = DateFormatter()
formatter.locale = WatchL10n.locale(language)
formatter.dateFormat = "EEE"
return formatter.string(from: date)
.replacingOccurrences(of: ".", with: "")
.uppercased()
}
/// What a countdown counts to: the start if it has not begun, otherwise the
/// end. Counting up from a start time that has passed reads as broken.
static func countdownTarget(_ event: SnapshotEvent, at now: Date) -> Date {
event.start > now ? event.start : event.end
}
/// A countdown narrow enough for a corner or a circular gauge: at most
/// three characters. `Text(style: .timer)` would be live and free of
/// timeline entries, but renders `1:23:45` and overlaps whatever sits
/// beside it — so the timeline carries minute-granular entries instead and
/// this is recomputed per entry.
static func compactCountdown(to target: Date, at now: Date, language: String) -> String {
let seconds = target.timeIntervalSince(now)
guard seconds > 0 else { return WatchL10n.t("watch.now", language) }
let minutes = Int(seconds / 60)
if minutes < 1 { return WatchL10n.t("watch.now", language) }
if minutes < 60 { return "\(minutes)m" }
let hours = minutes / 60
if hours < 24 { return "\(hours)h" }
return "\(hours / 24)d"
}
/// How full a draining ring should be, over a two-hour lead window.
static func gaugeFraction(to target: Date, at now: Date) -> Double {
let window: TimeInterval = 2 * 3600
return min(1, max(0, target.timeIntervalSince(now) / window))
}
/// How much of today is still ahead. Drains through the day, matching the
/// countdown ring rather than inventing a second direction.
static func dayRemainingFraction(at now: Date,
calendar: Calendar = Calendar(identifier: .gregorian)) -> Double {
let dayStart = calendar.startOfDay(for: now)
guard let dayEnd = calendar.date(byAdding: .day, value: 1, to: dayStart) else { return 0 }
let total = dayEnd.timeIntervalSince(dayStart)
guard total > 0 else { return 0 }
return min(1, max(0, dayEnd.timeIntervalSince(now) / total))
}
/// The share of today's appointments still ahead. Zero when there are none,
/// which leaves the ring empty rather than full — an empty day should not
/// look like an untouched one.
static func todayRemainingFraction(in snapshot: CalendarrSnapshot?, at now: Date,
calendar: Calendar = Calendar(identifier: .gregorian)) -> Double {
guard let snapshot else { return 0 }
let dayStart = calendar.startOfDay(for: now)
guard let dayEnd = calendar.date(byAdding: .day, value: 1, to: dayStart) else { return 0 }
let today = snapshot.events.filter { $0.start < dayEnd && $0.end > dayStart }
guard !today.isEmpty else { return 0 }
let ahead = today.filter { $0.end > now }.count
return min(1, max(0, Double(ahead) / Double(today.count)))
}
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)
}
static func monthAbbreviation(_ date: Date, language: String) -> String {
let formatter = DateFormatter()
formatter.locale = WatchL10n.locale(language)
formatter.dateFormat = "LLL"
return formatter.string(from: date).uppercased()
}
/// The curved label that accompanies a circular or corner complication:
/// time plus title, or a plain "no events".
///
/// On a corner this follows the screen edge. On a face with a bezel slot —
/// Infograph — the same label arcs across the top, where the tick marks
/// otherwise are. That is the position ClockKit called `graphicBezel`, and
/// in WidgetKit it is reached by attaching `.widgetLabel` to an
/// `accessoryCircular` widget.
static func eventLabel(_ event: SnapshotEvent?, at now: Date, language: String) -> String {
guard let event else { return WatchL10n.t("watch.no_events", language) }
if event.isAllDay { return event.title }
return "\(time(event.start, language: language)) \(event.title)"
}
}