From 9e4c45dfa380d7609c075c35ff1e92fc4052b293 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 15 Aug 2026 21:41:59 +0200 Subject: [PATCH] Split manual_workout.dart (410) into four part files manual_workout.dart constants, the draft, buildEntry manual_workout_validation.dart duration and per-sport rules manual_workout_sync.dart sync payload + record id manual_workout_budget.dart rolling budget accounting This file is a cross-language contract: its JSON keys must match the Python side's build_sync_payload / manual_sync_record_id. Nothing was retyped, and both sides' fixtures still pass -- 16 Python wire-format tests and all 402 Dart tests. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y --- .../lib/models/manual_workout.dart | 184 +----------------- .../lib/models/manual_workout_budget.dart | 39 ++++ .../lib/models/manual_workout_sync.dart | 51 +++++ .../lib/models/manual_workout_validation.dart | 108 ++++++++++ 4 files changed, 202 insertions(+), 180 deletions(-) create mode 100644 stronglift_replacement/workout_app/lib/models/manual_workout_budget.dart create mode 100644 stronglift_replacement/workout_app/lib/models/manual_workout_sync.dart create mode 100644 stronglift_replacement/workout_app/lib/models/manual_workout_validation.dart diff --git a/stronglift_replacement/workout_app/lib/models/manual_workout.dart b/stronglift_replacement/workout_app/lib/models/manual_workout.dart index c632fc9..b98bf14 100644 --- a/stronglift_replacement/workout_app/lib/models/manual_workout.dart +++ b/stronglift_replacement/workout_app/lib/models/manual_workout.dart @@ -10,6 +10,10 @@ library; import 'package:crdt_sync/crdt_sync.dart'; +part 'manual_workout_budget.dart'; +part 'manual_workout_sync.dart'; +part 'manual_workout_validation.dart'; + // ── Shared constants ───────────────────────────────────────────────────────── // Hand-maintained mirror of the Python source of truth in // `screen_locker/_constants.py` (the `MANUAL_WORKOUT_*` values). @@ -181,107 +185,6 @@ class ManualWorkoutDraft { final String equipment; } -int? _parseHhmm(String value) { - final match = RegExp(r'^(\d{1,2}):(\d{2})$').firstMatch(value.trim()); - if (match == null) return null; - final hours = int.parse(match.group(1)!); - final minutes = int.parse(match.group(2)!); - if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null; - return hours * 60 + minutes; -} - -/// Minutes between start and end, or null if unparsable / not end-after-start. -double? durationMinutes(ManualWorkoutDraft draft) { - final start = _parseHhmm(draft.startTime); - final end = _parseHhmm(draft.endTime); - if (start == null || end == null || end <= start) return null; - return (end - start).toDouble(); -} - -/// Returns an error message if [draft] is invalid, else null. -/// -/// Ports `validate_manual_workout`: required fields, HH:MM with end after -/// start, minimum duration, RPE range, per-sport rules, and reflection length. -String? validateManualWorkout(ManualWorkoutDraft draft) { - if (!kSportChoices.contains(draft.sport)) return 'Please choose a sport'; - - final required = { - 'Start time': draft.startTime, - 'End time': draft.endTime, - 'Location name': draft.locationName, - 'Transport method': draft.transportMethod, - 'Cost': draft.cost, - }; - for (final entry in required.entries) { - if (entry.value.trim().isEmpty) return '${entry.key} is required'; - } - - final duration = durationMinutes(draft); - if (duration == null) { - return 'Start/end time must be valid HH:MM, with end after start'; - } - // Accept bar carries the hidden leeway; the message advertises the round - // number only. - if (duration < kWorkoutDurationAcceptMinutes) { - return 'Session must be at least $kMinWorkoutDurationMinutes ' - 'minutes (currently ${duration.toStringAsFixed(0)})'; - } - - if (draft.rpe < kManualWorkoutRpeMin || draft.rpe > kManualWorkoutRpeMax) { - return 'RPE must be between $kManualWorkoutRpeMin ' - 'and $kManualWorkoutRpeMax'; - } - - final sportError = draft.sport == kSportTableTennis - ? _validateTableTennis(draft) - : _validateOtherSport(draft); - if (sportError != null) return sportError; - - final reflections = { - 'What went well': draft.wentWell, - 'What to improve': draft.toImprove, - 'Overall feeling': draft.overallFeeling, - }; - for (final entry in reflections.entries) { - final length = entry.value.trim().length; - if (length < kManualWorkoutReflectionMinChars) { - return '${entry.key} must be at least $kManualWorkoutReflectionMinChars ' - 'characters (currently $length)'; - } - } - - return null; -} - -String? _validateTableTennis(ManualWorkoutDraft draft) { - final scores = { - 'Matches won': draft.matchesWon, - 'Matches lost': draft.matchesLost, - 'Sets won': draft.setsWon, - 'Sets lost': draft.setsLost, - }; - for (final entry in scores.entries) { - if (entry.value < 0) return '${entry.key} cannot be negative'; - } - if (draft.matchesWon + draft.matchesLost == 0) { - return 'Enter at least one match played (won + lost)'; - } - if (draft.racket.trim().isEmpty) return 'Racket is required'; - if (draft.balls.trim().isEmpty) return 'Balls are required'; - return null; -} - -String? _validateOtherSport(ManualWorkoutDraft draft) { - if (draft.activityTypeOther.trim().isEmpty) { - return 'Activity type is required'; - } - final length = draft.activityDetails.trim().length; - if (length < kManualWorkoutDescriptionMinChars) { - return 'What was done must be at least ' - '$kManualWorkoutDescriptionMinChars characters (currently $length)'; - } - return null; -} /// Builds the persisted `workout_data` dict for a validated draft. /// @@ -329,82 +232,3 @@ Map buildEntry(ManualWorkoutDraft draft) { } return entry; } - -/// Builds the cross-device sync payload: [buildEntry] + `kind` + `date`. -Map buildSyncPayload(ManualWorkoutDraft draft, String date) { - return { - ...buildEntry(draft), - 'kind': kManualWorkoutSyncKind, - 'date': date, - }; -} - -/// The stable crdt-sync record id for a manual workout. -/// -/// Format: `manual:T`. -String manualSyncRecordId(String date, String startTime) => - 'manual:${date}T${startTime.trim()}'; - -/// Builds the crdt-sync [Record] for a manual workout, stamped with [hlc]. -Record buildManualRecord( - ManualWorkoutDraft draft, - String date, { - required Hlc hlc, -}) { - return Record( - id: manualSyncRecordId(date, draft.startTime), - fields: {'payload': (buildSyncPayload(draft, date), hlc)}, - ); -} - -/// Manual-workout counts in the rolling 7-/30-day windows (the shared budget). -class ManualBudget { - /// Creates a budget snapshot. - const ManualBudget({required this.week, required this.month}); - - /// Manual workouts in the last 7 days. - final int week; - - /// Manual workouts in the last 30 days. - final int month; - - /// Whether either window has reached its limit. - bool get exhausted => - week >= kManualWorkoutBudgetPer7Days || - month >= kManualWorkoutBudgetPer30Days; -} - -/// Counts DAYS with a manual workout in the rolling 7-/30-day windows. -/// -/// The budget is counted per ENTRY, not per day: each self-report consumes its -/// own slot, so three workouts logged on one day cost three. That matches the -/// PC's `count_in_window` (screen_locker/_manual_workout.py), which is the -/// source of truth — each entry separately earns weekly-count and shutdown -/// credit, so each must separately cost budget. -/// -/// This counted DAYS until 2026-08-09 (a `Set` of date strings) while the PC -/// counted entries, so three same-day workouts read as 3/2 on the PC — over -/// its 7-day cap — but 1/2 here, and the phone kept accepting. Callers pass -/// one payload per synced record (`manual:T`), so same-day -/// workouts at different times arrive as distinct payloads. -ManualBudget countManualBudget( - Iterable> payloads, - DateTime now, -) { - final today = DateTime(now.year, now.month, now.day); - var week = 0; - var month = 0; - for (final payload in payloads) { - final dateStr = payload['date']; - if (dateStr is! String) continue; - final date = DateTime.tryParse(dateStr); - if (date == null) continue; - final days = today - .difference(DateTime(date.year, date.month, date.day)) - .inDays; - if (days < 0) continue; - if (days < 7) week++; - if (days < 30) month++; - } - return ManualBudget(week: week, month: month); -} diff --git a/stronglift_replacement/workout_app/lib/models/manual_workout_budget.dart b/stronglift_replacement/workout_app/lib/models/manual_workout_budget.dart new file mode 100644 index 0000000..8fbc97c --- /dev/null +++ b/stronglift_replacement/workout_app/lib/models/manual_workout_budget.dart @@ -0,0 +1,39 @@ +// Rolling manual-workout budget accounting. +// +// See manual_workout_validation.dart for why these are `part` files. +part of 'manual_workout.dart'; + +/// Counts DAYS with a manual workout in the rolling 7-/30-day windows. +/// +/// The budget is counted per ENTRY, not per day: each self-report consumes its +/// own slot, so three workouts logged on one day cost three. That matches the +/// PC's `count_in_window` (screen_locker/_manual_workout.py), which is the +/// source of truth — each entry separately earns weekly-count and shutdown +/// credit, so each must separately cost budget. +/// +/// This counted DAYS until 2026-08-09 (a `Set` of date strings) while the PC +/// counted entries, so three same-day workouts read as 3/2 on the PC — over +/// its 7-day cap — but 1/2 here, and the phone kept accepting. Callers pass +/// one payload per synced record (`manual:T`), so same-day +/// workouts at different times arrive as distinct payloads. +ManualBudget countManualBudget( + Iterable> payloads, + DateTime now, +) { + final today = DateTime(now.year, now.month, now.day); + var week = 0; + var month = 0; + for (final payload in payloads) { + final dateStr = payload['date']; + if (dateStr is! String) continue; + final date = DateTime.tryParse(dateStr); + if (date == null) continue; + final days = today + .difference(DateTime(date.year, date.month, date.day)) + .inDays; + if (days < 0) continue; + if (days < 7) week++; + if (days < 30) month++; + } + return ManualBudget(week: week, month: month); +} diff --git a/stronglift_replacement/workout_app/lib/models/manual_workout_sync.dart b/stronglift_replacement/workout_app/lib/models/manual_workout_sync.dart new file mode 100644 index 0000000..c5ff803 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/models/manual_workout_sync.dart @@ -0,0 +1,51 @@ +// The sync payload and record id for a manual workout. +// +// The key strings here are a cross-language contract with the Python +// side's build_sync_payload / manual_sync_record_id -- a shared literal +// fixture test pins them on both sides. Moving the code must not touch +// a single key. +part of 'manual_workout.dart'; + +/// Builds the cross-device sync payload: [buildEntry] + `kind` + `date`. +Map buildSyncPayload(ManualWorkoutDraft draft, String date) { + return { + ...buildEntry(draft), + 'kind': kManualWorkoutSyncKind, + 'date': date, + }; +} + +/// The stable crdt-sync record id for a manual workout. +/// +/// Format: `manual:T`. +String manualSyncRecordId(String date, String startTime) => + 'manual:${date}T${startTime.trim()}'; + +/// Builds the crdt-sync [Record] for a manual workout, stamped with [hlc]. +Record buildManualRecord( + ManualWorkoutDraft draft, + String date, { + required Hlc hlc, +}) { + return Record( + id: manualSyncRecordId(date, draft.startTime), + fields: {'payload': (buildSyncPayload(draft, date), hlc)}, + ); +} + +/// Manual-workout counts in the rolling 7-/30-day windows (the shared budget). +class ManualBudget { + /// Creates a budget snapshot. + const ManualBudget({required this.week, required this.month}); + + /// Manual workouts in the last 7 days. + final int week; + + /// Manual workouts in the last 30 days. + final int month; + + /// Whether either window has reached its limit. + bool get exhausted => + week >= kManualWorkoutBudgetPer7Days || + month >= kManualWorkoutBudgetPer30Days; +} diff --git a/stronglift_replacement/workout_app/lib/models/manual_workout_validation.dart b/stronglift_replacement/workout_app/lib/models/manual_workout_validation.dart new file mode 100644 index 0000000..d58db03 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/models/manual_workout_validation.dart @@ -0,0 +1,108 @@ +// Draft validation: duration, per-sport rules, and the shared minimums. +// +// A `part` so the constants and the draft stay one library -- this file is +// a cross-language contract with the Python side, and the JSON keys it +// emits must not move or change. +part of 'manual_workout.dart'; + +int? _parseHhmm(String value) { + final match = RegExp(r'^(\d{1,2}):(\d{2})$').firstMatch(value.trim()); + if (match == null) return null; + final hours = int.parse(match.group(1)!); + final minutes = int.parse(match.group(2)!); + if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null; + return hours * 60 + minutes; +} + +/// Minutes between start and end, or null if unparsable / not end-after-start. +double? durationMinutes(ManualWorkoutDraft draft) { + final start = _parseHhmm(draft.startTime); + final end = _parseHhmm(draft.endTime); + if (start == null || end == null || end <= start) return null; + return (end - start).toDouble(); +} + +/// Returns an error message if [draft] is invalid, else null. +/// +/// Ports `validate_manual_workout`: required fields, HH:MM with end after +/// start, minimum duration, RPE range, per-sport rules, and reflection length. +String? validateManualWorkout(ManualWorkoutDraft draft) { + if (!kSportChoices.contains(draft.sport)) return 'Please choose a sport'; + + final required = { + 'Start time': draft.startTime, + 'End time': draft.endTime, + 'Location name': draft.locationName, + 'Transport method': draft.transportMethod, + 'Cost': draft.cost, + }; + for (final entry in required.entries) { + if (entry.value.trim().isEmpty) return '${entry.key} is required'; + } + + final duration = durationMinutes(draft); + if (duration == null) { + return 'Start/end time must be valid HH:MM, with end after start'; + } + // Accept bar carries the hidden leeway; the message advertises the round + // number only. + if (duration < kWorkoutDurationAcceptMinutes) { + return 'Session must be at least $kMinWorkoutDurationMinutes ' + 'minutes (currently ${duration.toStringAsFixed(0)})'; + } + + if (draft.rpe < kManualWorkoutRpeMin || draft.rpe > kManualWorkoutRpeMax) { + return 'RPE must be between $kManualWorkoutRpeMin ' + 'and $kManualWorkoutRpeMax'; + } + + final sportError = draft.sport == kSportTableTennis + ? _validateTableTennis(draft) + : _validateOtherSport(draft); + if (sportError != null) return sportError; + + final reflections = { + 'What went well': draft.wentWell, + 'What to improve': draft.toImprove, + 'Overall feeling': draft.overallFeeling, + }; + for (final entry in reflections.entries) { + final length = entry.value.trim().length; + if (length < kManualWorkoutReflectionMinChars) { + return '${entry.key} must be at least $kManualWorkoutReflectionMinChars ' + 'characters (currently $length)'; + } + } + + return null; +} + +String? _validateTableTennis(ManualWorkoutDraft draft) { + final scores = { + 'Matches won': draft.matchesWon, + 'Matches lost': draft.matchesLost, + 'Sets won': draft.setsWon, + 'Sets lost': draft.setsLost, + }; + for (final entry in scores.entries) { + if (entry.value < 0) return '${entry.key} cannot be negative'; + } + if (draft.matchesWon + draft.matchesLost == 0) { + return 'Enter at least one match played (won + lost)'; + } + if (draft.racket.trim().isEmpty) return 'Racket is required'; + if (draft.balls.trim().isEmpty) return 'Balls are required'; + return null; +} + +String? _validateOtherSport(ManualWorkoutDraft draft) { + if (draft.activityTypeOther.trim().isEmpty) { + return 'Activity type is required'; + } + final length = draft.activityDetails.trim().length; + if (length < kManualWorkoutDescriptionMinChars) { + return 'What was done must be at least ' + '$kManualWorkoutDescriptionMinChars characters (currently $length)'; + } + return null; +}