Compare commits

...
12 Commits
Author SHA1 Message Date
ScarriffleandClaude Opus 5 31d7a0080e Ship the app for iPad, and keep the watch out of the Mac build
Device family becomes 1,2 for the app and the widget extension, and the iPad
gets all four interface orientations. That is not a preference: App Store
Connect rejects an iPad-capable bundle that declares fewer (error 90474),
because iPad multitasking can present the app at any size. The iPhone key stays
portrait-only. Mac Catalyst still resolves to family 2 alone, which is what
84f5c00 set up and what this had to avoid breaking.

Building for Catalyst also turned up a regression from the watch work: the
Embed Watch Content phase carried watchOS content into a macOS build, which
fails outright. It went unnoticed because no Catalyst build was run after the
watch targets were added. Both the copy and its target dependency are now
filtered to iOS.

Verified on all three destinations: iOS carries Watch/CalendarrWatch.app with
its complications, Catalyst carries no Watch folder, and both report the right
device family.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 14:57:02 +02:00
ScarriffleandClaude Opus 5 e5b3e549e8 Catch multi-day events that do not end on a day boundary
Requiring both ends to sit exactly on midnight was too strict. A holiday stored
as the 16th at 00:00 through the 21st at 01:00 slipped through and rendered as
a one-hour slot. Starting on a day boundary and running into a later day is
enough to call it all-day.

The agenda row now names the weekday on both sides when an event crosses a day.
Showing only "00:00 – 01:00" for something that runs from Wednesday to Monday
is true and useless.

The timezone handling above this is deliberately untouched: timed events show
the right time, so the UTC assumption for timestamps without an offset is not
what produced the odd hour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 14:52:52 +02:00
ScarriffleandClaude Opus 5 465c465af2 Treat a whole-day span as all-day, whatever the payload claims
The previous rule only caught date-only strings. A multi-day event sent with
full timestamps and no allDay flag still arrived as a timed event, so a holiday
running from the 16th to the 21st rendered as a one-hour slot at midnight.

Now an event that starts and ends on a day boundary and covers at least one
whole day is all-day regardless. That is a statement about what the data means,
not about how any one client formats it, which is why it lives in the parser
and not in a view.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 14:49:57 +02:00
ScarriffleandClaude Opus 5 6e91eaaaa2 Read all-day events correctly, and make the date circle say a date
Three things, one of which is not a watch problem at all.

parseDate already treated a date-only string as all-day, but isAllDay came from
the JSON flag alone. An event sent as "2026-09-12" with no flag therefore
landed at midnight with isAllDay false, and every surface that formats a time
put "00:00" in front of it — the widgets as much as the watch. Now derived from
both.

The date complication showed the day number and nothing else, because
.accessoryCircularCapacity renders only currentValueLabel and the month sat in
the label closure, where it is never drawn. It now stacks weekday over day
number, which is what makes it read as a date rather than a number.

The ring only appears while an appointment is running, and then shows how far
through it we are. An empty track around an idle day said nothing, so there is
no Gauge at all in that case. All-day events do not count as running: one would
hold the ring at an arbitrary fraction for twenty-four hours.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 14:10:21 +02:00
ScarriffleandClaude Opus 5 1a1230a796 Put the next appointment on the Infograph bezel
The position asked for is the bezel slot: a circular complication plus a label
that arcs across the top, where the tick marks otherwise are. ClockKit called
that family graphicBezel; in WidgetKit it is an accessoryCircular widget with
.widgetLabel attached. Nothing was attached, so that arc stayed empty while
Apple's Calendar fills it — which is the difference that was visible on the
watch and invisible in every check made here.

All three circular kinds now carry it. Faces without a bezel slot ignore the
label, so there is no cost to always providing it.

