workout_app: log manual workouts for future dates, planned list + delete
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
release-apk / release (APK) (push) Has been cancelled

The manual form takes a workout date from today to today+7 (never past). The budget is read as of that date: the fullest 7-/30-day window containing it, past and planned entries alike. History lists future manual entries under PLANNED (off the calendar) and deletes them until their day via a crdt-sync tombstone. Re-logging onto a tombstoned id is refused; a failed push keeps the form open with the reason. Shared fixture manual_budget_merge.json gains a 'planned' scenario (top-level expectations unchanged).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mmTBiMpvyfLuEAXc6nDYT
This commit is contained in:
Krzysztof Rudnicki 2026-10-16 15:36:01 +02:00
parent e12b840f8c
commit 8ba9ee93fa
20 changed files with 1257 additions and 157 deletions

View File

@ -3,7 +3,8 @@
// 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.
/// Counts manual workouts against the rolling 7-/30-day caps, as seen from
/// the workout date [date].
///
/// 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
@ -16,13 +17,21 @@ part of 'manual_workout.dart';
/// 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.
///
/// Since 2026-10-16 a workout can be PLANNED up to a week ahead, so the count
/// is no longer "the trailing window ending today". Each figure is the
/// fullest window of that length that contains [date], counting past and
/// future entries alike: adding an entry on [date] is only allowed if every
/// such window stays within its cap, and the fullest one is the binding
/// constraint. With no future entries and [date] = today this is exactly the
/// old trailing count, since every other window holds a subset of it.
ManualBudget countManualBudget(
Iterable<Map<String, dynamic>> payloads,
DateTime now,
DateTime date,
) {
final today = DateTime(now.year, now.month, now.day);
var week = 0;
var month = 0;
final target = _dayNumber(date);
// Offsets (in days) of every counted entry relative to [date].
final offsets = <int>[];
for (final payload in payloads) {
// Skip evidence-less `{type, kind, date}` stubs. The PC refuses to ingest
// these ("no workout data to ingest; skipping permanently"), so counting
@ -32,14 +41,33 @@ ManualBudget countManualBudget(
if (payload['start_time'] is! String) continue;
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++;
final entryDate = DateTime.tryParse(dateStr);
if (entryDate == null) continue;
offsets.add(_dayNumber(entryDate) - target);
}
return ManualBudget(week: week, month: month);
return ManualBudget(
week: _fullestWindow(offsets, 7),
month: _fullestWindow(offsets, 30),
);
}
/// Days since the epoch for [day]'s calendar date.
///
/// Built in UTC so a DST change between two local midnights (a 23- or 25-hour
/// "day") cannot truncate a one-day difference to zero.
int _dayNumber(DateTime day) =>
DateTime.utc(day.year, day.month, day.day).millisecondsSinceEpoch ~/
Duration.millisecondsPerDay;
/// The most entries any [length]-day window containing offset 0 holds.
int _fullestWindow(List<int> offsets, int length) {
var fullest = 0;
// A window containing day 0 starts anywhere from `length - 1` days before
// it up to day 0 itself.
for (var start = 1 - length; start <= 0; start++) {
final end = start + length - 1;
final count = offsets.where((o) => o >= start && o <= end).length;
if (count > fullest) fullest = count;
}
return fullest;
}

View File

@ -22,6 +22,9 @@ String manualSyncRecordId(String date, String startTime) =>
'manual:${date}T${startTime.trim()}';
/// Builds the crdt-sync [Record] for a manual workout, stamped with [hlc].
///
/// [date] is the WORKOUT date, which may be up to a week ahead of the day it
/// is logged on: the PC credits a planned entry on its own date, never early.
Record buildManualRecord(
ManualWorkoutDraft draft,
String date, {
@ -33,15 +36,28 @@ Record buildManualRecord(
);
}
/// The tombstone that deletes the manual workout [recordId] on every device.
///
/// Carries no fields: [mergeRecord] keeps whatever payload this device's log
/// already holds and only flips `deleted`, which is sticky. A record logged
/// on another device gets a field-less tombstone here, and both readers
/// (this app's and the PC's `_tombstoned_ids`) suppress across the whole
/// device union, so that is enough. Deleting `log.json` locally instead would
/// just be re-ingested on the next sync.
Record buildManualTombstone(String recordId, {required Hlc hlc}) =>
Record(id: recordId, fields: const {}, deleted: true, deletedHlc: 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.
/// Manual workouts in the fullest 7-day window containing the workout
/// date (see [countManualBudget]).
final int week;
/// Manual workouts in the last 30 days.
/// Manual workouts in the fullest 30-day window containing the workout
/// date.
final int month;
/// Whether either window has reached its limit.

View File

@ -11,6 +11,9 @@ import 'dart:math';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:workout_app/models/manual_workout.dart';
import 'package:workout_app/sandbox/sandbox_log.dart';
import 'package:workout_app/services/done_today.dart';
import 'package:workout_app/services/storage_service.dart';
import 'package:workout_app/services/workout_sync_service.dart';
import 'package:workout_app/ui/theme.dart';
@ -19,6 +22,7 @@ import 'package:workout_app/widgets/calendar_widget.dart';
part 'history_screen_charts.dart';
part 'history_screen_data.dart';
part 'history_screen_painter.dart';
part 'history_screen_planned.dart';
part 'history_screen_tiles.dart';
const _kTotal = 'Total (all workouts)';
@ -35,7 +39,12 @@ class HistoryScreen extends StatefulWidget {
/// and it asks Firebase that question directly. On a desktop that has a
/// cached refresh token the real factory answers yes, so the guard opens and
/// the fetch goes to the network even with the sync token faked away.
const HistoryScreen({super.key, this.httpClient, this.firebaseFactory});
const HistoryScreen({
super.key,
this.httpClient,
this.firebaseFactory,
this.clock,
});
/// Overrides the HTTP client used to read synced workouts (tests only).
final http.Client? httpClient;
@ -43,21 +52,18 @@ class HistoryScreen extends StatefulWidget {
/// Overrides how a Firebase account is discovered (tests only).
final Future<FirebaseRestClient?> Function()? firebaseFactory;
/// Decides which manual entries are still PLANNED (dated after today);
/// defaults to [DateTime.now], injected by tests.
final DateTime Function()? clock;
@override
State<HistoryScreen> createState() => _HistoryScreenState();
}
/// Synced kinds this phone has no local record of, so showing them can't
/// double up an existing session.
///
/// A `phone_verified` record is the PC's mirror of a StrongLifts session this
/// phone already stores in full (and renders from `_rows`), so it is
/// deliberately excluded — otherwise the same workout would appear twice.
const _kSyncedOnlyKinds = {'runnerup_verified', 'manual_workout'};
class _HistoryScreenState extends State<HistoryScreen> {
List<Map<String, dynamic>> _rows = [];
List<Map<String, dynamic>> _syncedRows = [];
List<Map<String, dynamic>> _plannedRows = [];
bool _loading = true;
String _selected = _kTotal;
List<String> _exerciseNames = [];
@ -96,11 +102,14 @@ class _HistoryScreenState extends State<HistoryScreen> {
state = await StorageService.instance.getExerciseState(_selected);
// coverage:ignore-end
}
final synced = _syncedOnly(remote);
final today = dayKey((widget.clock ?? DateTime.now)());
final synced = _syncedOnly(remote, today);
final planned = _plannedOnly(remote, today);
if (mounted) {
setState(() {
_rows = rows;
_syncedRows = synced;
_plannedRows = planned;
_exerciseNames = names;
_selectedState = state;
_loading = false;
@ -113,10 +122,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
/// Read once per load and used twice: to restore this device's own missing
/// sessions into local history, and to list the PC-only records below them.
Future<List<Map<String, dynamic>>> _readSyncedPayloads() =>
WorkoutSyncService(
httpClient: widget.httpClient,
firebaseFactory: widget.firebaseFactory,
).readMergedWorkoutPayloads();
_syncService().readMergedWorkoutPayloads();
Future<void> _pickExercise(String name) async {
ExerciseState? state;
@ -147,7 +153,9 @@ class _HistoryScreenState extends State<HistoryScreen> {
),
body: _loading
? const Center(child: CircularProgressIndicator())
: _rows.isEmpty
// Synced and planned entries count too: a phone with no local
// sessions must still list (and let the user delete) its plans.
: _rows.isEmpty && _syncedRows.isEmpty && _plannedRows.isEmpty
? Center(
child: Text(
'No workouts yet.',
@ -173,6 +181,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
}
List<Widget> _buildTotalView() => [
..._plannedSection(),
const _SectionLabel('TOTAL VOLUME (2-session rolling avg, kg)'),
const SizedBox(height: 6),
_WeightChart(points: _rollingAvg2(_totalVolumePoints(_rows))),

View File

@ -125,6 +125,14 @@ Set<String> _allWorkoutDates(
...syncedRows.map((r) => '${r['date']}'),
};
/// Synced kinds this phone has no local record of, so showing them can't
/// double up an existing session.
///
/// A `phone_verified` record is the PC's mirror of a StrongLifts session this
/// phone already stores in full (and renders from `_rows`), so it is
/// deliberately excluded — otherwise the same workout would appear twice.
const _kSyncedOnlyKinds = {'runnerup_verified', 'manual_workout'};
/// Workouts the PC published that this phone has no local record of.
///
/// The PC pushes its whole `log.json` (RunnerUp runs and manual
@ -132,6 +140,37 @@ Set<String> _allWorkoutDates(
/// the SAME history. Sorted newest-first to match the local session list.
/// StrongLifts sessions are deliberately excluded: they are restored into
/// local history instead, so listing them here too would double them up.
List<Map<String, dynamic>> _syncedOnly(List<Map<String, dynamic>> payloads) =>
payloads.where((p) => _kSyncedOnlyKinds.contains(p['kind'])).toList()
///
/// Planned manual entries (see [_isPlanned]) are excluded as well: they have
/// not happened, so they belong in their own list and never on the calendar,
/// which is built from these rows.
List<Map<String, dynamic>> _syncedOnly(
List<Map<String, dynamic>> payloads,
String today,
) =>
payloads
.where((p) => _kSyncedOnlyKinds.contains(p['kind']))
.where((p) => !_isPlanned(p, today))
.toList()
..sort((a, b) => '${b['date']}'.compareTo('${a['date']}'));
/// Manual entries dated after [today] (`YYYY-MM-DD`), soonest first.
List<Map<String, dynamic>> _plannedOnly(
List<Map<String, dynamic>> payloads,
String today,
) =>
payloads.where((p) => _isPlanned(p, today)).toList()
..sort((a, b) => '${a['date']}'.compareTo('${b['date']}'));
/// Whether [payload] is a manual workout dated after [today].
///
/// A plain string comparison is a date comparison for `YYYY-MM-DD` keys. Only
/// manual entries can be planned; anything else dated ahead is a clock skew,
/// not a plan, and is left where it was.
///
/// A stub with no `start_time` is never a plan: its record id cannot be
/// derived, so a delete would tombstone nothing and the tile would return.
bool _isPlanned(Map<String, dynamic> payload, String today) =>
payload['kind'] == kManualWorkoutSyncKind &&
payload['start_time'] is String &&
'${payload['date']}'.compareTo(today) > 0;

View File

@ -0,0 +1,152 @@
// Planned (future-dated) manual workouts: their section and their delete,
// plus the sync service both this and the load use.
//
// A `part` so the extension keeps reaching the private state. Nothing here
// calls `setState`: a delete ends in `_load`, which applies the reload.
part of 'history_screen.dart';
/// The PLANNED section of the history view.
extension _HistoryPlanned on _HistoryScreenState {
/// The sync service, wired to the test seams this screen was built with.
WorkoutSyncService _syncService() => WorkoutSyncService(
httpClient: widget.httpClient,
firebaseFactory: widget.firebaseFactory,
);
/// The planned entries, above everything that already happened; nothing
/// when there are none.
List<Widget> _plannedSection() => [
if (_plannedRows.isNotEmpty) ...[
const _SectionLabel('PLANNED (not done yet)'),
const SizedBox(height: 8),
..._plannedRows.map(
(row) => _PlannedWorkoutTile(
payload: row,
onDelete: () => _deletePlanned(row),
),
),
const SizedBox(height: 16),
],
];
/// Deletes the planned manual entry [payload] on every device, after a
/// confirmation, then re-reads history from sync.
///
/// Re-reading rather than dropping the tile: the tile only goes once the
/// tombstone is really in the log. A failed push keeps it and says why.
Future<void> _deletePlanned(Map<String, dynamic> payload) async {
final date = '${payload['date']}';
final id = manualSyncRecordId(date, '${payload['start_time']}');
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete planned workout?'),
content: Text(
'${payload['source'] ?? 'Manual workout'} on $date at '
'${payload['start_time']}. It will not be credited.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('KEEP'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('DELETE'),
),
],
),
);
if (confirmed != true || !mounted) return;
// Re-checked against the clock: "planned" was decided when History
// loaded, and a delete confirmed after midnight would cancel a workout
// that is now today's -- one the PC may already have credited.
if (!_isPlanned(payload, dayKey((widget.clock ?? DateTime.now)()))) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Not deleted — $date has started, so it is no longer '
'a planned workout.',
),
),
);
await _load();
return;
}
final result = await _syncService().tombstoneManual(id);
SandboxLog.event('manual planned delete', {
'id': id,
'pushed': result.pushed,
'reason': result.reason,
});
if (!mounted) return;
if (!result.pushed) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Not deleted — ${result.reason}')));
return;
}
await _load();
}
}
/// A manual workout dated after today: listed, but not as done.
///
/// The only tile with a delete button. A plan can be cancelled until its day
/// comes; anything that already happened stays history.
class _PlannedWorkoutTile extends StatelessWidget {
const _PlannedWorkoutTile({required this.payload, required this.onDelete});
final Map<String, dynamic> payload;
final VoidCallback onDelete;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final detail = '${payload['source'] ?? ''}';
final time = '${payload['start_time'] ?? ''}';
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.only(left: 14, top: 4, bottom: 4),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(AppRadius.sm),
border: Border.all(color: colorScheme.outline),
),
child: Row(
children: [
Icon(Icons.event, color: colorScheme.onSurfaceVariant, size: 20),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${payload['date']} $time · Planned',
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
),
),
if (detail.isNotEmpty)
Text(
detail,
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: AppTextSize.caption,
),
),
],
),
),
IconButton(
tooltip: 'Delete planned workout',
icon: Icon(Icons.delete_outline, color: colorScheme.error),
onPressed: onDelete,
),
],
),
);
}
}

