Ajoute le thème centralisé (presentation/theme.dart) basé sur les polices Anton/Archivo et l'applique à tous les écrans (accueil, bibliothèque d'exercices, programme, séance-modèle, exécution de séance, historique). flutter analyze propre, 27/27 tests verts, build APK debug validé. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
749 lines
23 KiB
Dart
749 lines
23 KiB
Dart
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,
|
||
),
|
||
),
|
||
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;
|
||
String? _imageMediaId;
|
||
String? _videoMediaId;
|
||
String? _selectedImageName;
|
||
String? _selectedVideoName;
|
||
var _hasTime = true;
|
||
var _hasReps = false;
|
||
var _hasScore = false;
|
||
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);
|
||
_imageMediaId = exercise?.imageMediaId;
|
||
_videoMediaId = exercise?.videoMediaId;
|
||
_hasTime = exercise?.hasTimeMeasure ?? true;
|
||
_hasReps = exercise?.hasRepsMeasure ?? false;
|
||
_hasScore = exercise?.hasScoreMeasure ?? false;
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_nameController.dispose();
|
||
_descriptionController.dispose();
|
||
_scoreLabelController.dispose();
|
||
_scoreUnitController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Scaffold(
|
||
appBar: AppBar(
|
||
title: Text(_isEditing ? 'Modifier l’exercice' : '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 d’une série.',
|
||
value: _hasTime,
|
||
onChanged: (value) => _setMeasure(() => _hasTime = value),
|
||
),
|
||
_MeasureSwitch(
|
||
title: 'Répétitions',
|
||
subtitle: 'Compter le nombre de mouvements réalisés.',
|
||
value: _hasReps,
|
||
onChanged: (value) => _setMeasure(() => _hasReps = value),
|
||
),
|
||
_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),
|
||
TextFormField(
|
||
controller: _scoreLabelController,
|
||
decoration: const InputDecoration(labelText: 'Score à saisir'),
|
||
validator: (value) {
|
||
if (!_hasScore) 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) return null;
|
||
return value == null || value.trim().isEmpty
|
||
? 'L’unité du score est obligatoire.'
|
||
: null;
|
||
},
|
||
),
|
||
],
|
||
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);
|
||
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,
|
||
scoreLabel: _hasScore ? _scoreLabelController.text.trim() : null,
|
||
scoreUnit: _hasScore ? _scoreUnitController.text.trim() : null,
|
||
);
|
||
} 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,
|
||
scoreLabel: _hasScore ? _scoreLabelController.text.trim() : null,
|
||
scoreUnit: _hasScore ? _scoreUnitController.text.trim() : null,
|
||
);
|
||
}
|
||
if (mounted) {
|
||
Navigator.of(context).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;
|
||
}
|
||
|
||
String? _optionalText(TextEditingController controller) {
|
||
final text = controller.text.trim();
|
||
return text.isEmpty ? null : text;
|
||
}
|
||
|
||
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.scoreUnit, super.key});
|
||
|
||
final WorkoutMeasure measure;
|
||
final String? scoreUnit;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final unit = scoreUnit?.trim();
|
||
final label =
|
||
measure == WorkoutMeasure.score && unit != null && unit.isNotEmpty
|
||
? '${measure.label} ($unit)'
|
||
: measure.label;
|
||
return Semantics(
|
||
label: label,
|
||
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',
|
||
};
|
||
}
|
||
}
|