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/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 3dc93be2b..4393e726d 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -16343,5 +16343,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 62fe88e66..d5eeb1945 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -2891,6 +2891,18 @@ } } }, + "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", "reportContactLabel": "Contacto (opcional)", "reportContactHint": "Correo o Discord, solo si quieres una respuesta" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 41cab9ff7..64b0f1aed 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -2862,6 +2862,18 @@ } } }, + "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", "reportContactLabel": "Contact (facultatif)", "reportContactHint": "E-mail ou Discord, seulement si vous voulez une réponse" } diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 6159de0a0..e649024d4 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -2862,6 +2862,18 @@ } } }, + "strengthCustomWeight": "कस्टम वज़न", + "strengthEnterValue": "एक मान दर्ज करें", + "strengthInvalidNumber": "अमान्य संख्या", + "strengthLoadRangeKg": "0–500 kg के बीच होना चाहिए", + "strengthLoadRangeLb": "0–1100 lb के बीच होना चाहिए", + "strengthRepsRange": "1–200 पुनरावृत्तियों के बीच होना चाहिए", + "strengthEditSet": "सेट संपादित करें", + "strengthDeleteSet": "सेट हटाएं", + "strengthDeleteSetConfirm": "क्या यह सेट हटाना चाहते हैं?", + "strengthBodyweight": "शारीरिक वज़न", + "strengthDelete": "हटाएं", + "strengthSaveFailed": "परिवर्तन सहेजने में विफल", "reportContactLabel": "संपर्क (वैकल्पिक)", "reportContactHint": "ईमेल या Discord, केवल अगर आप जवाब चाहते हैं" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 84bc7cfc2..486ab2b8f 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -7905,6 +7905,18 @@ } } }, + "strengthCustomWeight": "Свой вес", + "strengthEnterValue": "Введите значение", + "strengthInvalidNumber": "Неверное число", + "strengthLoadRangeKg": "Должно быть от 0 до 500 кг", + "strengthLoadRangeLb": "Должно быть от 0 до 1100 фунтов", + "strengthRepsRange": "Должно быть от 1 до 200 повторений", + "strengthEditSet": "Редактировать подход", + "strengthDeleteSet": "Удалить подход", + "strengthDeleteSetConfirm": "Удалить этот подход?", + "strengthBodyweight": "Свой вес", + "strengthDelete": "Удалить", + "strengthSaveFailed": "Не удалось сохранить изменения", "whoopFirmwareWarningTitle": "Пользователям WHOOP: не подключайтесь снова к приложению WHOOP", "whoopFirmwareWarningAction": "Если вы пользуетесь OpenStrap или другим приложением с открытым кодом либо планируете перейти на него позже, не подключайте браслет к официальному приложению WHOOP снова. Оно может автоматически установить обновления прошивки. Удалите или отключите приложение WHOOP, чтобы оно не обновляло ваш браслет.", "whoopFirmwareWarningDetails": "В сообщениях о прошивках WHOOP 5.0 / MG 50.43.1.0 и 50.43.2.0 описывается заблокированный доступ к данным. Bluetooth при этом может подключаться. OpenStrap не может выпустить подписанный WHOOP токен членства, необходимый для разблокировки затронутых браслетов. Мы работаем над решением, но исправления пока нет." diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 6347b62ca..53fbcc8e0 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -16326,6 +16326,18 @@ } } }, + "strengthCustomWeight": "น้ำหนักที่กำหนดเอง", + "strengthEnterValue": "ป้อนค่า", + "strengthInvalidNumber": "ตัวเลขไม่ถูกต้อง", + "strengthLoadRangeKg": "ต้องอยู่ระหว่าง 0–500 กก.", + "strengthLoadRangeLb": "ต้องอยู่ระหว่าง 0–1100 ปอนด์", + "strengthRepsRange": "ต้องอยู่ระหว่าง 1–200 ครั้ง", + "strengthEditSet": "แก้ไขเซ็ต", + "strengthDeleteSet": "ลบเซ็ต", + "strengthDeleteSetConfirm": "ลบเซ็ตนี้หรือไม่?", + "strengthBodyweight": "น้ำหนักตัว", + "strengthDelete": "ลบ", + "strengthSaveFailed": "ไม่สามารถบันทึกการเปลี่ยนแปลงได้", "whoopFirmwareWarningTitle": "ผู้ใช้ WHOOP: อย่าเชื่อมต่อกับแอป WHOOP อีก", "whoopFirmwareWarningAction": "หากคุณใช้ OpenStrap หรือแอปโอเพนซอร์สอื่น หรือวางแผนจะเปลี่ยนไปใช้ในภายหลัง อย่าเชื่อมต่อสายรัดกับแอป WHOOP อย่างเป็นทางการอีก แอปอาจติดตั้งอัปเดตเฟิร์มแวร์โดยอัตโนมัติ ให้ถอนการติดตั้งหรือปิดใช้งานแอป WHOOP เพื่อไม่ให้แอปอัปเดตสายรัดของคุณ", "whoopFirmwareWarningDetails": "มีรายงานเกี่ยวกับเฟิร์มแวร์ WHOOP 5.0 / MG เวอร์ชัน 50.43.1.0 และ 50.43.2.0 ว่าเข้าถึงข้อมูลไม่ได้ บลูทูธอาจยังเชื่อมต่อได้ OpenStrap ไม่สามารถออกโทเค็นสมาชิกที่ WHOOP ลงนามซึ่งจำเป็นต่อการปลดล็อกสายรัดที่ได้รับผลกระทบ เรากำลังหาทางแก้ไข แต่ยังไม่มีวิธีแก้ในตอนนี้" diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 472e0d493..0c160ab21 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -2862,6 +2862,18 @@ } } }, + "strengthCustomWeight": "自定义重量", + "strengthEnterValue": "输入数值", + "strengthInvalidNumber": "无效数字", + "strengthLoadRangeKg": "必须在 0–500 千克之间", + "strengthLoadRangeLb": "必须在 0–1100 磅之间", + "strengthRepsRange": "必须在 1–200 次之间", + "strengthEditSet": "编辑组", + "strengthDeleteSet": "删除组", + "strengthDeleteSetConfirm": "删除此组?", + "strengthBodyweight": "自身体重", + "strengthDelete": "删除", + "strengthSaveFailed": "保存更改失败", "reportContactLabel": "联系方式(可选)", "reportContactHint": "邮箱或 Discord,仅在你希望得到回复时填写" } diff --git a/lib/l10n/display_text.dart b/lib/l10n/display_text.dart index 5bc7f8c93..68f0318ff 100644 --- a/lib/l10n/display_text.dart +++ b/lib/l10n/display_text.dart @@ -135,6 +135,18 @@ String localizedText(AppLocalizations? l, String text) { "lb" => l.presentationLb, "in" => l.presentationIn, "Weight" => l.journalComposeWeightLabel, + "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 3aac28114..6f3643c33 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)), + () => _stepLoad(c, -stepKg, u), + () => _stepLoad(c, 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', + '', + () => _stepReps(c, -1), + () => _stepReps(c, 1), + onTapValue: () => _editReps(c)), const SizedBox(height: S.x5), Align( alignment: Alignment.centerLeft, @@ -1761,32 +1797,266 @@ 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(); + } + + /// 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)); + } + setState(() => kg = next); + } + + 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); + String? errorText; + final picked = await showDialog( + context: c, + builder: (dc) => StatefulBuilder( + builder: (ctx, setDialogState) { + void submit() { + final r = check(ctrl.text.trim()); + if (r.error != null) { + setDialogState(() => errorText = r.error); + return; + } + Navigator.of(dc).pop(r.value); + } + + return AlertDialog( + backgroundColor: p.card, + 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( + 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, + ), + ), + ), + ], + ); + }, + ), + ); + // 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; + bodyweight = false; + }); + } + + Future _editReps(BuildContext c) async { + 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); + }, + ); + 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 +2064,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 +2121,14 @@ 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 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(); + 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..5b74e901a 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,395 @@ 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'\.$'), ''); +} + +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, + 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 (!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 > kMaxStrengthReps) { + 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 && dc.mounted) { + 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, + ), + ), + ), + ], + ), + ], + ), + ], + ); + }, + ), + ); + + // Controllers are not disposed: the dialog's exit animation still builds them. + 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 +1449,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 +2470,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 +2613,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 +2717,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 +2729,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..5e20a9d09 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.text('8').last); + 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); @@ -2139,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); @@ -2178,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); + }); + }); }