glp-companion/lib/features/exercise/presentation/screens/exercise_screen.dart

261 lines
8.4 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/utils/date_time_utils.dart';
import '../../domain/entities/exercise_entry.dart';
import '../controllers/exercise_controller.dart';
import '../widgets/log_exercise_dialog.dart';
/// Resistance-training log for lean mass preservation during GLP-1 therapy.
///
/// The screen pairs a weekly strength-workout frequency card (sessions
/// completed vs. the recommended three per week) with the full working-set
/// log, grouped by day, plus an interactive "log exercise" dialog.
class ExerciseScreen extends ConsumerWidget {
const ExerciseScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(exerciseControllerProvider);
ref.listen(exerciseControllerProvider, (previous, next) {
if (next.errorMessage != null &&
next.errorMessage != previous?.errorMessage) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(next.errorMessage!),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
ref.read(exerciseControllerProvider.notifier).clearError();
}
});
return Scaffold(
appBar: AppBar(title: const Text('Exercise Log')),
body: ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 88),
children: [
_WeeklyFrequencyCard(
sessionsThisWeek: state.weeklyWorkoutCount,
targetSessions: ExerciseController.recommendedWeeklySessions,
),
const SizedBox(height: 16),
Text(
'Working Sets',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
if (state.entries.isEmpty)
const _EmptyLogHint()
else
..._buildDayGroups(context, state),
],
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _showLogDialog(context),
icon: const Icon(Icons.add),
label: const Text('Log Exercise'),
),
);
}
/// Groups the log by calendar day (newest day first, newest set first).
List<Widget> _buildDayGroups(BuildContext context, ExerciseState state) {
final byDay = <DateTime, List<ExerciseEntry>>{};
for (final entry in state.entries) {
byDay.putIfAbsent(startOfDay(entry.timestamp), () => []).add(entry);
}
final days = byDay.keys.toList()..sort((a, b) => b.compareTo(a));
return [
for (final day in days) ...[
Padding(
padding: const EdgeInsets.only(top: 8, bottom: 4),
child: Text(
formatMonthDay(day),
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(color: Theme.of(context).colorScheme.primary),
),
),
for (final entry in byDay[day]!) _ExerciseTile(entry: entry),
],
];
}
Future<void> _showLogDialog(BuildContext context) {
return showDialog<void>(
context: context,
builder: (context) => const LogExerciseDialog(),
);
}
}
/// Card surfacing weekly strength-workout frequency against the lean-mass
/// preservation recommendation (three resistance sessions per week).
class _WeeklyFrequencyCard extends StatelessWidget {
const _WeeklyFrequencyCard({
required this.sessionsThisWeek,
required this.targetSessions,
});
final int sessionsThisWeek;
final int targetSessions;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final progress =
(sessionsThisWeek / targetSessions).clamp(0.0, 1.0).toDouble();
final goalMet = sessionsThisWeek >= targetSessions;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.fitness_center,
color: goalMet
? Colors.green.shade700
: theme.colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Weekly Strength Frequency',
style: theme.textTheme.titleSmall
?.copyWith(fontWeight: FontWeight.bold),
),
),
if (goalMet)
Chip(
visualDensity: VisualDensity.compact,
backgroundColor: Colors.green.shade100,
label: Text(
'Goal met',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.green.shade800,
),
),
),
],
),
const SizedBox(height: 12),
Text(
'$sessionsThisWeek of $targetSessions sessions this week',
style: theme.textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: progress,
minHeight: 8,
backgroundColor: theme.colorScheme.surfaceContainerHighest,
),
),
const SizedBox(height: 8),
Text(
'Resistance training preserves lean mass and offsets '
'GLP-1-driven muscle loss — aim for $targetSessions full-body '
'or split sessions per week.',
style: theme.textTheme.bodySmall
?.copyWith(color: theme.colorScheme.outline),
),
],
),
),
);
}
}
/// One logged working-set row with a swipe-to-delete affordance.
class _ExerciseTile extends ConsumerWidget {
const _ExerciseTile({required this.entry});
final ExerciseEntry entry;
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
return Dismissible(
key: ValueKey(entry.id),
direction: DismissDirection.endToStart,
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 16),
color: theme.colorScheme.errorContainer,
child: Icon(Icons.delete_outline, color: theme.colorScheme.error),
),
onDismissed: (_) => ref
.read(exerciseControllerProvider.notifier)
.deleteEntry(entry.id),
child: Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: ListTile(
leading: CircleAvatar(
backgroundColor: theme.colorScheme.secondaryContainer,
child: Icon(
Icons.fitness_center,
size: 20,
color: theme.colorScheme.onSecondaryContainer,
),
),
title: Text(
entry.exerciseName,
style: const TextStyle(fontWeight: FontWeight.w600),
),
subtitle: Text(
'${entry.muscleGroup.displayName} · ${entry.volumeLabel}',
),
trailing: Text(
formatDateTime(entry.timestamp),
style: theme.textTheme.labelSmall
?.copyWith(color: theme.colorScheme.outline),
),
),
),
);
}
}
/// Placeholder shown while the log has no entries.
class _EmptyLogHint extends StatelessWidget {
const _EmptyLogHint();
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 32),
child: Column(
children: [
Icon(Icons.fitness_center_outlined,
size: 40, color: theme.colorScheme.outline),
const SizedBox(height: 8),
Text(
'No sessions logged yet.\nTap "Log Exercise" to record your '
'first working set.',
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall
?.copyWith(fontStyle: FontStyle.italic),
),
],
),
);
}
}