chore(wip): consolidation intermédiaire multi-tickets (sprints Statistiques, UI, Bug resolution, Serveur-client)
Regroupe l'état de travail en cours réalisé dans un même worktree sur plusieurs tickets/sprints (#85, #136, #145, #155-160, #162-164), mélangeant des tickets QA et inProgress. Ne constitue pas une feature terminée : commit de sauvegarde avant triage/split par ticket en branches feature/* dédiées. Exclut les dossiers d'environnement de build locaux et le heap dump parasite (.gitignore mis à jour). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -1,4 +1,5 @@
|
||||
import 'package:audioplayers/audioplayers.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
abstract interface class ExerciseStepAudioCuePlayer {
|
||||
Future<void> playShortCountdownBeep();
|
||||
@ -13,9 +14,7 @@ final class AudioplayersExerciseStepAudioCuePlayer
|
||||
AudioplayersExerciseStepAudioCuePlayer();
|
||||
|
||||
final AudioPlayer _player = AudioPlayer();
|
||||
late final Future<void> _audioContextReady = _player.setAudioContext(
|
||||
_stepCueAudioContext,
|
||||
);
|
||||
late final Future<void> _audioContextReady = _configureAudioContext();
|
||||
|
||||
@override
|
||||
Future<void> playShortCountdownBeep() {
|
||||
@ -33,6 +32,14 @@ final class AudioplayersExerciseStepAudioCuePlayer
|
||||
await _player.play(AssetSource(assetPath));
|
||||
}
|
||||
|
||||
Future<void> _configureAudioContext() async {
|
||||
try {
|
||||
await _player.setAudioContext(_stepCueAudioContext);
|
||||
} on Object catch (error) {
|
||||
debugPrint('Audio cue context ignored: $error');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() {
|
||||
return _player.dispose();
|
||||
|
||||
@ -8,6 +8,7 @@ import 'package:share_plus/share_plus.dart';
|
||||
import '../application/application.dart';
|
||||
import '../domain/domain.dart';
|
||||
import 'share_inbox_screen.dart';
|
||||
import 'share_screen.dart';
|
||||
import 'theme.dart';
|
||||
|
||||
final class ProfileScreen extends StatefulWidget {
|
||||
@ -69,6 +70,7 @@ final class _ProfileScreenState extends State<ProfileScreen> {
|
||||
syncUseCases: widget.syncUseCases,
|
||||
onLogout: () => _confirmLogout(context),
|
||||
onShares: () => _openReceivedShares(context),
|
||||
onSharePack: () => _openSharePack(context),
|
||||
dataExportUseCase: widget.dataExportUseCase,
|
||||
dataImportUseCase: widget.dataImportUseCase,
|
||||
backupFileExporter: widget.backupFileExporter,
|
||||
@ -153,6 +155,18 @@ final class _ProfileScreenState extends State<ProfileScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
void _openSharePack(BuildContext context) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => SharePackSelectionScreen(
|
||||
shareUseCases: widget.shareUseCases,
|
||||
authUseCases: widget.authUseCases,
|
||||
syncUseCases: widget.syncUseCases,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _reloadSession() {
|
||||
setState(() {
|
||||
_session = widget.authUseCases.currentSession();
|
||||
@ -258,6 +272,7 @@ final class _SignedInProfile extends StatelessWidget {
|
||||
required this.syncUseCases,
|
||||
required this.onLogout,
|
||||
required this.onShares,
|
||||
required this.onSharePack,
|
||||
required this.dataExportUseCase,
|
||||
required this.dataImportUseCase,
|
||||
required this.backupFileExporter,
|
||||
@ -268,6 +283,7 @@ final class _SignedInProfile extends StatelessWidget {
|
||||
final SyncUseCases syncUseCases;
|
||||
final VoidCallback onLogout;
|
||||
final VoidCallback onShares;
|
||||
final VoidCallback onSharePack;
|
||||
final DataExportUseCase dataExportUseCase;
|
||||
final DataImportUseCase dataImportUseCase;
|
||||
final LocalBackupFileExporter backupFileExporter;
|
||||
@ -320,18 +336,34 @@ final class _SignedInProfile extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
CourtBlazerAccentPanel(
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.inbox_outlined),
|
||||
title: const Text('Partages reçus'),
|
||||
subtitle: const Text(
|
||||
"Programmes et séances reçus d'autres comptes",
|
||||
child: Column(
|
||||
children: [
|
||||
Material(
|
||||
type: MaterialType.transparency,
|
||||
child: ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.inventory_2_outlined),
|
||||
title: const Text('Partager un pack'),
|
||||
subtitle: const Text('Envoyer plusieurs séances ensemble'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: onSharePack,
|
||||
),
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: onShares,
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Material(
|
||||
type: MaterialType.transparency,
|
||||
child: ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.inbox_outlined),
|
||||
title: const Text('Partages reçus'),
|
||||
subtitle: const Text(
|
||||
"Programmes, séances et packs reçus d'autres comptes",
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: onShares,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@ -46,6 +46,9 @@ final class _ShareInboxScreenState extends State<ShareInboxScreen> {
|
||||
final item = items[index];
|
||||
return _ShareInboxTile(
|
||||
item: item,
|
||||
onOpen: item.resourceType == ShareResourceType.pack
|
||||
? () => _openPack(item)
|
||||
: null,
|
||||
onAccept: item.status == ShareInboxStatus.pending
|
||||
? () => _accept(item)
|
||||
: null,
|
||||
@ -80,6 +83,22 @@ final class _ShareInboxScreenState extends State<ShareInboxScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openPack(ShareInboxItem item) async {
|
||||
await Navigator.of(context).push<void>(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => _SharePackDetailScreen(
|
||||
item: item,
|
||||
onImport: item.status == ShareInboxStatus.pending
|
||||
? () => _accept(item)
|
||||
: null,
|
||||
onDecline: item.status == ShareInboxStatus.pending
|
||||
? () => _decline(item)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _decline(ShareInboxItem item) async {
|
||||
await widget.shareUseCases.declineShare(item.shareId);
|
||||
if (!mounted) return;
|
||||
@ -94,11 +113,13 @@ final class _ShareInboxScreenState extends State<ShareInboxScreen> {
|
||||
final class _ShareInboxTile extends StatelessWidget {
|
||||
const _ShareInboxTile({
|
||||
required this.item,
|
||||
required this.onOpen,
|
||||
required this.onAccept,
|
||||
required this.onDecline,
|
||||
});
|
||||
|
||||
final ShareInboxItem item;
|
||||
final VoidCallback? onOpen;
|
||||
final VoidCallback? onAccept;
|
||||
final VoidCallback? onDecline;
|
||||
|
||||
@ -112,11 +133,7 @@ final class _ShareInboxTile extends StatelessWidget {
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
item.resourceType == ShareResourceType.program
|
||||
? Icons.list_alt
|
||||
: Icons.event_note,
|
||||
),
|
||||
Icon(_resourceIcon(item.resourceType)),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
@ -140,31 +157,46 @@ final class _ShareInboxTile extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
Chip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
label: Text(_statusLabel(item.status)),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Chip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
label: Text(_statusLabel(item.status)),
|
||||
),
|
||||
if (item.resourceType == ShareResourceType.pack) ...[
|
||||
const SizedBox(height: 4),
|
||||
Chip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
label: Text(_packBadge(summary.itemCount)),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
if (item.status == ShareInboxStatus.pending) ...[
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: onDecline,
|
||||
child: const Text('Refuser'),
|
||||
if (item.resourceType == ShareResourceType.pack)
|
||||
FilledButton(onPressed: onOpen, child: const Text('Voir le pack'))
|
||||
else
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: onDecline,
|
||||
child: const Text('Refuser'),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: onAccept,
|
||||
child: const Text('Accepter'),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: onAccept,
|
||||
child: const Text('Accepter'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
@ -196,11 +228,91 @@ final class _EmptyInbox extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
final class _SharePackDetailScreen extends StatelessWidget {
|
||||
const _SharePackDetailScreen({
|
||||
required this.item,
|
||||
required this.onImport,
|
||||
required this.onDecline,
|
||||
});
|
||||
|
||||
final ShareInboxItem item;
|
||||
final VoidCallback? onImport;
|
||||
final VoidCallback? onDecline;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final summary = _summaryFromPayload(item);
|
||||
final workouts = _packWorkoutNames(item);
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Pack reçu')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
CourtBlazerAccentPanel(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
summary.name,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text('Envoyé par ${item.senderUserId}'),
|
||||
const SizedBox(height: 8),
|
||||
Chip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
label: Text(_packBadge(summary.itemCount)),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('Séances', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
for (final name in workouts)
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.event_note),
|
||||
title: Text(name),
|
||||
),
|
||||
if (item.status == ShareInboxStatus.pending) ...[
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: onImport == null
|
||||
? null
|
||||
: () {
|
||||
onImport!();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Importer'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton(
|
||||
onPressed: onDecline == null
|
||||
? null
|
||||
: () {
|
||||
onDecline!();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Refuser'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class _InboxSummary {
|
||||
const _InboxSummary({required this.name, required this.detail});
|
||||
const _InboxSummary({
|
||||
required this.name,
|
||||
required this.detail,
|
||||
this.itemCount = 0,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final String detail;
|
||||
final int itemCount;
|
||||
}
|
||||
|
||||
_InboxSummary _summaryFromPayload(ShareInboxItem item) {
|
||||
@ -216,6 +328,11 @@ _InboxSummary _summaryFromPayload(ShareInboxItem item) {
|
||||
name: name,
|
||||
detail: _templateDetail(payload),
|
||||
),
|
||||
ShareResourceType.pack => _InboxSummary(
|
||||
name: name,
|
||||
detail: _packDetail(payload),
|
||||
itemCount: _packItemCount(payload),
|
||||
),
|
||||
};
|
||||
} on Object {
|
||||
return const _InboxSummary(name: 'Partage GameTime', detail: '');
|
||||
@ -247,10 +364,45 @@ String _templateDetail(Map<String, dynamic> payload) {
|
||||
'$exerciseCount exercice${exerciseCount > 1 ? 's' : ''}';
|
||||
}
|
||||
|
||||
String _packDetail(Map<String, dynamic> payload) {
|
||||
final count = _packItemCount(payload);
|
||||
return '$count séance${count > 1 ? 's' : ''}';
|
||||
}
|
||||
|
||||
int _packItemCount(Map<String, dynamic> payload) {
|
||||
return (payload['workouts'] as List<dynamic>? ?? const []).length;
|
||||
}
|
||||
|
||||
List<String> _packWorkoutNames(ShareInboxItem item) {
|
||||
try {
|
||||
final payload = jsonDecode(item.payloadJson) as Map<String, dynamic>;
|
||||
final workouts = payload['workouts'] as List<dynamic>? ?? const [];
|
||||
return workouts
|
||||
.whereType<Map>()
|
||||
.map((raw) => raw['name'] as String? ?? 'Séance partagée')
|
||||
.toList(growable: false);
|
||||
} on Object {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
String _packBadge(int count) {
|
||||
return 'Pack · $count séance${count > 1 ? 's' : ''}';
|
||||
}
|
||||
|
||||
IconData _resourceIcon(ShareResourceType type) {
|
||||
return switch (type) {
|
||||
ShareResourceType.program => Icons.list_alt,
|
||||
ShareResourceType.workoutTemplate => Icons.event_note,
|
||||
ShareResourceType.pack => Icons.inventory_2_outlined,
|
||||
};
|
||||
}
|
||||
|
||||
String _resourceTitle(ShareResourceType type) {
|
||||
return switch (type) {
|
||||
ShareResourceType.program => 'Programme',
|
||||
ShareResourceType.workoutTemplate => 'Séance',
|
||||
ShareResourceType.pack => 'Pack',
|
||||
};
|
||||
}
|
||||
|
||||
@ -267,5 +419,6 @@ String _acceptedMessage(ShareResourceType type) {
|
||||
return switch (type) {
|
||||
ShareResourceType.program => 'Programme ajouté.',
|
||||
ShareResourceType.workoutTemplate => 'Séance ajoutée.',
|
||||
ShareResourceType.pack => 'Pack importé.',
|
||||
};
|
||||
}
|
||||
|
||||
@ -12,6 +12,8 @@ final class ShareFormScreen extends StatefulWidget {
|
||||
required this.localResourceId,
|
||||
required this.resourceName,
|
||||
required this.resourceSummary,
|
||||
this.localResourceIds = const [],
|
||||
this.packName,
|
||||
this.authUseCases,
|
||||
this.syncUseCases,
|
||||
super.key,
|
||||
@ -22,6 +24,8 @@ final class ShareFormScreen extends StatefulWidget {
|
||||
final SyncUseCases? syncUseCases;
|
||||
final ShareResourceType resourceType;
|
||||
final String localResourceId;
|
||||
final List<String> localResourceIds;
|
||||
final String? packName;
|
||||
final String resourceName;
|
||||
final String resourceSummary;
|
||||
|
||||
@ -106,6 +110,8 @@ final class _ShareFormScreenState extends State<ShareFormScreen> {
|
||||
final result = await widget.shareUseCases.sendShare(
|
||||
resourceType: widget.resourceType,
|
||||
localResourceId: widget.localResourceId,
|
||||
localResourceIds: widget.localResourceIds,
|
||||
packName: widget.packName,
|
||||
recipientEmails: _parseEmails(_emailsController.text),
|
||||
);
|
||||
if (!mounted) return;
|
||||
@ -154,6 +160,7 @@ final class ShareAccountRequiredScreen extends StatelessWidget {
|
||||
final targetLabel = switch (resourceType) {
|
||||
ShareResourceType.program => 'ce programme',
|
||||
ShareResourceType.workoutTemplate => 'cette séance',
|
||||
ShareResourceType.pack => 'ce pack',
|
||||
};
|
||||
final canOpenAuth = authUseCases != null && syncUseCases != null;
|
||||
return Scaffold(
|
||||
@ -254,6 +261,7 @@ String _shareTitle(ShareResourceType type) {
|
||||
return switch (type) {
|
||||
ShareResourceType.program => 'Partager le programme',
|
||||
ShareResourceType.workoutTemplate => 'Partager la séance',
|
||||
ShareResourceType.pack => 'Partager le pack',
|
||||
};
|
||||
}
|
||||
|
||||
@ -261,5 +269,156 @@ String _resourceTitle(ShareResourceType type) {
|
||||
return switch (type) {
|
||||
ShareResourceType.program => 'Programme',
|
||||
ShareResourceType.workoutTemplate => 'Séance',
|
||||
ShareResourceType.pack => 'Pack',
|
||||
};
|
||||
}
|
||||
|
||||
final class SharePackSelectionScreen extends StatefulWidget {
|
||||
const SharePackSelectionScreen({
|
||||
required this.shareUseCases,
|
||||
this.authUseCases,
|
||||
this.syncUseCases,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final ShareUseCases shareUseCases;
|
||||
final AuthUseCases? authUseCases;
|
||||
final SyncUseCases? syncUseCases;
|
||||
|
||||
@override
|
||||
State<SharePackSelectionScreen> createState() =>
|
||||
_SharePackSelectionScreenState();
|
||||
}
|
||||
|
||||
final class _SharePackSelectionScreenState
|
||||
extends State<SharePackSelectionScreen> {
|
||||
final _nameController = TextEditingController();
|
||||
late Future<List<WorkoutTemplate>> _templates;
|
||||
final _selectedTemplateIds = <String>{};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_templates = widget.shareUseCases.listShareableWorkoutTemplates();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Composer un pack')),
|
||||
body: FutureBuilder<List<WorkoutTemplate>>(
|
||||
future: _templates,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final templates = snapshot.data ?? const <WorkoutTemplate>[];
|
||||
if (templates.isEmpty) {
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Text(
|
||||
'Aucune séance à partager.',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
CourtBlazerAccentPanel(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Pack à partager',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _nameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Nom du pack',
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Séances',
|
||||
style: Theme.of(context).textTheme.labelLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
for (final template in templates)
|
||||
Material(
|
||||
type: MaterialType.transparency,
|
||||
child: CheckboxListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: _selectedTemplateIds.contains(
|
||||
template.metadata.id,
|
||||
),
|
||||
title: Text(template.name),
|
||||
subtitle: Text(_templatePackSummary(template)),
|
||||
onChanged: (selected) {
|
||||
setState(() {
|
||||
if (selected == true) {
|
||||
_selectedTemplateIds.add(template.metadata.id);
|
||||
} else {
|
||||
_selectedTemplateIds.remove(
|
||||
template.metadata.id,
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: _canContinue ? _continueToRecipients : null,
|
||||
icon: const Icon(Icons.inventory_2_outlined),
|
||||
label: const Text('Continuer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool get _canContinue =>
|
||||
_nameController.text.trim().isNotEmpty && _selectedTemplateIds.isNotEmpty;
|
||||
|
||||
Future<void> _continueToRecipients() async {
|
||||
final name = _nameController.text.trim();
|
||||
final ids = _selectedTemplateIds.toList(growable: false);
|
||||
await Navigator.of(context).push<void>(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ShareFormScreen(
|
||||
shareUseCases: widget.shareUseCases,
|
||||
authUseCases: widget.authUseCases,
|
||||
syncUseCases: widget.syncUseCases,
|
||||
resourceType: ShareResourceType.pack,
|
||||
localResourceId: '',
|
||||
localResourceIds: ids,
|
||||
packName: name,
|
||||
resourceName: name,
|
||||
resourceSummary: '${ids.length} séance${ids.length > 1 ? 's' : ''}',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _templatePackSummary(WorkoutTemplate template) {
|
||||
return '${template.programs.length} programme'
|
||||
'${template.programs.length > 1 ? 's' : ''}';
|
||||
}
|
||||
|
||||
@ -1634,7 +1634,13 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
||||
final rest = await widget.activeUseCases.findActiveRest(
|
||||
sessionId: _session.metadata.id,
|
||||
);
|
||||
if (!mounted || rest == null) {
|
||||
if (!mounted) {
|
||||
return false;
|
||||
}
|
||||
if (rest == null) {
|
||||
_activeRestStateId = null;
|
||||
_restTicker?.cancel();
|
||||
_restTicker = null;
|
||||
return false;
|
||||
}
|
||||
final after = ExecutionPosition(
|
||||
@ -2706,6 +2712,96 @@ final class _CurrentStepPane extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final hasRepsWithStopwatchScore =
|
||||
step.type == ExerciseStepType.reps &&
|
||||
step.hasScore &&
|
||||
step.scoreInputMode == ScoreInputMode.stopwatch;
|
||||
final hasTimedWithManualScore =
|
||||
step.type == ExerciseStepType.time &&
|
||||
step.hasScore &&
|
||||
step.scoreInputMode == ScoreInputMode.manual;
|
||||
if (hasRepsWithStopwatchScore || hasTimedWithManualScore) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
step.name,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, bodyConstraints) {
|
||||
return FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.center,
|
||||
child: SizedBox(
|
||||
width: bodyConstraints.maxWidth,
|
||||
height: hasRepsWithStopwatchScore ? 150 : 132,
|
||||
child: hasRepsWithStopwatchScore
|
||||
? _RepsWithStopwatchScoreStepBody(
|
||||
step: step,
|
||||
elapsedLabel: stepScoreElapsedLabel,
|
||||
running: stepScoreRunning,
|
||||
onStart: onStartStepScore,
|
||||
onStop: onStopStepScore,
|
||||
onReset: onResetStepScore,
|
||||
)
|
||||
: _TimedStepWithManualScoreBody(
|
||||
step: step,
|
||||
remainingLabel: remainingLabel,
|
||||
running:
|
||||
view.state.status ==
|
||||
ActiveExerciseStepProgressStatus
|
||||
.runningTimer,
|
||||
readyToStart: _isNextTimedStepReady(view),
|
||||
scoreController: stepScoreController,
|
||||
onStartTimer: onStartTimer,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (hasRepsWithStopwatchScore)
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: onSkipStep,
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(44),
|
||||
),
|
||||
child: const Text('Passer l’étape'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: onCompleteStep,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(44),
|
||||
),
|
||||
icon: const Icon(Icons.check),
|
||||
label: const Text('Étape suivante'),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
OutlinedButton(
|
||||
onPressed: onSkipStep,
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(44),
|
||||
),
|
||||
child: const Text('Passer l’étape'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: ConstrainedBox(
|
||||
@ -2860,6 +2956,274 @@ final class _TimedStepBody extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
final class _RepsWithStopwatchScoreStepBody extends StatelessWidget {
|
||||
const _RepsWithStopwatchScoreStepBody({
|
||||
required this.step,
|
||||
required this.elapsedLabel,
|
||||
required this.running,
|
||||
required this.onStart,
|
||||
required this.onStop,
|
||||
required this.onReset,
|
||||
});
|
||||
|
||||
final ExerciseStep step;
|
||||
final String elapsedLabel;
|
||||
final bool running;
|
||||
final VoidCallback onStart;
|
||||
final VoidCallback onStop;
|
||||
final VoidCallback onReset;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('RÉPÉTITIONS', style: theme.textTheme.labelLarge),
|
||||
const SizedBox(height: 4),
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
'${step.defaultTargetValue}',
|
||||
style: AppTextStyles.timer(
|
||||
context,
|
||||
).copyWith(color: theme.colorScheme.primary),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('CHRONO SCORE', style: theme.textTheme.labelLarge),
|
||||
const SizedBox(height: 6),
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
elapsedLabel,
|
||||
style: AppTextStyles.scoreNumber(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_CompactStepScoreStopwatchControls(
|
||||
running: running,
|
||||
onStart: onStart,
|
||||
onStop: onStop,
|
||||
onReset: onReset,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class _TimedStepWithManualScoreBody extends StatelessWidget {
|
||||
const _TimedStepWithManualScoreBody({
|
||||
required this.step,
|
||||
required this.remainingLabel,
|
||||
required this.running,
|
||||
required this.readyToStart,
|
||||
required this.scoreController,
|
||||
required this.onStartTimer,
|
||||
});
|
||||
|
||||
final ExerciseStep step;
|
||||
final String remainingLabel;
|
||||
final bool running;
|
||||
final bool readyToStart;
|
||||
final TextEditingController scoreController;
|
||||
final VoidCallback onStartTimer;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('CHRONO', style: theme.textTheme.labelLarge),
|
||||
const SizedBox(height: 4),
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
remainingLabel,
|
||||
style: theme.textTheme.displayMedium?.copyWith(
|
||||
fontFamily: 'Anton',
|
||||
fontFeatures: AppTextStyles.timer(context).fontFeatures,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!running) ...[
|
||||
const SizedBox(height: 8),
|
||||
if (readyToStart) ...[
|
||||
Text(
|
||||
'Chrono prêt',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
IconButton.filled(
|
||||
tooltip: 'Lancer',
|
||||
onPressed: onStartTimer,
|
||||
icon: const Icon(Icons.play_arrow),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Center(
|
||||
child: _StepManualScoreStepper(
|
||||
controller: scoreController,
|
||||
initialValue: step.defaultTargetScore,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class _CompactStepScoreStopwatchControls extends StatelessWidget {
|
||||
const _CompactStepScoreStopwatchControls({
|
||||
required this.running,
|
||||
required this.onStart,
|
||||
required this.onStop,
|
||||
required this.onReset,
|
||||
});
|
||||
|
||||
final bool running;
|
||||
final VoidCallback onStart;
|
||||
final VoidCallback onStop;
|
||||
final VoidCallback onReset;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton.filled(
|
||||
tooltip: running ? 'Arrêter' : 'Lancer',
|
||||
onPressed: running ? onStop : onStart,
|
||||
icon: Icon(running ? Icons.stop : Icons.play_arrow),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton.outlined(
|
||||
tooltip: 'Réinitialiser',
|
||||
onPressed: onReset,
|
||||
icon: const Icon(Icons.restart_alt),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class _StepManualScoreStepper extends StatefulWidget {
|
||||
const _StepManualScoreStepper({
|
||||
required this.controller,
|
||||
required this.initialValue,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final double? initialValue;
|
||||
|
||||
@override
|
||||
State<_StepManualScoreStepper> createState() =>
|
||||
_StepManualScoreStepperState();
|
||||
}
|
||||
|
||||
final class _StepManualScoreStepperState
|
||||
extends State<_StepManualScoreStepper> {
|
||||
double get _value {
|
||||
final parsed = double.tryParse(widget.controller.text.trim());
|
||||
return parsed ?? widget.initialValue ?? 0;
|
||||
}
|
||||
|
||||
void _changeBy(double delta) {
|
||||
final next = (_value + delta).clamp(0, 999).toDouble();
|
||||
widget.controller.text = _formatManualScoreInput(next);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('SCORE', style: theme.textTheme.labelLarge),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: 'Diminuer',
|
||||
onPressed: () => _changeBy(-1),
|
||||
constraints: const BoxConstraints.tightFor(width: 34, height: 34),
|
||||
padding: EdgeInsets.zero,
|
||||
iconSize: 20,
|
||||
visualDensity: VisualDensity.compact,
|
||||
style: IconButton.styleFrom(
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
icon: const Icon(Icons.remove),
|
||||
),
|
||||
SizedBox(
|
||||
width: 46,
|
||||
child: Text(
|
||||
_formatManualScoreInput(_value),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: AppTextStyles.scoreNumber(context),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Augmenter',
|
||||
onPressed: () => _changeBy(1),
|
||||
constraints: const BoxConstraints.tightFor(width: 34, height: 34),
|
||||
padding: EdgeInsets.zero,
|
||||
iconSize: 20,
|
||||
visualDensity: VisualDensity.compact,
|
||||
style: IconButton.styleFrom(
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
icon: const Icon(Icons.add),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _formatManualScoreInput(double value) {
|
||||
return value == value.roundToDouble()
|
||||
? value.toInt().toString()
|
||||
: value.toStringAsFixed(1);
|
||||
}
|
||||
|
||||
bool _isNextTimedStepReady(ActiveExerciseStepProgressView view) {
|
||||
final step = view.currentStep;
|
||||
if (step == null ||
|
||||
|
||||
Reference in New Issue
Block a user