Manual-workout phone sync P5: phone form + budget + push

The phone can now log an off-app workout that syncs to the PC and counts toward
the unlock.

- lib/screens/manual_workout_screen.dart: the form, mirroring the PC manual
  form (sport TT/other + sport-specific fields, times, location/transport/cost/
  reservation phone, RPE slider, techniques/warm-up/pain, reflections). Shows
  the shared 2/week + 5/month budget (pull-only, computed over the merged
  cross-device record set) and refuses to submit when exhausted. On submit it
  validates with validateManualWorkout, builds the shared sync record, and
  pushes it to devices/phone/log.json.
- workout_sync_service.dart: pushManual(record) and readMergedManualPayloads()
  (pull-only budget read — never writes).
- models/manual_workout.dart: ManualBudget + countManualBudget over the merged
  payloads, windowed by each payload's own date.
- home_screen.dart: a "Log manual workout" app-bar action.

All new lib code is 100% covered (screen widget tests via an injected fake sync
service; sync-service MockClient tests; model unit tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PM3auNTifaVgcXWDizZrjH
This commit is contained in:
Krzysztof Rudnicki 2026-07-13 13:14:42 +02:00
parent 6f2ec1fa86
commit 77151c0013
6 changed files with 735 additions and 0 deletions

View File

@ -333,3 +333,46 @@ Record buildManualRecord(
fields: {'payload': (buildSyncPayload(draft, date), hlc)},
);
}
/// Manual-workout counts in the rolling 7-/30-day windows (the shared budget).
class ManualBudget {
/// Creates a budget snapshot.
const ManualBudget({required this.week, required this.month});
/// Manual workouts in the last 7 days.
final int week;
/// Manual workouts in the last 30 days.
final int month;
/// Whether either window has reached its limit.
bool get exhausted =>
week >= kManualWorkoutBudgetPer7Days ||
month >= kManualWorkoutBudgetPer30Days;
}
/// Counts manual-workout [payloads] falling in the rolling 7-/30-day windows.
///
/// Each payload's own `date` (YYYY-MM-DD) is the window key, so both devices
/// compute the same shared budget over the merged record set.
ManualBudget countManualBudget(
Iterable<Map<String, dynamic>> payloads,
DateTime now,
) {
final today = DateTime(now.year, now.month, now.day);
var week = 0;
var month = 0;
for (final payload in payloads) {
final dateStr = payload['date'];
if (dateStr is! String) continue;
final date = DateTime.tryParse(dateStr);
if (date == null) continue;
final days = today
.difference(DateTime(date.year, date.month, date.day))
.inDays;
if (days < 0) continue;
if (days < 7) week++;
if (days < 30) month++;
}
return ManualBudget(week: week, month: month);
}

View File

