From d8062a601f079d0de5ec86d34a5dedc4cc683de4 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 6 Jun 2026 11:39:25 +0200 Subject: [PATCH] feat: overhaul workout app with progress tracking and UX improvements - Remove automatic rest timer after each set - Add inline threshold controls (success/fail streaks) on each exercise card during an active workout - Settings: auto-save on change with 600 ms debounce; replace Save button with Reset to defaults - Fix weight display asymmetry in settings (fixed 72 px width, centred) - Progress screen (renamed from History): per-exercise view shows streak counters, weight chart (kg Y-axis, date X-axis, rolling-2 avg for total volume), exercise-filtered calendar, and per-exercise session tiles - Total view shows rolling-2-session average volume chart + full calendar + all-session list - Add WorkoutCalendar widget with monthly navigation - Store warmupDone in ExerciseResult JSON; surface warmup per session tile Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 4 + .../lib/models/exercise_result.dart | 3 + .../lib/screens/history_screen.dart | 558 +++++++++++++++--- .../lib/screens/settings_screen.dart | 98 ++- .../lib/screens/workout_screen.dart | 246 ++------ .../lib/services/storage_service.dart | 26 + .../lib/widgets/calendar_widget.dart | 138 +++++ .../lib/widgets/exercise_tile.dart | 126 +++- 8 files changed, 915 insertions(+), 284 deletions(-) create mode 100644 CLAUDE.md create mode 100644 stronglift_replacement/workout_app/lib/widgets/calendar_widget.dart diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8031ec5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,4 @@ +do NOT run tests unless specifically instructed to do so or before committing +If tests fail on the same issue twice in a row, STOP and ask the user how to proceed instead of continuing to fix and retry. +ALWAYS confirm that the feature you add / bug you fixed behaves as it should by running the program after your changes (not tests!) and inspecting output comparing it with what user wanted, after confirming by yourself ask user if the program behaves as they intended +After running tests fix all coverage gaps and issues, do not ignore unless specifically instructed to do so diff --git a/stronglift_replacement/workout_app/lib/models/exercise_result.dart b/stronglift_replacement/workout_app/lib/models/exercise_result.dart index 9fa0b1f..ef6559b 100644 --- a/stronglift_replacement/workout_app/lib/models/exercise_result.dart +++ b/stronglift_replacement/workout_app/lib/models/exercise_result.dart @@ -8,10 +8,12 @@ class ExerciseResult { const ExerciseResult({ required this.exercise, required this.sets, + this.warmupDone = false, }); final Exercise exercise; final List sets; + final bool warmupDone; /// True when every set was fully completed. bool get succeeded => sets.isNotEmpty && sets.every((s) => s.succeeded); @@ -21,6 +23,7 @@ class ExerciseResult { 'targetSets': exercise.sets, 'targetReps': exercise.reps, 'targetWeight': exercise.weight, + 'warmupDone': warmupDone, 'sets': sets.map((s) => s.toJson()).toList(), 'succeeded': succeeded, }; diff --git a/stronglift_replacement/workout_app/lib/screens/history_screen.dart b/stronglift_replacement/workout_app/lib/screens/history_screen.dart index 2611bc1..fac8306 100644 --- a/stronglift_replacement/workout_app/lib/screens/history_screen.dart +++ b/stronglift_replacement/workout_app/lib/screens/history_screen.dart @@ -1,10 +1,18 @@ -/// History screen: past workout list with per-exercise weight progress chart. +/// Progress screen: total-load view plus per-exercise drill-down. +/// +/// "Total" (default): total-volume chart, full calendar, all sessions. +/// Specific exercise: streak card, weight chart, exercise-only calendar, +/// exercise-only session list. library; import 'dart:convert'; import 'dart:math'; import 'package:flutter/material.dart'; +import 'package:workout_app/models/exercise.dart'; import 'package:workout_app/services/storage_service.dart'; +import 'package:workout_app/widgets/calendar_widget.dart'; + +const _kTotal = 'Total (all workouts)'; class HistoryScreen extends StatefulWidget { const HistoryScreen({super.key}); @@ -16,8 +24,11 @@ class HistoryScreen extends StatefulWidget { class _HistoryScreenState extends State { List> _rows = []; bool _loading = true; - String? _selectedExercise; + String _selected = _kTotal; List _exerciseNames = []; + ExerciseState? _selectedState; + DateTime _calendarMonth = + DateTime(DateTime.now().year, DateTime.now().month); @override void initState() { @@ -26,59 +37,134 @@ class _HistoryScreenState extends State { } Future _load() async { - final rows = await StorageService.instance.getWorkoutHistory(); - final names = {}; + final rows = await StorageService.instance.getWorkoutHistory(limit: 200); + final names = []; + final seen = {}; for (final row in rows) { final json = jsonDecode(row['json'] as String) as Map; for (final ex in (json['exercises'] as List)) { - names.add((ex as Map)['name'] as String); + final name = (ex as Map)['name'] as String; + if (seen.add(name)) names.add(name); } } + ExerciseState? state; + if (_selected != _kTotal) { + state = await StorageService.instance.getExerciseState(_selected); + } if (mounted) { setState(() { _rows = rows; - _exerciseNames = names.toList(); - _selectedExercise = - _exerciseNames.isNotEmpty ? _exerciseNames.first : null; + _exerciseNames = names; + _selectedState = state; _loading = false; }); } } - String _formatDuration(int secs) { - final m = (secs ~/ 60).toString().padLeft(2, '0'); - final s = (secs % 60).toString().padLeft(2, '0'); - return '${secs ~/ 3600 > 0 ? '${secs ~/ 3600}h ' : ''}${m}m ${s}s'; + Future _pickExercise(String name) async { + ExerciseState? state; + if (name != _kTotal) { + state = await StorageService.instance.getExerciseState(name); + } + if (mounted) { + setState(() { + _selected = name; + _selectedState = state; + }); + } } - /// Extract (date, weight) points for the selected exercise from history. - List<(DateTime, double)> _buildChartPoints(String exerciseName) { + // ── Data helpers ──────────────────────────────────────────────────────────── + + /// All workout dates (YYYY-MM-DD) across all sessions. + Set get _allWorkoutDates => + _rows.map((r) => r['date'] as String).toSet(); + + /// Dates when the selected exercise appeared. + Set _exerciseDates(String name) { + final result = {}; + for (final row in _rows) { + final json = + jsonDecode(row['json'] as String) as Map; + for (final ex in (json['exercises'] as List)) { + if ((ex as Map)['name'] == name) { + result.add(row['date'] as String); + break; + } + } + } + return result; + } + + /// (date, total volume) per session – sum of targetWeight × targetSets. + List<(DateTime, double)> _totalVolumePoints() { + final points = <(DateTime, double)>[]; + for (final row in _rows.reversed) { + final json = + jsonDecode(row['json'] as String) as Map; + double total = 0; + for (final ex in (json['exercises'] as List)) { + final m = ex as Map; + final w = (m['targetWeight'] as num?)?.toDouble() ?? 0; + final s = (m['targetSets'] as num?)?.toInt() ?? 0; + final r = (m['targetReps'] as num?)?.toInt() ?? 0; + total += w * s * r; + } + final date = DateTime.tryParse(row['date'] as String); + if (date != null) points.add((date, total)); + } + return points; + } + + /// (date, weight) for the selected exercise. + List<(DateTime, double)> _exerciseWeightPoints(String name) { final points = <(DateTime, double)>[]; for (final row in _rows.reversed) { final json = jsonDecode(row['json'] as String) as Map; for (final ex in (json['exercises'] as List)) { final m = ex as Map; - if (m['name'] == exerciseName) { + if (m['name'] == name) { final date = DateTime.tryParse(row['date'] as String); - final weight = (m['targetWeight'] as num?)?.toDouble(); - if (date != null && weight != null) { - points.add((date, weight)); - } + final w = (m['targetWeight'] as num?)?.toDouble(); + if (date != null && w != null) points.add((date, w)); + break; } } } return points; } + /// Sessions filtered to those containing the selected exercise, newest first. + List> _sessionsForExercise(String name) { + final result = >[]; + for (final row in _rows) { + final json = + jsonDecode(row['json'] as String) as Map; + for (final ex in (json['exercises'] as List)) { + final m = ex as Map; + if (m['name'] == name) { + result.add({...row, 'exerciseData': m}); + break; + } + } + } + return result; + } + + // ── Build ─────────────────────────────────────────────────────────────────── + @override Widget build(BuildContext context) { + final allNames = [_kTotal, ..._exerciseNames]; + final isTotal = _selected == _kTotal; + return Scaffold( backgroundColor: Colors.grey.shade900, appBar: AppBar( backgroundColor: Colors.grey.shade800, - title: const Text('History', style: TextStyle(color: Colors.white)), + title: const Text('Progress', style: TextStyle(color: Colors.white)), iconTheme: const IconThemeData(color: Colors.white), ), body: _loading @@ -93,39 +179,113 @@ class _HistoryScreenState extends State { : ListView( padding: const EdgeInsets.all(12), children: [ - if (_selectedExercise != null) ...[ - _ExercisePicker( - names: _exerciseNames, - selected: _selectedExercise!, - onChanged: (v) => - setState(() => _selectedExercise = v), - ), - const SizedBox(height: 8), - _WeightChart( - points: _buildChartPoints(_selectedExercise!), - ), - const SizedBox(height: 16), - ], - const Text( - 'SESSIONS', - style: TextStyle( - color: Colors.white54, - fontSize: 11, - letterSpacing: 1.3, - ), + _ExercisePicker( + names: allNames, + selected: _selected, + onChanged: _pickExercise, ), - const SizedBox(height: 8), - ..._rows.map((row) => _SessionTile( - row: row, - formatDuration: _formatDuration, - )), + const SizedBox(height: 12), + if (isTotal) ..._buildTotalView() + else ..._buildExerciseView(_selected), ], ), ); } + + /// Rolling average of 2 consecutive points to smooth A/B alternation. + static List<(DateTime, double)> _rollingAvg2( + List<(DateTime, double)> pts, + ) { + if (pts.length < 2) return pts; + return [ + for (int i = 0; i < pts.length; i++) + (pts[i].$1, i == 0 ? pts[0].$2 : (pts[i].$2 + pts[i - 1].$2) / 2), + ]; + } + + List _buildTotalView() => [ + _SectionLabel('TOTAL VOLUME (2-session rolling avg, kg)'), + const SizedBox(height: 6), + _WeightChart( + points: _rollingAvg2(_totalVolumePoints()), + ), + const SizedBox(height: 16), + WorkoutCalendar( + workoutDates: _allWorkoutDates, + month: _calendarMonth, + onPrevMonth: () => setState(() { + _calendarMonth = DateTime( + _calendarMonth.year, + _calendarMonth.month - 1, + ); + }), + onNextMonth: () => setState(() { + _calendarMonth = DateTime( + _calendarMonth.year, + _calendarMonth.month + 1, + ); + }), + ), + const SizedBox(height: 16), + _SectionLabel('ALL SESSIONS'), + const SizedBox(height: 8), + ..._rows.map((row) => _AllSessionTile(row: row)), + ]; + + List _buildExerciseView(String name) => [ + if (_selectedState != null) ...[ + _ProgressStatsCard(state: _selectedState!), + const SizedBox(height: 12), + ], + _SectionLabel('WEIGHT OVER TIME'), + const SizedBox(height: 6), + _WeightChart( + points: _exerciseWeightPoints(name), + ), + const SizedBox(height: 16), + WorkoutCalendar( + workoutDates: _exerciseDates(name), + month: _calendarMonth, + onPrevMonth: () => setState(() { + _calendarMonth = DateTime( + _calendarMonth.year, + _calendarMonth.month - 1, + ); + }), + onNextMonth: () => setState(() { + _calendarMonth = DateTime( + _calendarMonth.year, + _calendarMonth.month + 1, + ); + }), + ), + const SizedBox(height: 16), + _SectionLabel(name.toUpperCase()), + const SizedBox(height: 8), + ..._sessionsForExercise(name) + .map((s) => _ExerciseSessionTile(session: s)), + ]; } -// ── Sub-widgets ──────────────────────────────────────────────────────────────── +// ── Shared sub-widgets ───────────────────────────────────────────────────────── + +class _SectionLabel extends StatelessWidget { + const _SectionLabel(this.text); + + final String text; + + @override + Widget build(BuildContext context) { + return Text( + text, + style: const TextStyle( + color: Colors.white54, + fontSize: 11, + letterSpacing: 1.3, + ), + ); + } +} class _ExercisePicker extends StatelessWidget { const _ExercisePicker({ @@ -150,7 +310,14 @@ class _ExercisePicker extends StatelessWidget { .map( (n) => DropdownMenuItem( value: n, - child: Text(n, style: const TextStyle(color: Colors.white)), + child: Text( + n, + style: TextStyle( + color: n == _kTotal ? Colors.white70 : Colors.white, + fontStyle: + n == _kTotal ? FontStyle.italic : FontStyle.normal, + ), + ), ), ) .toList(), @@ -161,6 +328,121 @@ class _ExercisePicker extends StatelessWidget { } } +class _ProgressStatsCard extends StatelessWidget { + const _ProgressStatsCard({required this.state}); + + final ExerciseState state; + + String _nextWeightLabel(double current, double max, double inc) { + if (current >= max) return '+1 rep'; + return '+${inc}kg (${(current + inc).clamp(0.0, max)}kg)'; + } + + String _prevWeightLabel(double current, double inc) { + return '-${inc}kg (${(current - inc).clamp(0.0, double.infinity)}kg)'; + } + + @override + Widget build(BuildContext context) { + final successLeft = state.successThreshold - state.successStreak; + final failLeft = state.failThreshold - state.failStreak; + + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey.shade800, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${state.name} — ${state.weight}kg', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + const SizedBox(height: 8), + _StreakRow( + icon: Icons.trending_up, + color: Colors.greenAccent, + current: state.successStreak, + threshold: state.successThreshold, + leftLabel: '$successLeft more', + actionLabel: _nextWeightLabel( + state.weight, + state.maxWeight, + kWeightIncrement, + ), + direction: '↑', + ), + const SizedBox(height: 6), + _StreakRow( + icon: Icons.trending_down, + color: Colors.redAccent, + current: state.failStreak, + threshold: state.failThreshold, + leftLabel: '$failLeft more', + actionLabel: _prevWeightLabel(state.weight, kWeightIncrement), + direction: '↓', + ), + ], + ), + ); + } +} + +class _StreakRow extends StatelessWidget { + const _StreakRow({ + required this.icon, + required this.color, + required this.current, + required this.threshold, + required this.leftLabel, + required this.actionLabel, + required this.direction, + }); + + final IconData icon; + final Color color; + final int current; + final int threshold; + final String leftLabel; + final String actionLabel; + final String direction; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Icon(icon, color: color, size: 14), + const SizedBox(width: 6), + ...List.generate( + threshold, + (i) => Container( + width: 8, + height: 8, + margin: const EdgeInsets.only(right: 3), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: i < current ? color : Colors.white24, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + '$leftLabel to $direction $actionLabel', + style: const TextStyle(color: Colors.white60, fontSize: 12), + ), + ), + ], + ); + } +} + class _WeightChart extends StatelessWidget { const _WeightChart({required this.points}); @@ -170,16 +452,16 @@ class _WeightChart extends StatelessWidget { Widget build(BuildContext context) { if (points.length < 2) { return Container( - height: 100, + height: 80, alignment: Alignment.center, child: const Text( - 'Not enough data', + 'Not enough data for chart', style: TextStyle(color: Colors.white38), ), ); } return SizedBox( - height: 120, + height: 140, child: CustomPaint( painter: _ChartPainter(points), size: Size.infinite, @@ -193,6 +475,18 @@ class _ChartPainter extends CustomPainter { final List<(DateTime, double)> points; + // Layout constants + static const _topPad = 14.0; // room for top Y label + static const _bottomPad = 22.0; // room for X-axis dates + static const _hPad = 8.0; + + static const _months = [ + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', + ]; + + static String _shortDate(DateTime d) => '${_months[d.month - 1]} ${d.day}'; + @override void paint(Canvas canvas, Size size) { final minW = points.map((p) => p.$2).reduce(min); @@ -202,12 +496,19 @@ class _ChartPainter extends CustomPainter { final wRange = maxW - minW; final tRange = maxMs - minMs; - double xOf(DateTime t) => - tRange == 0 ? size.width / 2 : - (t.millisecondsSinceEpoch - minMs) / tRange * (size.width - 16) + 8; - double yOf(double w) => - wRange == 0 ? size.height / 2 : - (1 - (w - minW) / wRange) * (size.height - 16) + 8; + final plotTop = _topPad; + final plotBottom = size.height - _bottomPad; + final plotLeft = _hPad; + final plotRight = size.width - _hPad; + final plotHeight = plotBottom - plotTop; + final plotWidth = plotRight - plotLeft; + + double xOf(DateTime t) => tRange == 0 + ? (plotLeft + plotRight) / 2 + : (t.millisecondsSinceEpoch - minMs) / tRange * plotWidth + plotLeft; + double yOf(double w) => wRange == 0 + ? (plotTop + plotBottom) / 2 + : (1 - (w - minW) / wRange) * plotHeight + plotTop; final linePaint = Paint() ..color = Colors.indigoAccent @@ -223,45 +524,65 @@ class _ChartPainter extends CustomPainter { path.lineTo(xOf(p.$1), yOf(p.$2)); } canvas.drawPath(path, linePaint); - for (final p in points) { canvas.drawCircle(Offset(xOf(p.$1), yOf(p.$2)), 4, dotPaint); } - // Label min/max weight + // Y-axis labels final tp = TextPainter(textDirection: TextDirection.ltr); - void drawLabel(String text, Offset offset) { + void drawText(String text, Offset offset, {double fontSize = 10}) { tp ..text = TextSpan( text: text, - style: const TextStyle(color: Colors.white54, fontSize: 10), + style: TextStyle(color: Colors.white54, fontSize: fontSize), ) ..layout() ..paint(canvas, offset); } - drawLabel('${maxW}kg', Offset(8, 0)); - drawLabel('${minW}kg', Offset(8, size.height - 14)); + + drawText('${maxW.round()}kg', Offset(plotLeft, 0)); + drawText('${minW.round()}kg', Offset(plotLeft, plotBottom + 2)); + + // X-axis date labels: first, middle, last + final n = points.length; + final xIndices = n <= 2 ? [0, n - 1] : [0, n ~/ 2, n - 1]; + for (final i in xIndices) { + final p = points[i]; + final label = _shortDate(p.$1); + tp + ..text = TextSpan( + text: label, + style: const TextStyle(color: Colors.white38, fontSize: 9), + ) + ..layout(); + final cx = xOf(p.$1); + final dx = (cx - tp.width / 2).clamp(plotLeft, plotRight - tp.width); + tp.paint(canvas, Offset(dx, size.height - tp.height)); + } } @override bool shouldRepaint(_ChartPainter old) => old.points != points; } -class _SessionTile extends StatelessWidget { - const _SessionTile({ - required this.row, - required this.formatDuration, - }); +class _AllSessionTile extends StatelessWidget { + const _AllSessionTile({required this.row}); final Map row; - final String Function(int) formatDuration; + + String _formatDuration(int secs) { + final h = secs ~/ 3600; + final m = (secs ~/ 60).remainder(60).toString().padLeft(2, '0'); + final s = (secs % 60).toString().padLeft(2, '0'); + return h > 0 ? '${h}h ${m}m ${s}s' : '${m}m ${s}s'; + } @override Widget build(BuildContext context) { final succeeded = (row['succeeded'] as int) == 1; final type = row['workout_type'] as String; final date = row['date'] as String; - final dur = formatDuration(row['duration_seconds'] as int); + final dur = _formatDuration(row['duration_seconds'] as int); return Container( margin: const EdgeInsets.only(bottom: 8), @@ -295,7 +616,104 @@ class _SessionTile extends StatelessWidget { ), Text( dur, - style: const TextStyle(color: Colors.white54, fontSize: 12), + style: + const TextStyle(color: Colors.white54, fontSize: 12), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _ExerciseSessionTile extends StatelessWidget { + const _ExerciseSessionTile({required this.session}); + + final Map session; + + String _formatDuration(int secs) { + final h = secs ~/ 3600; + final m = (secs ~/ 60).remainder(60).toString().padLeft(2, '0'); + final s = (secs % 60).toString().padLeft(2, '0'); + return h > 0 ? '${h}h ${m}m ${s}s' : '${m}m ${s}s'; + } + + @override + Widget build(BuildContext context) { + final exData = session['exerciseData'] as Map; + final succeeded = (exData['succeeded'] as bool?) == true; + final date = session['date'] as String; + final dur = _formatDuration(session['duration_seconds'] as int); + final weight = (exData['targetWeight'] as num?)?.toDouble(); + final warmupDone = exData['warmupDone'] as bool? ?? false; + final sets = + (exData['sets'] as List?)?.cast>() ?? []; + final targetSets = exData['targetSets'] as int? ?? sets.length; + final doneSets = sets.where((s) => s['succeeded'] == true).length; + final repsSummary = sets.map((s) => '${s['doneReps']}').join(', '); + + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey.shade800, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: succeeded ? Colors.green.shade800 : Colors.red.shade900, + width: 1, + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon( + succeeded ? Icons.check_circle : Icons.cancel, + color: succeeded ? Colors.greenAccent : Colors.redAccent, + size: 18, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + date, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 2), + Text( + '${weight ?? '?'}kg · $doneSets/$targetSets sets' + ' · ${warmupDone ? '⬤ warmup' : '○ no warmup'}', + style: const TextStyle( + color: Colors.white70, + fontSize: 12, + ), + ), + if (repsSummary.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + 'reps: $repsSummary', + style: const TextStyle( + color: Colors.white54, + fontSize: 11, + ), + ), + ], + const SizedBox(height: 2), + Text( + 'workout: $dur', + style: const TextStyle( + color: Colors.white38, + fontSize: 11, + ), ), ], ), diff --git a/stronglift_replacement/workout_app/lib/screens/settings_screen.dart b/stronglift_replacement/workout_app/lib/screens/settings_screen.dart index 2d2d374..c945db6 100644 --- a/stronglift_replacement/workout_app/lib/screens/settings_screen.dart +++ b/stronglift_replacement/workout_app/lib/screens/settings_screen.dart @@ -1,6 +1,8 @@ /// Settings screen: per-exercise streak thresholds and manual weight overrides. +/// Changes are saved immediately; a "Reset to defaults" button reverts all. library; +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:workout_app/models/exercise.dart'; import 'package:workout_app/models/workout_plan.dart'; @@ -16,18 +18,28 @@ class SettingsScreen extends StatefulWidget { class _SettingsScreenState extends State { List? _states; bool _loading = true; - bool _saving = false; final Map _successThresholds = {}; final Map _failThresholds = {}; final Map _weights = {}; + // Debounce weight saves to avoid resetting streaks on every tap. + final Map _weightTimers = {}; + @override void initState() { super.initState(); _load(); } + @override + void dispose() { + for (final t in _weightTimers.values) { + t.cancel(); + } + super.dispose(); + } + Future _load() async { final states = await StorageService.instance.getAllExerciseStates(); if (mounted) { @@ -43,21 +55,60 @@ class _SettingsScreenState extends State { } } - Future _save() async { - setState(() => _saving = true); - final storage = StorageService.instance; - for (final s in _states!) { - await storage.setExerciseThresholds( - s.name, - successThreshold: _successThresholds[s.name]!, - failThreshold: _failThresholds[s.name]!, - ); - final newWeight = _weights[s.name] ?? s.weight; - if ((newWeight - s.weight).abs() > 0.001) { - await storage.setExerciseWeight(s.name, newWeight); + void _onWeightChanged(String name, double value) { + setState(() => _weights[name] = value); + _weightTimers[name]?.cancel(); + _weightTimers[name] = Timer(const Duration(milliseconds: 600), () { + StorageService.instance.setExerciseWeight(name, value); + }); + } + + Future _onThresholdChanged(String name, int success, int fail) async { + setState(() { + _successThresholds[name] = success; + _failThresholds[name] = fail; + }); + await StorageService.instance.setExerciseThresholds( + name, + successThreshold: success, + failThreshold: fail, + ); + } + + Future _resetToDefaults() async { + final ok = await showDialog( + context: context, + builder: (_) => AlertDialog( + backgroundColor: Colors.grey.shade900, + title: const Text( + 'Reset to defaults?', + style: TextStyle(color: Colors.white), + ), + content: const Text( + 'All weights and thresholds will be reset. ' + 'Streak counters will be cleared.', + style: TextStyle(color: Colors.white70), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: + const Text('Cancel', style: TextStyle(color: Colors.white70)), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: + const Text('Reset', style: TextStyle(color: Colors.redAccent)), + ), + ], + ), + ); + if (ok == true) { + for (final name in _orderedNames) { + await StorageService.instance.resetExerciseToDefaults(name); } + await _load(); } - if (mounted) Navigator.of(context).pop(); } List get _orderedNames { @@ -78,8 +129,11 @@ class _SettingsScreenState extends State { iconTheme: const IconThemeData(color: Colors.white), actions: [ TextButton( - onPressed: (_loading || _saving) ? null : _save, - child: const Text('Save', style: TextStyle(color: Colors.white)), + onPressed: _loading ? null : _resetToDefaults, + child: const Text( + 'Reset defaults', + style: TextStyle(color: Colors.redAccent), + ), ), ], ), @@ -102,7 +156,7 @@ class _SettingsScreenState extends State { return _WeightRow( name: name, weight: w, - onChanged: (v) => setState(() => _weights[name] = v), + onChanged: (v) => _onWeightChanged(name, v), ); }), const SizedBox(height: 20), @@ -122,9 +176,9 @@ class _SettingsScreenState extends State { successThreshold: sThresh, failThreshold: fThresh, onSuccessChanged: (v) => - setState(() => _successThresholds[name] = v), + _onThresholdChanged(name, v, _failThresholds[name]!), onFailChanged: (v) => - setState(() => _failThresholds[name] = v), + _onThresholdChanged(name, _successThresholds[name]!, v), ); }), ], @@ -180,10 +234,12 @@ class _WeightRow extends StatelessWidget { (weight - kWeightIncrement).clamp(0.0, 999.0), ), ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 10), + // Fixed-width container supports up to "999.9kg" (7 chars). + SizedBox( + width: 72, child: Text( '${weight}kg', + textAlign: TextAlign.center, style: const TextStyle( color: Colors.white, fontSize: 14, diff --git a/stronglift_replacement/workout_app/lib/screens/workout_screen.dart b/stronglift_replacement/workout_app/lib/screens/workout_screen.dart index e657934..647d4c7 100644 --- a/stronglift_replacement/workout_app/lib/screens/workout_screen.dart +++ b/stronglift_replacement/workout_app/lib/screens/workout_screen.dart @@ -1,25 +1,18 @@ -/// Active workout screen: per-rep breaks, warmup, back-button protection, +/// Active workout screen: per-rep tracking, warmup, back-button protection, /// and crash-safe session persistence. library; import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:audioplayers/audioplayers.dart'; -import 'package:vibration/vibration.dart'; import 'package:workout_app/models/exercise.dart'; import 'package:workout_app/models/exercise_result.dart'; import 'package:workout_app/models/set_result.dart'; import 'package:workout_app/models/workout_session.dart'; import 'package:workout_app/services/storage_service.dart'; import 'package:workout_app/services/sync_service.dart'; -import 'package:workout_app/widgets/break_banner.dart'; import 'package:workout_app/widgets/exercise_tile.dart'; import 'package:workout_app/widgets/workout_summary_dialog.dart'; -const _successBreakSecs = 180; // 3 min after successful rep -const _failBreakSecs = 300; // 5 min after failed rep -const _warmupBreakSecs = 180; // 3 min after warmup - class WorkoutScreen extends StatefulWidget { const WorkoutScreen({ super.key, @@ -30,8 +23,6 @@ class WorkoutScreen extends StatefulWidget { final String workoutType; final List exercises; - - /// Non-null when resuming a previously interrupted session. final Map? savedState; @override @@ -46,18 +37,8 @@ class _WorkoutScreenState extends State { late Timer _elapsedTimer; Duration _elapsed = Duration.zero; - // Break state - int _breakRemaining = 0; - int _breakDurationSecs = 0; - DateTime? _breakStartTime; - Timer? _breakTimer; - String _breakLabel = ''; - int _breakForExIdx = -1; - int _breakForRepIdx = -1; // -1 = warmup break + Map _exerciseStates = {}; - bool get _inBreak => _breakRemaining > 0; - - final _audio = AudioPlayer(); final _sync = SyncService(); bool _finished = false; @@ -73,6 +54,7 @@ class _WorkoutScreenState extends State { _elapsedTimer = Timer.periodic(const Duration(seconds: 1), (_) { setState(() => _elapsed = DateTime.now().difference(_startTime)); }); + _loadExerciseStates(); } void _initFresh() { @@ -97,29 +79,20 @@ class _WorkoutScreenState extends State { .map((row) => (row as List).cast()) .toList(); _warmupTapped = (s['warmupTapped'] as List).cast(); + } - final breakEndMs = s['breakEndMs'] as int? ?? 0; - final breakDur = s['breakDurationSecs'] as int? ?? 0; - if (breakEndMs > 0 && breakDur > 0) { - final endTime = DateTime.fromMillisecondsSinceEpoch(breakEndMs); - final remaining = endTime.difference(DateTime.now()).inSeconds; - if (remaining > 0) { - _breakForExIdx = s['breakForExIdx'] as int? ?? -1; - _breakForRepIdx = s['breakForRepIdx'] as int? ?? -1; - _breakLabel = s['breakLabel'] as String? ?? 'Rest'; - _breakDurationSecs = breakDur; - _breakStartTime = endTime.subtract(Duration(seconds: breakDur)); - _breakRemaining = remaining; - _breakTimer = Timer.periodic(const Duration(seconds: 1), _tickBreak); - } + Future _loadExerciseStates() async { + final states = await StorageService.instance.getAllExerciseStates(); + if (mounted) { + setState(() { + _exerciseStates = {for (final s in states) s.name: s}; + }); } } @override void dispose() { _elapsedTimer.cancel(); - _breakTimer?.cancel(); - _audio.dispose(); super.dispose(); } @@ -132,15 +105,6 @@ class _WorkoutScreenState extends State { 'tapped': _tapped, 'doneReps': _doneReps, 'warmupTapped': _warmupTapped, - 'breakForExIdx': _breakForExIdx, - 'breakForRepIdx': _breakForRepIdx, - 'breakLabel': _breakLabel, - 'breakDurationSecs': _breakDurationSecs, - 'breakEndMs': _breakStartTime != null - ? _breakStartTime! - .add(Duration(seconds: _breakDurationSecs)) - .millisecondsSinceEpoch - : 0, }); } @@ -154,60 +118,24 @@ class _WorkoutScreenState extends State { bool get _allSetsCompleted => _tapped.every((row) => row.every((t) => t)); - bool _isLastUntappedCircle(int exIdx, int repIdx) { - int remaining = 0; - for (int i = 0; i < widget.exercises.length; i++) { - for (int s = 0; s < widget.exercises[i].sets; s++) { - if (!_tapped[i][s]) remaining++; - } - } - return remaining == 1; - } - // ── Interaction ──────────────────────────────────────────────────────────── void _tapCircle(int exIdx, int repIdx) { if (_finished) return; - - final wasNotTapped = !_tapped[exIdx][repIdx]; - if (wasNotTapped && _inBreak) return; - setState(() { - if (wasNotTapped) { + if (!_tapped[exIdx][repIdx]) { _tapped[exIdx][repIdx] = true; } else { - // Subsequent taps decrement reps (records actual reps done). _doneReps[exIdx][repIdx] = (_doneReps[exIdx][repIdx] - 1).clamp(0, 999); - _recomputeBreakIfNeeded(exIdx, repIdx); } }); - - if (wasNotTapped) { - final isLast = _isLastUntappedCircle(exIdx, repIdx); - if (!isLast) { - final succeeded = - _doneReps[exIdx][repIdx] >= widget.exercises[exIdx].reps; - _startBreak( - succeeded ? _successBreakSecs : _failBreakSecs, - succeeded - ? 'Rest (3 min — well done!)' - : 'Rest (5 min — keep going!)', - exIdx, - repIdx, - ); - } - } - _saveActiveSession(); } void _tapWarmup(int exIdx) { if (_finished || _warmupTapped[exIdx]) return; setState(() => _warmupTapped[exIdx] = true); - if (!_inBreak) { - _startBreak(_warmupBreakSecs, 'Warmup rest (3 min)', exIdx, -1); - } _saveActiveSession(); } @@ -217,81 +145,36 @@ class _WorkoutScreenState extends State { _tapped[exIdx][repIdx] = false; _doneReps[exIdx][repIdx] = widget.exercises[exIdx].reps; }); - if (_breakForExIdx == exIdx && _breakForRepIdx == repIdx) { - _cancelBreak(); + _saveActiveSession(); + } + + Future _onThresholdChanged( + String name, + int success, + int fail, + ) async { + await StorageService.instance.setExerciseThresholds( + name, + successThreshold: success, + failThreshold: fail, + ); + if (mounted) { + setState(() { + final s = _exerciseStates[name]; + if (s != null) { + _exerciseStates[name] = ExerciseState( + name: s.name, + weight: s.weight, + reps: s.reps, + successStreak: s.successStreak, + failStreak: s.failStreak, + maxWeight: s.maxWeight, + successThreshold: success, + failThreshold: fail, + ); + } + }); } - _saveActiveSession(); - } - - // ── Break management ─────────────────────────────────────────────────────── - - void _startBreak(int secs, String label, int exIdx, int repIdx) { - _breakTimer?.cancel(); - setState(() { - _breakDurationSecs = secs; - _breakRemaining = secs; - _breakLabel = label; - _breakForExIdx = exIdx; - _breakForRepIdx = repIdx; - _breakStartTime = DateTime.now(); - }); - _breakTimer = Timer.periodic(const Duration(seconds: 1), _tickBreak); - } - - void _tickBreak(Timer t) { - setState(() => _breakRemaining--); - if (_breakRemaining <= 0) { - t.cancel(); - _onBreakFinished(); - } - } - - void _cancelBreak() { - _breakTimer?.cancel(); - setState(() { - _breakRemaining = 0; - _breakForExIdx = -1; - _breakForRepIdx = -1; - _breakStartTime = null; - }); - } - - void _skipBreak() { - _cancelBreak(); - _saveActiveSession(); - } - - /// If the user reduces reps on the rep that triggered the current break, - /// switch from 3-min to 5-min (or vice versa). - void _recomputeBreakIfNeeded(int exIdx, int repIdx) { - if (!_inBreak) return; - if (_breakForExIdx != exIdx || _breakForRepIdx != repIdx) return; - if (_breakForRepIdx == -1) return; // warmup break, never recompute - - final succeeded = - _doneReps[exIdx][repIdx] >= widget.exercises[exIdx].reps; - final newDuration = succeeded ? _successBreakSecs : _failBreakSecs; - if (newDuration == _breakDurationSecs) return; - - final elapsed = DateTime.now().difference(_breakStartTime!).inSeconds; - final newRemaining = (newDuration - elapsed).clamp(0, newDuration); - - _breakDurationSecs = newDuration; - _breakRemaining = newRemaining; - _breakLabel = - succeeded ? 'Rest (3 min — well done!)' : 'Rest (5 min — keep going!)'; - } - - Future _onBreakFinished() async { - await _audio.play(AssetSource('sounds/break_end.mp3')).catchError((_) {}); - final hasVibrator = await Vibration.hasVibrator() == true; - if (hasVibrator) Vibration.vibrate(duration: 800); - setState(() { - _breakForExIdx = -1; - _breakForRepIdx = -1; - _breakStartTime = null; - }); - _saveActiveSession(); } // ── Finish / Reset ───────────────────────────────────────────────────────── @@ -359,7 +242,6 @@ class _WorkoutScreenState extends State { Future _finishWorkout() async { _elapsedTimer.cancel(); - _breakTimer?.cancel(); setState(() => _finished = true); final endTime = DateTime.now(); @@ -369,6 +251,7 @@ class _WorkoutScreenState extends State { final ex = widget.exercises[i]; results.add(ExerciseResult( exercise: ex, + warmupDone: _warmupTapped[i], sets: List.generate( ex.sets, (s) => SetResult( @@ -425,7 +308,6 @@ class _WorkoutScreenState extends State { @override Widget build(BuildContext context) { return PopScope( - // canPop: true → back navigates home, workout stays in DB // ignore: avoid_redundant_argument_values canPop: true, child: Scaffold( @@ -460,31 +342,27 @@ class _WorkoutScreenState extends State { ), ], ), - body: Column( - children: [ - if (_inBreak) - BreakBanner( - breakRemaining: _breakRemaining, - breakLabel: _breakLabel, - onSkip: _skipBreak, - ), - Expanded( - child: ListView.separated( - padding: const EdgeInsets.all(12), - itemCount: widget.exercises.length, - separatorBuilder: (_, _) => const SizedBox(height: 8), - itemBuilder: (_, i) => ExerciseTile( - exercise: widget.exercises[i], - tapped: _tapped[i], - doneReps: _doneReps[i], - warmupTapped: _warmupTapped[i], - onTapCircle: (s) => _tapCircle(i, s), - onLongPressCircle: (s) => _resetCircle(i, s), - onTapWarmup: () => _tapWarmup(i), - ), - ), - ), - ], + body: ListView.separated( + padding: const EdgeInsets.all(12), + itemCount: widget.exercises.length, + separatorBuilder: (_, _) => const SizedBox(height: 8), + itemBuilder: (_, i) { + final exName = widget.exercises[i].name; + final state = _exerciseStates[exName]; + return ExerciseTile( + exercise: widget.exercises[i], + tapped: _tapped[i], + doneReps: _doneReps[i], + warmupTapped: _warmupTapped[i], + successThreshold: state?.successThreshold ?? 3, + failThreshold: state?.failThreshold ?? 2, + onTapCircle: (s) => _tapCircle(i, s), + onLongPressCircle: (s) => _resetCircle(i, s), + onTapWarmup: () => _tapWarmup(i), + onThresholdChanged: (success, fail) => + _onThresholdChanged(exName, success, fail), + ); + }, ), ), ); diff --git a/stronglift_replacement/workout_app/lib/services/storage_service.dart b/stronglift_replacement/workout_app/lib/services/storage_service.dart index a7b4776..f710660 100644 --- a/stronglift_replacement/workout_app/lib/services/storage_service.dart +++ b/stronglift_replacement/workout_app/lib/services/storage_service.dart @@ -361,4 +361,30 @@ class StorageService { [limit], ); } + + Future> getAllWorkoutDates() async { + final rows = await _db.rawQuery( + 'SELECT DISTINCT date FROM workout_history ORDER BY date DESC', + ); + return rows.map((r) => r['date'] as String).toList(); + } + + Future resetExerciseToDefaults(String name) async { + final defaults = [...workoutA, ...workoutB].firstWhere( + (e) => e.name == name, + orElse: () => throw Exception('Unknown exercise: $name'), + ); + await _db.update( + 'exercise_state', + { + 'weight': defaults.weight, + 'success_threshold': 3, + 'fail_threshold': 2, + 'success_streak': 0, + 'fail_streak': 0, + }, + where: 'name = ?', + whereArgs: [name], + ); + } } diff --git a/stronglift_replacement/workout_app/lib/widgets/calendar_widget.dart b/stronglift_replacement/workout_app/lib/widgets/calendar_widget.dart new file mode 100644 index 0000000..ecdaf7d --- /dev/null +++ b/stronglift_replacement/workout_app/lib/widgets/calendar_widget.dart @@ -0,0 +1,138 @@ +/// Monthly calendar showing which days had workouts. +library; + +import 'package:flutter/material.dart'; + +class WorkoutCalendar extends StatelessWidget { + const WorkoutCalendar({ + super.key, + required this.workoutDates, + required this.month, + required this.onPrevMonth, + required this.onNextMonth, + }); + + final Set workoutDates; + + /// Only the year and month of this DateTime are used. + final DateTime month; + final VoidCallback onPrevMonth; + final VoidCallback onNextMonth; + + static const _weekHeaders = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']; + + static const _monthNames = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December', + ]; + + String _dateKey(int year, int m, int day) => + '$year-${m.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}'; + + @override + Widget build(BuildContext context) { + final year = month.year; + final m = month.month; + final daysInMonth = DateTime(year, m + 1, 0).day; + // weekday: 1=Mon..7=Sun → offset 0..6 + final firstWeekday = DateTime(year, m, 1).weekday - 1; + final totalCells = firstWeekday + daysInMonth; + final rows = (totalCells / 7).ceil(); + + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey.shade800, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + children: [ + // Month navigation header + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + icon: const Icon(Icons.chevron_left, color: Colors.white70), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + onPressed: onPrevMonth, + ), + Text( + '${_monthNames[m - 1]} $year', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + IconButton( + icon: const Icon(Icons.chevron_right, color: Colors.white70), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + onPressed: onNextMonth, + ), + ], + ), + const SizedBox(height: 6), + // Day-of-week headers + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: _weekHeaders + .map( + (h) => SizedBox( + width: 30, + child: Text( + h, + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white38, + fontSize: 11, + fontWeight: FontWeight.bold, + ), + ), + ), + ) + .toList(), + ), + const SizedBox(height: 4), + // Day grid + ...List.generate(rows, (row) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: List.generate(7, (col) { + final cell = row * 7 + col; + final day = cell - firstWeekday + 1; + if (day < 1 || day > daysInMonth) { + return const SizedBox(width: 30, height: 30); + } + final key = _dateKey(year, m, day); + final worked = workoutDates.contains(key); + return Container( + width: 30, + height: 30, + margin: const EdgeInsets.symmetric(vertical: 2), + decoration: worked + ? BoxDecoration( + shape: BoxShape.circle, + color: Colors.green.shade700, + ) + : null, + alignment: Alignment.center, + child: Text( + '$day', + style: TextStyle( + color: worked ? Colors.white : Colors.white38, + fontSize: 12, + fontWeight: + worked ? FontWeight.bold : FontWeight.normal, + ), + ), + ); + }), + ); + }), + ], + ), + ); + } +} diff --git a/stronglift_replacement/workout_app/lib/widgets/exercise_tile.dart b/stronglift_replacement/workout_app/lib/widgets/exercise_tile.dart index f8d129f..c9e8ac5 100644 --- a/stronglift_replacement/workout_app/lib/widgets/exercise_tile.dart +++ b/stronglift_replacement/workout_app/lib/widgets/exercise_tile.dart @@ -12,25 +12,27 @@ class ExerciseTile extends StatelessWidget { required this.tapped, required this.doneReps, required this.warmupTapped, + required this.successThreshold, + required this.failThreshold, required this.onTapCircle, required this.onLongPressCircle, required this.onTapWarmup, + required this.onThresholdChanged, }); final Exercise exercise; - - /// tapped[setIdx] - whether each main set circle has been tapped. final List tapped; - - /// doneReps[setIdx] - how many reps recorded for each set. final List doneReps; - final bool warmupTapped; - + final int successThreshold; + final int failThreshold; final void Function(int setIdx) onTapCircle; final void Function(int setIdx) onLongPressCircle; final VoidCallback onTapWarmup; + /// Called when user changes thresholds inline; args are (newSuccess, newFail). + final void Function(int success, int fail) onThresholdChanged; + bool get _allCompleted => tapped.every((t) => t); bool get _allSucceeded => @@ -51,7 +53,6 @@ class ExerciseTile extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Header row: name + target Row( children: [ Expanded( @@ -71,14 +72,12 @@ class ExerciseTile extends StatelessWidget { ], ), const SizedBox(height: 8), - // Warmup row _WarmupRow( warmupWeight: exercise.warmupWeight, tapped: warmupTapped, onTap: onTapWarmup, ), const SizedBox(height: 10), - // Main set circles Wrap( spacing: 8, runSpacing: 8, @@ -93,6 +92,13 @@ class ExerciseTile extends StatelessWidget { ), ), ), + const Divider(color: Colors.white12, height: 20), + _ThresholdRow( + successThreshold: successThreshold, + failThreshold: failThreshold, + onSuccessChanged: (v) => onThresholdChanged(v, failThreshold), + onFailChanged: (v) => onThresholdChanged(successThreshold, v), + ), ], ), ), @@ -100,6 +106,108 @@ class ExerciseTile extends StatelessWidget { } } +class _ThresholdRow extends StatelessWidget { + const _ThresholdRow({ + required this.successThreshold, + required this.failThreshold, + required this.onSuccessChanged, + required this.onFailChanged, + }); + + final int successThreshold; + final int failThreshold; + final ValueChanged onSuccessChanged; + final ValueChanged onFailChanged; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + const Icon(Icons.trending_up, size: 13, color: Colors.greenAccent), + const SizedBox(width: 4), + const Text( + 'after', + style: TextStyle(color: Colors.white38, fontSize: 11), + ), + const SizedBox(width: 6), + _MiniStepper( + value: successThreshold, + onChanged: onSuccessChanged, + ), + const SizedBox(width: 4), + const Text( + '↑', + style: TextStyle(color: Colors.white38, fontSize: 11), + ), + const Spacer(), + const Icon(Icons.trending_down, size: 13, color: Colors.redAccent), + const SizedBox(width: 4), + const Text( + 'after', + style: TextStyle(color: Colors.white38, fontSize: 11), + ), + const SizedBox(width: 6), + _MiniStepper( + value: failThreshold, + onChanged: onFailChanged, + ), + const SizedBox(width: 4), + const Text( + '↓', + style: TextStyle(color: Colors.white38, fontSize: 11), + ), + ], + ); + } +} + +class _MiniStepper extends StatelessWidget { + const _MiniStepper({required this.value, required this.onChanged}); + + final int value; + final ValueChanged onChanged; + + static const _min = 1; + static const _max = 5; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + _btn(Icons.remove, value > _min ? () => onChanged(value - 1) : null), + SizedBox( + width: 22, + child: Text( + '$value', + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white, fontSize: 12), + ), + ), + _btn(Icons.add, value < _max ? () => onChanged(value + 1) : null), + ], + ); + } + + Widget _btn(IconData icon, VoidCallback? onTap) => GestureDetector( + onTap: onTap, + child: Container( + width: 22, + height: 22, + decoration: BoxDecoration( + color: Colors.grey.shade700, + borderRadius: BorderRadius.circular(4), + ), + alignment: Alignment.center, + child: Icon( + icon, + size: 12, + color: onTap != null ? Colors.white : Colors.white24, + ), + ), + ); +} + class _WarmupRow extends StatelessWidget { const _WarmupRow({ required this.warmupWeight,