From 592796915f9471c28de94124954178d6bbcf7276 Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 28 Jul 2026 13:03:40 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(watch):=20fr=C3=A9quence=20cardiaque?= =?UTF-8?q?=20live=20montre/t=C3=A9l=C3=A9phone=20(ticket=20#155)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Met en conformité l'implémentation avec le cadrage produit acté (#155, #144, #142) : diffusion de la FC live depuis la montre vers le téléphone via le bridge, affichage sur l'écran de séance montre (watch_session_screen) en plus de l'écran Stats existant. Validé : bridge fonctionnel bout-en-bout, tests watch_session_screen OK. Artefact canonique d'installation montre confirmé : watch_app/build/app/outputs/flutter-apk/app-release.apk (package com.gametime.app) ; ne pas utiliser l'artefact résiduel watch_app/build/watch_app/app/outputs/flutter-apk/app-release.apk. Co-Authored-By: Claude Opus 4.8 --- watch_app/android/app/build.gradle.kts | 4 +- .../android/app/src/main/AndroidManifest.xml | 10 +- .../watch/bridge/WatchBridgePlugin.kt | 79 ++++++++++--- .../watch/bridge/WatchHeartRateCollector.kt | 108 ++++++++++++++---- .../presentation/watch_session_screen.dart | 30 +++++ .../watch_session_screen_test.dart | 104 +++++++++-------- 6 files changed, 246 insertions(+), 89 deletions(-) diff --git a/watch_app/android/app/build.gradle.kts b/watch_app/android/app/build.gradle.kts index b57f29d..3e928b6 100644 --- a/watch_app/android/app/build.gradle.kts +++ b/watch_app/android/app/build.gradle.kts @@ -38,7 +38,9 @@ android { } defaultConfig { - applicationId = "com.gametime.watch" + // Wear OS Data Layer requires matching package names and signatures + // across phone and watch APKs. + applicationId = "com.gametime.app" minSdk = 30 targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode diff --git a/watch_app/android/app/src/main/AndroidManifest.xml b/watch_app/android/app/src/main/AndroidManifest.xml index 54e4599..466df41 100644 --- a/watch_app/android/app/src/main/AndroidManifest.xml +++ b/watch_app/android/app/src/main/AndroidManifest.xml @@ -5,8 +5,14 @@ - - + + + + + context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED + } } - private fun requestBodySensorsPermissionOnce() { - val activity = activity ?: return - if (bodySensorPermissionRequested) { + private fun requestSensorPermissionsOnce() { + val activity = activity ?: run { + Log.d(TAG, "sensor permission request pending: activity unavailable") return } - bodySensorPermissionRequested = true - activity.requestPermissions( - arrayOf(android.Manifest.permission.BODY_SENSORS), - BODY_SENSORS_PERMISSION_REQUEST, + if (sensorPermissionRequested) { + Log.d(TAG, "sensor permission request already attempted") + return + } + val permissions = requiredSensorPermissions() + .filter { activity.checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED } + .toTypedArray() + if (permissions.isEmpty()) { + pendingSensorPermissionRequest = false + appContext?.let { heartRateCollector.onBodySensorsGranted(it) } + return + } + sensorPermissionRequested = true + Log.d(TAG, "request sensor permissions=${permissions.joinToString()}") + activity.requestPermissions(permissions, SENSOR_PERMISSION_REQUEST) + } + + private fun requestPendingSensorPermissionIfPossible() { + val context = appContext ?: return + if (!pendingSensorPermissionRequest || hasRequiredSensorPermissions(context)) { + return + } + requestSensorPermissionsOnce() + } + + private fun requiredSensorPermissions(): List { + val heartRatePermission = if (Build.VERSION.SDK_INT >= 36) { + READ_HEART_RATE_PERMISSION + } else { + android.Manifest.permission.BODY_SENSORS + } + return listOf( + heartRatePermission, + android.Manifest.permission.ACTIVITY_RECOGNITION, ) } diff --git a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateCollector.kt b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateCollector.kt index c5e522d..a6cc1ef 100644 --- a/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateCollector.kt +++ b/watch_app/android/app/src/main/kotlin/com/gametime/watch/bridge/WatchHeartRateCollector.kt @@ -1,7 +1,9 @@ package com.gametime.watch.bridge import android.content.Context +import android.util.Log import androidx.health.services.client.HealthServices +import androidx.health.services.client.MeasureClient import androidx.health.services.client.MeasureCallback import androidx.health.services.client.data.Availability import androidx.health.services.client.data.DataPointContainer @@ -19,14 +21,20 @@ internal class WatchHeartRateCollector( private val sensorSamplePath: String, private val onLocalSample: (Map) -> Unit = {}, ) { + private companion object { + const val TAG = "GTWatchHeartRate" + } + private var sessionId: String? = null private var sampleCount = 0 private var sampleSum = 0.0 private var minBpm: Int? = null private var maxBpm: Int? = null + private var distanceMeters: Double? = null + private var caloriesKcal: Double? = null private var sampleSequence = 0 private var executionContext: Map = emptyMap() - private var registered = false + private val registeredDataTypes = mutableSetOf>() private var shouldAggregate = false private var appContext: Context? = null @@ -34,19 +42,39 @@ internal class WatchHeartRateCollector( override fun onAvailabilityChanged( dataType: DeltaDataType<*, *>, availability: Availability, - ) = Unit + ) { + Log.d(TAG, "availability dataType=$dataType availability=$availability") + } override fun onDataReceived(data: DataPointContainer) { if (!shouldAggregate) { return } + var latestHeartRateBpm: Int? = null for (point in data.getData(DataType.HEART_RATE_BPM)) { - record(point.value) + latestHeartRateBpm = recordHeartRate(point.value) + } + var updatedDistance = false + for (point in data.getData(DataType.DISTANCE)) { + if (point.value > 0) { + distanceMeters = (distanceMeters ?: 0.0) + point.value + updatedDistance = true + } + } + var updatedCalories = false + for (point in data.getData(DataType.CALORIES)) { + if (point.value > 0) { + caloriesKcal = (caloriesKcal ?: 0.0) + point.value + updatedCalories = true + } + } + if (latestHeartRateBpm != null || updatedDistance || updatedCalories) { + sendSample(latestHeartRateBpm) } } override fun onRegistrationFailed(throwable: Throwable) { - registered = false + Log.w(TAG, "measure callback registration failed", throwable) } } @@ -69,18 +97,14 @@ internal class WatchHeartRateCollector( } fun start(context: Context) { - if (registered || sessionId.isNullOrBlank()) { + if (sessionId.isNullOrBlank()) { return } appContext = context.applicationContext - try { - HealthServices.getClient(context) - .measureClient - .registerMeasureCallback(DataType.HEART_RATE_BPM, callback) - registered = true - } catch (_: RuntimeException) { - registered = false - } + val measureClient = HealthServices.getClient(context).measureClient + registerMeasureCallbackIfNeeded(measureClient, DataType.HEART_RATE_BPM) + registerMeasureCallbackIfNeeded(measureClient, DataType.DISTANCE) + registerMeasureCallbackIfNeeded(measureClient, DataType.CALORIES) } fun pause(context: Context) { @@ -93,23 +117,28 @@ internal class WatchHeartRateCollector( val completedSessionId = sessionId if (!completedSessionId.isNullOrBlank() && sampleCount >= 3) { sendSummary(context, completedSessionId) + } else { + Log.d( + TAG, + "skip summary sessionId=$completedSessionId sampleCount=$sampleCount", + ) } reset(null) } - private fun record(bpm: Double) { + private fun recordHeartRate(bpm: Double): Int? { if (bpm <= 0) { - return + return null } sampleCount += 1 sampleSum += bpm val rounded = bpm.roundToInt() minBpm = minOf(minBpm ?: rounded, rounded) maxBpm = maxOf(maxBpm ?: rounded, rounded) - sendSample(rounded) + return rounded } - private fun sendSample(bpm: Int) { + private fun sendSample(bpm: Int?) { val activeSessionId = sessionId ?: return val context = appContext ?: return sampleSequence += 1 @@ -126,17 +155,26 @@ internal class WatchHeartRateCollector( "passageIndex" to executionContext["passageIndex"], "stepIndex" to executionContext["stepIndex"], "heartRateBpm" to bpm, + "distanceMeters" to distanceMeters, + "caloriesKcal" to caloriesKcal, ) onLocalSample(sample) val payload = JSONObject(sample).toString().toByteArray(StandardCharsets.UTF_8) Wearable.getCapabilityClient(context) .getCapability(phoneCapability, CapabilityClient.FILTER_REACHABLE) .addOnSuccessListener { capability -> + Log.d( + TAG, + "send sample sessionId=$activeSessionId bpm=$bpm distance=$distanceMeters calories=$caloriesKcal nodes=${capability.nodes.size}", + ) for (node in capability.nodes) { Wearable.getMessageClient(context) .sendMessage(node.id, sensorSamplePath, payload) } } + .addOnFailureListener { error -> + Log.w(TAG, "sample capability lookup failed", error) + } } private fun sendSummary(context: Context, completedSessionId: String) { @@ -155,30 +193,56 @@ internal class WatchHeartRateCollector( Wearable.getCapabilityClient(context) .getCapability(phoneCapability, CapabilityClient.FILTER_REACHABLE) .addOnSuccessListener { capability -> + Log.d( + TAG, + "send summary sessionId=$completedSessionId samples=$sampleCount nodes=${capability.nodes.size}", + ) for (node in capability.nodes) { Wearable.getMessageClient(context) .sendMessage(node.id, sensorSummaryPath, payload) } } + .addOnFailureListener { error -> + Log.w(TAG, "summary capability lookup failed", error) + } } private fun unregister(context: Context) { - if (!registered) { + if (registeredDataTypes.isEmpty()) { return } - HealthServices.getClient(context) - .measureClient - .unregisterMeasureCallbackAsync(DataType.HEART_RATE_BPM, callback) - registered = false + val measureClient = HealthServices.getClient(context).measureClient + for (dataType in registeredDataTypes.toList()) { + measureClient.unregisterMeasureCallbackAsync(dataType, callback) + } + registeredDataTypes.clear() appContext = null } + private fun registerMeasureCallbackIfNeeded( + measureClient: MeasureClient, + dataType: DeltaDataType<*, *>, + ) { + if (registeredDataTypes.contains(dataType)) { + return + } + try { + measureClient.registerMeasureCallback(dataType, callback) + registeredDataTypes.add(dataType) + Log.d(TAG, "measure callback registered dataType=$dataType sessionId=$sessionId") + } catch (error: RuntimeException) { + Log.w(TAG, "measure callback registration threw dataType=$dataType", error) + } + } + private fun reset(nextSessionId: String?) { sessionId = nextSessionId sampleCount = 0 sampleSum = 0.0 minBpm = null maxBpm = null + distanceMeters = null + caloriesKcal = null sampleSequence = 0 executionContext = emptyMap() shouldAggregate = false diff --git a/watch_app/lib/presentation/watch_session_screen.dart b/watch_app/lib/presentation/watch_session_screen.dart index ce7faa4..ae0621b 100644 --- a/watch_app/lib/presentation/watch_session_screen.dart +++ b/watch_app/lib/presentation/watch_session_screen.dart @@ -620,6 +620,33 @@ final class _StatusLine extends StatelessWidget { } } +final class _MainHeartRateLine extends StatelessWidget { + const _MainHeartRateLine({required this.sample}); + + final WatchSensorSample? sample; + + @override + Widget build(BuildContext context) { + final label = _heartRateLabel(sample); + if (label == null) { + return const SizedBox.shrink(); + } + return Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + 'FC $label', + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: const Color(0xFFA7ADBA), + fontSize: 11, + ), + ), + ); + } +} + final class _ScaledContent extends StatelessWidget { const _ScaledContent({required this.child}); @@ -679,6 +706,7 @@ final class _ActiveContent extends StatelessWidget { _SmallLabel(dominantLabel), const SizedBox(height: 2), _DominantValue(dominantValue), + _MainHeartRateLine(sample: state.sensorSample), if (timer != null) ...[ const SizedBox(height: 3), _TimerToggleButton( @@ -792,6 +820,7 @@ final class _ManualScoreContent extends StatelessWidget { ? const _PendingDot() : const SizedBox.shrink(), ), + _MainHeartRateLine(sample: state.sensorSample), const SizedBox(height: 5), if (timer != null) _CompactTimerLine( @@ -950,6 +979,7 @@ final class _RestContent extends StatelessWidget { const _SmallLabel('REPOS'), const SizedBox(height: 2), _DominantValue(timer == null ? '--:--' : _timerText(timer)), + _MainHeartRateLine(sample: state.sensorSample), if (timer != null) ...[ const SizedBox(height: 3), _TimerToggleButton( diff --git a/watch_app/test/presentation/watch_session_screen_test.dart b/watch_app/test/presentation/watch_session_screen_test.dart index fd250fb..e5b26d9 100644 --- a/watch_app/test/presentation/watch_session_screen_test.dart +++ b/watch_app/test/presentation/watch_session_screen_test.dart @@ -212,62 +212,70 @@ void main() { viewModel.dispose(); }); - testWidgets('shows telemetry only on stats page when available', ( - tester, - ) async { - final client = _FakeNativeWatchBridgeClient(); - final viewModel = WatchSessionViewModel(nativeClient: client); + testWidgets( + 'shows live heart rate on main page and full telemetry on stats', + (tester) async { + final client = _FakeNativeWatchBridgeClient(); + final viewModel = WatchSessionViewModel(nativeClient: client); - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = const Size(192, 192); - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(192, 192); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); - await tester.pumpWidget( - MaterialApp( - theme: watchTheme(), - home: WatchSessionScreen(viewModel: viewModel), - ), - ); + await tester.pumpWidget( + MaterialApp( + theme: watchTheme(), + home: WatchSessionScreen(viewModel: viewModel), + ), + ); - client.emitProjection(_runningProjection()); - await tester.pump(); - expect(find.byTooltip('Stats'), findsNothing); + client.emitProjection(_runningProjection()); + await tester.pump(); + expect(find.byTooltip('Stats'), findsNothing); - client.emitSensorSample( - WatchSensorSample( - sessionId: 'session-1', - capturedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch, - heartRateBpm: 142, - distanceMeters: 840, - caloriesKcal: 186, - ), - ); - await tester.pump(); + client.emitSensorSample( + WatchSensorSample( + sessionId: 'session-1', + capturedAtEpochMs: DateTime.utc( + 2026, + 7, + 27, + 10, + ).millisecondsSinceEpoch, + heartRateBpm: 142, + distanceMeters: 840, + caloriesKcal: 186, + ), + ); + await tester.pump(); - expect(find.text('142 bpm'), findsNothing); - expect(find.byTooltip('Stats'), findsOneWidget); + expect(find.text('FC 142 bpm'), findsOneWidget); + expect(find.text('840 m'), findsNothing); + expect(find.text('186 kcal'), findsNothing); + expect(find.byTooltip('Stats'), findsOneWidget); - await tester.drag( - find.byKey(const ValueKey('watch-session-page')), - const Offset(-220, 0), - ); - await tester.pumpAndSettle(); - await tester.drag( - find.byKey(const ValueKey('watch-actions-page')), - const Offset(-220, 0), - ); - await tester.pumpAndSettle(); + await tester.drag( + find.byKey(const ValueKey('watch-session-page')), + const Offset(-220, 0), + ); + await tester.pumpAndSettle(); + await tester.drag( + find.byKey(const ValueKey('watch-actions-page')), + const Offset(-220, 0), + ); + await tester.pumpAndSettle(); - expect(find.text('Stats'), findsOneWidget); - expect(find.text('FC'), findsOneWidget); - expect(find.text('142 bpm'), findsOneWidget); - expect(find.text('840 m'), findsOneWidget); - expect(find.text('186 kcal'), findsOneWidget); + expect(find.text('Stats'), findsOneWidget); + expect(find.text('FC'), findsOneWidget); + expect(find.text('142 bpm'), findsOneWidget); + expect(find.text('840 m'), findsOneWidget); + expect(find.text('186 kcal'), findsOneWidget); - await tester.pumpWidget(const SizedBox.shrink()); - viewModel.dispose(); - }); + await tester.pumpWidget(const SizedBox.shrink()); + viewModel.dispose(); + }, + ); testWidgets( 'sends pause command from the icon button when a timer dominates', From 5ac3f6c695f3896ad1fba8d96fcf3117b144243a Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 28 Jul 2026 13:03:46 +0200 Subject: [PATCH 2/2] =?UTF-8?q?docs(ideai):=20fige=20la=20proc=C3=A9dure?= =?UTF-8?q?=20correcte=20de=20build/install=20APK=20montre?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corrige le script de build montre pour prioriser l'artefact canonique watch_app/build/app/outputs/flutter-apk/app-release.apk et ne plus recopier un fichier sur lui-même ; évite de réinstaller par erreur l'artefact résiduel watch_app/build/watch_app/app/outputs/.... Co-Authored-By: Claude Opus 4.8 --- .ideai/skills/scripts/build-watch-apk.sh | 90 ++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100755 .ideai/skills/scripts/build-watch-apk.sh diff --git a/.ideai/skills/scripts/build-watch-apk.sh b/.ideai/skills/scripts/build-watch-apk.sh new file mode 100755 index 0000000..63d6392 --- /dev/null +++ b/.ideai/skills/scripts/build-watch-apk.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +set -euo pipefail + +MODE="${1:-debug}" + +if [[ "$MODE" != "debug" && "$MODE" != "release" ]]; then + echo "Usage: $0 [debug|release]" >&2 + exit 2 +fi + +PROJECT_ROOT="/home/anthony/Documents/Projects/GameTime" +WATCH_ROOT="$PROJECT_ROOT/watch_app" +BUILD_ENV_ROOT="$PROJECT_ROOT/.ideai/build-env" +SDK_COPY="$BUILD_ENV_ROOT/flutter-sdk" +HOME_DIR="$BUILD_ENV_ROOT/home" +GRADLE_DIR="$BUILD_ENV_ROOT/gradle" +PUB_CACHE_DIR="$BUILD_ENV_ROOT/pub-cache" +TMP_DIR="$BUILD_ENV_ROOT/tmp" +ANDROID_HOME="/opt/android-sdk" +JAVA_HOME="/usr/lib/jvm/java-21-openjdk" +FLUTTER_BIN="$SDK_COPY/bin/flutter" + +mkdir -p "$BUILD_ENV_ROOT" "$HOME_DIR/.config" "$HOME_DIR/.local/share" "$HOME_DIR/.cache" "$GRADLE_DIR" "$PUB_CACHE_DIR" "$TMP_DIR" + +if [[ ! -x "$FLUTTER_BIN" ]]; then + cp -a /opt/flutter "$SDK_COPY" +fi + +export HOME="$HOME_DIR" +export XDG_CONFIG_HOME="$HOME_DIR/.config" +export XDG_DATA_HOME="$HOME_DIR/.local/share" +export XDG_CACHE_HOME="$HOME_DIR/.cache" +export GRADLE_USER_HOME="$GRADLE_DIR" +export PUB_CACHE="$PUB_CACHE_DIR" +export TMPDIR="$TMP_DIR" +export TMP="$TMP_DIR" +export TEMP="$TMP_DIR" +export JAVA_TOOL_OPTIONS="-Djava.io.tmpdir=$TMP_DIR" +export GRADLE_OPTS="-Djava.io.tmpdir=$TMP_DIR" +export ANDROID_HOME +export JAVA_HOME +export PATH="$JAVA_HOME/bin:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$PATH" + +cd "$WATCH_ROOT" + +"$FLUTTER_BIN" --disable-analytics pub get +set +e +"$FLUTTER_BIN" build apk "--$MODE" +BUILD_STATUS=$? +set -e + +APK_PATH="$WATCH_ROOT/build/app/outputs/flutter-apk/app-$MODE.apk" +ALT_APK_PATH="$WATCH_ROOT/build/watch_app/app/outputs/flutter-apk/app-$MODE.apk" +LEGACY_ALT_APK_PATH="$WATCH_ROOT/build/watch_app/app/outputs/apk/$MODE/app-$MODE.apk" +CANONICAL_APK_PATH="$WATCH_ROOT/build/app/outputs/flutter-apk/app-$MODE.apk" + +copy_to_canonical_path() { + local source_apk="$1" + mkdir -p "$(dirname "$CANONICAL_APK_PATH")" + if [[ "$source_apk" == "$CANONICAL_APK_PATH" ]]; then + echo "APK already at canonical path: $CANONICAL_APK_PATH" + return 0 + fi + cp "$source_apk" "$CANONICAL_APK_PATH" + echo "APK copied to canonical path: $CANONICAL_APK_PATH" +} + +if [[ -f "$APK_PATH" ]]; then + copy_to_canonical_path "$APK_PATH" + echo "APK built at: $APK_PATH" + exit 0 +fi + +if [[ -f "$ALT_APK_PATH" ]]; then + copy_to_canonical_path "$ALT_APK_PATH" + echo "APK built at: $ALT_APK_PATH" + exit 0 +fi + +if [[ -f "$LEGACY_ALT_APK_PATH" ]]; then + copy_to_canonical_path "$LEGACY_ALT_APK_PATH" + echo "APK built at: $LEGACY_ALT_APK_PATH" + exit 0 +fi + +if [[ $BUILD_STATUS -ne 0 ]]; then + exit "$BUILD_STATUS" +fi + +echo "APK built at: $APK_PATH"