cornerLabel becomes eventLabel: the same text serves the corner edge and the
bezel arc, and the old name claimed otherwise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 20:19:21 +02:00
ScarriffleandClaude Opus 5 2c26be2ab5 Add a corner complication with the appointment's name as the value
Requested shape: the event title as the text, the time small alongside. This
inverts how accessoryCorner is built — the curved bezel is where a corner has
room for long text and the inner area holds three or four characters — so the
title shrinks and truncates. Kept alongside WatchCornerTime, which carries the
same information with the roles the other way round, so both can be compared on
the face rather than argued about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 18:53:17 +02:00
ScarriffleandClaude Opus 5 938ca7ca98 Render the circular complications as edge-to-edge closed gauges
Date and "events today" filled their slot with AccessoryWidgetBackground and
centred text, which reads as a small element with space around it. Both are now
Gauges in .accessoryCircularCapacity — the WidgetKit equivalent of ClockKit's
ClosedGauge templates, which no longer exist and cannot be reached from a
WidgetKit complication at all.

The rings carry meaning rather than decorating: the date's drains through the
day, and the count's drains as today's appointments are worked through. Both
drain, matching the countdown rather than inventing a second direction. An
empty day leaves the ring empty instead of full, so it does not read as an
untouched one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 18:38:54 +02:00
ScarriffleandClaude Opus 5 b489f10c28 Make the countdown narrow enough for the slot it sits in
Text(style: .timer) renders 1:23:45 once more than an hour out. An
accessoryCorner has room for three or four characters beside its curved label,
and a circular gauge for about the same, so the live timer overlapped what it
sat next to.

Replaced with a compact value — "23m", "2h", "3d" — recomputed per timeline
entry, which is why the timeline now carries a mark every minute for the first
hour. Both corner kinds and the gauge also get lineLimit(1) and a minimum scale
factor, so an unexpectedly wide value shrinks instead of colliding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 18:33:38 +02:00
ScarriffleandClaude Opus 5 fdae680e02 Match the watch targets' version to the iOS app
A companion watch app has to carry the same CFBundleShortVersionString and
CFBundleVersion as the app it ships inside. The template left the watch at
1.0 (1) against the app's 3.5 (7), and watchOS answers that by refusing the
install without reporting anything: the Install button simply comes back.

Same class of drift as c9bd42a, which unified the app and widget versions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 17:21:56 +02:00
ScarriffleandClaude Opus 5 e7fbe5c092 Give the watch app an icon and the right name
The target template left AppIcon.appiconset with an entry carrying no filename
— an empty slot. watchOS refuses to install an app without a valid icon, which
is why the Watch app's Install button kept coming back after ten seconds with
no error anywhere. Reuses the iOS 1024 icon, which is already alpha-free.

