feat(watch): renforce la vibration de fin de chrono et consolide la remontee des donnees stats montre

Cote montre pour #180 (vibration timerFinished plus longue et plus forte) et #179 (contrat/collecte des donnees stats vers le telephone).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 14:55:52 +02:00
parent 36691643d7
commit b4503d6b5f
8 changed files with 170 additions and 29 deletions

View File

@ -1,4 +1,4 @@
const int watchBridgeSchemaVersion = 5;
const int watchBridgeSchemaVersion = 6;
enum WatchCommandType {
startCurrentExercise,
@ -617,6 +617,7 @@ final class WatchSensorSample {
this.heartRateBpm,
this.distanceMeters,
this.caloriesKcal,
this.sensorCollectionStatus,
}) : capturedAtEpochMs = capturedAtEpochMs ?? recordedAtEpochMs ?? 0;
factory WatchSensorSample.fromJson(Map<String, Object?> json) {
@ -642,6 +643,9 @@ final class WatchSensorSample {
heartRateBpm: _nullableIntFromJson(json['heartRateBpm']),
distanceMeters: _nullableDoubleFromJson(json['distanceMeters']),
caloriesKcal: _nullableDoubleFromJson(json['caloriesKcal']),
sensorCollectionStatus: _nullableStringFromJson(
json['sensorCollectionStatus'],
),
);
}
@ -660,6 +664,7 @@ final class WatchSensorSample {
final int? heartRateBpm;
final double? distanceMeters;
final double? caloriesKcal;
final String? sensorCollectionStatus;
int get recordedAtEpochMs => capturedAtEpochMs;
@ -681,6 +686,7 @@ final class WatchSensorSample {
'heartRateBpm': heartRateBpm,
'distanceMeters': distanceMeters,
'caloriesKcal': caloriesKcal,
'sensorCollectionStatus': sensorCollectionStatus,
};
}
@ -702,7 +708,8 @@ final class WatchSensorSample {
stepSnapshotId == other.stepSnapshotId &&
heartRateBpm == other.heartRateBpm &&
distanceMeters == other.distanceMeters &&
caloriesKcal == other.caloriesKcal;
caloriesKcal == other.caloriesKcal &&
sensorCollectionStatus == other.sensorCollectionStatus;
}
@override
@ -723,6 +730,7 @@ final class WatchSensorSample {
heartRateBpm,
distanceMeters,
caloriesKcal,
sensorCollectionStatus,
);
}
}

View File

@ -151,6 +151,7 @@ void main() {
heartRateBpm: 142,
distanceMeters: 840.5,
caloriesKcal: 184.2,
sensorCollectionStatus: 'active',
);
final decoded = WatchSensorSample.fromJson(

View File

@ -672,6 +672,21 @@ final class _FakeWorkoutTelemetryRepository
.toList(growable: false);
}
@override
Future<List<WorkoutTelemetrySample>> listSamplesForScope({
required String sessionId,
required WorkoutTelemetryAggregateScope scope,
int? programIndex,
int? exerciseIndex,
int? setIndex,
int? passageIndex,
int? stepIndex,
}) async {
return samples
.where((sample) => sample.sessionId == sessionId)
.toList(growable: false);
}
@override
Future<void> replaceAggregatesForSession({
required String sessionId,

View File

@ -170,8 +170,7 @@ object WatchBridgePlugin {
return
}
sensorPermissionRequestInFlight = false
val granted = appContext?.let(::hasRequiredSensorPermissions) == true ||
grantResults.all { it == PackageManager.PERMISSION_GRANTED }
val granted = appContext?.let(::hasRequiredRuntimePermissions) == true
if (granted) {
pendingSensorPermissionRequest = false
Log.d(TAG, "sensor permissions granted")
@ -187,6 +186,7 @@ object WatchBridgePlugin {
grantResults.getOrNull(index) != PackageManager.PERMISSION_GRANTED
}
Log.w(TAG, "sensor permissions denied=${denied.joinToString()}")
emitSensorPermissionRequired()
}
}
@ -458,12 +458,12 @@ object WatchBridgePlugin {
@Suppress("DEPRECATION")
context.getSystemService(Vibrator::class.java)
} ?: return
val timings = longArrayOf(0L, 180L, 120L, 260L)
val timings = longArrayOf(0L, 320L, 90L, 420L, 90L, 520L)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
vibrator.vibrate(
VibrationEffect.createWaveform(
timings,
intArrayOf(0, 255, 0, 255),
intArrayOf(0, 255, 0, 255, 0, 255),
-1,
),
)
@ -492,6 +492,7 @@ object WatchBridgePlugin {
executionContext = telemetryContext(projection),
)
pendingSensorPermissionRequest = true
emitSensorPermissionRequired()
requestSensorPermissionsOnce()
return
}
@ -603,6 +604,29 @@ object WatchBridgePlugin {
val index = (value as? Number)?.toInt() ?: return null
return if (index > 0) index - 1 else null
}
private fun emitSensorPermissionRequired() {
val projection = lastSensorProjection ?: lastActiveProjection ?: return
val sessionId = projection["deviceSessionId"] as? String ?: return
if (sessionId.isBlank()) {
return
}
emitSensorSample(
mapOf(
"schemaVersion" to 4,
"sampleId" to "$sessionId-permission-${System.currentTimeMillis()}",
"sessionId" to sessionId,
"capturedAtEpochMs" to System.currentTimeMillis(),
"recordedAtEpochMs" to System.currentTimeMillis(),
"programIndex" to projection["programIndex"],
"exerciseIndex" to projection["exerciseIndex"],
"setIndex" to projection["setIndex"],
"passageIndex" to zeroBasedNullableIndex(projection["passageIndex"]),
"stepIndex" to zeroBasedNullableIndex(projection["stepIndex"]),
"sensorCollectionStatus" to "authorizationRequired",
),
)
}
}
private fun JSONObject.toMap(): Map<String, Any?> {

View File

@ -445,6 +445,8 @@ internal class WatchHeartRateCollector(
sampleSequence = 0
executionContext = emptyMap()
shouldAggregate = false
exerciseMetricsStarted = false
exerciseMetricsStartInFlight = false
exerciseHeartRateSupported = false
exerciseHeartRateObserved = false
}

View File

@ -86,7 +86,8 @@ final class WatchSessionUiState {
return sample != null &&
(sample.heartRateBpm != null ||
sample.distanceMeters != null ||
sample.caloriesKcal != null);
sample.caloriesKcal != null ||
sample.sensorCollectionStatus != null);
}
}
@ -348,7 +349,8 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
final hasMetric =
sample.heartRateBpm != null ||
sample.distanceMeters != null ||
sample.caloriesKcal != null;
sample.caloriesKcal != null ||
sample.sensorCollectionStatus != null;
if (!hasMetric) {
return;
}

