From a5795bf83f4c941c593093c7e82cd1ec21f948f0 Mon Sep 17 00:00:00 2001 From: Sylvain Date: Tue, 26 May 2026 19:51:57 +0200 Subject: [PATCH] feat(live): Live Activity / Dynamic Island (WidgetKit extension iOS 16.2+/17+) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ios/App/App.xcodeproj/project.pbxproj | 175 ++++++++++++++++++ ios/App/App/Info.plist | 2 + ios/App/App/LiveActivityManager.swift | 77 ++++++++ ios/App/App/LiveStore.swift | 60 +++++- .../CoachLiveActivityAttributes.swift | 23 +++ .../CoachLiveActivityBundle.swift | 13 ++ .../CoachLiveActivityWidget.swift | 158 ++++++++++++++++ ios/App/CoachLiveActivity/Info.plist | 27 +++ 8 files changed, 533 insertions(+), 2 deletions(-) create mode 100644 ios/App/App/LiveActivityManager.swift create mode 100644 ios/App/CoachLiveActivity/CoachLiveActivityAttributes.swift create mode 100644 ios/App/CoachLiveActivity/CoachLiveActivityBundle.swift create mode 100644 ios/App/CoachLiveActivity/CoachLiveActivityWidget.swift create mode 100644 ios/App/CoachLiveActivity/Info.plist diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 10664c4..67f971d 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 01468471ECA28EA66AFE2D6C /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0EB6C76F68679844EC72178F /* Foundation.framework */; }; 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 */; }; 290D0F17978B7EF3DDB3A792 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A06CDFF5B4F7852B6329862 /* ContentView.swift */; }; 2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; }; @@ -23,12 +24,18 @@ 50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; }; 543A7005429A43F54C0EC61B /* CoachWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6BEAEF6F6274D61CAF22636 /* CoachWatchApp.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 */; }; 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 */; }; 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 */; }; + E1E8BD7CF9C19E56CE0F57C4 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D61D7B2F5AFEF834423E07AB /* Foundation.framework */; }; 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 */; }; /* End PBXBuildFile section */ @@ -40,9 +47,27 @@ remoteGlobalIDString = D5BF41648D334DC24770B9D2; remoteInfo = CoachWatch; }; + 9D160400D2AE8723B1112F2F /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 504EC2FC1FED79650016851F /* Project object */; + proxyType = 1; + remoteGlobalIDString = 28FB3582F417B2F3C6BFC5D1; + remoteInfo = CoachLiveActivity; + }; /* End PBXContainerItemProxy 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 */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -58,10 +83,12 @@ /* 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; }; + 1BA75EEC3CBF1F46A9028C66 /* CoachLiveActivityBundle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachLiveActivityBundle.swift; sourceTree = ""; }; 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 = ""; }; 3342ACCE085010828CF63565 /* Assets.xcassets */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 340B99D5EA4DB985D7DFDA10 /* CoachLiveView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachLiveView.swift; sourceTree = ""; }; + 431C7B10248D8EED4D397AB0 /* CoachLiveActivityAttributes.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachLiveActivityAttributes.swift; sourceTree = ""; }; 50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = ""; }; 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 = ""; }; @@ -77,14 +104,19 @@ 8A06CDFF5B4F7852B6329862 /* ContentView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 8AC9C9B9971F55027D227A06 /* WorkoutManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WorkoutManager.swift; sourceTree = ""; }; 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 = ""; }; A9DF3346C49A690C3C3DF2F3 /* CoachLiveBridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachLiveBridge.swift; sourceTree = ""; }; + B93DA6B121E94B11DF938C90 /* LiveActivityManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = ""; }; C0AC4A07811751FB78AA0002 /* CoachAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachAuth.swift; sourceTree = ""; }; D09B589FA572B2C14E8CD835 /* CoachWatch.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = CoachWatch.entitlements; sourceTree = ""; }; D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachWorkoutKit.swift; sourceTree = ""; }; + 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 = ""; }; + EBBF61A35F291A021BA75C60 /* CoachLiveActivityWidget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachLiveActivityWidget.swift; sourceTree = ""; }; F1ABCDEF0123456789AB0002 /* CoachHealthRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachHealthRoute.swift; sourceTree = ""; }; F6BEAEF6F6274D61CAF22636 /* CoachWatchApp.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachWatchApp.swift; sourceTree = ""; }; + FBFCA969A1B083CD3178D9B2 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -104,6 +136,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D8B443A150F5A7E1C135ABAA /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + E1E8BD7CF9C19E56CE0F57C4 /* Foundation.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -115,6 +155,18 @@ name = watchOS; sourceTree = ""; }; + 460F58BF290358F572689016 /* CoachLiveActivity */ = { + isa = PBXGroup; + children = ( + 431C7B10248D8EED4D397AB0 /* CoachLiveActivityAttributes.swift */, + EBBF61A35F291A021BA75C60 /* CoachLiveActivityWidget.swift */, + 1BA75EEC3CBF1F46A9028C66 /* CoachLiveActivityBundle.swift */, + FBFCA969A1B083CD3178D9B2 /* Info.plist */, + ); + name = CoachLiveActivity; + path = CoachLiveActivity; + sourceTree = ""; + }; 504EC2FB1FED79650016851F = { isa = PBXGroup; children = ( @@ -123,6 +175,7 @@ 504EC3051FED79650016851F /* Products */, B91ACF72653FD495148698CF /* Frameworks */, 5A8EA0CF92316E437E9053A0 /* CoachWatch */, + 460F58BF290358F572689016 /* CoachLiveActivity */, ); sourceTree = ""; }; @@ -131,6 +184,7 @@ children = ( 504EC3041FED79650016851F /* App.app */, 2517374227A4072A4D6AFC15 /* CoachWatch.app */, + 9B5664FCDB4DDE62514D3A1E /* CoachLiveActivity.appex */, ); name = Products; sourceTree = ""; @@ -155,6 +209,7 @@ A9DF3346C49A690C3C3DF2F3 /* CoachLiveBridge.swift */, 6030BD533EBC2D1D26DB9479 /* LiveStore.swift */, 340B99D5EA4DB985D7DFDA10 /* CoachLiveView.swift */, + B93DA6B121E94B11DF938C90 /* LiveActivityManager.swift */, ); path = App; sourceTree = ""; @@ -173,10 +228,19 @@ path = CoachWatch; sourceTree = ""; }; + 7497026ECEFF81CCD11C289D /* iOS */ = { + isa = PBXGroup; + children = ( + D61D7B2F5AFEF834423E07AB /* Foundation.framework */, + ); + name = iOS; + sourceTree = ""; + }; B91ACF72653FD495148698CF /* Frameworks */ = { isa = PBXGroup; children = ( 3E58B60E9B4F1CA14B5EC667 /* watchOS */, + 7497026ECEFF81CCD11C289D /* iOS */, ); name = Frameworks; sourceTree = ""; @@ -184,6 +248,23 @@ /* End PBXGroup 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 */ = { isa = PBXNativeTarget; buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */; @@ -192,11 +273,13 @@ 504EC3011FED79650016851F /* Frameworks */, 504EC3021FED79650016851F /* Resources */, F50D9A7DFABB52EE96BB93D6 /* Embed Watch Content */, + C4916F81E61FABAAD62EE4F9 /* Embed App Extensions */, ); buildRules = ( ); dependencies = ( EEEAB45810829EE8B3C4976E /* PBXTargetDependency */, + BACE810C3CD85E302BBFEC84 /* PBXTargetDependency */, ); name = App; packageProductDependencies = ( @@ -258,11 +341,19 @@ targets = ( 504EC3031FED79650016851F /* App */, D5BF41648D334DC24770B9D2 /* CoachWatch */, + 28FB3582F417B2F3C6BFC5D1 /* CoachLiveActivity */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 411CB19BB5DB127758A86A2D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 504EC3021FED79650016851F /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -287,6 +378,16 @@ /* End PBXResourcesBuildPhase 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 */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -300,6 +401,8 @@ 21B242F8E61FB07C476C477C /* CoachLiveBridge.swift in Sources */, 97116C31D4CC40F71A6A0FE7 /* LiveStore.swift in Sources */, 548F8BFA06417B35474BD69E /* CoachLiveView.swift in Sources */, + D1384CFA4960060CDBF05FC3 /* CoachLiveActivityAttributes.swift in Sources */, + E547F675B2F61C3475D2A787 /* LiveActivityManager.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -317,6 +420,12 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + BACE810C3CD85E302BBFEC84 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = CoachLiveActivity; + target = 28FB3582F417B2F3C6BFC5D1 /* CoachLiveActivity */; + targetProxy = 9D160400D2AE8723B1112F2F /* PBXContainerItemProxy */; + }; EEEAB45810829EE8B3C4976E /* PBXTargetDependency */ = { isa = PBXTargetDependency; name = CoachWatch; @@ -575,6 +684,63 @@ }; 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 */ /* Begin XCConfigurationList section */ @@ -596,6 +762,15 @@ defaultConfigurationIsVisible = 0; 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" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/ios/App/App/Info.plist b/ios/App/App/Info.plist index 6bc5523..936873d 100644 --- a/ios/App/App/Info.plist +++ b/ios/App/App/Info.plist @@ -38,6 +38,8 @@ Coach Hypnotruck utilise votre position pour tracer vos sorties course et VTT. NSMotionUsageDescription Coach Hypnotruck utilise les capteurs de mouvement pour mesurer votre activité. + NSSupportsLiveActivities + NSPhotoLibraryUsageDescription Coach Hypnotruck peut accéder à vos photos pour ajouter une photo de profil. UIBackgroundModes diff --git a/ios/App/App/LiveActivityManager.swift b/ios/App/App/LiveActivityManager.swift new file mode 100644 index 0000000..74a4157 --- /dev/null +++ b/ios/App/App/LiveActivityManager.swift @@ -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? + 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") + } +} diff --git a/ios/App/App/LiveStore.swift b/ios/App/App/LiveStore.swift index 604c366..8117e9a 100644 --- a/ios/App/App/LiveStore.swift +++ b/ios/App/App/LiveStore.swift @@ -27,14 +27,31 @@ final class LiveStore: ObservableObject { private var watchdog: Timer? private let lostThreshold: TimeInterval = 8 + private let endAfterLost: TimeInterval = 60 + private var lostSince: Date? private init() { watchdog = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in Task { @MainActor [weak self] in guard let self else { return } - guard self.status == .live, let ts = self.lastSampleAt else { return } - if Date().timeIntervalSince(ts) > self.lostThreshold { + guard let ts = self.lastSampleAt else { return } + 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.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 Task { @MainActor [weak self] in guard let self else { return } + let wasNotLive = self.status != .live if let v = hr { self.heartRate = v } if let v = kcal { self.activeEnergyKcal = v } if let v = dist { self.distanceMeters = v } @@ -58,9 +76,36 @@ final class LiveStore: ObservableObject { if let v = act { self.activityType = v } self.lastSampleAt = Date() 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) var heartRateText: String { heartRate > 0 ? String(Int(heartRate.rounded())) : "—" } @@ -95,4 +140,15 @@ final class LiveStore: ObservableObject { 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" + } + } } diff --git a/ios/App/CoachLiveActivity/CoachLiveActivityAttributes.swift b/ios/App/CoachLiveActivity/CoachLiveActivityAttributes.swift new file mode 100644 index 0000000..0e9e29e --- /dev/null +++ b/ios/App/CoachLiveActivity/CoachLiveActivityAttributes.swift @@ -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 +} diff --git a/ios/App/CoachLiveActivity/CoachLiveActivityBundle.swift b/ios/App/CoachLiveActivity/CoachLiveActivityBundle.swift new file mode 100644 index 0000000..46c67ad --- /dev/null +++ b/ios/App/CoachLiveActivity/CoachLiveActivityBundle.swift @@ -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() + } +} diff --git a/ios/App/CoachLiveActivity/CoachLiveActivityWidget.swift b/ios/App/CoachLiveActivity/CoachLiveActivityWidget.swift new file mode 100644 index 0000000..7571aff --- /dev/null +++ b/ios/App/CoachLiveActivity/CoachLiveActivityWidget.swift @@ -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) -> 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))" + } +} diff --git a/ios/App/CoachLiveActivity/Info.plist b/ios/App/CoachLiveActivity/Info.plist new file mode 100644 index 0000000..ce1924a --- /dev/null +++ b/ios/App/CoachLiveActivity/Info.plist @@ -0,0 +1,27 @@ + + + + + CFBundleDisplayName + Coach Live + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + +