Add the watch app and its eight complications
The watch receives the phone's payload over WatchConnectivity and writes it into its own App Group container through the same SnapshotStore, so there is one wire format and one reader. The agenda, the detail screen and the filter all read that; the complications read what the app last wrote, since an extension cannot run a WCSession of its own. Complication timelines land on event boundaries rather than a schedule, so the next appointment flips in the exact minute it changes without spending refresh budget. CalendarrWatchShared holds what both watch targets need, attached to each the way Shared/ is attached to the app and the widgets. recommendations() is implemented because watchOS requires it — iOS has a default, the watch does not, since the complication gallery has to be populated. Found by building, not by reading. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6a7afe0647
commit
f9bc1965a5
@@ -10,13 +10,13 @@
|
|||||||
3934CF4630544C2E003958B8 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3934CF4530544C2E003958B8 /* WidgetKit.framework */; };
|
3934CF4630544C2E003958B8 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3934CF4530544C2E003958B8 /* WidgetKit.framework */; };
|
||||||
3934CF4830544C2E003958B8 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3934CF4730544C2E003958B8 /* SwiftUI.framework */; };
|
3934CF4830544C2E003958B8 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3934CF4730544C2E003958B8 /* SwiftUI.framework */; };
|
||||||
3934CF5530544C2E003958B8 /* CalendarrWatchComplications.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 3934CF4330544C2E003958B8 /* CalendarrWatchComplications.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
3934CF5530544C2E003958B8 /* CalendarrWatchComplications.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 3934CF4330544C2E003958B8 /* CalendarrWatchComplications.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
|
39E1B0010001000000000001 /* CalendarrWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 3934CF1A30544C01003958B8 /* CalendarrWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
AA9701000001000000000001 /* AppAuth in Frameworks */ = {isa = PBXBuildFile; productRef = AA9701000002000000000001 /* AppAuth */; };
|
AA9701000001000000000001 /* AppAuth in Frameworks */ = {isa = PBXBuildFile; productRef = AA9701000002000000000001 /* AppAuth */; };
|
||||||
CA1E4D0A0002000000000001 /* CalendarrCore in Frameworks */ = {isa = PBXBuildFile; productRef = CA1E4D0A0001000000000001 /* CalendarrCore */; };
|
CA1E4D0A0002000000000001 /* CalendarrCore in Frameworks */ = {isa = PBXBuildFile; productRef = CA1E4D0A0001000000000001 /* CalendarrCore */; };
|
||||||
CA1E4D0A0002000000000002 /* CalendarrCore in Frameworks */ = {isa = PBXBuildFile; productRef = CA1E4D0A0001000000000002 /* CalendarrCore */; };
|
CA1E4D0A0002000000000002 /* CalendarrCore in Frameworks */ = {isa = PBXBuildFile; productRef = CA1E4D0A0001000000000002 /* CalendarrCore */; };
|
||||||
CA1E4D0A0002000000000003 /* CalendarrCore in Frameworks */ = {isa = PBXBuildFile; productRef = CA1E4D0A0001000000000003 /* CalendarrCore */; };
|
CA1E4D0A0002000000000003 /* CalendarrCore in Frameworks */ = {isa = PBXBuildFile; productRef = CA1E4D0A0001000000000003 /* CalendarrCore */; };
|
||||||
CA1E4D0A0002000000000004 /* CalendarrCore in Frameworks */ = {isa = PBXBuildFile; productRef = CA1E4D0A0001000000000004 /* CalendarrCore */; };
|
CA1E4D0A0002000000000004 /* CalendarrCore in Frameworks */ = {isa = PBXBuildFile; productRef = CA1E4D0A0001000000000004 /* CalendarrCore */; };
|
||||||
D0001A0CABCDEF0100AB5001 /* CalendarrWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D0001A02ABCDEF0100AB5001 /* CalendarrWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
D0001A0CABCDEF0100AB5001 /* CalendarrWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D0001A02ABCDEF0100AB5001 /* CalendarrWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
39E1B0010001000000000001 /* CalendarrWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 3934CF1A30544C01003958B8 /* CalendarrWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -48,13 +48,6 @@
|
|||||||
remoteGlobalIDString = 3934CF4230544C2E003958B8;
|
remoteGlobalIDString = 3934CF4230544C2E003958B8;
|
||||||
remoteInfo = CalendarrWatchComplications;
|
remoteInfo = CalendarrWatchComplications;
|
||||||
};
|
};
|
||||||
D0001A0DABCDEF0100AB5001 /* PBXContainerItemProxy */ = {
|
|
||||||
isa = PBXContainerItemProxy;
|
|
||||||
containerPortal = C0000301FB4E10100AB5001 /* Project object */;
|
|
||||||
proxyType = 1;
|
|
||||||
remoteGlobalIDString = D0001A01ABCDEF0100AB5001;
|
|
||||||
remoteInfo = CalendarrWidgets;
|
|
||||||
};
|
|
||||||
39E1B0010003000000000001 /* PBXContainerItemProxy */ = {
|
39E1B0010003000000000001 /* PBXContainerItemProxy */ = {
|
||||||
isa = PBXContainerItemProxy;
|
isa = PBXContainerItemProxy;
|
||||||
containerPortal = C0000301FB4E10100AB5001 /* Project object */;
|
containerPortal = C0000301FB4E10100AB5001 /* Project object */;
|
||||||
@@ -62,6 +55,13 @@
|
|||||||
remoteGlobalIDString = 3934CF1930544C01003958B8;
|
remoteGlobalIDString = 3934CF1930544C01003958B8;
|
||||||
remoteInfo = CalendarrWatch;
|
remoteInfo = CalendarrWatch;
|
||||||
};
|
};
|
||||||
|
D0001A0DABCDEF0100AB5001 /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = C0000301FB4E10100AB5001 /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = D0001A01ABCDEF0100AB5001;
|
||||||
|
remoteInfo = CalendarrWidgets;
|
||||||
|
};
|
||||||
/* End PBXContainerItemProxy section */
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
/* Begin PBXCopyFilesBuildPhase section */
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
@@ -76,17 +76,6 @@
|
|||||||
name = "Embed Foundation Extensions";
|
name = "Embed Foundation Extensions";
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
D0001A0BABCDEF0100AB5001 /* Embed Foundation Extensions */ = {
|
|
||||||
isa = PBXCopyFilesBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
dstPath = "";
|
|
||||||
dstSubfolderSpec = 13;
|
|
||||||
files = (
|
|
||||||
D0001A0CABCDEF0100AB5001 /* CalendarrWidgets.appex in Embed Foundation Extensions */,
|
|
||||||
);
|
|
||||||
name = "Embed Foundation Extensions";
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
39E1B0010002000000000001 /* Embed Watch Content */ = {
|
39E1B0010002000000000001 /* Embed Watch Content */ = {
|
||||||
isa = PBXCopyFilesBuildPhase;
|
isa = PBXCopyFilesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -98,6 +87,17 @@
|
|||||||
name = "Embed Watch Content";
|
name = "Embed Watch Content";
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
D0001A0BABCDEF0100AB5001 /* Embed Foundation Extensions */ = {
|
||||||
|
isa = PBXCopyFilesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
dstPath = "";
|
||||||
|
dstSubfolderSpec = 13;
|
||||||
|
files = (
|
||||||
|
D0001A0CABCDEF0100AB5001 /* CalendarrWidgets.appex in Embed Foundation Extensions */,
|
||||||
|
);
|
||||||
|
name = "Embed Foundation Extensions";
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXCopyFilesBuildPhase section */
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
@@ -158,6 +158,11 @@
|
|||||||
path = CalendarrWatchComplications;
|
path = CalendarrWatchComplications;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
39E1B0010005000000000001 /* CalendarrWatchShared */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
path = CalendarrWatchShared;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
C0000D01FC4E10100AB5001 /* Calendarr iOS */ = {
|
C0000D01FC4E10100AB5001 /* Calendarr iOS */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
path = "Calendarr iOS";
|
path = "Calendarr iOS";
|
||||||
@@ -260,6 +265,7 @@
|
|||||||
3934CF4930544C2E003958B8 /* CalendarrWatchComplications */,
|
3934CF4930544C2E003958B8 /* CalendarrWatchComplications */,
|
||||||
3934CF4430544C2E003958B8 /* Frameworks */,
|
3934CF4430544C2E003958B8 /* Frameworks */,
|
||||||
C0000C01FB4E10100AB5001 /* Products */,
|
C0000C01FB4E10100AB5001 /* Products */,
|
||||||
|
39E1B0010005000000000001 /* CalendarrWatchShared */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -319,6 +325,7 @@
|
|||||||
);
|
);
|
||||||
fileSystemSynchronizedGroups = (
|
fileSystemSynchronizedGroups = (
|
||||||
3934CF1B30544C01003958B8 /* CalendarrWatch */,
|
3934CF1B30544C01003958B8 /* CalendarrWatch */,
|
||||||
|
39E1B0010005000000000001 /* CalendarrWatchShared */,
|
||||||
);
|
);
|
||||||
name = CalendarrWatch;
|
name = CalendarrWatch;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
@@ -388,6 +395,7 @@
|
|||||||
);
|
);
|
||||||
fileSystemSynchronizedGroups = (
|
fileSystemSynchronizedGroups = (
|
||||||
3934CF4930544C2E003958B8 /* CalendarrWatchComplications */,
|
3934CF4930544C2E003958B8 /* CalendarrWatchComplications */,
|
||||||
|
39E1B0010005000000000001 /* CalendarrWatchShared */,
|
||||||
);
|
);
|
||||||
name = CalendarrWatchComplications;
|
name = CalendarrWatchComplications;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
@@ -641,16 +649,16 @@
|
|||||||
target = 3934CF4230544C2E003958B8 /* CalendarrWatchComplications */;
|
target = 3934CF4230544C2E003958B8 /* CalendarrWatchComplications */;
|
||||||
targetProxy = 3934CF5330544C2E003958B8 /* PBXContainerItemProxy */;
|
targetProxy = 3934CF5330544C2E003958B8 /* PBXContainerItemProxy */;
|
||||||
};
|
};
|
||||||
D0001A0EABCDEF0100AB5001 /* PBXTargetDependency */ = {
|
|
||||||
isa = PBXTargetDependency;
|
|
||||||
target = D0001A01ABCDEF0100AB5001 /* CalendarrWidgets */;
|
|
||||||
targetProxy = D0001A0DABCDEF0100AB5001 /* PBXContainerItemProxy */;
|
|
||||||
};
|
|
||||||
39E1B0010004000000000001 /* PBXTargetDependency */ = {
|
39E1B0010004000000000001 /* PBXTargetDependency */ = {
|
||||||
isa = PBXTargetDependency;
|
isa = PBXTargetDependency;
|
||||||
target = 3934CF1930544C01003958B8 /* CalendarrWatch */;
|
target = 3934CF1930544C01003958B8 /* CalendarrWatch */;
|
||||||
targetProxy = 39E1B0010003000000000001 /* PBXContainerItemProxy */;
|
targetProxy = 39E1B0010003000000000001 /* PBXContainerItemProxy */;
|
||||||
};
|
};
|
||||||
|
D0001A0EABCDEF0100AB5001 /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = D0001A01ABCDEF0100AB5001 /* CalendarrWidgets */;
|
||||||
|
targetProxy = D0001A0DABCDEF0100AB5001 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
/* End PBXTargetDependency section */
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
@@ -709,6 +717,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = CalendarrWatch/CalendarrWatch.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||||
@@ -722,7 +731,6 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
CODE_SIGN_ENTITLEMENTS = CalendarrWatch/CalendarrWatch.entitlements;
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios.watchkitapp;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios.watchkitapp;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = watchos;
|
SDKROOT = watchos;
|
||||||
@@ -743,6 +751,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = CalendarrWatch/CalendarrWatch.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||||
@@ -756,7 +765,6 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
CODE_SIGN_ENTITLEMENTS = CalendarrWatch/CalendarrWatch.entitlements;
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios.watchkitapp;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios.watchkitapp;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = watchos;
|
SDKROOT = watchos;
|
||||||
@@ -867,6 +875,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = CalendarrWatchComplications/CalendarrWatchComplications.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||||
@@ -881,7 +890,6 @@
|
|||||||
"@executable_path/../../../../Frameworks",
|
"@executable_path/../../../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
CODE_SIGN_ENTITLEMENTS = CalendarrWatchComplications/CalendarrWatchComplications.entitlements;
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios.watchkitapp.complications;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios.watchkitapp.complications;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = watchos;
|
SDKROOT = watchos;
|
||||||
@@ -901,6 +909,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = CalendarrWatchComplications/CalendarrWatchComplications.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||||
@@ -915,7 +924,6 @@
|
|||||||
"@executable_path/../../../../Frameworks",
|
"@executable_path/../../../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
CODE_SIGN_ENTITLEMENTS = CalendarrWatchComplications/CalendarrWatchComplications.entitlements;
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios.watchkitapp.complications;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios.watchkitapp.complications;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = watchos;
|
SDKROOT = watchos;
|
||||||
@@ -1329,6 +1337,10 @@
|
|||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = CalendarrCore;
|
productName = CalendarrCore;
|
||||||
};
|
};
|
||||||
|
CA1E4D0A0001000000000002 /* CalendarrCore */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = CalendarrCore;
|
||||||
|
};
|
||||||
CA1E4D0A0001000000000003 /* CalendarrCore */ = {
|
CA1E4D0A0001000000000003 /* CalendarrCore */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = CalendarrCore;
|
productName = CalendarrCore;
|
||||||
@@ -1337,10 +1349,6 @@
|
|||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = CalendarrCore;
|
productName = CalendarrCore;
|
||||||
};
|
};
|
||||||
CA1E4D0A0001000000000002 /* CalendarrCore */ = {
|
|
||||||
isa = XCSwiftPackageProductDependency;
|
|
||||||
productName = CalendarrCore;
|
|
||||||
};
|
|
||||||
/* End XCSwiftPackageProductDependency section */
|
/* End XCSwiftPackageProductDependency section */
|
||||||
};
|
};
|
||||||
rootObject = C0000301FB4E10100AB5001 /* Project object */;
|
rootObject = C0000301FB4E10100AB5001 /* Project object */;
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
//
|
|
||||||
// CalendarrWatchApp.swift
|
|
||||||
// CalendarrWatch Watch App
|
|
||||||
//
|
|
||||||
// Created by Guido Schmit on 11.09.2026.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct CalendarrWatch_Watch_AppApp: App {
|
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 {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
WatchAgendaView()
|
||||||
|
.environment(model)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
//
|
|
||||||
// ContentView.swift
|
|
||||||
// CalendarrWatch Watch App
|
|
||||||
//
|
|
||||||
// Created by Guido Schmit on 11.09.2026.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct ContentView: View {
|
|
||||||
var body: some View {
|
|
||||||
VStack {
|
|
||||||
Image(systemName: "globe")
|
|
||||||
.imageScale(.large)
|
|
||||||
.foregroundStyle(.tint)
|
|
||||||
Text("Hello, world!")
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview {
|
|
||||||
ContentView()
|
|
||||||
}
|
|
||||||
@@ -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,76 @@
|
|||||||
|
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) -> String {
|
||||||
|
if event.isAllDay { return WatchL10n.t("watch.allday", language) }
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.locale = WatchL10n.locale(language)
|
||||||
|
formatter.dateFormat = "HH:mm"
|
||||||
|
let start = formatter.string(from: event.start)
|
||||||
|
guard event.end > event.start else { return start }
|
||||||
|
return "\(start) – \(formatter.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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
//
|
|
||||||
// CalendarrWatch.swift
|
|
||||||
// CalendarrWatch
|
|
||||||
//
|
|
||||||
// Created by Guido Schmit on 11.09.2026.
|
|
||||||
//
|
|
||||||
|
|
||||||
import WidgetKit
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct Provider: TimelineProvider {
|
|
||||||
func placeholder(in context: Context) -> SimpleEntry {
|
|
||||||
SimpleEntry(date: Date(), emoji: "😀")
|
|
||||||
}
|
|
||||||
|
|
||||||
func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
|
|
||||||
let entry = SimpleEntry(date: Date(), emoji: "😀")
|
|
||||||
completion(entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
|
|
||||||
var entries: [SimpleEntry] = []
|
|
||||||
|
|
||||||
// Generate a timeline consisting of five entries an hour apart, starting from the current date.
|
|
||||||
let currentDate = Date()
|
|
||||||
for hourOffset in 0 ..< 5 {
|
|
||||||
let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
|
|
||||||
let entry = SimpleEntry(date: entryDate, emoji: "😀")
|
|
||||||
entries.append(entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
let timeline = Timeline(entries: entries, policy: .atEnd)
|
|
||||||
completion(timeline)
|
|
||||||
}
|
|
||||||
|
|
||||||
// func relevances() async -> WidgetRelevances<Void> {
|
|
||||||
// // Generate a list containing the contexts this widget is relevant in.
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SimpleEntry: TimelineEntry {
|
|
||||||
let date: Date
|
|
||||||
let emoji: String
|
|
||||||
}
|
|
||||||
|
|
||||||
struct CalendarrWatchEntryView : View {
|
|
||||||
var entry: Provider.Entry
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack {
|
|
||||||
HStack {
|
|
||||||
Text("Time:")
|
|
||||||
Text(entry.date, style: .time)
|
|
||||||
}
|
|
||||||
|
|
||||||
Text("Emoji:")
|
|
||||||
Text(entry.emoji)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct CalendarrWatch: Widget {
|
|
||||||
let kind: String = "CalendarrWatch"
|
|
||||||
|
|
||||||
var body: some WidgetConfiguration {
|
|
||||||
StaticConfiguration(kind: kind, provider: Provider()) { entry in
|
|
||||||
if #available(watchOS 10.0, *) {
|
|
||||||
CalendarrWatchEntryView(entry: entry)
|
|
||||||
.containerBackground(.fill.tertiary, for: .widget)
|
|
||||||
} else {
|
|
||||||
CalendarrWatchEntryView(entry: entry)
|
|
||||||
.padding()
|
|
||||||
.background()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.configurationDisplayName("My Widget")
|
|
||||||
.description("This is an example widget.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview(as: .accessoryRectangular) {
|
|
||||||
CalendarrWatch()
|
|
||||||
} timeline: {
|
|
||||||
SimpleEntry(date: .now, emoji: "😀")
|
|
||||||
SimpleEntry(date: .now, emoji: "🤩")
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
//
|
|
||||||
// CalendarrWatchBundle.swift
|
|
||||||
// CalendarrWatch
|
|
||||||
//
|
|
||||||
// Created by Guido Schmit on 11.09.2026.
|
|
||||||
//
|
|
||||||
|
|
||||||
import WidgetKit
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
@main
|
|
||||||
struct CalendarrWatchBundle: WidgetBundle {
|
|
||||||
var body: some Widget {
|
|
||||||
CalendarrWatch()
|
|
||||||
CalendarrWatchControl()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import WidgetKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct CalendarrWatchComplicationBundle: WidgetBundle {
|
||||||
|
var body: some Widget {
|
||||||
|
WatchNextEventWidget()
|
||||||
|
WatchCornerCountdownWidget()
|
||||||
|
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 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])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
//
|
|
||||||
// CalendarrWatchControl.swift
|
|
||||||
// CalendarrWatch
|
|
||||||
//
|
|
||||||
// Created by Guido Schmit on 11.09.2026.
|
|
||||||
//
|
|
||||||
|
|
||||||
import AppIntents
|
|
||||||
import SwiftUI
|
|
||||||
import WidgetKit
|
|
||||||
|
|
||||||
struct CalendarrWatchControl: ControlWidget {
|
|
||||||
var body: some ControlWidgetConfiguration {
|
|
||||||
StaticControlConfiguration(
|
|
||||||
kind: ".watchkitapp.CalendarrWatch",
|
|
||||||
provider: Provider()
|
|
||||||
) { value in
|
|
||||||
ControlWidgetToggle(
|
|
||||||
"Start Timer",
|
|
||||||
isOn: value,
|
|
||||||
action: StartTimerIntent()
|
|
||||||
) { isRunning in
|
|
||||||
Label(isRunning ? "On" : "Off", systemImage: "timer")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.displayName("Timer")
|
|
||||||
.description("A an example control that runs a timer.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension CalendarrWatchControl {
|
|
||||||
struct Provider: ControlValueProvider {
|
|
||||||
var previewValue: Bool {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
func currentValue() async throws -> Bool {
|
|
||||||
let isRunning = true // Check if the timer is running
|
|
||||||
return isRunning
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct StartTimerIntent: SetValueIntent {
|
|
||||||
static let title: LocalizedStringResource = "Start a timer"
|
|
||||||
|
|
||||||
@Parameter(title: "Timer is running")
|
|
||||||
var value: Bool
|
|
||||||
|
|
||||||
func perform() async throws -> some IntentResult {
|
|
||||||
// Start / stop the timer based on `value`.
|
|
||||||
return .result()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
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.countdownTarget(next, at: entry.date), style: .timer)
|
||||||
|
.widgetAccentable()
|
||||||
|
} else {
|
||||||
|
Image(systemName: "calendar")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.widgetLabel {
|
||||||
|
Text(WatchComplicationSupport.cornerLabel(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))
|
||||||
|
.widgetAccentable()
|
||||||
|
} else {
|
||||||
|
Image(systemName: "calendar")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.widgetLabel {
|
||||||
|
Text(next?.title ?? WatchL10n.t("watch.no_events", entry.language))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,113 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import WidgetKit
|
||||||
|
import CalendarrCore
|
||||||
|
|
||||||
|
/// 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(target, style: .timer)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
}
|
||||||
|
.gaugeStyle(.accessoryCircularCapacity)
|
||||||
|
.widgetAccentable()
|
||||||
|
} 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Day number and month. No appointment information — a date replacement.
|
||||||
|
struct DateComplicationView: View {
|
||||||
|
let entry: WatchComplicationEntry
|
||||||
|
@Environment(\.widgetFamily) private var family
|
||||||
|
|
||||||
|
private var day: Int {
|
||||||
|
Calendar(identifier: .gregorian).component(.day, from: entry.date)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
switch family {
|
||||||
|
case .accessoryInline:
|
||||||
|
Label("\(WatchComplicationSupport.monthAbbreviation(entry.date, language: entry.language)) \(day)",
|
||||||
|
systemImage: "calendar")
|
||||||
|
default:
|
||||||
|
ZStack {
|
||||||
|
AccessoryWidgetBackground()
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Text("\(day)")
|
||||||
|
.font(.system(size: 22, weight: .bold))
|
||||||
|
.minimumScaleFactor(0.7)
|
||||||
|
.widgetAccentable()
|
||||||
|
Text(WatchComplicationSupport.monthAbbreviation(entry.date, language: entry.language))
|
||||||
|
.font(.system(size: 8, weight: .semibold))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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:
|
||||||
|
ZStack {
|
||||||
|
AccessoryWidgetBackground()
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Text("\(count)")
|
||||||
|
.font(.system(size: 22, weight: .bold))
|
||||||
|
.widgetAccentable()
|
||||||
|
Text(WatchL10n.t("watch.events_today", entry.language).uppercased())
|
||||||
|
.font(.system(size: 7, weight: .semibold))
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,58 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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))
|
||||||
|
}
|
||||||
|
|
||||||
|
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 bezel label for a corner complication: time plus title, or a
|
||||||
|
/// plain "no events".
|
||||||
|
static func cornerLabel(_ 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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import Foundation
|
||||||
|
import CalendarrCore
|
||||||
|
|
||||||
|
/// Which calendars this watch hides, on top of what the phone already filtered.
|
||||||
|
///
|
||||||
|
/// The snapshot arrives with hidden and banished calendars already removed, so
|
||||||
|
/// this narrows further and can never contradict the phone. It lives in the App
|
||||||
|
/// Group so one toggle in the app affects every complication at once.
|
||||||
|
enum WatchFilterStore {
|
||||||
|
private static let key = "watchHiddenCalendarKeys"
|
||||||
|
|
||||||
|
private static var defaults: UserDefaults? {
|
||||||
|
UserDefaults(suiteName: CalendarrAppGroup.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func load() -> Set<String> {
|
||||||
|
Set(defaults?.stringArray(forKey: key) ?? [])
|
||||||
|
}
|
||||||
|
|
||||||
|
static func save(_ keys: Set<String>) {
|
||||||
|
defaults?.set(keys.sorted(), forKey: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// German and English strings for the watch app and its complications.
|
||||||
|
///
|
||||||
|
/// A local copy rather than a shared one: `CalendarrCore` is deliberately
|
||||||
|
/// Foundation-only and holds no presentation concerns, and the iOS `WidgetL10n`
|
||||||
|
/// belongs to a target the watch cannot link against.
|
||||||
|
enum WatchL10n {
|
||||||
|
static func t(_ key: String, _ stored: String) -> String {
|
||||||
|
strings[resolve(stored)]?[key] ?? strings["en"]?[key] ?? key
|
||||||
|
}
|
||||||
|
|
||||||
|
static func locale(_ stored: String) -> Locale {
|
||||||
|
Locale(identifier: resolve(stored) == "de" ? "de_DE" : "en_US")
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func resolve(_ stored: String) -> String {
|
||||||
|
if stored == "de" || stored == "en" { return stored }
|
||||||
|
let preferred = Locale.preferredLanguages.first ?? "en"
|
||||||
|
return preferred.lowercased().hasPrefix("de") ? "de" : "en"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let strings: [String: [String: String]] = [
|
||||||
|
"de": [
|
||||||
|
"watch.title": "Calendarr",
|
||||||
|
"watch.calendars": "Kalender",
|
||||||
|
"watch.allday": "Ganztägig",
|
||||||
|
"watch.no_events": "Keine Termine",
|
||||||
|
"watch.today": "Heute",
|
||||||
|
"watch.tomorrow": "Morgen",
|
||||||
|
"watch.events_today": "Termine",
|
||||||
|
"watch.sign_in": "Auf dem iPhone anmelden",
|
||||||
|
"watch.update": "Watch-App aktualisieren",
|
||||||
|
"watch.waiting": "Warte auf Daten vom iPhone",
|
||||||
|
"watch.unreadable": "Daten nicht lesbar",
|
||||||
|
"watch.as_of": "Stand",
|
||||||
|
"watch.no_information": "Keine Daten nach",
|
||||||
|
],
|
||||||
|
"en": [
|
||||||
|
"watch.title": "Calendarr",
|
||||||
|
"watch.calendars": "Calendars",
|
||||||
|
"watch.allday": "All-day",
|
||||||
|
"watch.no_events": "No events",
|
||||||
|
"watch.today": "Today",
|
||||||
|
"watch.tomorrow": "Tomorrow",
|
||||||
|
"watch.events_today": "events",
|
||||||
|
"watch.sign_in": "Sign in on your iPhone",
|
||||||
|
"watch.update": "Update the watch app",
|
||||||
|
"watch.waiting": "Waiting for data from your iPhone",
|
||||||
|
"watch.unreadable": "Data unreadable",
|
||||||
|
"watch.as_of": "As of",
|
||||||
|
"watch.no_information": "No data after",
|
||||||
|
],
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# Apple Watch app and watch-face complications
|
# Apple Watch app and watch-face complications
|
||||||
|
|
||||||
Date: 2026-09-11
|
Date: 2026-09-11
|
||||||
Status: approved — ready for implementation planning
|
Status: implemented — pending on-device verification
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user