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