Merge branch 'feat/watch-outdoor' of https://git.nexus.hypnotruck.ch/sylvain/coach-ios into feat/watch-outdoor

This commit is contained in:
Sylvain
2026-09-22 10:33:27 +02:00
7 changed files with 170 additions and 49 deletions

View File

@@ -15,7 +15,8 @@ Instructions pour Claude Code sur ce projet. Lis ce fichier en début de session
## 🔒 Règles non négociables
1. **Le code web n'est PAS ici** — pour modifier l'UI/UX, c'est `coach_sportif/web/`. Ce repo ne touche QUE le natif (Swift, Kotlin, plugins Capacitor, config).
2. **Cookie inject AppDelegate** (iOS) : mécanisme critique pour l'auth WKWebView. Toute modif `AppDelegate.swift` doit préserver ce flux.
2. **Cookie inject AppDelegate** (iOS) : mécanisme critique pour l'auth WKWebView. Toute modif `AppDelegate.swift` doit préserver ce flux. Il vit dans `didFinishLaunchingWithOptions`, qui **reste appelé** en cycle de vie par scènes et **avant** la connexion de la scène — Apple documente la séquence, l'ordre cookie → WebView est donc garanti.
2bis. **⚠️ Cycle de vie UIScene depuis le 22/09/2026** (obligatoire iOS 27, Capacitor ≥ 8.5.2). Trois callbacks ont quitté `AppDelegate.swift` pour `SceneDelegate.swift`, et UIKit **ne les appelle plus sur l'AppDelegate même si on les y remet** : retour OAuth Google (`scene(_:openURLContexts:)`), Universal Links (`scene(_:continue:)`), flush des saisies widget (`sceneDidBecomeActive`). Aucun ne produit d'erreur de compilation s'il est mal placé — la panne est silencieuse. Le `SceneDelegate` instancie **`MainViewController`**, jamais un `CAPBridgeViewController` nu comme le template Capacitor : la sous-classe porte l'enregistrement des plugins et la config WKWebView. ⛔ Ne pas lancer `npx cap migrate` sur ce projet (le guide officiel exclut les AppDelegate à gestion d'URL personnalisée), ni `npm audit fix --force` (il redescend le CLI en v7 et supprime le support UIScene).
3. **Health Connect Android** : nécessite la privacy policy URL dans le manifest (validation Play Console).
4. **Pas d'extrapolation** : root cause vérifiable avant tout fix natif. iOS+Android = doubles surfaces de bug.
5. **Default yes** : décider et exécuter (sauf actions destructives).

View File

@@ -71,6 +71,7 @@
E547F675B2F61C3475D2A787 /* LiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B93DA6B121E94B11DF938C90 /* LiveActivityManager.swift */; };
F1ABCDEF0123456789AB0001 /* CoachHealthRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1ABCDEF0123456789AB0002 /* CoachHealthRoute.swift */; };
F1ABCDEF0123456789AB0003 /* CoachSleep.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1ABCDEF0123456789AB0004 /* CoachSleep.swift */; };
60BB59C4096F4C8EB22DE183 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82F240520FD4C6CB4A80522 /* SceneDelegate.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -195,6 +196,7 @@
F1ABCDEF0123456789AB0004 /* CoachSleep.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachSleep.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>"; };
B82F240520FD4C6CB4A80522 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -208,7 +210,18 @@
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
880A76D4302B601000F582D5 /* CoachWatchWidgets */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (880A76E2302B601100F582D5 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = CoachWatchWidgets; sourceTree = "<group>"; };
880A76D4302B601000F582D5 /* CoachWatchWidgets */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
880A76E2302B601100F582D5 /* PBXFileSystemSynchronizedBuildFileExceptionSet */,
);
explicitFileTypes = {
};
explicitFolders = (
);
path = CoachWatchWidgets;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
@@ -330,6 +343,7 @@
6030BD533EBC2D1D26DB9479 /* LiveStore.swift */,
340B99D5EA4DB985D7DFDA10 /* CoachLiveView.swift */,
B93DA6B121E94B11DF938C90 /* LiveActivityManager.swift */,
B82F240520FD4C6CB4A80522 /* SceneDelegate.swift */,
);
path = App;
sourceTree = "<group>";
@@ -589,6 +603,7 @@
548F8BFA06417B35474BD69E /* CoachLiveView.swift in Sources */,
D1384CFA4960060CDBF05FC3 /* CoachLiveActivityAttributes.swift in Sources */,
E547F675B2F61C3475D2A787 /* LiveActivityManager.swift in Sources */,
60BB59C4096F4C8EB22DE183 /* SceneDelegate.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View File

@@ -1,12 +1,11 @@
import UIKit
import Capacitor
#if canImport(GoogleSignIn)
import GoogleSignIn
#endif
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
// Conservée comme dans le template Capacitor 8.5. En cycle par scènes,
// c'est le SceneDelegate qui possède la fenêtre affichée.
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
@@ -47,28 +46,30 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
HTTPCookieStorage.shared.setCookie(cookie)
}
func applicationWillResignActive(_ application: UIApplication) {}
func applicationDidEnterBackground(_ application: UIApplication) {}
func applicationWillEnterForeground(_ application: UIApplication) {}
func applicationDidBecomeActive(_ application: UIApplication) {
// Les saisies faites depuis le widget (eau, café) attendent dans l'App
// Group : une extension n'a ni session ni droit de tenir une requête.
// On les transmet ici, à chaque retour au premier plan.
Task { await CoachQuickSync.flush() }
// ─── Cycle de vie par scènes (obligatoire iOS 27) ───────────────────
//
// ⚠️ Depuis l'adoption du `UIApplicationSceneManifest`, UIKit n'appelle
// PLUS `applicationDidBecomeActive`, `application(_:open:options:)` ni
// `application(_:continue:restorationHandler:)` — « even if you keep them
// implemented » (Apple, *Transitioning to the UIKit scene-based life
// cycle*). Les trois ont déménagé dans `SceneDelegate.swift` :
//
// applicationDidBecomeActive -> sceneDidBecomeActive (CoachQuickSync)
// application(_:open:options:) -> scene(_:openURLContexts:) (OAuth Google)
// application(_:continue:…) -> scene(_:continue:) (Universal Links)
//
// Elles ne sont pas conservées ici en doublon : une méthode morte qui a
// l'air vivante est exactement ce qui fait chercher la panne au mauvais
// endroit. Les callbacks de notification distante, eux, restent sur
// l'AppDelegate — `UISceneDelegate` n'en a aucun équivalent.
func application(_ application: UIApplication,
configurationForConnecting connectingSceneSession: UISceneSession,
options: UIScene.ConnectionOptions) -> UISceneConfiguration {
let config = UISceneConfiguration(name: "Default Configuration",
sessionRole: connectingSceneSession.role)
config.delegateClass = SceneDelegate.self
return config
}
func applicationWillTerminate(_ application: UIApplication) {}
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
#if canImport(GoogleSignIn)
// Retour du flux OAuth Google (reversed client id URL scheme).
if GIDSignIn.sharedInstance.handle(url) {
return true
}
#endif
return ApplicationDelegateProxy.shared.application(app, open: url, options: options)
}
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
}
}

View File

@@ -50,6 +50,25 @@
<string>location</string>
<string>fetch</string>
</array>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneConfigurationName</key>
<string>Default Configuration</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>

View File

@@ -0,0 +1,81 @@
import UIKit
import Capacitor
#if canImport(GoogleSignIn)
import GoogleSignIn
#endif
/// Cycle de vie par scènes, **obligatoire** depuis iOS 27 : « apps built with
/// the latest SDK must adopt the scene-based life cycle or they fail to launch »
/// (Apple, *Transitioning to the UIKit scene-based life cycle*). Sans ce
/// fichier et le `UIApplicationSceneManifest` de l'Info.plist, l'app ne démarre
/// pas du tout — c'est un assert au lancement, pas un avertissement.
///
/// ⚠️ **Ce qui arrive ici arrive en SILENCE.** Une fois le manifeste en place,
/// UIKit cesse d'appeler `application(_:open:options:)`,
/// `application(_:continue:restorationHandler:)` et `applicationDidBecomeActive`
/// sur l'AppDelegate — « even if you keep them implemented » (Apple). Rien ne
/// casse à la compilation : le retour OAuth Google part vers Safari et ne
/// revient jamais, et les saisies du widget restent bloquées dans l'App Group.
/// Les trois sont donc repris ci-dessous.
///
/// Ce que la migration NE change PAS, et c'est ce qui la rendait acceptable :
/// `application(_:didFinishLaunchingWithOptions:)` reste appelé, et toujours
/// **avant** que la scène ne se connecte — donc avant que la WKWebView charge.
/// L'injection du cookie d'auth garde son ordre (Apple documente la séquence :
/// launch → création des scènes → `scene(_:willConnectTo:)`). Les callbacks de
/// notification distante ne bougent pas non plus : `UISceneDelegate` n'a aucun
/// équivalent, et ils ne sont pas dépréciés.
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = scene as? UIWindowScene else { return }
// Le manifeste déclare `UISceneStoryboardFile = Main` : UIKit a donc
// déjà chargé Main.storyboard et instancié `MainViewController`, qui
// porte l'enregistrement des plugins natifs et la config WKWebView.
//
// Le template de Capacitor pose ici un `CAPBridgeViewController()` nu.
// L'appliquer tel quel remplacerait notre sous-classe et ferait perdre
// cette configuration **sans erreur visible**. On ne crée donc la
// fenêtre que si le storyboard ne l'a pas fait, et avec notre
// contrôleur.
if window == nil {
let fenetre = UIWindow(windowScene: windowScene)
fenetre.rootViewController = MainViewController()
window = fenetre
fenetre.makeKeyAndVisible()
}
SceneDelegateProxy.shared.scene(scene, willConnectTo: session, options: connectionOptions)
}
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
// Retour du flux OAuth Google. `handle(url)` prend UNE url alors que la
// scène en livre un ensemble : on laisse à Capacitor ce que Google n'a
// pas consommé, plutôt que de tout lui passer ou de tout lui cacher.
var restants = URLContexts
#if canImport(GoogleSignIn)
restants = URLContexts.filter { !GIDSignIn.sharedInstance.handle($0.url) }
#endif
guard !restants.isEmpty else { return }
SceneDelegateProxy.shared.scene(scene, openURLContexts: restants)
}
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
// Universal Links : les .widgetURL des widgets, de la Live Activity et
// des complications Watch passent par là. Sans ce relais, ils
// ouvriraient Safari, où le cookie injecté dans la WKWebView n'existe
// pas.
SceneDelegateProxy.shared.scene(scene, continue: userActivity)
}
func sceneDidBecomeActive(_ scene: UIScene) {
// Reprend `applicationDidBecomeActive`, qu'UIKit n'appelle plus. Les
// saisies faites depuis le widget (eau, café) attendent dans l'App
// Group : une extension n'a ni session ni droit de tenir une requête.
Task { await CoachQuickSync.flush() }
}
}

40
package-lock.json generated
View File

@@ -9,12 +9,12 @@
"version": "0.1.0",
"license": "UNLICENSED",
"dependencies": {
"@capacitor/android": "^8.3.1",
"@capacitor/android": "^8.5.2",
"@capacitor/app": "^8.1.0",
"@capacitor/core": "^8.3.1",
"@capacitor/core": "^8.5.2",
"@capacitor/geolocation": "^8.2.0",
"@capacitor/haptics": "^8.0.2",
"@capacitor/ios": "^8.3.1",
"@capacitor/ios": "^8.5.2",
"@capacitor/local-notifications": "^8.0.2",
"@capacitor/push-notifications": "^8.0.3",
"@capacitor/splash-screen": "^8.0.1",
@@ -24,7 +24,7 @@
},
"devDependencies": {
"@capacitor/assets": "^3.0.5",
"@capacitor/cli": "^8.3.1"
"@capacitor/cli": "^8.5.2"
}
},
"node_modules/@babel/code-frame": {
@@ -51,12 +51,12 @@
}
},
"node_modules/@capacitor/android": {
"version": "8.3.1",
"resolved": "https://registry.npmjs.org/@capacitor/android/-/android-8.3.1.tgz",
"integrity": "sha512-hjskIG8YcBEh3X4yaTXvE9gcqpdcxunTgFruSKnuPxtMxAUzEK4Oq25x0Z1g3cz+MQPc+lRG09R7Ovc+ydKsNw==",
"version": "8.5.2",
"resolved": "https://registry.npmjs.org/@capacitor/android/-/android-8.5.2.tgz",
"integrity": "sha512-rtiaz78egCcS+vs29wkWCL3C+RhazKMSygXcjutwWP8NmO6r5UBxOvGf3Wjz5+ahobyQ723tn9gCePwRwcNDZQ==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": "^8.3.0"
"@capacitor/core": "^8.5.0"
}
},
"node_modules/@capacitor/app": {
@@ -452,10 +452,11 @@
"dev": true
},
"node_modules/@capacitor/cli": {
"version": "8.3.1",
"resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-8.3.1.tgz",
"integrity": "sha512-1sPGW4THTDfR6YjXwZ0jM7oAfAtciPOHN00qs/3sNAQx1kKrrEYSfDPwCm1/xlAgi0OeL69SiRfw314Ans+1sw==",
"version": "8.5.2",
"resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-8.5.2.tgz",
"integrity": "sha512-O2kpA5ZPJUAmliDCcmpMf8xnJwMezxlFPUrlyOAjbL3U9MFNY3l4ZpPI/UXwvyPmR8ETcVF9pFr+VdWq61rqTQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@ionic/cli-framework-output": "^2.2.8",
"@ionic/utils-subprocess": "^3.0.1",
@@ -473,6 +474,7 @@
"semver": "^7.6.3",
"tar": "^7.5.3",
"tslib": "^2.8.1",
"xcode": "^3.0.1",
"xml2js": "^0.6.2"
},
"bin": {
@@ -484,9 +486,10 @@
}
},
"node_modules/@capacitor/core": {
"version": "8.3.1",
"resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.3.1.tgz",
"integrity": "sha512-UF8ItlHguU1Z6GXfPTeT2gakf+ctNI8pAS1kwSBQlsJMlfD4OPoto/SmKnOxKCQvnF4WRcdWeg6C0zREUNaAQg==",
"version": "8.5.2",
"resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.5.2.tgz",
"integrity": "sha512-agnQbGdhGw/KZFuLf7H9SY2o+oE7upkr/WPQsYaokXP6f9chaE8iVmbKkfN6SVl4w3pmz7oZ3Ur1hbkmy1hx+w==",
"license": "MIT",
"dependencies": {
"tslib": "^2.1.0"
}
@@ -511,11 +514,12 @@
}
},
"node_modules/@capacitor/ios": {
"version": "8.3.1",
"resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-8.3.1.tgz",
"integrity": "sha512-BEhLyYYHWJLib4mpaPMaaylbC8meqgxbNYwQJH2svsSLW7yo/hFie+Zoo66a44XnqcMd2tvmAuzimWunXZi/xA==",
"version": "8.5.2",
"resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-8.5.2.tgz",
"integrity": "sha512-eOT2S67AfTFG7KN0T3oxwAajrJz4rSye+ODfvChiwrk0F/+kWufFM1qU6cDjGJUOfwdAoeuEpZyWHSwZlu4GFg==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": "^8.3.0"
"@capacitor/core": "^8.5.0"
}
},
"node_modules/@capacitor/local-notifications": {

View File

@@ -15,12 +15,12 @@
"author": "Sylvain Bettinelli",
"license": "UNLICENSED",
"dependencies": {
"@capacitor/android": "^8.3.1",
"@capacitor/android": "^8.5.2",
"@capacitor/app": "^8.1.0",
"@capacitor/core": "^8.3.1",
"@capacitor/core": "^8.5.2",
"@capacitor/geolocation": "^8.2.0",
"@capacitor/haptics": "^8.0.2",
"@capacitor/ios": "^8.3.1",
"@capacitor/ios": "^8.5.2",
"@capacitor/local-notifications": "^8.0.2",
"@capacitor/push-notifications": "^8.0.3",
"@capacitor/splash-screen": "^8.0.1",
@@ -30,6 +30,6 @@
},
"devDependencies": {
"@capacitor/assets": "^3.0.5",
"@capacitor/cli": "^8.3.1"
"@capacitor/cli": "^8.5.2"
}
}