mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 20:07:16 +01:00
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:
parent
b8cf634fa6
commit
38e8364306
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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']}'));
|
||||
@ -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.
|
||||
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user