From 77151c0013e1d2c8b2164e969e05cc7491f866c2 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 13 Jul 2026 13:14:42 +0200 Subject: [PATCH] Manual-workout phone sync P5: phone form + budget + push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01PM3auNTifaVgcXWDizZrjH --- .../lib/models/manual_workout.dart | 43 +++ .../workout_app/lib/screens/home_screen.dart | 10 + .../lib/screens/manual_workout_screen.dart | 308 ++++++++++++++++++ .../lib/services/workout_sync_service.dart | 84 +++++ .../screens/manual_workout_screen_test.dart | 157 +++++++++ .../services/workout_sync_service_test.dart | 133 ++++++++ 6 files changed, 735 insertions(+) create mode 100644 stronglift_replacement/workout_app/lib/screens/manual_workout_screen.dart create mode 100644 stronglift_replacement/workout_app/test/screens/manual_workout_screen_test.dart diff --git a/stronglift_replacement/workout_app/lib/models/manual_workout.dart b/stronglift_replacement/workout_app/lib/models/manual_workout.dart index 6fb347a..8c7afb5 100644 --- a/stronglift_replacement/workout_app/lib/models/manual_workout.dart +++ b/stronglift_replacement/workout_app/lib/models/manual_workout.dart @@ -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> 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); +} diff --git a/stronglift_replacement/workout_app/lib/screens/home_screen.dart b/stronglift_replacement/workout_app/lib/screens/home_screen.dart index fa9eecd..4567caa 100644 --- a/stronglift_replacement/workout_app/lib/screens/home_screen.dart +++ b/stronglift_replacement/workout_app/lib/screens/home_screen.dart @@ -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 { 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( + builder: (_) => const ManualWorkoutScreen(), + ), + ), + ), IconButton( icon: const Icon(Icons.history, color: Colors.white), onPressed: () => Navigator.of(context).push( diff --git a/stronglift_replacement/workout_app/lib/screens/manual_workout_screen.dart b/stronglift_replacement/workout_app/lib/screens/manual_workout_screen.dart new file mode 100644 index 0000000..6f805bc --- /dev/null +++ b/stronglift_replacement/workout_app/lib/screens/manual_workout_screen.dart @@ -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 createState() => _ManualWorkoutScreenState(); +} + +class _ManualWorkoutScreenState extends State { + late final WorkoutSyncService _sync = + widget.syncService ?? WorkoutSyncService(); + DateTime Function() get _clock => widget.clock ?? DateTime.now; + + final _fields = { + 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 _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 _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 _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( + 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(), + ), + ), + ); +} diff --git a/stronglift_replacement/workout_app/lib/services/workout_sync_service.dart b/stronglift_replacement/workout_app/lib/services/workout_sync_service.dart index cc49dd4..f10f94a 100644 --- a/stronglift_replacement/workout_app/lib/services/workout_sync_service.dart +++ b/stronglift_replacement/workout_app/lib/services/workout_sync_service.dart @@ -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 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 + ? {} + : _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>> 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 = {}; + 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()) + .toList(); + } on GitHubSyncError catch (error) { + debugPrint('WorkoutSyncService.readMergedManualPayloads failed: $error'); + return const []; + } finally { + client.close(); + } + } + + static void _mergeManualRecords(Log log, Map 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; + } + } + } } diff --git a/stronglift_replacement/workout_app/test/screens/manual_workout_screen_test.dart b/stronglift_replacement/workout_app/test/screens/manual_workout_screen_test.dart new file mode 100644 index 0000000..58b2d51 --- /dev/null +++ b/stronglift_replacement/workout_app/test/screens/manual_workout_screen_test.dart @@ -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> payloads; + Record? pushed; + + @override + Future>> readMergedManualPayloads() async => + payloads; + + @override + Future pushManual(Record record) async { + pushed = record; + } +} + +DateTime _fixedNow() => DateTime(2026, 7, 13, 20); + +Future _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 _fillValidTt(WidgetTester tester) async { + Future 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)); + 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)); + 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); + }); +} diff --git a/stronglift_replacement/workout_app/test/services/workout_sync_service_test.dart b/stronglift_replacement/workout_app/test/services/workout_sync_service_test.dart index fcc1756..88caf4f 100644 --- a/stronglift_replacement/workout_app/test/services/workout_sync_service_test.dart +++ b/stronglift_replacement/workout_app/test/services/workout_sync_service_test.dart @@ -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; + 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, + ); + }); }