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,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)"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user