Both watch targets also called themselves "CalendarrWatch", which is what would
have appeared under the icon and in the complication gallery.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 17:16:08 +02:00
ScarriffleandClaude Opus 5 d0cff20be8 Note the watch plan as implemented
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 17:08:44 +02:00
ScarriffleandClaude Opus 5 f9bc1965a5 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>
2026-09-11 17:08:27 +02:00
30 changed files with 1780 additions and 250 deletions
+71 -56
View File
@@ -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 */; platformFilters = (ios, ); 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,19 @@
target = 3934CF4230544C2E003958B8 /* CalendarrWatchComplications */; target = 3934CF4230544C2E003958B8 /* CalendarrWatchComplications */;
targetProxy = 3934CF5330544C2E003958B8 /* PBXContainerItemProxy */; targetProxy = 3934CF5330544C2E003958B8 /* PBXContainerItemProxy */;
}; };
39E1B0010004000000000001 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
platformFilters = (
ios,
);
target = 3934CF1930544C01003958B8 /* CalendarrWatch */;
targetProxy = 39E1B0010003000000000001 /* PBXContainerItemProxy */;
};
D0001A0EABCDEF0100AB5001 /* PBXTargetDependency */ = { D0001A0EABCDEF0100AB5001 /* PBXTargetDependency */ = {
isa = PBXTargetDependency; isa = PBXTargetDependency;
target = D0001A01ABCDEF0100AB5001 /* CalendarrWidgets */; target = D0001A01ABCDEF0100AB5001 /* CalendarrWidgets */;
targetProxy = D0001A0DABCDEF0100AB5001 /* PBXContainerItemProxy */; targetProxy = D0001A0DABCDEF0100AB5001 /* PBXContainerItemProxy */;
}; };
39E1B0010004000000000001 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 3934CF1930544C01003958B8 /* CalendarrWatch */;
targetProxy = 39E1B0010003000000000001 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */ /* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */ /* Begin XCBuildConfiguration section */
@@ -709,20 +720,20 @@
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 = 7;
DEVELOPMENT_TEAM = PP34X97WS3; DEVELOPMENT_TEAM = PP34X97WS3;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = CalendarrWatch; INFOPLIST_KEY_CFBundleDisplayName = Calendarr;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = com.scarriffleservices.calendarr.ios; INFOPLIST_KEY_WKCompanionAppBundleIdentifier = com.scarriffleservices.calendarr.ios;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 3.5;
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,20 +754,20 @@
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 = 7;
DEVELOPMENT_TEAM = PP34X97WS3; DEVELOPMENT_TEAM = PP34X97WS3;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = CalendarrWatch; INFOPLIST_KEY_CFBundleDisplayName = Calendarr;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = com.scarriffleservices.calendarr.ios; INFOPLIST_KEY_WKCompanionAppBundleIdentifier = com.scarriffleservices.calendarr.ios;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 3.5;
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;
@@ -777,10 +788,10 @@
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = PP34X97WS3; DEVELOPMENT_TEAM = PP34X97WS3;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 3.5;
PRODUCT_BUNDLE_IDENTIFIER = "com.scarriffleservices.calendarr.ios.watchkitapp.CalendarrWatch-Watch-AppTests"; PRODUCT_BUNDLE_IDENTIFIER = "com.scarriffleservices.calendarr.ios.watchkitapp.CalendarrWatch-Watch-AppTests";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos; SDKROOT = watchos;
@@ -800,10 +811,10 @@
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = PP34X97WS3; DEVELOPMENT_TEAM = PP34X97WS3;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 3.5;
PRODUCT_BUNDLE_IDENTIFIER = "com.scarriffleservices.calendarr.ios.watchkitapp.CalendarrWatch-Watch-AppTests"; PRODUCT_BUNDLE_IDENTIFIER = "com.scarriffleservices.calendarr.ios.watchkitapp.CalendarrWatch-Watch-AppTests";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos; SDKROOT = watchos;
@@ -822,10 +833,10 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = PP34X97WS3; DEVELOPMENT_TEAM = PP34X97WS3;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 3.5;
PRODUCT_BUNDLE_IDENTIFIER = "com.scarriffleservices.calendarr.ios.watchkitapp.CalendarrWatch-Watch-AppUITests"; PRODUCT_BUNDLE_IDENTIFIER = "com.scarriffleservices.calendarr.ios.watchkitapp.CalendarrWatch-Watch-AppUITests";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos; SDKROOT = watchos;
@@ -844,10 +855,10 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = PP34X97WS3; DEVELOPMENT_TEAM = PP34X97WS3;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 3.5;
PRODUCT_BUNDLE_IDENTIFIER = "com.scarriffleservices.calendarr.ios.watchkitapp.CalendarrWatch-Watch-AppUITests"; PRODUCT_BUNDLE_IDENTIFIER = "com.scarriffleservices.calendarr.ios.watchkitapp.CalendarrWatch-Watch-AppUITests";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos; SDKROOT = watchos;
@@ -867,12 +878,13 @@
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 = 7;
DEVELOPMENT_TEAM = PP34X97WS3; DEVELOPMENT_TEAM = PP34X97WS3;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = CalendarrWatchComplications/Info.plist; INFOPLIST_FILE = CalendarrWatchComplications/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = CalendarrWatch; INFOPLIST_KEY_CFBundleDisplayName = Calendarr;
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
@@ -880,8 +892,7 @@
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
"@executable_path/../../../../Frameworks", "@executable_path/../../../../Frameworks",
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 3.5;
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,12 +912,13 @@
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 = 7;
DEVELOPMENT_TEAM = PP34X97WS3; DEVELOPMENT_TEAM = PP34X97WS3;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = CalendarrWatchComplications/Info.plist; INFOPLIST_FILE = CalendarrWatchComplications/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = CalendarrWatch; INFOPLIST_KEY_CFBundleDisplayName = Calendarr;
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
@@ -914,8 +926,7 @@
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
"@executable_path/../../../../Frameworks", "@executable_path/../../../../Frameworks",
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 3.5;
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;
@@ -1079,11 +1090,13 @@
INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDarkContent; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDarkContent;
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 26.0; IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 3.6;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"$(inherited)", "$(inherited)",
"-ObjC", "-ObjC",
@@ -1100,7 +1113,7 @@
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1; TARGETED_DEVICE_FAMILY = "1,2";
"TARGETED_DEVICE_FAMILY[sdk=macosx*]" = 2; "TARGETED_DEVICE_FAMILY[sdk=macosx*]" = 2;
}; };
name = Debug; name = Debug;
@@ -1129,11 +1142,13 @@
INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDarkContent; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDarkContent;
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 26.0; IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 3.6;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"$(inherited)", "$(inherited)",
"-ObjC", "-ObjC",
@@ -1150,7 +1165,7 @@
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1; TARGETED_DEVICE_FAMILY = "1,2";
"TARGETED_DEVICE_FAMILY[sdk=macosx*]" = 2; "TARGETED_DEVICE_FAMILY[sdk=macosx*]" = 2;
}; };
name = Release; name = Release;
@@ -1185,7 +1200,7 @@
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1; TARGETED_DEVICE_FAMILY = "1,2";
"TARGETED_DEVICE_FAMILY[sdk=macosx*]" = 2; "TARGETED_DEVICE_FAMILY[sdk=macosx*]" = 2;
}; };
name = Debug; name = Debug;
@@ -1219,7 +1234,7 @@
SWIFT_COMPILATION_MODE = wholemodule; SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1; TARGETED_DEVICE_FAMILY = "1,2";
"TARGETED_DEVICE_FAMILY[sdk=macosx*]" = 2; "TARGETED_DEVICE_FAMILY[sdk=macosx*]" = 2;
}; };
name = Release; name = Release;
@@ -1329,6 +1344,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 +1356,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 */;
@@ -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>
+24 -3
View File
@@ -91,11 +91,32 @@ struct CalEvent: Identifiable, Hashable {
else if let n = json["id"] as? Int { id = String(n) } else if let n = json["id"] as? Int { id = String(n) }
else { return nil } else { return nil }
let isAllDay = json["allDay"] as? Bool ?? false // A date-only string is an all-day event whether or not the flag says
let startDate = parseDate(startStr, allDay: isAllDay) // so — `parseDate` already treats it that way. Trusting the flag alone
let endDate = parseDate(endStr, allDay: isAllDay) // 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 } 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( return CalEvent(
id: id, id: id,
url: json["url"] as? String ?? "", url: json["url"] as? String ?? "",
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

@@ -1,6 +1,7 @@
{ {
"images" : [ "images" : [
{ {
"filename" : "AppIcon-1024.png",
"idiom" : "universal", "idiom" : "universal",
"platform" : "watchos", "platform" : "watchos",
"size" : "1024x1024" "size" : "1024x1024"
+11 -9
View File
@@ -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)
} }
} }
} }
-24
View File
@@ -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()
}
+114
View File
@@ -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)
}
}
+89
View File
@@ -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)
}
}
}
+52
View File
@@ -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)
}
}
+57
View File
@@ -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. > **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. **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. **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 # 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