@ -5,6 +5,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:workout_app/models/exercise.dart';
import 'package:workout_app/screens/history_screen.dart';
import 'package:workout_app/screens/manual_workout_screen.dart';
import 'package:workout_app/screens/settings_screen.dart';
import 'package:workout_app/screens/workout_screen.dart';
import 'package:workout_app/services/http_server_service.dart';
@ -108,6 +109,15 @@ class _HomeScreenState extends State<HomeScreen> {
style: TextStyle(color: Colors.white),
),
actions: [
IconButton(
icon: const Icon(Icons.edit_note, color: Colors.white),
tooltip: 'Log manual workout',
onPressed: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => const ManualWorkoutScreen(),
),
),
),
IconButton(
icon: const Icon(Icons.history, color: Colors.white),
onPressed: () => Navigator.of(context).push(

View File

@ -0,0 +1,308 @@
/// Form for logging a manual (off-app) workout that syncs to the PC.
///
/// Mirrors the PC screen-locker manual-workout form: the same fields, the same
/// validation ([validateManualWorkout]), and the same rolling budget
/// (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`.
library;
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/services/workout_sync_service.dart';
/// 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
/// tests; production uses a real [WorkoutSyncService] and [DateTime.now].
const ManualWorkoutScreen({super.key, this.syncService, this.clock});
/// Sync service used to read the budget and push the workout.
final WorkoutSyncService? syncService;
/// Returns "now"; defaults to [DateTime.now].
final DateTime Function()? clock;
@override
State<ManualWorkoutScreen> createState() => _ManualWorkoutScreenState();
}
class _ManualWorkoutScreenState extends State<ManualWorkoutScreen> {
late final WorkoutSyncService _sync =
widget.syncService ?? WorkoutSyncService();
DateTime Function() get _clock => widget.clock ?? DateTime.now;
final _fields = <String, TextEditingController>{
for (final key in [
'start_time',
'end_time',
'location_name',
'transport_method',
'cost',
'reservation_phone',
'techniques_practiced',
'warm_up_minutes',
'pain_or_injury',
'matches_won',
'matches_lost',
'sets_won',
'sets_lost',
'racket',
'balls',
'activity_type_other',
'activity_details',
'equipment',
'went_well',
'to_improve',
'overall_feeling',
])
key: TextEditingController(),
};
String _sport = kSportTableTennis;
double _rpe = 5;
String? _error;
bool _submitting = false;
ManualBudget? _budget;
bool _budgetLoaded = false;
@override
void initState() {
super.initState();
unawaited(_loadBudget());
}
@override
void dispose() {
for (final c in _fields.values) {
c.dispose();
}
super.dispose();
}
Future<void> _loadBudget() async {
final payloads = await _sync.readMergedManualPayloads();
if (!mounted) return;
setState(() {
_budget = countManualBudget(payloads, _clock());
_budgetLoaded = true;
});
}
int _int(String key) => int.tryParse(_fields[key]!.text.trim()) ?? 0;
String _str(String key) => _fields[key]!.text;
ManualWorkoutDraft _draft() => ManualWorkoutDraft(
sport: _sport,
startTime: _str('start_time'),
endTime: _str('end_time'),
locationName: _str('location_name'),
transportMethod: _str('transport_method'),
cost: _str('cost'),
rpe: _rpe.round(),
wentWell: _str('went_well'),
toImprove: _str('to_improve'),
overallFeeling: _str('overall_feeling'),
reservationPhone: _str('reservation_phone'),
techniquesPracticed: _str('techniques_practiced'),
warmUpMinutes: _str('warm_up_minutes'),
painOrInjury: _str('pain_or_injury').trim().isEmpty
? 'none'
: _str('pain_or_injury'),
matchesWon: _int('matches_won'),
matchesLost: _int('matches_lost'),
setsWon: _int('sets_won'),
setsLost: _int('sets_lost'),
racket: _str('racket'),
balls: _str('balls'),
activityTypeOther: _str('activity_type_other'),
activityDetails: _str('activity_details'),
equipment: _str('equipment'),
);
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);
}
@override
Widget build(BuildContext context) {
final budget = _budget;
final exhausted = budget?.exhausted ?? false;
return Scaffold(
backgroundColor: Colors.grey.shade900,
appBar: AppBar(
backgroundColor: Colors.grey.shade800,
title: const Text(
'Log Manual Workout',
style: TextStyle(color: Colors.white),
),
),
body: !_budgetLoaded
? const Center(child: CircularProgressIndicator())
: ListView(
padding: const EdgeInsets.all(16),
children: [
_budgetBanner(budget!),
if (exhausted)
const Padding(
padding: EdgeInsets.only(top: 8),
child: Text(
'Manual-workout budget exhausted for this window.',
style: TextStyle(color: Color(0xFFFF4444)),
),
)
else ...[
_section('Basics'),
_sportDropdown(),
_text('start_time', 'Start time (HH:MM)'),
_text('end_time', 'End time (HH:MM)'),
_section('Location & logistics'),
_text('location_name', 'Location name'),
_text('transport_method', 'How did you get there?'),
_text('cost', 'Cost (e.g. 40 PLN)'),
_text('reservation_phone', 'Reservation phone (optional)'),
_section('Activity details'),
..._sportFields(),
_rpeSlider(),
_text('techniques_practiced', 'Techniques (optional)'),
_text('warm_up_minutes', 'Warm-up (optional)'),
_text('pain_or_injury', 'Pain or injury (optional)'),
_section('Reflection'),
_text('went_well', 'What went well', lines: 3),
_text('to_improve', 'What to improve', lines: 3),
_text('overall_feeling', 'Overall feeling', lines: 3),
if (_error != null)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
_error!,
style: const TextStyle(color: Color(0xFFFF4444)),
),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: ElevatedButton(
onPressed: _submitting ? null : _submit,
child: const Text('SUBMIT'),
),
),
],
],
),
);
}
List<Widget> _sportFields() {
if (_sport == kSportTableTennis) {
return [
_text('matches_won', 'Matches won'),
_text('matches_lost', 'Matches lost'),
_text('sets_won', 'Sets won'),
_text('sets_lost', 'Sets lost'),
_text('racket', 'Racket used'),
_text('balls', 'Balls used'),
];
}
return [
_text('activity_type_other', 'What sport/activity'),
_text('activity_details', 'What was done', lines: 3),
_text('equipment', 'Equipment (optional)'),
];
}
Widget _budgetBanner(ManualBudget budget) => Text(
'Manual: ${budget.week}/$kManualWorkoutBudgetPer7Days this week · '
'${budget.month}/$kManualWorkoutBudgetPer30Days this month',
style: const TextStyle(color: Color(0xFF88CCFF), fontSize: 16),
);
Widget _section(String title) => Padding(
padding: const EdgeInsets.only(top: 16, bottom: 4),
child: Text(
title,
style: const TextStyle(
color: Color(0xFF88CCFF),
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
);
Widget _sportDropdown() => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: DropdownButton<String>(
value: _sport,
dropdownColor: Colors.grey.shade800,
style: const TextStyle(color: Colors.white),
isExpanded: true,
items: [
for (final entry in kSportLabels.entries)
DropdownMenuItem(value: entry.key, child: Text(entry.value)),
],
onChanged: (value) {
if (value != null) setState(() => _sport = value);
},
),
);
Widget _rpeSlider() => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Text(
'RPE ${_rpe.round()}',
style: const TextStyle(color: Colors.white),
),
Expanded(
child: Slider(
value: _rpe,
min: kManualWorkoutRpeMin.toDouble(),
max: kManualWorkoutRpeMax.toDouble(),
divisions: kManualWorkoutRpeMax - kManualWorkoutRpeMin,
label: '${_rpe.round()}',
onChanged: (value) => setState(() => _rpe = value),
),
),
],
),
);
Widget _text(String key, String label, {int lines = 1}) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: TextField(
key: Key('mw_$key'),
controller: _fields[key],
maxLines: lines,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
labelText: label,
labelStyle: TextStyle(color: Colors.grey.shade400),
filled: true,
fillColor: Colors.grey.shade800,
border: const OutlineInputBorder(),
),
),
);
}

