From 38e836430614fe1bd2e7bc3419d63586a3cdcce8 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 15 Aug 2026 21:06:09 +0200 Subject: [PATCH] Split history_screen.dart (897) into four part files history_screen.dart the State: load, pick, build history_screen_charts.dart picker, stats card, streak row, chart widget history_screen_painter.dart the CustomPainter history_screen_tiles.dart the three session tiles history_screen_data.dart pure transforms over the loaded rows `part` files rather than separate libraries, for two reasons. Dart privacy is library-scoped, so these `_`-prefixed widgets cannot be imported across a library boundary -- making them public would be a real API change and public_member_api_docs would then demand docs for every one. And a `part` does not get its own SF: entry in lcov, so the coverage gate's "every lib file must appear" guard has nothing new to miss. The data helpers become top-level functions taking `rows` explicitly, since they only ever read it -- which is also what let them leave the State class. 402 Dart tests still pass; flutter coverage still 100% (2717/2717); dart analyze clean under very_good_analysis. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y --- .../lib/screens/history_screen.dart | 685 +----------------- .../lib/screens/history_screen_charts.dart | 222 ++++++ .../lib/screens/history_screen_data.dart | 137 ++++ .../lib/screens/history_screen_painter.dart | 124 ++++ .../lib/screens/history_screen_tiles.dart | 212 ++++++ 5 files changed, 708 insertions(+), 672 deletions(-) create mode 100644 stronglift_replacement/workout_app/lib/screens/history_screen_charts.dart create mode 100644 stronglift_replacement/workout_app/lib/screens/history_screen_data.dart create mode 100644 stronglift_replacement/workout_app/lib/screens/history_screen_painter.dart create mode 100644 stronglift_replacement/workout_app/lib/screens/history_screen_tiles.dart diff --git a/stronglift_replacement/workout_app/lib/screens/history_screen.dart b/stronglift_replacement/workout_app/lib/screens/history_screen.dart index afc9e06..d1f15ef 100644 --- a/stronglift_replacement/workout_app/lib/screens/history_screen.dart +++ b/stronglift_replacement/workout_app/lib/screens/history_screen.dart @@ -16,6 +16,11 @@ import 'package:workout_app/services/workout_sync_service.dart'; import 'package:workout_app/ui/theme.dart'; import 'package:workout_app/widgets/calendar_widget.dart'; +part 'history_screen_charts.dart'; +part 'history_screen_data.dart'; +part 'history_screen_painter.dart'; +part 'history_screen_tiles.dart'; + const _kTotal = 'Total (all workouts)'; /// Screen showing workout history with per-exercise drill-down and charts. @@ -103,19 +108,6 @@ class _HistoryScreenState extends State { httpClient: widget.httpClient, ).readMergedWorkoutPayloads(); - /// Workouts the PC published that this phone has no local record of. - /// - /// The PC pushes its whole `workout_log.json` (RunnerUp runs and manual - /// entries included), so pulling them here is what makes both devices show - /// the SAME history. Sorted newest-first to match the local session list. - /// StrongLifts sessions are deliberately excluded: they are restored into - /// local history instead, so listing them here too would double them up. - List> _syncedOnly( - List> payloads, - ) => - payloads.where((p) => _kSyncedOnlyKinds.contains(p['kind'])).toList() - ..sort((a, b) => '${b['date']}'.compareTo('${a['date']}')); - Future _pickExercise(String name) async { ExerciseState? state; if (name != _kTotal) { @@ -131,109 +123,6 @@ class _HistoryScreenState extends State { // ── Data helpers ────────────────────────────────────────────────────────── - /// All workout dates (YYYY-MM-DD): local sessions plus synced ones. - /// - /// Synced dates are included so a day you only worked out on the PC (a - /// RunnerUp run) still marks the calendar here. - Set get _allWorkoutDates => { - ..._rows.map((r) => r['date'] as String), - ..._syncedRows.map((r) => '${r['date']}'), - }; - - /// 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? ?? const [])) { - if ((ex as Map)['name'] == name) { - result.add(row['date'] as String); - break; - } - } - } - return result; - } - - /// (date, total volume) per session – tonnage actually lifted, summed as - /// `weight × doneReps` over every logged set. - /// - /// This deliberately uses the per-set actuals rather than - /// `targetWeight × targetSets × targetReps`: with targets, a session where - /// every set failed plotted identically to one completed in full, so the - /// line only moved when progression changed the target. - /// - /// The target-based fallback is defensive only — `ExerciseResult.toJson` - /// has emitted `sets` since the app's first commit, so no row written by - /// this app lacks it. It exists for hand-edited or truncated backup JSON, - /// not for a migration that ever happened. - 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? ?? const [])) { - final m = ex as Map; - final sets = m['sets'] as List?; - if (sets == null || sets.isEmpty) { - 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; - continue; - } - for (final set in sets) { - final sm = set as Map; - // `weight` is a non-nullable double in SetResult and always - // serialized, so there is no targetWeight fallback here — an absent - // value would mean hand-edited JSON, and silently substituting the - // target would overstate a set the user never did. - final w = (sm['weight'] as num?)?.toDouble() ?? 0; - total += w * ((sm['doneReps'] as num?)?.toInt() ?? 0); - } - } - 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? ?? const [])) { - final m = ex as Map; - if (m['name'] == name) { - final date = DateTime.tryParse(row['date'] as String); - 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? ?? const [])) { - 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]; @@ -276,26 +165,15 @@ class _HistoryScreenState extends State { ); } - /// 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() => [ const _SectionLabel('TOTAL VOLUME (2-session rolling avg, kg)'), const SizedBox(height: 6), _WeightChart( - points: _rollingAvg2(_totalVolumePoints()), + points: _rollingAvg2(_totalVolumePoints(_rows)), ), const SizedBox(height: 16), WorkoutCalendar( - workoutDates: _allWorkoutDates, + workoutDates: _allWorkoutDates(_rows, _syncedRows), month: _calendarMonth, onPrevMonth: () => setState(() { _calendarMonth = DateTime( @@ -330,11 +208,11 @@ class _HistoryScreenState extends State { const _SectionLabel('WEIGHT OVER TIME'), const SizedBox(height: 6), _WeightChart( - points: _exerciseWeightPoints(name), + points: _exerciseWeightPoints(_rows, name), ), const SizedBox(height: 16), WorkoutCalendar( - workoutDates: _exerciseDates(name), + workoutDates: _exerciseDates(_rows, name), month: _calendarMonth, onPrevMonth: () => setState(() { _calendarMonth = DateTime( @@ -352,546 +230,9 @@ class _HistoryScreenState extends State { const SizedBox(height: 16), _SectionLabel(name.toUpperCase()), const SizedBox(height: 8), - ..._sessionsForExercise(name).map((s) => _ExerciseSessionTile(session: s)), + ..._sessionsForExercise( + _rows, + name, + ).map((s) => _ExerciseSessionTile(session: s)), ]; } - -// ── Shared sub-widgets ────────────────────────────────────────────────────── - -class _SectionLabel extends StatelessWidget { - const _SectionLabel(this.text); - - final String text; - - @override - Widget build(BuildContext context) { - return Text( - text, - style: TextStyle( - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontSize: AppTextSize.caption, - letterSpacing: 1.3, - ), - ); - } -} - -class _ExercisePicker extends StatelessWidget { - const _ExercisePicker({ - required this.names, - required this.selected, - required this.onChanged, - }); - - final List names; - final String selected; - final ValueChanged onChanged; - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return DropdownButton( - value: selected, - dropdownColor: colorScheme.surfaceContainerHigh, - style: TextStyle(color: colorScheme.onSurface), - underline: const SizedBox(), - isExpanded: true, - items: names - .map( - (n) => DropdownMenuItem( - value: n, - child: Text( - n, - style: TextStyle( - color: n == _kTotal - ? colorScheme.onSurfaceVariant - : colorScheme.onSurface, - fontStyle: n == _kTotal ? FontStyle.italic : FontStyle.normal, - ), - ), - ), - ) - .toList(), - onChanged: (v) { - if (v != null) onChanged(v); - }, - ); - } -} - -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; - - final colorScheme = Theme.of(context).colorScheme; - final status = Theme.of(context).extension()!; - return Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(AppRadius.sm), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '${state.name} — ${state.weight}kg', - style: TextStyle( - color: colorScheme.onSurface, - fontWeight: FontWeight.bold, - fontSize: AppTextSize.label, - ), - ), - const SizedBox(height: 8), - _StreakRow( - icon: Icons.trending_up, - color: status.success, - 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: colorScheme.error, - 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) { - final colorScheme = Theme.of(context).colorScheme; - 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 : colorScheme.surfaceContainerHighest, - ), - ), - ), - const SizedBox(width: 8), - Expanded( - child: Text( - '$leftLabel to $direction $actionLabel', - style: TextStyle( - color: colorScheme.onSurfaceVariant, - fontSize: AppTextSize.caption, - ), - ), - ), - ], - ); - } -} - -class _WeightChart extends StatelessWidget { - const _WeightChart({required this.points}); - - final List<(DateTime, double)> points; - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - if (points.length < 2) { - return Container( - height: 80, - alignment: Alignment.center, - child: Text( - 'Not enough data for chart', - style: TextStyle(color: colorScheme.onSurfaceVariant), - ), - ); - } - return SizedBox( - height: 140, - child: CustomPaint( - painter: _ChartPainter( - points, - lineColor: colorScheme.primary, - labelColor: colorScheme.onSurfaceVariant, - ), - size: Size.infinite, - ), - ); - } -} - -class _ChartPainter extends CustomPainter { - _ChartPainter( - this.points, { - required this.lineColor, - required this.labelColor, - }); - - final List<(DateTime, double)> points; - - // CustomPainter.paint() has no BuildContext, so the caller (which does) - // passes the themed colors in explicitly. - final Color lineColor; - final Color labelColor; - - // 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); - final maxW = points.map((p) => p.$2).reduce(max); - final minMs = points.first.$1.millisecondsSinceEpoch.toDouble(); - final maxMs = points.last.$1.millisecondsSinceEpoch.toDouble(); - final wRange = maxW - minW; - final tRange = maxMs - minMs; - - const plotTop = _topPad; - final plotBottom = size.height - _bottomPad; - const 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 = lineColor - ..strokeWidth = 2 - ..style = PaintingStyle.stroke; - final dotPaint = Paint() - ..color = lineColor - ..style = PaintingStyle.fill; - - final path = Path()..moveTo(xOf(points.first.$1), yOf(points.first.$2)); - for (final p in points.skip(1)) { - 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); - } - - // Y-axis labels - final tp = TextPainter(textDirection: TextDirection.ltr); - void drawText(String text, Offset offset, {double fontSize = 10}) { - tp - ..text = TextSpan( - text: text, - style: TextStyle(color: labelColor, fontSize: fontSize), - ) - ..layout() - ..paint(canvas, offset); - } - - drawText('${maxW.round()}kg', const 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: TextStyle(color: labelColor, 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)); - } - } - - // lineColor/labelColor come from the app's single fixed dark theme, so - // they never actually change at runtime — only points varies. - @override - bool shouldRepaint(_ChartPainter old) => old.points != points; -} - -/// One workout the PC published that this phone has no local session for. -class _SyncedWorkoutTile extends StatelessWidget { - const _SyncedWorkoutTile({required this.payload}); - - final Map payload; - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final kind = '${payload['kind']}'; - final isRun = kind == 'runnerup_verified'; - final label = isRun ? 'Run' : 'Manual'; - final detail = '${payload['source'] ?? ''}'; - - return Container( - margin: const EdgeInsets.only(bottom: 8), - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(AppRadius.sm), - // Neutral — this distinguishes entry TYPE (run vs. manual), not a - // status/judgment, so it doesn't borrow a semantic status color. - border: Border.all(color: colorScheme.outline), - ), - child: Row( - children: [ - Icon( - isRun ? Icons.directions_run : Icons.edit_note, - color: colorScheme.onSurfaceVariant, - size: 20, - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '${payload['date']} · $label', - style: TextStyle( - color: colorScheme.onSurface, - fontWeight: FontWeight.bold, - ), - ), - if (detail.isNotEmpty) - Text( - detail, - style: TextStyle( - color: colorScheme.onSurfaceVariant, - fontSize: AppTextSize.caption, - ), - ), - ], - ), - ), - ], - ), - ); - } -} - -class _AllSessionTile extends StatelessWidget { - const _AllSessionTile({required this.row}); - - final Map row; - - 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 colorScheme = Theme.of(context).colorScheme; - final status = Theme.of(context).extension()!; - 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 statusColor = succeeded ? status.success : colorScheme.error; - - return Container( - margin: const EdgeInsets.only(bottom: 8), - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(AppRadius.sm), - border: Border.all(color: statusColor), - ), - child: Row( - children: [ - Icon( - succeeded ? Icons.check_circle : Icons.cancel, - color: statusColor, - size: 18, - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Workout $type · $date', - style: TextStyle( - color: colorScheme.onSurface, - fontWeight: FontWeight.bold, - ), - ), - Text( - dur, - style: TextStyle( - color: colorScheme.onSurfaceVariant, - fontSize: AppTextSize.caption, - ), - ), - ], - ), - ), - ], - ), - ); - } -} - -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 colorScheme = Theme.of(context).colorScheme; - final status = Theme.of(context).extension()!; - 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(', '); - final statusColor = succeeded ? status.success : colorScheme.error; - final mutedStyle = TextStyle( - color: colorScheme.onSurfaceVariant, - fontSize: AppTextSize.caption, - ); - - return Container( - margin: const EdgeInsets.only(bottom: 8), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(AppRadius.sm), - border: Border.all(color: statusColor), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(top: 2), - child: Icon( - succeeded ? Icons.check_circle : Icons.cancel, - color: statusColor, - size: 18, - ), - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - date, - style: TextStyle( - color: colorScheme.onSurface, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 2), - Text( - '${weight ?? '?'}kg · $doneSets/$targetSets sets' - ' · ${warmupDone ? '⬤ warmup' : '○ no warmup'}', - style: mutedStyle, - ), - if (repsSummary.isNotEmpty) ...[ - const SizedBox(height: 2), - Text('reps: $repsSummary', style: mutedStyle), - ], - const SizedBox(height: 2), - Text('workout: $dur', style: mutedStyle), - ], - ), - ), - ], - ), - ); - } -} diff --git a/stronglift_replacement/workout_app/lib/screens/history_screen_charts.dart b/stronglift_replacement/workout_app/lib/screens/history_screen_charts.dart new file mode 100644 index 0000000..dca23a0 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/screens/history_screen_charts.dart @@ -0,0 +1,222 @@ +// Chart and stat sub-widgets for HistoryScreen. +// +// A `part` rather than its own library: these widgets are private to +// history_screen.dart and stay that way, so the split is a pure move and +// public_member_api_docs does not start demanding docs for them. +part of 'history_screen.dart'; + +// ── Shared sub-widgets ────────────────────────────────────────────────────── + +class _SectionLabel extends StatelessWidget { + const _SectionLabel(this.text); + + final String text; + + @override + Widget build(BuildContext context) { + return Text( + text, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: AppTextSize.caption, + letterSpacing: 1.3, + ), + ); + } +} + +class _ExercisePicker extends StatelessWidget { + const _ExercisePicker({ + required this.names, + required this.selected, + required this.onChanged, + }); + + final List names; + final String selected; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return DropdownButton( + value: selected, + dropdownColor: colorScheme.surfaceContainerHigh, + style: TextStyle(color: colorScheme.onSurface), + underline: const SizedBox(), + isExpanded: true, + items: names + .map( + (n) => DropdownMenuItem( + value: n, + child: Text( + n, + style: TextStyle( + color: n == _kTotal + ? colorScheme.onSurfaceVariant + : colorScheme.onSurface, + fontStyle: n == _kTotal ? FontStyle.italic : FontStyle.normal, + ), + ), + ), + ) + .toList(), + onChanged: (v) { + if (v != null) onChanged(v); + }, + ); + } +} + +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; + + final colorScheme = Theme.of(context).colorScheme; + final status = Theme.of(context).extension()!; + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${state.name} — ${state.weight}kg', + style: TextStyle( + color: colorScheme.onSurface, + fontWeight: FontWeight.bold, + fontSize: AppTextSize.label, + ), + ), + const SizedBox(height: 8), + _StreakRow( + icon: Icons.trending_up, + color: status.success, + 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: colorScheme.error, + 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) { + final colorScheme = Theme.of(context).colorScheme; + 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 : colorScheme.surfaceContainerHighest, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + '$leftLabel to $direction $actionLabel', + style: TextStyle( + color: colorScheme.onSurfaceVariant, + fontSize: AppTextSize.caption, + ), + ), + ), + ], + ); + } +} + +class _WeightChart extends StatelessWidget { + const _WeightChart({required this.points}); + + final List<(DateTime, double)> points; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + if (points.length < 2) { + return Container( + height: 80, + alignment: Alignment.center, + child: Text( + 'Not enough data for chart', + style: TextStyle(color: colorScheme.onSurfaceVariant), + ), + ); + } + return SizedBox( + height: 140, + child: CustomPaint( + painter: _ChartPainter( + points, + lineColor: colorScheme.primary, + labelColor: colorScheme.onSurfaceVariant, + ), + size: Size.infinite, + ), + ); + } +} diff --git a/stronglift_replacement/workout_app/lib/screens/history_screen_data.dart b/stronglift_replacement/workout_app/lib/screens/history_screen_data.dart new file mode 100644 index 0000000..3ef1d01 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/screens/history_screen_data.dart @@ -0,0 +1,137 @@ +// Pure transforms over the loaded history rows. +// +// Top-level rather than methods on the State: they only read `rows`, so +// keeping them off the widget makes the data shaping testable on its own. +// A `part` so they stay library-private -- see history_screen_charts.dart. +part of 'history_screen.dart'; + +Set _exerciseDates(List> rows, 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? ?? const [])) { + if ((ex as Map)['name'] == name) { + result.add(row['date'] as String); + break; + } + } + } + return result; +} + +/// (date, total volume) per session – tonnage actually lifted, summed as +/// `weight × doneReps` over every logged set. +/// +/// This deliberately uses the per-set actuals rather than +/// `targetWeight × targetSets × targetReps`: with targets, a session where +/// every set failed plotted identically to one completed in full, so the +/// line only moved when progression changed the target. +/// +/// The target-based fallback is defensive only — `ExerciseResult.toJson` +/// has emitted `sets` since the app's first commit, so no row written by +/// this app lacks it. It exists for hand-edited or truncated backup JSON, +/// not for a migration that ever happened. +List<(DateTime, double)> _totalVolumePoints(List> rows) { + 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? ?? const [])) { + final m = ex as Map; + final sets = m['sets'] as List?; + if (sets == null || sets.isEmpty) { + 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; + continue; + } + for (final set in sets) { + final sm = set as Map; + // `weight` is a non-nullable double in SetResult and always + // serialized, so there is no targetWeight fallback here — an absent + // value would mean hand-edited JSON, and silently substituting the + // target would overstate a set the user never did. + final w = (sm['weight'] as num?)?.toDouble() ?? 0; + total += w * ((sm['doneReps'] as num?)?.toInt() ?? 0); + } + } + 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( + List> rows, + 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? ?? const [])) { + final m = ex as Map; + if (m['name'] == name) { + final date = DateTime.tryParse(row['date'] as String); + 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( + List> rows, + 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? ?? const [])) { + final m = ex as Map; + if (m['name'] == name) { + result.add({...row, 'exerciseData': m}); + break; + } + } + } + return result; +} + +// ── Build ──────────────────────────────────────────────────────────────── + +/// Rolling average of 2 consecutive points to smooth A/B alternation. +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), + ]; +} + +/// All workout dates (YYYY-MM-DD): local sessions plus synced ones. +/// +/// Synced dates are included so a day you only worked out on the PC (a +/// RunnerUp run) still marks the calendar here. +Set _allWorkoutDates( + List> rows, + List> syncedRows, +) => { + ...rows.map((r) => r['date'] as String), + ...syncedRows.map((r) => '${r['date']}'), +}; + +/// Workouts the PC published that this phone has no local record of. +/// +/// The PC pushes its whole `workout_log.json` (RunnerUp runs and manual +/// entries included), so pulling them here is what makes both devices show +/// the SAME history. Sorted newest-first to match the local session list. +/// StrongLifts sessions are deliberately excluded: they are restored into +/// local history instead, so listing them here too would double them up. +List> _syncedOnly(List> payloads) => + payloads.where((p) => _kSyncedOnlyKinds.contains(p['kind'])).toList() + ..sort((a, b) => '${b['date']}'.compareTo('${a['date']}')); diff --git a/stronglift_replacement/workout_app/lib/screens/history_screen_painter.dart b/stronglift_replacement/workout_app/lib/screens/history_screen_painter.dart new file mode 100644 index 0000000..6ee5bed --- /dev/null +++ b/stronglift_replacement/workout_app/lib/screens/history_screen_painter.dart @@ -0,0 +1,124 @@ +// The CustomPainter that draws the weight/volume line chart. +// +// Separated from the chart *widgets* because painting is the part that +// changes for visual reasons; see history_screen_charts.dart for why +// these are `part` files. +part of 'history_screen.dart'; + + +class _ChartPainter extends CustomPainter { + _ChartPainter( + this.points, { + required this.lineColor, + required this.labelColor, + }); + + final List<(DateTime, double)> points; + + // CustomPainter.paint() has no BuildContext, so the caller (which does) + // passes the themed colors in explicitly. + final Color lineColor; + final Color labelColor; + + // 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); + final maxW = points.map((p) => p.$2).reduce(max); + final minMs = points.first.$1.millisecondsSinceEpoch.toDouble(); + final maxMs = points.last.$1.millisecondsSinceEpoch.toDouble(); + final wRange = maxW - minW; + final tRange = maxMs - minMs; + + const plotTop = _topPad; + final plotBottom = size.height - _bottomPad; + const 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 = lineColor + ..strokeWidth = 2 + ..style = PaintingStyle.stroke; + final dotPaint = Paint() + ..color = lineColor + ..style = PaintingStyle.fill; + + final path = Path()..moveTo(xOf(points.first.$1), yOf(points.first.$2)); + for (final p in points.skip(1)) { + 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); + } + + // Y-axis labels + final tp = TextPainter(textDirection: TextDirection.ltr); + void drawText(String text, Offset offset, {double fontSize = 10}) { + tp + ..text = TextSpan( + text: text, + style: TextStyle(color: labelColor, fontSize: fontSize), + ) + ..layout() + ..paint(canvas, offset); + } + + drawText('${maxW.round()}kg', const 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: TextStyle(color: labelColor, 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)); + } + } + + // lineColor/labelColor come from the app's single fixed dark theme, so + // they never actually change at runtime — only points varies. + @override + bool shouldRepaint(_ChartPainter old) => old.points != points; +} + +/// One workout the PC published that this phone has no local session for. diff --git a/stronglift_replacement/workout_app/lib/screens/history_screen_tiles.dart b/stronglift_replacement/workout_app/lib/screens/history_screen_tiles.dart new file mode 100644 index 0000000..7dae261 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/screens/history_screen_tiles.dart @@ -0,0 +1,212 @@ +// Session list tiles for HistoryScreen. +// +// See history_screen_charts.dart for why these are `part` files. +part of 'history_screen.dart'; + +class _SyncedWorkoutTile extends StatelessWidget { + const _SyncedWorkoutTile({required this.payload}); + + final Map payload; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final kind = '${payload['kind']}'; + final isRun = kind == 'runnerup_verified'; + final label = isRun ? 'Run' : 'Manual'; + final detail = '${payload['source'] ?? ''}'; + + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(AppRadius.sm), + // Neutral — this distinguishes entry TYPE (run vs. manual), not a + // status/judgment, so it doesn't borrow a semantic status color. + border: Border.all(color: colorScheme.outline), + ), + child: Row( + children: [ + Icon( + isRun ? Icons.directions_run : Icons.edit_note, + color: colorScheme.onSurfaceVariant, + size: 20, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${payload['date']} · $label', + style: TextStyle( + color: colorScheme.onSurface, + fontWeight: FontWeight.bold, + ), + ), + if (detail.isNotEmpty) + Text( + detail, + style: TextStyle( + color: colorScheme.onSurfaceVariant, + fontSize: AppTextSize.caption, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _AllSessionTile extends StatelessWidget { + const _AllSessionTile({required this.row}); + + final Map row; + + 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 colorScheme = Theme.of(context).colorScheme; + final status = Theme.of(context).extension()!; + 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 statusColor = succeeded ? status.success : colorScheme.error; + + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all(color: statusColor), + ), + child: Row( + children: [ + Icon( + succeeded ? Icons.check_circle : Icons.cancel, + color: statusColor, + size: 18, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Workout $type · $date', + style: TextStyle( + color: colorScheme.onSurface, + fontWeight: FontWeight.bold, + ), + ), + Text( + dur, + style: TextStyle( + color: colorScheme.onSurfaceVariant, + fontSize: AppTextSize.caption, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +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 colorScheme = Theme.of(context).colorScheme; + final status = Theme.of(context).extension()!; + 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(', '); + final statusColor = succeeded ? status.success : colorScheme.error; + final mutedStyle = TextStyle( + color: colorScheme.onSurfaceVariant, + fontSize: AppTextSize.caption, + ); + + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all(color: statusColor), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon( + succeeded ? Icons.check_circle : Icons.cancel, + color: statusColor, + size: 18, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + date, + style: TextStyle( + color: colorScheme.onSurface, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 2), + Text( + '${weight ?? '?'}kg · $doneSets/$targetSets sets' + ' · ${warmupDone ? '⬤ warmup' : '○ no warmup'}', + style: mutedStyle, + ), + if (repsSummary.isNotEmpty) ...[ + const SizedBox(height: 2), + Text('reps: $repsSummary', style: mutedStyle), + ], + const SizedBox(height: 2), + Text('workout: $dur', style: mutedStyle), + ], + ), + ), + ], + ), + ); + } +}