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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
This commit is contained in:
Krzysztof Rudnicki 2026-08-15 21:41:59 +02:00
parent 5977475021
commit 9e4c45dfa3
4 changed files with 202 additions and 180 deletions

View File

@ -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 = <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';
}
// 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 = <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.
///
@ -329,82 +232,3 @@ Map<String, Object?> buildEntry(ManualWorkoutDraft draft) {
}
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)},
);
}
/// 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:<date>T<HH:MM>`), so same-day
/// workouts at different times arrive as distinct payloads.
ManualBudget countManualBudget(
Iterable<Map<String, dynamic>> 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);
}

View File

@ -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:<date>T<HH:MM>`), so same-day
/// workouts at different times arrive as distinct payloads.
ManualBudget countManualBudget(
Iterable<Map<String, dynamic>> 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);
}

View File

@ -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<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)},
);
}
/// 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;
}

View File

@ -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 = <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';
}
// 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 = <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;
}