mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 20:27:13 +01:00
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
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:
parent
e12b840f8c
commit
8ba9ee93fa
@ -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;
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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))),
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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),
|
||||
),
|
||||
)
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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 {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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'},
|
||||
|
||||
@ -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());
|
||||
});
|
||||
}
|
||||
@ -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: {
|
||||
|
||||
@ -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');
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user