View File

@ -6,6 +6,7 @@ import 'dart:convert';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:workout_app/models/manual_workout.dart';
import 'package:workout_app/models/workout_session.dart';
import 'package:workout_app/services/sync_settings.dart';
@ -85,4 +86,87 @@ class WorkoutSyncService {
client.close();
}
}
/// Pushes a pre-built manual-workout [record] to `devices/phone/log.json`,
/// merging with whatever this device has already pushed. Same swallow-on-
/// failure contract as [push].
Future<void> pushManual(Record record) async {
final settings = await SyncSettings.load();
if (!settings.isConfigured) return;
final client = GitHubClient(
owner: owner,
repo: repo,
token: settings.token,
httpClient: _httpClient,
);
try {
const path = '$_pathPrefix/$_deviceId/$_logFilename';
final existingText = await client.getFileText(path);
final existingLog = existingText == null
? <String, Record>{}
: _decode(existingText);
final localLog = mergeLogs(existingLog, {record.id: record});
await syncLog(
client: client,
deviceId: _deviceId,
pathPrefix: _pathPrefix,
localLog: localLog,
encode: _encode,
decode: _decode,
);
} on GitHubSyncError catch (error) {
debugPrint('WorkoutSyncService.pushManual failed: $error');
} finally {
client.close();
}
}
/// Returns every device's manual-workout payloads, merged and deduped by id
/// (highest HLC wins), for computing the shared budget. Pull-only — unlike
/// [pushManual] it never writes, so showing the budget can't mutate the repo.
/// Returns an empty list if sync isn't configured or the repo is unreachable.
Future<List<Map<String, dynamic>>> readMergedManualPayloads() async {
final settings = await SyncSettings.load();
if (!settings.isConfigured) return const [];
final client = GitHubClient(
owner: owner,
repo: repo,
token: settings.token,
httpClient: _httpClient,
);
try {
final merged = <String, Record>{};
for (final device in await client.listDirectory(_pathPrefix)) {
final text = await client.getFileText('$_pathPrefix/$device/$_logFilename');
if (text == null) continue;
_mergeManualRecords(_decode(text), merged);
}
return merged.values
.map((r) => (r.fields['payload']!.$1! as Map).cast<String, dynamic>())
.toList();
} on GitHubSyncError catch (error) {
debugPrint('WorkoutSyncService.readMergedManualPayloads failed: $error');
return const [];
} finally {
client.close();
}
}
static void _mergeManualRecords(Log log, Map<String, Record> into) {
for (final entry in log.entries) {
final field = entry.value.fields['payload'];
if (field == null) continue;
final payload = field.$1;
if (payload is! Map ||
payload['kind'] != kManualWorkoutSyncKind) {
continue;
}
final existing = into[entry.key];
if (existing == null || existing.fields['payload']!.$2 < field.$2) {
into[entry.key] = entry.value;
}
}
}
}

