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:
@ -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>
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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',
|
||||
|
||||
Reference in New Issue
Block a user