diff --git a/CLAUDE.md b/CLAUDE.md index cbc262f..c7c6676 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,7 @@ Instructions pour Claude Code sur ce projet. Lis ce fichier en début de session - **Repo Gitea** : encore nommé `coach-ios` (renommage `coach-mobile` à faire) ; fichier `coach-ios.local.json` conservé pour ne pas casser les refs Swift - **Plateformes** : iOS 13+ et Android 8.0+ (API 26+, requis par Health Connect) - **Build iOS** : nécessite le Mac mini (macOS 13+, Xcode 15+). Pas de Mac cloud nécessaire. -- **État** : iOS prêt (HealthKit + cookie inject AppDelegate). Android prêt. Bloqué externe : upgrade macOS 26 pour build iOS + Play Console $25 ~3-5j. +- **État** : iOS prêt (HealthKit + cookie inject AppDelegate). Android prêt. Mac mini à jour, builds iOS possibles (confirmé 2026-08-19). Reste bloqué : Play Console $25 ~3-5j pour Android. ## 🔒 Règles non négociables @@ -98,7 +98,7 @@ l'emplacement de la toolchain. ## 🚧 Backlog notable - Renommer le repo Gitea `coach-ios` → `coach-mobile` -- Upgrade macOS 26 sur Mac mini pour reprendre les builds iOS +- ~~Upgrade macOS 26 sur Mac mini~~ — fait, builds iOS opérationnels (2026-08-19) - Play Console validation (~3-5j, $25 one-shot) pour upload Android - **watchOS live workout compagnon** — Phases 1-3 RÉALISÉES (2026-05-25, sur `main`) : target `CoachWatch` + 4 fichiers Swift watchOS + plugin Capacitor diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index a45fb37..74c5a83 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -64,6 +64,7 @@ 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 */; }; + F1ABCDEF0123456789AB0003 /* CoachSleep.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1ABCDEF0123456789AB0004 /* CoachSleep.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -180,6 +181,7 @@ 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 = ""; }; + F1ABCDEF0123456789AB0004 /* CoachSleep.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachSleep.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 */ @@ -296,6 +298,7 @@ C0AC4A07811751FB78AA0002 /* CoachAuth.swift */, D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */, F1ABCDEF0123456789AB0002 /* CoachHealthRoute.swift */, + F1ABCDEF0123456789AB0004 /* CoachSleep.swift */, A99E51A99E51A99E51A90002 /* CoachAppleAuth.swift */, 60061E60061E60061E060002 /* CoachGoogleAuth.swift */, E2CE6C2AA1DF4F3CAD6E0002 /* MainViewController.swift */, @@ -554,6 +557,7 @@ 88ABE0043024C45A002636FD /* CoachQuickLog.swift in Sources */, 88ABE0053024C45A002636FD /* CoachQuickSync.swift in Sources */, F1ABCDEF0123456789AB0001 /* CoachHealthRoute.swift in Sources */, + F1ABCDEF0123456789AB0003 /* CoachSleep.swift in Sources */, A99E51A99E51A99E51A90001 /* CoachAppleAuth.swift in Sources */, 60061E60061E60061E060001 /* CoachGoogleAuth.swift in Sources */, 88F28EB12FBB330E00E8306E /* CoachWorkoutObserver.swift in Sources */, diff --git a/ios/App/App/CoachSleep.swift b/ios/App/App/CoachSleep.swift new file mode 100644 index 0000000..d542ba2 --- /dev/null +++ b/ios/App/App/CoachSleep.swift @@ -0,0 +1,167 @@ +// CoachSleep.swift +// Plugin Capacitor natif iOS : lit les échantillons de sommeil HealthKit AVEC +// leur fuseau horaire et leur source. Comble un manque de +// @capgo/capacitor-health, dont l'interface `HealthSample` n'expose aucun champ +// `metadata` (seul `Workout` en a un) — l'information n'arrive donc jamais +// jusqu'au JavaScript. +// +// ⚠️ Pourquoi ce plugin existe. Douze nuits d'avril 2026 s'affichaient à +// 06:17 → 14:25 au lieu de 23:17 → 07:25 : des nuits passées au Pérou (UTC−5) +// rendues en heure de Zurich (UTC+2). Sans le fuseau d'origine, l'app ne peut +// ni les afficher juste, ni juger la régularité du coucher — elle les écarte +// (cf. `web/sleep_score.py::bedtime_regularity`). +// +// Apple recommande explicitement de stocker ce fuseau pour le sommeil : +// « For best results when analyzing sleep samples, it's recommended that you +// store time zone metadata with your sleep sample data » (HKMetadataKeyTimeZone). +// La clé « takes a string value compatible with the NSTimeZone class's +// timeZoneWithName: method » — donc un identifiant du type "America/Lima". +// +// ⚠️ Rien ne garantit que l'Apple Watch la renseigne : Apple la recommande aux +// apps qui écrivent, sans documenter ce que ses propres sources posent. Le +// champ `timeZone` peut donc rester nul, et c'est un cas normal, pas une +// panne — d'où `tzSource`, qui dit d'où vient (ou ne vient pas) l'information. +// +// Usage côté JS : +// const r = await window.Capacitor.Plugins.CoachSleep.readSamples({ +// startDate: '2026-08-01T00:00:00Z', endDate: '2026-08-19T00:00:00Z', +// }); +// // r.samples = [{startDate, endDate, sleepState, sourceName, timeZone, tzSource}, ...] +// // r.available = false si HealthKit est indisponible ou non autorisé + +import Foundation +import UIKit +import Capacitor +import HealthKit + +@objc(CoachSleepPlugin) +public class CoachSleepPlugin: CAPPlugin, CAPBridgedPlugin { + public let identifier = "CoachSleepPlugin" + public let jsName = "CoachSleep" + public let pluginMethods: [CAPPluginMethod] = [ + CAPPluginMethod(name: "isAvailable", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "readSamples", returnType: CAPPluginReturnPromise), + ] + + private lazy var store = HKHealthStore() + + // Plafond de sécurité. La lecture web tourne par tranches de 14 jours + // (~560 échantillons) ; six mois d'un coup en feraient ~7 000. On garde de + // la marge sans jamais tronquer en silence : `truncated` le dit. + private static let sampleLimit = 20000 + + public override func load() { + NSLog("[CoachSleep] plugin loaded (iOS \(UIDevice.current.systemVersion))") + super.load() + } + + @objc func isAvailable(_ call: CAPPluginCall) { + call.resolve(["available": HKHealthStore.isHealthDataAvailable()]) + } + + @objc func readSamples(_ call: CAPPluginCall) { + guard HKHealthStore.isHealthDataAvailable() else { + call.resolve(["available": false, "samples": [], "reason": "HealthKit unavailable"]) + return + } + guard let sleepType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) else { + call.resolve(["available": false, "samples": [], "reason": "sleepAnalysis unavailable"]) + return + } + guard let start = Self.parseISO(call.getString("startDate")), + let end = Self.parseISO(call.getString("endDate")) else { + call.reject("startDate et endDate requis (ISO 8601)") + return + } + + // `.strictStartDate` n'est PAS utilisé : une nuit commence la veille et + // doit être trouvée par une fenêtre qui la chevauche, sinon la première + // nuit de chaque tranche disparaîtrait. + let pred = HKQuery.predicateForSamples(withStart: start, end: end, options: []) + let sort = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true) + let query = HKSampleQuery(sampleType: sleepType, + predicate: pred, + limit: Self.sampleLimit, + sortDescriptors: [sort]) { _, results, err in + if let err = err { + call.reject("Sleep fetch failed: \(err.localizedDescription)") + return + } + let samples = (results as? [HKCategorySample]) ?? [] + let out = samples.map { Self.describe($0) } + call.resolve([ + "available": true, + "samples": out, + "truncated": samples.count >= Self.sampleLimit, + ]) + } + store.execute(query) + } + + // MARK: - Conversion + + private static func describe(_ sample: HKCategorySample) -> [String: Any] { + var out: [String: Any] = [ + "startDate": iso(sample.startDate), + "endDate": iso(sample.endDate), + "sourceName": sample.sourceRevision.source.name, + "platformId": sample.uuid.uuidString, + ] + if let state = sleepState(sample.value) { + out["sleepState"] = state + } + // Fuseau de la nuit. Deux origines possibles, distinguées pour ne pas + // faire passer une valeur d'appareil pour une valeur de l'échantillon. + if let name = sample.metadata?[HKMetadataKeyTimeZone] as? String, + TimeZone(identifier: name) != nil { + out["timeZone"] = name + out["tzSource"] = "sample" + } + return out + } + + /// Nomme le stade avec le vocabulaire déjà utilisé côté JS (celui de Capgo), + /// pour que l'agrégation par nuit n'ait rien à traduire. + /// + /// Le passage par l'énumération plutôt que par les entiers bruts est + /// délibéré : Apple publie les cas, jamais leurs `rawValue`. + private static func sleepState(_ value: Int) -> String? { + guard let v = HKCategoryValueSleepAnalysis(rawValue: value) else { return nil } + switch v { + case .inBed: return "inBed" + case .awake: return "awake" + case .asleepCore: return "light" // Capgo nomme "light" le core d'Apple + case .asleepDeep: return "deep" + case .asleepREM: return "rem" + case .asleepUnspecified: return "asleep" + @unknown default: return "asleep" // un stade inédit reste du sommeil + } + } + + // MARK: - Dates + + private static let isoFormatter: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f + }() + + private static let isoFormatterNoFraction: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime] + return f + }() + + private static func iso(_ date: Date) -> String { + isoFormatter.string(from: date) + } + + /// Accepte les deux formes ISO 8601 : avec et sans fraction de seconde. + /// `ISO8601DateFormatter` rend `nil` sur celle qu'il n'attend pas, et le JS + /// envoie les deux selon le chemin (`toISOString()` en produit toujours, + /// une date recomposée à la main souvent pas). + private static func parseISO(_ value: String?) -> Date? { + guard let value = value, !value.isEmpty else { return nil } + return isoFormatter.date(from: value) ?? isoFormatterNoFraction.date(from: value) + } +}