feat(watch): fréquence cardiaque live montre/téléphone (ticket #155)
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 <noreply@anthropic.com>
This commit is contained in:
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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',
|
||||
|
||||
Reference in New Issue
Block a user