mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-14 17:30:55 +01:00
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:
parent
6f2ec1fa86
commit
77151c0013
@ -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);
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user