Compare commits

..
19 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
ScarriffleandClaude Opus 5 6a7afe0647 Drop -sdk from the plan's build commands
-sdk overrides SDKROOT for the whole dependency graph, so the watch app was
built against the iOS SDK. It still succeeded, and produced a watch app with no
WKApplication key — the kind of failure that only shows up on the device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:56:37 +02:00
ScarriffleandClaude Opus 5 9394a80ea2 Add the watchOS app and complication targets
The companion dropdown in Xcode's target template came up empty, which left
both bundle identifiers starting with a bare dot and
WKCompanionAppBundleIdentifier as an empty string. Both are set explicitly
here, along with the App Group entitlements and watchOS 26 as the floor.

The iOS app had no Embed Watch Content phase, so the watch app would have built
and never shipped. Added, with the matching target dependency: the product now
lands at Calendarr iOS.app/Watch/CalendarrWatch.app with the complications
inside it.

Folders follow what they contain: CalendarrWatch is the app,
CalendarrWatchComplications the extension. Xcode had them the other way round.

Package.resolved is now tracked. It pins AppAuth, whose version drift is what
produced the two compile errors fixed in 75a6a5b.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:56:26 +02:00
ScarriffleandClaude Opus 5 ddda0ecd3e Correct the plan's Info.plist step to what actually works
Xcode exposes neither UIBackgroundModes nor BGTaskSchedulerPermittedIdentifiers
as an INFOPLIST_KEY_ build setting, and a partial plist inside a synchronized
group collides with the processed one. Both found by building, not by reading.

Also records that synchronized groups pick up new source files on their own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:41:07 +02:00
ScarriffleandClaude Opus 5 b28c5a0502 Send the trimmed snapshot to the watch over WatchConnectivity
App Groups are per-device, so the watch cannot read the file the phone writes
and has to be handed the data. Three delivery paths: application context for
the latest state, a complication push only when the next appointment actually
changed (the budget is small), and a reply to the watch asking directly.

Sign-out pushes an empty signed-out snapshot. Clearing our own container is not
enough — the watch holds its own copy and would keep rendering the previous
user's calendar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:40:45 +02:00
ScarriffleandClaude Opus 5 ad7a405f32 Refresh the shared snapshot in the background so widgets stop going stale
publishWidgetSnapshot() only ever ran from a user action, so an event created
on the web or on Android did not reach the widgets until the app was opened
again. A BGAppRefreshTask now republishes without the foreground.

The two keys this needs are arrays, which Xcode does not expose as
INFOPLIST_KEY_ build settings — verified by building and inspecting the
result. Hence a partial Calendarr-Info.plist that generation merges into. It
sits at the project root rather than inside the target folder: that folder is
a synchronized group, which would also copy the file as a resource and collide
with the processed one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:39:34 +02:00
ScarriffleandClaude Opus 5 c8a36c161f Filter widget snapshots through the shared package rule
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:35:23 +02:00
ScarriffleandClaude Opus 5 75a6a5bd79 Fix two OIDC compile errors against the current SDK and AppAuth
UIAccessibilityIsGuidedAccessEnabled() is gone in iOS 26; use the property.