View File

@ -0,0 +1,157 @@
import 'package:crdt_sync/crdt_sync.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
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<void> pushManual(Record record) async {
pushed = record;
}
}
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');
}
void main() {
testWidgets('shows the form and budget when not exhausted', (tester) async {
await _pump(
tester,
ManualWorkoutScreen(syncService: _FakeSync(), clock: _fixedNow),
);
expect(find.text('Log Manual Workout'), findsOneWidget);
expect(find.text('SUBMIT'), findsOneWidget);
expect(find.textContaining('0/2 this week'), findsOneWidget);
});
testWidgets('hides the form when the budget is exhausted', (tester) async {
final payloads = [
{'kind': 'manual_workout', 'date': '2026-07-12'},
{'kind': 'manual_workout', 'date': '2026-07-13'},
];
await _pump(
tester,
ManualWorkoutScreen(
syncService: _FakeSync(payloads: payloads),
clock: _fixedNow,
),
);
expect(find.textContaining('budget exhausted'), findsOneWidget);
expect(find.text('SUBMIT'), findsNothing);
});
testWidgets('shows a validation error and does not push', (tester) async {
final sync = _FakeSync();
await _pump(
tester,
ManualWorkoutScreen(syncService: sync, clock: _fixedNow),
);
await tester.tap(find.text('SUBMIT'));
await tester.pump();
expect(find.textContaining('is required'), findsOneWidget);
expect(sync.pushed, isNull);
});
testWidgets('valid table-tennis workout pushes and pops', (tester) async {
final sync = _FakeSync();
await _pump(
tester,
ManualWorkoutScreen(syncService: sync, clock: _fixedNow),
);
await _fillValidTt(tester);
await tester.tap(find.text('SUBMIT'));
await tester.pumpAndSettle();
expect(sync.pushed, isNotNull);
expect(sync.pushed!.id, 'manual:2026-07-13T18:00');
final payload = sync.pushed!.fields['payload']!.$1! as Map;
expect(payload['kind'], 'manual_workout');
expect(payload['matches_won'], 3);
});
testWidgets('switching sport reveals the other-sport fields', (tester) async {
await _pump(
tester,
ManualWorkoutScreen(syncService: _FakeSync(), clock: _fixedNow),
);
expect(find.byKey(const Key('mw_matches_won')), findsOneWidget);
await tester.tap(find.byType(DropdownButton<String>));
await tester.pumpAndSettle();
await tester.tap(find.text('Other').last);
await tester.pumpAndSettle();
expect(find.byKey(const Key('mw_activity_type_other')), findsOneWidget);
expect(find.byKey(const Key('mw_matches_won')), findsNothing);
});
testWidgets('RPE slider updates the label', (tester) async {
await _pump(
tester,
ManualWorkoutScreen(syncService: _FakeSync(), clock: _fixedNow),
);
expect(find.text('RPE 5'), findsOneWidget);
await tester.drag(find.byType(Slider), const Offset(300, 0));
await tester.pump();
expect(find.text('RPE 5'), findsNothing);
});
testWidgets('other-sport validation error pushes nothing', (tester) async {
final sync = _FakeSync();
await _pump(
tester,
ManualWorkoutScreen(syncService: sync, clock: _fixedNow),
);
await tester.tap(find.byType(DropdownButton<String>));
await tester.pumpAndSettle();
await tester.tap(find.text('Other').last);
await tester.pumpAndSettle();
await tester.enterText(find.byKey(const Key('mw_start_time')), '18:00');
await tester.enterText(find.byKey(const Key('mw_end_time')), '19:30');
await tester.enterText(find.byKey(const Key('mw_location_name')), 'Club');
await tester.enterText(find.byKey(const Key('mw_transport_method')), 'car');
await tester.enterText(find.byKey(const Key('mw_cost')), '30');
await tester.tap(find.text('SUBMIT'));
await tester.pump();
expect(find.textContaining('Activity type is required'), findsOneWidget);
expect(sync.pushed, isNull);
});
}

View File

