From bc602ae867b95ffed34efaf5c2694efe27f0c5f1 Mon Sep 17 00:00:00 2001 From: Eyadmb Date: Sat, 7 Nov 2026 15:07:21 +0300 Subject: [PATCH 01/10] 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 02/10] 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 03/10] 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 04/10] 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 05/10] 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 06/10] 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 07/10] 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 08/10] 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); From 8cea4089398227d80a7a861c5571ddb9dec96019 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Tue, 10 Nov 2026 17:41:27 +0530 Subject: [PATCH 09/10] strength sets: shared limits, merged number editor, strings to arb, no unit drift --- lib/l10n/app_de.arb | 14 +- lib/l10n/app_en.arb | 26 +++- lib/l10n/app_es.arb | 14 +- lib/l10n/app_fr.arb | 14 +- lib/l10n/app_hi.arb | 14 +- lib/l10n/app_ru.arb | 14 +- lib/l10n/app_th.arb | 14 +- lib/l10n/app_zh.arb | 14 +- lib/l10n/display_text.dart | 132 ++--------------- lib/ui2/activity/live.dart | 261 ++++++++++++++-------------------- lib/ui2/activity/summary.dart | 42 +++++- test/ui2_activity_test.dart | 38 ++++- 12 files changed, 303 insertions(+), 294 deletions(-) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index fe1fa7f57..2dd700e15 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -3472,5 +3472,17 @@ "type": "int" } } - } + }, + "strengthCustomWeight": "Benutzerdefiniertes Gewicht", + "strengthEnterValue": "Wert eingeben", + "strengthInvalidNumber": "Ungültige Zahl", + "strengthLoadRangeKg": "Muss zwischen 0 und 500 kg liegen", + "strengthLoadRangeLb": "Muss zwischen 0 und 1100 lb liegen", + "strengthRepsRange": "Muss zwischen 1 und 200 Wdh. liegen", + "strengthEditSet": "Satz bearbeiten", + "strengthDeleteSet": "Satz löschen", + "strengthDeleteSetConfirm": "Diesen Satz löschen?", + "strengthBodyweight": "Körpergewicht", + "strengthDelete": "Löschen", + "strengthSaveFailed": "Änderungen konnten nicht gespeichert werden" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 4ea800171..912f47ae3 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -16331,5 +16331,29 @@ "type": "int" } } - } + }, + "strengthCustomWeight": "Custom weight", + "@strengthCustomWeight": {"description": "Strength set editor copy."}, + "strengthEnterValue": "Enter a value", + "@strengthEnterValue": {"description": "Strength set editor copy."}, + "strengthInvalidNumber": "Invalid number", + "@strengthInvalidNumber": {"description": "Strength set editor copy."}, + "strengthLoadRangeKg": "Must be 0–500 kg", + "@strengthLoadRangeKg": {"description": "Strength set editor copy."}, + "strengthLoadRangeLb": "Must be 0–1100 lb", + "@strengthLoadRangeLb": {"description": "Strength set editor copy."}, + "strengthRepsRange": "Must be 1–200 reps", + "@strengthRepsRange": {"description": "Strength set editor copy."}, + "strengthEditSet": "Edit set", + "@strengthEditSet": {"description": "Strength set editor copy."}, + "strengthDeleteSet": "Delete set", + "@strengthDeleteSet": {"description": "Strength set editor copy."}, + "strengthDeleteSetConfirm": "Delete this set?", + "@strengthDeleteSetConfirm": {"description": "Strength set editor copy."}, + "strengthBodyweight": "Bodyweight", + "@strengthBodyweight": {"description": "Strength set editor copy."}, + "strengthDelete": "Delete", + "@strengthDelete": {"description": "Strength set editor copy."}, + "strengthSaveFailed": "Failed to save changes", + "@strengthSaveFailed": {"description": "Strength set editor copy."} } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 415c2de32..36428a5bf 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -2890,5 +2890,17 @@ "type": "int" } } - } + }, + "strengthCustomWeight": "Peso personalizado", + "strengthEnterValue": "Introduce un valor", + "strengthInvalidNumber": "Número no válido", + "strengthLoadRangeKg": "Debe estar entre 0 y 500 kg", + "strengthLoadRangeLb": "Debe estar entre 0 y 1100 lb", + "strengthRepsRange": "Debe estar entre 1 y 200 repeticiones", + "strengthEditSet": "Editar serie", + "strengthDeleteSet": "Eliminar serie", + "strengthDeleteSetConfirm": "¿Eliminar esta serie?", + "strengthBodyweight": "Peso corporal", + "strengthDelete": "Eliminar", + "strengthSaveFailed": "No se pudieron guardar los cambios" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 7c26d7e89..7f54c43c4 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -2861,5 +2861,17 @@ "type": "int" } } - } + }, + "strengthCustomWeight": "Poids personnalisé", + "strengthEnterValue": "Saisissez une valeur", + "strengthInvalidNumber": "Nombre invalide", + "strengthLoadRangeKg": "Doit être compris entre 0 et 500 kg", + "strengthLoadRangeLb": "Doit être compris entre 0 et 1100 lb", + "strengthRepsRange": "Doit être compris entre 1 et 200 répétitions", + "strengthEditSet": "Modifier la série", + "strengthDeleteSet": "Supprimer la série", + "strengthDeleteSetConfirm": "Supprimer cette série ?", + "strengthBodyweight": "Poids du corps", + "strengthDelete": "Supprimer", + "strengthSaveFailed": "Échec de l'enregistrement des modifications" } diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 990d67975..7482f65fc 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -2861,5 +2861,17 @@ "type": "int" } } - } + }, + "strengthCustomWeight": "कस्टम वज़न", + "strengthEnterValue": "एक मान दर्ज करें", + "strengthInvalidNumber": "अमान्य संख्या", + "strengthLoadRangeKg": "0–500 kg के बीच होना चाहिए", + "strengthLoadRangeLb": "0–1100 lb के बीच होना चाहिए", + "strengthRepsRange": "1–200 पुनरावृत्तियों के बीच होना चाहिए", + "strengthEditSet": "सेट संपादित करें", + "strengthDeleteSet": "सेट हटाएं", + "strengthDeleteSetConfirm": "क्या यह सेट हटाना चाहते हैं?", + "strengthBodyweight": "शारीरिक वज़न", + "strengthDelete": "हटाएं", + "strengthSaveFailed": "परिवर्तन सहेजने में विफल" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index c86544a36..3d9ef22f2 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -7904,5 +7904,17 @@ "type": "int" } } - } + }, + "strengthCustomWeight": "Свой вес", + "strengthEnterValue": "Введите значение", + "strengthInvalidNumber": "Неверное число", + "strengthLoadRangeKg": "Должно быть от 0 до 500 кг", + "strengthLoadRangeLb": "Должно быть от 0 до 1100 фунтов", + "strengthRepsRange": "Должно быть от 1 до 200 повторений", + "strengthEditSet": "Редактировать подход", + "strengthDeleteSet": "Удалить подход", + "strengthDeleteSetConfirm": "Удалить этот подход?", + "strengthBodyweight": "Свой вес", + "strengthDelete": "Удалить", + "strengthSaveFailed": "Не удалось сохранить изменения" } diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 924d3dbf9..4debf7535 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -16325,5 +16325,17 @@ "type": "int" } } - } + }, + "strengthCustomWeight": "น้ำหนักที่กำหนดเอง", + "strengthEnterValue": "ป้อนค่า", + "strengthInvalidNumber": "ตัวเลขไม่ถูกต้อง", + "strengthLoadRangeKg": "ต้องอยู่ระหว่าง 0–500 กก.", + "strengthLoadRangeLb": "ต้องอยู่ระหว่าง 0–1100 ปอนด์", + "strengthRepsRange": "ต้องอยู่ระหว่าง 1–200 ครั้ง", + "strengthEditSet": "แก้ไขเซ็ต", + "strengthDeleteSet": "ลบเซ็ต", + "strengthDeleteSetConfirm": "ลบเซ็ตนี้หรือไม่?", + "strengthBodyweight": "น้ำหนักตัว", + "strengthDelete": "ลบ", + "strengthSaveFailed": "ไม่สามารถบันทึกการเปลี่ยนแปลงได้" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 1cf2c9848..55004b3b9 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -2861,5 +2861,17 @@ "type": "int" } } - } + }, + "strengthCustomWeight": "自定义重量", + "strengthEnterValue": "输入数值", + "strengthInvalidNumber": "无效数字", + "strengthLoadRangeKg": "必须在 0–500 千克之间", + "strengthLoadRangeLb": "必须在 0–1100 磅之间", + "strengthRepsRange": "必须在 1–200 次之间", + "strengthEditSet": "编辑组", + "strengthDeleteSet": "删除组", + "strengthDeleteSetConfirm": "删除此组?", + "strengthBodyweight": "自身体重", + "strengthDelete": "删除", + "strengthSaveFailed": "保存更改失败" } diff --git a/lib/l10n/display_text.dart b/lib/l10n/display_text.dart index 2aa0086ca..68f0318ff 100644 --- a/lib/l10n/display_text.dart +++ b/lib/l10n/display_text.dart @@ -135,126 +135,18 @@ 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', - }, - "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', - }, - "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', - }, - "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', - }, + "Custom weight" => l.strengthCustomWeight, + "Enter a value" => l.strengthEnterValue, + "Invalid number" => l.strengthInvalidNumber, + "Must be 0–500 kg" => l.strengthLoadRangeKg, + "Must be 0–1100 lb" => l.strengthLoadRangeLb, + "Must be 1–200 reps" => l.strengthRepsRange, + "Edit set" => l.strengthEditSet, + "Delete set" => l.strengthDeleteSet, + "Delete this set?" => l.strengthDeleteSetConfirm, + "Bodyweight" => l.strengthBodyweight, + "Delete" => l.strengthDelete, + "Failed to save changes" => l.strengthSaveFailed, "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 22d76cfa0..6f3643c33 100644 --- a/lib/ui2/activity/live.dart +++ b/lib/ui2/activity/live.dart @@ -1704,8 +1704,8 @@ class _LiveStrengthState extends State { l?.activityLiveWeightLabel ?? 'WEIGHT', bodyweight ? (l?.activityLiveBwAbbrev ?? 'BW') : _fmt(kg, u), bodyweight ? '' : (u?.weightUnit ?? 'kg'), - () => setState(() => kg = _step(kg, -stepKg, u)), - () => setState(() => kg = _step(kg, stepKg, u)), + () => _stepLoad(c, -stepKg, u), + () => _stepLoad(c, stepKg, u), onTapValue: () => _editWeight(c, u)), const SizedBox(height: S.x3), Row( @@ -1743,8 +1743,8 @@ class _LiveStrengthState extends State { l?.activityLiveRepsLabel ?? 'REPS', '$reps', '', - () => setState(() => reps = (reps - 1).clamp(1, 200)), - () => setState(() => reps = (reps + 1).clamp(1, 200)), + () => _stepReps(c, -1), + () => _stepReps(c, 1), onTapValue: () => _editReps(c)), const SizedBox(height: S.x5), Align( @@ -1901,91 +1901,83 @@ class _LiveStrengthState extends State { _persist(); } - 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; - final nextLb = (currentLb + stepLb).clamp(0.0, 1100.0); - return u.weightToKg('$nextLb') ?? (val + delta).clamp(0.0, 500.0); + /// Clamped by [stepStrengthLoad]; says so when a press hits a limit. + void _stepLoad(BuildContext c, double delta, UnitsController? u) { + final next = stepStrengthLoad(kg, delta, u); + if (delta != 0 && (delta > 0 ? next <= kg : next >= kg)) { + _limitHint(c, strengthLoadRangeText(c, u)); } - final next = (val + delta).clamp(0.0, 500.0); - return double.parse(next.toStringAsFixed(2)); + setState(() => kg = next); } - Future _editWeight(BuildContext c, UnitsController? u) async { - final imperial = u?.isImperial ?? false; - final currentText = bodyweight ? '' : _fmt(kg, u); - final ctrl = TextEditingController(text: currentText); + void _stepReps(BuildContext c, int delta) { + final next = (reps + delta).clamp(1, kMaxStrengthReps); + if (next == reps) _limitHint(c, uiText(c, 'Must be 1–200 reps')); + setState(() => reps = next); + } + + void _limitHint(BuildContext c, String msg) { + HapticFeedback.selectionClick(); + ScaffoldMessenger.maybeOf(c) + ?..clearSnackBars() + ..showSnackBar(SnackBar(content: Text(msg))); + } + + /// One text-field dialog for both weight and reps. [check] returns the value + /// to accept or an error to show under the field (never a silent clamp). + Future _numberDialog( + BuildContext c, { + required String title, + required String initial, + required bool decimal, + String? suffix, + String? hint, + required ({T? value, String? error}) Function(String text) check, + }) async { + final ctrl = TextEditingController(text: initial); final p = P.of(c); final l = AppLocalizations.of(c); - final unitLabel = u?.weightUnit ?? 'kg'; String? errorText; - - final picked = await showDialog( + final picked = await showDialog( context: c, builder: (dc) => StatefulBuilder( builder: (ctx, setDialogState) { void submit() { - final text = ctrl.text.trim(); - if (text.isEmpty) { - setDialogState(() => errorText = uiText(c, 'Enter a value')); + final r = check(ctrl.text.trim()); + if (r.error != null) { + setDialogState(() => errorText = r.error); return; } - if (text == currentText) { - Navigator.of(dc).pop(kg); - return; - } - final parsed = _parseWeightInput(text, u); - if (parsed == null) { - setDialogState(() => errorText = uiText(c, 'Invalid number')); - return; - } - if (parsed < 0 || parsed > 500) { - setDialogState(() => errorText = imperial - ? uiText(c, 'Must be 0–1100 lb') - : uiText(c, 'Must be 0–500 kg')); - return; - } - Navigator.of(dc).pop(parsed); + Navigator.of(dc).pop(r.value); } 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(), + title: Text(title, style: F.head.copyWith(color: p.ink)), + content: TextField( + controller: ctrl, + autofocus: true, + keyboardType: decimal + ? const TextInputType.numberWithOptions(decimal: true) + : TextInputType.number, + style: F.body.copyWith(color: p.ink), + cursorColor: p.on(C.purple), + decoration: InputDecoration( + labelText: suffix == null ? title : '$title ($suffix)', + labelStyle: F.over.copyWith(color: p.ink3), + hintText: hint, + hintStyle: F.body.copyWith(color: p.ink3), + suffixText: suffix, + 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(), ), actions: [ TextButton( @@ -2010,7 +2002,33 @@ class _LiveStrengthState extends State { }, ), ); - ctrl.dispose(); + // Not disposed: the dialog's exit animation still builds the field after + // this returns, and a bare controller holds nothing once the field is gone. + return picked; + } + + Future _editWeight(BuildContext c, UnitsController? u) async { + final currentText = bodyweight ? '' : _fmt(kg, u); + final picked = await _numberDialog( + c, + title: AppLocalizations.of(c)?.activityLiveWeightLabel ?? 'Weight', + initial: currentText, + decimal: true, + suffix: u?.weightUnit ?? 'kg', + hint: (u?.isImperial ?? false) ? '135' : '60', + check: (text) { + if (text.isEmpty) return (value: null, error: uiText(c, 'Enter a value')); + if (text == currentText) return (value: kg, error: null); + final parsed = u != null ? u.weightToKg(text) : parseTypedNumber(text); + if (parsed == null) { + return (value: null, error: uiText(c, 'Invalid number')); + } + if (!strengthLoadInRange(parsed, u)) { + return (value: null, error: strengthLoadRangeText(c, u)); + } + return (value: parsed, error: null); + }, + ); if (!mounted || picked == null) return; setState(() { kg = picked; @@ -2018,90 +2036,20 @@ class _LiveStrengthState extends State { }); } - 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); - String? errorText; - - final picked = await showDialog( - context: c, - 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 = uiText(c, '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(), - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(dc).pop(), - child: Text( - l?.actionCancel ?? 'Cancel', - style: F.body.copyWith(color: p.ink2), - ), - ), - TextButton( - onPressed: submit, - child: Text( - l?.actionDone ?? 'Done', - style: F.body.copyWith( - color: p.on(C.purple), - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ); - }, - ), + final picked = await _numberDialog( + c, + title: AppLocalizations.of(c)?.activityLiveRepsLabel ?? 'Reps', + initial: '$reps', + decimal: false, + check: (text) { + final n = int.tryParse(text); + if (n == null || n < 1 || n > kMaxStrengthReps) { + return (value: null, error: uiText(c, 'Must be 1–200 reps')); + } + return (value: n, error: null); + }, ); - ctrl.dispose(); if (!mounted || picked == null) return; setState(() => reps = picked); } @@ -2174,7 +2122,8 @@ 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]) { - final v = u != null ? u.weightValue(d) : d; + final imperial = u?.isImperial ?? false; + final v = imperial ? roundLbHalf(u!.weightValue(d)) : (u?.weightValue(d) ?? d); final s = v.toStringAsFixed(2); final r = double.parse(s); if (r == r.roundToDouble()) return r.round().toString(); diff --git a/lib/ui2/activity/summary.dart b/lib/ui2/activity/summary.dart index 5b16d5243..bcc83dfd5 100644 --- a/lib/ui2/activity/summary.dart +++ b/lib/ui2/activity/summary.dart @@ -275,6 +275,36 @@ String formatStrengthWeight(double v, [UnitsController? u]) { .replaceFirst(RegExp(r'\.$'), ''); } +const kMaxStrengthKg = 500.0; +const kMaxStrengthLb = 1100.0; +const kMaxStrengthReps = 200; + +/// Cap on a set's load, in the user's display unit. +bool strengthLoadInRange(double kg, UnitsController? u) { + final imperial = u?.isImperial ?? false; + return kg >= 0 && + (imperial ? u!.weightValue(kg) <= kMaxStrengthLb + 1e-6 : kg <= kMaxStrengthKg); +} + +String strengthLoadRangeText(BuildContext c, UnitsController? u) => uiText( + c, (u?.isImperial ?? false) ? 'Must be 0–1100 lb' : 'Must be 0–500 kg'); + +/// Imperial loads show and step on half-pound increments. +double roundLbHalf(double lb) => (lb * 2).roundToDouble() / 2; + +/// One stepper press on a load in kg, clamped to the cap. Imperial steps in +/// whole display-unit increments (re-derived from the stored kg each time), so +/// repeated 5 lb presses land on 135, 140, 145 rather than drifting. +double stepStrengthLoad(double kg, double deltaKg, UnitsController? u) { + if (deltaKg == 0) return kg; + if (u != null && u.isImperial) { + final lb = roundLbHalf(u.weightValue(kg)); + final next = (lb + (deltaKg > 0 ? 5.0 : -5.0)).clamp(0.0, kMaxStrengthLb); + return u.weightToKg(next.toString()) ?? kg; + } + return double.parse((kg + deltaKg).clamp(0.0, kMaxStrengthKg).toStringAsFixed(2)); +} + Future showEditSetDialog( BuildContext context, { required LoggedSet set, @@ -325,17 +355,16 @@ Future showEditSetDialog( () => 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')); + if (!strengthLoadInRange(parsedKg, units)) { + setDialogState( + () => weightError = strengthLoadRangeText(context, units)); return; } } final rText = repsCtrl.text.trim(); final parsedReps = int.tryParse(rText); - if (parsedReps == null || parsedReps < 1 || parsedReps > 200) { + if (parsedReps == null || parsedReps < 1 || parsedReps > kMaxStrengthReps) { setDialogState( () => repsError = uiText(context, 'Must be 1–200 reps')); return; @@ -580,8 +609,7 @@ Future showEditSetDialog( ), ); - weightCtrl.dispose(); - repsCtrl.dispose(); + // Controllers are not disposed: the dialog's exit animation still builds them. return result; } diff --git a/test/ui2_activity_test.dart b/test/ui2_activity_test.dart index 81eb250b8..5e20a9d09 100644 --- a/test/ui2_activity_test.dart +++ b/test/ui2_activity_test.dart @@ -1989,7 +1989,7 @@ 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.tap(find.text('8').last); await tester.pumpAndSettle(); await tester.tap(find.text('Save')); @@ -2292,9 +2292,9 @@ void main() { await tester.tap(find.text('Log set')); await tester.pump(); expect(find.text('93 lb × 8'), findsWidgets); - // Volume = (40 kg + one 5 lb step, in kg) × 8 reps, shown in lb. + // Volume = 93 lb × 8 reps, shown in lb. final u = UnitsController.seed(UnitSystem.imperial); - final loggedKg = 40.0 + u.loadStepKg(2.5); + final loggedKg = u.weightToKg('93')!; expect(find.text(u.weightValue(loggedKg * 8).round().toString()), findsOneWidget); expect(tester.takeException(), isNull); @@ -2331,4 +2331,36 @@ void main() { }); }); + group('strength load stepper limits', () { + final lb = UnitsController.seed(UnitSystem.imperial); + final kgU = UnitsController.seed(UnitSystem.metric); + + test('metric steps and clamps at 0 and 500', () { + expect(stepStrengthLoad(60, 2.5, kgU), 62.5); + expect(stepStrengthLoad(499, 2.5, kgU), 500); + expect(stepStrengthLoad(500, 2.5, kgU), 500); + expect(stepStrengthLoad(1, -2.5, kgU), 0); + }); + + test('imperial 5 lb presses do not drift', () { + var kg = lb.weightToKg('135')!; + for (var i = 0; i < 20; i++) { + kg = stepStrengthLoad(kg, lb.loadStepKg(2.5), lb); + } + expect(double.parse(lb.weightValue(kg).toStringAsFixed(2)), 235.0); + for (var i = 0; i < 20; i++) { + kg = stepStrengthLoad(kg, -lb.loadStepKg(2.5), lb); + } + expect(double.parse(lb.weightValue(kg).toStringAsFixed(2)), 135.0); + }); + + test('imperial cap is 1100 lb, matching the editor', () { + final top = lb.weightToKg('1100')!; + expect(strengthLoadInRange(top, lb), isTrue); + expect(strengthLoadInRange(lb.weightToKg('1101')!, lb), isFalse); + expect(stepStrengthLoad(top, lb.loadStepKg(2.5), lb), top); + expect(strengthLoadInRange(500.1, kgU), isFalse); + expect(strengthLoadInRange(-1, kgU), isFalse); + }); + }); } From e83acc1acfbfdb1b32d13db25ce3f4d7de6e155c Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:43:04 +0530 Subject: [PATCH 10/10] delete confirm: check mounted after the dialog --- lib/ui2/activity/summary.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ui2/activity/summary.dart b/lib/ui2/activity/summary.dart index bcc83dfd5..5b74e901a 100644 --- a/lib/ui2/activity/summary.dart +++ b/lib/ui2/activity/summary.dart @@ -559,7 +559,7 @@ Future showEditSetDialog( ], ), ); - if (confirm == true) { + if (confirm == true && dc.mounted) { Navigator.of(dc).pop(const EditSetResult.delete()); } },