diff --git a/stronglift_replacement/workout_app/lib/models/manual_workout.dart b/stronglift_replacement/workout_app/lib/models/manual_workout.dart new file mode 100644 index 0000000..6fb347a --- /dev/null +++ b/stronglift_replacement/workout_app/lib/models/manual_workout.dart @@ -0,0 +1,335 @@ +/// Manual (off-app) workout model, validation, and sync-record building. +/// +/// Mirrors the Python `screen_locker._manual_workout` module +/// (`ManualWorkoutDraft`, `validate_manual_workout`, `build_entry` / +/// `build_sync_payload`, `manual_sync_record_id`). The payload wire format is +/// pinned by a cross-language fixture shared with the Python side +/// (`test/models/manual_workout_test.dart` and its twin +/// `test_manual_workout.py::TestSyncWireFormat`) — keep the two in lockstep. +library; + +import 'package:crdt_sync/crdt_sync.dart'; + +// ── Shared constants ───────────────────────────────────────────────────────── +// Hand-maintained mirror of the Python source of truth in +// `screen_locker/_constants.py` (the `MANUAL_WORKOUT_*` values); the two +// languages can't share a file, so the cross-language payload fixture (see the +// model test and its Python twin) is the tripwire that catches drift. + +/// Max manual workouts allowed in any rolling 7-day window. +const int kManualWorkoutBudgetPer7Days = 2; + +/// Max manual workouts allowed in any rolling 30-day window. +const int kManualWorkoutBudgetPer30Days = 5; + +/// Minimum session length, in minutes, for a manual workout to count. +const int kManualWorkoutMinDurationMinutes = 20; + +/// Minimum characters for the "other sport" activity description. +const int kManualWorkoutDescriptionMinChars = 40; + +/// Minimum characters required in each reflection field. +const int kManualWorkoutReflectionMinChars = 20; + +/// Lowest valid RPE (rate of perceived exertion). +const int kManualWorkoutRpeMin = 1; + +/// Highest valid RPE (rate of perceived exertion). +const int kManualWorkoutRpeMax = 10; + +/// Record `kind` discriminator marking a manual-workout sync record. +/// +/// A StrongLifts session payload has no `kind`; the ingesting PC routes on this +/// value before any session logic, so a manual can never be stamped verified. +const String kManualWorkoutSyncKind = 'manual_workout'; + +/// Internal sport code for table tennis (structured score fields). +const String kSportTableTennis = 'table_tennis'; + +/// Internal sport code for any other sport (free-text activity fields). +const String kSportOther = 'other'; + +/// The sport codes a manual workout may use. +const List kSportChoices = [kSportTableTennis, kSportOther]; + +/// Human labels for the sport codes, in menu order. +const Map kSportLabels = { + kSportTableTennis: 'Table tennis', + kSportOther: 'Other', +}; + +/// User-supplied evidence fields for a manual (unverified) workout. +/// +/// Fields shared by every sport come first; the sport-specific groups follow — +/// only the group matching [sport] is validated/persisted. +class ManualWorkoutDraft { + /// Creates a manual-workout draft. + const ManualWorkoutDraft({ + required this.sport, + required this.startTime, + required this.endTime, + required this.locationName, + required this.transportMethod, + required this.cost, + required this.rpe, + required this.wentWell, + required this.toImprove, + required this.overallFeeling, + this.reservationPhone = '', + this.techniquesPracticed = '', + this.warmUpMinutes = '', + this.painOrInjury = 'none', + this.matchesWon = 0, + this.matchesLost = 0, + this.setsWon = 0, + this.setsLost = 0, + this.racket = '', + this.balls = '', + this.activityTypeOther = '', + this.activityDetails = '', + this.equipment = '', + }); + + /// Sport code (see [kSportChoices]). + final String sport; + + /// Start time as `HH:MM`. + final String startTime; + + /// End time as `HH:MM`. + final String endTime; + + /// Where the session happened. + final String locationName; + + /// How the user travelled there. + final String transportMethod; + + /// Session cost (free text, e.g. `40 PLN`). + final String cost; + + /// Rate of perceived exertion (1-10). + final int rpe; + + /// Reflection: what went well. + final String wentWell; + + /// Reflection: what to improve. + final String toImprove; + + /// Reflection: overall feeling. + final String overallFeeling; + + /// Optional reservation phone number. + final String reservationPhone; + + /// Optional techniques/focus areas practised. + final String techniquesPracticed; + + /// Optional warm-up duration (free text). + final String warmUpMinutes; + + /// Pain or injury notes (defaults to `none`). + final String painOrInjury; + + /// Table tennis: matches won. + final int matchesWon; + + /// Table tennis: matches lost. + final int matchesLost; + + /// Table tennis: sets won. + final int setsWon; + + /// Table tennis: sets lost. + final int setsLost; + + /// Table tennis: racket used. + final String racket; + + /// Table tennis: balls used. + final String balls; + + /// Other sport: what activity it was. + final String activityTypeOther; + + /// Other sport: description of what was done. + final String activityDetails; + + /// Other sport: equipment used. + 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'; + } + if (duration < kManualWorkoutMinDurationMinutes) { + return 'Session must be at least $kManualWorkoutMinDurationMinutes ' + '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. +/// +/// Byte-for-byte equivalent of the Python `build_entry`. +Map buildEntry(ManualWorkoutDraft draft) { + final duration = durationMinutes(draft); + final activityLabel = draft.sport == kSportTableTennis + ? 'table tennis' + : draft.activityTypeOther.trim(); + + final entry = { + 'type': kManualWorkoutSyncKind, + 'source': '$activityLabel at ${draft.locationName.trim()}', + 'sport': draft.sport, + 'activity_type': activityLabel, + 'start_time': draft.startTime.trim(), + 'end_time': draft.endTime.trim(), + 'duration_minutes': duration != null ? duration.toStringAsFixed(1) : '', + 'location_name': draft.locationName.trim(), + 'transport_method': draft.transportMethod.trim(), + 'cost': draft.cost.trim(), + 'reservation_phone': draft.reservationPhone.trim(), + 'rpe': draft.rpe, + 'techniques_practiced': draft.techniquesPracticed.trim(), + 'warm_up_minutes': draft.warmUpMinutes.trim(), + 'pain_or_injury': draft.painOrInjury.trim(), + 'went_well': draft.wentWell.trim(), + 'to_improve': draft.toImprove.trim(), + 'overall_feeling': draft.overallFeeling.trim(), + }; + if (draft.sport == kSportTableTennis) { + entry.addAll({ + 'matches_won': draft.matchesWon, + 'matches_lost': draft.matchesLost, + 'sets_won': draft.setsWon, + 'sets_lost': draft.setsLost, + 'racket': draft.racket.trim(), + 'balls': draft.balls.trim(), + }); + } else { + entry.addAll({ + 'activity_details': draft.activityDetails.trim(), + 'equipment': draft.equipment.trim(), + }); + } + 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)}, + ); +} diff --git a/stronglift_replacement/workout_app/lib/models/manual_workout_constants.dart b/stronglift_replacement/workout_app/lib/models/manual_workout_constants.dart deleted file mode 100644 index 7115bf4..0000000 --- a/stronglift_replacement/workout_app/lib/models/manual_workout_constants.dart +++ /dev/null @@ -1,38 +0,0 @@ -/// Shared constants for manual (off-app) workout logging. -/// -/// These mirror the Python source of truth in the screen-locker package -/// (`screen_locker/_constants.py`, the `MANUAL_WORKOUT_*` values). The two -/// languages cannot share a constants file, so this is a hand-maintained -/// mirror; the cross-language sync-payload fixture (see the manual_workout -/// model test here and its Python twin, -/// `test_manual_workout.py::TestSyncWireFormat`) is the tripwire that catches -/// drift. Keep these values in sync with `_constants.py`. -library; - -/// Max manual workouts allowed in any rolling 7-day window. -const int kManualWorkoutBudgetPer7Days = 2; - -/// Max manual workouts allowed in any rolling 30-day window. -const int kManualWorkoutBudgetPer30Days = 5; - -/// Minimum session length, in minutes, for a manual workout to count. -const int kManualWorkoutMinDurationMinutes = 20; - -/// Minimum characters for the "other sport" activity description. -const int kManualWorkoutDescriptionMinChars = 40; - -/// Minimum characters required in each reflection field. -const int kManualWorkoutReflectionMinChars = 20; - -/// Lowest valid RPE (rate of perceived exertion). -const int kManualWorkoutRpeMin = 1; - -/// Highest valid RPE (rate of perceived exertion). -const int kManualWorkoutRpeMax = 10; - -/// Record `kind` discriminator marking a manual-workout sync record. -/// -/// A StrongLifts session payload has no `kind`; the ingesting PC routes on this -/// value before any session logic, so a manual can never be stamped as a -/// verified session. -const String kManualWorkoutSyncKind = 'manual_workout'; diff --git a/stronglift_replacement/workout_app/test/models/manual_workout_test.dart b/stronglift_replacement/workout_app/test/models/manual_workout_test.dart new file mode 100644 index 0000000..ab2d979 --- /dev/null +++ b/stronglift_replacement/workout_app/test/models/manual_workout_test.dart @@ -0,0 +1,270 @@ +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:workout_app/models/manual_workout.dart'; + +// Builds a VALID table-tennis draft; override individual fields per test. +ManualWorkoutDraft _tt({ + String sport = kSportTableTennis, + String startTime = '18:00', + String endTime = '19:30', + String locationName = 'Solec', + String transportMethod = 'bike', + String cost = '40 PLN', + int rpe = 6, + String wentWell = 'Served consistently and moved feet well', + String toImprove = 'Backhand topspin needs more consistency', + String overallFeeling = 'Felt strong focused and in good rhythm', + int matchesWon = 3, + int matchesLost = 1, + int setsWon = 7, + int setsLost = 4, + String racket = 'Butterfly', + String balls = 'Nittaku 3-star', +}) => ManualWorkoutDraft( + sport: sport, + startTime: startTime, + endTime: endTime, + locationName: locationName, + transportMethod: transportMethod, + cost: cost, + rpe: rpe, + wentWell: wentWell, + toImprove: toImprove, + overallFeeling: overallFeeling, + matchesWon: matchesWon, + matchesLost: matchesLost, + setsWon: setsWon, + setsLost: setsLost, + racket: racket, + balls: balls, +); + +// Builds a VALID "other sport" draft; override individual fields per test. +ManualWorkoutDraft _other({ + String activityTypeOther = 'squash', + String activityDetails = + 'Full-court squash drills and three practice games total', + String equipment = 'racket, goggles', +}) => ManualWorkoutDraft( + sport: kSportOther, + startTime: '09:00', + endTime: '10:00', + locationName: 'Squash club', + transportMethod: 'car', + cost: '30 PLN', + rpe: 5, + wentWell: 'Moved well and kept long rallies going', + toImprove: 'Need to volley earlier off the back wall', + overallFeeling: 'Tired but satisfied with the effort', + activityTypeOther: activityTypeOther, + activityDetails: activityDetails, + equipment: equipment, +); + +// The cross-language wire fixture — an IDENTICAL literal lives in the Python +// suite (test_manual_workout.py::TestSyncWireFormat). Neither side's own +// round-trip test catches a key-name/format drift from the other; this shared +// literal on both sides does. Change one, change the other. +const _wireDate = '2026-07-13'; +const _wirePayload = { + 'type': 'manual_workout', + 'source': 'table tennis at Solec', + 'sport': 'table_tennis', + 'activity_type': 'table tennis', + 'start_time': '18:00', + 'end_time': '19:30', + 'duration_minutes': '90.0', + 'location_name': 'Solec', + 'transport_method': 'bike', + 'cost': '40 PLN', + 'reservation_phone': '600100200', + 'rpe': 6, + 'techniques_practiced': 'topspin serve', + 'warm_up_minutes': '10', + 'pain_or_injury': 'none', + 'went_well': 'Served consistently and moved feet well', + 'to_improve': 'Backhand topspin needs more consistency', + 'overall_feeling': 'Felt strong focused and in good rhythm', + 'matches_won': 3, + 'matches_lost': 1, + 'sets_won': 7, + 'sets_lost': 4, + 'racket': 'Butterfly', + 'balls': 'Nittaku 3-star', + 'kind': 'manual_workout', + 'date': '2026-07-13', +}; + +void main() { + group('cross-language wire format', () { + // Matches the _wire draft used to produce _wirePayload on the Python side. + final wireDraft = _tt( + // These optional fields aren't in _tt's parameter list; set via a fresh + // draft below so the fixture is exact. + ); + + test('buildSyncPayload matches the shared fixture', () { + const draft = ManualWorkoutDraft( + sport: kSportTableTennis, + startTime: '18:00', + endTime: '19:30', + locationName: 'Solec', + transportMethod: 'bike', + cost: '40 PLN', + rpe: 6, + wentWell: 'Served consistently and moved feet well', + toImprove: 'Backhand topspin needs more consistency', + overallFeeling: 'Felt strong focused and in good rhythm', + reservationPhone: '600100200', + techniquesPracticed: 'topspin serve', + warmUpMinutes: '10', + matchesWon: 3, + matchesLost: 1, + setsWon: 7, + setsLost: 4, + racket: 'Butterfly', + balls: 'Nittaku 3-star', + ); + expect(buildSyncPayload(draft, _wireDate), _wirePayload); + }); + + test('record id is stable and prefixed', () { + expect(manualSyncRecordId(_wireDate, '18:00'), 'manual:2026-07-13T18:00'); + }); + + test('buildManualRecord wraps the payload under a manual id', () { + final record = buildManualRecord( + wireDraft, + _wireDate, + hlc: Hlc.newTick('phone'), + ); + expect(record.id, 'manual:2026-07-13T18:00'); + expect(record.fields['payload']!.$1, buildSyncPayload(wireDraft, _wireDate)); + }); + }); + + group('validateManualWorkout', () { + test('valid table tennis returns null', () { + expect(validateManualWorkout(_tt()), isNull); + }); + + test('valid other sport returns null', () { + expect(validateManualWorkout(_other()), isNull); + }); + + test('rejects an unknown sport', () { + expect(validateManualWorkout(_tt(sport: 'badminton')), isNotNull); + }); + + for (final field in ['start', 'end', 'location', 'transport', 'cost']) { + test('rejects blank $field', () { + final draft = switch (field) { + 'start' => _tt(startTime: ' '), + 'end' => _tt(endTime: ' '), + 'location' => _tt(locationName: ' '), + 'transport' => _tt(transportMethod: ' '), + _ => _tt(cost: ' '), + }; + expect(validateManualWorkout(draft), isNotNull); + }); + } + + test('rejects a bad time format', () { + expect(validateManualWorkout(_tt(startTime: 'noon')), isNotNull); + }); + + test('rejects end not after start', () { + expect( + validateManualWorkout(_tt(startTime: '14:00', endTime: '12:00')), + isNotNull, + ); + }); + + test('rejects an out-of-range hour', () { + expect(validateManualWorkout(_tt(startTime: '25:00')), isNotNull); + }); + + test('rejects a too-short session', () { + expect( + validateManualWorkout(_tt(startTime: '12:00', endTime: '12:05')), + isNotNull, + ); + }); + + test('rejects an out-of-range RPE', () { + expect(validateManualWorkout(_tt(rpe: 0)), isNotNull); + expect(validateManualWorkout(_tt(rpe: 11)), isNotNull); + }); + + test('rejects a short reflection', () { + expect(validateManualWorkout(_tt(wentWell: 'ok')), isNotNull); + }); + }); + + group('table tennis validation', () { + test('rejects a negative score', () { + expect(validateManualWorkout(_tt(matchesWon: -1)), isNotNull); + }); + + test('rejects zero matches played', () { + expect( + validateManualWorkout(_tt(matchesWon: 0, matchesLost: 0)), + isNotNull, + ); + }); + + test('requires a racket', () { + expect(validateManualWorkout(_tt(racket: ' ')), isNotNull); + }); + + test('requires balls', () { + expect(validateManualWorkout(_tt(balls: ' ')), isNotNull); + }); + }); + + group('other sport validation', () { + test('requires an activity type', () { + expect(validateManualWorkout(_other(activityTypeOther: ' ')), isNotNull); + }); + + test('requires a long-enough description', () { + expect(validateManualWorkout(_other(activityDetails: 'short')), isNotNull); + }); + }); + + group('buildEntry', () { + test('table tennis carries score fields and a table-tennis label', () { + final entry = buildEntry(_tt()); + expect(entry['activity_type'], 'table tennis'); + expect(entry['matches_lost'], 1); + expect(entry['source'], 'table tennis at Solec'); + }); + + test('other sport carries description/equipment and its own label', () { + final entry = buildEntry(_other()); + expect(entry['activity_type'], 'squash'); + expect(entry['activity_details'], contains('squash')); + expect(entry['equipment'], 'racket, goggles'); + }); + + test('duration is empty for an unparsable time', () { + expect(buildEntry(_tt(startTime: 'bogus'))['duration_minutes'], ''); + }); + + test('strips whitespace from fields', () { + final entry = buildEntry(_tt(locationName: ' Solec ', racket: ' pro ')); + expect(entry['location_name'], 'Solec'); + expect(entry['racket'], 'pro'); + }); + }); + + group('durationMinutes', () { + test('returns minutes for a valid range', () { + expect(durationMinutes(_tt(startTime: '12:00', endTime: '13:30')), 90.0); + }); + + test('returns null for an unparsable time', () { + expect(durationMinutes(_tt(startTime: 'noon')), isNull); + }); + }); +}