Files
GameTime/lib/presentation/exercise_library_screen.dart
Blomios 88af0387da feat(exercice): valeurs par défaut d'exercice (tickets #39/#40, parent #30)
Étend le modèle Drift (tables.dart, app_database.dart/.g.dart), les
entités/use cases/repositories et les écrans bibliothèque d'exercices
et programme pour supporter des valeurs par défaut par exercice
(répétitions/temps/poids proposés à la création d'une série).
flutter analyze propre, 51/51 tests verts, build APK debug validé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 10:18:31 +02:00

977 lines
31 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import '../application/application.dart';
import '../domain/domain.dart';
abstract interface class MediaSourcePicker {
Future<String?> pickPath(MediaKind kind);
}
final class ImagePickerMediaSourcePicker implements MediaSourcePicker {
const ImagePickerMediaSourcePicker();
@override
Future<String?> pickPath(MediaKind kind) async {
final picker = ImagePicker();
final file = switch (kind) {
MediaKind.image => await picker.pickImage(source: ImageSource.gallery),
MediaKind.video => await picker.pickVideo(source: ImageSource.gallery),
};
return file?.path;
}
}
final class ExerciseLibraryScreen extends StatefulWidget {
const ExerciseLibraryScreen({
required this.exerciseUseCases,
required this.mediaUseCases,
super.key,
});
final ExerciseUseCases exerciseUseCases;
final MediaUseCases mediaUseCases;
@override
State<ExerciseLibraryScreen> createState() => _ExerciseLibraryScreenState();
}
final class _ExerciseLibraryScreenState extends State<ExerciseLibraryScreen> {
final _searchController = TextEditingController();
var _selectedMeasures = <WorkoutMeasure>{};
late Future<List<Exercise>> _exercises;
@override
void initState() {
super.initState();
_exercises = widget.exerciseUseCases.listActive();
_searchController.addListener(() => setState(() {}));
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Exercices')),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _openForm(),
icon: const Icon(Icons.add),
label: const Text('Créer'),
),
body: FutureBuilder<List<Exercise>>(
future: _exercises,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return _CenteredMessage(
title: 'Impossible de charger les exercices',
actionLabel: 'Réessayer',
onAction: _reload,
);
}
final exercises = snapshot.data ?? const <Exercise>[];
final filtered = _filter(exercises);
return RefreshIndicator(
onRefresh: () {
_reload();
return Future<void>.value();
},
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _searchController,
decoration: const InputDecoration(
labelText: 'Rechercher',
prefixIcon: Icon(Icons.search),
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: WorkoutMeasure.values.map((measure) {
return FilterChip(
label: Text(measure.label),
selected: _selectedMeasures.contains(measure),
onSelected: (selected) {
setState(() {
if (selected) {
_selectedMeasures = {
..._selectedMeasures,
measure,
};
} else {
_selectedMeasures = {..._selectedMeasures}
..remove(measure);
}
});
},
);
}).toList(),
),
],
),
),
),
if (exercises.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: _CenteredMessage(
title: 'Aucun exercice pour le moment',
message:
'Crée ton premier exercice pour composer tes programmes.',
actionLabel: 'Créer un exercice',
onAction: () => _openForm(),
),
)
else if (filtered.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
child: _CenteredMessage(
title: 'Aucun exercice ne correspond',
message:
'Modifie la recherche ou les filtres de mesures.',
),
)
else
SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
if (index.isOdd) {
return const Divider(height: 1, indent: 72);
}
final exercise = filtered[index ~/ 2];
return ExerciseListTile(
exercise: exercise,
onTap: () => _openForm(exercise: exercise),
);
}, childCount: filtered.length * 2 - 1),
),
],
),
);
},
),
);
}
List<Exercise> _filter(List<Exercise> exercises) {
final query = _searchController.text.trim().toLowerCase();
return exercises.where((exercise) {
final matchesQuery =
query.isEmpty ||
exercise.name.toLowerCase().contains(query) ||
(exercise.description?.toLowerCase().contains(query) ?? false);
final matchesMeasures =
_selectedMeasures.isEmpty ||
_selectedMeasures.every(exercise.availableMeasures.contains);
return matchesQuery && matchesMeasures;
}).toList();
}
void _reload() {
setState(() {
_exercises = widget.exerciseUseCases.listActive();
});
}
Future<void> _openForm({Exercise? exercise}) async {
final changed = await Navigator.of(context).push<bool>(
MaterialPageRoute(
builder: (context) => ExerciseFormScreen(
exerciseUseCases: widget.exerciseUseCases,
mediaUseCases: widget.mediaUseCases,
exercise: exercise,
),
),
);
if (changed == true) {
if (!mounted) return;
_reload();
}
}
}
final class ExerciseListTile extends StatelessWidget {
const ExerciseListTile({required this.exercise, this.onTap, super.key});
final Exercise exercise;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final description = exercise.description?.trim();
final hasMedia =
exercise.imageMediaId != null || exercise.videoMediaId != null;
return ListTile(
onTap: onTap,
leading: CircleAvatar(
child: Icon(
hasMedia ? Icons.perm_media_outlined : Icons.fitness_center,
),
),
title: Text(exercise.name),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (description != null && description.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 2, bottom: 6),
child: Text(
description,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
)
else
const SizedBox(height: 6),
Wrap(
spacing: 6,
runSpacing: 6,
children: [
...exercise.availableMeasures.map(
(measure) => MeasureBadge(
measure: measure,
scoreUnit: exercise.scoreUnit,
scoreInputMode: exercise.scoreInputMode,
),
),
if (hasMedia)
const Tooltip(
message: 'Média associé',
child: Icon(Icons.image_outlined, size: 18),
),
],
),
],
),
trailing: const Icon(Icons.chevron_right),
);
}
}
final class ExerciseFormScreen extends StatefulWidget {
const ExerciseFormScreen({
required this.exerciseUseCases,
required this.mediaUseCases,
this.mediaPicker = const ImagePickerMediaSourcePicker(),
this.exercise,
super.key,
});
final ExerciseUseCases exerciseUseCases;
final MediaUseCases mediaUseCases;
final MediaSourcePicker mediaPicker;
final Exercise? exercise;
@override
State<ExerciseFormScreen> createState() => _ExerciseFormScreenState();
}
final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _nameController;
late final TextEditingController _descriptionController;
late final TextEditingController _scoreLabelController;
late final TextEditingController _scoreUnitController;
late final TextEditingController _defaultTimeController;
late final TextEditingController _defaultRepsController;
late final TextEditingController _defaultScoreController;
late final TextEditingController _defaultScoreTimeController;
String? _imageMediaId;
String? _videoMediaId;
String? _selectedImageName;
String? _selectedVideoName;
var _hasTime = true;
var _hasReps = false;
var _hasScore = false;
var _scoreInputMode = ScoreInputMode.manual;
var _saving = false;
var _importingImage = false;
var _importingVideo = false;
String? _measureError;
String? _warning;
bool get _isEditing => widget.exercise != null;
@override
void initState() {
super.initState();
final exercise = widget.exercise;
_nameController = TextEditingController(text: exercise?.name);
_descriptionController = TextEditingController(text: exercise?.description);
_scoreLabelController = TextEditingController(text: exercise?.scoreLabel);
_scoreUnitController = TextEditingController(text: exercise?.scoreUnit);
_defaultTimeController = TextEditingController(
text: _optionalIntText(exercise?.defaultTargetTimeSeconds),
);
_defaultRepsController = TextEditingController(
text: _optionalIntText(exercise?.defaultTargetReps),
);
_defaultScoreController = TextEditingController(
text: _optionalDoubleText(exercise?.defaultTargetScore),
);
_defaultScoreTimeController = TextEditingController(
text: _optionalDoubleText(
_millisecondsToSeconds(exercise?.defaultTargetScoreTimeMs),
),
);
_imageMediaId = exercise?.imageMediaId;
_videoMediaId = exercise?.videoMediaId;
_hasTime = exercise?.hasTimeMeasure ?? true;
_hasReps = exercise?.hasRepsMeasure ?? false;
_hasScore = exercise?.hasScoreMeasure ?? false;
_scoreInputMode = exercise?.scoreInputMode ?? ScoreInputMode.manual;
}
@override
void dispose() {
_nameController.dispose();
_descriptionController.dispose();
_scoreLabelController.dispose();
_scoreUnitController.dispose();
_defaultTimeController.dispose();
_defaultRepsController.dispose();
_defaultScoreController.dispose();
_defaultScoreTimeController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(_isEditing ? 'Modifier lexercice' : 'Créer un exercice'),
),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
TextFormField(
controller: _nameController,
decoration: const InputDecoration(labelText: 'Nom'),
textInputAction: TextInputAction.next,
validator: (value) => value == null || value.trim().isEmpty
? 'Le nom est obligatoire.'
: null,
),
const SizedBox(height: 12),
TextFormField(
controller: _descriptionController,
decoration: const InputDecoration(labelText: 'Description'),
minLines: 2,
maxLines: 4,
),
const SizedBox(height: 24),
Text(
'Média optionnel',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
_MediaImportField(
label: 'Image',
selectedFileName: _selectedImageName,
imported: _imageMediaId != null,
importing: _importingImage,
actionLabel: 'Choisir une image',
onPick: () => _importMedia(MediaKind.image),
),
const SizedBox(height: 8),
_MediaImportField(
label: 'Vidéo',
selectedFileName: _selectedVideoName,
imported: _videoMediaId != null,
importing: _importingVideo,
actionLabel: 'Choisir une vidéo',
onPick: () => _importMedia(MediaKind.video),
),
const SizedBox(height: 24),
Text(
'Mesures disponibles',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
_MeasureSwitch(
title: 'Temps',
subtitle: 'Chronométrer la durée dune série.',
value: _hasTime,
onChanged: (value) => _setMeasure(() => _hasTime = value),
),
if (_hasTime) ...[
const SizedBox(height: 8),
TextFormField(
controller: _defaultTimeController,
decoration: const InputDecoration(
labelText: 'Temps par défaut (s)',
),
keyboardType: TextInputType.number,
validator: (value) => _hasTime
? _positiveIntValidator(
value,
'Saisis un temps supérieur à 0.',
)
: null,
),
],
_MeasureSwitch(
title: 'Répétitions',
subtitle: 'Compter le nombre de mouvements réalisés.',
value: _hasReps,
onChanged: (value) => _setMeasure(() => _hasReps = value),
),
if (_hasReps) ...[
const SizedBox(height: 8),
TextFormField(
controller: _defaultRepsController,
decoration: const InputDecoration(
labelText: 'Répétitions par défaut',
),
keyboardType: TextInputType.number,
validator: (value) => _hasReps
? _positiveIntValidator(
value,
'Saisis un nombre de répétitions supérieur à 0.',
)
: null,
),
],
_MeasureSwitch(
title: 'Score',
subtitle: 'Saisir une valeur libre avec son unité.',
value: _hasScore,
onChanged: (value) => _setMeasure(() => _hasScore = value),
),
if (_measureError != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
_measureError!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
if (_hasScore) ...[
const SizedBox(height: 12),
Text(
'Mode de saisie',
style: Theme.of(context).textTheme.titleSmall,
),
RadioListTile<ScoreInputMode>(
contentPadding: EdgeInsets.zero,
title: const Text('Saisie libre'),
value: ScoreInputMode.manual,
groupValue: _scoreInputMode,
onChanged: (value) {
if (value == null) return;
setState(() => _scoreInputMode = value);
},
),
RadioListTile<ScoreInputMode>(
contentPadding: EdgeInsets.zero,
title: const Text('Chrono intégré'),
subtitle: const Text('Temps réalisé'),
value: ScoreInputMode.stopwatch,
groupValue: _scoreInputMode,
onChanged: (value) {
if (value == null) return;
setState(() => _scoreInputMode = value);
},
),
if (_scoreInputMode == ScoreInputMode.manual) ...[
const SizedBox(height: 12),
TextFormField(
controller: _scoreLabelController,
decoration: const InputDecoration(
labelText: 'Score à saisir',
),
validator: (value) {
if (!_hasScore ||
_scoreInputMode != ScoreInputMode.manual) {
return null;
}
return value == null || value.trim().isEmpty
? 'Le libellé du score est obligatoire.'
: null;
},
),
const SizedBox(height: 12),
TextFormField(
controller: _scoreUnitController,
decoration: const InputDecoration(labelText: 'Unité'),
validator: (value) {
if (!_hasScore ||
_scoreInputMode != ScoreInputMode.manual) {
return null;
}
return value == null || value.trim().isEmpty
? 'Lunité du score est obligatoire.'
: null;
},
),
const SizedBox(height: 12),
TextFormField(
controller: _defaultScoreController,
decoration: const InputDecoration(
labelText: 'Score par défaut',
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
validator: (value) {
if (!_hasScore ||
_scoreInputMode != ScoreInputMode.manual) {
return null;
}
return _positiveDoubleValidator(
value,
'Saisis un score supérieur à 0.',
);
},
),
] else ...[
const SizedBox(height: 12),
TextFormField(
controller: _defaultScoreTimeController,
decoration: const InputDecoration(
labelText: 'Objectif de chrono par défaut (optionnel)',
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
validator: (value) {
if (!_hasScore ||
_scoreInputMode != ScoreInputMode.stopwatch ||
value == null ||
value.trim().isEmpty) {
return null;
}
return _positiveDoubleValidator(
value,
'Saisis un objectif supérieur à 0.',
);
},
),
],
],
if (_warning != null) ...[
const SizedBox(height: 16),
MaterialBanner(
content: Text(_warning!),
leading: const Icon(Icons.info_outline),
actions: [
TextButton(
onPressed: () => setState(() => _warning = null),
child: const Text('OK'),
),
],
),
],
const SizedBox(height: 24),
FilledButton.icon(
onPressed: _saving ? null : _save,
icon: _saving
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.check),
label: const Text('Enregistrer'),
),
],
),
),
);
}
void _setMeasure(VoidCallback update) {
setState(() {
update();
_measureError = null;
_warning = null;
});
}
Future<void> _importMedia(MediaKind kind) async {
final sourcePath = await widget.mediaPicker.pickPath(kind);
if (sourcePath == null) {
return;
}
final fileName = _fileNameFromPath(sourcePath);
setState(() {
if (kind == MediaKind.image) {
_importingImage = true;
_selectedImageName = fileName;
} else {
_importingVideo = true;
_selectedVideoName = fileName;
}
});
try {
final asset = await widget.mediaUseCases.importMedia(
sourcePath: sourcePath,
kind: kind,
);
if (!mounted) return;
setState(() {
if (kind == MediaKind.image) {
_imageMediaId = asset.metadata.id;
} else {
_videoMediaId = asset.metadata.id;
}
});
_showSnackBar(
kind == MediaKind.image ? 'Image importée.' : 'Vidéo importée.',
);
} on Exception catch (error) {
_showSnackBar(error.toString());
} finally {
if (mounted) {
setState(() {
_importingImage = false;
_importingVideo = false;
});
}
}
}
Future<void> _save() async {
final hasMeasure = _hasTime || _hasReps || _hasScore;
setState(() {
_measureError = hasMeasure
? null
: 'Active au moins une mesure pour enregistrer.';
});
if (!_formKey.currentState!.validate() || !hasMeasure) {
return;
}
final exercise = widget.exercise;
if (exercise != null &&
_measuresChanged(exercise) &&
_warning == null &&
await widget.exerciseUseCases.isUsedInPrograms(exercise.metadata.id)) {
if (!mounted) return;
setState(() {
_warning =
'Cet exercice est déjà utilisé dans un programme. '
'Les programmes existants restent inchangés.';
});
return;
}
setState(() => _saving = true);
final scoreInputMode = _hasScore ? _scoreInputMode : ScoreInputMode.manual;
final scoreLabel = _hasScore
? switch (scoreInputMode) {
ScoreInputMode.manual => _scoreLabelController.text.trim(),
ScoreInputMode.stopwatch => 'Temps réalisé',
}
: null;
final scoreUnit = _hasScore && scoreInputMode == ScoreInputMode.manual
? _scoreUnitController.text.trim()
: null;
final defaultTargetTimeSeconds = _hasTime
? int.parse(_defaultTimeController.text.trim())
: null;
final defaultTargetReps = _hasReps
? int.parse(_defaultRepsController.text.trim())
: null;
final defaultTargetScore =
_hasScore && scoreInputMode == ScoreInputMode.manual
? double.parse(_defaultScoreController.text.trim())
: null;
final defaultTargetScoreTimeMs =
_hasScore && scoreInputMode == ScoreInputMode.stopwatch
? _optionalSecondsToMilliseconds(_defaultScoreTimeController.text)
: null;
try {
if (exercise == null) {
await widget.exerciseUseCases.create(
name: _nameController.text.trim(),
description: _optionalText(_descriptionController),
imageMediaId: _imageMediaId,
videoMediaId: _videoMediaId,
hasTimeMeasure: _hasTime,
hasRepsMeasure: _hasReps,
hasScoreMeasure: _hasScore,
scoreInputMode: scoreInputMode,
scoreLabel: scoreLabel,
scoreUnit: scoreUnit,
defaultTargetTimeSeconds: defaultTargetTimeSeconds,
defaultTargetReps: defaultTargetReps,
defaultTargetScore: defaultTargetScore,
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
);
} else {
await widget.exerciseUseCases.update(
id: exercise.metadata.id,
name: _nameController.text.trim(),
description: _optionalText(_descriptionController),
imageMediaId: _imageMediaId,
videoMediaId: _videoMediaId,
hasTimeMeasure: _hasTime,
hasRepsMeasure: _hasReps,
hasScoreMeasure: _hasScore,
scoreInputMode: scoreInputMode,
scoreLabel: scoreLabel,
scoreUnit: scoreUnit,
defaultTargetTimeSeconds: defaultTargetTimeSeconds,
defaultTargetReps: defaultTargetReps,
defaultTargetScore: defaultTargetScore,
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
);
}
if (mounted) {
final navigator = Navigator.of(context);
if (navigator.canPop()) {
navigator.pop(true);
}
}
} on Exception catch (error) {
_showSnackBar(error.toString());
} finally {
if (mounted) {
setState(() => _saving = false);
}
}
}
bool _measuresChanged(Exercise exercise) {
return exercise.hasTimeMeasure != _hasTime ||
exercise.hasRepsMeasure != _hasReps ||
exercise.hasScoreMeasure != _hasScore ||
exercise.scoreInputMode != _scoreInputMode;
}
String? _optionalText(TextEditingController controller) {
final text = controller.text.trim();
return text.isEmpty ? null : text;
}
String? _positiveIntValidator(String? value, String message) {
final number = int.tryParse(value?.trim() ?? '');
if (number == null || number <= 0) {
return message;
}
return null;
}
String? _positiveDoubleValidator(String? value, String message) {
final number = double.tryParse(value?.trim() ?? '');
if (number == null || number <= 0) {
return message;
}
return null;
}
String _optionalIntText(int? value) {
return value == null ? '' : '$value';
}
String _optionalDoubleText(double? value) {
if (value == null) {
return '';
}
if (value == value.roundToDouble()) {
return '${value.round()}';
}
return '$value';
}
double? _millisecondsToSeconds(int? milliseconds) {
return milliseconds == null ? null : milliseconds / 1000;
}
int? _optionalSecondsToMilliseconds(String value) {
final text = value.trim();
if (text.isEmpty) {
return null;
}
return (double.parse(text) * 1000).round();
}
String _fileNameFromPath(String path) {
final parts = path.split(RegExp(r'[/\\]'));
return parts.isEmpty ? path : parts.last;
}
void _showSnackBar(String message) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
}
final class MeasureBadge extends StatelessWidget {
const MeasureBadge({
required this.measure,
this.scoreInputMode = ScoreInputMode.manual,
this.scoreUnit,
super.key,
});
final WorkoutMeasure measure;
final ScoreInputMode scoreInputMode;
final String? scoreUnit;
@override
Widget build(BuildContext context) {
final unit = scoreUnit?.trim();
var label = measure.label;
if (measure == WorkoutMeasure.score &&
scoreInputMode == ScoreInputMode.stopwatch) {
label = 'Score chrono';
} else if (measure == WorkoutMeasure.score &&
unit != null &&
unit.isNotEmpty) {
label = '${measure.label} ($unit)';
}
return Semantics(
label: label,
excludeSemantics: true,
child: Chip(visualDensity: VisualDensity.compact, label: Text(label)),
);
}
}
final class _MeasureSwitch extends StatelessWidget {
const _MeasureSwitch({
required this.title,
required this.subtitle,
required this.value,
required this.onChanged,
});
final String title;
final String subtitle;
final bool value;
final ValueChanged<bool> onChanged;
@override
Widget build(BuildContext context) {
return SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(title),
subtitle: Text(subtitle),
value: value,
onChanged: onChanged,
);
}
}
final class _MediaImportField extends StatelessWidget {
const _MediaImportField({
required this.label,
required this.selectedFileName,
required this.imported,
required this.importing,
required this.actionLabel,
required this.onPick,
});
final String label;
final String? selectedFileName;
final bool imported;
final bool importing;
final String actionLabel;
final VoidCallback onPick;
@override
Widget build(BuildContext context) {
final fileName = selectedFileName;
final status = fileName == null
? imported
? 'Média déjà importé'
: 'Aucun fichier sélectionné'
: fileName;
return InputDecorator(
decoration: InputDecoration(labelText: label),
child: Row(
children: [
Icon(imported ? Icons.check_circle_outline : Icons.perm_media),
const SizedBox(width: 12),
Expanded(
child: Text(status, maxLines: 1, overflow: TextOverflow.ellipsis),
),
const SizedBox(width: 12),
OutlinedButton.icon(
onPressed: importing ? null : onPick,
icon: importing
? const SizedBox.square(
dimension: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.upload_file),
label: Text(actionLabel),
),
],
),
);
}
}
final class _CenteredMessage extends StatelessWidget {
const _CenteredMessage({
required this.title,
this.message,
this.actionLabel,
this.onAction,
});
final String title;
final String? message;
final String? actionLabel;
final VoidCallback? onAction;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium,
),
if (message != null) ...[
const SizedBox(height: 8),
Text(message!, textAlign: TextAlign.center),
],
if (actionLabel != null && onAction != null) ...[
const SizedBox(height: 16),
FilledButton(onPressed: onAction, child: Text(actionLabel!)),
],
],
),
),
);
}
}
extension on WorkoutMeasure {
String get label {
return switch (this) {
WorkoutMeasure.time => 'Temps',
WorkoutMeasure.reps => 'Répétitions',
WorkoutMeasure.score => 'Score',
};
}
}