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 5bc7f8c93..2aa0086ca 100644 --- a/lib/l10n/display_text.dart +++ b/lib/l10n/display_text.dart @@ -135,6 +135,126 @@ 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', + }, "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 3aac28114..22d76cfa0 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, }, ]); @@ -1621,43 +1625,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), ], @@ -1694,22 +1704,48 @@ 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, u)), + () => setState(() => kg = _step(kg, stepKg, u)), + 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(uiText(c, '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)), + _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( alignment: Alignment.centerLeft, @@ -1761,32 +1797,318 @@ 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); + final isLatest = idx >= 0 && idx == logged.length - 1; + if (idx >= 0) { + logged[idx] = targetSet.copyWith( + loadKg: res.loadKg, + clearLoadKg: res.loadKg == null, + reps: res.reps, + rpe: res.rpe, + clearRpe: res.rpe == null, + ); + } + if (isLatest) { + if (res.loadKg != null) { + kg = res.loadKg!; + bodyweight = false; + } else { + bodyweight = true; + } + reps = res.reps; + if (res.rpe != null) { + rpe = res.rpe!; + } + } + } + }); + _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); + } + final next = (val + delta).clamp(0.0, 500.0); + return double.parse(next.toStringAsFixed(2)); + } + + 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'; + String? errorText; + + 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')); + 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); + } + + 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(), + ), + ], + ), + 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, + ), + ), + ), + ], + ); + }, + ), + ); + ctrl.dispose(); + if (!mounted || picked == null) return; + setState(() { + kg = picked; + 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); + 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, + ), + ), + ), + ], + ); + }, + ), + ); + ctrl.dispose(); + if (!mounted || picked == null) return; + setState(() => reps = picked); + } + 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 +2116,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: label, + onTap: () { + HapticFeedback.selectionClick(); + onTapValue(); + }, + child: bigNum(p, value, unit), + ), + ), counterButton(p, LucideIcons.plus, p.ink, l?.activityLiveIncrease(label) ?? '$label up', plus), ]), @@ -1840,9 +2173,13 @@ 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]) { + final v = u != null ? u.weightValue(d) : d; + final s = v.toStringAsFixed(2); + final r = double.parse(s); + if (r == r.roundToDouble()) return r.round().toString(); + return s.endsWith('0') ? r.toStringAsFixed(1) : s; + } } // ══════════════ SWIMMING — laps are counted, not measured ══════════════ diff --git a/lib/ui2/activity/summary.dart b/lib/ui2/activity/summary.dart index 80618f8e2..5b16d5243 100644 --- a/lib/ui2/activity/summary.dart +++ b/lib/ui2/activity/summary.dart @@ -121,10 +121,40 @@ 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, + int? reps, + double? loadKg, + bool clearLoadKg = false, + int? rpe, + bool clearRpe = false, + int? restSec, + int? holdSec, + String? note, + 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, + holdSec: holdSec ?? this.holdSec, + note: note ?? this.note, + 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 +224,367 @@ 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, + 'hold_sec': s.holdSec, + 'note': s.note, + 'at_ts': s.at.millisecondsSinceEpoch ~/ 1000, + }, + ]; +} + +class EditSetResult { + final double? loadKg; + final int reps; + final int? rpe; + final bool deleted; + + 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]) { + 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 initialWeightText = + 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; + + 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; + } + 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')); + 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, + rpe: selectedRpe, + )); + } + + 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(), + ), + 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]), + ), + ), + ), + ), + ); + }), + ), + ], + ), + ), + actions: [ + Wrap( + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: S.x2, + runSpacing: S.x2, + 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)), + ), + ], + ), + ), + ), + 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, + ), + ), + ), + ], + ), + ], + ), + ], + ); + }, + ), + ); + + 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. @@ -1030,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; @@ -2035,7 +2442,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), ], @@ -2177,22 +2585,103 @@ class _ActivitySummaryState extends State { } } - Widget _setRow(P p, int n, LoggedSet s) { + Future _editSet(LoggedSet s, int n, + [String? exerciseKey, int? setIndexInExercise]) async { + final res = await showEditSetDialog( + context, + set: s, + setIndex: n, + units: _u, + ); + if (!mounted || res == null) return; + final updated = List.of(r.strength.sets); + 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] = updated[idx].copyWith( + loadKg: res.loadKg, + clearLoadKg: res.loadKg == null, + reps: res.reps, + rpe: res.rpe, + clearRpe: res.rpe == null, + ); + } + setState(() => _rated = r.copyWith(strength: StrengthLog(updated))); + + final sid = r.sessionId; + if (sid != null) { + try { + await LocalDb.replaceStrengthSets( + sid, + strengthSetsToRows(updated), + ); + } 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')), + ), + ); + } + } + }, + ), + ), + ); + } + } + } + } + + Widget _setRow(P p, int n, LoggedSet s, + [String? exerciseKey, int? setIndexInExercise]) { 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, exerciseKey, setIndexInExercise), + 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 +2689,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 +2701,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..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'; @@ -653,6 +654,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 +1031,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 +1389,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 +1430,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. @@ -1703,6 +1699,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 utf8.decode(val, allowMalformed: true); + } 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([ @@ -1713,6 +1722,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: _parseNote(r['note']), at: DateTime.fromMillisecondsSinceEpoch( ((r['at_ts'] as num?)?.toInt() ?? 0) * 1000), ), diff --git a/test/db_strength_schema_test.dart b/test/db_strength_schema_test.dart index 0886eb681..7bb90f7b5 100644 --- a/test/db_strength_schema_test.dart +++ b/test/db_strength_schema_test.dart @@ -250,4 +250,53 @@ 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, + 'rpe': 9, + 'note': 'Good set 💪', + '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); + 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 c5e4ce5e1..81eb250b8 100644 --- a/test/ui2_activity_test.dart +++ b/test/ui2_activity_test.dart @@ -1892,6 +1892,113 @@ 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('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.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', (tester) async { tester.view.physicalSize = const Size(390 * 3, 2200 * 3); @@ -1923,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);