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:
@ -1,4 +1,4 @@
|
|||||||
const int watchBridgeSchemaVersion = 5;
|
const int watchBridgeSchemaVersion = 6;
|
||||||
|
|
||||||
enum WatchCommandType {
|
enum WatchCommandType {
|
||||||
startCurrentExercise,
|
startCurrentExercise,
|
||||||
@ -617,6 +617,7 @@ final class WatchSensorSample {
|
|||||||
this.heartRateBpm,
|
this.heartRateBpm,
|
||||||
this.distanceMeters,
|
this.distanceMeters,
|
||||||
this.caloriesKcal,
|
this.caloriesKcal,
|
||||||
|
this.sensorCollectionStatus,
|
||||||
}) : capturedAtEpochMs = capturedAtEpochMs ?? recordedAtEpochMs ?? 0;
|
}) : capturedAtEpochMs = capturedAtEpochMs ?? recordedAtEpochMs ?? 0;
|
||||||
|
|
||||||
factory WatchSensorSample.fromJson(Map<String, Object?> json) {
|
factory WatchSensorSample.fromJson(Map<String, Object?> json) {
|
||||||
@ -642,6 +643,9 @@ final class WatchSensorSample {
|
|||||||
heartRateBpm: _nullableIntFromJson(json['heartRateBpm']),
|
heartRateBpm: _nullableIntFromJson(json['heartRateBpm']),
|
||||||
distanceMeters: _nullableDoubleFromJson(json['distanceMeters']),
|
distanceMeters: _nullableDoubleFromJson(json['distanceMeters']),
|
||||||
caloriesKcal: _nullableDoubleFromJson(json['caloriesKcal']),
|
caloriesKcal: _nullableDoubleFromJson(json['caloriesKcal']),
|
||||||
|
sensorCollectionStatus: _nullableStringFromJson(
|
||||||
|
json['sensorCollectionStatus'],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -660,6 +664,7 @@ final class WatchSensorSample {
|
|||||||
final int? heartRateBpm;
|
final int? heartRateBpm;
|
||||||
final double? distanceMeters;
|
final double? distanceMeters;
|
||||||
final double? caloriesKcal;
|
final double? caloriesKcal;
|
||||||
|
final String? sensorCollectionStatus;
|
||||||
|
|
||||||
int get recordedAtEpochMs => capturedAtEpochMs;
|
int get recordedAtEpochMs => capturedAtEpochMs;
|
||||||
|
|
||||||
@ -681,6 +686,7 @@ final class WatchSensorSample {
|
|||||||
'heartRateBpm': heartRateBpm,
|
'heartRateBpm': heartRateBpm,
|
||||||
'distanceMeters': distanceMeters,
|
'distanceMeters': distanceMeters,
|
||||||
'caloriesKcal': caloriesKcal,
|
'caloriesKcal': caloriesKcal,
|
||||||
|
'sensorCollectionStatus': sensorCollectionStatus,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -702,7 +708,8 @@ final class WatchSensorSample {
|
|||||||
stepSnapshotId == other.stepSnapshotId &&
|
stepSnapshotId == other.stepSnapshotId &&
|
||||||
heartRateBpm == other.heartRateBpm &&
|
heartRateBpm == other.heartRateBpm &&
|
||||||
distanceMeters == other.distanceMeters &&
|
distanceMeters == other.distanceMeters &&
|
||||||
caloriesKcal == other.caloriesKcal;
|
caloriesKcal == other.caloriesKcal &&
|
||||||
|
sensorCollectionStatus == other.sensorCollectionStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -723,6 +730,7 @@ final class WatchSensorSample {
|
|||||||
heartRateBpm,
|
heartRateBpm,
|
||||||
distanceMeters,
|
distanceMeters,
|
||||||
caloriesKcal,
|
caloriesKcal,
|
||||||
|
sensorCollectionStatus,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -151,6 +151,7 @@ void main() {
|
|||||||
heartRateBpm: 142,
|
heartRateBpm: 142,
|
||||||
distanceMeters: 840.5,
|
distanceMeters: 840.5,
|
||||||
caloriesKcal: 184.2,
|
caloriesKcal: 184.2,
|
||||||
|
sensorCollectionStatus: 'active',
|
||||||
);
|
);
|
||||||
|
|
||||||
final decoded = WatchSensorSample.fromJson(
|
final decoded = WatchSensorSample.fromJson(
|
||||||
|
|||||||
@ -672,6 +672,21 @@ final class _FakeWorkoutTelemetryRepository
|
|||||||
.toList(growable: false);
|
.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
|
@override
|
||||||
Future<void> replaceAggregatesForSession({
|
Future<void> replaceAggregatesForSession({
|
||||||
required String sessionId,
|
required String sessionId,
|
||||||
|
|||||||
@ -170,8 +170,7 @@ object WatchBridgePlugin {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
sensorPermissionRequestInFlight = false
|
sensorPermissionRequestInFlight = false
|
||||||
val granted = appContext?.let(::hasRequiredSensorPermissions) == true ||
|
val granted = appContext?.let(::hasRequiredRuntimePermissions) == true
|
||||||
grantResults.all { it == PackageManager.PERMISSION_GRANTED }
|
|
||||||
if (granted) {
|
if (granted) {
|
||||||
pendingSensorPermissionRequest = false
|
pendingSensorPermissionRequest = false
|
||||||
Log.d(TAG, "sensor permissions granted")
|
Log.d(TAG, "sensor permissions granted")
|
||||||
@ -187,6 +186,7 @@ object WatchBridgePlugin {
|
|||||||
grantResults.getOrNull(index) != PackageManager.PERMISSION_GRANTED
|
grantResults.getOrNull(index) != PackageManager.PERMISSION_GRANTED
|
||||||
}
|
}
|
||||||
Log.w(TAG, "sensor permissions denied=${denied.joinToString()}")
|
Log.w(TAG, "sensor permissions denied=${denied.joinToString()}")
|
||||||
|
emitSensorPermissionRequired()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -458,12 +458,12 @@ object WatchBridgePlugin {
|
|||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
context.getSystemService(Vibrator::class.java)
|
context.getSystemService(Vibrator::class.java)
|
||||||
} ?: return
|
} ?: 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) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
vibrator.vibrate(
|
vibrator.vibrate(
|
||||||
VibrationEffect.createWaveform(
|
VibrationEffect.createWaveform(
|
||||||
timings,
|
timings,
|
||||||
intArrayOf(0, 255, 0, 255),
|
intArrayOf(0, 255, 0, 255, 0, 255),
|
||||||
-1,
|
-1,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@ -492,6 +492,7 @@ object WatchBridgePlugin {
|
|||||||
executionContext = telemetryContext(projection),
|
executionContext = telemetryContext(projection),
|
||||||
)
|
)
|
||||||
pendingSensorPermissionRequest = true
|
pendingSensorPermissionRequest = true
|
||||||
|
emitSensorPermissionRequired()
|
||||||
requestSensorPermissionsOnce()
|
requestSensorPermissionsOnce()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -603,6 +604,29 @@ object WatchBridgePlugin {
|
|||||||
val index = (value as? Number)?.toInt() ?: return null
|
val index = (value as? Number)?.toInt() ?: return null
|
||||||
return if (index > 0) index - 1 else 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?> {
|
private fun JSONObject.toMap(): Map<String, Any?> {
|
||||||
|
|||||||
@ -445,6 +445,8 @@ internal class WatchHeartRateCollector(
|
|||||||
sampleSequence = 0
|
sampleSequence = 0
|
||||||
executionContext = emptyMap()
|
executionContext = emptyMap()
|
||||||
shouldAggregate = false
|
shouldAggregate = false
|
||||||
|
exerciseMetricsStarted = false
|
||||||
|
exerciseMetricsStartInFlight = false
|
||||||
exerciseHeartRateSupported = false
|
exerciseHeartRateSupported = false
|
||||||
exerciseHeartRateObserved = false
|
exerciseHeartRateObserved = false
|
||||||
}
|
}
|
||||||
|
|||||||
@ -86,7 +86,8 @@ final class WatchSessionUiState {
|
|||||||
return sample != null &&
|
return sample != null &&
|
||||||
(sample.heartRateBpm != null ||
|
(sample.heartRateBpm != null ||
|
||||||
sample.distanceMeters != 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 =
|
final hasMetric =
|
||||||
sample.heartRateBpm != null ||
|
sample.heartRateBpm != null ||
|
||||||
sample.distanceMeters != null ||
|
sample.distanceMeters != null ||
|
||||||
sample.caloriesKcal != null;
|
sample.caloriesKcal != null ||
|
||||||
|
sample.sensorCollectionStatus != null;
|
||||||
if (!hasMetric) {
|
if (!hasMetric) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1296,19 +1296,23 @@ final class _StatsView extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final sample = state.sensorSample;
|
final sample = state.sensorSample;
|
||||||
final metrics = [
|
final metrics = [
|
||||||
if (_heartRateLabel(sample) case final value?)
|
_StatsMetric(
|
||||||
_StatsMetric(icon: Icons.favorite, label: 'FC', value: value),
|
icon: Icons.favorite,
|
||||||
if (_distanceLabel(sample) case final value?)
|
label: 'FC',
|
||||||
|
value: _heartRateMetricLabel(sample),
|
||||||
|
available: _heartRateLabel(sample) != null,
|
||||||
|
),
|
||||||
_StatsMetric(
|
_StatsMetric(
|
||||||
icon: Icons.directions_run,
|
icon: Icons.directions_run,
|
||||||
label: 'Distance',
|
label: 'Distance',
|
||||||
value: value,
|
value: _distanceMetricLabel(sample),
|
||||||
|
available: _distanceLabel(sample) != null,
|
||||||
),
|
),
|
||||||
if (_caloriesLabel(sample) case final value?)
|
|
||||||
_StatsMetric(
|
_StatsMetric(
|
||||||
icon: Icons.local_fire_department,
|
icon: Icons.local_fire_department,
|
||||||
label: 'Calories',
|
label: 'Calories',
|
||||||
value: value,
|
value: _caloriesMetricLabel(sample),
|
||||||
|
available: _caloriesLabel(sample) != null,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
return Stack(
|
return Stack(
|
||||||
@ -1356,11 +1360,13 @@ final class _StatsMetric extends StatelessWidget {
|
|||||||
required this.icon,
|
required this.icon,
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.value,
|
required this.value,
|
||||||
|
required this.available,
|
||||||
});
|
});
|
||||||
|
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String label;
|
final String label;
|
||||||
final String value;
|
final String value;
|
||||||
|
final bool available;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@ -1384,13 +1390,18 @@ final class _StatsMetric extends StatelessWidget {
|
|||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Flexible(
|
||||||
|
child: Text(
|
||||||
value,
|
value,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
|
textAlign: TextAlign.end,
|
||||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
fontSize: 14,
|
fontSize: available ? 14 : 11,
|
||||||
color: const Color(0xFFC9A24A),
|
color: available
|
||||||
|
? const Color(0xFFC9A24A)
|
||||||
|
: const Color(0xFFA7ADBA),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -1777,6 +1788,28 @@ String? _heartRateLabel(WatchSensorSample? sample) {
|
|||||||
return '$bpm bpm';
|
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) {
|
String? _distanceLabel(WatchSensorSample? sample) {
|
||||||
final meters = sample?.distanceMeters;
|
final meters = sample?.distanceMeters;
|
||||||
if (meters == null || meters < 0) {
|
if (meters == null || meters < 0) {
|
||||||
|
|||||||
@ -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(
|
testWidgets(
|
||||||
'sends pause command from the icon button when a timer dominates',
|
'sends pause command from the icon button when a timer dominates',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
|
|||||||
Reference in New Issue
Block a user