From bc602ae867b95ffed34efaf5c2694efe27f0c5f1 Mon Sep 17 00:00:00 2001 From: Eyadmb Date: Sat, 7 Nov 2026 15:07:21 +0300 Subject: [PATCH 1/8] Allow entering custom weight and reps in strength workouts Previously, the weight stepper only incremented/decremented by fixed steps (def.step or 2.5 kg). In gyms with non-standard increments, microplates (1.25 kg / 0.5 kg), or differing dumbbell jumps, users could not select the exact weight lifted. This adds: - Tapping the weight value or the new 'Custom weight' action to open a numeric dialog and input any custom value. - Decimal and unit handling respecting imperial/metric settings via existing helpers. - Tapping the reps value to input custom reps directly. - Clean decimal formatting up to 2 places for fractional weights without trailing zeros. --- lib/ui2/activity/live.dart | 243 ++++++++++++++++++++++++++++++++++--- 1 file changed, 227 insertions(+), 16 deletions(-) diff --git a/lib/ui2/activity/live.dart b/lib/ui2/activity/live.dart index 3aac28114..237f3dddd 100644 --- a/lib/ui2/activity/live.dart +++ b/lib/ui2/activity/live.dart @@ -1694,22 +1694,43 @@ class _LiveStrengthState extends State { l?.activityLiveWeightLabel ?? 'WEIGHT', bodyweight ? (l?.activityLiveBwAbbrev ?? 'BW') : _fmt(kg, u), bodyweight ? '' : (u?.weightUnit ?? 'kg'), - () => setState(() => kg = (kg - stepKg).clamp(0, 500).toDouble()), - () => setState(() => kg = kg + stepKg)), + () => setState(() => kg = _step(kg, -stepKg)), + () => setState(() => kg = _step(kg, stepKg)), + onTapValue: () => _editWeight(c, u)), const SizedBox(height: S.x3), - Pressable( - onTap: () => setState(() => bodyweight = !bodyweight), - child: Text( - bodyweight - ? (l?.activityLiveBodyweightExcludedNote ?? - 'Bodyweight — left out of volume') - : (l?.activityLiveLogAsBodyweight ?? 'Log as bodyweight'), - style: F.cap.copyWith(color: p.on(C.purple))), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (!bodyweight) ...[ + Pressable( + onTap: () => _editWeight(c, u), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(LucideIcons.pencil, size: 13, color: p.on(C.purple)), + const SizedBox(width: S.x1), + Text('Custom weight', + style: F.cap.copyWith(color: p.on(C.purple))), + ]), + ), + const SizedBox(width: S.x3), + Text('·', style: F.cap.copyWith(color: p.ink3)), + const SizedBox(width: S.x3), + ], + Pressable( + onTap: () => setState(() => bodyweight = !bodyweight), + child: Text( + bodyweight + ? (l?.activityLiveBodyweightExcludedNote ?? + 'Bodyweight — left out of volume') + : (l?.activityLiveLogAsBodyweight ?? 'Log as bodyweight'), + style: F.cap.copyWith(color: p.on(C.purple))), + ), + ], ), const SizedBox(height: S.x5), _stepper(c, p, l?.activityLiveRepsLabel ?? 'REPS', '$reps', '', () => setState(() => reps = (reps - 1).clamp(1, 100)), - () => setState(() => reps = reps + 1)), + () => setState(() => reps = reps + 1), + onTapValue: () => _editReps(c)), const SizedBox(height: S.x5), Align( alignment: Alignment.centerLeft, @@ -1785,8 +1806,175 @@ class _LiveStrengthState extends State { ]); } + static double _step(double val, double delta) { + final next = (val + delta).clamp(0.0, 500.0); + return (next * 100).roundToDouble() / 100.0; + } + + Future _editWeight(BuildContext c, UnitsController? u) async { + final imperial = u?.isImperial ?? false; + final currentText = bodyweight ? '' : _fmt(kg, u); + final ctrl = TextEditingController(text: currentText); + final p = P.of(c); + final l = AppLocalizations.of(c); + final unitLabel = u?.weightUnit ?? 'kg'; + + final picked = await showDialog( + context: c, + builder: (dc) => AlertDialog( + backgroundColor: p.card, + title: Text( + l?.activityLiveWeightLabel ?? 'Weight', + style: F.head.copyWith(color: p.ink), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: ctrl, + autofocus: true, + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + style: F.body.copyWith(color: p.ink), + cursorColor: p.on(C.purple), + decoration: InputDecoration( + labelText: + '${l?.activityLiveWeightLabel ?? 'Weight'} ($unitLabel)', + labelStyle: F.over.copyWith(color: p.ink3), + hintText: imperial ? '135' : '60', + hintStyle: F.body.copyWith(color: p.ink3), + suffixText: unitLabel, + suffixStyle: F.body.copyWith(color: p.ink3), + enabledBorder: UnderlineInputBorder( + borderSide: BorderSide(color: p.line), + ), + focusedBorder: UnderlineInputBorder( + borderSide: BorderSide(color: p.on(C.purple)), + ), + ), + onSubmitted: (val) { + final parsed = _parseWeightInput(val, u); + if (parsed != null) Navigator.of(dc).pop(parsed); + }, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dc).pop(), + child: Text( + l?.actionCancel ?? 'Cancel', + style: F.body.copyWith(color: p.ink2), + ), + ), + TextButton( + onPressed: () { + final parsed = _parseWeightInput(ctrl.text, u); + if (parsed != null) { + Navigator.of(dc).pop(parsed); + } + }, + child: Text( + l?.actionDone ?? 'Done', + style: F.body.copyWith( + color: p.on(C.purple), + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ); + ctrl.dispose(); + if (!mounted || picked == null) return; + setState(() { + kg = picked.clamp(0, 500).toDouble(); + bodyweight = false; + }); + } + + double? _parseWeightInput(String text, UnitsController? u) { + final s = text.trim(); + if (s.isEmpty) return null; + if (u != null) { + return u.weightToKg(s); + } + return parseTypedNumber(s); + } + + Future _editReps(BuildContext c) async { + final ctrl = TextEditingController(text: '$reps'); + final p = P.of(c); + final l = AppLocalizations.of(c); + + final picked = await showDialog( + context: c, + builder: (dc) => AlertDialog( + backgroundColor: p.card, + title: Text( + l?.activityLiveRepsLabel ?? 'Reps', + style: F.head.copyWith(color: p.ink), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: ctrl, + autofocus: true, + keyboardType: TextInputType.number, + style: F.body.copyWith(color: p.ink), + cursorColor: p.on(C.purple), + decoration: InputDecoration( + labelText: l?.activityLiveRepsLabel ?? 'Reps', + labelStyle: F.over.copyWith(color: p.ink3), + enabledBorder: UnderlineInputBorder( + borderSide: BorderSide(color: p.line), + ), + focusedBorder: UnderlineInputBorder( + borderSide: BorderSide(color: p.on(C.purple)), + ), + ), + onSubmitted: (val) { + final n = int.tryParse(val.trim()); + if (n != null && n > 0) Navigator.of(dc).pop(n); + }, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dc).pop(), + child: Text( + l?.actionCancel ?? 'Cancel', + style: F.body.copyWith(color: p.ink2), + ), + ), + TextButton( + onPressed: () { + final n = int.tryParse(ctrl.text.trim()); + if (n != null && n > 0) Navigator.of(dc).pop(n); + }, + child: Text( + l?.actionDone ?? 'Done', + style: F.body.copyWith( + color: p.on(C.purple), + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ); + ctrl.dispose(); + if (!mounted || picked == null) return; + setState(() => reps = picked.clamp(1, 200)); + } + Widget _stepper(BuildContext c, P p, String label, String value, - String unit, VoidCallback minus, VoidCallback plus) { + String unit, VoidCallback minus, VoidCallback plus, + {VoidCallback? onTapValue}) { final l = AppLocalizations.of(c); return Column(children: [ Text(label, style: F.over.copyWith(color: p.ink3)), @@ -1794,7 +1982,18 @@ class _LiveStrengthState extends State { Row(children: [ counterButton(p, LucideIcons.minus, p.ink, l?.activityLiveDecrease(label) ?? '$label down', minus), - Expanded(child: bigNum(p, value, unit)), + Expanded( + child: onTapValue == null + ? bigNum(p, value, unit) + : Pressable( + semanticLabel: 'Set $label', + onTap: () { + HapticFeedback.selectionClick(); + onTapValue(); + }, + child: bigNum(p, value, unit), + ), + ), counterButton(p, LucideIcons.plus, p.ink, l?.activityLiveIncrease(label) ?? '$label up', plus), ]), @@ -1840,9 +2039,21 @@ class _LiveStrengthState extends State { /// [d] is always in kg (storage unit); [u] converts + rounds for display /// the same way its edit-field does. Null [u] (a golden, or no unit /// context) shows metric untouched. - String _fmt(double d, [UnitsController? u]) => - u?.weightField(d) ?? - (d == d.roundToDouble() ? d.round().toString() : d.toStringAsFixed(1)); + String _fmt(double d, [UnitsController? u]) { + if (u != null && u.isImperial) { + return u.weightField(d); + } + final v = u != null ? u.weightValue(d) : d; + if (v == v.roundToDouble()) return v.round().toString(); + final fixed1 = double.parse(v.toStringAsFixed(1)); + if ((v - fixed1).abs() < 1e-4) return v.toStringAsFixed(1); + final fixed2 = double.parse(v.toStringAsFixed(2)); + if ((v - fixed2).abs() < 1e-4) { + final s = v.toStringAsFixed(2); + return s.endsWith('0') ? v.toStringAsFixed(1) : s; + } + return v.toStringAsFixed(1); + } } // ══════════════ SWIMMING — laps are counted, not measured ══════════════ From 32418c210dd7780027295ccf1422ad834663e409 Mon Sep 17 00:00:00 2001 From: Eyadmb Date: Sat, 7 Nov 2026 15:17:08 +0300 Subject: [PATCH 2/8] Address review feedback on custom weight and reps entry - Validate input bounds in weight and reps dialogs with inline error display - Preserve fractional pounds and metric microplates in weight display - Step in exact 5 lb plate increments in imperial without conversion drift - Align reps stepper upper bound to 200 - Localize custom weight action across supported locales - Add test coverage for custom weight entry --- lib/l10n/display_text.dart | 10 ++ lib/ui2/activity/live.dart | 298 ++++++++++++++++++++---------------- test/ui2_activity_test.dart | 30 ++++ 3 files changed, 206 insertions(+), 132 deletions(-) diff --git a/lib/l10n/display_text.dart b/lib/l10n/display_text.dart index 5bc7f8c93..e46f649b3 100644 --- a/lib/l10n/display_text.dart +++ b/lib/l10n/display_text.dart @@ -135,6 +135,16 @@ String localizedText(AppLocalizations? l, String text) { "lb" => l.presentationLb, "in" => l.presentationIn, "Weight" => l.journalComposeWeightLabel, + "Custom weight" => switch (l.localeName) { + 'de' => 'Benutzerdefiniertes Gewicht', + 'es' => 'Peso personalizado', + 'fr' => 'Poids personnalisé', + 'hi' => 'कस्टम वज़न', + 'ru' => 'Свой вес', + 'th' => 'น้ำหนักที่กำหนดเอง', + 'zh' => '自定义重量', + _ => 'Custom weight', + }, "Screens before bed" => l.presentationScreensBeforeBed, "Soreness" => l.presentationSoreness, "Alcohol" => l.presentationAlcohol, diff --git a/lib/ui2/activity/live.dart b/lib/ui2/activity/live.dart index 237f3dddd..81ca44a07 100644 --- a/lib/ui2/activity/live.dart +++ b/lib/ui2/activity/live.dart @@ -1694,8 +1694,8 @@ class _LiveStrengthState extends State { l?.activityLiveWeightLabel ?? 'WEIGHT', bodyweight ? (l?.activityLiveBwAbbrev ?? 'BW') : _fmt(kg, u), bodyweight ? '' : (u?.weightUnit ?? 'kg'), - () => setState(() => kg = _step(kg, -stepKg)), - () => setState(() => kg = _step(kg, stepKg)), + () => setState(() => kg = _step(kg, -stepKg, u)), + () => setState(() => kg = _step(kg, stepKg, u)), onTapValue: () => _editWeight(c, u)), const SizedBox(height: S.x3), Row( @@ -1707,7 +1707,7 @@ class _LiveStrengthState extends State { child: Row(mainAxisSize: MainAxisSize.min, children: [ Icon(LucideIcons.pencil, size: 13, color: p.on(C.purple)), const SizedBox(width: S.x1), - Text('Custom weight', + Text(uiText(c, 'Custom weight'), style: F.cap.copyWith(color: p.on(C.purple))), ]), ), @@ -1727,9 +1727,14 @@ class _LiveStrengthState extends State { ], ), const SizedBox(height: S.x5), - _stepper(c, p, l?.activityLiveRepsLabel ?? 'REPS', '$reps', '', - () => setState(() => reps = (reps - 1).clamp(1, 100)), - () => setState(() => reps = reps + 1), + _stepper( + c, + p, + l?.activityLiveRepsLabel ?? 'REPS', + '$reps', + '', + () => setState(() => reps = (reps - 1).clamp(1, 200)), + () => setState(() => reps = (reps + 1).clamp(1, 200)), onTapValue: () => _editReps(c)), const SizedBox(height: S.x5), Align( @@ -1806,9 +1811,15 @@ class _LiveStrengthState extends State { ]); } - static double _step(double val, double delta) { + static double _step(double val, double delta, UnitsController? u) { + if (u != null && u.isImperial) { + final currentLb = u.weightValue(val); + final stepLb = delta > 0 ? 5.0 : -5.0; + final nextLb = (currentLb + stepLb).clamp(0.0, 1100.0); + return u.weightToKg('$nextLb') ?? (val + delta).clamp(0.0, 500.0); + } final next = (val + delta).clamp(0.0, 500.0); - return (next * 100).roundToDouble() / 100.0; + return double.parse(next.toStringAsFixed(2)); } Future _editWeight(BuildContext c, UnitsController? u) async { @@ -1818,78 +1829,100 @@ class _LiveStrengthState extends State { final p = P.of(c); final l = AppLocalizations.of(c); final unitLabel = u?.weightUnit ?? 'kg'; + String? errorText; final picked = await showDialog( context: c, - builder: (dc) => AlertDialog( - backgroundColor: p.card, - title: Text( - l?.activityLiveWeightLabel ?? 'Weight', - style: F.head.copyWith(color: p.ink), - ), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextField( - controller: ctrl, - autofocus: true, - keyboardType: - const TextInputType.numberWithOptions(decimal: true), - style: F.body.copyWith(color: p.ink), - cursorColor: p.on(C.purple), - decoration: InputDecoration( - labelText: - '${l?.activityLiveWeightLabel ?? 'Weight'} ($unitLabel)', - labelStyle: F.over.copyWith(color: p.ink3), - hintText: imperial ? '135' : '60', - hintStyle: F.body.copyWith(color: p.ink3), - suffixText: unitLabel, - suffixStyle: F.body.copyWith(color: p.ink3), - enabledBorder: UnderlineInputBorder( - borderSide: BorderSide(color: p.line), + builder: (dc) => StatefulBuilder( + builder: (ctx, setDialogState) { + void submit() { + final text = ctrl.text.trim(); + if (text.isEmpty) { + setDialogState(() => errorText = 'Enter a value'); + return; + } + if (text == currentText) { + Navigator.of(dc).pop(kg); + return; + } + final parsed = _parseWeightInput(text, u); + if (parsed == null) { + setDialogState(() => errorText = 'Invalid number'); + return; + } + if (parsed < 0 || parsed > 500) { + setDialogState(() => errorText = imperial + ? 'Must be 0–1100 lb' + : 'Must be 0–500 kg'); + return; + } + Navigator.of(dc).pop(parsed); + } + + return AlertDialog( + backgroundColor: p.card, + title: Text( + l?.activityLiveWeightLabel ?? 'Weight', + style: F.head.copyWith(color: p.ink), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: ctrl, + autofocus: true, + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + style: F.body.copyWith(color: p.ink), + cursorColor: p.on(C.purple), + decoration: InputDecoration( + labelText: + '${l?.activityLiveWeightLabel ?? 'Weight'} ($unitLabel)', + labelStyle: F.over.copyWith(color: p.ink3), + hintText: imperial ? '135' : '60', + hintStyle: F.body.copyWith(color: p.ink3), + suffixText: unitLabel, + suffixStyle: F.body.copyWith(color: p.ink3), + errorText: errorText, + enabledBorder: UnderlineInputBorder( + borderSide: BorderSide(color: p.line), + ), + focusedBorder: UnderlineInputBorder( + borderSide: BorderSide(color: p.on(C.purple)), + ), + ), + onSubmitted: (_) => submit(), ), - focusedBorder: UnderlineInputBorder( - borderSide: BorderSide(color: p.on(C.purple)), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dc).pop(), + child: Text( + l?.actionCancel ?? 'Cancel', + style: F.body.copyWith(color: p.ink2), ), ), - onSubmitted: (val) { - final parsed = _parseWeightInput(val, u); - if (parsed != null) Navigator.of(dc).pop(parsed); - }, - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(dc).pop(), - child: Text( - l?.actionCancel ?? 'Cancel', - style: F.body.copyWith(color: p.ink2), - ), - ), - TextButton( - onPressed: () { - final parsed = _parseWeightInput(ctrl.text, u); - if (parsed != null) { - Navigator.of(dc).pop(parsed); - } - }, - child: Text( - l?.actionDone ?? 'Done', - style: F.body.copyWith( - color: p.on(C.purple), - fontWeight: FontWeight.w600, + TextButton( + onPressed: submit, + child: Text( + l?.actionDone ?? 'Done', + style: F.body.copyWith( + color: p.on(C.purple), + fontWeight: FontWeight.w600, + ), + ), ), - ), - ), - ], + ], + ); + }, ), ); ctrl.dispose(); if (!mounted || picked == null) return; setState(() { - kg = picked.clamp(0, 500).toDouble(); + kg = picked; bodyweight = false; }); } @@ -1907,69 +1940,79 @@ class _LiveStrengthState extends State { final ctrl = TextEditingController(text: '$reps'); final p = P.of(c); final l = AppLocalizations.of(c); + String? errorText; final picked = await showDialog( context: c, - builder: (dc) => AlertDialog( - backgroundColor: p.card, - title: Text( - l?.activityLiveRepsLabel ?? 'Reps', - style: F.head.copyWith(color: p.ink), - ), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextField( - controller: ctrl, - autofocus: true, - keyboardType: TextInputType.number, - style: F.body.copyWith(color: p.ink), - cursorColor: p.on(C.purple), - decoration: InputDecoration( - labelText: l?.activityLiveRepsLabel ?? 'Reps', - labelStyle: F.over.copyWith(color: p.ink3), - enabledBorder: UnderlineInputBorder( - borderSide: BorderSide(color: p.line), + builder: (dc) => StatefulBuilder( + builder: (ctx, setDialogState) { + void submit() { + final val = ctrl.text.trim(); + final n = int.tryParse(val); + if (n == null || n < 1 || n > 200) { + setDialogState(() => errorText = 'Must be 1–200 reps'); + return; + } + Navigator.of(dc).pop(n); + } + + return AlertDialog( + backgroundColor: p.card, + title: Text( + l?.activityLiveRepsLabel ?? 'Reps', + style: F.head.copyWith(color: p.ink), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: ctrl, + autofocus: true, + keyboardType: TextInputType.number, + style: F.body.copyWith(color: p.ink), + cursorColor: p.on(C.purple), + decoration: InputDecoration( + labelText: l?.activityLiveRepsLabel ?? 'Reps', + labelStyle: F.over.copyWith(color: p.ink3), + errorText: errorText, + enabledBorder: UnderlineInputBorder( + borderSide: BorderSide(color: p.line), + ), + focusedBorder: UnderlineInputBorder( + borderSide: BorderSide(color: p.on(C.purple)), + ), + ), + onSubmitted: (_) => submit(), ), - focusedBorder: UnderlineInputBorder( - borderSide: BorderSide(color: p.on(C.purple)), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dc).pop(), + child: Text( + l?.actionCancel ?? 'Cancel', + style: F.body.copyWith(color: p.ink2), ), ), - onSubmitted: (val) { - final n = int.tryParse(val.trim()); - if (n != null && n > 0) Navigator.of(dc).pop(n); - }, - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(dc).pop(), - child: Text( - l?.actionCancel ?? 'Cancel', - style: F.body.copyWith(color: p.ink2), - ), - ), - TextButton( - onPressed: () { - final n = int.tryParse(ctrl.text.trim()); - if (n != null && n > 0) Navigator.of(dc).pop(n); - }, - child: Text( - l?.actionDone ?? 'Done', - style: F.body.copyWith( - color: p.on(C.purple), - fontWeight: FontWeight.w600, + TextButton( + onPressed: submit, + child: Text( + l?.actionDone ?? 'Done', + style: F.body.copyWith( + color: p.on(C.purple), + fontWeight: FontWeight.w600, + ), + ), ), - ), - ), - ], + ], + ); + }, ), ); ctrl.dispose(); if (!mounted || picked == null) return; - setState(() => reps = picked.clamp(1, 200)); + setState(() => reps = picked); } Widget _stepper(BuildContext c, P p, String label, String value, @@ -1986,7 +2029,7 @@ class _LiveStrengthState extends State { child: onTapValue == null ? bigNum(p, value, unit) : Pressable( - semanticLabel: 'Set $label', + semanticLabel: label, onTap: () { HapticFeedback.selectionClick(); onTapValue(); @@ -2040,19 +2083,10 @@ class _LiveStrengthState extends State { /// the same way its edit-field does. Null [u] (a golden, or no unit /// context) shows metric untouched. String _fmt(double d, [UnitsController? u]) { - if (u != null && u.isImperial) { - return u.weightField(d); - } final v = u != null ? u.weightValue(d) : d; if (v == v.roundToDouble()) return v.round().toString(); - final fixed1 = double.parse(v.toStringAsFixed(1)); - if ((v - fixed1).abs() < 1e-4) return v.toStringAsFixed(1); - final fixed2 = double.parse(v.toStringAsFixed(2)); - if ((v - fixed2).abs() < 1e-4) { - final s = v.toStringAsFixed(2); - return s.endsWith('0') ? v.toStringAsFixed(1) : s; - } - return v.toStringAsFixed(1); + final s = v.toStringAsFixed(2); + return s.endsWith('0') ? v.toStringAsFixed(1) : s; } } diff --git a/test/ui2_activity_test.dart b/test/ui2_activity_test.dart index c5e4ce5e1..8bb2ad5d7 100644 --- a/test/ui2_activity_test.dart +++ b/test/ui2_activity_test.dart @@ -1892,6 +1892,36 @@ void main() { expect(StrengthLog(sets).volumeKg, 40 * 8); }); + testWidgets('custom weight and reps entry updates set values', + (tester) async { + tester.view.physicalSize = const Size(390 * 3, 2400 * 3); + tester.view.devicePixelRatio = 3; + addTearDown(tester.view.reset); + addTearDown(LiveDraft.clear); + + final activity = activityByName('weight_training')!; + LiveDraft.begin(activity); + LiveDraft.current!.put('exercise_plan', ['bench_press']); + + var sets = []; + await tester.pumpWidget(_frame( + LiveStrength(activity, onSets: (s) => sets = s), + Brightness.light, + 1.0)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('40')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), '47.5'); + await tester.tap(find.text('Done')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Log set')); + await tester.pump(); + expect(sets.single.loadKg, 47.5); + }); + testWidgets('a session that failed to save says so and can retry', (tester) async { tester.view.physicalSize = const Size(390 * 3, 2200 * 3); From 5df9bfd71f42198225a56e141ae9a5306ea4c871 Mon Sep 17 00:00:00 2001 From: Eyadmb Date: Sat, 7 Nov 2026 15:28:58 +0300 Subject: [PATCH 3/8] Localize dialog validation messages and refine weight display rounding - Localize required-value, invalid-number, and range errors via uiText across all supported languages - Base _fmt integer and zero-trimming checks on the two-decimal rounded value to prevent floating-point epsilon artifacts --- lib/l10n/display_text.dart | 50 ++++++++++++++++++++++++++++++++++++++ lib/ui2/activity/live.dart | 15 ++++++------ 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/lib/l10n/display_text.dart b/lib/l10n/display_text.dart index e46f649b3..acb3e96a2 100644 --- a/lib/l10n/display_text.dart +++ b/lib/l10n/display_text.dart @@ -145,6 +145,56 @@ String localizedText(AppLocalizations? l, String text) { 'zh' => '自定义重量', _ => 'Custom weight', }, + "Enter a value" => switch (l.localeName) { + 'de' => 'Wert eingeben', + 'es' => 'Introduce un valor', + 'fr' => 'Saisissez une valeur', + 'hi' => 'एक मान दर्ज करें', + 'ru' => 'Введите значение', + 'th' => 'ป้อนค่า', + 'zh' => '输入数值', + _ => 'Enter a value', + }, + "Invalid number" => switch (l.localeName) { + 'de' => 'Ungültige Zahl', + 'es' => 'Número no válido', + 'fr' => 'Nombre invalide', + 'hi' => 'अमान्य संख्या', + 'ru' => 'Неверное число', + 'th' => 'ตัวเลขไม่ถูกต้อง', + 'zh' => '无效数字', + _ => 'Invalid number', + }, + "Must be 0–500 kg" => switch (l.localeName) { + 'de' => 'Muss zwischen 0 und 500 kg liegen', + 'es' => 'Debe estar entre 0 y 500 kg', + 'fr' => 'Doit être compris entre 0 et 500 kg', + 'hi' => '0–500 kg के बीच होना चाहिए', + 'ru' => 'Должно быть от 0 до 500 кг', + 'th' => 'ต้องอยู่ระหว่าง 0–500 กก.', + 'zh' => '必须在 0–500 千克之间', + _ => 'Must be 0–500 kg', + }, + "Must be 0–1100 lb" => switch (l.localeName) { + 'de' => 'Muss zwischen 0 und 1100 lb liegen', + 'es' => 'Debe estar entre 0 y 1100 lb', + 'fr' => 'Doit être compris entre 0 et 1100 lb', + 'hi' => '0–1100 lb के बीच होना चाहिए', + 'ru' => 'Должно быть от 0 до 1100 фунтов', + 'th' => 'ต้องอยู่ระหว่าง 0–1100 ปอนด์', + 'zh' => '必须在 0–1100 磅之间', + _ => 'Must be 0–1100 lb', + }, + "Must be 1–200 reps" => switch (l.localeName) { + 'de' => 'Muss zwischen 1 und 200 Wdh. liegen', + 'es' => 'Debe estar entre 1 y 200 repeticiones', + 'fr' => 'Doit être compris entre 1 et 200 répétitions', + 'hi' => '1–200 पुनरावृत्तियों के बीच होना चाहिए', + 'ru' => 'Должно быть от 1 до 200 повторений', + 'th' => 'ต้องอยู่ระหว่าง 1–200 ครั้ง', + 'zh' => '必须在 1–200 次之间', + _ => 'Must be 1–200 reps', + }, "Screens before bed" => l.presentationScreensBeforeBed, "Soreness" => l.presentationSoreness, "Alcohol" => l.presentationAlcohol, diff --git a/lib/ui2/activity/live.dart b/lib/ui2/activity/live.dart index 81ca44a07..f1c438891 100644 --- a/lib/ui2/activity/live.dart +++ b/lib/ui2/activity/live.dart @@ -1838,7 +1838,7 @@ class _LiveStrengthState extends State { void submit() { final text = ctrl.text.trim(); if (text.isEmpty) { - setDialogState(() => errorText = 'Enter a value'); + setDialogState(() => errorText = uiText(c, 'Enter a value')); return; } if (text == currentText) { @@ -1847,13 +1847,13 @@ class _LiveStrengthState extends State { } final parsed = _parseWeightInput(text, u); if (parsed == null) { - setDialogState(() => errorText = 'Invalid number'); + setDialogState(() => errorText = uiText(c, 'Invalid number')); return; } if (parsed < 0 || parsed > 500) { setDialogState(() => errorText = imperial - ? 'Must be 0–1100 lb' - : 'Must be 0–500 kg'); + ? uiText(c, 'Must be 0–1100 lb') + : uiText(c, 'Must be 0–500 kg')); return; } Navigator.of(dc).pop(parsed); @@ -1950,7 +1950,7 @@ class _LiveStrengthState extends State { final val = ctrl.text.trim(); final n = int.tryParse(val); if (n == null || n < 1 || n > 200) { - setDialogState(() => errorText = 'Must be 1–200 reps'); + setDialogState(() => errorText = uiText(c, 'Must be 1–200 reps')); return; } Navigator.of(dc).pop(n); @@ -2084,9 +2084,10 @@ class _LiveStrengthState extends State { /// context) shows metric untouched. String _fmt(double d, [UnitsController? u]) { final v = u != null ? u.weightValue(d) : d; - if (v == v.roundToDouble()) return v.round().toString(); final s = v.toStringAsFixed(2); - return s.endsWith('0') ? v.toStringAsFixed(1) : s; + final r = double.parse(s); + if (r == r.roundToDouble()) return r.round().toString(); + return s.endsWith('0') ? r.toStringAsFixed(1) : s; } } From 62d479c300c682c99006c082e816928dcec9604c Mon Sep 17 00:00:00 2001 From: Eyadmb Date: Sun, 8 Nov 2026 18:13:42 +0300 Subject: [PATCH 4/8] Allow editing strength workout sets in live workout and history - Add set editing dialog supporting load, bodyweight toggle, reps adjustment, and deletion - Allow tapping sets or the active rest indicator in live strength workouts to edit or correct logged sets - Allow tapping sets in workout history summaries to update logged sets and recalculate volume - Add LocalDb.replaceStrengthSets to replace sets and prune removed rows in SQLite - Add widget and database schema tests for set editing and replacement --- lib/data/db.dart | 20 ++ lib/l10n/display_text.dart | 50 ++++ lib/ui2/activity/live.dart | 180 +++++++++---- lib/ui2/activity/summary.dart | 385 ++++++++++++++++++++++++++-- lib/ui2/screens/workout_screen.dart | 39 ++- test/db_strength_schema_test.dart | 45 ++++ test/ui2_activity_test.dart | 74 ++++++ 7 files changed, 703 insertions(+), 90 deletions(-) diff --git a/lib/data/db.dart b/lib/data/db.dart index c3ab9f1f6..bb6b99a8b 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -5594,6 +5594,26 @@ class LocalDb { }); } + /// Replace the sets of one strength session, in log order. Deletes any + /// prior rows for this session before inserting the new list. + static Future replaceStrengthSets( + String sessionId, + List> sets, + ) async { + final db = await instance; + await db.transaction((txn) async { + await txn.delete('strength_set', + where: 'session_id = ?', whereArgs: [sessionId]); + for (var i = 0; i < sets.length; i++) { + await txn.insert('strength_set', { + ...sets[i], + 'session_id': sessionId, + 'seq': i, + }); + } + }); + } + static Future>> strengthSets( String sessionId, ) async { diff --git a/lib/l10n/display_text.dart b/lib/l10n/display_text.dart index acb3e96a2..4c64358da 100644 --- a/lib/l10n/display_text.dart +++ b/lib/l10n/display_text.dart @@ -195,6 +195,56 @@ String localizedText(AppLocalizations? l, String text) { 'zh' => '必须在 1–200 次之间', _ => 'Must be 1–200 reps', }, + "Edit set" => switch (l.localeName) { + 'de' => 'Satz bearbeiten', + 'es' => 'Editar serie', + 'fr' => 'Modifier la série', + 'hi' => 'सेट संपादित करें', + 'ru' => 'Редактировать подход', + 'th' => 'แก้ไขเซ็ต', + 'zh' => '编辑组', + _ => 'Edit set', + }, + "Delete set" => switch (l.localeName) { + 'de' => 'Satz löschen', + 'es' => 'Eliminar serie', + 'fr' => 'Supprimer la série', + 'hi' => 'सेट हटाएं', + 'ru' => 'Удалить подход', + 'th' => 'ลบเซ็ต', + 'zh' => '删除组', + _ => 'Delete set', + }, + "Delete this set?" => switch (l.localeName) { + 'de' => 'Diesen Satz löschen?', + 'es' => '¿Eliminar esta serie?', + 'fr' => 'Supprimer cette série ?', + 'hi' => 'क्या यह सेट हटाना चाहते हैं?', + 'ru' => 'Удалить этот подход?', + 'th' => 'ลบเซ็ตนี้หรือไม่?', + 'zh' => '删除此组?', + _ => 'Delete this set?', + }, + "Bodyweight" => switch (l.localeName) { + 'de' => 'Körpergewicht', + 'es' => 'Peso corporal', + 'fr' => 'Poids du corps', + 'hi' => 'शारीरिक वज़न', + 'ru' => 'Свой вес', + 'th' => 'น้ำหนักตัว', + 'zh' => '自身体重', + _ => 'Bodyweight', + }, + "Delete" => switch (l.localeName) { + 'de' => 'Löschen', + 'es' => 'Eliminar', + 'fr' => 'Supprimer', + 'hi' => 'हटाएं', + 'ru' => 'Удалить', + 'th' => 'ลบ', + 'zh' => '删除', + _ => 'Delete', + }, "Screens before bed" => l.presentationScreensBeforeBed, "Soreness" => l.presentationSoreness, "Alcohol" => l.presentationAlcohol, diff --git a/lib/ui2/activity/live.dart b/lib/ui2/activity/live.dart index f1c438891..21f3d9b6d 100644 --- a/lib/ui2/activity/live.dart +++ b/lib/ui2/activity/live.dart @@ -1621,43 +1621,49 @@ class _LiveStrengthState extends State { pad: const EdgeInsets.symmetric(horizontal: S.x4), child: Column(children: [ for (var i = 0; i < setsHere.length; i++) ...[ - Padding( - padding: const EdgeInsets.symmetric(vertical: S.x3), - child: Row(children: [ - Container( - width: 24, - height: 24, - alignment: Alignment.center, - decoration: BoxDecoration( - color: p.wash(C.purple), borderRadius: R.rSm), - child: Text('${i + 1}', - style: F.over.copyWith(color: p.on(C.purple))), - ), - const SizedBox(width: S.x3), - Expanded( - child: Text( + Pressable( + onTap: () => _editLoggedSet(c, setsHere[i], u, setNumber: i + 1), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: S.x3), + child: Row(children: [ + Container( + width: 24, + height: 24, + alignment: Alignment.center, + decoration: BoxDecoration( + color: p.wash(C.purple), borderRadius: R.rSm), + child: Text('${i + 1}', + style: F.over.copyWith(color: p.on(C.purple))), + ), + const SizedBox(width: S.x3), + Expanded( + child: Text( setsHere[i].loadKg == null ? (l?.activityLiveRepsBodyweightRow( setsHere[i].reps) ?? '${setsHere[i].reps} reps · bodyweight') - : '${_fmt(setsHere[i].loadKg!, u)} ' - '${uiText(c, u?.weightUnit ?? 'kg')} × ' - '${setsHere[i].reps}', - style: F.body.copyWith(color: p.ink), - ), - ), - if (setsHere[i].rpe != null) - Text('RPE ${setsHere[i].rpe}', - style: F.cap.copyWith(color: p.ink3)), - if (setsHere[i].volume != null) ...[ - const SizedBox(width: S.x3), - Text( - '${grouped(u?.weightValue(setsHere[i].volume!) ?? setsHere[i].volume!)} ' - '${uiText(c, u?.weightUnit ?? 'kg')}', + : '${_fmt(setsHere[i].loadKg!, u)} ' + '${uiText(c, u?.weightUnit ?? 'kg')} × ' + '${setsHere[i].reps}', + style: F.body.copyWith(color: p.ink), + ), + ), + if (setsHere[i].rpe != null) + Text('RPE ${setsHere[i].rpe}', + style: F.cap.copyWith(color: p.ink3)), + if (setsHere[i].volume != null) ...[ + const SizedBox(width: S.x3), + Text( + '${grouped(u?.weightValue(setsHere[i].volume!) ?? setsHere[i].volume!)} ' + '${uiText(c, u?.weightUnit ?? 'kg')}', style: F.cap.copyWith( - color: p.ink2, fontWeight: FontWeight.w600)), - ], - ]), + color: p.ink2, fontWeight: FontWeight.w600), + ), + ], + const SizedBox(width: S.x2), + Icon(LucideIcons.pencil, size: 13, color: p.ink3), + ]), + ), ), if (i < setsHere.length - 1) Divider(color: p.line, height: 1), ], @@ -1787,30 +1793,104 @@ class _LiveStrengthState extends State { Column(mainAxisSize: MainAxisSize.min, children: [ Text(clock(restLeft.value), style: F.n34.copyWith(color: p.ink)), if (logged.isNotEmpty) - Text( - logged.last.loadKg == null - ? (l?.activityLiveRepsLoggedBodyweight( - logged.last.reps) ?? - '${logged.last.reps} reps logged') - : (u?.isImperial == true - ? (l?.presentationWeightRepsLogged( - _fmt(logged.last.loadKg!, u), - uiText(c, u!.weightUnit), - logged.last.reps, - ) ?? - '${_fmt(logged.last.loadKg!, u)} ${u!.weightUnit} × ${logged.last.reps} logged') - : (l?.activityLiveWeightRepsLogged( - _fmt(logged.last.loadKg!), - logged.last.reps) ?? - '${_fmt(logged.last.loadKg!)} kg × ' - '${logged.last.reps} logged')), - style: F.cap.copyWith(color: p.ink3)), + Pressable( + onTap: () => _editLoggedSet(c, logged.last, u, + setNumber: setsHere.length), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: S.x2, vertical: S.x1), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + logged.last.loadKg == null + ? (l?.activityLiveRepsLoggedBodyweight( + logged.last.reps) ?? + '${logged.last.reps} reps logged') + : (u?.isImperial == true + ? (l?.presentationWeightRepsLogged( + _fmt(logged.last.loadKg!, u), + uiText(c, u!.weightUnit), + logged.last.reps, + ) ?? + '${_fmt(logged.last.loadKg!, u)} ${u!.weightUnit} × ${logged.last.reps} logged') + : (l?.activityLiveWeightRepsLogged( + _fmt(logged.last.loadKg!), + logged.last.reps) ?? + '${_fmt(logged.last.loadKg!)} kg × ' + '${logged.last.reps} logged')), + style: F.cap.copyWith(color: p.ink3), + ), + ), + const SizedBox(width: S.x1), + Icon(LucideIcons.pencil, size: 12, color: p.on(C.teal)), + ], + ), + ), + ), ]), ]), ), + const SizedBox(height: S.x3), + if (logged.isNotEmpty) + Pressable( + onTap: () => _editLoggedSet(c, logged.last, u, + setNumber: setsHere.length), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(LucideIcons.pencil, size: 13, color: p.on(C.teal)), + const SizedBox(width: S.x1), + Text( + uiText(c, 'Edit set'), + style: F.cap.copyWith( + color: p.on(C.teal), + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), ]); } + Future _editLoggedSet( + BuildContext c, LoggedSet targetSet, UnitsController? u, + {int setNumber = 1}) async { + final res = await showEditSetDialog( + c, + set: targetSet, + setIndex: setNumber, + units: u, + ); + if (!mounted || res == null) return; + setState(() { + if (res.deleted) { + logged.remove(targetSet); + } else { + final idx = logged.indexOf(targetSet); + if (idx >= 0) { + logged[idx] = targetSet.copyWith( + loadKg: res.loadKg, + clearLoadKg: res.loadKg == null, + reps: res.reps, + ); + } + if (targetSet == logged.lastOrNull) { + if (res.loadKg != null) { + kg = res.loadKg!; + bodyweight = false; + } else { + bodyweight = true; + } + reps = res.reps; + } + } + }); + _persist(); + } + static double _step(double val, double delta, UnitsController? u) { if (u != null && u.isImperial) { final currentLb = u.weightValue(val); diff --git a/lib/ui2/activity/summary.dart b/lib/ui2/activity/summary.dart index 80618f8e2..691bacdd0 100644 --- a/lib/ui2/activity/summary.dart +++ b/lib/ui2/activity/summary.dart @@ -126,6 +126,25 @@ class LoggedSet { const LoggedSet(this.exerciseKey, this.reps, {this.loadKg, this.rpe, this.restSec, required this.at}); + LoggedSet copyWith({ + String? exerciseKey, + int? reps, + double? loadKg, + bool clearLoadKg = false, + int? rpe, + bool clearRpe = false, + int? restSec, + DateTime? at, + }) => + LoggedSet( + exerciseKey ?? this.exerciseKey, + reps ?? this.reps, + loadKg: clearLoadKg ? null : (loadKg ?? this.loadKg), + rpe: clearRpe ? null : (rpe ?? this.rpe), + restSec: restSec ?? this.restSec, + at: at ?? this.at, + ); + /// Volume for this set, or null when the load was never recorded. double? get volume => loadKg == null ? null : loadKg! * reps; } @@ -194,6 +213,292 @@ class StrengthLog { [for (final s in sets) if (s.exerciseKey == key) s]; } +/// Convert logged strength sets to database rows for storage. +List> strengthSetsToRows(List sets) { + final perExercise = {}; + return [ + for (final s in sets) + { + 'exercise_key': s.exerciseKey, + 'set_index': + perExercise[s.exerciseKey] = (perExercise[s.exerciseKey] ?? 0) + 1, + 'reps': s.reps, + 'load_kg': s.loadKg, + 'rpe': s.rpe, + 'rest_sec': s.restSec, + 'at_ts': s.at.millisecondsSinceEpoch ~/ 1000, + }, + ]; +} + +class EditSetResult { + final double? loadKg; + final int reps; + final bool deleted; + + const EditSetResult({this.loadKg, required this.reps, this.deleted = false}); + const EditSetResult.delete() : loadKg = null, reps = 0, deleted = true; +} + +String formatStrengthWeight(double v, [UnitsController? u]) { + final displayVal = u != null ? u.weightValue(v) : v; + final rounded = double.parse(displayVal.toStringAsFixed(2)); + if (rounded == rounded.roundToDouble()) { + return '${rounded.round()}'; + } + return rounded + .toStringAsFixed(2) + .replaceFirst(RegExp(r'0+$'), '') + .replaceFirst(RegExp(r'\.$'), ''); +} + +Future showEditSetDialog( + BuildContext context, { + required LoggedSet set, + required int setIndex, + UnitsController? units, +}) async { + final p = P.of(context); + final l = AppLocalizations.of(context); + final imperial = units?.isImperial ?? false; + final unitLabel = units?.weightUnit ?? 'kg'; + + var isBodyweight = set.loadKg == null; + final weightCtrl = TextEditingController( + text: set.loadKg != null ? formatStrengthWeight(set.loadKg!, units) : '', + ); + final repsCtrl = TextEditingController(text: '${set.reps}'); + String? weightError; + String? repsError; + + final result = await showDialog( + context: context, + builder: (dc) => StatefulBuilder( + builder: (ctx, setDialogState) { + void submit() { + setDialogState(() { + weightError = null; + repsError = null; + }); + + double? parsedKg; + if (!isBodyweight) { + final wText = weightCtrl.text.trim(); + if (wText.isEmpty) { + setDialogState( + () => weightError = uiText(context, 'Enter a value')); + return; + } + parsedKg = units != null + ? units.weightToKg(wText) + : parseTypedNumber(wText); + if (parsedKg == null) { + setDialogState( + () => weightError = uiText(context, 'Invalid number')); + return; + } + if (parsedKg < 0 || parsedKg > 500) { + setDialogState(() => weightError = imperial + ? uiText(context, 'Must be 0–1100 lb') + : uiText(context, 'Must be 0–500 kg')); + return; + } + } + + final rText = repsCtrl.text.trim(); + final parsedReps = int.tryParse(rText); + if (parsedReps == null || parsedReps < 1 || parsedReps > 200) { + setDialogState( + () => repsError = uiText(context, 'Must be 1–200 reps')); + return; + } + + Navigator.of(dc).pop(EditSetResult( + loadKg: isBodyweight ? null : parsedKg, + reps: parsedReps, + )); + } + + return AlertDialog( + backgroundColor: p.card, + title: Text( + uiText(context, 'Edit set'), + style: F.head.copyWith(color: p.ink), + ), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!isBodyweight) ...[ + TextField( + controller: weightCtrl, + autofocus: true, + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + style: F.body.copyWith(color: p.ink), + cursorColor: p.on(C.purple), + decoration: InputDecoration( + labelText: + '${l?.activityLiveWeightLabel ?? 'Weight'} ($unitLabel)', + labelStyle: F.over.copyWith(color: p.ink3), + suffixText: unitLabel, + suffixStyle: F.body.copyWith(color: p.ink3), + errorText: weightError, + enabledBorder: UnderlineInputBorder( + borderSide: BorderSide(color: p.line), + ), + focusedBorder: UnderlineInputBorder( + borderSide: BorderSide(color: p.on(C.purple)), + ), + ), + onSubmitted: (_) => submit(), + ), + const SizedBox(height: S.x3), + ], + Pressable( + onTap: () { + setDialogState(() { + isBodyweight = !isBodyweight; + weightError = null; + if (!isBodyweight && weightCtrl.text.isEmpty) { + weightCtrl.text = set.loadKg != null + ? formatStrengthWeight(set.loadKg!, units) + : (imperial ? '135' : '60'); + } + }); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isBodyweight + ? LucideIcons.checkSquare + : LucideIcons.square, + size: 16, + color: isBodyweight ? p.on(C.purple) : p.ink3, + ), + const SizedBox(width: S.x2), + Text( + uiText(context, 'Bodyweight'), + style: F.body.copyWith( + color: isBodyweight ? p.ink : p.ink2, + ), + ), + ], + ), + ), + const SizedBox(height: S.x4), + TextField( + controller: repsCtrl, + keyboardType: TextInputType.number, + style: F.body.copyWith(color: p.ink), + cursorColor: p.on(C.purple), + decoration: InputDecoration( + labelText: l?.activityLiveRepsLabel ?? 'Reps', + labelStyle: F.over.copyWith(color: p.ink3), + errorText: repsError, + enabledBorder: UnderlineInputBorder( + borderSide: BorderSide(color: p.line), + ), + focusedBorder: UnderlineInputBorder( + borderSide: BorderSide(color: p.on(C.purple)), + ), + ), + onSubmitted: (_) => submit(), + ), + ], + ), + ), + actions: [ + Row( + children: [ + Pressable( + onTap: () async { + final confirm = await showDialog( + context: ctx, + builder: (confCtx) => AlertDialog( + backgroundColor: p.card, + title: Text( + uiText(context, 'Delete set'), + style: F.head.copyWith(color: p.ink), + ), + content: Text( + uiText(context, 'Delete this set?'), + style: F.body.copyWith(color: p.ink2), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(confCtx).pop(false), + child: Text( + l?.actionCancel ?? 'Cancel', + style: F.body.copyWith(color: p.ink2), + ), + ), + TextButton( + onPressed: () => Navigator.of(confCtx).pop(true), + child: Text( + uiText(context, 'Delete'), + style: F.body.copyWith( + color: p.on(C.red), + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ); + if (confirm == true) { + Navigator.of(dc).pop(const EditSetResult.delete()); + } + }, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: S.x2, vertical: S.x2), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(LucideIcons.trash2, size: 16, color: p.on(C.red)), + const SizedBox(width: S.x1), + Text( + uiText(context, 'Delete'), + style: F.body.copyWith(color: p.on(C.red)), + ), + ], + ), + ), + ), + const Spacer(), + TextButton( + onPressed: () => Navigator.of(dc).pop(), + child: Text( + l?.actionCancel ?? 'Cancel', + style: F.body.copyWith(color: p.ink2), + ), + ), + TextButton( + onPressed: submit, + child: Text( + l?.actionSave ?? 'Save', + style: F.body.copyWith( + color: p.on(C.purple), + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ], + ); + }, + ), + ); + + weightCtrl.dispose(); + repsCtrl.dispose(); + return result; +} + /// Epley 1RM estimate — ALWAYS an estimate, never shown without saying so. /// Epley B. (1985), Poundage Chart. Boone's/Brzycki disagree by a few kg; at /// six reps or fewer they are all within noise of each other. @@ -2177,22 +2482,64 @@ class _ActivitySummaryState extends State { } } + Future _editSet(LoggedSet s, int n) async { + final res = await showEditSetDialog( + context, + set: s, + setIndex: n, + units: _u, + ); + if (!mounted || res == null) return; + final updated = List.of(r.strength.sets); + final idx = updated.indexWhere((x) => + identical(x, s) || + (x.at == s.at && + x.exerciseKey == s.exerciseKey && + x.reps == s.reps && + x.loadKg == s.loadKg)); + if (idx < 0) return; + if (res.deleted) { + updated.removeAt(idx); + } else { + updated[idx] = s.copyWith( + loadKg: res.loadKg, + clearLoadKg: res.loadKg == null, + reps: res.reps, + ); + } + setState(() => _rated = r.copyWith(strength: StrengthLog(updated))); + + final sid = r.sessionId; + if (sid != null) { + try { + await LocalDb.replaceStrengthSets( + sid, + strengthSetsToRows(updated), + ); + } catch (_) { + // Ignored; local state already updated + } + } + } + Widget _setRow(P p, int n, LoggedSet s) { final l = AppLocalizations.of(context); - return Padding( - padding: const EdgeInsets.symmetric(vertical: S.x3), - child: Row(children: [ - Container( - width: 24, - height: 24, - alignment: Alignment.center, - decoration: - BoxDecoration(color: p.wash(C.purple), borderRadius: R.rSm), - child: Text('$n', style: F.over.copyWith(color: p.on(C.purple))), - ), - const SizedBox(width: S.x3), - Expanded( - child: Text( + return Pressable( + onTap: () => _editSet(s, n), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: S.x3), + child: Row(children: [ + Container( + width: 24, + height: 24, + alignment: Alignment.center, + decoration: + BoxDecoration(color: p.wash(C.purple), borderRadius: R.rSm), + child: Text('$n', style: F.over.copyWith(color: p.on(C.purple))), + ), + const SizedBox(width: S.x3), + Expanded( + child: Text( s.loadKg == null ? (l?.activitySummaryBodyweightReps(s.reps) ?? '${s.reps} reps · bodyweight') @@ -2200,9 +2547,9 @@ class _ActivitySummaryState extends State { style: F.body.copyWith(color: p.ink), ), ), - if (s.rpe != null) - Text(l?.activitySummaryRpeValue(s.rpe!) ?? 'RPE ${s.rpe}', - style: F.cap.copyWith(color: p.ink3)), + if (s.rpe != null) + Text(l?.activitySummaryRpeValue(s.rpe!) ?? 'RPE ${s.rpe}', + style: F.cap.copyWith(color: p.ink3)), if (s.volume != null) ...[ const SizedBox(width: S.x3), Text( @@ -2212,7 +2559,9 @@ class _ActivitySummaryState extends State { .copyWith(color: p.ink2, fontWeight: FontWeight.w600), ), ], - ], + const SizedBox(width: S.x2), + Icon(LucideIcons.pencil, size: 13, color: p.ink3), + ]), ), ); } diff --git a/lib/ui2/screens/workout_screen.dart b/lib/ui2/screens/workout_screen.dart index ec0c8308e..e51440f43 100644 --- a/lib/ui2/screens/workout_screen.dart +++ b/lib/ui2/screens/workout_screen.dart @@ -653,6 +653,9 @@ class _WorkoutScreenState extends State with RevisionReload { for (final w in d.workouts) ...[ _HistoryRow(w, weightKg: d.weightKg, + onChanged: () { + if (mounted) reload(); + }, onDelete: w.id.isEmpty ? null : () => _confirmDeleteWorkout(c, w), @@ -1027,14 +1030,22 @@ class _HistoryRow extends StatelessWidget { /// An imported row's tap: score its window from the band (#325). final VoidCallback? onScore; + /// Called after returning from the session's summary in case it changed. + final VoidCallback? onChanged; + const _HistoryRow(this.w, - {this.weightKg, this.onRetime, this.onDelete, this.onScore}); + {this.weightKg, + this.onRetime, + this.onDelete, + this.onScore, + this.onChanged}); Future _open(BuildContext c) async { final nav = Navigator.of(c); final r = await _detailOf(c.read(), w); await nav.push(MaterialPageRoute( builder: (_) => ActivitySummary(r, weightKg: weightKg))); + onChanged?.call(); } @override @@ -1377,31 +1388,17 @@ Future _startSession(AppState app, Activity a) async { /// `strength_set` rows for one log. `set_index` counts per exercise; `seq` is /// the position in the whole session and is [LocalDb.saveStrengthSets]'s own /// key, which is why re-sending the same log is idempotent. -List> _setRows(List sets) { - final perExercise = {}; - return [ - for (final s in sets) - { - 'exercise_key': s.exerciseKey, - 'set_index': - perExercise[s.exerciseKey] = (perExercise[s.exerciseKey] ?? 0) + 1, - 'reps': s.reps, - 'load_kg': s.loadKg, - 'rpe': s.rpe, - 'rest_sec': s.restSec, - 'at_ts': s.at.millisecondsSinceEpoch ~/ 1000, - }, - ]; -} +List> _setRows(List sets) => + strengthSetsToRows(sets); /// Bank the sets logged so far against the OPEN session. Called on every set, /// because a typed set is the one thing in a session no sensor can reproduce /// and it used to live in widget state until the user pressed stop. Future _bankSets(AppState app, List sets) async { final id = app.activeWorkout?.workoutId; - if (id == null || sets.isEmpty) return; + if (id == null) return; try { - await LocalDb.saveStrengthSets(id, _setRows(sets)); + await LocalDb.replaceStrengthSets(id, _setRows(sets)); } catch (_) { // The draft still holds them, and finish writes the whole log again. } @@ -1432,9 +1429,7 @@ Future _finishSession( // Written on every set already; written again here because the last one // may have landed while the app was being torn down. - if (draft.strength.sets.isNotEmpty) { - await LocalDb.saveStrengthSets(id, _setRows(draft.strength.sets)); - } + await LocalDb.replaceStrengthSets(id, _setRows(draft.strength.sets)); // The GPS tail is flushed by stopWorkout before it returns, so the route // read here is the whole route. diff --git a/test/db_strength_schema_test.dart b/test/db_strength_schema_test.dart index 0886eb681..637c3d32d 100644 --- a/test/db_strength_schema_test.dart +++ b/test/db_strength_schema_test.dart @@ -250,4 +250,49 @@ void main() { await LocalDb.setSessionPrivate('sess-priv', false); expect((await LocalDb.session('sess-priv'))!['private'], 0); }); + + test('replaceStrengthSets replaces all sets and prunes removed rows', + () async { + created.add('strength_replace.db'); + await databaseFactory.deleteDatabase(await _dbPath('strength_replace.db')); + await _openThroughLocalDb('strength_replace.db'); + + await LocalDb.replaceStrengthSets('sess-r', [ + { + 'exercise_key': 'bench_press', + 'set_index': 1, + 'reps': 10, + 'load_kg': 60.0, + 'at_ts': 1770000000, + }, + { + 'exercise_key': 'bench_press', + 'set_index': 2, + 'reps': 8, + 'load_kg': 70.0, + 'at_ts': 1770000100, + }, + ]); + + var sets = await LocalDb.strengthSets('sess-r'); + expect(sets, hasLength(2)); + expect(sets[0]['load_kg'], 60.0); + expect(sets[1]['load_kg'], 70.0); + + // Replace with a single updated set + await LocalDb.replaceStrengthSets('sess-r', [ + { + 'exercise_key': 'bench_press', + 'set_index': 1, + 'reps': 12, + 'load_kg': 65.0, + 'at_ts': 1770000000, + }, + ]); + + sets = await LocalDb.strengthSets('sess-r'); + expect(sets, hasLength(1)); + expect(sets[0]['reps'], 12); + expect(sets[0]['load_kg'], 65.0); + }); } diff --git a/test/ui2_activity_test.dart b/test/ui2_activity_test.dart index 8bb2ad5d7..1b1c2f2aa 100644 --- a/test/ui2_activity_test.dart +++ b/test/ui2_activity_test.dart @@ -1922,6 +1922,80 @@ void main() { expect(sets.single.loadKg, 47.5); }); + testWidgets('editing a logged set during rest updates the set and draft', + (tester) async { + tester.view.physicalSize = const Size(390 * 3, 2400 * 3); + tester.view.devicePixelRatio = 3; + addTearDown(tester.view.reset); + addTearDown(LiveDraft.clear); + + final activity = activityByName('weight_training')!; + LiveDraft.begin(activity); + LiveDraft.current!.put('exercise_plan', ['bench_press']); + + var sets = []; + await tester.pumpWidget(_frame( + LiveStrength(activity, onSets: (s) => sets = s), + Brightness.light, + 1.0)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Log set')); + await tester.pump(); + expect(sets.length, 1); + expect(sets.single.loadKg, 40.0); + expect(sets.single.reps, 8); + + await tester.tap(find.text('Edit set')); + await tester.pumpAndSettle(); + + final textFields = find.byType(TextField); + await tester.enterText(textFields.first, '55'); + await tester.enterText(textFields.last, '10'); + + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(sets.single.loadKg, 55.0); + expect(sets.single.reps, 10); + }); + + testWidgets('editing a set in ActivitySummary updates strength log', + (tester) async { + tester.view.physicalSize = const Size(390 * 3, 2400 * 3); + tester.view.devicePixelRatio = 3; + addTearDown(tester.view.reset); + + final initialSets = [ + LoggedSet('bench_press', 8, loadKg: 50.0, at: DateTime.now()), + ]; + final r = _result(Arch.strength).copyWith( + strength: StrengthLog(initialSets), + ); + + await tester.pumpWidget(_frame( + ActivitySummary(r, weightKg: 75.0), + Brightness.light, + 1.0, + )); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Splits')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('50 kg × 8')); + await tester.pumpAndSettle(); + + final textFields = find.byType(TextField); + await tester.enterText(textFields.first, '57.5'); + await tester.enterText(textFields.last, '9'); + + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(find.text('57.5 kg × 9'), findsOneWidget); + }); + testWidgets('a session that failed to save says so and can retry', (tester) async { tester.view.physicalSize = const Size(390 * 3, 2200 * 3); From 169a58beacfa6a4925bfbefcec0f13b5495de435 Mon Sep 17 00:00:00 2001 From: Eyadmb Date: Sun, 8 Nov 2026 18:28:07 +0300 Subject: [PATCH 5/8] Preserve hold and note set metadata, avoid imperial drift on untouched weight, and refine dialog actions layout --- lib/ui2/activity/live.dart | 4 ++ lib/ui2/activity/summary.dart | 73 +++++++++++++++++++---------- lib/ui2/screens/workout_screen.dart | 2 + 3 files changed, 55 insertions(+), 24 deletions(-) diff --git a/lib/ui2/activity/live.dart b/lib/ui2/activity/live.dart index 21f3d9b6d..4a479a2ea 100644 --- a/lib/ui2/activity/live.dart +++ b/lib/ui2/activity/live.dart @@ -1237,6 +1237,8 @@ class _LiveStrengthState extends State { loadKg: (e['kg'] as num?)?.toDouble(), rpe: (e['rpe'] as num?)?.toInt(), restSec: (e['rest'] as num?)?.toInt(), + holdSec: (e['hold'] as num?)?.toInt(), + note: (e['note'] as String?) ?? '', at: DateTime.fromMillisecondsSinceEpoch( (e['at'] as num?)?.toInt() ?? 0), )); @@ -1265,6 +1267,8 @@ class _LiveStrengthState extends State { 'kg': s.loadKg, 'rpe': s.rpe, 'rest': s.restSec, + 'hold': s.holdSec, + 'note': s.note, 'at': s.at.millisecondsSinceEpoch, }, ]); diff --git a/lib/ui2/activity/summary.dart b/lib/ui2/activity/summary.dart index 691bacdd0..6ada1d416 100644 --- a/lib/ui2/activity/summary.dart +++ b/lib/ui2/activity/summary.dart @@ -121,10 +121,17 @@ class LoggedSet { final double? loadKg; final int? rpe; final int? restSec; + final int? holdSec; + final String note; final DateTime at; const LoggedSet(this.exerciseKey, this.reps, - {this.loadKg, this.rpe, this.restSec, required this.at}); + {this.loadKg, + this.rpe, + this.restSec, + this.holdSec, + this.note = '', + required this.at}); LoggedSet copyWith({ String? exerciseKey, @@ -134,6 +141,8 @@ class LoggedSet { int? rpe, bool clearRpe = false, int? restSec, + int? holdSec, + String? note, DateTime? at, }) => LoggedSet( @@ -142,6 +151,8 @@ class LoggedSet { loadKg: clearLoadKg ? null : (loadKg ?? this.loadKg), rpe: clearRpe ? null : (rpe ?? this.rpe), restSec: restSec ?? this.restSec, + holdSec: holdSec ?? this.holdSec, + note: note ?? this.note, at: at ?? this.at, ); @@ -226,6 +237,8 @@ List> strengthSetsToRows(List sets) { 'load_kg': s.loadKg, 'rpe': s.rpe, 'rest_sec': s.restSec, + 'hold_sec': s.holdSec, + 'note': s.note, 'at_ts': s.at.millisecondsSinceEpoch ~/ 1000, }, ]; @@ -264,9 +277,9 @@ Future showEditSetDialog( final unitLabel = units?.weightUnit ?? 'kg'; var isBodyweight = set.loadKg == null; - final weightCtrl = TextEditingController( - text: set.loadKg != null ? formatStrengthWeight(set.loadKg!, units) : '', - ); + final initialWeightText = + set.loadKg != null ? formatStrengthWeight(set.loadKg!, units) : ''; + final weightCtrl = TextEditingController(text: initialWeightText); final repsCtrl = TextEditingController(text: '${set.reps}'); String? weightError; String? repsError; @@ -289,9 +302,13 @@ Future showEditSetDialog( () => weightError = uiText(context, 'Enter a value')); return; } - parsedKg = units != null - ? units.weightToKg(wText) - : parseTypedNumber(wText); + if (set.loadKg != null && wText == initialWeightText) { + parsedKg = set.loadKg; + } else { + parsedKg = units != null + ? units.weightToKg(wText) + : parseTypedNumber(wText); + } if (parsedKg == null) { setDialogState( () => weightError = uiText(context, 'Invalid number')); @@ -411,7 +428,11 @@ Future showEditSetDialog( ), ), actions: [ - Row( + Wrap( + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: S.x2, + runSpacing: S.x2, children: [ Pressable( onTap: () async { @@ -468,23 +489,27 @@ Future showEditSetDialog( ), ), ), - const Spacer(), - TextButton( - onPressed: () => Navigator.of(dc).pop(), - child: Text( - l?.actionCancel ?? 'Cancel', - style: F.body.copyWith(color: p.ink2), - ), - ), - TextButton( - onPressed: submit, - child: Text( - l?.actionSave ?? 'Save', - style: F.body.copyWith( - color: p.on(C.purple), - fontWeight: FontWeight.w600, + Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton( + onPressed: () => Navigator.of(dc).pop(), + child: Text( + l?.actionCancel ?? 'Cancel', + style: F.body.copyWith(color: p.ink2), + ), ), - ), + TextButton( + onPressed: submit, + child: Text( + l?.actionSave ?? 'Save', + style: F.body.copyWith( + color: p.on(C.purple), + fontWeight: FontWeight.w600, + ), + ), + ), + ], ), ], ), diff --git a/lib/ui2/screens/workout_screen.dart b/lib/ui2/screens/workout_screen.dart index e51440f43..89b1b2825 100644 --- a/lib/ui2/screens/workout_screen.dart +++ b/lib/ui2/screens/workout_screen.dart @@ -1708,6 +1708,8 @@ StrengthLog _logFrom(List> rows) => StrengthLog([ loadKg: (r['load_kg'] as num?)?.toDouble(), rpe: (r['rpe'] as num?)?.toInt(), restSec: (r['rest_sec'] as num?)?.toInt(), + holdSec: (r['hold_sec'] as num?)?.toInt(), + note: (r['note'] as String?) ?? '', at: DateTime.fromMillisecondsSinceEpoch( ((r['at_ts'] as num?)?.toInt() ?? 0) * 1000), ), From 9a3105debe2ac4f468e4a2348f04669feae9cb13 Mon Sep 17 00:00:00 2001 From: Eyadmb Date: Sun, 8 Nov 2026 18:41:24 +0300 Subject: [PATCH 6/8] Address CodeRabbit review feedback on set indexing, zero-step delta, note parsing, and save failure notification --- lib/l10n/display_text.dart | 10 +++++++ lib/ui2/activity/live.dart | 4 ++- lib/ui2/activity/summary.dart | 41 ++++++++++++++++++++--------- lib/ui2/screens/workout_screen.dart | 15 ++++++++++- 4 files changed, 56 insertions(+), 14 deletions(-) diff --git a/lib/l10n/display_text.dart b/lib/l10n/display_text.dart index 4c64358da..2aa0086ca 100644 --- a/lib/l10n/display_text.dart +++ b/lib/l10n/display_text.dart @@ -245,6 +245,16 @@ String localizedText(AppLocalizations? l, String text) { 'zh' => '删除', _ => 'Delete', }, + "Failed to save changes" => switch (l.localeName) { + 'de' => 'Änderungen konnten nicht gespeichert werden', + 'es' => 'No se pudieron guardar los cambios', + 'fr' => 'Échec de l\'enregistrement des modifications', + 'hi' => 'परिवर्तन सहेजने में विफल', + 'ru' => 'Не удалось сохранить изменения', + 'th' => 'ไม่สามารถบันทึกการเปลี่ยนแปลงได้', + 'zh' => '保存更改失败', + _ => 'Failed to save changes', + }, "Screens before bed" => l.presentationScreensBeforeBed, "Soreness" => l.presentationSoreness, "Alcohol" => l.presentationAlcohol, diff --git a/lib/ui2/activity/live.dart b/lib/ui2/activity/live.dart index 4a479a2ea..397b304b0 100644 --- a/lib/ui2/activity/live.dart +++ b/lib/ui2/activity/live.dart @@ -1874,6 +1874,7 @@ class _LiveStrengthState extends State { logged.remove(targetSet); } else { final idx = logged.indexOf(targetSet); + final isLatest = idx >= 0 && idx == logged.length - 1; if (idx >= 0) { logged[idx] = targetSet.copyWith( loadKg: res.loadKg, @@ -1881,7 +1882,7 @@ class _LiveStrengthState extends State { reps: res.reps, ); } - if (targetSet == logged.lastOrNull) { + if (isLatest) { if (res.loadKg != null) { kg = res.loadKg!; bodyweight = false; @@ -1896,6 +1897,7 @@ class _LiveStrengthState extends State { } static double _step(double val, double delta, UnitsController? u) { + if (delta == 0) return val; if (u != null && u.isImperial) { final currentLb = u.weightValue(val); final stepLb = delta > 0 ? 5.0 : -5.0; diff --git a/lib/ui2/activity/summary.dart b/lib/ui2/activity/summary.dart index 6ada1d416..12c11f402 100644 --- a/lib/ui2/activity/summary.dart +++ b/lib/ui2/activity/summary.dart @@ -2365,7 +2365,8 @@ class _ActivitySummaryState extends State { for (var i = 0; i < r.strength.forExercise(key).length; i++) ...[ - _setRow(p, i + 1, r.strength.forExercise(key)[i]), + _setRow( + p, i + 1, r.strength.forExercise(key)[i], key, i), if (i < r.strength.forExercise(key).length - 1) Divider(color: p.line, height: 1), ], @@ -2507,7 +2508,8 @@ class _ActivitySummaryState extends State { } } - Future _editSet(LoggedSet s, int n) async { + Future _editSet(LoggedSet s, int n, + [String? exerciseKey, int? setIndexInExercise]) async { final res = await showEditSetDialog( context, set: s, @@ -2516,17 +2518,25 @@ class _ActivitySummaryState extends State { ); if (!mounted || res == null) return; final updated = List.of(r.strength.sets); - final idx = updated.indexWhere((x) => - identical(x, s) || - (x.at == s.at && - x.exerciseKey == s.exerciseKey && - x.reps == s.reps && - x.loadKg == s.loadKg)); + var idx = -1; + if (exerciseKey != null && setIndexInExercise != null) { + var count = 0; + for (var j = 0; j < updated.length; j++) { + if (updated[j].exerciseKey == exerciseKey) { + if (count == setIndexInExercise) { + idx = j; + break; + } + count++; + } + } + } + if (idx < 0) idx = updated.indexOf(s); if (idx < 0) return; if (res.deleted) { updated.removeAt(idx); } else { - updated[idx] = s.copyWith( + updated[idx] = updated[idx].copyWith( loadKg: res.loadKg, clearLoadKg: res.loadKg == null, reps: res.reps, @@ -2542,15 +2552,22 @@ class _ActivitySummaryState extends State { strengthSetsToRows(updated), ); } catch (_) { - // Ignored; local state already updated + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(uiText(context, 'Failed to save changes')), + ), + ); + } } } } - Widget _setRow(P p, int n, LoggedSet s) { + Widget _setRow(P p, int n, LoggedSet s, + [String? exerciseKey, int? setIndexInExercise]) { final l = AppLocalizations.of(context); return Pressable( - onTap: () => _editSet(s, n), + onTap: () => _editSet(s, n, exerciseKey, setIndexInExercise), child: Padding( padding: const EdgeInsets.symmetric(vertical: S.x3), child: Row(children: [ diff --git a/lib/ui2/screens/workout_screen.dart b/lib/ui2/screens/workout_screen.dart index 89b1b2825..8c87309be 100644 --- a/lib/ui2/screens/workout_screen.dart +++ b/lib/ui2/screens/workout_screen.dart @@ -1698,6 +1698,19 @@ Future _detailOf(AppState app, _PastWorkout w) async { return out; } +String _parseNote(Object? val) { + if (val == null) return ''; + if (val is String) return val; + if (val is List) { + try { + return String.fromCharCodes(val); + } catch (_) { + return ''; + } + } + return '$val'; +} + /// `strength_set` rows → the log the summary renders. `load_kg` stays null /// when it was null: a bodyweight set is not a zero-kilo set. StrengthLog _logFrom(List> rows) => StrengthLog([ @@ -1709,7 +1722,7 @@ StrengthLog _logFrom(List> rows) => StrengthLog([ rpe: (r['rpe'] as num?)?.toInt(), restSec: (r['rest_sec'] as num?)?.toInt(), holdSec: (r['hold_sec'] as num?)?.toInt(), - note: (r['note'] as String?) ?? '', + note: _parseNote(r['note']), at: DateTime.fromMillisecondsSinceEpoch( ((r['at_ts'] as num?)?.toInt() ?? 0) * 1000), ), From d4d3c2a4560f562b3d1fed709ba6bb8cfe8cf637 Mon Sep 17 00:00:00 2001 From: Eyadmb Date: Sun, 8 Nov 2026 18:57:19 +0300 Subject: [PATCH 7/8] Support RPE editing in strength workout set dialog and decode notes as UTF-8 --- lib/ui2/activity/live.dart | 5 +++ lib/ui2/activity/summary.dart | 67 ++++++++++++++++++++++++++++- lib/ui2/screens/workout_screen.dart | 3 +- test/db_strength_schema_test.dart | 4 ++ test/ui2_activity_test.dart | 3 ++ 5 files changed, 79 insertions(+), 3 deletions(-) diff --git a/lib/ui2/activity/live.dart b/lib/ui2/activity/live.dart index 397b304b0..22d76cfa0 100644 --- a/lib/ui2/activity/live.dart +++ b/lib/ui2/activity/live.dart @@ -1880,6 +1880,8 @@ class _LiveStrengthState extends State { loadKg: res.loadKg, clearLoadKg: res.loadKg == null, reps: res.reps, + rpe: res.rpe, + clearRpe: res.rpe == null, ); } if (isLatest) { @@ -1890,6 +1892,9 @@ class _LiveStrengthState extends State { bodyweight = true; } reps = res.reps; + if (res.rpe != null) { + rpe = res.rpe!; + } } } }); diff --git a/lib/ui2/activity/summary.dart b/lib/ui2/activity/summary.dart index 12c11f402..afb6b7345 100644 --- a/lib/ui2/activity/summary.dart +++ b/lib/ui2/activity/summary.dart @@ -247,10 +247,20 @@ List> strengthSetsToRows(List sets) { class EditSetResult { final double? loadKg; final int reps; + final int? rpe; final bool deleted; - const EditSetResult({this.loadKg, required this.reps, this.deleted = false}); - const EditSetResult.delete() : loadKg = null, reps = 0, deleted = true; + const EditSetResult({ + this.loadKg, + required this.reps, + this.rpe, + this.deleted = false, + }); + const EditSetResult.delete() + : loadKg = null, + reps = 0, + rpe = null, + deleted = true; } String formatStrengthWeight(double v, [UnitsController? u]) { @@ -281,6 +291,7 @@ Future showEditSetDialog( set.loadKg != null ? formatStrengthWeight(set.loadKg!, units) : ''; final weightCtrl = TextEditingController(text: initialWeightText); final repsCtrl = TextEditingController(text: '${set.reps}'); + int? selectedRpe = set.rpe; String? weightError; String? repsError; @@ -333,6 +344,7 @@ Future showEditSetDialog( Navigator.of(dc).pop(EditSetResult( loadKg: isBodyweight ? null : parsedKg, reps: parsedReps, + rpe: selectedRpe, )); } @@ -424,6 +436,55 @@ Future showEditSetDialog( ), onSubmitted: (_) => submit(), ), + const SizedBox(height: S.x4), + Row( + children: [ + Text( + l?.activityLiveEffortRpeHeader ?? 'EFFORT (RPE)', + style: F.over.copyWith(color: p.ink3), + ), + const Spacer(), + if (selectedRpe != null) + Pressable( + onTap: () => setDialogState(() => selectedRpe = null), + child: Text( + l?.journalComposeClear ?? 'Clear', + style: F.cap.copyWith(color: p.ink3), + ), + ), + ], + ), + const SizedBox(height: S.x2), + Row( + children: List.generate(5, (i) { + final v = i + 6; + final on = selectedRpe == v; + const cols = [C.green, C.teal, C.blue, C.orange, C.red]; + return Expanded( + child: Pressable( + semanticLabel: 'RPE $v', + onTap: () => setDialogState(() { + selectedRpe = selectedRpe == v ? null : v; + }), + child: Container( + margin: EdgeInsets.only(right: i == 4 ? 0 : S.x2), + height: 38, + alignment: Alignment.center, + decoration: BoxDecoration( + color: on ? p.fill(cols[i]) : p.wash(cols[i]), + borderRadius: R.rMd, + ), + child: Text( + '$v', + style: F.head.copyWith( + color: on ? p.inkOnFill : p.on(cols[i]), + ), + ), + ), + ), + ); + }), + ), ], ), ), @@ -2540,6 +2601,8 @@ class _ActivitySummaryState extends State { loadKg: res.loadKg, clearLoadKg: res.loadKg == null, reps: res.reps, + rpe: res.rpe, + clearRpe: res.rpe == null, ); } setState(() => _rated = r.copyWith(strength: StrengthLog(updated))); diff --git a/lib/ui2/screens/workout_screen.dart b/lib/ui2/screens/workout_screen.dart index 8c87309be..40ab845bb 100644 --- a/lib/ui2/screens/workout_screen.dart +++ b/lib/ui2/screens/workout_screen.dart @@ -13,6 +13,7 @@ import '../../l10n/display_text.dart'; // fixes it. import 'dart:async' show unawaited; +import 'dart:convert' show utf8; import 'dart:math' as math; import 'package:flutter/material.dart'; @@ -1703,7 +1704,7 @@ String _parseNote(Object? val) { if (val is String) return val; if (val is List) { try { - return String.fromCharCodes(val); + return utf8.decode(val, allowMalformed: true); } catch (_) { return ''; } diff --git a/test/db_strength_schema_test.dart b/test/db_strength_schema_test.dart index 637c3d32d..7bb90f7b5 100644 --- a/test/db_strength_schema_test.dart +++ b/test/db_strength_schema_test.dart @@ -286,6 +286,8 @@ void main() { 'set_index': 1, 'reps': 12, 'load_kg': 65.0, + 'rpe': 9, + 'note': 'Good set 💪', 'at_ts': 1770000000, }, ]); @@ -294,5 +296,7 @@ void main() { expect(sets, hasLength(1)); expect(sets[0]['reps'], 12); expect(sets[0]['load_kg'], 65.0); + expect(sets[0]['rpe'], 9); + expect(sets[0]['note'], 'Good set 💪'); }); } diff --git a/test/ui2_activity_test.dart b/test/ui2_activity_test.dart index 1b1c2f2aa..2e7c854b0 100644 --- a/test/ui2_activity_test.dart +++ b/test/ui2_activity_test.dart @@ -1989,11 +1989,14 @@ void main() { final textFields = find.byType(TextField); await tester.enterText(textFields.first, '57.5'); await tester.enterText(textFields.last, '9'); + await tester.tap(find.bySemanticsLabel('RPE 8')); + await tester.pumpAndSettle(); await tester.tap(find.text('Save')); await tester.pumpAndSettle(); expect(find.text('57.5 kg × 9'), findsOneWidget); + expect(find.text('RPE 8'), findsOneWidget); }); testWidgets('a session that failed to save says so and can retry', From 9ac51c0b602462cd3c337d969d16e0a597cb32e9 Mon Sep 17 00:00:00 2001 From: Eyadmb Date: Sun, 8 Nov 2026 19:30:09 +0300 Subject: [PATCH 8/8] Persist strength set edits on retry save and add retry action to save error notification --- lib/ui2/activity/summary.dart | 39 ++++++++++++++++++++++++++++- test/ui2_activity_test.dart | 46 +++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/lib/ui2/activity/summary.dart b/lib/ui2/activity/summary.dart index afb6b7345..5b16d5243 100644 --- a/lib/ui2/activity/summary.dart +++ b/lib/ui2/activity/summary.dart @@ -1421,7 +1421,23 @@ class _ActivitySummaryState extends State { setState(() => _saving = true); var ok = false; try { - await widget.onRetrySave!(); + final saved = await widget.onRetrySave!(); + if (saved.sessionId != null) { + try { + await LocalDb.replaceStrengthSets( + saved.sessionId!, + strengthSetsToRows(r.strength.sets), + ); + } catch (_) {} + } + if (mounted) { + setState(() { + _rated = saved.copyWith( + rpe: _rated?.rpe ?? saved.rpe, + strength: r.strength, + ); + }); + } ok = true; } catch (_) { ok = false; @@ -2616,9 +2632,30 @@ class _ActivitySummaryState extends State { ); } catch (_) { if (mounted) { + final l = AppLocalizations.of(context); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(uiText(context, 'Failed to save changes')), + action: SnackBarAction( + label: l?.reportSaveRetry ?? 'Try again', + onPressed: () async { + try { + await LocalDb.replaceStrengthSets( + sid, + strengthSetsToRows(updated), + ); + } catch (_) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: + Text(uiText(context, 'Failed to save changes')), + ), + ); + } + } + }, + ), ), ); } diff --git a/test/ui2_activity_test.dart b/test/ui2_activity_test.dart index 2e7c854b0..81eb250b8 100644 --- a/test/ui2_activity_test.dart +++ b/test/ui2_activity_test.dart @@ -2030,6 +2030,52 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('retry save synchronizes edited strength sets', + (tester) async { + tester.view.physicalSize = const Size(390 * 3, 2400 * 3); + tester.view.devicePixelRatio = 3; + addTearDown(tester.view.reset); + + final initialSets = [ + LoggedSet('bench_press', 8, loadKg: 50.0, at: DateTime.now()), + ]; + final draft = _result(Arch.strength).copyWith( + strength: StrengthLog(initialSets), + ); + + ActivityResult? savedResult; + await tester.pumpWidget(_frame( + ActivitySummary(draft, weightKg: 75.0, onRetrySave: () async { + savedResult = draft.copyWith(sessionId: 'sess-saved-1'); + return savedResult!; + }), + Brightness.light, + 1.0)); + await tester.pumpAndSettle(); + + expect(find.text('This session is not saved yet'), findsOneWidget); + + await tester.tap(find.text('Splits')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('50 kg × 8')); + await tester.pumpAndSettle(); + + final textFields = find.byType(TextField); + await tester.enterText(textFields.first, '55'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Overview')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Try again')); + await tester.pumpAndSettle(); + + expect(find.text('This session is not saved yet'), findsNothing); + expect(tester.takeException(), isNull); + }); + testWidgets('the picker lists every activity and searches by name', (tester) async { tester.view.physicalSize = const Size(390 * 3, 1400 * 3);