@ -145,4 +145,137 @@ void main() {
completes,
);
});
Record _manual(String id, String cost, int wallMs) => Record(
id: id,
fields: {
'payload': (
{'kind': 'manual_workout', 'date': '2026-07-13', 'cost': cost},
Hlc(wallTimeMs: wallMs, counter: 0, nodeId: 'phone'),
),
},
);
test('pushManual does nothing without a token', () async {
installFakeSecureStorage();
final (:httpClient, :putCalls) = _mockGitHub();
await WorkoutSyncService(
httpClient: httpClient,
).pushManual(_manual('manual:x', '40', 1000));
expect(putCalls, isEmpty);
});
test('pushManual pushes when nothing has been synced yet', () async {
installFakeSecureStorage(initial: {'sync.token': 'tok'});
final (:httpClient, :putCalls) = _mockGitHub(
contentResponses: {'screen-locker-sync/devices': _response(200, [])},
);
await WorkoutSyncService(
httpClient: httpClient,
).pushManual(_manual('manual:2026-07-13T18:00', '40', 1000));
expect(putCalls.single.path, 'screen-locker-sync/devices/phone/log.json');
});
test('pushManual merges onto an existing log', () async {
installFakeSecureStorage(initial: {'sync.token': 'tok'});
final existing = jsonEncode({
'manual:old': _manual('manual:old', '20', 1000).toJson(),
});
final (:httpClient, :putCalls) = _mockGitHub(
contentResponses: {
'screen-locker-sync/devices': _response(200, []),
'screen-locker-sync/devices/phone/log.json': _fileContaining(existing),
},
);
await WorkoutSyncService(
httpClient: httpClient,
).pushManual(_manual('manual:2026-07-13T18:00', '40', 2000));
final pushedLog =
jsonDecode(
utf8.decode(
base64.decode(putCalls.single.body['content'] as String),
),
)
as Map<String, dynamic>;
expect(
pushedLog.keys,
containsAll(['manual:old', 'manual:2026-07-13T18:00']),
);
});
test('pushManual swallows a sync error', () async {
installFakeSecureStorage(initial: {'sync.token': 'tok'});
final httpClient = http_testing.MockClient(
(request) async => http.Response('', 500),
);
await expectLater(
WorkoutSyncService(
httpClient: httpClient,
).pushManual(_manual('manual:x', '40', 1000)),
completes,
);
});
test('readMergedManualPayloads is empty without a token', () async {
installFakeSecureStorage();
final (:httpClient, putCalls: _) = _mockGitHub();
expect(
await WorkoutSyncService(
httpClient: httpClient,
).readMergedManualPayloads(),
isEmpty,
);
});
test('readMergedManualPayloads merges manuals and skips the rest', () async {
installFakeSecureStorage(initial: {'sync.token': 'tok'});
final session = Record(
id: 's',
fields: {
'payload': ({'workout_type': 'A'}, Hlc(wallTimeMs: 1000, counter: 0, nodeId: 'phone')),
},
);
final noPayload = Record(
id: 'np',
fields: {'other': (1, Hlc(wallTimeMs: 1000, counter: 0, nodeId: 'phone'))},
);
final phoneLog = jsonEncode({
'manual:a': _manual('manual:a', 'NEW', 2000).toJson(),
's': session.toJson(),
'np': noPayload.toJson(),
});
final pcLog = jsonEncode({
// Same id, older clock -> must lose to phone's newer copy.
'manual:a': _manual('manual:a', 'OLD', 1000).toJson(),
});
final (:httpClient, putCalls: _) = _mockGitHub(
contentResponses: {
'screen-locker-sync/devices': _response(200, [
{'name': 'phone'},
{'name': 'pc'},
{'name': 'empty'}, // its log.json 404s -> skipped
]),
'screen-locker-sync/devices/phone/log.json': _fileContaining(phoneLog),
'screen-locker-sync/devices/pc/log.json': _fileContaining(pcLog),
},
);
final payloads = await WorkoutSyncService(
httpClient: httpClient,
).readMergedManualPayloads();
expect(payloads, hasLength(1));
expect(payloads.single['cost'], 'NEW');
});
test('readMergedManualPayloads swallows a sync error', () async {
installFakeSecureStorage(initial: {'sync.token': 'tok'});
final httpClient = http_testing.MockClient(
(request) async => http.Response('', 500),
);
expect(
await WorkoutSyncService(
httpClient: httpClient,
).readMergedManualPayloads(),
isEmpty,
);
});
}