View File

@ -0,0 +1,101 @@
// What the manual-workout form DOES: load the ledger, pick a date, submit.
//
// A `part` so the extension keeps reaching the private state; it mutates it
// through the `_applyState` shim because `setState` is `@protected` and
// cannot be called from an extension (same pattern as home_screen_sync.dart).
part of 'manual_workout_screen.dart';
/// Loading, date selection and submission for the manual-workout form.
extension _ManualWorkoutActions on _ManualWorkoutScreenState {
Future<void> _loadBudget() async {
final ledger = await _sync.readManualLedger();
if (!mounted) return;
_applyState(() {
_payloads = ledger.payloads;
_tombstoned = ledger.tombstoned;
_budgetLoaded = true;
});
}
Future<void> _pickDate() async {
final today = _today();
final picked = await showDatePicker(
context: context,
// Clamped: a form left open across midnight holds yesterday, and the
// picker asserts its initial date is inside the range.
initialDate: _date.isBefore(today) ? today : _date,
firstDate: today,
lastDate: today.add(const Duration(days: kManualWorkoutMaxDaysAhead)),
helpText: 'Workout date',
);
if (picked == null || !mounted) return;
SandboxLog.event('manual date picked', {'date': dayKey(picked)});
_applyState(() {
_date = DateTime(picked.year, picked.month, picked.day);
_error = null;
});
}
/// Why [_date] can no longer be submitted, or null when it can.
///
/// Re-checked at submit because the clock moves: a form opened at 23:58
/// with "today" selected is dated yesterday by the time it is sent.
String? _dateError() {
final today = _today();
if (_date.isBefore(today)) {
return 'The workout date ${dayKey(_date)} is in the past — pick '
'today or a day ahead.';
}
final last = today.add(const Duration(days: kManualWorkoutMaxDaysAhead));
if (_date.isAfter(last)) {
return 'A workout can be planned at most $kManualWorkoutMaxDaysAhead '
'days ahead.';
}
return null;
}
Future<void> _submit() async {
final draft = _draft();
final date = dayKey(_date);
final recordId = manualSyncRecordId(date, draft.startTime);
final error =
validateManualWorkout(draft) ??
_dateError() ??
// A deleted record's id stays deleted forever (tombstones are
// sticky), so re-logging the same date and start time would vanish
// on every device. Refuse it here, where the user can still act.
(_tombstoned.contains(recordId)
? 'A workout at ${draft.startTime.trim()} on $date was deleted '
'earlier and cannot be logged again — use a different '
'start time.'
: null);
if (error != null) {
SandboxLog.event('manual submit refused', {'reason': error});
_applyState(() => _error = error);
return;
}
_applyState(() {
_error = null;
_submitting = true;
});
final result = await _sync.pushManual(
buildManualRecord(draft, date, hlc: Hlc.newTick(currentSyncDeviceId)),
);
SandboxLog.event('manual submit', {
'id': recordId,
'pushed': result.pushed,
'reason': result.reason,
});
if (!mounted) return;
if (!result.pushed) {
// Popping here used to report a workout that never left the phone as
// logged. Stay on the filled form and say why, so it can be retried.
_applyState(() {
_submitting = false;
_error = 'Not saved — ${result.reason}';
});
return;
}
Navigator.of(context).pop(true);
}
}