View File

@ -1296,20 +1296,24 @@ final class _StatsView extends StatelessWidget {
Widget build(BuildContext context) {
final sample = state.sensorSample;
final metrics = [
if (_heartRateLabel(sample) case final value?)
_StatsMetric(icon: Icons.favorite, label: 'FC', value: value),
if (_distanceLabel(sample) case final value?)
_StatsMetric(
icon: Icons.directions_run,
label: 'Distance',
value: value,
),
if (_caloriesLabel(sample) case final value?)
_StatsMetric(
icon: Icons.local_fire_department,
label: 'Calories',
value: value,
),
_StatsMetric(
icon: Icons.favorite,
label: 'FC',
value: _heartRateMetricLabel(sample),
available: _heartRateLabel(sample) != null,
),
_StatsMetric(
icon: Icons.directions_run,
label: 'Distance',
value: _distanceMetricLabel(sample),
available: _distanceLabel(sample) != null,
),
_StatsMetric(
icon: Icons.local_fire_department,
label: 'Calories',
value: _caloriesMetricLabel(sample),
available: _caloriesLabel(sample) != null,
),
];
return Stack(
children: [
@ -1356,11 +1360,13 @@ final class _StatsMetric extends StatelessWidget {
required this.icon,
required this.label,
required this.value,
required this.available,
});
final IconData icon;
final String label;
final String value;
final bool available;
@override
Widget build(BuildContext context) {
@ -1384,13 +1390,18 @@ final class _StatsMetric extends StatelessWidget {
style: Theme.of(context).textTheme.bodySmall,
),
),
Text(
value,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontSize: 14,
color: const Color(0xFFC9A24A),
Flexible(
child: Text(
value,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.end,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontSize: available ? 14 : 11,
color: available
? const Color(0xFFC9A24A)
: const Color(0xFFA7ADBA),
),
),
),
],
@ -1777,6 +1788,28 @@ String? _heartRateLabel(WatchSensorSample? sample) {
return '$bpm bpm';
}
String _heartRateMetricLabel(WatchSensorSample? sample) {
return _heartRateLabel(sample) ?? _sensorUnavailableLabel(sample);
}
String _distanceMetricLabel(WatchSensorSample? sample) {
return _distanceLabel(sample) ?? _sensorUnavailableLabel(sample);
}
String _caloriesMetricLabel(WatchSensorSample? sample) {
return _caloriesLabel(sample) ?? _sensorUnavailableLabel(sample);
}
String _sensorUnavailableLabel(WatchSensorSample? sample) {
if (sample?.sensorCollectionStatus == 'authorizationRequired') {
return 'Autorisation requise';
}
if (sample == null) {
return 'Mesure en attente';
}
return 'Donnée indisponible';
}
String? _distanceLabel(WatchSensorSample? sample) {
final meters = sample?.distanceMeters;
if (meters == null || meters < 0) {

View File

@ -347,6 +347,62 @@ void main() {
},
);
testWidgets(
'shows explicit metric states when sensor permission is required',
(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(_runningProjection());
await tester.pump();
client.emitSensorSample(
WatchSensorSample(
sessionId: 'session-1',
capturedAtEpochMs: DateTime.utc(
2026,
7,
30,
10,
).millisecondsSinceEpoch,
sensorCollectionStatus: 'authorizationRequired',
),
);
await tester.pump();
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();
expect(find.text('Stats'), findsOneWidget);
expect(find.text('Autorisation requise'), findsNWidgets(3));
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox.shrink());
viewModel.dispose();
},
);
testWidgets(
'sends pause command from the icon button when a timer dominates',
(tester) async {