merge(develop): fréquence cardiaque live montre/téléphone (ticket #155)

Intègre la mise en conformité avec le cadrage #155/#144/#142 (FC live
bridge montre -> téléphone, affichage écran de séance montre) ainsi que
la correction de la procédure de build/install montre. QA verte.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 13:03:50 +02:00
7 changed files with 336 additions and 89 deletions

View File

@ -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"

View File

@ -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

View File

@ -5,8 +5,14 @@
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.BODY_SENSORS" />
<uses-permission android:name="android.permission.BODY_SENSORS_BACKGROUND" />
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
<uses-permission
android:name="android.permission.BODY_SENSORS"
android:maxSdkVersion="35" />
<uses-permission
android:name="android.permission.BODY_SENSORS_BACKGROUND"
android:maxSdkVersion="35" />
<uses-permission android:name="android.permission.health.READ_HEART_RATE" />
<application
android:label="GameTime"

View File

@ -4,8 +4,10 @@ import android.app.Activity
import android.content.Context
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import com.google.android.gms.wearable.CapabilityClient
import com.google.android.gms.wearable.DataEvent
import com.google.android.gms.wearable.DataMapItem
@ -19,6 +21,7 @@ import org.json.JSONObject
import java.nio.charset.StandardCharsets
object WatchBridgePlugin {
private const val TAG = "GTWatchBridgeWatch"
private const val METHOD_CHANNEL = "gametime.watch_bridge/methods"
private const val PROJECTION_CHANNEL = "gametime.watch_bridge/projections"
private const val SENSOR_SAMPLE_CHANNEL = "gametime.watch_bridge/sensor_samples"
@ -31,7 +34,9 @@ object WatchBridgePlugin {
const val ACK_PATH = "/gametime/phone/ack"
const val STATE_PATH = "/gametime/phone/projection"
const val PHONE_CAPABILITY = "gametime_phone_companion"
private const val BODY_SENSORS_PERMISSION_REQUEST = 4106
private const val SENSOR_PERMISSION_REQUEST = 4106
private const val READ_HEART_RATE_PERMISSION =
"android.permission.health.READ_HEART_RATE"
private var appContext: Context? = null
private var activity: Activity? = null
@ -46,7 +51,8 @@ object WatchBridgePlugin {
sensorSamplePath = SENSOR_SAMPLE_PATH,
onLocalSample = ::emitSensorSample,
)
private var bodySensorPermissionRequested = false
private var sensorPermissionRequested = false
private var pendingSensorPermissionRequest = false
fun register(flutterEngine: FlutterEngine, context: Context) {
appContext = context.applicationContext
@ -102,10 +108,12 @@ object WatchBridgePlugin {
}
},
)
requestPendingSensorPermissionIfPossible()
}
fun attachActivity(activity: Activity) {
this.activity = activity
requestPendingSensorPermissionIfPossible()
}
fun detachActivity(activity: Activity) {
@ -115,11 +123,17 @@ object WatchBridgePlugin {
}
fun handlePermissionResult(requestCode: Int, grantResults: IntArray) {
if (requestCode != BODY_SENSORS_PERMISSION_REQUEST) {
if (requestCode != SENSOR_PERMISSION_REQUEST) {
return
}
if (grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) {
val granted = appContext?.let(::hasRequiredSensorPermissions) == true ||
grantResults.all { it == PackageManager.PERMISSION_GRANTED }
if (granted) {
pendingSensorPermissionRequest = false
Log.d(TAG, "sensor permissions granted")
appContext?.let { heartRateCollector.onBodySensorsGranted(it) }
} else {
Log.w(TAG, "sensor permissions denied")
}
}
@ -291,15 +305,17 @@ object WatchBridgePlugin {
return
}
val shouldAggregate = phase == "running"
if (!hasBodySensorsPermission(context)) {
if (!hasRequiredSensorPermissions(context)) {
heartRateCollector.noteActiveSession(
sessionId,
shouldAggregate = false,
executionContext = telemetryContext(projection),
)
requestBodySensorsPermissionOnce()
pendingSensorPermissionRequest = true
requestSensorPermissionsOnce()
return
}
pendingSensorPermissionRequest = false
heartRateCollector.noteActiveSession(
sessionId,
shouldAggregate,
@ -312,20 +328,51 @@ object WatchBridgePlugin {
}
}
private fun hasBodySensorsPermission(context: Context): Boolean {
return context.checkSelfPermission(android.Manifest.permission.BODY_SENSORS) ==
PackageManager.PERMISSION_GRANTED
private fun hasRequiredSensorPermissions(context: Context): Boolean {
return requiredSensorPermissions().all { permission ->
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<String> {
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,
)
}

View File

@ -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<String, Any?>) -> 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<String, Any?> = emptyMap()
private var registered = false
private val registeredDataTypes = mutableSetOf<DeltaDataType<*, *>>()
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

View File

@ -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(

View File

@ -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',