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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
This commit is contained in:
Krzysztof Rudnicki 2026-08-15 21:06:09 +02:00
parent b8cf634fa6
commit 38e8364306
5 changed files with 708 additions and 672 deletions

View File

@ -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<HistoryScreen> {
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<Map<String, dynamic>> _syncedOnly(
List<Map<String, dynamic>> payloads,
) =>
payloads.where((p) => _kSyncedOnlyKinds.contains(p['kind'])).toList()
..sort((a, b) => '${b['date']}'.compareTo('${a['date']}'));
Future<void> _pickExercise(String name) async {
ExerciseState? state;
if (name != _kTotal) {
@ -131,109 +123,6 @@ class _HistoryScreenState extends State<HistoryScreen> {
// ── 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<String> get _allWorkoutDates => {
..._rows.map((r) => r['date'] as String),
..._syncedRows.map((r) => '${r['date']}'),
};
/// Dates when the selected exercise appeared.
Set<String> _exerciseDates(String name) {
final result = <String>{};
for (final row in _rows) {
final json = jsonDecode(row['json'] as String) as Map<String, dynamic>;
for (final ex in (json['exercises'] as List? ?? const [])) {
if ((ex as Map<String, dynamic>)['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<String, dynamic>;
double total = 0;
for (final ex in (json['exercises'] as List? ?? const [])) {
final m = ex as Map<String, dynamic>;
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<String, dynamic>;
// `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<String, dynamic>;
for (final ex in (json['exercises'] as List? ?? const [])) {
final m = ex as Map<String, dynamic>;
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<Map<String, dynamic>> _sessionsForExercise(String name) {
final result = <Map<String, dynamic>>[];
for (final row in _rows) {
final json = jsonDecode(row['json'] as String) as Map<String, dynamic>;
for (final ex in (json['exercises'] as List? ?? const [])) {
final m = ex as Map<String, dynamic>;
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<HistoryScreen> {
);
}
/// 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<Widget> _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<HistoryScreen> {
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<HistoryScreen> {
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<String> names;
final String selected;
final ValueChanged<String> onChanged;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return DropdownButton<String>(
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<AppStatusColors>()!;
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<String, dynamic> 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<String, dynamic> 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<AppStatusColors>()!;
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<String, dynamic> 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<AppStatusColors>()!;
final exData = session['exerciseData'] as Map<String, dynamic>;
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<Map<String, dynamic>>() ?? [];
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),
],
),
),
],
),
);
}
}

View File

@ -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<String> names;
final String selected;
final ValueChanged<String> onChanged;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return DropdownButton<String>(
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<AppStatusColors>()!;
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,
),
);
}
}

View File

@ -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<String> _exerciseDates(List<Map<String, dynamic>> rows, String name) {
final result = <String>{};
for (final row in rows) {
final json = jsonDecode(row['json'] as String) as Map<String, dynamic>;
for (final ex in (json['exercises'] as List? ?? const [])) {
if ((ex as Map<String, dynamic>)['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<Map<String, dynamic>> rows) {
final points = <(DateTime, double)>[];
for (final row in rows.reversed) {
final json = jsonDecode(row['json'] as String) as Map<String, dynamic>;
double total = 0;
for (final ex in (json['exercises'] as List? ?? const [])) {
final m = ex as Map<String, dynamic>;
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<String, dynamic>;
// `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<Map<String, dynamic>> rows,
String name,
) {
final points = <(DateTime, double)>[];
for (final row in rows.reversed) {
final json = jsonDecode(row['json'] as String) as Map<String, dynamic>;
for (final ex in (json['exercises'] as List? ?? const [])) {
final m = ex as Map<String, dynamic>;
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<Map<String, dynamic>> _sessionsForExercise(
List<Map<String, dynamic>> rows,
String name,
) {
final result = <Map<String, dynamic>>[];
for (final row in rows) {
final json = jsonDecode(row['json'] as String) as Map<String, dynamic>;
for (final ex in (json['exercises'] as List? ?? const [])) {
final m = ex as Map<String, dynamic>;
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<String> _allWorkoutDates(
List<Map<String, dynamic>> rows,
List<Map<String, dynamic>> 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<Map<String, dynamic>> _syncedOnly(List<Map<String, dynamic>> payloads) =>
payloads.where((p) => _kSyncedOnlyKinds.contains(p['kind'])).toList()
..sort((a, b) => '${b['date']}'.compareTo('${a['date']}'));

View File

@ -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.

View File

@ -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<String, dynamic> 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<String, dynamic> 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<AppStatusColors>()!;
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<String, dynamic> 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<AppStatusColors>()!;
final exData = session['exerciseData'] as Map<String, dynamic>;
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<Map<String, dynamic>>() ?? [];
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),
],
),
),
],
),
);
}
}