View File

@ -8,15 +8,54 @@ part of 'manual_workout_screen.dart';
/// The form's non-interactive building blocks.
extension _ManualWorkoutFormParts on _ManualWorkoutScreenState {
/// The budget as seen from the selected date: the fullest 7- and 30-day
/// windows around it, planned entries included.
Widget _budgetBanner(ManualBudget budget) => Text(
'Manual: ${budget.week}/$kManualWorkoutBudgetPer7Days this week · '
'${budget.month}/$kManualWorkoutBudgetPer30Days this month',
'Manual around ${dayKey(_date)}: '
'${budget.week}/$kManualWorkoutBudgetPer7Days per 7 days · '
'${budget.month}/$kManualWorkoutBudgetPer30Days per 30 days',
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontSize: AppTextSize.body,
),
);
/// "today", "tomorrow" or "in N days" for the selected date.
String _relativeDay() {
// Rounded, not truncated: across a DST change two local midnights are
// 23 or 25 hours apart.
final days = (_date.difference(_today()).inHours / 24).round();
return switch (days) {
0 => 'today',
1 => 'tomorrow',
_ => 'in $days days',
};
}
/// The workout date and the button that changes it.
Widget _dateRow() => Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
child: Row(
children: [
Expanded(
child: Text(
'Workout date: ${dayKey(_date)} (${_relativeDay()})',
key: const Key('mw_date'),
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontSize: AppTextSize.body,
),
),
),
TextButton(
key: const Key('mw_pick_date'),
onPressed: _submitting ? null : _pickDate,
child: const Text('CHANGE DATE'),
),
],
),
);
Widget _section(String title) => Padding(
padding: const EdgeInsets.only(top: 16, bottom: 4),
child: Text(

View File

@ -5,6 +5,11 @@
/// (2/week, 5/month) computed over the merged cross-device record set. On
/// submit it builds the shared sync payload and pushes it to the phone's device
/// log; the PC ingests it as a counted `manual_workout`.
///
/// The workout date defaults to today and may be set up to
/// [kManualWorkoutMaxDaysAhead] days ahead (never back): a planned session is
/// logged now and credited by the PC on its own date. The budget is read as
/// of the chosen date, so moving the date re-evaluates it.
library;
import 'dart:async';
@ -12,11 +17,19 @@ import 'dart:async';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:flutter/material.dart';
import 'package:workout_app/models/manual_workout.dart';
import 'package:workout_app/sandbox/sandbox_log.dart';
import 'package:workout_app/services/done_today.dart';
import 'package:workout_app/services/sync_device_id.dart';
import 'package:workout_app/services/workout_sync_service.dart';
import 'package:workout_app/ui/theme.dart';
part 'manual_workout_actions.dart';
part 'manual_workout_form_parts.dart';
/// How many days ahead a manual workout may be dated. Past dates are never
/// offered: a manual entry is either today's or a planned one.
const int kManualWorkoutMaxDaysAhead = 7;
/// A screen that logs an off-app workout and syncs it to the PC.
class ManualWorkoutScreen extends StatefulWidget {
/// Creates the manual-workout form. [syncService]/[clock] are injectable for
@ -69,9 +82,22 @@ class _ManualWorkoutScreenState extends State<ManualWorkoutScreen> {
double _rpe = 5;
String? _error;
bool _submitting = false;
ManualBudget? _budget;
List<Map<String, dynamic>> _payloads = const [];
Set<String> _tombstoned = const {};
bool _budgetLoaded = false;
/// The workout date (a calendar day, no time). Starts as today.
late DateTime _date = _today();
DateTime _today() {
final now = _clock();
return DateTime(now.year, now.month, now.day);
}
/// Read fresh on every build, so a date change re-evaluates it without
/// another round trip to the sync backend.
ManualBudget get _budget => countManualBudget(_payloads, _date);
@override
void initState() {
super.initState();
@ -86,43 +112,15 @@ class _ManualWorkoutScreenState extends State<ManualWorkoutScreen> {
super.dispose();
}
Future<void> _loadBudget() async {
final payloads = await _sync.readMergedManualPayloads();
if (!mounted) return;
setState(() {
_budget = countManualBudget(payloads, _clock());
_budgetLoaded = true;
});
}
Future<void> _submit() async {
final draft = _draft();
final error = validateManualWorkout(draft);
if (error != null) {
setState(() => _error = error);
return;
}
setState(() {
_error = null;
_submitting = true;
});
final now = _clock();
final date =
'${now.year.toString().padLeft(4, '0')}-'
'${now.month.toString().padLeft(2, '0')}-'
'${now.day.toString().padLeft(2, '0')}';
await _sync.pushManual(
buildManualRecord(draft, date, hlc: Hlc.newTick('phone')),
);
if (!mounted) return;
Navigator.of(context).pop(true);
}
/// Runs [fn] inside `setState` on behalf of this library's extensions,
/// which cannot call the `@protected` original.
void _applyState(VoidCallback fn) => setState(fn);
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final budget = _budget;
final exhausted = budget?.exhausted ?? false;
final exhausted = budget.exhausted;
return Scaffold(
appBar: AppBar(
backgroundColor: colorScheme.surfaceContainerHigh,
@ -136,12 +134,16 @@ class _ManualWorkoutScreenState extends State<ManualWorkoutScreen> {
: ListView(
padding: const EdgeInsets.all(16),
children: [
_budgetBanner(budget!),
// Above the exhausted branch on purpose: a full window around
// today must not stop the user picking a free day later on.
_dateRow(),
_budgetBanner(budget),
if (exhausted)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
'Manual-workout budget exhausted for this window.',
'Manual-workout budget exhausted for a window '
'containing ${dayKey(_date)} — pick another date.',
style: TextStyle(color: colorScheme.error),
),
)

View File

@ -212,6 +212,22 @@ class WorkoutSyncService {
Future<List<Map<String, dynamic>>> readMergedManualPayloads() =>
_readMergedPayloads(kind: kManualWorkoutSyncKind);
/// [readMergedManualPayloads] plus every tombstoned id, from ONE read.
///
/// The manual-workout form needs both: the payloads for the budget, the
/// ids to refuse re-logging onto a deleted record (see [ManualLedger]).
Future<ManualLedger> readManualLedger() =>
_readMergedLedger(kind: kManualWorkoutSyncKind);
/// Deletes the manual workout [recordId] everywhere: a crdt-sync
/// tombstone through [pushManual]. Only planned (future-dated) entries
/// are offered for deletion; the caller enforces that. Same never-throw
/// contract as [push]: a failed tombstone leaves the record live, and
/// says so.
Future<PushResult> tombstoneManual(String recordId) => pushManual(
buildManualTombstone(recordId, hlc: Hlc.newTick(currentSyncDeviceId)),
);
/// Every synced workout, whatever kind — manual, StrongLifts or RunnerUp.
///
/// The PC publishes its whole `log.json` (including verified runs),

View File

@ -3,18 +3,37 @@
// An `extension` in a `part` -- see storage_service_backup.dart for why.
part of 'workout_sync_service.dart';
/// What the sync backends hold: the live payloads plus every tombstoned id.
///
/// The ids matter to the manual-workout form because a manual record's id is
/// derived (`manual:<date>T<HH:MM>`) and deletion is sticky: re-logging the
/// same date and start time after deleting a planned entry would land on the
/// tombstone and vanish on every device without a word.
typedef ManualLedger = ({
List<Map<String, dynamic>> payloads,
Set<String> tombstoned,
});
/// The ledger an unreadable or unconfigured backend answers with.
const ManualLedger _emptyLedger = (
payloads: <Map<String, dynamic>>[],
tombstoned: <String>{},
);
/// Reads merged workout/manual payloads across every device log.
extension WorkoutSyncServiceRead on WorkoutSyncService {
Future<List<Map<String, dynamic>>> _readMergedPayloads({
String? kind,
}) async => (await _readMergedLedger(kind: kind)).payloads;
Future<List<Map<String, dynamic>>> _readMergedPayloads({String? kind}) async {
Future<ManualLedger> _readMergedLedger({String? kind}) async {
final settings = await SyncSettings.load();
if (!settings.isConfigured && !await _hasFirebaseAccount()) {
debugPrint(
'WorkoutSyncService: NOT reading synced workouts — no sync backend '
'configured in Settings, so this phone cannot see the PC history.',
);
return const [];
return _emptyLedger;
}
try {
@ -42,14 +61,11 @@ extension WorkoutSyncServiceRead on WorkoutSyncService {
'WorkoutSyncService: FAILED reading synced workouts '
'(kind=$which) from $owner/$repo: $failure — history may be incomplete.',
);
return const [];
return _emptyLedger;
}
}
Future<List<Map<String, dynamic>>> _fetchPayloads(
String token,
String? kind,
) async {
Future<ManualLedger> _fetchPayloads(String token, String? kind) async {
final github = GitHubClient(
owner: owner,
repo: repo,
@ -79,9 +95,14 @@ extension WorkoutSyncServiceRead on WorkoutSyncService {
// delete it, and a per-file skip would just let the live copy win the
// merge. Mirrors the PC's `_tombstoned_ids` (screen_locker/_sync_records.py).
tombstoned.forEach(merged.remove);
return merged.values
.map((r) => (r.fields['payload']!.$1! as Map).cast<String, dynamic>())
.toList();
return (
payloads: merged.values
.map(
(r) => (r.fields['payload']!.$1! as Map).cast<String, dynamic>(),
)
.toList(),
tombstoned: tombstoned,
);
} finally {
github.close();
firebase?.close();
@ -118,5 +139,4 @@ extension WorkoutSyncServiceRead on WorkoutSyncService {
}
}
}
}

View File

@ -110,4 +110,43 @@ void main() {
expect(budget.week, 0);
expect(budget.month, 0);
});
// The `planned` key (2026-10-16): future-dated entries. The top-level
// expectations above are the PC's trailing-window reading and stay as-is;
// this block pins the phone's admission rule for a workout dated D -- the
// fullest 7-/30-day window CONTAINING D, past and planned entries alike.
group('planned (future-dated) entries', () {
Map<String, dynamic> planned() =>
_fixture()['planned'] as Map<String, dynamic>;
List<dynamic> allRecords() => [
..._fixture()['records'] as List<dynamic>,
...planned()['extra_records'] as List<dynamic>,
];
test('budget for each date matches the shared expectation', () {
final expected = planned()['expected_by_date'] as Map<String, dynamic>;
final payloads = _livePayloads(_deviceLogJson(allRecords()));
expect(expected, isNotEmpty);
for (final entry in expected.entries) {
final want = entry.value as Map<String, dynamic>;
final budget = countManualBudget(payloads, DateTime.parse(entry.key));
expect(budget.week, want['week'], reason: entry.key);
expect(budget.month, want['month'], reason: entry.key);
expect(budget.exhausted, want['exhausted'], reason: entry.key);
}
});
test('a cancelled plan frees its weekly slot', () {
final resurrected = [
for (final entry in allRecords().cast<Map<String, dynamic>>())
{...entry, 'deleted': false},
];
final budget = countManualBudget(
_livePayloads(_deviceLogJson(resurrected)),
DateTime(2026, 8, 28),
);
expect(budget.week, 2);
expect(budget.exhausted, isTrue);
});
});
}

View File

@ -143,15 +143,63 @@ void main() {
expect(budget.week, 3);
});
test('ignores dates outside the 30-day window and future dates', () {
test('ignores dates outside every window containing the date', () {
final budget = countManualBudget([
payload('2026-05-01', '10:00'),
payload('2026-07-20', '10:00'),
payload('2026-09-20', '10:00'),
], now);
expect(budget.week, 0);
expect(budget.month, 0);
});
// Planned entries (2026-10-16): the count is the fullest window that
// CONTAINS the workout date, so a future entry costs budget too.
test('a future entry counts against a window containing the date', () {
final budget = countManualBudget([payload('2026-07-20', '10:00')], now);
expect(budget.week, 1);
expect(budget.month, 1);
});
test('a past and a future entry six days apart exhaust the week', () {
// 07-13 .. 07-19 is one 7-day window holding both, and it contains
// 07-16; the trailing window alone would have read 1/2.
final budget = countManualBudget([
payload('2026-07-13', '10:00'),
payload('2026-07-19', '10:00'),
], now);
expect(budget.week, 2);
expect(budget.exhausted, isTrue);
});
test('moving the date away from a full week frees it', () {
final payloads = [
payload('2026-07-13', '10:00'),
payload('2026-07-19', '10:00'),
];
// 07-26: windows containing it start 07-20 or later, past both.
final later = countManualBudget(payloads, DateTime(2026, 7, 26));
expect(later.week, 0);
expect(later.month, 2);
// 07-23: the window 07-17 .. 07-23 still holds 07-19.
expect(countManualBudget(payloads, DateTime(2026, 7, 23)).week, 1);
});
test('the date may carry a time of day', () {
final budget = countManualBudget([
payload('2026-07-16', '10:00'),
], DateTime(2026, 7, 16, 23, 59));
expect(budget.week, 1);
});
test('a DST change between dates does not shift the day count', () {
// Europe's 2026 autumn change is 10-25; 10-19 .. 10-25 is one window.
final budget = countManualBudget([
payload('2026-10-19', '10:00'),
payload('2026-10-25', '10:00'),
], DateTime(2026, 10, 25));
expect(budget.week, 2);
});
test('skips payloads with missing or unparsable date', () {
final budget = countManualBudget([
{'start_time': '10:00'},

View File

@ -0,0 +1,36 @@
// The tombstone that deletes a planned manual workout on every device.
//
// Split from manual_workout_test.dart for the 250-line cap.
import 'package:crdt_sync/crdt_sync.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:workout_app/models/manual_workout.dart';
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',
);
void main() {
test('buildManualTombstone is a field-less, deleted record', () {
final hlc = Hlc.newTick('phone');
final tombstone = buildManualTombstone('manual:2026-07-13T18:00', hlc: hlc);
expect(tombstone.id, 'manual:2026-07-13T18:00');
expect(tombstone.fields, isEmpty);
expect(tombstone.deleted, isTrue);
expect(tombstone.deletedHlc, hlc);
// Merged onto the live record it keeps the payload and stays deleted.
final live = buildManualRecord(_draft, '2026-07-13', hlc: hlc);
final merged = mergeRecord(live, tombstone);
expect(merged.deleted, isTrue);
expect(merged.fields['payload'], live.fields['payload']);
expect(tombstone.toJson()['deleted_hlc'], hlc.toStr());
});
}

View File

@ -34,20 +34,20 @@ Future<void> pumpHistory(WidgetTester tester, Widget w) async {
Widget wrapHistory({
http.Client? httpClient,
Future<FirebaseRestClient?> Function()? firebaseFactory,
DateTime Function()? clock,
}) => MaterialApp(
theme: buildAppTheme(),
home: HistoryScreen(
httpClient: httpClient,
firebaseFactory: firebaseFactory ?? () async => null,
clock: clock,
),
);
/// A GitHub mock serving one PC device log holding a run and a manual.
http.Client historySyncMock() {
String file(String body) => jsonEncode({
'content': base64Encode(utf8.encode(body)),
'sha': 'sha',
});
String file(String body) =>
jsonEncode({'content': base64Encode(utf8.encode(body)), 'sha': 'sha'});
Record rec(String id, String kind, int ms) => Record(
id: id,
fields: {

View File

@ -0,0 +1,80 @@
// Shared fixtures for the manual-workout form tests.
//
// Hoisted to top level so the split test files import one definition (see
// _history_test_fixtures.dart for why local copies drift).
import 'package:crdt_sync/crdt_sync.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:workout_app/services/workout_sync_service.dart';
class FakeManualSync extends WorkoutSyncService {
FakeManualSync({
this.payloads = const [],
this.tombstoned = const {},
this.result = const PushResult(pushed: true, reason: 'pushed'),
});
final List<Map<String, dynamic>> payloads;
final Set<String> tombstoned;
final PushResult result;
Record? pushed;
@override
Future<ManualLedger> readManualLedger() async =>
(payloads: payloads, tombstoned: tombstoned);
@override
Future<PushResult> pushManual(Record record) async {
pushed = record;
return result;
}
}
Map<String, dynamic> manualEntry(String date) => {
'kind': 'manual_workout',
'date': date,
'start_time': '18:00',
'end_time': '19:00',
};
/// Opens the date picker, taps [day] of the shown month, confirms.
Future<void> pickDay(WidgetTester tester, String day) async {
await tester.tap(find.text('CHANGE DATE'));
await tester.pumpAndSettle();
await tester.tap(find.text(day));
await tester.tap(find.text('OK'));
await tester.pumpAndSettle();
}
DateTime fixedManualNow() => DateTime(2026, 7, 13, 20);
Future<void> pumpManual(WidgetTester tester, Widget child) async {
// Tall viewport so the whole (long) form is laid out — a ListView only builds
// children within the viewport, so off-screen widgets aren't findable.
tester.view.physicalSize = const Size(500, 2600);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(MaterialApp(home: child));
await tester.pumpAndSettle(); // let the budget future resolve
}
Future<void> fillValidTt(WidgetTester tester) async {
Future<void> enter(String key, String value) =>
tester.enterText(find.byKey(Key('mw_$key')), value);
await enter('start_time', '18:00');
await enter('end_time', '19:30');
await enter('location_name', 'Solec');
await enter('transport_method', 'bike');
await enter('cost', '40 PLN');
await enter('matches_won', '3');
await enter('matches_lost', '1');
await enter('sets_won', '7');
await enter('sets_lost', '4');
await enter('racket', 'Butterfly');
await enter('balls', 'Nittaku');
await enter('pain_or_injury', 'sore knee');
await enter('went_well', 'Served consistently and moved feet well');
await enter('to_improve', 'Backhand topspin needs more consistency');
await enter('overall_feeling', 'Felt strong focused and in good rhythm');
}

View File

@ -0,0 +1,223 @@
// Planned (future-dated) manual workouts in the history view: listed apart
// from what was done, kept off the calendar, and deletable until their day
// through a crdt-sync tombstone.
import 'dart:convert';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:workout_app/services/storage_service.dart';
import 'package:workout_app/widgets/calendar_widget.dart';
import '../fake_path_provider.dart';
import '../fake_secure_storage.dart';
import '_history_test_fixtures.dart';
const _pcLog = 'screen-locker-sync/devices/pc/log.json';
Map<String, Object?> _manual(String date, String? start, String source) => {
'kind': 'manual_workout',
'type': 'manual_workout',
'date': date,
if (start != null) 'start_time': start,
'source': source,
};
String _log(Map<String, Map<String, Object?>> byId) {
final hlc = Hlc(wallTimeMs: 1000, counter: 0, nodeId: 'pc');
return jsonEncode({
for (final e in byId.entries)
e.key: Record(id: e.key, fields: {'payload': (e.value, hlc)}).toJson(),
});
}
/// A GitHub Contents API that remembers what was PUT, so a reload after a
/// delete sees the tombstone. [failPuts] answers every write with a 500.
({http.Client client, Map<String, String> files}) _github(
Map<String, String> files, {
bool failPuts = false,
}) {
final client = MockClient((req) async {
final path = req.url.path;
if (!path.contains('/contents/')) return http.Response('{}', 200);
final key = Uri.decodeComponent(path.split('/contents/').last);
if (req.method == 'PUT') {
if (failPuts) return http.Response('boom', 500);
final body = jsonDecode(req.body) as Map<String, dynamic>;
files[key] = utf8.decode(base64.decode(body['content'] as String));
return http.Response('{"content":{"sha":"s"}}', 200);
}
final text = files[key];
if (text != null) {
return http.Response(
jsonEncode({'content': base64Encode(utf8.encode(text)), 'sha': 's'}),
200,
);
}
final children = {
for (final k in files.keys)
if (k.startsWith('$key/')) k.substring(key.length + 1).split('/').first,
};
if (children.isEmpty) return http.Response('not found', 404);
return http.Response(
jsonEncode([
for (final c in children) {'name': c, 'type': 'dir'},
]),
200,
);
});
return (client: client, files: files);
}
Map<String, String> _seed() => {
_pcLog: _log({
'manual:2026-10-14T18:00': _manual('2026-10-14', '18:00', 'squash'),
'manual:2026-10-16T07:00': _manual('2026-10-16', '07:00', 'swim today'),
'manual:2026-10-17T10:00': _manual('2026-10-17', '10:00', 'padel'),
// A future stub has no start time, hence no derivable id: never a plan.
'manual_workout:2026-10-18': _manual('2026-10-18', null, 'stub'),
}),
};
DateTime _now() => DateTime(2026, 10, 16, 15);
/// Lets the real-I/O sync calls finish, then rebuilds.
Future<void> _settle(WidgetTester tester, [int rounds = 1]) async {
for (var i = 0; i < rounds; i++) {
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 300)),
);
await tester.pump();
}
}
Future<void> _open(
WidgetTester tester,
http.Client client, {
DateTime Function() clock = _now,
}) async {
tester.view.physicalSize = const Size(500, 3000);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await pumpHistory(tester, wrapHistory(httpClient: client, clock: clock));
}
Future<void> _confirmDelete(WidgetTester tester, String button) async {
await tester.tap(find.byTooltip('Delete planned workout'));
await tester.pumpAndSettle();
await tester.tap(find.text(button));
await tester.pump();
}
Record _phoneRecord(Map<String, String> files, String id) {
final log =
jsonDecode(files['screen-locker-sync/devices/phone/log.json']!)
as Map<String, dynamic>;
return Record.fromJson(log[id] as Map<String, dynamic>);
}
void main() {
setUpAll(() {
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
});
setUp(() async {
StorageService.resetForTesting();
await StorageService.init();
installFakePathProvider();
installFakeSecureStorage(initial: {'sync.token': 'tok'});
});
testWidgets('a future manual is listed as planned, not as done', (
tester,
) async {
await _open(tester, _github(_seed()).client);
// No local sessions, but synced and planned entries still show.
expect(find.text('No workouts yet.'), findsNothing);
expect(find.text('PLANNED (not done yet)'), findsOneWidget);
expect(find.text('2026-10-17 10:00 · Planned'), findsOneWidget);
expect(find.byTooltip('Delete planned workout'), findsOneWidget);
// Today's entry already happened: done, and not deletable.
expect(find.text('2026-10-16 · Manual'), findsOneWidget);
expect(find.text('2026-10-14 · Manual'), findsOneWidget);
expect(find.text('2026-10-18 · Manual'), findsOneWidget);
final calendar = tester.widget<WorkoutCalendar>(
find.byType(WorkoutCalendar),
);
expect(calendar.workoutDates, isNot(contains('2026-10-17')));
expect(calendar.workoutDates, contains('2026-10-16'));
});
testWidgets('deleting a plan tombstones it and drops the tile', (
tester,
) async {
final github = _github(_seed());
await _open(tester, github.client);
await _confirmDelete(tester, 'DELETE');
await _settle(tester, 4);
final record = _phoneRecord(github.files, 'manual:2026-10-17T10:00');
expect(record.deleted, isTrue);
expect(record.deletedHlc, isNotNull);
// The tile goes only once the reload has read the tombstone back.
await _settle(tester, 4);
await tester.pumpAndSettle();
expect(find.text('PLANNED (not done yet)'), findsNothing);
expect(find.text('2026-10-17 10:00 · Planned'), findsNothing);
});
testWidgets('KEEP leaves the plan alone', (tester) async {
final github = _github(_seed());
await _open(tester, github.client);
await _confirmDelete(tester, 'KEEP');
await _settle(tester);
expect(
github.files.containsKey('screen-locker-sync/devices/phone/log.json'),
isFalse,
);
expect(find.text('2026-10-17 10:00 · Planned'), findsOneWidget);
});
testWidgets('a failed delete keeps the tile and says why', (tester) async {
await _open(tester, _github(_seed(), failPuts: true).client);
await _confirmDelete(tester, 'DELETE');
await _settle(tester, 2);
expect(find.textContaining('Not deleted — push failed'), findsOneWidget);
expect(find.text('2026-10-17 10:00 · Planned'), findsOneWidget);
});
testWidgets('a delete confirmed once the day has started is refused', (
tester,
) async {
var now = _now();
final github = _github(_seed());
await _open(tester, github.client, clock: () => now);
await tester.tap(find.byTooltip('Delete planned workout'));
await tester.pumpAndSettle();
now = DateTime(2026, 10, 17, 0, 1);
await tester.tap(find.text('DELETE'));
await tester.pump();
await _settle(tester, 2);
expect(find.textContaining('no longer a planned workout'), findsOneWidget);
expect(
github.files.containsKey('screen-locker-sync/devices/phone/log.json'),
isFalse,
);
// Reloaded against the new day: it is done now, not planned.
await tester.pumpAndSettle();
expect(find.text('2026-10-17 · Manual'), findsOneWidget);
});
}

View File

@ -0,0 +1,178 @@
// The workout date on the manual form: today up to a week ahead, the budget
// read as of that date, and the refusals around it.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:workout_app/screens/manual_workout_screen.dart';
import 'package:workout_app/services/workout_sync_service.dart';
import '_manual_screen_fixtures.dart';
void main() {
group('workout date', () {
testWidgets('a future date is pushed under that date', (tester) async {
final sync = FakeManualSync();
await pumpManual(
tester,
ManualWorkoutScreen(syncService: sync, clock: fixedManualNow),
);
await pickDay(tester, '14');
expect(find.text('Workout date: 2026-07-14 (tomorrow)'), findsOneWidget);
await fillValidTt(tester);
await tester.tap(find.text('SUBMIT'));
await tester.pumpAndSettle();
expect(sync.pushed!.id, 'manual:2026-07-14T18:00');
final payload = sync.pushed!.fields['payload']!.$1! as Map;
expect(payload['date'], '2026-07-14');
});
testWidgets('the picker offers today to today+7 only', (tester) async {
await pumpManual(
tester,
ManualWorkoutScreen(
syncService: FakeManualSync(),
clock: fixedManualNow,
),
);
await tester.tap(find.text('CHANGE DATE'));
await tester.pumpAndSettle();
final picker = tester.widget<DatePickerDialog>(
find.byType(DatePickerDialog),
);
expect(picker.firstDate, DateTime(2026, 7, 13));
expect(picker.lastDate, DateTime(2026, 7, 20));
expect(picker.initialDate, DateTime(2026, 7, 13));
await tester.tap(find.text('20'));
await tester.tap(find.text('OK'));
await tester.pumpAndSettle();
expect(find.text('Workout date: 2026-07-20 (in 7 days)'), findsOneWidget);
});
testWidgets('cancelling the picker keeps the date', (tester) async {
await pumpManual(
tester,
ManualWorkoutScreen(
syncService: FakeManualSync(),
clock: fixedManualNow,
),
);
await tester.tap(find.text('CHANGE DATE'));
await tester.pumpAndSettle();
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(find.text('Workout date: 2026-07-13 (today)'), findsOneWidget);
});
testWidgets('the budget follows the selected date', (tester) async {
// 07-12 and a planned 07-18 share the window 07-12..07-18, which
// contains today: today is full, 07-20 is not.
await pumpManual(
tester,
ManualWorkoutScreen(
syncService: FakeManualSync(
payloads: [manualEntry('2026-07-12'), manualEntry('2026-07-18')],
),
clock: fixedManualNow,
),
);
expect(find.textContaining('2/2 per 7 days'), findsOneWidget);
expect(find.textContaining('budget exhausted'), findsOneWidget);
expect(find.text('SUBMIT'), findsNothing);
// The date row stays reachable while the window is full.
await pickDay(tester, '20');
expect(
find.text(
'Manual around 2026-07-20: 1/2 per 7 days · 2/10 per 30 days',
),
findsOneWidget,
);
expect(find.textContaining('budget exhausted'), findsNothing);
expect(find.text('SUBMIT'), findsOneWidget);
});
testWidgets('re-logging onto a deleted record is refused', (tester) async {
final sync = FakeManualSync(tombstoned: {'manual:2026-07-13T18:00'});
await pumpManual(
tester,
ManualWorkoutScreen(syncService: sync, clock: fixedManualNow),
);
await fillValidTt(tester);
await tester.tap(find.text('SUBMIT'));
await tester.pump();
expect(find.textContaining('was deleted earlier'), findsOneWidget);
expect(sync.pushed, isNull);
// A different start time is a different record id.
await tester.enterText(find.byKey(const Key('mw_start_time')), '17:30');
await tester.tap(find.text('SUBMIT'));
await tester.pumpAndSettle();
expect(sync.pushed!.id, 'manual:2026-07-13T17:30');
});
testWidgets('a failed push keeps the form open and says why', (
tester,
) async {
final sync = FakeManualSync(
result: const PushResult(pushed: false, reason: 'push failed: 500'),
);
await pumpManual(
tester,
ManualWorkoutScreen(syncService: sync, clock: fixedManualNow),
);
await fillValidTt(tester);
await tester.tap(find.text('SUBMIT'));
await tester.pumpAndSettle();
expect(sync.pushed, isNotNull);
expect(find.text('Not saved — push failed: 500'), findsOneWidget);
expect(find.text('Log Manual Workout'), findsOneWidget);
final submit = tester.widget<ElevatedButton>(
find.widgetWithText(ElevatedButton, 'SUBMIT'),
);
expect(submit.onPressed, isNotNull);
});
testWidgets('a date left behind by midnight is refused, then clamped', (
tester,
) async {
var now = DateTime(2026, 7, 13, 23, 59);
final sync = FakeManualSync();
await pumpManual(
tester,
ManualWorkoutScreen(syncService: sync, clock: () => now),
);
await fillValidTt(tester);
now = DateTime(2026, 7, 14, 0, 1);
await tester.tap(find.text('SUBMIT'));
await tester.pump();
expect(find.textContaining('is in the past'), findsOneWidget);
expect(sync.pushed, isNull);
// The picker would assert on an initial date before its range.
await tester.tap(find.text('CHANGE DATE'));
await tester.pumpAndSettle();
final picker = tester.widget<DatePickerDialog>(
find.byType(DatePickerDialog),
);
expect(picker.initialDate, DateTime(2026, 7, 14));
await tester.tap(find.text('OK'));
await tester.pumpAndSettle();
expect(find.text('Workout date: 2026-07-14 (today)'), findsOneWidget);
});
testWidgets('a date beyond the window is refused at submit', (
tester,
) async {
// Only a clock moving backwards can get here; refuse rather than push.
var now = DateTime(2026, 7, 13, 12);
final sync = FakeManualSync();
await pumpManual(
tester,
ManualWorkoutScreen(syncService: sync, clock: () => now),
);
await pickDay(tester, '20');
await fillValidTt(tester);
now = DateTime(2026, 7, 12, 12);
await tester.tap(find.text('SUBMIT'));
await tester.pump();
expect(find.textContaining('at most 7 days ahead'), findsOneWidget);
expect(sync.pushed, isNull);
});
});
}

View File

@ -5,65 +5,21 @@ import 'package:workout_app/models/manual_workout.dart';
import 'package:workout_app/screens/manual_workout_screen.dart';
import 'package:workout_app/services/workout_sync_service.dart';
class _FakeSync extends WorkoutSyncService {
_FakeSync({this.payloads = const []});
final List<Map<String, dynamic>> payloads;
Record? pushed;
@override
Future<List<Map<String, dynamic>>> readMergedManualPayloads() async =>
payloads;
@override
Future<PushResult> pushManual(Record record) async {
pushed = record;
return const PushResult(pushed: true, reason: 'pushed');
}
}
DateTime _fixedNow() => DateTime(2026, 7, 13, 20);
Future<void> _pump(WidgetTester tester, Widget child) async {
// Tall viewport so the whole (long) form is laid out — a ListView only builds
// children within the viewport, so off-screen widgets aren't findable.
tester.view.physicalSize = const Size(500, 2600);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(MaterialApp(home: child));
await tester.pumpAndSettle(); // let the budget future resolve
}
Future<void> _fillValidTt(WidgetTester tester) async {
Future<void> enter(String key, String value) =>
tester.enterText(find.byKey(Key('mw_$key')), value);
await enter('start_time', '18:00');
await enter('end_time', '19:30');
await enter('location_name', 'Solec');
await enter('transport_method', 'bike');
await enter('cost', '40 PLN');
await enter('matches_won', '3');
await enter('matches_lost', '1');
await enter('sets_won', '7');
await enter('sets_lost', '4');
await enter('racket', 'Butterfly');
await enter('balls', 'Nittaku');
await enter('pain_or_injury', 'sore knee');
await enter('went_well', 'Served consistently and moved feet well');
await enter('to_improve', 'Backhand topspin needs more consistency');
await enter('overall_feeling', 'Felt strong focused and in good rhythm');
}
import '_manual_screen_fixtures.dart';
void main() {
testWidgets('shows the form and budget when not exhausted', (tester) async {
await _pump(
await pumpManual(
tester,
ManualWorkoutScreen(syncService: _FakeSync(), clock: _fixedNow),
ManualWorkoutScreen(syncService: FakeManualSync(), clock: fixedManualNow),
);
expect(find.text('Log Manual Workout'), findsOneWidget);
expect(find.text('SUBMIT'), findsOneWidget);
expect(find.textContaining('0/2 this week'), findsOneWidget);
expect(
find.text('Manual around 2026-07-13: 0/2 per 7 days · 0/10 per 30 days'),
findsOneWidget,
);
expect(find.text('Workout date: 2026-07-13 (today)'), findsOneWidget);
});
testWidgets('hides the form when the budget is exhausted', (tester) async {
@ -84,11 +40,11 @@ void main() {
'end_time': '19:00',
},
];
await _pump(
await pumpManual(
tester,
ManualWorkoutScreen(
syncService: _FakeSync(payloads: payloads),
clock: _fixedNow,
syncService: FakeManualSync(payloads: payloads),
clock: fixedManualNow,
),
);
expect(find.textContaining('budget exhausted'), findsOneWidget);
@ -96,10 +52,10 @@ void main() {
});
testWidgets('shows a validation error and does not push', (tester) async {
final sync = _FakeSync();
await _pump(
final sync = FakeManualSync();
await pumpManual(
tester,
ManualWorkoutScreen(syncService: sync, clock: _fixedNow),
ManualWorkoutScreen(syncService: sync, clock: fixedManualNow),
);
await tester.tap(find.text('SUBMIT'));
await tester.pump();
@ -108,12 +64,12 @@ void main() {
});
testWidgets('valid table-tennis workout pushes and pops', (tester) async {
final sync = _FakeSync();
await _pump(
final sync = FakeManualSync();
await pumpManual(
tester,
ManualWorkoutScreen(syncService: sync, clock: _fixedNow),
ManualWorkoutScreen(syncService: sync, clock: fixedManualNow),
);
await _fillValidTt(tester);
await fillValidTt(tester);
await tester.tap(find.text('SUBMIT'));
await tester.pumpAndSettle();
expect(sync.pushed, isNotNull);
@ -124,9 +80,9 @@ void main() {
});
testWidgets('switching sport reveals the other-sport fields', (tester) async {
await _pump(
await pumpManual(
tester,
ManualWorkoutScreen(syncService: _FakeSync(), clock: _fixedNow),
ManualWorkoutScreen(syncService: FakeManualSync(), clock: fixedManualNow),
);
expect(find.byKey(const Key('mw_matches_won')), findsOneWidget);
await tester.tap(find.byType(DropdownButton<String>));
@ -138,9 +94,9 @@ void main() {
});
testWidgets('RPE slider updates the label', (tester) async {
await _pump(
await pumpManual(
tester,
ManualWorkoutScreen(syncService: _FakeSync(), clock: _fixedNow),
ManualWorkoutScreen(syncService: FakeManualSync(), clock: fixedManualNow),
);
expect(find.text('RPE 5'), findsOneWidget);
await tester.drag(find.byType(Slider), const Offset(300, 0));
@ -149,10 +105,10 @@ void main() {
});
testWidgets('other-sport validation error pushes nothing', (tester) async {
final sync = _FakeSync();
await _pump(
final sync = FakeManualSync();
await pumpManual(
tester,
ManualWorkoutScreen(syncService: sync, clock: _fixedNow),
ManualWorkoutScreen(syncService: sync, clock: fixedManualNow),
);
await tester.tap(find.byType(DropdownButton<String>));
await tester.pumpAndSettle();

View File

@ -0,0 +1,72 @@
// readManualLedger: the manual payloads plus every tombstoned id, which the
// form needs to refuse re-logging onto a deleted record.
//
// Split from workout_sync_read_test.dart for the 250-line cap.
import 'dart:convert';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart' as http_testing;
import '../fake_secure_storage.dart';
import '_sync_test_fixtures.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('readManualLedger', () {
test('returns live manuals and every tombstoned id', () async {
installFakeSecureStorage(initial: {'sync.token': 'tok'});
final deleted = Record(
id: 'manual:gone',
fields: const {},
deleted: true,
deletedHlc: Hlc(wallTimeMs: 3000, counter: 0, nodeId: 'phone'),
);
final phoneLog = jsonEncode({'manual:gone': deleted.toJson()});
final pcLog = jsonEncode({
'manual:a': manualRecord('manual:a', 'NEW', 2000).toJson(),
// Live on the PC, deleted on the phone: the tombstone wins.
'manual:gone': manualRecord('manual:gone', 'X', 1000).toJson(),
});
final (:httpClient, putCalls: _) = mockGitHub(
contentResponses: {
'screen-locker-sync/devices': syncResponse(200, [
{'name': 'phone'},
{'name': 'pc'},
]),
'screen-locker-sync/devices/phone/log.json': fileContaining(phoneLog),
'screen-locker-sync/devices/pc/log.json': fileContaining(pcLog),
},
);
final ledger = await syncService(
httpClient: httpClient,
).readManualLedger();
expect(ledger.payloads.map((p) => p['cost']), ['NEW']);
expect(ledger.tombstoned, {'manual:gone'});
});
test('is empty without a token', () async {
installFakeSecureStorage();
final (:httpClient, putCalls: _) = mockGitHub();
final ledger = await syncService(
httpClient: httpClient,
).readManualLedger();
expect(ledger.payloads, isEmpty);
expect(ledger.tombstoned, isEmpty);
});
test('is empty when the backend fails', () async {
installFakeSecureStorage(initial: {'sync.token': 'tok'});
final httpClient = http_testing.MockClient(
(request) async => http.Response('', 500),
);
final ledger = await syncService(
httpClient: httpClient,
).readManualLedger();
expect(ledger.payloads, isEmpty);
expect(ledger.tombstoned, isEmpty);
});
});
}

View File

@ -180,6 +180,52 @@ void main() {
);
});
test('tombstoneManual pushes a sticky delete for the id', () async {
installFakeSecureStorage(initial: {'sync.token': 'tok'});
final existing = jsonEncode({
'manual:2026-07-20T10:00': manualRecord(
'manual:2026-07-20T10:00',
'40',
1000,
).toJson(),
});
final (:httpClient, :putCalls) = mockGitHub(
contentResponses: {
'screen-locker-sync/devices': syncResponse(200, []),
'screen-locker-sync/devices/phone/log.json': fileContaining(existing),
},
);
final result = await syncService(
httpClient: httpClient,
).tombstoneManual('manual:2026-07-20T10:00');
expect(result.pushed, isTrue);
final pushedLog =
jsonDecode(
utf8.decode(
base64.decode(logPut(putCalls).body['content'] as String),
),
)
as Map<String, dynamic>;
final record = Record.fromJson(
pushedLog['manual:2026-07-20T10:00'] as Map<String, dynamic>,
);
expect(record.deleted, isTrue);
expect(record.deletedHlc, isNotNull);
// The payload survives the merge; only the delete flag is added.
expect((record.fields['payload']!.$1! as Map)['cost'], '40');
});
test('tombstoneManual reports why when sync is not set up', () async {
installFakeSecureStorage();
final (:httpClient, :putCalls) = mockGitHub();
final result = await syncService(
httpClient: httpClient,
).tombstoneManual('manual:2026-07-20T10:00');
expect(result.pushed, isFalse);
expect(result.reason, contains('not configured'));
expect(putCalls, isEmpty);
});
test('pushManual reports a failure instead of hiding it', () async {
installFakeSecureStorage(initial: {'sync.token': 'tok'});
final httpClient = http_testing.MockClient(