feat(server): implemente synchronisation et serveur avec fixtures (#187)

- Implémente la couche de synchronisation avec le serveur
- Ajoute les fixtures versionnées pour les tests
- Met à jour Drift database et repositories pour le support sync
- Améliore les tests de synchronisation
- Corrige et améliore le watch companion pour la collecte de métriques

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 23:07:31 +02:00
parent d1edb2397e
commit fe3608cc4a
31 changed files with 5056 additions and 394 deletions

View File

@ -43,6 +43,7 @@ final class ExerciseLibraryScreen extends StatefulWidget {
final class _ExerciseLibraryScreenState extends State<ExerciseLibraryScreen> {
final _searchController = TextEditingController();
var _selectedBusinessTypes = <BusinessExerciseType?>{};
var _selectedMeasures = <WorkoutMeasure>{};
var _selectedTags = <String>{};
late Future<List<Exercise>> _exercises;
@ -84,6 +85,7 @@ final class _ExerciseLibraryScreenState extends State<ExerciseLibraryScreen> {
}
final exercises = snapshot.data ?? const <Exercise>[];
final typeFilterOptions = _typeFilterOptionsFor(exercises);
final tagSuggestions = tagSuggestionsFor(
exercises,
(exercise) => exercise.tags,
@ -110,6 +112,41 @@ final class _ExerciseLibraryScreenState extends State<ExerciseLibraryScreen> {
),
),
const SizedBox(height: 12),
if (typeFilterOptions.length > 1) ...[
Text(
'Type',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final type in typeFilterOptions)
FilterChip(
label: Text(_businessTypeFilterLabel(type)),
selected: _selectedBusinessTypes.contains(
type,
),
onSelected: (selected) {
setState(() {
if (selected) {
_selectedBusinessTypes = {
..._selectedBusinessTypes,
type,
};
} else {
_selectedBusinessTypes = {
..._selectedBusinessTypes,
}..remove(type);
}
});
},
),
],
),
const SizedBox(height: 12),
],
Wrap(
spacing: 8,
runSpacing: 8,
@ -169,7 +206,7 @@ final class _ExerciseLibraryScreenState extends State<ExerciseLibraryScreen> {
child: _CenteredMessage(
title: 'Aucun exercice ne correspond',
message:
'Modifie la recherche, les mesures ou les tags sélectionnés.',
'Modifie la recherche, les types, les mesures ou les tags sélectionnés.',
actionLabel: 'Effacer les filtres',
onAction: _clearFilters,
),
@ -206,7 +243,15 @@ final class _ExerciseLibraryScreenState extends State<ExerciseLibraryScreen> {
final matchesMeasures =
_selectedMeasures.isEmpty ||
_selectedMeasures.every(exercise.availableMeasures.contains);
return matchesQuery && matchesMeasures;
final matchesTypes =
_selectedBusinessTypes.isEmpty ||
_selectedBusinessTypes.any((type) {
final effectiveTypes = exercise.effectiveBusinessTypes;
return type == null
? effectiveTypes.isEmpty
: effectiveTypes.contains(type);
});
return matchesQuery && matchesTypes && matchesMeasures;
}).toList();
return filterByRequiredTags(
matchingQueryAndMeasures,
@ -217,6 +262,7 @@ final class _ExerciseLibraryScreenState extends State<ExerciseLibraryScreen> {
bool get _filtersActive {
return _searchController.text.trim().isNotEmpty ||
_selectedBusinessTypes.isNotEmpty ||
_selectedMeasures.isNotEmpty ||
_selectedTags.isNotEmpty;
}
@ -234,6 +280,7 @@ final class _ExerciseLibraryScreenState extends State<ExerciseLibraryScreen> {
void _clearFilters() {
setState(() {
_searchController.clear();
_selectedBusinessTypes = {};
_selectedMeasures = {};
_selectedTags = {};
});
@ -314,6 +361,27 @@ final class _ExerciseLibraryScreenState extends State<ExerciseLibraryScreen> {
}
}
List<BusinessExerciseType?> _typeFilterOptionsFor(List<Exercise> exercises) {
final options = <BusinessExerciseType?>[
for (final type in BusinessExerciseType.values)
if (exercises.any((exercise) {
return exercise.effectiveBusinessTypes.contains(type);
}))
type,
];
final hasUntypedExercise = exercises.any(
(exercise) => exercise.effectiveBusinessTypes.isEmpty,
);
if (hasUntypedExercise) {
options.add(null);
}
return options;
}
String _businessTypeFilterLabel(BusinessExerciseType? type) {
return type == null ? 'Libre / autre' : type.label;
}
final class ExerciseListTile extends StatelessWidget {
const ExerciseListTile({
required this.exercise,

File diff suppressed because it is too large Load Diff

View File

@ -2332,7 +2332,7 @@ final class _LiveSensorBar extends StatelessWidget {
_LiveSensorPill(
icon: Icons.directions_run,
label: _distanceMetricLabel(sensorState),
muted: _distanceLabel(sensorState) == null,
muted: !_isDistanceAvailable(sensorState),
),
_LiveSensorPill(
icon: Icons.local_fire_department,
@ -5332,6 +5332,9 @@ String _heartRateMetricLabel(ActiveWorkoutSensorState? state) {
}
String _distanceMetricLabel(ActiveWorkoutSensorState? state) {
if (!_isDistanceAvailable(state)) {
return _sensorUnavailableLabel(state);
}
return _distanceLabel(state) ?? _sensorUnavailableLabel(state);
}
@ -5357,6 +5360,12 @@ String? _distanceLabel(ActiveWorkoutSensorState? state) {
return '${meters.round()} m';
}
bool _isDistanceAvailable(ActiveWorkoutSensorState? state) {
return state != null &&
state.latestDistanceAvailable &&
_distanceLabel(state) != null;
}
String? _caloriesLabel(ActiveWorkoutSensorState? state) {
final actualCalories = state?.latestCaloriesKcal;
if (actualCalories != null && actualCalories >= 0) {