chore(wip): consolidation intermédiaire multi-tickets (sprints Statistiques, UI, Bug resolution, Serveur-client)
Regroupe l'état de travail en cours réalisé dans un même worktree sur plusieurs tickets/sprints (#85, #136, #145, #155-160, #162-164), mélangeant des tickets QA et inProgress. Ne constitue pas une feature terminée : commit de sauvegarde avant triage/split par ticket en branches feature/* dédiées. Exclut les dossiers d'environnement de build locaux et le heap dump parasite (.gitignore mis à jour). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -9,6 +9,7 @@ import java.nio.charset.StandardCharsets
|
||||
|
||||
class WatchBridgeListenerService : WearableListenerService() {
|
||||
override fun onDataChanged(dataEvents: DataEventBuffer) {
|
||||
WatchBridgePlugin.attachApplicationContext(applicationContext)
|
||||
try {
|
||||
for (event in dataEvents) {
|
||||
WatchBridgePlugin.handleDataEvent(event)
|
||||
@ -19,6 +20,7 @@ class WatchBridgeListenerService : WearableListenerService() {
|
||||
}
|
||||
|
||||
override fun onMessageReceived(messageEvent: MessageEvent) {
|
||||
WatchBridgePlugin.attachApplicationContext(applicationContext)
|
||||
if (messageEvent.path != WatchBridgePlugin.ACK_PATH) {
|
||||
return
|
||||
}
|
||||
@ -27,6 +29,7 @@ class WatchBridgeListenerService : WearableListenerService() {
|
||||
}
|
||||
|
||||
override fun onCapabilityChanged(capabilityInfo: CapabilityInfo) {
|
||||
WatchBridgePlugin.attachApplicationContext(applicationContext)
|
||||
if (capabilityInfo.name != WatchBridgePlugin.PHONE_CAPABILITY) {
|
||||
return
|
||||
}
|
||||
@ -41,6 +44,7 @@ class WatchBridgeListenerService : WearableListenerService() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
WatchBridgePlugin.attachApplicationContext(applicationContext)
|
||||
WatchBridgePlugin.requestCapabilityRefresh(applicationContext)
|
||||
WatchBridgePlugin.requestLatestProjection(applicationContext)
|
||||
}
|
||||
|
||||
@ -56,8 +56,12 @@ object WatchBridgePlugin {
|
||||
private var lastSensorPermissionRequestEpochMs = 0L
|
||||
private var pendingSensorPermissionRequest = false
|
||||
|
||||
fun register(flutterEngine: FlutterEngine, context: Context) {
|
||||
fun attachApplicationContext(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
}
|
||||
|
||||
fun register(flutterEngine: FlutterEngine, context: Context) {
|
||||
attachApplicationContext(context)
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, METHOD_CHANNEL)
|
||||
.setMethodCallHandler(::handleMethodCall)
|
||||
EventChannel(flutterEngine.dartExecutor.binaryMessenger, PROJECTION_CHANNEL)
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
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
|
||||
@ -37,6 +39,7 @@ 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(
|
||||
@ -101,6 +104,7 @@ 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)
|
||||
@ -217,6 +221,7 @@ internal class WatchHeartRateCollector(
|
||||
}
|
||||
registeredDataTypes.clear()
|
||||
appContext = null
|
||||
releaseWakeLock()
|
||||
}
|
||||
|
||||
private fun registerMeasureCallbackIfNeeded(
|
||||
@ -235,6 +240,34 @@ 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
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
|
||||
|
||||
import '../application/watch_session_view_model.dart';
|
||||
@ -22,6 +23,8 @@ final class _WatchSessionScreenState extends State<WatchSessionScreen> {
|
||||
var _lastFailureNoticeSerial = 0;
|
||||
var _returnToSessionAfterSecondarySuccess = false;
|
||||
var _secondaryActionStartFailureSerial = 0;
|
||||
final _timerRemainingMsByKey = <String, int>{};
|
||||
final _completionHapticTimerKeys = <String>{};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@ -50,6 +53,7 @@ final class _WatchSessionScreenState extends State<WatchSessionScreen> {
|
||||
_syncFailureNotice(state);
|
||||
_syncSecondaryNavigation(state);
|
||||
final projection = state.projection;
|
||||
_syncTimerCompletionHaptic(projection);
|
||||
if (projection.phase == WatchSessionPhase.noActiveSession) {
|
||||
final phoneReachable =
|
||||
projection.phoneReachable && !state.connectionLost;
|
||||
@ -198,6 +202,26 @@ final class _WatchSessionScreenState extends State<WatchSessionScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
void _syncTimerCompletionHaptic(WatchSessionProjection projection) {
|
||||
final timer = _primaryDisplayTimer(projection);
|
||||
if (timer == null ||
|
||||
timer.displayMode != WatchTimerDisplayMode.countdown ||
|
||||
timer.runState != WatchTimerRunState.running) {
|
||||
return;
|
||||
}
|
||||
final key = _timerHapticKey(projection, timer);
|
||||
final remainingMs = _displayDuration(timer).inMilliseconds;
|
||||
final previousRemainingMs = _timerRemainingMsByKey[key];
|
||||
_timerRemainingMsByKey[key] = remainingMs;
|
||||
if (remainingMs > 0 ||
|
||||
previousRemainingMs == null ||
|
||||
previousRemainingMs <= 0 ||
|
||||
!_completionHapticTimerKeys.add(key)) {
|
||||
return;
|
||||
}
|
||||
unawaited(HapticFeedback.heavyImpact());
|
||||
}
|
||||
|
||||
Future<bool> _confirm({
|
||||
required String title,
|
||||
required String message,
|
||||
@ -392,8 +416,9 @@ final class _SessionMainView extends StatelessWidget {
|
||||
projection.phase == WatchSessionPhase.restPaused;
|
||||
final connectionLost = state.connectionLost || !projection.phoneReachable;
|
||||
final hasActions = projection.secondaryActions.isNotEmpty;
|
||||
final timer = projection.dominantTimer;
|
||||
final timer = _primaryDisplayTimer(projection);
|
||||
final showsTimer = timer != null;
|
||||
final footerText = _sessionFooterText(state);
|
||||
final canToggleTimer =
|
||||
showsTimer &&
|
||||
!connectionLost &&
|
||||
@ -485,24 +510,21 @@ final class _SessionMainView extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Text(
|
||||
state.commandPending
|
||||
? _primaryLabel(state)
|
||||
: state.staleProjection
|
||||
? 'Dernier état reçu'
|
||||
: _exerciseProgress(projection),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(fontSize: 10),
|
||||
if (footerText.isNotEmpty)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Text(
|
||||
footerText,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(fontSize: 10),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@ -633,15 +655,23 @@ final class _MainHeartRateLine extends StatelessWidget {
|
||||
}
|
||||
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,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.favorite, size: 11, color: Color(0xFFFF4D5E)),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: const Color(0xFFA7ADBA),
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -691,7 +721,11 @@ final class _ActiveContent extends StatelessWidget {
|
||||
onDecrement: onDecrementScore,
|
||||
);
|
||||
}
|
||||
final timer = projection.dominantTimer;
|
||||
final timer = _primaryDisplayTimer(projection);
|
||||
final secondaryTimers = _visibleSecondaryTimers(
|
||||
projection,
|
||||
primaryTimer: timer,
|
||||
);
|
||||
final dominantValue = timer == null
|
||||
? _seriesValue(projection)
|
||||
: _timerText(timer);
|
||||
@ -705,23 +739,25 @@ final class _ActiveContent extends StatelessWidget {
|
||||
const SizedBox(height: 2),
|
||||
_SmallLabel(dominantLabel),
|
||||
const SizedBox(height: 2),
|
||||
_DominantValue(dominantValue),
|
||||
_MainHeartRateLine(sample: state.sensorSample),
|
||||
if (timer != null) ...[
|
||||
const SizedBox(height: 3),
|
||||
_TimerToggleButton(
|
||||
runState: timer.runState,
|
||||
if (timer == null)
|
||||
_DominantValue(dominantValue)
|
||||
else
|
||||
_DominantTimerLine(
|
||||
value: dominantValue,
|
||||
timer: timer,
|
||||
pending: state.timerTogglePending,
|
||||
onPressed: onTogglePause,
|
||||
onTogglePause: onTogglePause,
|
||||
),
|
||||
_MainHeartRateLine(sample: state.sensorSample),
|
||||
if (timer == null) ...[
|
||||
const SizedBox(height: 5),
|
||||
_StatusLine(projection.statusLabel),
|
||||
],
|
||||
const SizedBox(height: 5),
|
||||
_StatusLine(projection.statusLabel ?? timer?.label),
|
||||
if (projection.secondaryTimers.isNotEmpty)
|
||||
if (secondaryTimers.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
projection.secondaryTimers.map(_compactTimerText).join(' · '),
|
||||
secondaryTimers.map(_compactTimerText).join(' · '),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
@ -734,6 +770,54 @@ final class _ActiveContent extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
final class _DominantTimerLine extends StatelessWidget {
|
||||
const _DominantTimerLine({
|
||||
required this.value,
|
||||
required this.timer,
|
||||
required this.pending,
|
||||
required this.onTogglePause,
|
||||
this.compact = false,
|
||||
});
|
||||
|
||||
final String value;
|
||||
final WatchTimerProjection timer;
|
||||
final bool pending;
|
||||
final VoidCallback? onTogglePause;
|
||||
final bool compact;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: compact ? 34 : 48,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Flexible(
|
||||
child: compact
|
||||
? Text(
|
||||
value,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleSmall?.copyWith(fontSize: 26, height: 1),
|
||||
)
|
||||
: _DominantValue(value),
|
||||
),
|
||||
SizedBox(width: compact ? 4 : 6),
|
||||
_TimerToggleButton(
|
||||
runState: timer.runState,
|
||||
pending: pending,
|
||||
onPressed: onTogglePause,
|
||||
compact: compact,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class _ManualScoreContent extends StatelessWidget {
|
||||
const _ManualScoreContent({
|
||||
required this.state,
|
||||
@ -759,7 +843,7 @@ final class _ManualScoreContent extends StatelessWidget {
|
||||
controlsEnabled &&
|
||||
score > 0 &&
|
||||
(state.scoreCommandPending || projection.canDecrementScore);
|
||||
final timer = projection.dominantTimer;
|
||||
final timer = _primaryDisplayTimer(projection);
|
||||
final canToggleTimer =
|
||||
timer != null &&
|
||||
controlsEnabled &&
|
||||
@ -850,28 +934,35 @@ final class _CompactTimerLine extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 28,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
height: 45,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
_compactTimerText(timer),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
_SmallLabel(timer.label),
|
||||
const SizedBox(height: 1),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
_timerText(timer),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleSmall?.copyWith(fontSize: 18, height: 1),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
_TimerToggleButton(
|
||||
runState: timer.runState,
|
||||
pending: pending,
|
||||
onPressed: onTogglePause,
|
||||
compact: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (onTogglePause != null || pending) ...[
|
||||
const SizedBox(width: 4),
|
||||
_TimerToggleButton(
|
||||
runState: timer.runState,
|
||||
pending: pending,
|
||||
onPressed: onTogglePause,
|
||||
compact: true,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
@ -972,40 +1063,37 @@ final class _RestContent extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final projection = state.projection;
|
||||
final timer = projection.dominantTimer;
|
||||
final timer = _primaryDisplayTimer(projection);
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const _SmallLabel('REPOS'),
|
||||
const SizedBox(height: 2),
|
||||
_DominantValue(timer == null ? '--:--' : _timerText(timer)),
|
||||
_MainHeartRateLine(sample: state.sensorSample),
|
||||
_SmallLabel(_afterSeriesLabel(projection)),
|
||||
if (timer != null) ...[
|
||||
const SizedBox(height: 3),
|
||||
_TimerToggleButton(
|
||||
runState: timer.runState,
|
||||
const SizedBox(height: 2),
|
||||
_DominantTimerLine(
|
||||
value: _timerText(timer),
|
||||
timer: timer,
|
||||
pending: state.timerTogglePending,
|
||||
onPressed: onTogglePause,
|
||||
onTogglePause: onTogglePause,
|
||||
compact: true,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 6),
|
||||
_MainHeartRateLine(sample: state.sensorSample),
|
||||
const SizedBox(height: 3),
|
||||
if (projection.nextExerciseName case final next?) ...[
|
||||
const _SmallLabel('Ensuite'),
|
||||
Text(
|
||||
'Ensuite : $next',
|
||||
next,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
] else ...[
|
||||
_ExerciseName(projection.exerciseName),
|
||||
const SizedBox(height: 5),
|
||||
],
|
||||
_StatusLine(
|
||||
projection.statusLabel ??
|
||||
'Série ${projection.seriesIndex}/${projection.seriesTotal}',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@ -1398,6 +1486,51 @@ String _exerciseProgress(WatchSessionProjection projection) {
|
||||
return projection.statusLabel ?? '';
|
||||
}
|
||||
|
||||
String _sessionFooterText(WatchSessionUiState state) {
|
||||
if (state.commandPending) {
|
||||
return _primaryLabel(state);
|
||||
}
|
||||
if (state.staleProjection) {
|
||||
return 'Dernier état reçu';
|
||||
}
|
||||
final projection = state.projection;
|
||||
if (projection.phase == WatchSessionPhase.restRunning ||
|
||||
projection.phase == WatchSessionPhase.restPaused) {
|
||||
return '';
|
||||
}
|
||||
return _exerciseProgress(projection);
|
||||
}
|
||||
|
||||
String _afterSeriesLabel(WatchSessionProjection projection) {
|
||||
if (projection.seriesIndex <= 0 || projection.seriesTotal <= 0) {
|
||||
return 'Après série';
|
||||
}
|
||||
return 'Après série ${projection.seriesIndex}/${projection.seriesTotal}';
|
||||
}
|
||||
|
||||
WatchTimerProjection? _primaryDisplayTimer(WatchSessionProjection projection) {
|
||||
final dominantTimer = projection.dominantTimer;
|
||||
if (dominantTimer != null && dominantTimer.kind != WatchTimerKind.setTimer) {
|
||||
return dominantTimer;
|
||||
}
|
||||
for (final timer in _visibleSecondaryTimers(projection)) {
|
||||
return timer;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
List<WatchTimerProjection> _visibleSecondaryTimers(
|
||||
WatchSessionProjection projection, {
|
||||
WatchTimerProjection? primaryTimer,
|
||||
}) {
|
||||
return projection.secondaryTimers
|
||||
.where(
|
||||
(timer) =>
|
||||
timer.kind != WatchTimerKind.setTimer && timer != primaryTimer,
|
||||
)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
String _timerText(WatchTimerProjection timer) {
|
||||
final duration = _displayDuration(timer);
|
||||
final totalSeconds = duration.inSeconds;
|
||||
@ -1410,6 +1543,23 @@ String _compactTimerText(WatchTimerProjection timer) {
|
||||
return '${timer.label} ${_timerText(timer)}';
|
||||
}
|
||||
|
||||
String _timerHapticKey(
|
||||
WatchSessionProjection projection,
|
||||
WatchTimerProjection timer,
|
||||
) {
|
||||
return [
|
||||
projection.deviceSessionId,
|
||||
projection.seriesIndex,
|
||||
projection.passageIndex ?? '-',
|
||||
projection.stepIndex ?? '-',
|
||||
timer.kind.name,
|
||||
timer.label,
|
||||
timer.referenceEpochMs,
|
||||
timer.startedAtEpochMs ?? '-',
|
||||
timer.targetMs ?? '-',
|
||||
].join(':');
|
||||
}
|
||||
|
||||
String? _heartRateLabel(WatchSensorSample? sample) {
|
||||
final bpm = sample?.heartRateBpm;
|
||||
if (bpm == null || bpm <= 0) {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gametime_watch/application/watch_session_view_model.dart';
|
||||
import 'package:gametime_watch/infrastructure/watch_bridge/native_watch_bridge_client.dart';
|
||||
@ -35,7 +36,10 @@ void main() {
|
||||
|
||||
expect(find.text('Squat jump'), findsOneWidget);
|
||||
expect(find.text('02:14'), findsOneWidget);
|
||||
expect(find.text('Chrono étape'), findsOneWidget);
|
||||
expect(find.byTooltip('Pause'), findsOneWidget);
|
||||
expect(find.text('Séance active'), findsNothing);
|
||||
expect(find.text('Série 00:45'), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
@ -83,6 +87,39 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('hides set timer even when it is projected as dominant', (
|
||||
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(_setTimerDominantProjection());
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.text('Chrono étape'), findsOneWidget);
|
||||
expect(find.text('02:14'), findsOneWidget);
|
||||
expect(find.text('Série'), findsNothing);
|
||||
expect(find.text('00:45'), findsNothing);
|
||||
expect(find.byTooltip('Pause'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
viewModel.dispose();
|
||||
});
|
||||
|
||||
testWidgets('does not offer a start action from the no-session screen', (
|
||||
tester,
|
||||
) async {
|
||||
@ -204,7 +241,8 @@ void main() {
|
||||
|
||||
expect(find.text('3'), findsOneWidget);
|
||||
expect(find.text('Routine de tir'), findsOneWidget);
|
||||
expect(find.text('Chrono étape 02:14'), findsOneWidget);
|
||||
expect(find.text('Chrono étape'), findsOneWidget);
|
||||
expect(find.text('02:14'), findsOneWidget);
|
||||
expect(find.byTooltip('Pause'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
@ -250,7 +288,9 @@ void main() {
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('FC 142 bpm'), findsOneWidget);
|
||||
expect(find.text('FC 142 bpm'), findsNothing);
|
||||
expect(find.byIcon(Icons.favorite), findsOneWidget);
|
||||
expect(find.text('142 bpm'), findsOneWidget);
|
||||
expect(find.text('840 m'), findsNothing);
|
||||
expect(find.text('186 kcal'), findsNothing);
|
||||
expect(find.byTooltip('Stats'), findsOneWidget);
|
||||
@ -478,6 +518,122 @@ void main() {
|
||||
viewModel.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'renders rest without overlapping series progress and next name',
|
||||
(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(_restProjection());
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.text('REPOS'), findsOneWidget);
|
||||
expect(find.text('Après série 2/4'), findsOneWidget);
|
||||
expect(find.text('Ensuite'), findsOneWidget);
|
||||
expect(find.text('Gainage latéral'), findsOneWidget);
|
||||
expect(find.text('Série 2/4'), findsNothing);
|
||||
expect(find.textContaining('Ensuite :'), findsNothing);
|
||||
expect(find.text('00:30'), findsOneWidget);
|
||||
expect(find.byTooltip('Pause'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
viewModel.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('hides timer controls when no active timer is projected', (
|
||||
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(_manualScoreProjection());
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Chrono étape'), findsNothing);
|
||||
expect(find.text('02:14'), findsNothing);
|
||||
expect(find.byTooltip('Pause'), findsNothing);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
viewModel.dispose();
|
||||
});
|
||||
|
||||
testWidgets('vibrates once when a countdown timer reaches zero', (
|
||||
tester,
|
||||
) async {
|
||||
final hapticCalls = <MethodCall>[];
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(SystemChannels.platform, (call) async {
|
||||
if (call.method == 'HapticFeedback.vibrate') {
|
||||
hapticCalls.add(call);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
addTearDown(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(SystemChannels.platform, null);
|
||||
});
|
||||
|
||||
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(_countdownProjection(accumulatedMs: 29000));
|
||||
await tester.pump();
|
||||
expect(hapticCalls, isEmpty);
|
||||
|
||||
client.emitProjection(_countdownProjection(accumulatedMs: 30000));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(hapticCalls, hasLength(1));
|
||||
expect(hapticCalls.single.arguments, 'HapticFeedbackType.heavyImpact');
|
||||
|
||||
client.emitProjection(_countdownProjection(accumulatedMs: 30000));
|
||||
await tester.pump();
|
||||
expect(hapticCalls, hasLength(1));
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
viewModel.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
final class _FakeNativeWatchBridgeClient implements NativeWatchBridgeClient {
|
||||
@ -622,6 +778,31 @@ WatchSessionProjection _secondaryRestActionProjection() {
|
||||
);
|
||||
}
|
||||
|
||||
WatchSessionProjection _setTimerDominantProjection() {
|
||||
return WatchSessionProjection(
|
||||
deviceSessionId: 'session-1',
|
||||
revision: 6,
|
||||
projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
|
||||
phase: WatchSessionPhase.running,
|
||||
phoneReachable: true,
|
||||
seriesIndex: 2,
|
||||
seriesTotal: 4,
|
||||
exerciseName: 'Squat jump',
|
||||
statusLabel: 'Séance active',
|
||||
primaryAction: WatchPrimaryAction.pauseSession,
|
||||
dominantTimer: const WatchTimerProjection(
|
||||
kind: WatchTimerKind.setTimer,
|
||||
label: 'Série',
|
||||
displayMode: WatchTimerDisplayMode.elapsed,
|
||||
runState: WatchTimerRunState.running,
|
||||
referenceEpochMs: 0,
|
||||
accumulatedMs: 45000,
|
||||
),
|
||||
secondaryTimers: [_runningStepTimer()],
|
||||
secondaryActions: const [WatchSecondaryAction.finishCurrentSet],
|
||||
);
|
||||
}
|
||||
|
||||
void _expectActionsPageVisible(WidgetTester tester) {
|
||||
expect(
|
||||
tester.getTopLeft(find.byKey(const ValueKey('watch-actions-page'))).dx,
|
||||
@ -681,6 +862,60 @@ WatchSessionProjection _manualScoreProjectionWithTimer() {
|
||||
);
|
||||
}
|
||||
|
||||
WatchSessionProjection _restProjection() {
|
||||
return WatchSessionProjection(
|
||||
deviceSessionId: 'session-1',
|
||||
revision: 5,
|
||||
projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
|
||||
phase: WatchSessionPhase.restRunning,
|
||||
phoneReachable: true,
|
||||
seriesIndex: 2,
|
||||
seriesTotal: 4,
|
||||
exerciseName: 'Squat jump',
|
||||
nextExerciseName: 'Gainage latéral',
|
||||
statusLabel: 'Repos',
|
||||
primaryAction: WatchPrimaryAction.pauseSession,
|
||||
dominantTimer: WatchTimerProjection(
|
||||
kind: WatchTimerKind.rest,
|
||||
label: 'Repos',
|
||||
displayMode: WatchTimerDisplayMode.countdown,
|
||||
runState: WatchTimerRunState.running,
|
||||
referenceEpochMs: DateTime.now()
|
||||
.toUtc()
|
||||
.add(const Duration(minutes: 1))
|
||||
.millisecondsSinceEpoch,
|
||||
accumulatedMs: 30000,
|
||||
startedAtEpochMs: DateTime.now().toUtc().millisecondsSinceEpoch,
|
||||
targetMs: 60000,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
WatchSessionProjection _countdownProjection({required int accumulatedMs}) {
|
||||
return WatchSessionProjection(
|
||||
deviceSessionId: 'session-1',
|
||||
revision: accumulatedMs,
|
||||
projectedAtEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
|
||||
phase: WatchSessionPhase.running,
|
||||
phoneReachable: true,
|
||||
seriesIndex: 1,
|
||||
seriesTotal: 3,
|
||||
exerciseName: 'Gainage',
|
||||
statusLabel: 'Chrono étape',
|
||||
primaryAction: WatchPrimaryAction.pauseSession,
|
||||
dominantTimer: WatchTimerProjection(
|
||||
kind: WatchTimerKind.step,
|
||||
label: 'Chrono étape',
|
||||
displayMode: WatchTimerDisplayMode.countdown,
|
||||
runState: WatchTimerRunState.running,
|
||||
referenceEpochMs: DateTime.utc(2026, 7, 27, 10).millisecondsSinceEpoch,
|
||||
accumulatedMs: accumulatedMs,
|
||||
startedAtEpochMs: null,
|
||||
targetMs: 30000,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
WatchTimerProjection _runningStepTimer() {
|
||||
return WatchTimerProjection(
|
||||
kind: WatchTimerKind.step,
|
||||
|
||||
Reference in New Issue
Block a user