AppAuth declares lastAuthorizationResponse and its request as nonnull, so the
optional chain was rejected. Behaviour is unchanged: nonce is optional either
way, which is the tolerance the comment there already describes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:35:23 +02:00
46 changed files with 2893 additions and 76 deletions
+642 -23
View File
@@ -7,10 +7,16 @@
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
D0001A0CABCDEF0100AB5001 /* CalendarrWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D0001A02ABCDEF0100AB5001 /* CalendarrWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 3934CF4630544C2E003958B8 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3934CF4530544C2E003958B8 /* WidgetKit.framework */; };
3934CF4830544C2E003958B8 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3934CF4730544C2E003958B8 /* SwiftUI.framework */; };
3934CF5530544C2E003958B8 /* CalendarrWatchComplications.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 3934CF4330544C2E003958B8 /* CalendarrWatchComplications.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
39E1B0010001000000000001 /* CalendarrWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 3934CF1A30544C01003958B8 /* CalendarrWatch.app */; platformFilters = (ios, ); settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
AA9701000001000000000001 /* AppAuth in Frameworks */ = {isa = PBXBuildFile; productRef = AA9701000002000000000001 /* AppAuth */; };
CA1E4D0A0002000000000001 /* CalendarrCore in Frameworks */ = {isa = PBXBuildFile; productRef = CA1E4D0A0001000000000001 /* CalendarrCore */; }; 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 */; };
AA9701000001000000000001 /* AppAuth in Frameworks */ = {isa = PBXBuildFile; productRef = AA9701000002000000000001 /* AppAuth */; }; CA1E4D0A0002000000000003 /* CalendarrCore in Frameworks */ = {isa = PBXBuildFile; productRef = CA1E4D0A0001000000000003 /* CalendarrCore */; };
CA1E4D0A0002000000000004 /* CalendarrCore in Frameworks */ = {isa = PBXBuildFile; productRef = CA1E4D0A0001000000000004 /* CalendarrCore */; };
D0001A0CABCDEF0100AB5001 /* CalendarrWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D0001A02ABCDEF0100AB5001 /* CalendarrWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -21,6 +27,34 @@
remoteGlobalIDString = C0000A01FB4E10100AB5001; remoteGlobalIDString = C0000A01FB4E10100AB5001;
remoteInfo = "Calendarr iOS"; remoteInfo = "Calendarr iOS";
}; };
3934CF2730544C02003958B8 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = C0000301FB4E10100AB5001 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 3934CF1930544C01003958B8;
remoteInfo = CalendarrWatch;
};
3934CF3130544C02003958B8 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = C0000301FB4E10100AB5001 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 3934CF1930544C01003958B8;
remoteInfo = CalendarrWatch;
};
3934CF5330544C2E003958B8 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = C0000301FB4E10100AB5001 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 3934CF4230544C2E003958B8;
remoteInfo = CalendarrWatchComplications;
};
39E1B0010003000000000001 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = C0000301FB4E10100AB5001 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 3934CF1930544C01003958B8;
remoteInfo = CalendarrWatch;
};
D0001A0DABCDEF0100AB5001 /* PBXContainerItemProxy */ = { D0001A0DABCDEF0100AB5001 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy; isa = PBXContainerItemProxy;
containerPortal = C0000301FB4E10100AB5001 /* Project object */; containerPortal = C0000301FB4E10100AB5001 /* Project object */;
@@ -31,6 +65,28 @@
/* End PBXContainerItemProxy section */ /* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */ /* Begin PBXCopyFilesBuildPhase section */
3934CF5A30544C2E003958B8 /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
3934CF5530544C2E003958B8 /* CalendarrWatchComplications.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
39E1B0010002000000000001 /* Embed Watch Content */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "$(CONTENTS_FOLDER_PATH)/Watch";
dstSubfolderSpec = 16;
files = (
39E1B0010001000000000001 /* CalendarrWatch.app in Embed Watch Content */,
);
name = "Embed Watch Content";
runOnlyForDeploymentPostprocessing = 0;
};
D0001A0BABCDEF0100AB5001 /* Embed Foundation Extensions */ = { D0001A0BABCDEF0100AB5001 /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase; isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@@ -46,11 +102,24 @@
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
3927C7BC2FB99D0E00EAD8ED /* Calendarr iOSTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Calendarr iOSTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; 3927C7BC2FB99D0E00EAD8ED /* Calendarr iOSTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Calendarr iOSTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
3934CF1A30544C01003958B8 /* CalendarrWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CalendarrWatch.app; sourceTree = BUILT_PRODUCTS_DIR; };
3934CF2630544C02003958B8 /* CalendarrWatchTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CalendarrWatchTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3934CF3030544C02003958B8 /* CalendarrWatchUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CalendarrWatchUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3934CF4330544C2E003958B8 /* CalendarrWatchComplications.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = CalendarrWatchComplications.appex; sourceTree = BUILT_PRODUCTS_DIR; };
3934CF4530544C2E003958B8 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
3934CF4730544C2E003958B8 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
C0000B01FC4E10100AB5001 /* Calendarr iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Calendarr iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; C0000B01FC4E10100AB5001 /* Calendarr iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Calendarr iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
D0001A02ABCDEF0100AB5001 /* CalendarrWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = CalendarrWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; D0001A02ABCDEF0100AB5001 /* CalendarrWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = CalendarrWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
3934CF5630544C2E003958B8 /* Exceptions for "CalendarrWatchComplications" folder in "CalendarrWatchComplications" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = 3934CF4230544C2E003958B8 /* CalendarrWatchComplications */;
};
D0001A0FABCDEF0100AB5001 /* Exceptions for "CalendarrWidgets" folder in "CalendarrWidgets" target */ = { D0001A0FABCDEF0100AB5001 /* Exceptions for "CalendarrWidgets" folder in "CalendarrWidgets" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet; isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = ( membershipExceptions = (
@@ -66,6 +135,34 @@
path = "Calendarr iOSTests"; path = "Calendarr iOSTests";
sourceTree = "<group>"; sourceTree = "<group>";
}; };
3934CF1B30544C01003958B8 /* CalendarrWatch */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = CalendarrWatch;
sourceTree = "<group>";
};
3934CF2930544C02003958B8 /* CalendarrWatchTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = CalendarrWatchTests;
sourceTree = "<group>";
};
3934CF3330544C02003958B8 /* CalendarrWatchUITests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = CalendarrWatchUITests;
sourceTree = "<group>";
};
3934CF4930544C2E003958B8 /* CalendarrWatchComplications */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
3934CF5630544C2E003958B8 /* Exceptions for "CalendarrWatchComplications" folder in "CalendarrWatchComplications" target */,
);
path = CalendarrWatchComplications;
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";
@@ -94,6 +191,38 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
3934CF1730544C01003958B8 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
CA1E4D0A0002000000000003 /* CalendarrCore in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
3934CF2330544C02003958B8 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
3934CF2D30544C02003958B8 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
3934CF4030544C2E003958B8 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
3934CF4830544C2E003958B8 /* SwiftUI.framework in Frameworks */,
3934CF4630544C2E003958B8 /* WidgetKit.framework in Frameworks */,
CA1E4D0A0002000000000004 /* CalendarrCore in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
C0000801FB4E10100AB5001 /* Frameworks */ = { C0000801FB4E10100AB5001 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@@ -114,6 +243,15 @@
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
3934CF4430544C2E003958B8 /* Frameworks */ = {
isa = PBXGroup;
children = (
3934CF4530544C2E003958B8 /* WidgetKit.framework */,
3934CF4730544C2E003958B8 /* SwiftUI.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
C0000201FB4E10100AB5001 = { C0000201FB4E10100AB5001 = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -121,7 +259,13 @@
D0001A03ABCDEF0100AB5001 /* CalendarrWidgets */, D0001A03ABCDEF0100AB5001 /* CalendarrWidgets */,
D0001A04ABCDEF0100AB5001 /* Shared */, D0001A04ABCDEF0100AB5001 /* Shared */,
3927C7BD2FB99D0F00EAD8ED /* Calendarr iOSTests */, 3927C7BD2FB99D0F00EAD8ED /* Calendarr iOSTests */,
3934CF1B30544C01003958B8 /* CalendarrWatch */,
3934CF2930544C02003958B8 /* CalendarrWatchTests */,
3934CF3330544C02003958B8 /* CalendarrWatchUITests */,
3934CF4930544C2E003958B8 /* CalendarrWatchComplications */,
3934CF4430544C2E003958B8 /* Frameworks */,
C0000C01FB4E10100AB5001 /* Products */, C0000C01FB4E10100AB5001 /* Products */,
39E1B0010005000000000001 /* CalendarrWatchShared */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@@ -131,6 +275,10 @@
C0000B01FC4E10100AB5001 /* Calendarr iOS.app */, C0000B01FC4E10100AB5001 /* Calendarr iOS.app */,
3927C7BC2FB99D0E00EAD8ED /* Calendarr iOSTests.xctest */, 3927C7BC2FB99D0E00EAD8ED /* Calendarr iOSTests.xctest */,
D0001A02ABCDEF0100AB5001 /* CalendarrWidgets.appex */, D0001A02ABCDEF0100AB5001 /* CalendarrWidgets.appex */,
3934CF1A30544C01003958B8 /* CalendarrWatch.app */,
3934CF2630544C02003958B8 /* CalendarrWatchTests.xctest */,
3934CF3030544C02003958B8 /* CalendarrWatchUITests.xctest */,
3934CF4330544C2E003958B8 /* CalendarrWatchComplications.appex */,
); );
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -161,6 +309,102 @@
productReference = 3927C7BC2FB99D0E00EAD8ED /* Calendarr iOSTests.xctest */; productReference = 3927C7BC2FB99D0E00EAD8ED /* Calendarr iOSTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test"; productType = "com.apple.product-type.bundle.unit-test";
}; };
3934CF1930544C01003958B8 /* CalendarrWatch */ = {
isa = PBXNativeTarget;
buildConfigurationList = 3934CF3C30544C02003958B8 /* Build configuration list for PBXNativeTarget "CalendarrWatch" */;
buildPhases = (
3934CF1630544C01003958B8 /* Sources */,
3934CF1730544C01003958B8 /* Frameworks */,
3934CF1830544C01003958B8 /* Resources */,
3934CF5A30544C2E003958B8 /* Embed Foundation Extensions */,
);
buildRules = (
);
dependencies = (
3934CF5430544C2E003958B8 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
3934CF1B30544C01003958B8 /* CalendarrWatch */,
39E1B0010005000000000001 /* CalendarrWatchShared */,
);
name = CalendarrWatch;
packageProductDependencies = (
CA1E4D0A0001000000000003 /* CalendarrCore */,
);
productName = CalendarrWatch;
productReference = 3934CF1A30544C01003958B8 /* CalendarrWatch.app */;
productType = "com.apple.product-type.application";
};
3934CF2530544C02003958B8 /* CalendarrWatchTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 3934CF3D30544C02003958B8 /* Build configuration list for PBXNativeTarget "CalendarrWatchTests" */;
buildPhases = (
3934CF2230544C02003958B8 /* Sources */,
3934CF2330544C02003958B8 /* Frameworks */,
3934CF2430544C02003958B8 /* Resources */,
);
buildRules = (
);
dependencies = (
3934CF2830544C02003958B8 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
3934CF2930544C02003958B8 /* CalendarrWatchTests */,
);
name = CalendarrWatchTests;
packageProductDependencies = (
);
productName = CalendarrWatchTests;
productReference = 3934CF2630544C02003958B8 /* CalendarrWatchTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
3934CF2F30544C02003958B8 /* CalendarrWatchUITests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 3934CF3E30544C02003958B8 /* Build configuration list for PBXNativeTarget "CalendarrWatchUITests" */;
buildPhases = (
3934CF2C30544C02003958B8 /* Sources */,
3934CF2D30544C02003958B8 /* Frameworks */,
3934CF2E30544C02003958B8 /* Resources */,
);
buildRules = (
);
dependencies = (
3934CF3230544C02003958B8 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
3934CF3330544C02003958B8 /* CalendarrWatchUITests */,
);
name = CalendarrWatchUITests;
packageProductDependencies = (
);
productName = CalendarrWatchUITests;
productReference = 3934CF3030544C02003958B8 /* CalendarrWatchUITests.xctest */;
productType = "com.apple.product-type.bundle.ui-testing";
};
3934CF4230544C2E003958B8 /* CalendarrWatchComplications */ = {
isa = PBXNativeTarget;
buildConfigurationList = 3934CF5730544C2E003958B8 /* Build configuration list for PBXNativeTarget "CalendarrWatchComplications" */;
buildPhases = (
3934CF3F30544C2E003958B8 /* Sources */,
3934CF4030544C2E003958B8 /* Frameworks */,
3934CF4130544C2E003958B8 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
3934CF4930544C2E003958B8 /* CalendarrWatchComplications */,
39E1B0010005000000000001 /* CalendarrWatchShared */,
);
name = CalendarrWatchComplications;
packageProductDependencies = (
CA1E4D0A0001000000000004 /* CalendarrCore */,
);
productName = CalendarrWatchComplications;
productReference = 3934CF4330544C2E003958B8 /* CalendarrWatchComplications.appex */;
productType = "com.apple.product-type.app-extension";
};
C0000A01FB4E10100AB5001 /* Calendarr iOS */ = { C0000A01FB4E10100AB5001 /* Calendarr iOS */ = {
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = C0001601FB4E10100AB5001 /* Build configuration list for PBXNativeTarget "Calendarr iOS" */; buildConfigurationList = C0001601FB4E10100AB5001 /* Build configuration list for PBXNativeTarget "Calendarr iOS" */;
@@ -169,11 +413,13 @@
C0000801FB4E10100AB5001 /* Frameworks */, C0000801FB4E10100AB5001 /* Frameworks */,
C0000901FB4E10100AB5001 /* Resources */, C0000901FB4E10100AB5001 /* Resources */,
D0001A0BABCDEF0100AB5001 /* Embed Foundation Extensions */, D0001A0BABCDEF0100AB5001 /* Embed Foundation Extensions */,
39E1B0010002000000000001 /* Embed Watch Content */,
); );
buildRules = ( buildRules = (
); );
dependencies = ( dependencies = (
D0001A0EABCDEF0100AB5001 /* PBXTargetDependency */, D0001A0EABCDEF0100AB5001 /* PBXTargetDependency */,
39E1B0010004000000000001 /* PBXTargetDependency */,
); );
fileSystemSynchronizedGroups = ( fileSystemSynchronizedGroups = (
C0000D01FC4E10100AB5001 /* Calendarr iOS */, C0000D01FC4E10100AB5001 /* Calendarr iOS */,
@@ -219,13 +465,27 @@
isa = PBXProject; isa = PBXProject;
attributes = { attributes = {
BuildIndependentTargetsInParallel = 1; BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2650; LastSwiftUpdateCheck = 2700;
LastUpgradeCheck = 2640; LastUpgradeCheck = 2640;
TargetAttributes = { TargetAttributes = {
3927C7BB2FB99D0E00EAD8ED = { 3927C7BB2FB99D0E00EAD8ED = {
CreatedOnToolsVersion = 26.5; CreatedOnToolsVersion = 26.5;
TestTargetID = C0000A01FB4E10100AB5001; TestTargetID = C0000A01FB4E10100AB5001;
}; };
3934CF1930544C01003958B8 = {
CreatedOnToolsVersion = 27.0;
};
3934CF2530544C02003958B8 = {
CreatedOnToolsVersion = 27.0;
TestTargetID = 3934CF1930544C01003958B8;
};
3934CF2F30544C02003958B8 = {
CreatedOnToolsVersion = 27.0;
TestTargetID = 3934CF1930544C01003958B8;
};
3934CF4230544C2E003958B8 = {
CreatedOnToolsVersion = 27.0;
};
C0000A01FB4E10100AB5001 = { C0000A01FB4E10100AB5001 = {
CreatedOnToolsVersion = 26.4.1; CreatedOnToolsVersion = 26.4.1;
}; };
@@ -256,6 +516,10 @@
C0000A01FB4E10100AB5001 /* Calendarr iOS */, C0000A01FB4E10100AB5001 /* Calendarr iOS */,
D0001A01ABCDEF0100AB5001 /* CalendarrWidgets */, D0001A01ABCDEF0100AB5001 /* CalendarrWidgets */,
3927C7BB2FB99D0E00EAD8ED /* Calendarr iOSTests */, 3927C7BB2FB99D0E00EAD8ED /* Calendarr iOSTests */,
3934CF1930544C01003958B8 /* CalendarrWatch */,
3934CF2530544C02003958B8 /* CalendarrWatchTests */,
3934CF2F30544C02003958B8 /* CalendarrWatchUITests */,
3934CF4230544C2E003958B8 /* CalendarrWatchComplications */,
); );
}; };
/* End PBXProject section */ /* End PBXProject section */
@@ -268,6 +532,34 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
3934CF1830544C01003958B8 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
3934CF2430544C02003958B8 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
3934CF2E30544C02003958B8 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
3934CF4130544C2E003958B8 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
C0000901FB4E10100AB5001 /* Resources */ = { C0000901FB4E10100AB5001 /* Resources */ = {
isa = PBXResourcesBuildPhase; isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@@ -292,6 +584,34 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
3934CF1630544C01003958B8 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
3934CF2230544C02003958B8 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
3934CF2C30544C02003958B8 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
3934CF3F30544C2E003958B8 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
C0000701FB4E10100AB5001 /* Sources */ = { C0000701FB4E10100AB5001 /* Sources */ = {
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@@ -314,6 +634,29 @@
target = C0000A01FB4E10100AB5001 /* Calendarr iOS */; target = C0000A01FB4E10100AB5001 /* Calendarr iOS */;
targetProxy = 3927C7C02FB99D0F00EAD8ED /* PBXContainerItemProxy */; targetProxy = 3927C7C02FB99D0F00EAD8ED /* PBXContainerItemProxy */;
}; };
3934CF2830544C02003958B8 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 3934CF1930544C01003958B8 /* CalendarrWatch */;
targetProxy = 3934CF2730544C02003958B8 /* PBXContainerItemProxy */;
};
3934CF3230544C02003958B8 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 3934CF1930544C01003958B8 /* CalendarrWatch */;
targetProxy = 3934CF3130544C02003958B8 /* PBXContainerItemProxy */;
};
3934CF5430544C2E003958B8 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 3934CF4230544C2E003958B8 /* CalendarrWatchComplications */;
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 */;
@@ -372,6 +715,232 @@
}; };
name = Release; name = Release;
}; };
3934CF3630544C02003958B8 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = CalendarrWatch/CalendarrWatch.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = PP34X97WS3;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Calendarr;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = com.scarriffleservices.calendarr.ios;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 3.5;
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios.watchkitapp;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 26.0;
};
name = Debug;
};
3934CF3730544C02003958B8 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = CalendarrWatch/CalendarrWatch.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = PP34X97WS3;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Calendarr;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = com.scarriffleservices.calendarr.ios;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 3.5;
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios.watchkitapp;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 26.0;
};
name = Release;
};
3934CF3830544C02003958B8 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = PP34X97WS3;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 3.5;
PRODUCT_BUNDLE_IDENTIFIER = "com.scarriffleservices.calendarr.ios.watchkitapp.CalendarrWatch-Watch-AppTests";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/CalendarrWatch.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/CalendarrWatch";
WATCHOS_DEPLOYMENT_TARGET = 26.0;
};
name = Debug;
};
3934CF3930544C02003958B8 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = PP34X97WS3;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 3.5;
PRODUCT_BUNDLE_IDENTIFIER = "com.scarriffleservices.calendarr.ios.watchkitapp.CalendarrWatch-Watch-AppTests";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/CalendarrWatch.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/CalendarrWatch";
WATCHOS_DEPLOYMENT_TARGET = 26.0;
};
name = Release;
};
3934CF3A30544C02003958B8 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = PP34X97WS3;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 3.5;
PRODUCT_BUNDLE_IDENTIFIER = "com.scarriffleservices.calendarr.ios.watchkitapp.CalendarrWatch-Watch-AppUITests";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
TEST_TARGET_NAME = CalendarrWatch;
WATCHOS_DEPLOYMENT_TARGET = 26.0;
};
name = Debug;
};
3934CF3B30544C02003958B8 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = PP34X97WS3;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 3.5;
PRODUCT_BUNDLE_IDENTIFIER = "com.scarriffleservices.calendarr.ios.watchkitapp.CalendarrWatch-Watch-AppUITests";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
TEST_TARGET_NAME = CalendarrWatch;
WATCHOS_DEPLOYMENT_TARGET = 26.0;
};
name = Release;
};
3934CF5830544C2E003958B8 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = CalendarrWatchComplications/CalendarrWatchComplications.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = PP34X97WS3;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = CalendarrWatchComplications/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Calendarr;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
"@executable_path/../../../../Frameworks",
);
MARKETING_VERSION = 3.5;
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios.watchkitapp.complications;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 26.0;
};
name = Debug;
};
3934CF5930544C2E003958B8 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = CalendarrWatchComplications/CalendarrWatchComplications.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 7;
DEVELOPMENT_TEAM = PP34X97WS3;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = CalendarrWatchComplications/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Calendarr;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
"@executable_path/../../../../Frameworks",
);
MARKETING_VERSION = 3.5;
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios.watchkitapp.complications;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 26.0;
};
name = Release;
};
C0001401FB4E10100AB5001 /* Debug */ = { C0001401FB4E10100AB5001 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
@@ -509,6 +1078,7 @@
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Calendarr-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = Calendarr; INFOPLIST_KEY_CFBundleDisplayName = Calendarr;
INFOPLIST_KEY_CFBundleName = Calendarr; INFOPLIST_KEY_CFBundleName = Calendarr;
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
@@ -520,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",
@@ -541,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;
@@ -558,6 +1130,7 @@
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Calendarr-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = Calendarr; INFOPLIST_KEY_CFBundleDisplayName = Calendarr;
INFOPLIST_KEY_CFBundleName = Calendarr; INFOPLIST_KEY_CFBundleName = Calendarr;
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
@@ -569,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",
@@ -590,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;
@@ -625,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;
@@ -659,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;
@@ -676,6 +1251,42 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
3934CF3C30544C02003958B8 /* Build configuration list for PBXNativeTarget "CalendarrWatch" */ = {
isa = XCConfigurationList;
buildConfigurations = (
3934CF3630544C02003958B8 /* Debug */,
3934CF3730544C02003958B8 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
3934CF3D30544C02003958B8 /* Build configuration list for PBXNativeTarget "CalendarrWatchTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
3934CF3830544C02003958B8 /* Debug */,
3934CF3930544C02003958B8 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
3934CF3E30544C02003958B8 /* Build configuration list for PBXNativeTarget "CalendarrWatchUITests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
3934CF3A30544C02003958B8 /* Debug */,
3934CF3B30544C02003958B8 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
3934CF5730544C2E003958B8 /* Build configuration list for PBXNativeTarget "CalendarrWatchComplications" */ = {
isa = XCConfigurationList;
buildConfigurations = (
3934CF5830544C2E003958B8 /* Debug */,
3934CF5930544C2E003958B8 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
C0000601FB4E10100AB5001 /* Build configuration list for PBXProject "Calendarr iOS" */ = { C0000601FB4E10100AB5001 /* Build configuration list for PBXProject "Calendarr iOS" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (
@@ -705,22 +1316,6 @@
}; };
/* End XCConfigurationList section */ /* End XCConfigurationList section */
/* Begin XCSwiftPackageProductDependency section */
CA1E4D0A0001000000000001 /* CalendarrCore */ = {
isa = XCSwiftPackageProductDependency;
productName = CalendarrCore;
};
CA1E4D0A0001000000000002 /* CalendarrCore */ = {
isa = XCSwiftPackageProductDependency;
productName = CalendarrCore;
};
AA9701000002000000000001 /* AppAuth */ = {
isa = XCSwiftPackageProductDependency;
package = AA9701000003000000000001 /* XCRemoteSwiftPackageReference "AppAuth-iOS" */;
productName = AppAuth;
};
/* End XCSwiftPackageProductDependency section */
/* Begin XCLocalSwiftPackageReference section */ /* Begin XCLocalSwiftPackageReference section */
39450EFC302A266A0090DDD7 /* XCLocalSwiftPackageReference "../CalendarrKit" */ = { 39450EFC302A266A0090DDD7 /* XCLocalSwiftPackageReference "../CalendarrKit" */ = {
isa = XCLocalSwiftPackageReference; isa = XCLocalSwiftPackageReference;
@@ -738,6 +1333,30 @@
}; };
}; };
/* End XCRemoteSwiftPackageReference section */ /* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
AA9701000002000000000001 /* AppAuth */ = {
isa = XCSwiftPackageProductDependency;
package = AA9701000003000000000001 /* XCRemoteSwiftPackageReference "AppAuth-iOS" */;
productName = AppAuth;
};
CA1E4D0A0001000000000001 /* CalendarrCore */ = {
isa = XCSwiftPackageProductDependency;
productName = CalendarrCore;
};
CA1E4D0A0001000000000002 /* CalendarrCore */ = {
isa = XCSwiftPackageProductDependency;
productName = CalendarrCore;
};
CA1E4D0A0001000000000003 /* CalendarrCore */ = {
isa = XCSwiftPackageProductDependency;
productName = CalendarrCore;
};
CA1E4D0A0001000000000004 /* CalendarrCore */ = {
isa = XCSwiftPackageProductDependency;
productName = CalendarrCore;
};
/* End XCSwiftPackageProductDependency section */
}; };
rootObject = C0000301FB4E10100AB5001 /* Project object */; rootObject = C0000301FB4E10100AB5001 /* Project object */;
} }
@@ -0,0 +1,15 @@
{
"originHash" : "478d8419d86ca78c1539d3775396b811ce835c77379f2af98e26eefd35ee2311",
"pins" : [
{
"identity" : "appauth-ios",
"kind" : "remoteSourceControl",
"location" : "https://github.com/openid/AppAuth-iOS.git",
"state" : {
"revision" : "2781038865a80e2c425a1da12cc1327bcd56501f",
"version" : "1.7.6"
}
}
],
"version" : 3
}
@@ -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>
@@ -9,11 +9,31 @@
<key>orderHint</key> <key>orderHint</key>
<integer>0</integer> <integer>0</integer>
</dict> </dict>
<key>CalendarrWidgets.xcscheme_^#shared#^_</key> <key>CalendarrWatch Watch App.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>3</integer>
</dict>
<key>CalendarrWatch.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>2</integer> <integer>2</integer>
</dict> </dict>
<key>CalendarrWatchComplications.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>3</integer>
</dict>
<key>CalendarrWatchExtension.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>4</integer>
</dict>
<key>CalendarrWidgets.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>4</integer>
</dict>
</dict> </dict>
<key>SuppressBuildableAutocreation</key> <key>SuppressBuildableAutocreation</key>
<dict> <dict>
+11
View File
@@ -4,10 +4,18 @@ import SwiftUI
struct CalendarrApp: App { struct CalendarrApp: App {
@State private var appState = AppState() @State private var appState = AppState()
init() {
// Registration has to happen before the app finishes launching, which
// is why it is here and not in a .task modifier.
BackgroundRefresh.register()
WatchSyncService.shared.activate()
}
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
RootView() RootView()
.environment(appState) .environment(appState)
.task { BackgroundRefresh.schedule() }
} }
} }
} }
@@ -107,6 +115,9 @@ class AppState {
// say "sign in to Calendarr" rather than "open Calendarr once". // say "sign in to Calendarr" rather than "open Calendarr once".
WidgetStore.clear() WidgetStore.clear()
publishSession() publishSession()
// The watch keeps its own copy of the snapshot, so clearing ours is not
// enough — it has to be told, or it keeps rendering this user's events.
WatchSyncService.shared.pushSignedOut()
} }
func resetServer() { func resetServer() {
+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 ?? "",
+9 -4
View File
@@ -599,7 +599,8 @@ class CalendarStore {
colorHex: ev.calendarColor) colorHex: ev.calendarColor)
} }
} }
WidgetStore.writeCalendars(Array(calendarMap.values).sorted { $0.name < $1.name }) let calendars = Array(calendarMap.values).sorted { $0.name < $1.name }
WidgetStore.writeCalendars(calendars)
let visible = allCachedEvents let visible = allCachedEvents
.filter { ev in .filter { ev in
@@ -620,10 +621,14 @@ class CalendarStore {
location: ev.location, location: ev.location,
calendarKey: Self.calendarKey(source: ev.source, calendarId: ev.calendarId)) calendarKey: Self.calendarKey(source: ev.source, calendarId: ev.calendarId))
} }
WidgetStore.write(WidgetStore.makeSnapshot(events: Array(visible), let snapshot = WidgetStore.makeSnapshot(events: Array(visible),
coverageStart: from, coverageStart: from,
coverageEnd: to)) coverageEnd: to)
WidgetStore.write(snapshot)
WidgetTimelineNotifier.reload() WidgetTimelineNotifier.reload()
// The watch has its own container and cannot read the file we just
// wrote, so it is handed the data explicitly.
WatchSyncService.shared.push(snapshot: snapshot, calendars: calendars)
} }
// MARK: – Writable calendars // MARK: – Writable calendars
@@ -0,0 +1,82 @@
import BackgroundTasks
import Foundation
import CalendarrCore
/// Keeps the shared snapshot fresh while the app is not in the foreground.
///
/// Without this, `publishWidgetSnapshot()` only ever runs from a user action,
/// so an event created on the web or on Android does not reach the widgets — or
/// the watch — until the app is opened again. iOS decides when this actually
/// runs, based on how the user uses the app: it improves freshness, it does not
/// guarantee it.
@MainActor
enum BackgroundRefresh {
static let taskIdentifier = "com.scarriffleservices.calendarr.ios.refresh"
/// Minimum gap between runs we ask for. iOS treats this as the earliest
/// acceptable time, not a promise.
private static let interval: TimeInterval = 30 * 60
/// Call once at launch, before the app finishes starting up.
static func register() {
BGTaskScheduler.shared.register(forTaskWithIdentifier: taskIdentifier,
using: nil) { task in
guard let refresh = task as? BGAppRefreshTask else {
task.setTaskCompleted(success: false)
return
}
Task { @MainActor in await handle(refresh) }
}
}
/// Ask for the next run. Safe to call repeatedly; a duplicate submission
/// replaces the pending request rather than queueing a second one.
static func schedule() {
let request = BGAppRefreshTaskRequest(identifier: taskIdentifier)
request.earliestBeginDate = Date(timeIntervalSinceNow: interval)
do { try BGTaskScheduler.shared.submit(request) }
catch {
// Submission fails on a simulator and when the user has disabled
// background refresh. Neither is worth interrupting anyone over.
print("[BackgroundRefresh] submit failed: \(error.localizedDescription)")
}
}
private static func handle(_ task: BGAppRefreshTask) async {
// Reschedule first: if the work below throws or is killed, the chain
// must not end here.
schedule()
let work = Task { @MainActor in await refreshSnapshot() }
task.expirationHandler = { work.cancel() }
let succeeded = await work.value
task.setTaskCompleted(success: succeeded)
}
/// Fetch the snapshot window and republish. Returns false when there was
/// nothing to do or the fetch failed.
private static func refreshSnapshot() async -> Bool {
let state = AppState()
guard state.isConfigured, state.isLoggedIn else { return false }
// An SSO session can lapse while the app is closed; renewing first
// avoids spending the whole background window on a 401.
await OIDCSessionRefresher.refreshIfNeeded(state)
let api = CalendarrAPI(baseURL: state.serverURL, token: state.authToken)
let store = CalendarStore()
let calendar = Calendar(identifier: .gregorian)
let dayStart = calendar.startOfDay(for: Date())
guard let from = calendar.date(byAdding: .day, value: -SnapshotCoverage.daysBehind, to: dayStart),
let to = calendar.date(byAdding: .day, value: SnapshotCoverage.daysAhead, to: dayStart)
else { return false }
// force: a freshly created store has an empty in-memory cache, so
// without this the "already cached" short-circuit would skip the fetch.
await store.loadEvents(api: api, start: from, end: to, force: true)
return store.lastError == nil
}
}
@@ -66,7 +66,7 @@ final class OIDCAuthenticator {
/// the app target generates its Info.plist, which has no way to declare /// the app target generates its Info.plist, which has no way to declare
/// `CFBundleURLTypes`. Rather than hang forever waiting for a callback that /// `CFBundleURLTypes`. Rather than hang forever waiting for a callback that
/// cannot arrive, refuse up front. /// cannot arrive, refuse up front.
var guidedAccessBlocksLogin: Bool { UIAccessibilityIsGuidedAccessEnabled() } var guidedAccessBlocksLogin: Bool { UIAccessibility.isGuidedAccessEnabled }
func discover(issuer: String) async throws -> OIDServiceConfiguration { func discover(issuer: String) async throws -> OIDServiceConfiguration {
guard let url = URL(string: issuer) else { throw OIDCError(slug: "oidc_bad_issuer") } guard let url = URL(string: issuer) else { throw OIDCError(slug: "oidc_bad_issuer") }
@@ -174,7 +174,7 @@ final class OIDCAuthenticator {
// The original request travels inside the stored state, so the nonce // The original request travels inside the stored state, so the nonce
// is still available. A refreshed token often drops the claim, which // is still available. A refreshed token often drops the claim, which
// the server tolerates — but when it keeps it, it has to match. // the server tolerates — but when it keeps it, it has to match.
let nonce = state.lastAuthorizationResponse?.request.nonce let nonce = state.lastAuthorizationResponse.request.nonce
return (idToken, tokens.1, nonce) return (idToken, tokens.1, nonce)
} }
@@ -0,0 +1,156 @@
import Foundation
import WatchConnectivity
import CalendarrCore
/// Feeds the watch. App Groups are per-device, so the watch cannot read the
/// file this app just wrote — it has to be handed the data.
///
/// Three delivery paths, because each one alone leaves a gap:
///
/// 1. `updateApplicationContext` carries only the latest state and has no queue
/// to overflow. It is delivered when the watch app next launches or wakes.
/// 2. `transferCurrentComplicationUserInfo` is the only path that wakes the
/// watch app while it is not running. Its daily budget is small, so it is
/// spent only when the *next* appointment changed.
/// 3. A reply to `sendMessage` covers the watch app asking while it is open.
@MainActor
final class WatchSyncService: NSObject {
static let shared = WatchSyncService()
private let sessions = SharedSessionStore()
private static let lastIdentityKey = "watchLastPushedNextEvent"
private static let pushTimesKey = "watchComplicationPushTimes"
/// The system budget is roughly 50 a day; this keeps a burst of edits from
/// spending it in an hour.
private static let maxPushesPerHour = 4
private var session: WCSession? {
WCSession.isSupported() ? WCSession.default : nil
}
/// Call once at launch.
func activate() {
guard let session else { return }
session.delegate = self
session.activate()
}
/// Publish the current calendar to the watch.
func push(snapshot: CalendarrSnapshot, calendars: [SnapshotCalendar]) {
guard let session, session.activationState == .activated else { return }
let trimmed = snapshot.trimmedForWatch()
let payload = WatchTransportPayload(snapshot: trimmed,
calendars: calendars,
session: sessions.read())
guard let dict = try? payload.encoded() else { return }
try? session.updateApplicationContext(dict)
guard session.isComplicationEnabled else { return }
let current = WatchPushPolicy.nextEventIdentity(in: trimmed, at: Date())
guard WatchPushPolicy.shouldPushComplicationUpdate(previous: loadLastIdentity(),
current: current),
consumePushBudget()
else { return }
session.transferCurrentComplicationUserInfo(dict)
storeLastIdentity(current)
}
/// Tell the watch the user signed out, so it stops showing their calendar.
/// The watch keeps its own copy of the snapshot, so clearing ours is not
/// enough.
func pushSignedOut() {
guard let session, session.activationState == .activated else { return }
let now = Date()
let empty = CalendarrSnapshot(
writtenAt: now, coverageStart: now, coverageEnd: now,
isLoggedIn: false, writerVersion: "signout", events: [],
theme: SnapshotTheme(today: "#4285f4", text: "#FFFFFF", background: "#000000",
line: "#3A3A52", primary: "#4285f4", accent: "#ea4335"),
language: "system")
let payload = WatchTransportPayload(snapshot: empty, calendars: [], session: sessions.read())
guard let dict = try? payload.encoded() else { return }
try? session.updateApplicationContext(dict)
if session.isComplicationEnabled, consumePushBudget() {
session.transferCurrentComplicationUserInfo(dict)
}
storeLastIdentity(nil)
}
/// The payload for a watch that asked directly. Built from the file we
/// already wrote, so an on-demand pull cannot disagree with a push.
fileprivate func currentPayloadDictionary() -> [String: Any]? {
let store = SnapshotStore()
guard case .ok(let snapshot) = store.read() else { return nil }
let payload = WatchTransportPayload(snapshot: snapshot.trimmedForWatch(),
calendars: store.readCalendars(),
session: sessions.read())
return try? payload.encoded()
}
// MARK: – Push budget
private func consumePushBudget() -> Bool {
let cutoff = Date().addingTimeInterval(-3600)
var times = (UserDefaults.standard.array(forKey: Self.pushTimesKey) as? [Double] ?? [])
.map { Date(timeIntervalSince1970: $0) }
.filter { $0 > cutoff }
guard times.count < Self.maxPushesPerHour else {
UserDefaults.standard.set(times.map(\.timeIntervalSince1970), forKey: Self.pushTimesKey)
return false
}
times.append(Date())
UserDefaults.standard.set(times.map(\.timeIntervalSince1970), forKey: Self.pushTimesKey)
return true
}
// MARK: – Last pushed identity
private func loadLastIdentity() -> NextEventIdentity? {
guard let data = UserDefaults.standard.data(forKey: Self.lastIdentityKey) else { return nil }
let decoder = JSONDecoder(); decoder.dateDecodingStrategy = .iso8601
return try? decoder.decode(NextEventIdentity.self, from: data)
}
private func storeLastIdentity(_ identity: NextEventIdentity?) {
guard let identity else {
UserDefaults.standard.removeObject(forKey: Self.lastIdentityKey)
return
}
let encoder = JSONEncoder(); encoder.dateEncodingStrategy = .iso8601
UserDefaults.standard.set(try? encoder.encode(identity), forKey: Self.lastIdentityKey)
}
}
extension WatchSyncService: WCSessionDelegate {
nonisolated func session(_ session: WCSession,
activationDidCompleteWith state: WCSessionActivationState,
error: Error?) {
if let error {
print("[WatchSync] activation failed: \(error.localizedDescription)")
}
}
/// The watch asking for the current state, which is what covers "it is open
/// in front of me and looks stale".
nonisolated func session(_ session: WCSession,
didReceiveMessage message: [String: Any],
replyHandler: @escaping ([String: Any]) -> Void) {
Task { @MainActor in
replyHandler(self.currentPayloadDictionary() ?? [:])
}
}
// Required on iOS: the session is torn down and re-activated when the user
// switches to a different paired watch.
nonisolated func sessionDidBecomeInactive(_ session: WCSession) {}
nonisolated func sessionDidDeactivate(_ session: WCSession) {
WCSession.default.activate()
}
}
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.scarriffleservices.calendarr.ios.refresh</string>
</array>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
</array>
</dict>
</plist>
@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "AppIcon-1024.png",
"idiom" : "universal",
"platform" : "watchos",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.scarriffleservices.calendarr</string>
</array>
</dict>
</plist>
+19
View File
@@ -0,0 +1,19 @@
import SwiftUI
@main
struct CalendarrWatchApp: App {
@State private var model = WatchSnapshotModel()
init() {
// The session has to be live before the first payload arrives, and a
// complication push can wake this app with no UI on screen at all.
WatchSnapshotReceiver.shared.activate()
}
var body: some Scene {
WindowGroup {
WatchAgendaView()
.environment(model)
}
}
}
+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) }
}
}
@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,13 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "watchos",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.scarriffleservices.calendarr</string>
</array>
</dict>
</plist>
@@ -0,0 +1,161 @@
import WidgetKit
import SwiftUI
@main
struct CalendarrWatchComplicationBundle: WidgetBundle {
var body: some Widget {
WatchNextEventWidget()
WatchCornerCountdownWidget()
WatchCornerTitleWidget()
WatchCornerTimeWidget()
WatchCountdownWidget()
WatchDateWidget()
WatchTodayCountWidget()
WatchNextTwoWidget()
WatchDatePlusEventWidget()
}
}
private extension View {
/// Accessory families paint their own background; ours must stay out of
/// the way so the watch face's tint comes through.
func complicationChrome(_ language: String) -> some View {
containerBackground(for: .widget) { Color.clear }
.environment(\.locale, WatchL10n.locale(language))
}
}
struct WatchNextEventWidget: Widget {
let kind = "WatchNextEvent"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind,
intent: WatchCalendarSelectionIntent.self,
provider: WatchComplicationProvider()) { entry in
NextEventComplicationView(entry: entry).complicationChrome(entry.language)
}
.configurationDisplayName("Nächster Termin")
.description("Zeit, Titel und Ort des nächsten Termins.")
.supportedFamilies([.accessoryRectangular, .accessoryInline])
}
}
struct WatchCornerCountdownWidget: Widget {
let kind = "WatchCornerCountdown"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind,
intent: WatchCalendarSelectionIntent.self,
provider: WatchComplicationProvider()) { entry in
CornerCountdownComplicationView(entry: entry).complicationChrome(entry.language)
}
.configurationDisplayName("Restzeit (Ecke)")
.description("Restzeit bis zum nächsten Termin, Name am Rand.")
.supportedFamilies([.accessoryCorner])
}
}
struct WatchCornerTitleWidget: Widget {
let kind = "WatchCornerTitle"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind,
intent: WatchCalendarSelectionIntent.self,
provider: WatchComplicationProvider()) { entry in
CornerTitleComplicationView(entry: entry).complicationChrome(entry.language)
}
.configurationDisplayName("Terminname (Ecke)")
.description("Name des nächsten Termins, Uhrzeit klein am Rand.")
.supportedFamilies([.accessoryCorner])
}
}
struct WatchCornerTimeWidget: Widget {
let kind = "WatchCornerTime"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind,
intent: WatchCalendarSelectionIntent.self,
provider: WatchComplicationProvider()) { entry in
CornerTimeComplicationView(entry: entry).complicationChrome(entry.language)
}
.configurationDisplayName("Uhrzeit (Ecke)")
.description("Uhrzeit des nächsten Termins, Name am Rand.")
.supportedFamilies([.accessoryCorner])
}
}
struct WatchCountdownWidget: Widget {
let kind = "WatchCountdown"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind,
intent: WatchCalendarSelectionIntent.self,
provider: WatchComplicationProvider()) { entry in
CountdownComplicationView(entry: entry).complicationChrome(entry.language)
}
.configurationDisplayName("Countdown")
.description("Ring, der bis zum nächsten Termin leerläuft.")
.supportedFamilies([.accessoryCircular, .accessoryInline])
}
}
struct WatchDateWidget: Widget {
let kind = "WatchDate"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind,
intent: WatchCalendarSelectionIntent.self,
provider: WatchComplicationProvider()) { entry in
DateComplicationView(entry: entry).complicationChrome(entry.language)
}
.configurationDisplayName("Datum")
.description("Tag und Monat.")
.supportedFamilies([.accessoryCircular, .accessoryInline])
}
}
struct WatchTodayCountWidget: Widget {
let kind = "WatchTodayCount"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind,
intent: WatchCalendarSelectionIntent.self,
provider: WatchComplicationProvider()) { entry in
TodayCountComplicationView(entry: entry).complicationChrome(entry.language)
}
.configurationDisplayName("Termine heute")
.description("Anzahl der heutigen Termine.")
.supportedFamilies([.accessoryCircular, .accessoryInline])
}
}
struct WatchNextTwoWidget: Widget {
let kind = "WatchNextTwo"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind,
intent: WatchCalendarSelectionIntent.self,
provider: WatchComplicationProvider()) { entry in
NextTwoComplicationView(entry: entry).complicationChrome(entry.language)
}
.configurationDisplayName("Die nächsten zwei")
.description("Die beiden nächsten Termine.")
.supportedFamilies([.accessoryRectangular])
}
}
struct WatchDatePlusEventWidget: Widget {
let kind = "WatchDatePlusEvent"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind,
intent: WatchCalendarSelectionIntent.self,
provider: WatchComplicationProvider()) { entry in
DatePlusEventComplicationView(entry: entry).complicationChrome(entry.language)
}
.configurationDisplayName("Datum + Termin")
.description("Datum und nächster Termin in einem Slot.")
.supportedFamilies([.accessoryRectangular])
}
}
@@ -0,0 +1,90 @@
import SwiftUI
import WidgetKit
import CalendarrCore
// A corner holds **one** value plus the curved bezel label. A second inner line
// runs into the curved text — do not add one. `.widgetLabel` is what makes
// WidgetKit lay the text along the edge; there is no arc to draw by hand.
/// Value: live countdown. Label: time and title.
struct CornerCountdownComplicationView: View {
let entry: WatchComplicationEntry
var body: some View {
let next = WatchComplicationSupport.nextEvent(in: entry.snapshot, at: entry.date)
Group {
if let next {
Text(WatchComplicationSupport.compactCountdown(
to: WatchComplicationSupport.countdownTarget(next, at: entry.date),
at: entry.date,
language: entry.language))
.lineLimit(1)
.minimumScaleFactor(0.6)
.widgetAccentable()
} else {
Image(systemName: "calendar")
}
}
.widgetLabel {
Text(WatchComplicationSupport.eventLabel(next, at: entry.date, language: entry.language))
}
}
}
/// Value: the appointment's clock time. Label: the title.
struct CornerTimeComplicationView: View {
let entry: WatchComplicationEntry
var body: some View {
let next = WatchComplicationSupport.nextEvent(in: entry.snapshot, at: entry.date)
Group {
if let next {
Text(next.isAllDay
? WatchL10n.t("watch.allday", entry.language)
: WatchComplicationSupport.time(next.start, language: entry.language))
.lineLimit(1)
.minimumScaleFactor(0.6)
.widgetAccentable()
} else {
Image(systemName: "calendar")
}
}
.widgetLabel {
Text(next?.title ?? WatchL10n.t("watch.no_events", entry.language))
}
}
}
/// Value: the appointment's name. Label: the time, small and curved.
///
/// This is the inverse of how the slot is shaped — the curved bezel is where a
/// corner has room for long text, and the inner area holds three or four
/// characters. The title therefore shrinks and truncates here. Built this way
/// on request; `CornerTimeComplicationView` is the same information with the
/// roles the other way round, so both can be compared on the face.
struct CornerTitleComplicationView: View {
let entry: WatchComplicationEntry
var body: some View {
let next = WatchComplicationSupport.nextEvent(in: entry.snapshot, at: entry.date)
Group {
if let next {
Text(next.title)
.font(.system(size: 13, weight: .medium))
.lineLimit(2)
.minimumScaleFactor(0.5)
.multilineTextAlignment(.center)
.widgetAccentable()
} else {
Image(systemName: "calendar")
}
}
.widgetLabel {
Text(next.map {
$0.isAllDay
? WatchL10n.t("watch.allday", entry.language)
: WatchComplicationSupport.time($0.start, language: entry.language)
} ?? WatchL10n.t("watch.no_events", entry.language))
}
}
}
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>
@@ -0,0 +1,113 @@
import SwiftUI
import WidgetKit
import CalendarrCore
/// Time, title, location. The variant that answers the actual question.
struct NextEventComplicationView: View {
let entry: WatchComplicationEntry
@Environment(\.widgetFamily) private var family
var body: some View {
let next = WatchComplicationSupport.nextEvent(in: entry.snapshot, at: entry.date)
switch family {
case .accessoryInline:
inline(next)
default:
rectangular(next)
}
}
@ViewBuilder
private func rectangular(_ next: SnapshotEvent?) -> some View {
VStack(alignment: .leading, spacing: 1) {
if let next {
Text(next.isAllDay
? WatchL10n.t("watch.allday", entry.language)
: WatchComplicationSupport.time(next.start, language: entry.language))
.font(.system(size: 12, weight: .semibold))
.widgetAccentable()
Text(next.title)
.font(.system(size: 15))
.lineLimit(1)
if !next.location.isEmpty {
Text(next.location)
.font(.system(size: 11))
.lineLimit(1)
}
} else {
Image(systemName: "calendar").font(.system(size: 13)).widgetAccentable()
Text(WatchL10n.t("watch.no_events", entry.language)).font(.system(size: 13))
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private func inline(_ next: SnapshotEvent?) -> some View {
let text: String = {
guard let next else { return WatchL10n.t("watch.no_events", entry.language) }
if next.isAllDay { return next.title }
return "\(WatchComplicationSupport.time(next.start, language: entry.language)) \(next.title)"
}()
return Label(text, systemImage: "calendar")
}
}
/// The next two, so back-to-back appointments are visible at once.
struct NextTwoComplicationView: View {
let entry: WatchComplicationEntry
var body: some View {
let events = WatchComplicationSupport.upcoming(in: entry.snapshot, at: entry.date, limit: 2)
VStack(alignment: .leading, spacing: 3) {
if events.isEmpty {
Image(systemName: "calendar").font(.system(size: 13)).widgetAccentable()
Text(WatchL10n.t("watch.no_events", entry.language)).font(.system(size: 13))
} else {
ForEach(events) { event in
HStack(spacing: 5) {
Text(event.isAllDay
? WatchL10n.t("watch.allday", entry.language)
: WatchComplicationSupport.time(event.start, language: entry.language))
.font(.system(size: 12, weight: .semibold))
.widgetAccentable()
Text(event.title)
.font(.system(size: 13))
.lineLimit(1)
}
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}
/// Weekday and date plus the next appointment, so one slot does both jobs.
struct DatePlusEventComplicationView: View {
let entry: WatchComplicationEntry
var body: some View {
let next = WatchComplicationSupport.nextEvent(in: entry.snapshot, at: entry.date)
VStack(alignment: .leading, spacing: 2) {
Text(dateLine)
.font(.system(size: 11, weight: .semibold))
.widgetAccentable()
if let next {
Text(next.isAllDay
? next.title
: "\(WatchComplicationSupport.time(next.start, language: entry.language)) \(next.title)")
.font(.system(size: 14))
.lineLimit(1)
} else {
Text(WatchL10n.t("watch.no_events", entry.language)).font(.system(size: 14))
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private var dateLine: String {
let formatter = DateFormatter()
formatter.locale = WatchL10n.locale(entry.language)
formatter.setLocalizedDateFormatFromTemplate("EEE d MMMM")
return formatter.string(from: entry.date).uppercased()
}
}
@@ -0,0 +1,156 @@
import SwiftUI
import WidgetKit
import CalendarrCore
/// Attaches the curved bezel label that a face with a bezel slot — Infograph —
/// renders across the top, where the tick marks otherwise are. Faces without
/// that slot ignore it, so it costs nothing to always provide.
private extension View {
func eventBezelLabel(_ entry: WatchComplicationEntry) -> some View {
let next = WatchComplicationSupport.nextEvent(in: entry.snapshot, at: entry.date)
return widgetLabel {
Text(WatchComplicationSupport.eventLabel(next, at: entry.date, language: entry.language))
}
}
}
/// A draining ring plus the remaining time.
struct CountdownComplicationView: View {
let entry: WatchComplicationEntry
@Environment(\.widgetFamily) private var family
var body: some View {
let next = WatchComplicationSupport.nextEvent(in: entry.snapshot, at: entry.date)
switch family {
case .accessoryInline:
inline(next)
default:
circular(next)
}
}
@ViewBuilder
private func circular(_ next: SnapshotEvent?) -> some View {
if let next {
let target = WatchComplicationSupport.countdownTarget(next, at: entry.date)
Gauge(value: WatchComplicationSupport.gaugeFraction(to: target, at: entry.date)) {
Image(systemName: "calendar")
} currentValueLabel: {
Text(WatchComplicationSupport.compactCountdown(to: target, at: entry.date,
language: entry.language))
.font(.system(size: 13))
.lineLimit(1)
.minimumScaleFactor(0.6)
}
.gaugeStyle(.accessoryCircularCapacity)
.widgetAccentable()
.eventBezelLabel(entry)
} else {
ZStack {
AccessoryWidgetBackground()
Image(systemName: "calendar").font(.system(size: 18))
}
}
}
@ViewBuilder
private func inline(_ next: SnapshotEvent?) -> some View {
if let next {
Label {
Text(WatchComplicationSupport.countdownTarget(next, at: entry.date), style: .timer)
} icon: {
Image(systemName: "timer")
}
} else {
Label(WatchL10n.t("watch.no_events", entry.language), systemImage: "calendar")
}
}
}
/// Weekday and day number, the way a date is actually read. The ring appears
/// only while an appointment is running, and then shows how far through it we
/// are — an empty track around an idle day is noise, so there is no Gauge at
/// all in that case.
struct DateComplicationView: View {
let entry: WatchComplicationEntry
@Environment(\.widgetFamily) private var family
private var day: Int {
Calendar(identifier: .gregorian).component(.day, from: entry.date)
}
private var weekday: String {
WatchComplicationSupport.weekdayAbbreviation(entry.date, language: entry.language)
}
var body: some View {
switch family {
case .accessoryInline:
Label("\(weekday) \(day)", systemImage: "calendar")
default:
circular
}
}
@ViewBuilder
private var circular: some View {
if let running = WatchComplicationSupport.runningEvent(in: entry.snapshot, at: entry.date) {
Gauge(value: WatchComplicationSupport.progress(of: running, at: entry.date)) {
Text(weekday)
} currentValueLabel: {
dateStack
}
.gaugeStyle(.accessoryCircularCapacity)
.eventBezelLabel(entry)
} else {
ZStack {
AccessoryWidgetBackground()
dateStack
}
.eventBezelLabel(entry)
}
}
private var dateStack: some View {
VStack(spacing: -2) {
Text(weekday)
.font(.system(size: 11, weight: .semibold))
.widgetAccentable()
Text("\(day)")
.font(.system(size: 23, weight: .semibold))
.lineLimit(1)
.minimumScaleFactor(0.7)
}
}
}
/// How many appointments today. Says "busy", not "what".
struct TodayCountComplicationView: View {
let entry: WatchComplicationEntry
@Environment(\.widgetFamily) private var family
private var count: Int {
WatchComplicationSupport.todayCount(in: entry.snapshot, at: entry.date)
}
var body: some View {
switch family {
case .accessoryInline:
Label("\(count) \(WatchL10n.t("watch.events_today", entry.language))",
systemImage: "calendar")
default:
Gauge(value: WatchComplicationSupport.todayRemainingFraction(in: entry.snapshot,
at: entry.date)) {
Image(systemName: "calendar")
} currentValueLabel: {
Text("\(count)")
.font(.system(size: 20, weight: .semibold))
.lineLimit(1)
.minimumScaleFactor(0.6)
}
.gaugeStyle(.accessoryCircularCapacity)
.widgetAccentable()
.eventBezelLabel(entry)
}
}
}
@@ -0,0 +1,44 @@
import AppIntents
import WidgetKit
import CalendarrCore
/// One calendar option in a complication's configuration.
struct WatchCalendarEntity: AppEntity, Identifiable {
let id: String
let name: String
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Kalender"
static var defaultQuery = WatchCalendarEntityQuery()
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(title: LocalizedStringResource(stringLiteral: name))
}
}
/// Reads the calendars from this watch's App Group container, so configuration
/// needs no network call and no phone.
struct WatchCalendarEntityQuery: EntityQuery {
func entities(for identifiers: [String]) async throws -> [WatchCalendarEntity] {
let wanted = Set(identifiers)
return SnapshotStore().readCalendars()
.filter { wanted.contains($0.id) }
.map { WatchCalendarEntity(id: $0.id, name: $0.name) }
}
func suggestedEntities() async throws -> [WatchCalendarEntity] {
SnapshotStore().readCalendars().map { WatchCalendarEntity(id: $0.id, name: $0.name) }
}
}
/// Per-complication override of the calendar selection. The in-app toggles are
/// the primary path — intent configuration on a watch face is awkward to
/// operate — so an empty selection means "whatever the app is set to".
struct WatchCalendarSelectionIntent: WidgetConfigurationIntent {
static var title: LocalizedStringResource = "Kalender auswählen"
static var description = IntentDescription("Leer = die Auswahl aus der Watch-App.")
// Must be optional: WidgetConfigurationIntent requires optional parameter
// types, and the watchOS SDK enforces it where iOS lets a bare array pass.
@Parameter(title: "Kalender")
var selectedCalendars: [WatchCalendarEntity]?
}
@@ -0,0 +1,62 @@
import WidgetKit
import AppIntents
import SwiftUI
import CalendarrCore
struct WatchComplicationEntry: TimelineEntry {
let date: Date
let snapshot: CalendarrSnapshot?
var language: String { snapshot?.language ?? "system" }
}
/// Builds every Calendarr complication's timeline.
///
/// Entries land on event boundaries rather than on a fixed schedule, so the
/// complication flips in the exact minute the next appointment changes without
/// a network call and without spending refresh budget. New *data* arrives by
/// push from the phone, which is why the refresh policy is `.atEnd`: when the
/// timeline simply runs out, regenerating from the same snapshot is cheap and
/// correct.
struct WatchComplicationProvider: AppIntentTimelineProvider {
typealias Entry = WatchComplicationEntry
typealias Intent = WatchCalendarSelectionIntent
func placeholder(in context: Context) -> WatchComplicationEntry {
WatchComplicationEntry(date: .now, snapshot: nil)
}
func snapshot(for configuration: WatchCalendarSelectionIntent,
in context: Context) async -> WatchComplicationEntry {
WatchComplicationEntry(date: .now, snapshot: resolved(configuration))
}
func timeline(for configuration: WatchCalendarSelectionIntent,
in context: Context) async -> Timeline<WatchComplicationEntry> {
let snapshot = resolved(configuration)
let now = Date()
let dates = snapshot.map { ComplicationTimeline.entryDates(from: now, in: $0) } ?? [now]
return Timeline(entries: dates.map { WatchComplicationEntry(date: $0, snapshot: snapshot) },
policy: .atEnd)
}
/// What the complication gallery offers before anyone configures anything.
/// Required on watchOS — iOS has a default implementation, the watch does
/// not, because the gallery has to be populated.
///
/// One entry, with nothing selected: that means "whatever the app is set
/// to", which is the filter the user already chose there.
func recommendations() -> [AppIntentRecommendation<WatchCalendarSelectionIntent>] {
[AppIntentRecommendation(intent: WatchCalendarSelectionIntent(),
description: Text("Calendarr"))]
}
/// The app-level filter applies first; a per-complication selection narrows
/// further. An empty selection means "whatever the app is set to".
private func resolved(_ configuration: WatchCalendarSelectionIntent) -> CalendarrSnapshot? {
guard case .ok(let snapshot) = SnapshotStore().read() else { return nil }
return snapshot
.filtered(excludingCalendarKeys: WatchFilterStore.load())
.filtered(toCalendarKeys: Set((configuration.selectedCalendars ?? []).map(\.id)))
}
}
@@ -0,0 +1,131 @@
import Foundation
import CalendarrCore
enum WatchComplicationSupport {
/// The first event that has not ended yet — including one running now,
/// which is what "what is next" means when you are already in a meeting.
/// Matches `WatchPushPolicy.nextEventIdentity`, deliberately.
static func nextEvent(in snapshot: CalendarrSnapshot?, at now: Date) -> SnapshotEvent? {
snapshot?.events.filter { $0.end > now }.min { $0.start < $1.start }
}
static func upcoming(in snapshot: CalendarrSnapshot?, at now: Date, limit: Int) -> [SnapshotEvent] {
guard let snapshot else { return [] }
return Array(snapshot.events.filter { $0.end > now }.sorted { $0.start < $1.start }.prefix(limit))
}
static func todayCount(in snapshot: CalendarrSnapshot?, at now: Date,
calendar: Calendar = Calendar(identifier: .gregorian)) -> Int {
guard let snapshot else { return 0 }
let dayStart = calendar.startOfDay(for: now)
guard let dayEnd = calendar.date(byAdding: .day, value: 1, to: dayStart) else { return 0 }
return snapshot.events.filter { $0.start < dayEnd && $0.end > dayStart }.count
}
/// The appointment happening right now, if there is one. All-day events are
/// excluded on purpose: one of those is "running" for twenty-four hours and
/// would hold the ring at some arbitrary fraction all day, which says
/// nothing.
static func runningEvent(in snapshot: CalendarrSnapshot?, at now: Date) -> SnapshotEvent? {
snapshot?.events
.filter { !$0.isAllDay && $0.start <= now && $0.end > now }
.min { $0.end < $1.end }
}
/// How far through an appointment we are, 0 at its start and 1 at its end.
static func progress(of event: SnapshotEvent, at now: Date) -> Double {
let total = event.end.timeIntervalSince(event.start)
guard total > 0 else { return 0 }
return min(1, max(0, now.timeIntervalSince(event.start) / total))
}
static func weekdayAbbreviation(_ date: Date, language: String) -> String {
let formatter = DateFormatter()
formatter.locale = WatchL10n.locale(language)
formatter.dateFormat = "EEE"
return formatter.string(from: date)
.replacingOccurrences(of: ".", with: "")
.uppercased()
}
/// What a countdown counts to: the start if it has not begun, otherwise the
/// end. Counting up from a start time that has passed reads as broken.
static func countdownTarget(_ event: SnapshotEvent, at now: Date) -> Date {
event.start > now ? event.start : event.end
}
/// A countdown narrow enough for a corner or a circular gauge: at most
/// three characters. `Text(style: .timer)` would be live and free of
/// timeline entries, but renders `1:23:45` and overlaps whatever sits
/// beside it — so the timeline carries minute-granular entries instead and
/// this is recomputed per entry.
static func compactCountdown(to target: Date, at now: Date, language: String) -> String {
let seconds = target.timeIntervalSince(now)
guard seconds > 0 else { return WatchL10n.t("watch.now", language) }
let minutes = Int(seconds / 60)
if minutes < 1 { return WatchL10n.t("watch.now", language) }
if minutes < 60 { return "\(minutes)m" }
let hours = minutes / 60
if hours < 24 { return "\(hours)h" }
return "\(hours / 24)d"
}
/// How full a draining ring should be, over a two-hour lead window.
static func gaugeFraction(to target: Date, at now: Date) -> Double {
let window: TimeInterval = 2 * 3600
return min(1, max(0, target.timeIntervalSince(now) / window))
}
/// How much of today is still ahead. Drains through the day, matching the
/// countdown ring rather than inventing a second direction.
static func dayRemainingFraction(at now: Date,
calendar: Calendar = Calendar(identifier: .gregorian)) -> Double {
let dayStart = calendar.startOfDay(for: now)
guard let dayEnd = calendar.date(byAdding: .day, value: 1, to: dayStart) else { return 0 }
let total = dayEnd.timeIntervalSince(dayStart)
guard total > 0 else { return 0 }
return min(1, max(0, dayEnd.timeIntervalSince(now) / total))
}
/// The share of today's appointments still ahead. Zero when there are none,
/// which leaves the ring empty rather than full — an empty day should not
/// look like an untouched one.
static func todayRemainingFraction(in snapshot: CalendarrSnapshot?, at now: Date,
calendar: Calendar = Calendar(identifier: .gregorian)) -> Double {
guard let snapshot else { return 0 }
let dayStart = calendar.startOfDay(for: now)
guard let dayEnd = calendar.date(byAdding: .day, value: 1, to: dayStart) else { return 0 }
let today = snapshot.events.filter { $0.start < dayEnd && $0.end > dayStart }
guard !today.isEmpty else { return 0 }
let ahead = today.filter { $0.end > now }.count
return min(1, max(0, Double(ahead) / Double(today.count)))
}
static func time(_ date: Date, language: String) -> String {
let formatter = DateFormatter()
formatter.locale = WatchL10n.locale(language)
formatter.dateFormat = "HH:mm"
return formatter.string(from: date)
}
static func monthAbbreviation(_ date: Date, language: String) -> String {
let formatter = DateFormatter()
formatter.locale = WatchL10n.locale(language)
formatter.dateFormat = "LLL"
return formatter.string(from: date).uppercased()
}
/// The curved label that accompanies a circular or corner complication:
/// time plus title, or a plain "no events".
///
/// On a corner this follows the screen edge. On a face with a bezel slot —
/// Infograph — the same label arcs across the top, where the tick marks
/// otherwise are. That is the position ClockKit called `graphicBezel`, and
/// in WidgetKit it is reached by attaching `.widgetLabel` to an
/// `accessoryCircular` widget.
static func eventLabel(_ event: SnapshotEvent?, at now: Date, language: String) -> String {
guard let event else { return WatchL10n.t("watch.no_events", language) }
if event.isAllDay { return event.title }
return "\(time(event.start, language: language)) \(event.title)"
}
}
@@ -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",
],
]
}
@@ -0,0 +1,19 @@
//
// CalendarrWatch_Watch_AppTests.swift
// CalendarrWatch Watch AppTests
//
// Created by Guido Schmit on 11.09.2026.
//
import Testing
@testable import CalendarrWatch_Watch_App
struct CalendarrWatch_Watch_AppTests {
@Test func example() async throws {
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
// Swift Testing Documentation
// https://developer.apple.com/documentation/testing
}
}
@@ -0,0 +1,43 @@
//
// CalendarrWatch_Watch_AppUITests.swift
// CalendarrWatch Watch AppUITests
//
// Created by Guido Schmit on 11.09.2026.
//
import XCTest
final class CalendarrWatch_Watch_AppUITests: XCTestCase {
override func setUpWithError() throws {
// Put setup code here. This method is called before the invocation of each test method in the class.
// In UI tests it is usually best to stop immediately when a failure occurs.
continueAfterFailure = false
// In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
}
override func tearDownWithError() throws {
// Put teardown code here. This method is called after the invocation of each test method in the class.
}
@MainActor
func testExample() throws {
// UI tests must launch the application that they test.
let app = XCUIApplication()
app.launch()
// Use XCTAssert and related functions to verify your tests produce the correct results.
// XCUIAutomation Documentation
// https://developer.apple.com/documentation/xcuiautomation
}
@MainActor
func testLaunchPerformance() throws {
// This measures how long it takes to launch your application.
measure(metrics: [XCTApplicationLaunchMetric()]) {
XCUIApplication().launch()
}
}
}
@@ -1,5 +1,6 @@
import WidgetKit import WidgetKit
import AppIntents import AppIntents
import CalendarrCore
struct CalendarrEntry: TimelineEntry { struct CalendarrEntry: TimelineEntry {
let date: Date let date: Date
@@ -37,22 +38,9 @@ struct CalendarrTimelineProvider: AppIntentTimelineProvider {
// MARK: – Filtering // MARK: – Filtering
private func filtered(_ snapshot: WidgetSnapshot?, by config: CalendarSelectionIntent) -> WidgetSnapshot? { private func filtered(_ snapshot: WidgetSnapshot?, by config: CalendarSelectionIntent) -> WidgetSnapshot? {
guard let snapshot else { return nil } // The rule lives in CalendarrCore so the watch provider cannot drift
let ids = (config.selectedCalendars ?? []).map { $0.id } // from this one. Nothing selected means "show all calendars".
guard !ids.isEmpty else { return snapshot } // nothing selected = show all snapshot?.filtered(toCalendarKeys: Set((config.selectedCalendars ?? []).map(\.id)))
let keep = Set(ids)
// Filtering narrows the events but not the window: the snapshot still
// speaks for the same range, it just has fewer calendars in it.
return WidgetSnapshot(
writtenAt: snapshot.writtenAt,
coverageStart: snapshot.coverageStart,
coverageEnd: snapshot.coverageEnd,
isLoggedIn: snapshot.isLoggedIn,
writerVersion: snapshot.writerVersion,
events: snapshot.events.filter { keep.contains($0.calendarKey) },
theme: snapshot.theme,
language: snapshot.language
)
} }
} }
@@ -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.
@@ -12,12 +14,26 @@
--- ---
## Note on the source files
`Calendarr iOS/`, `CalendarrWidgets/` and `Shared/` are
`PBXFileSystemSynchronizedRootGroup`s, so **new `.swift` files are picked up
automatically** — no `project.pbxproj` surgery to add them. This does not extend
to new *targets* (Task 9), and it is why a partial Info.plist has to live
outside those folders (Task 7).
## Prerequisite (blocks device verification only) ## Prerequisite (blocks device verification only)
Xcode 26.6 ships the watchOS 26.5 SDK. The target device runs watchOS 27 beta, so builds cannot be installed on it until an Xcode with watchOS 27 support is present. Every task below can be written and build-verified without it. Do not block on this; note it when handing back. Xcode 26.6 ships the watchOS 26.5 SDK. The target device runs watchOS 27 beta, so builds cannot be installed on it until an Xcode with watchOS 27 support is present. Every task below can be written and build-verified without it. Do not block on this; note it when handing back.
## Working rules for this repo ## Working rules for this repo
- **Never pass `-sdk` to xcodebuild.** It overrides SDKROOT for every target in
the dependency graph, so the watch app gets built against the iOS SDK: the
build still succeeds, but `WKApplication` is missing from the result and
`DTSDKName` reads `iphonesimulator`. Select the platform with `-destination`
alone and let each target keep its own SDK.
- Never run or start the server, and never run the apps. Apps are **build-verified only**; behaviour, layout and complication refresh are verified by the user on the device. - Never run or start the server, and never run the apps. Apps are **build-verified only**; behaviour, layout and complication refresh are verified by the user on the device.
- Commit messages in English. Commit after every task. - Commit messages in English. Commit after every task.
- `CalendarrKit` is a **separate git repo** at `../CalendarrKit` with **no remote** — commit there, do not push. The iOS repo (`.`) pushes to `origin main`. - `CalendarrKit` is a **separate git repo** at `../CalendarrKit` with **no remote** — commit there, do not push. The iOS repo (`.`) pushes to `origin main`.
@@ -44,6 +60,7 @@ Xcode 26.6 ships the watchOS 26.5 SDK. The target device runs watchOS 27 beta, s
| File | Responsibility | | File | Responsibility |
|---|---| |---|---|
| `Calendarr-Info.plist` | Partial Info.plist for the two background-task array keys, merged into the generated one. At the project root, not in the target folder. |
| `Calendarr iOS/Services/BackgroundRefresh.swift` | `BGAppRefreshTask` registration and handling. | | `Calendarr iOS/Services/BackgroundRefresh.swift` | `BGAppRefreshTask` registration and handling. |
| `Calendarr iOS/Services/WatchSyncService.swift` | WCSession sender on the phone. | | `Calendarr iOS/Services/WatchSyncService.swift` | WCSession sender on the phone. |
| `CalendarrWatch/CalendarrWatchApp.swift` | watchOS app entry point. | | `CalendarrWatch/CalendarrWatchApp.swift` | watchOS app entry point. |
@@ -500,7 +517,7 @@ import CalendarrCore
- [ ] **Step 6: Build the iOS app to verify the adoption compiles** - [ ] **Step 6: Build the iOS app to verify the adoption compiles**
```bash ```bash
xcodebuild -project "Calendarr iOS.xcodeproj" -scheme "Calendarr iOS" -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build xcodebuild -project "Calendarr iOS.xcodeproj" -scheme "Calendarr iOS" -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build
``` ```
Expected: `** BUILD SUCCEEDED **`. Expected: `** BUILD SUCCEEDED **`.
@@ -1114,7 +1131,8 @@ Independently valuable: the home-screen widgets are currently only as fresh as t
**Files:** **Files:**
- Create: `Calendarr iOS/Services/BackgroundRefresh.swift` - Create: `Calendarr iOS/Services/BackgroundRefresh.swift`
- Modify: `Calendarr iOS/CalendarrApp.swift:5-13` - Modify: `Calendarr iOS/CalendarrApp.swift:5-13`
- Modify: `Calendarr iOS.xcodeproj/project.pbxproj` (two Info.plist build settings, both configurations) - Create: `Calendarr-Info.plist` (project root)
- Modify: `Calendarr iOS.xcodeproj/project.pbxproj` (`INFOPLIST_FILE`, both configurations)
- [ ] **Step 1: Write the refresh service** - [ ] **Step 1: Write the refresh service**
@@ -1230,37 +1248,51 @@ struct CalendarrApp: App {
} }
``` ```
- [ ] **Step 3: Add the Info.plist keys** - [x] **Step 3: Add the Info.plist keys**
In Xcode, select the **Calendarr iOS** target → Build Settings, and add to **both** Debug and Release: Both keys are arrays, and Xcode does **not** expose either as an
`INFOPLIST_KEY_` build setting — verified by setting them, building, and finding
them absent from the result while `INFOPLIST_KEY_UIApplicationSupportsIndirect\
InputEvents` did land. Use a partial Info.plist instead; generation stays on and
merges into it.
- `INFOPLIST_KEY_UIBackgroundModes` = `fetch` Create `Calendarr-Info.plist` at the **project root**:
- `INFOPLIST_KEY_BGTaskSchedulerPermittedIdentifiers` = `com.scarriffleservices.calendarr.ios.refresh`
- [ ] **Step 4: Build and verify the keys reached the built Info.plist**
```bash
xcodebuild -project "Calendarr iOS.xcodeproj" -scheme "Calendarr iOS" -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' -derivedDataPath /tmp/calendarr-dd CODE_SIGNING_ALLOWED=NO build
plutil -p "/tmp/calendarr-dd/Build/Products/Debug-iphonesimulator/Calendarr iOS.app/Info.plist" | grep -A3 -i "BGTaskScheduler\|UIBackgroundModes"
```
Expected: `** BUILD SUCCEEDED **`, and the output shows both `UIBackgroundModes` containing `fetch` and `BGTaskSchedulerPermittedIdentifiers` containing the identifier.
If `INFOPLIST_KEY_BGTaskSchedulerPermittedIdentifiers` does not appear, the build setting is not supported by this Xcode. In that case create `Calendarr iOS/Info.plist` containing only the two keys, set `INFOPLIST_FILE = Calendarr iOS/Info.plist` and `GENERATE_INFOPLIST_FILE = NO` on the target, and re-run this step:
```xml ```xml
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>UIBackgroundModes</key>
<array><string>fetch</string></array>
<key>BGTaskSchedulerPermittedIdentifiers</key> <key>BGTaskSchedulerPermittedIdentifiers</key>
<array><string>com.scarriffleservices.calendarr.ios.refresh</string></array> <array>
<string>com.scarriffleservices.calendarr.ios.refresh</string>
</array>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
</array>
</dict> </dict>
</plist> </plist>
``` ```
Then set `INFOPLIST_FILE = "Calendarr-Info.plist"` on the **Calendarr iOS**
target in both configurations, leaving `GENERATE_INFOPLIST_FILE = YES`.
The root location is load-bearing: `Calendarr iOS/` is a
`PBXFileSystemSynchronizedRootGroup`, so a plist inside it is also copied as a
resource and the build fails with "Multiple commands produce .../Info.plist".
- [x] **Step 4: Build and verify the merge lost nothing**
```bash
xcodebuild -project "Calendarr iOS.xcodeproj" -scheme "Calendarr iOS" -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' -derivedDataPath /tmp/calendarr-dd CODE_SIGNING_ALLOWED=NO build
plutil -p "/tmp/calendarr-dd/Build/Products/Release-iphonesimulator/Calendarr iOS.app/Info.plist" | grep -A3 -i "BGTaskScheduler\|UIBackgroundModes"
```
Note the scheme builds **Release**, not Debug. Expected: `** BUILD SUCCEEDED **`
and both keys present. Diff the whole plist against a build without the change
to confirm no generated key was displaced.
- [ ] **Step 5: Commit** - [ ] **Step 5: Commit**
```bash ```bash
@@ -1505,7 +1537,7 @@ In `Calendarr iOS/CalendarrApp.swift`, in `AppState.logout()`, after the existin
- [ ] **Step 5: Build the iOS app** - [ ] **Step 5: Build the iOS app**
```bash ```bash
xcodebuild -project "Calendarr iOS.xcodeproj" -scheme "Calendarr iOS" -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build xcodebuild -project "Calendarr iOS.xcodeproj" -scheme "Calendarr iOS" -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build
``` ```
Expected: `** BUILD SUCCEEDED **`. Expected: `** BUILD SUCCEEDED **`.
@@ -1591,7 +1623,7 @@ For both new bundle IDs, enable the **App Groups** capability and select `group.
- [ ] **Step 6: Verify both targets build** - [ ] **Step 6: Verify both targets build**
```bash ```bash
xcodebuild -project "Calendarr iOS.xcodeproj" -scheme CalendarrWatch -sdk watchsimulator -destination 'generic/platform=watchOS Simulator' CODE_SIGNING_ALLOWED=NO build xcodebuild -project "Calendarr iOS.xcodeproj" -scheme "Calendarr iOS" -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build
``` ```
Expected: `** BUILD SUCCEEDED **` (an empty app at this point). Expected: `** BUILD SUCCEEDED **` (an empty app at this point).
@@ -2325,7 +2357,7 @@ struct WatchCalendarFilterView: View {
- [ ] **Step 2: Build the watch app** - [ ] **Step 2: Build the watch app**
```bash ```bash
xcodebuild -project "Calendarr iOS.xcodeproj" -scheme CalendarrWatch -sdk watchsimulator -destination 'generic/platform=watchOS Simulator' CODE_SIGNING_ALLOWED=NO build xcodebuild -project "Calendarr iOS.xcodeproj" -scheme "Calendarr iOS" -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build
``` ```
Expected: `** BUILD SUCCEEDED **`. Expected: `** BUILD SUCCEEDED **`.
@@ -2988,7 +3020,7 @@ struct WatchDatePlusEventWidget: Widget {
- [ ] **Step 5: Build the complications target** - [ ] **Step 5: Build the complications target**
```bash ```bash
xcodebuild -project "Calendarr iOS.xcodeproj" -scheme CalendarrWatch -sdk watchsimulator -destination 'generic/platform=watchOS Simulator' CODE_SIGNING_ALLOWED=NO build xcodebuild -project "Calendarr iOS.xcodeproj" -scheme "Calendarr iOS" -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build
``` ```
Expected: `** BUILD SUCCEEDED **` — the scheme builds the embedded extension too. Expected: `** BUILD SUCCEEDED **` — the scheme builds the embedded extension too.
@@ -3022,8 +3054,8 @@ Expected: build succeeds against the native macOS SDK, every test passes.
```bash ```bash
cd "../Calendarr iOS" cd "../Calendarr iOS"
xcodebuild -project "Calendarr iOS.xcodeproj" -scheme "Calendarr iOS" -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build xcodebuild -project "Calendarr iOS.xcodeproj" -scheme "Calendarr iOS" -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build
xcodebuild -project "Calendarr iOS.xcodeproj" -scheme CalendarrWatch -sdk watchsimulator -destination 'generic/platform=watchOS Simulator' CODE_SIGNING_ALLOWED=NO build xcodebuild -project "Calendarr iOS.xcodeproj" -scheme "Calendarr iOS" -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build
``` ```
Expected: `** BUILD SUCCEEDED **` twice. Expected: `** BUILD SUCCEEDED **` twice.
@@ -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