Archived
Compare commits
12
Commits
6a7afe0647
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31d7a0080e | ||
|
|
e5b3e549e8 | ||
|
|
465c465af2 | ||
|
|
6e91eaaaa2 | ||
|
|
1a1230a796 | ||
|
|
2c26be2ab5 | ||
|
|
938ca7ca98 | ||
|
|
b489f10c28 | ||
|
|
fdae680e02 | ||
|
|
e7fbe5c092 | ||
|
|
d0cff20be8 | ||
|
|
f9bc1965a5 |
@@ -10,13 +10,13 @@
|
||||
3934CF4630544C2E003958B8 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3934CF4530544C2E003958B8 /* WidgetKit.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, ); }; };
|
||||
39E1B0010001000000000001 /* CalendarrWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 3934CF1A30544C01003958B8 /* CalendarrWatch.app */; platformFilters = (ios, ); settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
AA9701000001000000000001 /* AppAuth in Frameworks */ = {isa = PBXBuildFile; productRef = AA9701000002000000000001 /* AppAuth */; };
|
||||
CA1E4D0A0002000000000001 /* CalendarrCore in Frameworks */ = {isa = PBXBuildFile; productRef = CA1E4D0A0001000000000001 /* CalendarrCore */; };
|
||||
CA1E4D0A0002000000000002 /* CalendarrCore in Frameworks */ = {isa = PBXBuildFile; productRef = CA1E4D0A0001000000000002 /* CalendarrCore */; };
|
||||
CA1E4D0A0002000000000003 /* CalendarrCore in Frameworks */ = {isa = PBXBuildFile; productRef = CA1E4D0A0001000000000003 /* 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, ); }; };
|
||||
39E1B0010001000000000001 /* CalendarrWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 3934CF1A30544C01003958B8 /* CalendarrWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -48,13 +48,6 @@
|
||||
remoteGlobalIDString = 3934CF4230544C2E003958B8;
|
||||
remoteInfo = CalendarrWatchComplications;
|
||||
};
|
||||
D0001A0DABCDEF0100AB5001 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = C0000301FB4E10100AB5001 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = D0001A01ABCDEF0100AB5001;
|
||||
remoteInfo = CalendarrWidgets;
|
||||
};
|
||||
39E1B0010003000000000001 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = C0000301FB4E10100AB5001 /* Project object */;
|
||||
@@ -62,6 +55,13 @@
|
||||
remoteGlobalIDString = 3934CF1930544C01003958B8;
|
||||
remoteInfo = CalendarrWatch;
|
||||
};
|
||||
D0001A0DABCDEF0100AB5001 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = C0000301FB4E10100AB5001 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = D0001A01ABCDEF0100AB5001;
|
||||
remoteInfo = CalendarrWidgets;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
@@ -76,17 +76,6 @@
|
||||
name = "Embed Foundation Extensions";
|
||||
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 */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -98,6 +87,17 @@
|
||||
name = "Embed Watch Content";
|
||||
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 */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
@@ -158,6 +158,11 @@
|
||||
path = CalendarrWatchComplications;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
39E1B0010005000000000001 /* CalendarrWatchShared */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = CalendarrWatchShared;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
C0000D01FC4E10100AB5001 /* Calendarr iOS */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = "Calendarr iOS";
|
||||
@@ -260,6 +265,7 @@
|
||||
3934CF4930544C2E003958B8 /* CalendarrWatchComplications */,
|
||||
3934CF4430544C2E003958B8 /* Frameworks */,
|
||||
C0000C01FB4E10100AB5001 /* Products */,
|
||||
39E1B0010005000000000001 /* CalendarrWatchShared */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -319,6 +325,7 @@
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
3934CF1B30544C01003958B8 /* CalendarrWatch */,
|
||||
39E1B0010005000000000001 /* CalendarrWatchShared */,
|
||||
);
|
||||
name = CalendarrWatch;
|
||||
packageProductDependencies = (
|
||||
@@ -388,6 +395,7 @@
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
3934CF4930544C2E003958B8 /* CalendarrWatchComplications */,
|
||||
39E1B0010005000000000001 /* CalendarrWatchShared */,
|
||||
);
|
||||
name = CalendarrWatchComplications;
|
||||
packageProductDependencies = (
|
||||
@@ -641,16 +649,19 @@
|
||||
target = 3934CF4230544C2E003958B8 /* CalendarrWatchComplications */;
|
||||
targetProxy = 3934CF5330544C2E003958B8 /* PBXContainerItemProxy */;
|
||||
};
|
||||
39E1B0010004000000000001 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
platformFilters = (
|
||||
ios,
|
||||
);
|
||||
target = 3934CF1930544C01003958B8 /* CalendarrWatch */;
|
||||
targetProxy = 39E1B0010003000000000001 /* PBXContainerItemProxy */;
|
||||
};
|
||||
D0001A0EABCDEF0100AB5001 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = D0001A01ABCDEF0100AB5001 /* CalendarrWidgets */;
|
||||
targetProxy = D0001A0DABCDEF0100AB5001 /* PBXContainerItemProxy */;
|
||||
};
|
||||
39E1B0010004000000000001 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 3934CF1930544C01003958B8 /* CalendarrWatch */;
|
||||
targetProxy = 39E1B0010003000000000001 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
@@ -709,20 +720,20 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = CalendarrWatch/CalendarrWatch.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 7;
|
||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = CalendarrWatch;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Calendarr;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = com.scarriffleservices.calendarr.ios;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
CODE_SIGN_ENTITLEMENTS = CalendarrWatch/CalendarrWatch.entitlements;
|
||||
MARKETING_VERSION = 3.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios.watchkitapp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = watchos;
|
||||
@@ -743,20 +754,20 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = CalendarrWatch/CalendarrWatch.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 7;
|
||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = CalendarrWatch;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Calendarr;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = com.scarriffleservices.calendarr.ios;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
CODE_SIGN_ENTITLEMENTS = CalendarrWatch/CalendarrWatch.entitlements;
|
||||
MARKETING_VERSION = 3.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios.watchkitapp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = watchos;
|
||||
@@ -777,10 +788,10 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 7;
|
||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 3.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.scarriffleservices.calendarr.ios.watchkitapp.CalendarrWatch-Watch-AppTests";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = watchos;
|
||||
@@ -800,10 +811,10 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 7;
|
||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 3.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.scarriffleservices.calendarr.ios.watchkitapp.CalendarrWatch-Watch-AppTests";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = watchos;
|
||||
@@ -822,10 +833,10 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 7;
|
||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 3.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.scarriffleservices.calendarr.ios.watchkitapp.CalendarrWatch-Watch-AppUITests";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = watchos;
|
||||
@@ -844,10 +855,10 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 7;
|
||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 3.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.scarriffleservices.calendarr.ios.watchkitapp.CalendarrWatch-Watch-AppUITests";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = watchos;
|
||||
@@ -867,12 +878,13 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
CODE_SIGN_ENTITLEMENTS = CalendarrWatchComplications/CalendarrWatchComplications.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 7;
|
||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = CalendarrWatchComplications/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = CalendarrWatch;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Calendarr;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
@@ -880,8 +892,7 @@
|
||||
"@executable_path/../../Frameworks",
|
||||
"@executable_path/../../../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
CODE_SIGN_ENTITLEMENTS = CalendarrWatchComplications/CalendarrWatchComplications.entitlements;
|
||||
MARKETING_VERSION = 3.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios.watchkitapp.complications;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = watchos;
|
||||
@@ -901,12 +912,13 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
CODE_SIGN_ENTITLEMENTS = CalendarrWatchComplications/CalendarrWatchComplications.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 7;
|
||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = CalendarrWatchComplications/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = CalendarrWatch;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Calendarr;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
@@ -914,8 +926,7 @@
|
||||
"@executable_path/../../Frameworks",
|
||||
"@executable_path/../../../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
CODE_SIGN_ENTITLEMENTS = CalendarrWatchComplications/CalendarrWatchComplications.entitlements;
|
||||
MARKETING_VERSION = 3.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios.watchkitapp.complications;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = watchos;
|
||||
@@ -1079,11 +1090,13 @@
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDarkContent;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 3.6;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -1100,7 +1113,7 @@
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
"TARGETED_DEVICE_FAMILY[sdk=macosx*]" = 2;
|
||||
};
|
||||
name = Debug;
|
||||
@@ -1129,11 +1142,13 @@
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDarkContent;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 3.6;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -1150,7 +1165,7 @@
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
"TARGETED_DEVICE_FAMILY[sdk=macosx*]" = 2;
|
||||
};
|
||||
name = Release;
|
||||
@@ -1185,7 +1200,7 @@
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
"TARGETED_DEVICE_FAMILY[sdk=macosx*]" = 2;
|
||||
};
|
||||
name = Debug;
|
||||
@@ -1219,7 +1234,7 @@
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
"TARGETED_DEVICE_FAMILY[sdk=macosx*]" = 2;
|
||||
};
|
||||
name = Release;
|
||||
@@ -1329,6 +1344,10 @@
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = CalendarrCore;
|
||||
};
|
||||
CA1E4D0A0001000000000002 /* CalendarrCore */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = CalendarrCore;
|
||||
};
|
||||
CA1E4D0A0001000000000003 /* CalendarrCore */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = CalendarrCore;
|
||||
@@ -1337,10 +1356,6 @@
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = CalendarrCore;
|
||||
};
|
||||
CA1E4D0A0001000000000002 /* CalendarrCore */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = CalendarrCore;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = C0000301FB4E10100AB5001 /* Project object */;
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2700"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
buildArchitectures = "Automatic">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "3934CF1930544C01003958B8"
|
||||
BuildableName = "CalendarrWatch.app"
|
||||
ReferencedContainer = "container:Calendarr iOS.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "C0000A01FB4E10100AB5001"
|
||||
BuildableName = "Calendarr iOS.app"
|
||||
ReferencedContainer = "container:Calendarr iOS.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "3934CF2530544C02003958B8"
|
||||
BuildableName = "CalendarrWatchTests.xctest"
|
||||
ReferencedContainer = "container:Calendarr iOS.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "3934CF2F30544C02003958B8"
|
||||
BuildableName = "CalendarrWatchUITests.xctest"
|
||||
ReferencedContainer = "container:Calendarr iOS.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES"
|
||||
queueDebuggingEnabled = "No">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "3934CF1930544C01003958B8"
|
||||
BuildableName = "CalendarrWatch.app"
|
||||
ReferencedContainer = "container:Calendarr iOS.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "3934CF1930544C01003958B8"
|
||||
BuildableName = "CalendarrWatch.app"
|
||||
ReferencedContainer = "container:Calendarr iOS.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,122 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2700"
|
||||
wasCreatedForAppExtension = "YES"
|
||||
version = "2.0">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
buildArchitectures = "Automatic">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "3934CF4230544C2E003958B8"
|
||||
BuildableName = "CalendarrWatchComplications.appex"
|
||||
ReferencedContainer = "container:Calendarr iOS.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "C0000A01FB4E10100AB5001"
|
||||
BuildableName = "Calendarr iOS.app"
|
||||
ReferencedContainer = "container:Calendarr iOS.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "3934CF1930544C01003958B8"
|
||||
BuildableName = "CalendarrWatch.app"
|
||||
ReferencedContainer = "container:Calendarr iOS.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = ""
|
||||
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
|
||||
launchStyle = "0"
|
||||
askForAppToLaunch = "Yes"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES"
|
||||
launchAutomaticallySubstyle = "2"
|
||||
queueDebuggingEnabled = "No">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "C0000A01FB4E10100AB5001"
|
||||
BuildableName = "Calendarr iOS.app"
|
||||
ReferencedContainer = "container:Calendarr iOS.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<EnvironmentVariables>
|
||||
<EnvironmentVariable
|
||||
key = "_XCWidgetKind"
|
||||
value = ""
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
<EnvironmentVariable
|
||||
key = "_XCWidgetDefaultView"
|
||||
value = "timeline"
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
<EnvironmentVariable
|
||||
key = "_XCWidgetFamily"
|
||||
value = "systemMedium"
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "3934CF4230544C2E003958B8"
|
||||
BuildableName = "CalendarrWatchComplications.appex"
|
||||
ReferencedContainer = "container:Calendarr iOS.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,111 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2700"
|
||||
wasCreatedForAppExtension = "YES"
|
||||
version = "2.0">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
buildArchitectures = "Automatic">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "D0001A01ABCDEF0100AB5001"
|
||||
BuildableName = "CalendarrWidgets.appex"
|
||||
ReferencedContainer = "container:Calendarr iOS.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "C0000A01FB4E10100AB5001"
|
||||
BuildableName = "Calendarr iOS.app"
|
||||
ReferencedContainer = "container:Calendarr iOS.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = ""
|
||||
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
|
||||
launchStyle = "0"
|
||||
askForAppToLaunch = "Yes"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES"
|
||||
launchAutomaticallySubstyle = "2"
|
||||
queueDebuggingEnabled = "No">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "C0000A01FB4E10100AB5001"
|
||||
BuildableName = "Calendarr iOS.app"
|
||||
ReferencedContainer = "container:Calendarr iOS.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<EnvironmentVariables>
|
||||
<EnvironmentVariable
|
||||
key = "_XCWidgetKind"
|
||||
value = ""
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
<EnvironmentVariable
|
||||
key = "_XCWidgetDefaultView"
|
||||
value = "timeline"
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
<EnvironmentVariable
|
||||
key = "_XCWidgetFamily"
|
||||
value = "systemMedium"
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
askForAppToLaunch = "Yes"
|
||||
launchAutomaticallySubstyle = "2">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "C0000A01FB4E10100AB5001"
|
||||
BuildableName = "Calendarr iOS.app"
|
||||
ReferencedContainer = "container:Calendarr iOS.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -91,11 +91,32 @@ struct CalEvent: Identifiable, Hashable {
|
||||
else if let n = json["id"] as? Int { id = String(n) }
|
||||
else { return nil }
|
||||
|
||||
let isAllDay = json["allDay"] as? Bool ?? false
|
||||
let startDate = parseDate(startStr, allDay: isAllDay)
|
||||
let endDate = parseDate(endStr, allDay: isAllDay)
|
||||
// A date-only string is an all-day event whether or not the flag says
|
||||
// so — `parseDate` already treats it that way. Trusting the flag alone
|
||||
// is what put "00:00" in front of birthdays everywhere they are shown.
|
||||
let startIsDateOnly: Bool = {
|
||||
let clean = startStr.trimmingCharacters(in: .whitespaces)
|
||||
return clean.count == 10 && !clean.contains("T")
|
||||
}()
|
||||
let declaredAllDay = (json["allDay"] as? Bool ?? false) || startIsDateOnly
|
||||
let startDate = parseDate(startStr, allDay: declaredAllDay)
|
||||
let endDate = parseDate(endStr, allDay: declaredAllDay)
|
||||
guard let s = startDate, let e = endDate else { return nil }
|
||||
|
||||
// An event running midnight to midnight across one or more whole days
|
||||
// is all-day whatever the payload claims. Without this, a six-day
|
||||
// holiday sent without the flag renders as "00:00 – 00:00" — a time
|
||||
// range that is technically true and tells the reader nothing.
|
||||
let cal = Calendar.current
|
||||
// Requiring the end to land exactly on midnight too was too strict: a
|
||||
// holiday stored as 16th 00:00 to 21st 01:00 slipped through and showed
|
||||
// up as a one-hour slot. Starting on a day boundary and running into a
|
||||
// later day is enough.
|
||||
let spansWholeDays = e > s
|
||||
&& cal.startOfDay(for: s) == s
|
||||
&& cal.startOfDay(for: e) > cal.startOfDay(for: s)
|
||||
let isAllDay = declaredAllDay || spansWholeDays
|
||||
|
||||
return CalEvent(
|
||||
id: id,
|
||||
url: json["url"] as? String ?? "",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AppIcon-1024.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "watchos",
|
||||
"size" : "1024x1024"
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
//
|
||||
// CalendarrWatchApp.swift
|
||||
// CalendarrWatch Watch App
|
||||
//
|
||||
// Created by Guido Schmit on 11.09.2026.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@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 {
|
||||
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,89 @@
|
||||
import SwiftUI
|
||||
import CalendarrCore
|
||||
|
||||
/// One event: a colour bar for the calendar, the time range, the title.
|
||||
struct WatchEventRow: View {
|
||||
let event: SnapshotEvent
|
||||
let language: String
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 7) {
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(Color(watchHex: event.colorHex))
|
||||
.frame(width: 3)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(WatchEventFormat.timeRange(event, language: language))
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(event.title)
|
||||
.font(.system(size: 14))
|
||||
.lineLimit(2)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension Color {
|
||||
/// Local copy of the hex initialiser: `CalendarrCore` is Foundation-only by
|
||||
/// design, so colour parsing cannot live there.
|
||||
init(watchHex hex: String) {
|
||||
let cleaned = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
||||
var value: UInt64 = 0
|
||||
Scanner(string: cleaned).scanHexInt64(&value)
|
||||
guard cleaned.count == 6 else { self = .gray; return }
|
||||
self.init(red: Double((value >> 16) & 0xFF) / 255,
|
||||
green: Double((value >> 8) & 0xFF) / 255,
|
||||
blue: Double(value & 0xFF) / 255)
|
||||
}
|
||||
}
|
||||
|
||||
enum WatchEventFormat {
|
||||
static func timeRange(_ event: SnapshotEvent, language: String,
|
||||
calendar: Calendar = Calendar(identifier: .gregorian)) -> String {
|
||||
if event.isAllDay { return WatchL10n.t("watch.allday", language) }
|
||||
|
||||
let time = DateFormatter()
|
||||
time.locale = WatchL10n.locale(language)
|
||||
time.dateFormat = "HH:mm"
|
||||
|
||||
let start = time.string(from: event.start)
|
||||
guard event.end > event.start else { return start }
|
||||
|
||||
// Without the weekday, an event running from Wednesday to Monday reads
|
||||
// as a one-hour slot: "00:00 – 01:00" is true and useless.
|
||||
if calendar.startOfDay(for: event.end) > calendar.startOfDay(for: event.start) {
|
||||
let day = DateFormatter()
|
||||
day.locale = WatchL10n.locale(language)
|
||||
day.setLocalizedDateFormatFromTemplate("EEE")
|
||||
return "\(day.string(from: event.start)) \(start) – "
|
||||
+ "\(day.string(from: event.end)) \(time.string(from: event.end))"
|
||||
}
|
||||
return "\(start) – \(time.string(from: event.end))"
|
||||
}
|
||||
|
||||
static func time(_ date: Date, language: String) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = WatchL10n.locale(language)
|
||||
formatter.dateFormat = "HH:mm"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
/// "Heute", "Morgen", else "Do 11. Sep".
|
||||
static func dayHeader(_ day: Date, language: String,
|
||||
calendar: Calendar, now: Date = Date()) -> String {
|
||||
if calendar.isDate(day, inSameDayAs: now) {
|
||||
return WatchL10n.t("watch.today", language)
|
||||
}
|
||||
if let tomorrow = calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: now)),
|
||||
calendar.isDate(day, inSameDayAs: tomorrow) {
|
||||
return WatchL10n.t("watch.tomorrow", language)
|
||||
}
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = WatchL10n.locale(language)
|
||||
formatter.setLocalizedDateFormatFromTemplate("EEE d MMM")
|
||||
return formatter.string(from: day)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import SwiftUI
|
||||
import CalendarrCore
|
||||
|
||||
/// Every reason there is nothing to show, named. "Never received", "signed out"
|
||||
/// and "genuinely empty" call for different sentences, and showing the wrong one
|
||||
/// sends the user looking in the wrong place.
|
||||
struct WatchStatusView: View {
|
||||
let result: SnapshotReadResult
|
||||
let language: String
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: symbol)
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(message)
|
||||
.font(.footnote)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 6)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private var symbol: String {
|
||||
switch result {
|
||||
case .ok: return "calendar"
|
||||
case .loggedOut: return "person.crop.circle.badge.xmark"
|
||||
case .neverWritten: return "iphone.and.arrow.forward"
|
||||
case .incompatible: return "arrow.down.circle"
|
||||
case .unreadable: return "exclamationmark.triangle"
|
||||
}
|
||||
}
|
||||
|
||||
private var message: String {
|
||||
switch result {
|
||||
case .ok: return WatchL10n.t("watch.no_events", language)
|
||||
case .loggedOut: return WatchL10n.t("watch.sign_in", language)
|
||||
case .neverWritten: return WatchL10n.t("watch.waiting", language)
|
||||
case .incompatible: return WatchL10n.t("watch.update", language)
|
||||
case .unreadable: return WatchL10n.t("watch.unreadable", language)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import Foundation
|
||||
import WidgetKit
|
||||
import CalendarrCore
|
||||
|
||||
/// What the watch views read. Holds the last received snapshot, the calendar
|
||||
/// list, and this watch's filter.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class WatchSnapshotModel {
|
||||
private(set) var result: SnapshotReadResult = .neverWritten
|
||||
private(set) var calendars: [SnapshotCalendar] = []
|
||||
private(set) var hiddenKeys: Set<String> = WatchFilterStore.load()
|
||||
|
||||
private let store = SnapshotStore()
|
||||
private var changeToken: SnapshotChangeNotifier.ObservationToken?
|
||||
|
||||
init() {
|
||||
reload()
|
||||
// The receiver writes through SnapshotStore, which broadcasts. Listening
|
||||
// here means the UI does not have to be notified separately.
|
||||
changeToken = SnapshotChangeNotifier.observe { [weak self] in
|
||||
Task { @MainActor in self?.reload() }
|
||||
}
|
||||
}
|
||||
|
||||
func reload() {
|
||||
result = store.read()
|
||||
calendars = store.readCalendars()
|
||||
}
|
||||
|
||||
/// The snapshot with this watch's filter applied, or nil when there is
|
||||
/// nothing usable to show.
|
||||
var visible: CalendarrSnapshot? {
|
||||
result.snapshot?.filtered(excludingCalendarKeys: hiddenKeys)
|
||||
}
|
||||
|
||||
var language: String { result.snapshot?.language ?? "system" }
|
||||
|
||||
func isHidden(_ key: String) -> Bool { hiddenKeys.contains(key) }
|
||||
|
||||
/// The display name for an event's calendar. Events carry only a
|
||||
/// `calendarKey`; the names travel separately, in the calendar list.
|
||||
func calendarName(for key: String) -> String? {
|
||||
calendars.first { $0.id == key }?.name
|
||||
}
|
||||
|
||||
func setHidden(_ key: String, _ hidden: Bool) {
|
||||
if hidden { hiddenKeys.insert(key) } else { hiddenKeys.remove(key) }
|
||||
WatchFilterStore.save(hiddenKeys)
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import Foundation
|
||||
import WatchConnectivity
|
||||
import WidgetKit
|
||||
@_spi(Writer) import CalendarrCore
|
||||
|
||||
/// Takes what the phone sends and writes it into this watch's own App Group
|
||||
/// container, using the same `SnapshotStore` the phone writes with. One wire
|
||||
/// format, one reader, no drift.
|
||||
///
|
||||
/// Only the app runs a `WCSession`; a widget extension cannot. The complications
|
||||
/// always read what this wrote.
|
||||
@MainActor
|
||||
final class WatchSnapshotReceiver: NSObject {
|
||||
static let shared = WatchSnapshotReceiver()
|
||||
|
||||
private let store = SnapshotStore()
|
||||
private let sessions = SharedSessionStore()
|
||||
|
||||
/// Shown in the agenda footer, because a silent failure here looks exactly
|
||||
/// like an empty calendar.
|
||||
private(set) var lastError: String?
|
||||
|
||||
func activate() {
|
||||
guard WCSession.isSupported() else { return }
|
||||
WCSession.default.delegate = self
|
||||
WCSession.default.activate()
|
||||
}
|
||||
|
||||
/// Ask the phone for the current state. Covers "the app is open in front of
|
||||
/// me and looks stale"; the other two paths are push-driven.
|
||||
func requestRefresh() {
|
||||
let session = WCSession.default
|
||||
guard session.activationState == .activated, session.isReachable else { return }
|
||||
|
||||
session.sendMessage(["request": "snapshot"],
|
||||
replyHandler: { reply in
|
||||
Task { @MainActor in self.apply(reply) }
|
||||
},
|
||||
errorHandler: { error in
|
||||
Task { @MainActor in self.lastError = error.localizedDescription }
|
||||
})
|
||||
}
|
||||
|
||||
func apply(_ dict: [String: Any]) {
|
||||
guard !dict.isEmpty else { return }
|
||||
do {
|
||||
let payload = try WatchTransportPayload.decode(dict)
|
||||
// This write posts SnapshotChangeNotifier, which is what the read
|
||||
// model listens for — so the UI updates without being told twice.
|
||||
try store.write(payload.snapshot)
|
||||
try store.writeCalendars(payload.calendars)
|
||||
if let session = payload.session { try sessions.write(session) }
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
lastError = nil
|
||||
} catch {
|
||||
lastError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension WatchSnapshotReceiver: WCSessionDelegate {
|
||||
nonisolated func session(_ session: WCSession,
|
||||
activationDidCompleteWith state: WCSessionActivationState,
|
||||
error: Error?) {
|
||||
if let error {
|
||||
Task { @MainActor in self.lastError = error.localizedDescription }
|
||||
}
|
||||
}
|
||||
|
||||
/// The main path: latest state only, delivered when this app launches or wakes.
|
||||
nonisolated func session(_ session: WCSession,
|
||||
didReceiveApplicationContext applicationContext: [String: Any]) {
|
||||
Task { @MainActor in self.apply(applicationContext) }
|
||||
}
|
||||
|
||||
/// The complication push, which is the only path that wakes this app while
|
||||
/// it is not running.
|
||||
nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
|
||||
Task { @MainActor in self.apply(userInfo) }
|
||||
}
|
||||
}
|
||||
@@ -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,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])
|
||||
}
|
||||
}
|
||||
@@ -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,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,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)"
|
||||
}
|
||||
}
|
||||
@@ -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,57 @@
|
||||
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.now": "jetzt",
|
||||
"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.now": "now",
|
||||
"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",
|
||||
],
|
||||
]
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Status:** All 17 tasks implemented and build-verified as of 2026-09-11. On-device behaviour is still unverified — see the handoff list in Task 17.
|
||||
|
||||
**Goal:** Ship a read-only Apple Watch app plus eight watch-face complications that show the next Calendarr appointment, fed from the iPhone over WatchConnectivity.
|
||||
|
||||
**Architecture:** The watch is a read-only consumer of the phone's snapshot. Every piece of logic that can be tested lives in `CalendarrCore` (testable with `swift test` on the dev machine); the watch targets hold only thin SwiftUI and WCSession glue, which can only be build-verified here. The phone trims its 42-day snapshot to a 14-day watch payload and sends it; the watch writes it into its *own* App Group container through the same `SnapshotStore`, so there is one wire format and one reader.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Apple Watch app and watch-face complications
|
||||
|
||||
Date: 2026-09-11
|
||||
Status: approved — ready for implementation planning
|
||||
Status: implemented — pending on-device verification
|
||||
|
||||
## Goal
|
||||
|
||||
|
||||
Reference in New Issue
Block a user