Manual-workout phone sync P4: Dart model + validation

Dart port of the Python manual-workout model so the phone can build the same
signed-on-ingest sync payload the PC form produces.

- lib/models/manual_workout.dart: ManualWorkoutDraft (mirrors the Python
  dataclass), validateManualWorkout (ports validate_manual_workout: required
  fields, HH:MM end-after-start, min duration, RPE range, per-sport rules,
  reflection length), buildEntry/buildSyncPayload (byte-for-byte equivalent of
  the Python build_entry + kind/date), manualSyncRecordId, buildManualRecord.
  The MANUAL_WORKOUT_* constants are inlined here (a const-only file has no
  coverable lines and trips the 100%-coverage file-presence guard), replacing
  models/manual_workout_constants.dart.
- test/models/manual_workout_test.dart: the cross-language wire fixture (an
  identical literal to test_manual_workout.py::TestSyncWireFormat — this catches
  key-name/format drift between the two serializers) plus full validation
  coverage. 29 tests; manual_workout.dart is 100% covered.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PM3auNTifaVgcXWDizZrjH
This commit is contained in:
Krzysztof Rudnicki 2026-07-13 12:55:05 +02:00
parent 7722260eeb
commit 6f2ec1fa86
3 changed files with 605 additions and 38 deletions

View File

@ -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<String> kSportChoices = [kSportTableTennis, kSportOther];
/// Human labels for the sport codes, in menu order.
const Map<String, String> 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 = <String, String>{
'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 = <String, String>{
'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 = <String, int>{
'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<String, Object?> buildEntry(ManualWorkoutDraft draft) {
final duration = durationMinutes(draft);
final activityLabel = draft.sport == kSportTableTennis
? 'table tennis'
: draft.activityTypeOther.trim();
final entry = <String, Object?>{
'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(<String, Object?>{
'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(<String, Object?>{
'activity_details': draft.activityDetails.trim(),
'equipment': draft.equipment.trim(),
});
}
return entry;
}
/// Builds the cross-device sync payload: [buildEntry] + `kind` + `date`.
Map<String, Object?> buildSyncPayload(ManualWorkoutDraft draft, String date) {
return <String, Object?>{
...buildEntry(draft),
'kind': kManualWorkoutSyncKind,
'date': date,
};
}
/// The stable crdt-sync record id for a manual workout.
///
/// Format: `manual:<date>T<HH:MM>`.
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)},
);
}

View File

@ -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';

View File

@ -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 = <String, Object?>{
'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);
});
});
}