feat(ui): écran Bibliothèque d'exercices
Ajoute l'écran de bibliothèque d'exercices (presentation/exercise_library_screen.dart) et ajuste la couche application/domaine (bootstrap, ports, use cases, entités, repositories Drift) pour l'alimenter. flutter analyze propre, 9/9 tests verts, build APK debug validé. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
732
lib/presentation/exercise_library_screen.dart
Normal file
732
lib/presentation/exercise_library_screen.dart
Normal file
@ -0,0 +1,732 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../application/application.dart';
|
||||
import '../domain/domain.dart';
|
||||
|
||||
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),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
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.exercise,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final ExerciseUseCases exerciseUseCases;
|
||||
final MediaUseCases mediaUseCases;
|
||||
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;
|
||||
final _imagePathController = TextEditingController();
|
||||
final _videoPathController = TextEditingController();
|
||||
String? _imageMediaId;
|
||||
String? _videoMediaId;
|
||||
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();
|
||||
_imagePathController.dispose();
|
||||
_videoPathController.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',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
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',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
minLines: 2,
|
||||
maxLines: 4,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Média optionnel',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_MediaImportField(
|
||||
controller: _imagePathController,
|
||||
label: 'Chemin de l’image',
|
||||
imported: _imageMediaId != null,
|
||||
importing: _importingImage,
|
||||
onImport: () => _importMedia(MediaKind.image),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_MediaImportField(
|
||||
controller: _videoPathController,
|
||||
label: 'Chemin de la vidéo',
|
||||
imported: _videoMediaId != null,
|
||||
importing: _importingVideo,
|
||||
onImport: () => _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',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
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é',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
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 controller = kind == MediaKind.image
|
||||
? _imagePathController
|
||||
: _videoPathController;
|
||||
final sourcePath = controller.text.trim();
|
||||
if (sourcePath.isEmpty) {
|
||||
_showSnackBar('Indique un chemin de fichier à importer.');
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
if (kind == MediaKind.image) {
|
||||
_importingImage = true;
|
||||
} else {
|
||||
_importingVideo = true;
|
||||
}
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
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.controller,
|
||||
required this.label,
|
||||
required this.imported,
|
||||
required this.importing,
|
||||
required this.onImport,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final String label;
|
||||
final bool imported;
|
||||
final bool importing;
|
||||
final VoidCallback onImport;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (imported)
|
||||
const Tooltip(
|
||||
message: 'Média importé',
|
||||
child: Icon(Icons.check_circle_outline),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Importer',
|
||||
onPressed: importing ? null : onImport,
|
||||
icon: importing
|
||||
? const SizedBox.square(
|
||||
dimension: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.upload_file),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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',
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user