261 lines
8.4 KiB
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),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|