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 {
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -151,6 +151,7 @@ void main() {
|
||||
heartRateBpm: 142,
|
||||
distanceMeters: 840.5,
|
||||
caloriesKcal: 184.2,
|
||||
sensorCollectionStatus: 'active',
|
||||
);
|
||||
|
||||
final decoded = WatchSensorSample.fromJson(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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?> {
|
||||
|
||||
@ -445,6 +445,8 @@ internal class WatchHeartRateCollector(
|
||||
sampleSequence = 0
|
||||
executionContext = emptyMap()
|
||||
shouldAggregate = false
|
||||
exerciseMetricsStarted = false
|
||||
exerciseMetricsStartInFlight = false
|
||||
exerciseHeartRateSupported = false
|
||||
exerciseHeartRateObserved = false
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user