mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 19:07:16 +01:00
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:
parent
5977475021
commit
9e4c45dfa3
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user