chore(wip): lot #165-169 validé QA + rework foreground #163 (KO - preuve device/toolchain insuffisante)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 17:59:43 +02:00
parent 917777e18b
commit bc533d6c45
32 changed files with 898 additions and 123 deletions

View File

@ -3,7 +3,8 @@
android:name="android.hardware.type.watch"
android:required="true" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_HEALTH" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
<uses-permission
@ -63,5 +64,9 @@
android:scheme="wear" />
</intent-filter>
</service>
<service
android:name=".bridge.WatchHeartRateForegroundService"
android:exported="false"
android:foregroundServiceType="health" />
</application>
</manifest>

View File

@ -55,6 +55,7 @@ object WatchBridgePlugin {
private var sensorPermissionRequestInFlight = false
private var lastSensorPermissionRequestEpochMs = 0L
private var pendingSensorPermissionRequest = false
private var lastSensorProjection: Map<String, Any?>? = null
fun attachApplicationContext(context: Context) {
appContext = context.applicationContext
@ -142,7 +143,13 @@ object WatchBridgePlugin {
if (granted) {
pendingSensorPermissionRequest = false
Log.d(TAG, "sensor permissions granted")
appContext?.let { heartRateCollector.onBodySensorsGranted(it) }
appContext?.let { context ->
val projection = lastSensorProjection
if (projection != null) {
WatchHeartRateForegroundService.start(context, projection)
}
heartRateCollector.onBodySensorsGranted(context)
}
} else {
val denied = permissions.filterIndexed { index, _ ->
grantResults.getOrNull(index) != PackageManager.PERMISSION_GRANTED
@ -315,11 +322,15 @@ object WatchBridgePlugin {
val phase = projection["phase"] as? String ?: "noActiveSession"
val sessionId = projection["deviceSessionId"] as? String ?: ""
if (phase == "noActiveSession" || sessionId.isBlank()) {
lastSensorProjection = null
WatchHeartRateForegroundService.stop(context)
heartRateCollector.finishCurrentSession(context)
return
}
lastSensorProjection = projection
val shouldAggregate = phase == "running"
if (!hasRequiredSensorPermissions(context)) {
WatchHeartRateForegroundService.stop(context)
heartRateCollector.noteActiveSession(
sessionId,
shouldAggregate = false,
@ -336,8 +347,10 @@ object WatchBridgePlugin {
executionContext = telemetryContext(projection),
)
if (shouldAggregate) {
WatchHeartRateForegroundService.start(context, projection)
heartRateCollector.start(context)
} else {
WatchHeartRateForegroundService.stop(context)
heartRateCollector.pause(context)
}
}

View File

@ -1,8 +1,6 @@
package com.gametime.watch.bridge
import android.annotation.SuppressLint
import android.content.Context
import android.os.PowerManager
import android.util.Log
import androidx.health.services.client.HealthServices
import androidx.health.services.client.MeasureClient
@ -39,7 +37,6 @@ internal class WatchHeartRateCollector(
private val registeredDataTypes = mutableSetOf<DeltaDataType<*, *>>()
private var shouldAggregate = false
private var appContext: Context? = null
private var wakeLock: PowerManager.WakeLock? = null
private val callback = object : MeasureCallback {
override fun onAvailabilityChanged(
@ -104,7 +101,6 @@ internal class WatchHeartRateCollector(
return
}
appContext = context.applicationContext
acquireWakeLock(context)
val measureClient = HealthServices.getClient(context).measureClient
registerMeasureCallbackIfNeeded(measureClient, DataType.HEART_RATE_BPM)
registerMeasureCallbackIfNeeded(measureClient, DataType.DISTANCE)
@ -221,7 +217,6 @@ internal class WatchHeartRateCollector(
}
registeredDataTypes.clear()
appContext = null
releaseWakeLock()
}
private fun registerMeasureCallbackIfNeeded(
@ -240,34 +235,6 @@ internal class WatchHeartRateCollector(
}
}
@SuppressLint("WakelockTimeout")
private fun acquireWakeLock(context: Context) {
val existing = wakeLock
if (existing?.isHeld == true) {
return
}
val powerManager = context.applicationContext
.getSystemService(Context.POWER_SERVICE) as? PowerManager
?: return
wakeLock = powerManager.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"GameTime:HeartRateCollection",
).apply {
setReferenceCounted(false)
acquire()
}
Log.d(TAG, "heart rate collection wake lock acquired sessionId=$sessionId")
}
private fun releaseWakeLock() {
val lock = wakeLock
wakeLock = null
if (lock?.isHeld == true) {
lock.release()
Log.d(TAG, "heart rate collection wake lock released")
}
}
private fun reset(nextSessionId: String?) {
sessionId = nextSessionId
sampleCount = 0

View File

@ -0,0 +1,108 @@
package com.gametime.watch.bridge
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import com.gametime.watch.MainActivity
import com.gametime.watch.R
internal class WatchHeartRateForegroundService : Service() {
companion object {
const val CHANNEL_ID = "gametime_watch_heart_rate"
const val CHANNEL_NAME = "Collecte cardio GameTime"
const val NOTIFICATION_ID = 9102
const val EXTRA_EXERCISE_NAME = "exerciseName"
fun start(context: Context, projection: Map<String, Any?>) {
val sessionId = projection["deviceSessionId"] as? String ?: ""
val phase = projection["phase"] as? String ?: "noActiveSession"
if (sessionId.isBlank() || phase != "running") {
stop(context)
return
}
val exerciseName = (projection["exerciseName"] as? String)
?.takeIf { it.isNotBlank() }
?: "Séance en cours"
ContextCompat.startForegroundService(
context,
Intent(context, WatchHeartRateForegroundService::class.java)
.putExtra(EXTRA_EXERCISE_NAME, exerciseName),
)
}
fun stop(context: Context) {
context.stopService(Intent(context, WatchHeartRateForegroundService::class.java))
}
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
WatchBridgePlugin.attachApplicationContext(applicationContext)
val exerciseName = intent
?.getStringExtra(EXTRA_EXERCISE_NAME)
?.takeIf { it.isNotBlank() }
?: "Séance en cours"
ServiceCompat.startForeground(
this,
NOTIFICATION_ID,
buildNotification(exerciseName),
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_HEALTH
} else {
0
},
)
return START_STICKY
}
private fun buildNotification(exerciseName: String): Notification {
ensureNotificationChannel()
val touchIntent = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_ongoing_gt)
.setContentTitle("GameTime")
.setContentText(exerciseName)
.setContentIntent(touchIntent)
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setShowWhen(false)
.build()
}
private fun ensureNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return
}
val manager = getSystemService(NotificationManager::class.java)
if (manager.getNotificationChannel(CHANNEL_ID) != null) {
return
}
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
NotificationManager.IMPORTANCE_LOW,
),
)
}
}

View File

@ -187,6 +187,10 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
return _sendScoreCommand(WatchCommandType.decrementScore, -1);
}
Future<void> completeCurrentStep() {
return _sendCommand(WatchCommandType.completeCurrentStep);
}
@override
void dispose() {
_waitingTimer?.cancel();
@ -288,8 +292,7 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
value = value.copyWith(scoreWaitingForPhone: true);
});
_scoreCommandTimeoutTimer = Timer(_commandTimeout, () {
_clearScorePending(recalibrate: true);
unawaited(HapticFeedback.heavyImpact());
value = value.copyWith(scoreWaitingForPhone: true);
unawaited(_nativeClient.requestResync());
});
try {

View File

@ -77,6 +77,7 @@ final class _WatchSessionScreenState extends State<WatchSessionScreen> {
onTogglePause: widget.viewModel.sendPrimaryAction,
onIncrementScore: widget.viewModel.incrementScore,
onDecrementScore: widget.viewModel.decrementScore,
onCompleteStep: widget.viewModel.completeCurrentStep,
),
),
_RoundScaffold(
@ -399,6 +400,7 @@ final class _SessionMainView extends StatelessWidget {
required this.onTogglePause,
required this.onIncrementScore,
required this.onDecrementScore,
required this.onCompleteStep,
});
final WatchSessionUiState state;
@ -407,6 +409,7 @@ final class _SessionMainView extends StatelessWidget {
final VoidCallback onTogglePause;
final VoidCallback onIncrementScore;
final VoidCallback onDecrementScore;
final VoidCallback onCompleteStep;
@override
Widget build(BuildContext context) {
@ -456,6 +459,7 @@ final class _SessionMainView extends StatelessWidget {
onTogglePause: canToggleTimer ? onTogglePause : null,
onIncrementScore: onIncrementScore,
onDecrementScore: onDecrementScore,
onCompleteStep: onCompleteStep,
),
),
),
@ -703,12 +707,14 @@ final class _ActiveContent extends StatelessWidget {
required this.onTogglePause,
required this.onIncrementScore,
required this.onDecrementScore,
required this.onCompleteStep,
});
final WatchSessionUiState state;
final VoidCallback? onTogglePause;
final VoidCallback onIncrementScore;
final VoidCallback onDecrementScore;
final VoidCallback onCompleteStep;
@override
Widget build(BuildContext context) {
@ -726,10 +732,15 @@ final class _ActiveContent extends StatelessWidget {
projection,
primaryTimer: timer,
);
final repsTarget = _repsStepTarget(projection);
final dominantValue = timer == null
? _seriesValue(projection)
: _timerText(timer);
final dominantLabel = timer == null ? 'SÉRIE' : timer.label;
final controlsEnabled =
!state.connectionLost &&
projection.phoneReachable &&
!state.commandPending;
return _ScaledContent(
child: Column(
mainAxisSize: MainAxisSize.min,
@ -737,9 +748,15 @@ final class _ActiveContent extends StatelessWidget {
_ExerciseName(projection.exerciseName),
_StepNameBand(projection.stepName),
const SizedBox(height: 2),
_SmallLabel(dominantLabel),
_SmallLabel(repsTarget == null ? dominantLabel : 'Répétitions'),
const SizedBox(height: 2),
if (timer == null)
if (repsTarget != null)
_DominantRepsLine(
value: repsTarget.toString(),
pending: state.commandPending,
onComplete: controlsEnabled ? onCompleteStep : null,
)
else if (timer == null)
_DominantValue(dominantValue)
else
_DominantTimerLine(
@ -770,6 +787,62 @@ final class _ActiveContent extends StatelessWidget {
}
}
final class _DominantRepsLine extends StatelessWidget {
const _DominantRepsLine({
required this.value,
required this.pending,
required this.onComplete,
});
final String value;
final bool pending;
final VoidCallback? onComplete;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 52,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(child: _DominantValue(value)),
const SizedBox(width: 6),
_CompleteStepButton(pending: pending, onPressed: onComplete),
],
),
);
}
}
final class _CompleteStepButton extends StatelessWidget {
const _CompleteStepButton({required this.pending, required this.onPressed});
final bool pending;
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
return SizedBox.square(
dimension: 48,
child: IconButton(
onPressed: onPressed,
tooltip: 'Valider létape',
visualDensity: VisualDensity.compact,
iconSize: 24,
color: const Color(0xFFC9A24A),
disabledColor: const Color(0xFF414754),
style: IconButton.styleFrom(
backgroundColor: const Color(0xFF141824),
side: const BorderSide(color: Color(0xFFD72638)),
),
icon: pending
? const _PendingDot(key: ValueKey('complete-step-pending-dot'))
: const Icon(Icons.check),
),
);
}
}
final class _DominantTimerLine extends StatelessWidget {
const _DominantTimerLine({
required this.value,
@ -901,7 +974,7 @@ final class _ManualScoreContent extends StatelessWidget {
SizedBox(
height: 6,
child: state.scoreWaitingForPhone
? const _PendingDot()
? const _PendingDot(key: ValueKey('score-pending-dot'))
: const SizedBox.shrink(),
),
_MainHeartRateLine(sample: state.sensorSample),
@ -1465,6 +1538,14 @@ String _seriesValue(WatchSessionProjection projection) {
return '${projection.seriesIndex}/${projection.seriesTotal}';
}
int? _repsStepTarget(WatchSessionProjection projection) {
final target = projection.stepTargetValue;
if (projection.stepType != WatchStepType.reps || target == null) {
return null;
}
return target > 0 ? target : null;
}
String _scoreText(double value) {
if (value == value.roundToDouble()) {
return value.toInt().toString();

View File

@ -585,6 +585,104 @@ void main() {
viewModel.dispose();
});
testWidgets('shows reps target and direct completion on a reps-only step', (
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);
await tester.pumpWidget(
MaterialApp(
theme: watchTheme(),
home: WatchSessionScreen(viewModel: viewModel),
),
);
client.emitProjection(_repsStepProjection());
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(find.text('Répétitions'), findsOneWidget);
expect(find.text('12'), findsOneWidget);
expect(find.text('SÉRIE'), findsNothing);
expect(find.text('Prêt pour la série suivante'), findsNothing);
expect(find.byTooltip('Valider létape'), findsOneWidget);
expect(tester.takeException(), isNull);
await tester.tap(find.byTooltip('Valider létape'));
await tester.pump();
expect(
client.sentCommands.single.type,
WatchCommandType.completeCurrentStep,
);
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
});
testWidgets(
'keeps optimistic score visible after timeout until explicit rejection',
(tester) async {
final client = _FakeNativeWatchBridgeClient();
final viewModel = WatchSessionViewModel(
nativeClient: client,
waitingThreshold: const Duration(milliseconds: 50),
commandTimeout: const Duration(milliseconds: 120),
);
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),
),
);
client.emitProjection(_manualScoreProjection());
await tester.pump();
await tester.tap(find.byTooltip('Ajouter'));
await tester.pump();
final commandId = client.sentCommands.single.commandId;
expect(find.text('4'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 150));
expect(find.text('4'), findsOneWidget);
expect(find.byKey(const ValueKey('score-pending-dot')), findsOneWidget);
expect(client.resyncRequests, greaterThanOrEqualTo(2));
client.emitProjection(_manualScoreProjection());
await tester.pump();
expect(find.text('4'), findsOneWidget);
client.emitAck(
WatchCommandAckEvent(
commandId: commandId,
status: WatchCommandAck.rejectedNotApplicable,
sessionId: 'session-1',
),
);
await tester.pump();
expect(find.text('3'), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
},
);
testWidgets('vibrates once when a countdown timer reaches zero', (
tester,
) async {
@ -762,6 +860,26 @@ WatchSessionProjection _readyProjection() {
);
}
WatchSessionProjection _repsStepProjection() {
return WatchSessionProjection(
deviceSessionId: 'session-1',
revision: 7,
projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
phase: WatchSessionPhase.running,
phoneReachable: true,
seriesIndex: 1,
seriesTotal: 3,
exerciseName: 'Pompes',
stepIndex: 1,
stepTotal: 2,
stepName: 'Pompes strictes',
stepType: WatchStepType.reps,
stepTargetValue: 12,
primaryAction: WatchPrimaryAction.pauseSession,
secondaryActions: const [WatchSecondaryAction.skipCurrentStep],
);
}
WatchSessionProjection _secondaryRestActionProjection() {
return WatchSessionProjection(
deviceSessionId: 'session-1',