feat(live): Live Activity / Dynamic Island (WidgetKit extension iOS 16.2+/17+)

Nouvelle target "CoachLiveActivity" (Widget Extension, bundle
ch.hypnotruck.coach.LiveActivity, iOS 17.0) embarquée dans App via copy phase
"Embed App Extensions" (dstSubfolderSpec=13). API ActivityKit + WidgetKit
vérifiées sur SDK iOS 26.5 swiftinterface (Activity.request(content:),
ActivityConfiguration, DynamicIsland(expanded:compactLeading:trailing:minimal:)).

Architecture :
- CoachLiveActivityAttributes (modèle partagé App ↔ extension)
- CoachLiveActivityWidget (Lock Screen + Dynamic Island UI)
- CoachLiveActivityBundle (@main WidgetBundle)
- LiveActivityManager (App side, wrap Activity.request/update/end)
- LiveStore hook : start sur 1er sample, update à chaque ingest, end après
  60s "lost". Tout guardé @available(iOS 16.2, *) pour préserver deployment iOS 15.
- Info.plist App : NSSupportsLiveActivities = YES.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain
2026-05-26 19:51:57 +02:00
parent f65d96e908
commit a5795bf83f
8 changed files with 533 additions and 2 deletions

View File

@@ -9,6 +9,7 @@
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
01468471ECA28EA66AFE2D6C /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0EB6C76F68679844EC72178F /* Foundation.framework */; }; 01468471ECA28EA66AFE2D6C /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0EB6C76F68679844EC72178F /* Foundation.framework */; };
0318E7E610501FC1F958251B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3342ACCE085010828CF63565 /* Assets.xcassets */; }; 0318E7E610501FC1F958251B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3342ACCE085010828CF63565 /* Assets.xcassets */; };
1B690F0D16AB76A6854CA1F7 /* CoachLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431C7B10248D8EED4D397AB0 /* CoachLiveActivityAttributes.swift */; };
21B242F8E61FB07C476C477C /* CoachLiveBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9DF3346C49A690C3C3DF2F3 /* CoachLiveBridge.swift */; }; 21B242F8E61FB07C476C477C /* CoachLiveBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9DF3346C49A690C3C3DF2F3 /* CoachLiveBridge.swift */; };
290D0F17978B7EF3DDB3A792 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A06CDFF5B4F7852B6329862 /* ContentView.swift */; }; 290D0F17978B7EF3DDB3A792 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A06CDFF5B4F7852B6329862 /* ContentView.swift */; };
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; }; 2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
@@ -23,12 +24,18 @@
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; }; 50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
543A7005429A43F54C0EC61B /* CoachWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6BEAEF6F6274D61CAF22636 /* CoachWatchApp.swift */; }; 543A7005429A43F54C0EC61B /* CoachWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6BEAEF6F6274D61CAF22636 /* CoachWatchApp.swift */; };
548F8BFA06417B35474BD69E /* CoachLiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 340B99D5EA4DB985D7DFDA10 /* CoachLiveView.swift */; }; 548F8BFA06417B35474BD69E /* CoachLiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 340B99D5EA4DB985D7DFDA10 /* CoachLiveView.swift */; };
694B2F69591CAA85CE5DEC3A /* CoachLiveActivityWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBBF61A35F291A021BA75C60 /* CoachLiveActivityWidget.swift */; };
88F28EB12FBB330E00E8306E /* CoachWorkoutObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88F28EB02FBB330E00E8306E /* CoachWorkoutObserver.swift */; }; 88F28EB12FBB330E00E8306E /* CoachWorkoutObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88F28EB02FBB330E00E8306E /* CoachWorkoutObserver.swift */; };
97116C31D4CC40F71A6A0FE7 /* LiveStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6030BD533EBC2D1D26DB9479 /* LiveStore.swift */; }; 97116C31D4CC40F71A6A0FE7 /* LiveStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6030BD533EBC2D1D26DB9479 /* LiveStore.swift */; };
9BED4C04C38FCB8634CE2A94 /* CoachLiveActivity.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 9B5664FCDB4DDE62514D3A1E /* CoachLiveActivity.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
9E88D51C0B0255D24274DACC /* CoachLiveActivityBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BA75EEC3CBF1F46A9028C66 /* CoachLiveActivityBundle.swift */; };
B06AB24B350B221F38D99E17 /* ConnectivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A420239FC6D065C8A599B61C /* ConnectivityManager.swift */; }; B06AB24B350B221F38D99E17 /* ConnectivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A420239FC6D065C8A599B61C /* ConnectivityManager.swift */; };
C0AC4A07811751FB78AA0001 /* CoachAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4A07811751FB78AA0002 /* CoachAuth.swift */; }; C0AC4A07811751FB78AA0001 /* CoachAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AC4A07811751FB78AA0002 /* CoachAuth.swift */; };
D1384CFA4960060CDBF05FC3 /* CoachLiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431C7B10248D8EED4D397AB0 /* CoachLiveActivityAttributes.swift */; };
D1BD5B1990CF3F2B9C5D0001 /* CoachWorkoutKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */; }; D1BD5B1990CF3F2B9C5D0001 /* CoachWorkoutKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */; };
E1E8BD7CF9C19E56CE0F57C4 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D61D7B2F5AFEF834423E07AB /* Foundation.framework */; };
E2CE6C2AA1DF4F3CAD6E0001 /* MainViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2CE6C2AA1DF4F3CAD6E0002 /* MainViewController.swift */; }; E2CE6C2AA1DF4F3CAD6E0001 /* MainViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2CE6C2AA1DF4F3CAD6E0002 /* MainViewController.swift */; };
E547F675B2F61C3475D2A787 /* LiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B93DA6B121E94B11DF938C90 /* LiveActivityManager.swift */; };
F1ABCDEF0123456789AB0001 /* CoachHealthRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1ABCDEF0123456789AB0002 /* CoachHealthRoute.swift */; }; F1ABCDEF0123456789AB0001 /* CoachHealthRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1ABCDEF0123456789AB0002 /* CoachHealthRoute.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
@@ -40,9 +47,27 @@
remoteGlobalIDString = D5BF41648D334DC24770B9D2; remoteGlobalIDString = D5BF41648D334DC24770B9D2;
remoteInfo = CoachWatch; remoteInfo = CoachWatch;
}; };
9D160400D2AE8723B1112F2F /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 504EC2FC1FED79650016851F /* Project object */;
proxyType = 1;
remoteGlobalIDString = 28FB3582F417B2F3C6BFC5D1;
remoteInfo = CoachLiveActivity;
};
/* End PBXContainerItemProxy section */ /* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */ /* Begin PBXCopyFilesBuildPhase section */
C4916F81E61FABAAD62EE4F9 /* Embed App Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
9BED4C04C38FCB8634CE2A94 /* CoachLiveActivity.appex in Embed App Extensions */,
);
name = "Embed App Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
F50D9A7DFABB52EE96BB93D6 /* Embed Watch Content */ = { F50D9A7DFABB52EE96BB93D6 /* Embed Watch Content */ = {
isa = PBXCopyFilesBuildPhase; isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@@ -58,10 +83,12 @@
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
0EB6C76F68679844EC72178F /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/WatchOS.platform/Developer/SDKs/WatchOS11.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; 0EB6C76F68679844EC72178F /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/WatchOS.platform/Developer/SDKs/WatchOS11.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; };
1BA75EEC3CBF1F46A9028C66 /* CoachLiveActivityBundle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachLiveActivityBundle.swift; sourceTree = "<group>"; };
2517374227A4072A4D6AFC15 /* CoachWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CoachWatch.app; sourceTree = BUILT_PRODUCTS_DIR; }; 2517374227A4072A4D6AFC15 /* CoachWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CoachWatch.app; sourceTree = BUILT_PRODUCTS_DIR; };
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; }; 2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
3342ACCE085010828CF63565 /* Assets.xcassets */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; 3342ACCE085010828CF63565 /* Assets.xcassets */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
340B99D5EA4DB985D7DFDA10 /* CoachLiveView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachLiveView.swift; sourceTree = "<group>"; }; 340B99D5EA4DB985D7DFDA10 /* CoachLiveView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachLiveView.swift; sourceTree = "<group>"; };
431C7B10248D8EED4D397AB0 /* CoachLiveActivityAttributes.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachLiveActivityAttributes.swift; sourceTree = "<group>"; };
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; }; 50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; }; 504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; };
504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; 504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
@@ -77,14 +104,19 @@
8A06CDFF5B4F7852B6329862 /* ContentView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; }; 8A06CDFF5B4F7852B6329862 /* ContentView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WorkoutManager.swift; sourceTree = "<group>"; }; 8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WorkoutManager.swift; sourceTree = "<group>"; };
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; }; 958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
9B5664FCDB4DDE62514D3A1E /* CoachLiveActivity.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = CoachLiveActivity.appex; sourceTree = BUILT_PRODUCTS_DIR; };
A420239FC6D065C8A599B61C /* ConnectivityManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConnectivityManager.swift; sourceTree = "<group>"; }; A420239FC6D065C8A599B61C /* ConnectivityManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConnectivityManager.swift; sourceTree = "<group>"; };
A9DF3346C49A690C3C3DF2F3 /* CoachLiveBridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachLiveBridge.swift; sourceTree = "<group>"; }; A9DF3346C49A690C3C3DF2F3 /* CoachLiveBridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachLiveBridge.swift; sourceTree = "<group>"; };
B93DA6B121E94B11DF938C90 /* LiveActivityManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = "<group>"; };
C0AC4A07811751FB78AA0002 /* CoachAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachAuth.swift; sourceTree = "<group>"; }; C0AC4A07811751FB78AA0002 /* CoachAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachAuth.swift; sourceTree = "<group>"; };
D09B589FA572B2C14E8CD835 /* CoachWatch.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = CoachWatch.entitlements; sourceTree = "<group>"; }; D09B589FA572B2C14E8CD835 /* CoachWatch.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = CoachWatch.entitlements; sourceTree = "<group>"; };
D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachWorkoutKit.swift; sourceTree = "<group>"; }; D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachWorkoutKit.swift; sourceTree = "<group>"; };
D61D7B2F5AFEF834423E07AB /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; };
E2CE6C2AA1DF4F3CAD6E0002 /* MainViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewController.swift; sourceTree = "<group>"; }; E2CE6C2AA1DF4F3CAD6E0002 /* MainViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewController.swift; sourceTree = "<group>"; };
EBBF61A35F291A021BA75C60 /* CoachLiveActivityWidget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachLiveActivityWidget.swift; sourceTree = "<group>"; };
F1ABCDEF0123456789AB0002 /* CoachHealthRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachHealthRoute.swift; sourceTree = "<group>"; }; F1ABCDEF0123456789AB0002 /* CoachHealthRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachHealthRoute.swift; sourceTree = "<group>"; };
F6BEAEF6F6274D61CAF22636 /* CoachWatchApp.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachWatchApp.swift; sourceTree = "<group>"; }; F6BEAEF6F6274D61CAF22636 /* CoachWatchApp.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachWatchApp.swift; sourceTree = "<group>"; };
FBFCA969A1B083CD3178D9B2 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@@ -104,6 +136,14 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
D8B443A150F5A7E1C135ABAA /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
E1E8BD7CF9C19E56CE0F57C4 /* Foundation.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
@@ -115,6 +155,18 @@
name = watchOS; name = watchOS;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
460F58BF290358F572689016 /* CoachLiveActivity */ = {
isa = PBXGroup;
children = (
431C7B10248D8EED4D397AB0 /* CoachLiveActivityAttributes.swift */,
EBBF61A35F291A021BA75C60 /* CoachLiveActivityWidget.swift */,
1BA75EEC3CBF1F46A9028C66 /* CoachLiveActivityBundle.swift */,
FBFCA969A1B083CD3178D9B2 /* Info.plist */,
);
name = CoachLiveActivity;
path = CoachLiveActivity;
sourceTree = "<group>";
};
504EC2FB1FED79650016851F = { 504EC2FB1FED79650016851F = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -123,6 +175,7 @@
504EC3051FED79650016851F /* Products */, 504EC3051FED79650016851F /* Products */,
B91ACF72653FD495148698CF /* Frameworks */, B91ACF72653FD495148698CF /* Frameworks */,
5A8EA0CF92316E437E9053A0 /* CoachWatch */, 5A8EA0CF92316E437E9053A0 /* CoachWatch */,
460F58BF290358F572689016 /* CoachLiveActivity */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@@ -131,6 +184,7 @@
children = ( children = (
504EC3041FED79650016851F /* App.app */, 504EC3041FED79650016851F /* App.app */,
2517374227A4072A4D6AFC15 /* CoachWatch.app */, 2517374227A4072A4D6AFC15 /* CoachWatch.app */,
9B5664FCDB4DDE62514D3A1E /* CoachLiveActivity.appex */,
); );
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -155,6 +209,7 @@
A9DF3346C49A690C3C3DF2F3 /* CoachLiveBridge.swift */, A9DF3346C49A690C3C3DF2F3 /* CoachLiveBridge.swift */,
6030BD533EBC2D1D26DB9479 /* LiveStore.swift */, 6030BD533EBC2D1D26DB9479 /* LiveStore.swift */,
340B99D5EA4DB985D7DFDA10 /* CoachLiveView.swift */, 340B99D5EA4DB985D7DFDA10 /* CoachLiveView.swift */,
B93DA6B121E94B11DF938C90 /* LiveActivityManager.swift */,
); );
path = App; path = App;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -173,10 +228,19 @@
path = CoachWatch; path = CoachWatch;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
7497026ECEFF81CCD11C289D /* iOS */ = {
isa = PBXGroup;
children = (
D61D7B2F5AFEF834423E07AB /* Foundation.framework */,
);
name = iOS;
sourceTree = "<group>";
};
B91ACF72653FD495148698CF /* Frameworks */ = { B91ACF72653FD495148698CF /* Frameworks */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
3E58B60E9B4F1CA14B5EC667 /* watchOS */, 3E58B60E9B4F1CA14B5EC667 /* watchOS */,
7497026ECEFF81CCD11C289D /* iOS */,
); );
name = Frameworks; name = Frameworks;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -184,6 +248,23 @@
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
28FB3582F417B2F3C6BFC5D1 /* CoachLiveActivity */ = {
isa = PBXNativeTarget;
buildConfigurationList = C2671D2741138A4A55CE1ED0 /* Build configuration list for PBXNativeTarget "CoachLiveActivity" */;
buildPhases = (
2D25180D64B3326AE6F9D91B /* Sources */,
D8B443A150F5A7E1C135ABAA /* Frameworks */,
411CB19BB5DB127758A86A2D /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = CoachLiveActivity;
productName = CoachLiveActivity;
productReference = 9B5664FCDB4DDE62514D3A1E /* CoachLiveActivity.appex */;
productType = "com.apple.product-type.app-extension";
};
504EC3031FED79650016851F /* App */ = { 504EC3031FED79650016851F /* App */ = {
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */; buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */;
@@ -192,11 +273,13 @@
504EC3011FED79650016851F /* Frameworks */, 504EC3011FED79650016851F /* Frameworks */,
504EC3021FED79650016851F /* Resources */, 504EC3021FED79650016851F /* Resources */,
F50D9A7DFABB52EE96BB93D6 /* Embed Watch Content */, F50D9A7DFABB52EE96BB93D6 /* Embed Watch Content */,
C4916F81E61FABAAD62EE4F9 /* Embed App Extensions */,
); );
buildRules = ( buildRules = (
); );
dependencies = ( dependencies = (
EEEAB45810829EE8B3C4976E /* PBXTargetDependency */, EEEAB45810829EE8B3C4976E /* PBXTargetDependency */,
BACE810C3CD85E302BBFEC84 /* PBXTargetDependency */,
); );
name = App; name = App;
packageProductDependencies = ( packageProductDependencies = (
@@ -258,11 +341,19 @@
targets = ( targets = (
504EC3031FED79650016851F /* App */, 504EC3031FED79650016851F /* App */,
D5BF41648D334DC24770B9D2 /* CoachWatch */, D5BF41648D334DC24770B9D2 /* CoachWatch */,
28FB3582F417B2F3C6BFC5D1 /* CoachLiveActivity */,
); );
}; };
/* End PBXProject section */ /* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */ /* Begin PBXResourcesBuildPhase section */
411CB19BB5DB127758A86A2D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
504EC3021FED79650016851F /* Resources */ = { 504EC3021FED79650016851F /* Resources */ = {
isa = PBXResourcesBuildPhase; isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@@ -287,6 +378,16 @@
/* End PBXResourcesBuildPhase section */ /* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
2D25180D64B3326AE6F9D91B /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
1B690F0D16AB76A6854CA1F7 /* CoachLiveActivityAttributes.swift in Sources */,
694B2F69591CAA85CE5DEC3A /* CoachLiveActivityWidget.swift in Sources */,
9E88D51C0B0255D24274DACC /* CoachLiveActivityBundle.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
504EC3001FED79650016851F /* Sources */ = { 504EC3001FED79650016851F /* Sources */ = {
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@@ -300,6 +401,8 @@
21B242F8E61FB07C476C477C /* CoachLiveBridge.swift in Sources */, 21B242F8E61FB07C476C477C /* CoachLiveBridge.swift in Sources */,
97116C31D4CC40F71A6A0FE7 /* LiveStore.swift in Sources */, 97116C31D4CC40F71A6A0FE7 /* LiveStore.swift in Sources */,
548F8BFA06417B35474BD69E /* CoachLiveView.swift in Sources */, 548F8BFA06417B35474BD69E /* CoachLiveView.swift in Sources */,
D1384CFA4960060CDBF05FC3 /* CoachLiveActivityAttributes.swift in Sources */,
E547F675B2F61C3475D2A787 /* LiveActivityManager.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -317,6 +420,12 @@
/* End PBXSourcesBuildPhase section */ /* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */ /* Begin PBXTargetDependency section */
BACE810C3CD85E302BBFEC84 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
name = CoachLiveActivity;
target = 28FB3582F417B2F3C6BFC5D1 /* CoachLiveActivity */;
targetProxy = 9D160400D2AE8723B1112F2F /* PBXContainerItemProxy */;
};
EEEAB45810829EE8B3C4976E /* PBXTargetDependency */ = { EEEAB45810829EE8B3C4976E /* PBXTargetDependency */ = {
isa = PBXTargetDependency; isa = PBXTargetDependency;
name = CoachWatch; name = CoachWatch;
@@ -575,6 +684,63 @@
}; };
name = Release; name = Release;
}; };
90CE3C22F60C6432852DC1B1 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = "";
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22;
DEVELOPMENT_TEAM = TZ2PVTRKNY;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = CoachLiveActivity/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.2;
PRODUCT_BUNDLE_IDENTIFIER = ch.hypnotruck.coach.LiveActivity;
PRODUCT_NAME = CoachLiveActivity;
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
C7A0838F3DA0842EDE320050 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = "";
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 22;
DEVELOPMENT_TEAM = TZ2PVTRKNY;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = CoachLiveActivity/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.2;
PRODUCT_BUNDLE_IDENTIFIER = ch.hypnotruck.coach.LiveActivity;
PRODUCT_NAME = CoachLiveActivity;
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
/* End XCBuildConfiguration section */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */ /* Begin XCConfigurationList section */
@@ -596,6 +762,15 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
C2671D2741138A4A55CE1ED0 /* Build configuration list for PBXNativeTarget "CoachLiveActivity" */ = {
isa = XCConfigurationList;
buildConfigurations = (
C7A0838F3DA0842EDE320050 /* Release */,
90CE3C22F60C6432852DC1B1 /* Debug */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
FA2A9602402AA384726BFF97 /* Build configuration list for PBXNativeTarget "CoachWatch" */ = { FA2A9602402AA384726BFF97 /* Build configuration list for PBXNativeTarget "CoachWatch" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (

View File

@@ -38,6 +38,8 @@
<string>Coach Hypnotruck utilise votre position pour tracer vos sorties course et VTT.</string> <string>Coach Hypnotruck utilise votre position pour tracer vos sorties course et VTT.</string>
<key>NSMotionUsageDescription</key> <key>NSMotionUsageDescription</key>
<string>Coach Hypnotruck utilise les capteurs de mouvement pour mesurer votre activité.</string> <string>Coach Hypnotruck utilise les capteurs de mouvement pour mesurer votre activité.</string>
<key>NSSupportsLiveActivities</key>
<true/>
<key>NSPhotoLibraryUsageDescription</key> <key>NSPhotoLibraryUsageDescription</key>
<string>Coach Hypnotruck peut accéder à vos photos pour ajouter une photo de profil.</string> <string>Coach Hypnotruck peut accéder à vos photos pour ajouter une photo de profil.</string>
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>

View File

@@ -0,0 +1,77 @@
// LiveActivityManager.swift
// Wrapper main-actor autour d'ActivityKit pour la Live Activity / Dynamic
// Island du live workout. Appelé par LiveStore à chaque transition / sample.
// API ActivityKit vérifiée sur iOS 26.5 SDK swiftinterface :
// Activity.request(attributes:, content: ActivityContent<...>, pushType:)
// activity.update(_ content: ActivityContent<...>) async
// activity.end(_ content: ActivityContent<...>?, dismissalPolicy:) async
// ActivityAuthorizationInfo().areActivitiesEnabled
import ActivityKit
import Foundation
import os
@available(iOS 16.2, *)
@MainActor
final class LiveActivityManager {
static let shared = LiveActivityManager()
private var current: Activity<CoachLiveActivityAttributes>?
private static let log = Logger(subsystem: "ch.hypnotruck.coach", category: "live-activity")
private init() {}
private var isAuthorized: Bool {
ActivityAuthorizationInfo().areActivitiesEnabled
}
/// Démarre l'activité si pas déjà en cours. Si déjà en cours, met à jour.
func start(label: String,
symbol: String,
state: CoachLiveActivityAttributes.ContentState) {
if current != nil {
update(state: state)
return
}
guard isAuthorized else {
Self.log.warning("Live Activities not authorized (user disabled in Settings)")
return
}
let attrs = CoachLiveActivityAttributes(
activityLabel: label,
activitySymbol: symbol,
startedAt: Date()
)
let content = ActivityContent(
state: state,
staleDate: Date().addingTimeInterval(60)
)
do {
current = try Activity.request(
attributes: attrs,
content: content,
pushType: nil
)
Self.log.info("Live Activity started: \(label, privacy: .public)")
} catch {
Self.log.error("Failed to start Live Activity: \(error.localizedDescription, privacy: .public)")
}
}
/// Met à jour l'activité en cours (no-op si pas démarrée).
func update(state: CoachLiveActivityAttributes.ContentState) {
guard let activity = current else { return }
let content = ActivityContent(
state: state,
staleDate: Date().addingTimeInterval(60)
)
Task { await activity.update(content) }
}
/// Termine l'activité. dismissalPolicy .default = bannière reste ~4h.
func end() {
guard let activity = current else { return }
current = nil
Task { await activity.end(nil, dismissalPolicy: .default) }
Self.log.info("Live Activity ended")
}
}

View File

@@ -27,14 +27,31 @@ final class LiveStore: ObservableObject {
private var watchdog: Timer? private var watchdog: Timer?
private let lostThreshold: TimeInterval = 8 private let lostThreshold: TimeInterval = 8
private let endAfterLost: TimeInterval = 60
private var lostSince: Date?
private init() { private init() {
watchdog = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in watchdog = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
guard let self else { return } guard let self else { return }
guard self.status == .live, let ts = self.lastSampleAt else { return } guard let ts = self.lastSampleAt else { return }
if Date().timeIntervalSince(ts) > self.lostThreshold { let sinceLast = Date().timeIntervalSince(ts)
if self.status == .live, sinceLast > self.lostThreshold {
// Transition live -> lost : signale à la Live Activity
// que la connexion est perdue (heartbeat off, statut orange).
self.status = .lost self.status = .lost
self.lostSince = Date()
if #available(iOS 16.2, *) {
LiveActivityManager.shared.update(state: self.currentState(isLive: false))
}
} else if self.status == .lost,
let lostAt = self.lostSince,
Date().timeIntervalSince(lostAt) > self.endAfterLost {
// Resté .lost trop longtemps : on termine l'activité.
if #available(iOS 16.2, *) {
LiveActivityManager.shared.end()
}
self.lostSince = nil
} }
} }
} }
@@ -51,6 +68,7 @@ final class LiveStore: ObservableObject {
let act = sample["activityType"] as? String let act = sample["activityType"] as? String
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
guard let self else { return } guard let self else { return }
let wasNotLive = self.status != .live
if let v = hr { self.heartRate = v } if let v = hr { self.heartRate = v }
if let v = kcal { self.activeEnergyKcal = v } if let v = kcal { self.activeEnergyKcal = v }
if let v = dist { self.distanceMeters = v } if let v = dist { self.distanceMeters = v }
@@ -58,8 +76,35 @@ final class LiveStore: ObservableObject {
if let v = act { self.activityType = v } if let v = act { self.activityType = v }
self.lastSampleAt = Date() self.lastSampleAt = Date()
self.status = .live self.status = .live
self.lostSince = nil
// Hook Live Activity / Dynamic Island (iOS 16.1+ uniquement,
// l'app cible iOS 15 mais ActivityKit n'existe qu'à partir de 16.1)
if #available(iOS 16.2, *) {
let state = self.currentState(isLive: true)
if wasNotLive {
LiveActivityManager.shared.start(
label: self.activityLabel ?? "Séance",
symbol: self.activitySymbol,
state: state
)
} else {
LiveActivityManager.shared.update(state: state)
} }
} }
}
}
@available(iOS 16.2, *)
private func currentState(isLive: Bool) -> CoachLiveActivityAttributes.ContentState {
CoachLiveActivityAttributes.ContentState(
heartRate: heartRate,
activeEnergyKcal: activeEnergyKcal,
distanceMeters: distanceMeters,
elapsedSec: elapsedSec,
isLive: isLive
)
}
// MARK: - Formatted accessors (utilisés par CoachLiveView) // MARK: - Formatted accessors (utilisés par CoachLiveView)
@@ -95,4 +140,15 @@ final class LiveStore: ObservableObject {
default: return nil default: return nil
} }
} }
/// SF Symbol pour l'activité (utilisé par la Live Activity).
var activitySymbol: String {
switch activityType {
case "cycling": return "bicycle"
case "walking": return "figure.walk"
case "hiking": return "figure.walk"
case "strength": return "dumbbell.fill"
case "mobility": return "figure.yoga"
default: return "figure.run"
}
}
} }

View File

@@ -0,0 +1,23 @@
// CoachLiveActivityAttributes.swift
// Modèle partagé App Widget Extension (membre des 2 targets).
// Définit ce que la Live Activity / Dynamic Island affiche.
import ActivityKit
import Foundation
@available(iOS 16.2, *)
struct CoachLiveActivityAttributes: ActivityAttributes {
/// Données dynamiques (mises à jour à chaque sample reçu de la Watch).
public struct ContentState: Codable, Hashable {
var heartRate: Double
var activeEnergyKcal: Double
var distanceMeters: Double
var elapsedSec: TimeInterval
var isLive: Bool // true = direct ; false = perdu/en pause
}
/// Données fixes (set au start, non modifiables).
var activityLabel: String // "Course", "Vélo", "Marche", "Renfo", ou "Séance"
var activitySymbol: String // SF Symbol (figure.run, bicycle, figure.walk, dumbbell)
var startedAt: Date
}

View File

@@ -0,0 +1,13 @@
// CoachLiveActivityBundle.swift
// Entry point @main de l'extension WidgetKit. Contient le ou les Widgets
// exposés par cette extension. Ici juste la Live Activity du live workout.
import WidgetKit
import SwiftUI
@main
struct CoachLiveActivityBundle: WidgetBundle {
var body: some Widget {
CoachLiveActivityWidget()
}
}

View File

@@ -0,0 +1,158 @@
// CoachLiveActivityWidget.swift
// Définition Widget pour la Live Activity (Lock Screen + Dynamic Island).
// Structure validée sur le template Apple Xcode 26.5 (Widget Extension /
// LiveActivity.swift) + SDK swiftinterface WidgetKit iOS 26.5
// (DynamicIsland init signature : expanded:compactLeading:compactTrailing:minimal:).
import ActivityKit
import WidgetKit
import SwiftUI
struct CoachLiveActivityWidget: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: CoachLiveActivityAttributes.self) { context in
// Lock screen / banner
lockScreenView(context: context)
.activityBackgroundTint(Color.black.opacity(0.55))
.activitySystemActionForegroundColor(.white)
} dynamicIsland: { context in
DynamicIsland {
// Expanded UI (long-press l'île)
DynamicIslandExpandedRegion(.leading) {
metricExpanded(label: "FC",
value: hrText(context.state),
color: .red,
symbol: "heart.fill")
}
DynamicIslandExpandedRegion(.trailing) {
metricExpanded(label: "Kcal",
value: kcalText(context.state),
color: .green,
symbol: "flame.fill")
}
DynamicIslandExpandedRegion(.center) {
Label(context.attributes.activityLabel,
systemImage: context.attributes.activitySymbol)
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
}
DynamicIslandExpandedRegion(.bottom) {
HStack(spacing: 18) {
metricInline(label: "Distance",
value: distKmText(context.state),
unit: "km",
color: .orange)
Divider().frame(height: 28)
metricInline(label: "Durée",
value: elapsedText(context.state.elapsedSec),
unit: "",
color: .white)
}
.frame(maxWidth: .infinity)
}
} compactLeading: {
HStack(spacing: 3) {
Image(systemName: "heart.fill")
.foregroundStyle(.red)
Text(hrText(context.state))
.font(.caption2.weight(.bold).monospacedDigit())
}
} compactTrailing: {
Text(elapsedText(context.state.elapsedSec))
.font(.caption2.weight(.semibold).monospacedDigit())
.foregroundStyle(.white)
} minimal: {
Image(systemName: "heart.fill")
.foregroundStyle(.red)
.symbolEffect(.pulse, isActive: context.state.isLive)
}
.keylineTint(.red)
}
}
// MARK: - Lock screen layout
@ViewBuilder
private func lockScreenView(context: ActivityViewContext<CoachLiveActivityAttributes>) -> some View {
VStack(spacing: 12) {
HStack(spacing: 8) {
Image(systemName: context.attributes.activitySymbol)
.foregroundStyle(.white)
Text(context.attributes.activityLabel)
.font(.headline)
Spacer()
Image(systemName: "heart.fill")
.foregroundStyle(.red)
.symbolEffect(.pulse, isActive: context.state.isLive)
Text(context.state.isLive ? "En direct" : "Connexion perdue")
.font(.caption.weight(.semibold))
.foregroundStyle(context.state.isLive ? .green : .orange)
}
HStack(spacing: 14) {
metricBlock(value: hrText(context.state), unit: "bpm", color: .red)
metricBlock(value: kcalText(context.state), unit: "kcal", color: .green)
metricBlock(value: distKmText(context.state), unit: "km", color: .orange)
metricBlock(value: elapsedText(context.state.elapsedSec), unit: "", color: .white)
}
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
}
@ViewBuilder
private func metricBlock(value: String, unit: String, color: Color) -> some View {
VStack(spacing: 0) {
Text(value)
.font(.system(.title3, design: .rounded).weight(.heavy).monospacedDigit())
.foregroundStyle(color)
if !unit.isEmpty {
Text(unit).font(.caption2).foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity)
}
@ViewBuilder
private func metricExpanded(label: String, value: String, color: Color, symbol: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
Label(label, systemImage: symbol)
.font(.caption2.weight(.semibold))
.foregroundStyle(color)
Text(value)
.font(.title2.weight(.heavy).monospacedDigit())
.foregroundStyle(color)
}
}
@ViewBuilder
private func metricInline(label: String, value: String, unit: String, color: Color) -> some View {
HStack(spacing: 4) {
Text(label).font(.caption2).foregroundStyle(.secondary)
Text(value).font(.callout.weight(.bold).monospacedDigit()).foregroundStyle(color)
if !unit.isEmpty {
Text(unit).font(.caption2).foregroundStyle(.tertiary)
}
}
}
// MARK: - Formatters (dupliqués ici car non-import de LiveStore depuis l'extension)
private func hrText(_ s: CoachLiveActivityAttributes.ContentState) -> String {
s.heartRate > 0 ? "\(Int(s.heartRate.rounded()))" : ""
}
private func kcalText(_ s: CoachLiveActivityAttributes.ContentState) -> String {
s.activeEnergyKcal > 0 ? "\(Int(s.activeEnergyKcal.rounded()))" : ""
}
private func distKmText(_ s: CoachLiveActivityAttributes.ContentState) -> String {
s.distanceMeters > 0 ? String(format: "%.2f", s.distanceMeters / 1000) : ""
}
private func elapsedText(_ sec: TimeInterval) -> String {
let total = Int(sec)
let h = total / 3600
let m = (total % 3600) / 60
let s = total % 60
if h > 0 { return "\(h):\(String(format: "%02d", m)):\(String(format: "%02d", s))" }
return "\(m):\(String(format: "%02d", s))"
}
}

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>Coach Live</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>