workout_app: send finished workout to the PC instantly; PC link pairing; summary <100 ms

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018RHwVPyiiQMPh4M3JQwQku
This commit is contained in:
Krzysztof Rudnicki 2026-10-09 19:08:22 +02:00
parent a826cfa166
commit 2dac472331
38 changed files with 1801 additions and 21 deletions

View File

@ -72,6 +72,18 @@
android:foregroundServiceType="dataSync"
android:exported="false"
android:stopWithTask="false" />
<!-- adb-only pairing for the phone->PC workout poke. Exported because
the shell is another uid; android.permission.DUMP is held by the
adb shell and cannot be granted to a third-party app, so nothing
else on the phone can send it. See PairPcReceiver. -->
<receiver
android:name=".PairPcReceiver"
android:exported="true"
android:permission="android.permission.DUMP">
<intent-filter>
<action android:name="com.kuhy.workout_app.PAIR_PC" />
</intent-filter>
</receiver>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data

View File

@ -24,9 +24,27 @@ class MainActivity : FlutterActivity() {
else -> result.notImplemented()
}
}
// Hands a key parked by PairPcReceiver to Dart, which stores it in
// secure storage and only then asks for the parked copy to go.
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, PAIRING_CHANNEL)
.setMethodCallHandler { call, result ->
when (call.method) {
"takePending" -> try {
result.success(PairPcReceiver.takePending(applicationContext))
} catch (error: Exception) {
result.error("pairing_unreadable", error.toString(), null)
}
"clearPending" -> {
PairPcReceiver.clearPending(applicationContext)
result.success(null)
}
else -> result.notImplemented()
}
}
}
companion object {
const val PAIRING_CHANNEL = "com.kuhy.workout_app/pc_pairing"
const val CHANNEL = "com.kuhy.workout_app/sandbox"
const val LOG_TAG = "WorkoutSandbox"
}

View File

@ -0,0 +1,94 @@
package com.kuhy.workout_app
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Process
import android.util.Log
import org.json.JSONObject
import java.io.File
/**
* Pairs this install with the PC for the workout poke, over adb only.
*
* `scripts/pair_phone.sh` on the PC runs
* `adb shell am broadcast -a com.kuhy.workout_app.PAIR_PC -n <pkg>/com.kuhy.workout_app.PairPcReceiver --es key <64 hex> [--es host <ip>]`.
*
* Two gates keep anything but the adb shell out:
* - the manifest requires the sender to hold `android.permission.DUMP`,
* which the shell has and no third-party app can be granted;
* - on API >= 34 the sender uid, when the platform shares it, must be the
* shell (2000) or root (0).
*
* A manifest receiver runs without a Dart isolate, so the key is parked in
* a private no-backup file; Dart moves it into secure storage the next time
* it asks (`PcPairing.absorbPending`) and only then deletes the file.
* The result code/data are what `am broadcast` prints and what
* `scripts/pair_phone.sh` parses: `result=-1 data="paired"` on success,
* `result=0 data="<reason>"` on rejection. `result=0` with NO data means the
* receiver never ran (sender lacks DUMP, or the OEM blocked a stopped app).
* An omitted host keeps the current setting.
*/
class PairPcReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != ACTION) return refuse("unexpected action ${intent.action}")
if (Build.VERSION.SDK_INT >= 34) {
val uid = sentFromUid
Log.i(TAG, "pair request sentFromUid=$uid")
if (uid != Process.INVALID_UID && uid != SHELL_UID && uid != ROOT_UID) {
return refuse("sender uid $uid is not the adb shell")
}
}
val key = intent.getStringExtra("key")?.trim()?.lowercase()
if (key == null || !KEY_PATTERN.matches(key)) {
return refuse("key must be exactly 64 hex characters")
}
val host = intent.getStringExtra("host")?.trim()
if (host != null && !HOST_PATTERN.matches(host)) {
return refuse("host '$host' is not an IPv4 address or hostname")
}
val json = JSONObject().put("key", key)
if (host != null) json.put("host", host)
try {
pendingFile(context).writeText(json.toString())
} catch (error: Exception) {
return refuse("could not park the key: $error")
}
Log.i(TAG, "pairing parked for ${context.packageName}; the app absorbs it on next use")
setResult(RESULT_PAIRED, "paired", null)
}
private fun refuse(reason: String) {
Log.w(TAG, "pairing refused: $reason")
setResult(RESULT_REFUSED, reason, null)
}
companion object {
const val ACTION = "com.kuhy.workout_app.PAIR_PC"
const val TAG = "WorkoutPair"
const val RESULT_PAIRED = -1 // Activity.RESULT_OK
const val RESULT_REFUSED = 0 // Activity.RESULT_CANCELED
private const val SHELL_UID = 2000
private const val ROOT_UID = 0
private val KEY_PATTERN = Regex("^[0-9a-f]{64}$")
private val HOST_PATTERN = Regex("^[A-Za-z0-9][A-Za-z0-9.-]{0,252}$")
fun pendingFile(context: Context): File =
File(context.noBackupFilesDir, "pc_pairing_pending.json")
/** `{key, host?}` parked by [onReceive], or null. */
fun takePending(context: Context): Map<String, String>? {
val file = pendingFile(context)
if (!file.exists()) return null
val json = JSONObject(file.readText())
val out = mutableMapOf("key" to json.getString("key"))
if (json.has("host")) out["host"] = json.getString("host")
return out
}
fun clearPending(context: Context) {
pendingFile(context).delete()
}
}
}

View File

@ -12,6 +12,7 @@ import 'package:workout_app/services/break_service_controller.dart';
import 'package:workout_app/services/foreground_break_client_flutter.dart';
import 'package:workout_app/services/http_server_service.dart';
import 'package:workout_app/services/lock_mode.dart';
import 'package:workout_app/services/pc_pairing.dart';
import 'package:workout_app/services/progression_sync_service.dart';
import 'package:workout_app/services/storage_service.dart';
import 'package:workout_app/services/sync_device_id.dart';
@ -71,6 +72,9 @@ void main(List<String> args) async {
);
}
await StorageService.init();
// Absorbs a key parked by an adb pairing and points the sandbox's one
// network exception at the configured PC, before anything can poke.
await PcPairing.load();
if (Sandbox.enabled) {
Sandbox.restSecs = await StorageService.instance.getSandboxRestSecs(
Sandbox.defaultRestSecs,

View File

@ -4,19 +4,44 @@ library;
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:workout_app/services/pc_poke_wire.dart';
/// Makes every `dart:io` HTTP connection fail before a socket is opened.
/// Makes every `dart:io` HTTP connection fail before a socket is opened --
/// except the workout poke to the configured PC.
///
/// Installed as `HttpOverrides.global` by `main` in the sandbox. Firebase and
/// GitHub sync both go through `package:http`, whose IO client is built from
/// `HttpClient()` and therefore from these overrides — one seam, not one
/// guard per service. A refused connection surfaces as a normal sync failure
/// in the UI, which is the honest answer: the sandbox is offline by design.
///
/// The single exception is `http://<pokeHost>:8773/v1/workout`, so a sandbox
/// workout can time the PC round trip. The PC verifies the signed
/// `"sandbox": true` and never writes or credits it. Firebase and GitHub are
/// https on 443 and can never match.
class SandboxHttpOverrides extends HttpOverrides {
/// The PC host the poke may reach; set by `PcPairing` whenever the host is
/// loaded or saved. Null means no exception at all.
static String? pokeHost;
/// Whether [uri] is exactly the poke endpoint on the configured PC.
static bool allows(Uri uri) =>
pokeHost != null &&
uri.scheme == 'http' &&
uri.host == pokeHost &&
uri.port == kPcPokePort &&
uri.path == kPcPokePath;
@override
HttpClient createHttpClient(SecurityContext? context) {
return super.createHttpClient(context)
..connectionFactory = (uri, proxyHost, proxyPort) {
if (proxyHost == null && allows(uri)) {
debugPrint(
'WorkoutSandbox: allowing the PC poke to ${uri.host}:${uri.port}',
);
return Socket.startConnect(uri.host, uri.port);
}
debugPrint(
'WorkoutSandbox: refused network connection to ${uri.host} — the '
'sandbox never talks to Firebase or GitHub.',

View File

@ -20,6 +20,7 @@ import 'package:workout_app/screens/github_mirror_screen.dart';
import 'package:workout_app/services/backup_service.dart';
import 'package:workout_app/services/firebase_backend.dart';
import 'package:workout_app/services/google_sign_in_backend.dart';
import 'package:workout_app/services/pc_pairing.dart';
import 'package:workout_app/services/progression_sync_service.dart';
import 'package:workout_app/services/storage_service.dart';
import 'package:workout_app/ui/theme.dart';
@ -29,6 +30,7 @@ part 'settings_screen_actions.dart';
part 'settings_screen_deload.dart';
part 'settings_screen_exercise_list.dart';
part 'settings_screen_exercise_sections.dart';
part 'settings_screen_pc_link.dart';
part 'settings_screen_rows.dart';
part 'settings_screen_sandbox.dart';
part 'settings_screen_sections.dart';
@ -230,6 +232,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
onOpenGitHubMirror: () => unawaited(_openGitHubMirror()),
),
const SizedBox(height: 20),
_PcLinkSection(pairingLoader: widget.pcPairingLoader),
const SizedBox(height: 20),
_OfflineBackupSection(
storageGranted: _storageGranted,
onGrantStorage: _grantStorage,

View File

@ -0,0 +1,123 @@
// The PC LINK section: where the instant workout credit is sent.
//
// A `part` like the other sections. It owns its state: the address field and
// the paired flag come from PcPairing, never from the exercise state the
// surrounding screen edits. The key itself is never shown or editable here --
// it only ever arrives over adb (scripts/pair_phone.sh on the PC).
part of 'settings_screen.dart';
/// PC address field and the "paired" indicator.
class _PcLinkSection extends StatefulWidget {
const _PcLinkSection({required this.pairingLoader});
/// Loads the pairing; injected through [SettingsScreen.pcPairingLoader].
final Future<PcPairingState> Function() pairingLoader;
@override
State<_PcLinkSection> createState() => _PcLinkSectionState();
}
class _PcLinkSectionState extends State<_PcLinkSection> {
final TextEditingController _host = TextEditingController();
bool? _paired;
String? _status;
@override
void initState() {
super.initState();
unawaited(_load());
}
Future<void> _load() async {
final state = await widget.pairingLoader();
if (!mounted) return;
_host.text = state.host;
setState(() => _paired = state.paired);
}
@override
void dispose() {
_host.dispose();
super.dispose();
}
Future<void> _save() async {
final host = _host.text.trim();
if (!isValidPcHost(host)) {
setState(() => _status = 'Not an IPv4 address or hostname: "$host".');
return;
}
await PcPairing.saveHost(host);
SandboxLog.event('pc host saved', {'host': host});
setState(() => _status = 'Workouts are now sent to $host:$kPcPokePort.');
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final status = Theme.of(context).extension<AppStatusColors>()!;
final paired = _paired;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const _SectionHeader('PC LINK'),
const SizedBox(height: AppSpacing.xs),
Text(
'A finished workout is sent straight to the PC on the home Wi-Fi, '
'so the credit lands in a second. Sync still delivers it otherwise.',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: AppTextSize.caption,
),
),
const SizedBox(height: AppSpacing.sm),
Row(
children: [
Expanded(
child: TextField(
key: const Key('pc-host'),
controller: _host,
keyboardType: TextInputType.url,
decoration: const InputDecoration(
isDense: true,
labelText: 'PC address',
),
),
),
const SizedBox(width: AppSpacing.sm),
TextButton(
key: const Key('pc-host-save'),
onPressed: () => unawaited(_save()),
// Distinct label: the sandbox section has its own "Save", and
// two identical labels cannot be driven by element.
child: const Text('Save', semanticsLabel: 'Save PC address'),
),
],
),
const SizedBox(height: AppSpacing.sm),
Text(
switch (paired) {
null => 'PC paired: checking…',
true => 'PC paired ✓',
false => 'PC paired ✗ — run scripts/pair_phone.sh on the PC',
},
key: const Key('pc-paired'),
style: TextStyle(
color: paired ?? false ? status.success : status.warning,
fontSize: AppTextSize.caption,
),
),
if (_status != null) ...[
const SizedBox(height: AppSpacing.xs),
Text(
_status!,
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: AppTextSize.caption,
),
),
],
],
);
}
}

View File

@ -69,8 +69,9 @@ class _SandboxSectionState extends State<_SandboxSection> {
const _SectionHeader('SANDBOX'),
const SizedBox(height: 4),
Text(
'This install is the sandbox: its own data, no network, no LAN '
'server. Nothing here can reach the daily build.',
'This install is the sandbox: its own data, no network (only the '
'signed sandbox poke to the PC LINK address, which the PC never '
'credits), no LAN server. Nothing here can reach the daily build.',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: AppTextSize.caption,

View File

@ -34,6 +34,7 @@ class SettingsScreen extends StatefulWidget {
this.storageRequester,
this.progressionPuller,
this.progressionPusher,
this.pcPairingLoader = PcPairing.load,
});
/// Injectable HTTP client, passed through to [GitHubMirrorScreen] so its
@ -89,6 +90,10 @@ class SettingsScreen extends StatefulWidget {
/// Injected so tests never reach Firebase.
final Future<ProgressionSyncResult> Function()? progressionPusher;
/// Loads the PC pairing for the PC LINK section. Injected so tests need
/// neither the keystore nor the pairing channel.
final Future<PcPairingState> Function() pcPairingLoader;
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}

View File

@ -22,6 +22,7 @@ import 'package:workout_app/services/break_service_controller.dart';
import 'package:workout_app/services/foreground_break_client.dart';
import 'package:workout_app/services/foreground_break_client_flutter.dart';
import 'package:workout_app/services/lock_mode.dart';
import 'package:workout_app/services/pc_poke_service.dart';
import 'package:workout_app/services/progression_sync_service.dart';
import 'package:workout_app/services/storage_service.dart';
import 'package:workout_app/services/sync_service.dart';
@ -197,13 +198,13 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
/// Only the `setState` lives here — `setState` is `@protected` and cannot be
/// called from an extension, so the rest is in [_persistFinishedWorkout].
Future<void> _finishWorkout() async {
SandboxLog.event('workout finish', {'type': widget.workoutType});
_breakTimer?.cancel();
// Before the state flips: the service must not outlive the workout, and
// with stopWithTask="false" nothing else will ever stop it.
await _breaks.stop();
final sinceTap = Stopwatch()..start();
// The service stop starts first and is awaited last (see _beginFinish):
// the summary must not wait on the notification teardown.
final stopped = _beginFinish();
setState(() => _finished = true);
await _persistFinishedWorkout();
await _persistFinishedWorkout(sinceTap);
await stopped;
}
// ── Build ──────────────────────────────────────────────────────────────────

View File

@ -5,11 +5,35 @@ part of 'workout_screen.dart';
/// The write-everything step that runs once a workout is marked finished.
extension _WorkoutScreenFinish on _WorkoutScreenState {
/// Starts tearing down the break service and returns the stop, which the
/// caller awaits only after the summary is up.
///
/// The stop is *started* before `_finished` flips -- the service must not
/// outlive the workout, and with stopWithTask="false" nothing else stops it
/// -- and its synchronous part (dropping the drain-nudge listener) runs
/// right here. Only its completion (~120 ms of platform-channel teardown on
/// the phone, 2026-10-09) no longer gates the summary. If it fails, the
/// failure is logged loudly and the next launch's `stopStaleBreakService`
/// takes the service down, since the active session is cleared by then.
Future<void> _beginFinish() {
SandboxLog.event('workout finish', {'type': widget.workoutType});
_breakTimer?.cancel();
return _breaks.stop().catchError((Object error, StackTrace stack) {
log(
'Break service did not stop at finish ($error); the next launch '
'stops it as stale.',
level: 1000,
error: error,
stackTrace: stack,
);
});
}
/// Writes the finished session, applies progression, and shows the summary.
///
/// Called by [_WorkoutScreenState._finishWorkout], which owns the `setState`
/// that marks the workout finished before this runs.
Future<void> _persistFinishedWorkout() async {
Future<void> _persistFinishedWorkout(Stopwatch sinceTap) async {
final endTime = DateTime.now();
final results = <ExerciseResult>[];
@ -41,6 +65,16 @@ extension _WorkoutScreenFinish on _WorkoutScreenState {
endTime: endTime,
exercises: results,
);
// The direct PC poke starts the moment the session exists, before any
// local write: it needs nothing from storage, and every millisecond here
// is a millisecond of "instant" credit. It runs in parallel with the
// sync push below (never after it); the PC dedups by record id, so
// whichever lands first credits and the other reads as a duplicate.
// Bounded to 2 s and never throws.
final pcPoke = PcPokeService().poke(session);
SandboxLog.event('pc poke started', {
'ms_since_tap': sinceTap.elapsedMilliseconds,
});
final storage = StorageService.instance;
await storage.saveSession(
@ -99,12 +133,24 @@ extension _WorkoutScreenFinish on _WorkoutScreenState {
);
if (!mounted) return;
SandboxLog.event('summary shown', {
'ms_since_tap': sinceTap.elapsedMilliseconds,
});
// The first frame that draws the dialog: "finish tap -> summary visible".
WidgetsBinding.instance.addPostFrameCallback(
(_) => SandboxLog.event('summary first frame', {
'ms_since_tap': sinceTap.elapsedMilliseconds,
}),
);
unawaited(
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) =>
WorkoutSummaryDialog(session: session, syncResult: syncResult),
builder: (_) => WorkoutSummaryDialog(
session: session,
syncResult: syncResult,
pcPoke: pcPoke,
),
),
);
}

View File

@ -0,0 +1,131 @@
/// Where the PC is and the key the poke is signed with.
///
/// The key arrives over adb (`scripts/pair_phone.sh` -> `PairPcReceiver`).
/// A manifest receiver runs without a Dart isolate, so it parks the key in a
/// private no-backup file and Dart moves it into secure storage the next
/// time it asks -- at startup, in Settings, and before every poke.
library;
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:workout_app/sandbox/sandbox_http.dart';
import 'package:workout_app/services/pc_poke_wire.dart';
import 'package:workout_app/services/storage_service.dart';
export 'package:workout_app/services/pc_poke_wire.dart' show kPcPokePort;
/// Snapshot of the pairing. [keyHex] never leaves the services layer: the
/// UI only ever reads [paired].
@immutable
class PcPairingState {
/// Creates a snapshot.
const PcPairingState({required this.host, this.keyHex});
/// The PC's LAN host.
final String host;
/// The shared HMAC key, or null when this install is not paired.
final String? keyHex;
/// Whether a key is stored.
bool get paired => keyHex != null;
}
final RegExp _hostPattern = RegExp(r'^[A-Za-z0-9](?:[A-Za-z0-9.-]{0,252})$');
/// Whether [host] is usable as the PC address (an IPv4 or a hostname).
bool isValidPcHost(String host) => _hostPattern.hasMatch(host);
/// Reads and writes the pairing. Each package (daily, sandbox) has its own
/// keystore and database, so each has its own key slot by construction.
abstract final class PcPairing {
/// Channel `MainActivity` answers `takePending` / `clearPending` on.
static const MethodChannel channel = MethodChannel(
'com.kuhy.workout_app/pc_pairing',
);
/// Secure-storage entry the key is kept under.
static const String keyName = 'pc_poke_key';
/// The keystore; tests swap the platform channel, not this.
static const FlutterSecureStorage _secure = FlutterSecureStorage();
static bool _channelDown = false;
/// Absorbs any pending adb pairing, then returns the current pairing and
/// points the sandbox's network exception at its host.
static Future<PcPairingState> load() async {
await absorbPending();
final host = await StorageService.instance.getPcHost() ?? kDefaultPcHost;
String? key;
try {
key = await _secure.read(key: keyName);
} on Exception catch (error) {
log(
'PC pairing key unreadable ($error) -- treating as not paired',
name: 'PcPairing',
level: 900,
);
}
SandboxHttpOverrides.pokeHost = host;
return PcPairingState(
host: host,
keyHex: key != null && isValidPokeKeyHex(key) ? key : null,
);
}
/// Persists the PC address the Settings field holds.
static Future<void> saveHost(String host) async {
await StorageService.instance.setPcHost(host);
SandboxHttpOverrides.pokeHost = host;
}
/// Moves a key the receiver parked into secure storage. Returns what
/// happened as a sentence; the parked copy is deleted only after the
/// secure write succeeded, so a failure here loses nothing.
static Future<String> absorbPending() async {
if (_channelDown) return 'no pairing channel on this platform';
Map<Object?, Object?>? pending;
try {
pending = await channel.invokeMapMethod<Object?, Object?>('takePending');
} on MissingPluginException catch (error) {
_channelDown = true;
log(
'no pairing channel ($error) -- adb pairing is Android-only',
name: 'PcPairing',
level: 900,
);
return 'no pairing channel on this platform';
} on PlatformException catch (error) {
log('reading the parked pairing failed: $error', level: 1000);
return 'reading the parked pairing failed: $error';
}
if (pending == null) return 'nothing pending';
final key = pending['key'];
final host = pending['host'];
if (key is! String || !isValidPokeKeyHex(key)) {
log('parked pairing key is malformed -- discarded', level: 1000);
await channel.invokeMethod<void>('clearPending');
return 'parked pairing key is malformed';
}
try {
await _secure.write(key: keyName, value: key.toLowerCase());
} on PlatformException catch (error) {
log('storing the pairing key failed: $error', level: 1000);
return 'storing the pairing key failed: $error';
}
if (host is String && isValidPcHost(host)) {
await saveHost(host);
}
await channel.invokeMethod<void>('clearPending');
log('PC pairing absorbed from adb', name: 'PcPairing', level: 800);
return 'paired';
}
/// Forgets a failed channel so the next call retries it.
@visibleForTesting
static void resetForTesting() => _channelDown = false;
}

View File

@ -0,0 +1,150 @@
/// The typed outcome of one phone->PC workout poke.
library;
import 'dart:convert';
import 'package:flutter/foundation.dart';
/// What the PC did with the poke, as far as the phone can tell.
enum PokeOutcome {
/// Verified, ingested and credited just now.
credited,
/// Already in the PC's log (the sync got there first): nothing new to do.
duplicate,
/// A sandbox poke: verified and timed, never written or credited.
sandbox,
/// New to the PC, but today's workout slot was already paid (`ok:true,
/// credited:false, duplicate:false`).
alreadyPaidToday,
/// Correctly signed, but the session does not count (too short, no date):
/// HTTP 200 with `ok:false`. [PokeResult.reason] says why.
notCounted,
/// No usable answer: not paired, unreachable, timed out, rejected.
failed,
}
/// One poke's result. Never null, never thrown: [reason] is always a
/// sentence a human can act on.
@immutable
class PokeResult {
/// Creates a result.
const PokeResult({
required this.outcome,
required this.reason,
this.shutdown,
this.gamingBudgetMinutes,
this.roundTripMs,
});
/// A failure that never got a usable reply.
const PokeResult.failed(this.reason, {this.roundTripMs})
: outcome = PokeOutcome.failed,
shutdown = null,
gamingBudgetMinutes = null;
/// Parses the PC's JSON reply. Any shape the contract does not allow is a
/// [PokeOutcome.failed] naming what was wrong, not an exception.
factory PokeResult.fromReply(int status, String body, int roundTripMs) {
Object? decoded;
FormatException? notJson;
try {
decoded = jsonDecode(body);
} on FormatException catch (error) {
// Handed on in the result; PcPokeService logs every failed result.
notJson = error;
}
if (notJson != null) {
return PokeResult.failed(
'PC answered HTTP $status with a non-JSON body ($notJson)',
roundTripMs: roundTripMs,
);
}
if (decoded is! Map<String, dynamic>) {
return PokeResult.failed(
'PC answered HTTP $status with JSON that is not an object',
roundTripMs: roundTripMs,
);
}
final reason = decoded['reason'] is String
? decoded['reason'] as String
: 'PC gave no reason';
// 401/409/400/404/405/411/413/500 all carry JSON, but none of them is
// an answer about this workout: the sync fallback covers them.
if (status != 200) {
return PokeResult.failed(
'PC rejected the poke (HTTP $status): $reason',
roundTripMs: roundTripMs,
);
}
// shutdown / gaming_budget_minutes may be null (refusals): never assumed.
final budget = decoded['gaming_budget_minutes'];
final shutdown = decoded['shutdown'];
final PokeOutcome outcome;
if (decoded['ok'] != true) {
outcome = PokeOutcome.notCounted;
} else if (decoded['sandbox'] == true) {
outcome = PokeOutcome.sandbox;
} else if (decoded['duplicate'] == true) {
outcome = PokeOutcome.duplicate;
} else if (decoded['credited'] == true) {
outcome = PokeOutcome.credited;
} else {
outcome = PokeOutcome.alreadyPaidToday;
}
return PokeResult(
outcome: outcome,
reason: reason,
shutdown: shutdown is String ? shutdown : null,
gamingBudgetMinutes: budget is int ? budget : null,
roundTripMs: roundTripMs,
);
}
/// What happened.
final PokeOutcome outcome;
/// Why, in words.
final String reason;
/// The PC's shutdown time after this poke, `HH:MM`.
final String? shutdown;
/// Today's gaming budget after this poke, in minutes.
final int? gamingBudgetMinutes;
/// Measured phone-side round trip, when a request was actually sent.
final int? roundTripMs;
/// The one line the finish screen shows for this result.
String get label {
switch (outcome) {
case PokeOutcome.credited:
final parts = <String>['PC ✓'];
if (shutdown != null) parts.add('shutdown $shutdown');
final minutes = gamingBudgetMinutes;
final gaming = minutes == null
? ''
: ' · gaming ${(minutes / 60).toStringAsFixed(1)}h';
return '${parts.join(' ')}$gaming';
case PokeOutcome.duplicate:
return 'PC: already credited';
case PokeOutcome.sandbox:
return 'PC ✓ (sandbox, not credited) · ${roundTripMs ?? '?'} ms';
case PokeOutcome.alreadyPaidToday:
return 'PC: already credited today';
case PokeOutcome.notCounted:
return 'PC: not counted — $reason';
case PokeOutcome.failed:
return 'PC: will credit within 60 s (via sync)';
}
}
@override
String toString() =>
'PokeResult(${outcome.name}, rtt=${roundTripMs}ms, reason: $reason)';
}

View File

@ -0,0 +1,142 @@
/// Sends a finished workout straight to the PC so the credit lands in under
/// a second instead of waiting for the sync round trip.
library;
import 'dart:async';
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:http/io_client.dart';
import 'package:uuid/uuid.dart';
import 'package:workout_app/models/workout_session.dart';
import 'package:workout_app/sandbox/sandbox.dart';
import 'package:workout_app/sandbox/sandbox_log.dart';
import 'package:workout_app/services/pc_pairing.dart';
import 'package:workout_app/services/pc_poke_result.dart';
import 'package:workout_app/services/pc_poke_wire.dart';
import 'package:workout_app/services/workout_sync_service.dart';
export 'package:workout_app/services/pc_poke_result.dart';
/// Signs and POSTs one session to `http://<pc-host>:8773/v1/workout`.
///
/// Runs beside [WorkoutSyncService.push], never instead of it: the PC dedups
/// by record id, so whichever path lands first credits and the other is a
/// duplicate. Off the home LAN this fails fast and the sync is the only path.
class PcPokeService {
/// Creates a service. Every collaborator is injectable so tests need no
/// network, keystore or clock.
PcPokeService({
http.Client Function()? clientFactory,
Future<PcPairingState> Function()? pairingLoader,
DateTime Function()? clock,
String Function()? nonce,
this.sandboxOverride,
this.timeout = const Duration(seconds: 2),
}) : _clientFactory = clientFactory ?? IOClient.new,
_pairingLoader = pairingLoader ?? PcPairing.load,
_clock = clock ?? DateTime.now,
_nonce = nonce ?? const Uuid().v4;
/// A fresh client per call, so it is built under whatever `HttpOverrides`
/// are current (the sandbox's narrow exception included).
final http.Client Function() _clientFactory;
final Future<PcPairingState> Function() _pairingLoader;
final DateTime Function() _clock;
final String Function() _nonce;
/// Forces the body's `sandbox` flag; null follows [Sandbox.enabled].
final bool? sandboxOverride;
/// Budget for the whole poke, key load included. Past it the finish screen
/// says the sync will credit instead.
final Duration timeout;
/// Pokes the PC with [session]. Never throws; failures log at warning.
Future<PokeResult> poke(WorkoutSession session) async {
final watch = Stopwatch()..start();
http.Client? client;
// Set by the catch below and reported (at warning) right after it.
Exception failure;
try {
final pairing = await _pairingLoader().timeout(timeout);
final keyHex = pairing.keyHex;
if (keyHex == null) {
return _report(
const PokeResult.failed(
'this phone is not paired with the PC -- run '
'scripts/pair_phone.sh there; the sync will credit meanwhile',
),
);
}
final sandbox = sandboxOverride ?? Sandbox.enabled;
final body = buildPokeBody(
sentAtMs: _clock().millisecondsSinceEpoch,
nonce: _nonce(),
sandbox: sandbox,
recordId: workoutRecordId(session),
payload: workoutRecordPayload(session),
);
final signature = signPokeBody(decodePokeKeyHex(keyHex), body);
final uri = Uri(
scheme: 'http',
host: pairing.host,
port: kPcPokePort,
path: kPcPokePath,
);
client = _clientFactory();
final left = timeout - watch.elapsed;
final response = await client
.post(
uri,
headers: {
'Content-Type': 'application/json',
kPcPokeSignatureHeader: signature,
},
body: body,
)
.timeout(left.isNegative ? Duration.zero : left);
return _report(
PokeResult.fromReply(
response.statusCode,
response.body,
watch.elapsedMilliseconds,
),
);
} on Exception catch (error) {
failure = error;
} finally {
// Closing aborts a request the timeout gave up on, so a dead PC never
// keeps a socket open behind the finish screen.
client?.close();
}
return _report(
PokeResult.failed(
failure is TimeoutException
? 'no reply from the PC within ${timeout.inMilliseconds} ms'
: 'could not reach the PC: $failure',
roundTripMs: watch.elapsedMilliseconds,
),
);
}
/// Logs every result -- a poke that fails says so at warning level, never
/// silently -- and mirrors it into the sandbox trace.
PokeResult _report(PokeResult result) {
final failed =
result.outcome == PokeOutcome.failed ||
result.outcome == PokeOutcome.notCounted;
final line =
'PC poke ${result.outcome.name} in ${result.roundTripMs ?? '-'} ms: '
'${result.reason}';
log(line, name: 'PcPoke', level: failed ? 900 : 800);
debugPrint('WorkoutApp: $line');
SandboxLog.event('pc poke', {
'outcome': result.outcome.name,
'rtt_ms': result.roundTripMs,
'reason': result.reason,
});
return result;
}
}

View File

@ -0,0 +1,69 @@
/// Wire format of the phone->PC workout poke (v1).
///
/// Binding spec: `docs/DOCS-workout-poke-contract.md` at the screen-locker
/// repo root, with the literal fixture `contracts/workout_poke_v1.json` that
/// both this file and the Python receiver are tested against. Pure Dart, no
/// Flutter: the contract test loads it without a binding.
library;
import 'dart:convert';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
/// TCP port the PC's poke listener binds (8771 belongs to another service).
const int kPcPokePort = 8773;
/// Request path on the PC.
const String kPcPokePath = '/v1/workout';
/// Header carrying `hex(HMAC-SHA256(key, raw body bytes))`.
const String kPcPokeSignatureHeader = 'X-Poke-Signature';
/// Wire-format version this phone speaks.
const int kPcPokeVersion = 1;
/// The PC's static LAN address; the Settings field starts here.
const String kDefaultPcHost = '192.168.1.43';
final RegExp _keyHex = RegExp(r'^[0-9a-fA-F]{64}$');
/// Whether [text] is a pairing key: exactly 32 bytes as 64 hex characters.
bool isValidPokeKeyHex(String text) => _keyHex.hasMatch(text);
/// Decodes a key accepted by [isValidPokeKeyHex]; throws [FormatException]
/// on anything else so a malformed key can never sign a request.
Uint8List decodePokeKeyHex(String keyHex) {
if (!isValidPokeKeyHex(keyHex)) {
throw const FormatException('pairing key must be exactly 64 hex chars');
}
final out = Uint8List(32);
for (var i = 0; i < 32; i++) {
out[i] = int.parse(keyHex.substring(i * 2, i * 2 + 2), radix: 16);
}
return out;
}
/// `hex(HMAC-SHA256(key, bodyBytes))` over the bytes exactly as sent.
String signPokeBody(List<int> key, List<int> bodyBytes) =>
Hmac(sha256, key).convert(bodyBytes).toString();
/// Serializes the request body ONCE; the caller signs and sends these exact
/// bytes. Key order follows the contract for readability only -- the PC
/// verifies the MAC over the received bytes and never re-serializes.
Uint8List buildPokeBody({
required int sentAtMs,
required String nonce,
required bool sandbox,
required String recordId,
required Map<String, dynamic> payload,
}) => utf8.encode(
jsonEncode({
'v': kPcPokeVersion,
'sent_at_ms': sentAtMs,
'nonce': nonce,
'sandbox': sandbox,
'record_id': recordId,
'payload': payload,
}),
);

View File

@ -26,6 +26,7 @@ export 'package:workout_app/models/progression.dart';
part 'storage_service_backup.dart';
part 'storage_service_deload.dart';
part 'storage_service_exercises.dart';
part 'storage_service_pc_link.dart';
part 'storage_service_sandbox.dart';
part 'storage_service_schema.dart';
part 'storage_service_sessions.dart';

View File

@ -0,0 +1,18 @@
// The PC address the workout poke is sent to.
//
// A `part` of StorageService for the same reason as the sandbox part: it
// needs `_getSetting`, and one setting does not earn a second storage layer.
// The key itself is NOT here -- it lives in secure storage (pc_pairing.dart).
part of 'storage_service.dart';
/// Settings key the PC host is stored under.
const String kPcHostKey = 'pc_host';
/// Persistence for the PC link.
extension StorageServicePcLink on StorageService {
/// The saved PC host, or null when the default should be used.
Future<String?> getPcHost() => _getSetting(kPcHostKey);
/// Persists the PC host.
Future<void> setPcHost(String host) => _setSetting(kPcHostKey, host);
}

View File

@ -1,7 +1,6 @@
/// Pushes completed workout sessions to the shared sync backend.
library;
import 'dart:convert';
import 'dart:developer';
import 'package:crdt_sync/crdt_sync.dart';
@ -27,6 +26,19 @@ Log _decode(String text) => (jsonDecode(text) as Map<String, dynamic>).map(
(id, data) => MapEntry(id, Record.fromJson(data as Map<String, dynamic>)),
);
/// The sync record id a finished [session] is pushed under.
///
/// The PC's `ingest_session_records` keys on this (it equals the payload's
/// `start_time`), so the direct phone->PC poke and the regular push must
/// both build it here -- two constructions could drift and credit twice.
String workoutRecordId(WorkoutSession session) =>
session.startTime.toIso8601String();
/// The payload a finished [session] carries in its sync record; shared with
/// the poke for the same reason as [workoutRecordId].
Map<String, dynamic> workoutRecordPayload(WorkoutSession session) =>
session.toJson();
/// The outcome of a [WorkoutSyncService.push] / [WorkoutSyncService.pushManual].
///
/// A bare `Future<void>` used to hide every failure: the caller could not
@ -124,9 +136,12 @@ class WorkoutSyncService {
successReason: 'pushed',
failurePrefix: 'push failed',
addition: Record(
id: session.startTime.toIso8601String(),
id: workoutRecordId(session),
fields: {
'payload': (session.toJson(), Hlc.newTick(currentSyncDeviceId)),
'payload': (
workoutRecordPayload(session),
Hlc.newTick(currentSyncDeviceId),
),
},
),
);
@ -172,7 +187,6 @@ class WorkoutSyncService {
);
}
/// Pushes a pre-built manual-workout [record] to this device's log.
///
/// Same never-throw, always-report contract as [push].
@ -197,6 +211,7 @@ class WorkoutSyncService {
/// Returns an empty list if sync isn't configured or the repo is unreachable.
Future<List<Map<String, dynamic>>> readMergedManualPayloads() =>
_readMergedPayloads(kind: kManualWorkoutSyncKind);
/// Every synced workout, whatever kind — manual, StrongLifts or RunnerUp.
///
/// The PC publishes its whole `log.json` (including verified runs),
@ -205,6 +220,7 @@ class WorkoutSyncService {
/// uses [readMergedManualPayloads] instead.
Future<List<Map<String, dynamic>>> readMergedWorkoutPayloads() =>
_readMergedPayloads();
/// Whether this device has ANY sync credentials -- a GitHub token or a
/// Firebase account.
///

View File

@ -0,0 +1,63 @@
/// The one-line PC status on the workout summary.
library;
import 'package:flutter/material.dart';
import 'package:workout_app/services/pc_poke_result.dart';
import 'package:workout_app/ui/theme.dart';
/// Shows "PC: applying…" until [poke] resolves, then its [PokeResult.label].
///
/// The service bounds [poke] to 2 s, so this never spins forever: an
/// unreachable PC turns into the sync fallback line on its own.
class PcPokeLine extends StatelessWidget {
/// Creates the line for an in-flight [poke].
const PcPokeLine({required this.poke, super.key});
/// The poke started when the workout finished.
final Future<PokeResult> poke;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final status = Theme.of(context).extension<AppStatusColors>()!;
return FutureBuilder<PokeResult>(
future: poke,
builder: (context, snapshot) {
final result = snapshot.data;
if (result == null) {
return Row(
key: const Key('pc-poke-pending'),
children: [
SizedBox.square(
dimension: AppTextSize.caption,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(width: AppSpacing.sm),
Text(
'PC: applying…',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: AppTextSize.caption,
),
),
],
);
}
final color = switch (result.outcome) {
PokeOutcome.credited || PokeOutcome.sandbox => status.success,
PokeOutcome.duplicate ||
PokeOutcome.alreadyPaidToday => colorScheme.onSurfaceVariant,
PokeOutcome.notCounted || PokeOutcome.failed => status.warning,
};
return Text(
result.label,
key: const Key('pc-poke-result'),
style: TextStyle(color: color, fontSize: AppTextSize.caption),
);
},
);
}
}

View File

@ -3,8 +3,10 @@ library;
import 'package:flutter/material.dart';
import 'package:workout_app/models/workout_session.dart';
import 'package:workout_app/services/pc_poke_result.dart';
import 'package:workout_app/services/sync_service.dart';
import 'package:workout_app/ui/theme.dart';
import 'package:workout_app/widgets/pc_poke_line.dart';
/// Dialog that summarises a completed workout and reports the sync status.
class WorkoutSummaryDialog extends StatelessWidget {
@ -12,6 +14,7 @@ class WorkoutSummaryDialog extends StatelessWidget {
const WorkoutSummaryDialog({
required this.session,
required this.syncResult,
this.pcPoke,
super.key,
});
@ -21,6 +24,10 @@ class WorkoutSummaryDialog extends StatelessWidget {
/// Result of writing the session to disk/HTTP server.
final SyncResult syncResult;
/// The direct phone->PC poke started at finish, shown as one status line;
/// null where none was sent.
final Future<PokeResult>? pcPoke;
String _fmt(Duration d) {
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
@ -70,6 +77,10 @@ class WorkoutSummaryDialog extends StatelessWidget {
fontSize: AppTextSize.caption,
),
),
if (pcPoke != null) ...[
const SizedBox(height: AppSpacing.sm),
PcPokeLine(poke: pcPoke!),
],
],
),
actions: [

View File

@ -147,7 +147,7 @@ packages:
source: git
version: "0.11.1"
crypto:
dependency: transitive
dependency: "direct main"
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf

View File

@ -14,6 +14,8 @@ dependencies:
url: https://github.com/kuhyx/utils
ref: crdt_sync_dart-v0.11.1
path: crdt_sync_dart
# HMAC-SHA256 for the signed phone->PC workout poke (pc_poke_wire.dart).
crypto: 3.0.7
flutter:
sdk: flutter
# Keeps the rest-period deadline ticking with the screen off, and owns the

View File

@ -0,0 +1,7 @@
import 'package:workout_app/services/pc_pairing.dart';
/// A settled, unpaired pairing for screens that only need the PC LINK
/// section to render: no pairing channel, keystore or database involved, and
/// `SandboxHttpOverrides.pokeHost` stays untouched.
Future<PcPairingState> fakePcPairing() async =>
const PcPairingState(host: '10.0.0.2');

View File

@ -0,0 +1,50 @@
// The phone half of the cross-language contract check for the workout poke.
//
// `contracts/workout_poke_v1.json` (screen-locker repo root) is a literal key,
// body and signature the Python receiver is tested against too. Each side's
// own round-trip test would pass while the two disagreed; this one cannot.
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:workout_app/services/pc_poke_wire.dart';
/// Relative to the package root, which is `flutter test`'s cwd.
const _fixturePath = '../../contracts/workout_poke_v1.json';
void main() {
final fixture =
jsonDecode(File(_fixturePath).readAsStringSync()) as Map<String, dynamic>;
final keyHex = fixture['key_hex'] as String;
final body = fixture['body_utf8'] as String;
final expected = fixture['signature_hex'] as String;
test('signer reproduces the shared fixture signature byte for byte', () {
expect(signPokeBody(decodePokeKeyHex(keyHex), utf8.encode(body)), expected);
});
test('the fixture key is a valid pairing key', () {
expect(isValidPokeKeyHex(keyHex), isTrue);
});
test('buildPokeBody emits the fixture body byte for byte', () {
// Float (100.0, 7020.5), key order and raw non-ASCII are where encoders
// differ. The PC never re-serializes, so this is not required for the MAC
// to verify -- it pins that the phone's body looks like the documented one.
final reparsed = jsonDecode(body) as Map<String, dynamic>;
final rebuilt = buildPokeBody(
sentAtMs: reparsed['sent_at_ms'] as int,
nonce: reparsed['nonce'] as String,
sandbox: reparsed['sandbox'] as bool,
recordId: reparsed['record_id'] as String,
payload: reparsed['payload'] as Map<String, dynamic>,
);
expect(utf8.decode(rebuilt), body);
});
test('malformed keys are rejected before they can sign', () {
expect(isValidPokeKeyHex('ab' * 31), isFalse);
expect(isValidPokeKeyHex('zz' * 32), isFalse);
expect(() => decodePokeKeyHex('nope'), throwsFormatException);
});
}

View File

@ -3,6 +3,7 @@ import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:workout_app/sandbox/sandbox_http.dart';
import 'package:workout_app/services/pc_poke_wire.dart';
void main() {
final printed = <String>[];
@ -10,7 +11,10 @@ void main() {
printed.clear();
debugPrint = (String? message, {int? wrapWidth}) => printed.add(message ?? '');
});
tearDown(() => debugPrint = debugPrintThrottled);
tearDown(() {
debugPrint = debugPrintThrottled;
SandboxHttpOverrides.pokeHost = null;
});
test('refuses every connection before a socket is opened', () async {
final client = SandboxHttpOverrides().createHttpClient(null);
@ -23,4 +27,73 @@ void main() {
expect(printed.single, contains('refused network connection to firebase.example.invalid'));
client.close(force: true);
});
group('the one PC-poke exception', () {
const host = '127.0.0.1';
final poke = Uri.parse('http://$host:$kPcPokePort$kPcPokePath');
test('allows nothing until a PC host is configured', () {
expect(SandboxHttpOverrides.allows(poke), isFalse);
});
test('allows exactly http + configured host + 8773 + /v1/workout', () {
SandboxHttpOverrides.pokeHost = host;
expect(SandboxHttpOverrides.allows(poke), isTrue);
for (final other in [
poke.replace(scheme: 'https'),
poke.replace(host: '127.0.0.2'),
poke.replace(port: 443),
poke.replace(path: '/v1/other'),
Uri.parse('https://firebaseio.com/x.json'),
]) {
expect(SandboxHttpOverrides.allows(other), isFalse, reason: '$other');
}
});
test('https to the same host and port is still refused', () async {
SandboxHttpOverrides.pokeHost = host;
final client = SandboxHttpOverrides().createHttpClient(null);
await expectLater(
() async => client.postUrl(
Uri.parse('https://$host:$kPcPokePort$kPcPokePath'),
),
throwsA(isA<SocketException>()),
);
expect(printed.single, contains('refused network connection to $host'));
client.close(force: true);
});
test('another host is still refused', () async {
SandboxHttpOverrides.pokeHost = host;
final client = SandboxHttpOverrides().createHttpClient(null);
await expectLater(
() async => client.postUrl(
Uri.parse('http://10.0.0.9:$kPcPokePort$kPcPokePath'),
),
throwsA(isA<SocketException>()),
);
expect(printed.single, contains('refused network connection to 10.0.0.9'));
client.close(force: true);
});
test('the poke itself reaches the socket layer', () async {
SandboxHttpOverrides.pokeHost = host;
final client = SandboxHttpOverrides().createHttpClient(null);
Object outcome = 'connected';
try {
// Nothing is sent: the request is opened, never written.
final request = await client.postUrl(poke);
request.abort();
} on SocketException catch (error) {
outcome = error;
}
// No listener on this host's 8773 is fine -- what matters is that a
// refusal, if any, did not come from the sandbox.
if (outcome is SocketException) {
expect(outcome.message, isNot(contains('sandbox')));
}
expect(printed.first, contains('allowing the PC poke to $host:8773'));
client.close(force: true);
});
});
}

View File

@ -10,6 +10,7 @@ import 'package:workout_app/services/storage_service.dart';
import 'package:workout_app/ui/theme.dart';
import 'package:workout_app/widgets/exercise_settings_sheet.dart';
import '../fake_pc_pairing.dart';
import '../fake_secure_storage.dart';
const _situp = 'Situp exercise settings';
@ -42,6 +43,7 @@ void main() {
MaterialApp(
theme: buildAppTheme(),
home: SettingsScreen(
pcPairingLoader: fakePcPairing,
firebaseFactory: () async => null,
accountLoader: () async => null,
sessionProbe: () async => false,

View File

@ -9,6 +9,7 @@ import 'package:workout_app/services/storage_service.dart';
import 'package:workout_app/ui/theme.dart';
import 'package:workout_app/widgets/exercise_settings_sheet.dart';
import '../fake_pc_pairing.dart';
import '../fake_secure_storage.dart';
const _situp = 'Situp exercise settings';
@ -36,6 +37,7 @@ void main() {
MaterialApp(
theme: buildAppTheme(),
home: SettingsScreen(
pcPairingLoader: fakePcPairing,
firebaseFactory: () async => null,
accountLoader: () async => null,
sessionProbe: () async => false,

View File

@ -0,0 +1,108 @@
// The PC LINK section: shows the pairing, validates and persists the host.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:workout_app/sandbox/sandbox_http.dart';
import 'package:workout_app/screens/settings_screen.dart';
import 'package:workout_app/services/pc_pairing.dart';
import 'package:workout_app/services/progression_sync_service.dart';
import 'package:workout_app/services/storage_service.dart';
import 'package:workout_app/ui/theme.dart';
import '../fake_secure_storage.dart';
void main() {
setUpAll(() {
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
});
setUp(() async {
StorageService.resetForTesting();
await StorageService.init();
installFakeSecureStorage();
});
tearDown(() => SandboxHttpOverrides.pokeHost = null);
Widget wrap(PcPairingState pairing) => MaterialApp(
theme: buildAppTheme(),
home: SettingsScreen(
pcPairingLoader: () async => pairing,
firebaseFactory: () async => null,
sessionProbe: () async => false,
storageChecker: () async => false,
progressionPuller: () async =>
const ProgressionSyncResult(changed: false, reason: 'stubbed'),
),
);
Future<void> pump(WidgetTester tester, PcPairingState pairing) async {
await tester.runAsync(() async {
await tester.pumpWidget(wrap(pairing));
await Future<void>.delayed(const Duration(milliseconds: 300));
});
await tester.pump();
await tester.scrollUntilVisible(
find.byKey(const Key('pc-paired')),
300,
scrollable: find.byType(Scrollable).first,
);
await tester.pump();
}
Future<void> save(WidgetTester tester, String host) async {
await tester.enterText(find.byKey(const Key('pc-host')), host);
final button = find.byKey(const Key('pc-host-save'));
await tester.ensureVisible(button);
await tester.runAsync(() async {
await tester.tap(button);
await Future<void>.delayed(const Duration(milliseconds: 200));
});
await tester.pump();
}
String pairedLine(WidgetTester tester) =>
tester.widget<Text>(find.byKey(const Key('pc-paired'))).data!;
testWidgets('a paired phone shows the host and a check', (tester) async {
await pump(
tester,
PcPairingState(host: '192.168.1.43', keyHex: 'c3' * 32),
);
expect(find.text('PC LINK'), findsOneWidget);
expect(pairedLine(tester), 'PC paired ✓');
final field = tester.widget<TextField>(find.byKey(const Key('pc-host')));
expect(field.controller!.text, '192.168.1.43');
});
testWidgets('an unpaired phone names the pairing script', (tester) async {
await pump(tester, const PcPairingState(host: '10.0.0.2'));
expect(pairedLine(tester), contains('run scripts/pair_phone.sh'));
});
testWidgets('a junk host is refused and nothing is saved', (tester) async {
await pump(tester, const PcPairingState(host: '10.0.0.2'));
await save(tester, 'not a host');
expect(
find.text('Not an IPv4 address or hostname: "not a host".'),
findsOneWidget,
);
final saved = await tester.runAsync(StorageService.instance.getPcHost);
expect(saved, isNull);
expect(SandboxHttpOverrides.pokeHost, isNull);
});
testWidgets('a valid host is persisted and moves the exception', (
tester,
) async {
await pump(tester, const PcPairingState(host: '10.0.0.2'));
await save(tester, ' 192.168.1.77 ');
expect(
find.text('Workouts are now sent to 192.168.1.77:$kPcPokePort.'),
findsOneWidget,
);
final saved = await tester.runAsync(StorageService.instance.getPcHost);
expect(saved, '192.168.1.77');
expect(SandboxHttpOverrides.pokeHost, '192.168.1.77');
});
}

View File

@ -10,6 +10,7 @@ import 'package:workout_app/services/progression_sync_service.dart';
import 'package:workout_app/services/storage_service.dart';
import 'package:workout_app/ui/theme.dart';
import '../fake_pc_pairing.dart';
import '../fake_secure_storage.dart';
void main() {
@ -47,6 +48,7 @@ void main() {
}) => MaterialApp(
theme: buildAppTheme(),
home: SettingsScreen(
pcPairingLoader: fakePcPairing,
httpClient: httpClient,
// Injected so the widget never reaches the OS keystore, which
// `flutter test` has no platform-channel binding for.

View File

@ -9,6 +9,7 @@ import 'package:workout_app/services/progression_sync_service.dart';
import 'package:workout_app/services/storage_service.dart';
import 'package:workout_app/ui/theme.dart';
import '../fake_pc_pairing.dart';
import '../fake_secure_storage.dart';
void main() {
@ -32,6 +33,7 @@ void main() {
Widget wrap() => MaterialApp(
theme: buildAppTheme(),
home: SettingsScreen(
pcPairingLoader: fakePcPairing,
firebaseFactory: () async => null,
sessionProbe: () async => false,
storageChecker: () async => false,
@ -119,17 +121,22 @@ void main() {
) async {
await pump(tester);
final field = find.byKey(const Key('sandbox-rest-secs'));
// The row's own button: the PC LINK section has a "Save" too.
final save = find.descendant(
of: find.ancestor(of: field, matching: find.byType(Row)).first,
matching: find.text('Save'),
);
await tester.enterText(field, 'abc');
await tapReal(tester, find.text('Save'));
await tapReal(tester, save);
expect(find.textContaining('whole number of seconds'), findsOneWidget);
expect(Sandbox.restSecs, Sandbox.defaultRestSecs);
await tester.enterText(field, '0');
await tapReal(tester, find.text('Save'));
await tapReal(tester, save);
expect(find.textContaining('whole number of seconds'), findsOneWidget);
await tester.enterText(field, '45');
await tapReal(tester, find.text('Save'));
await tapReal(tester, save);
expect(find.text('Every rest now lasts 45 s.'), findsOneWidget);
expect(Sandbox.restSecs, 45);
final saved = await tester.runAsync(

View File

@ -10,6 +10,7 @@ import 'package:workout_app/services/progression_sync_service.dart';
import 'package:workout_app/services/storage_service.dart';
import 'package:workout_app/ui/theme.dart';
import '../fake_pc_pairing.dart';
import '../fake_secure_storage.dart';
void main() {
@ -47,6 +48,7 @@ void main() {
}) => MaterialApp(
theme: buildAppTheme(),
home: SettingsScreen(
pcPairingLoader: fakePcPairing,
httpClient: httpClient,
// Injected so the widget never reaches the OS keystore, which
// `flutter test` has no platform-channel binding for.
@ -138,6 +140,9 @@ void main() {
findsOneWidget,
);
// The scroll step can leave the row just below the fold.
await tester.ensureVisible(find.text('Grant storage permission'));
await tester.pump();
await tester.tap(find.text('Grant storage permission'));
await tester.pumpAndSettle();

View File

@ -10,6 +10,7 @@ import 'package:workout_app/services/progression_sync_service.dart';
import 'package:workout_app/services/storage_service.dart';
import 'package:workout_app/ui/theme.dart';
import '../fake_pc_pairing.dart';
import '../fake_secure_storage.dart';
void main() {
@ -47,6 +48,7 @@ void main() {
}) => MaterialApp(
theme: buildAppTheme(),
home: SettingsScreen(
pcPairingLoader: fakePcPairing,
httpClient: httpClient,
// Injected so the widget never reaches the OS keystore, which
// `flutter test` has no platform-channel binding for.

View File

@ -10,6 +10,7 @@ import 'package:workout_app/services/progression_sync_service.dart';
import 'package:workout_app/services/storage_service.dart';
import 'package:workout_app/ui/theme.dart';
import '../fake_pc_pairing.dart';
import '../fake_secure_storage.dart';
void main() {
@ -47,6 +48,7 @@ void main() {
}) => MaterialApp(
theme: buildAppTheme(),
home: SettingsScreen(
pcPairingLoader: fakePcPairing,
httpClient: httpClient,
// Injected so the widget never reaches the OS keystore, which
// `flutter test` has no platform-channel binding for.

View File

@ -0,0 +1,71 @@
// Finishing a workout whose break service will not stop: the summary still
// appears (the stop no longer gates it) and the failure is logged, not thrown.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:workout_app/services/storage_service.dart';
import '../fake_audio_platform.dart';
import '../fake_break_service.dart';
import '../fake_secure_storage.dart';
import '_workout_screen_test_fixtures.dart';
/// A client whose teardown fails once [failStop] is armed.
class _FailingStopClient extends FakeForegroundBreakClient {
bool failStop = false;
@override
void stopListeningForDrainNudges() {
if (failStop) {
failStop = false;
throw StateError('notification teardown failed');
}
super.stopListeningForDrainNudges();
}
}
void main() {
late _FailingStopClient client;
setUpAll(() {
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
});
setUp(() async {
StorageService.resetForTesting();
await StorageService.init();
installFakeSecureStorage();
installFakeAudioPlatform();
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
client = _FailingStopClient();
});
testWidgets('a failed service stop still shows the summary', (tester) async {
await pumpWorkout(
tester,
wrapWorkout(savedState: completeSaved(), breakClient: client),
);
client.failStop = true;
await tester.runAsync(() async {
await tester.tap(find.widgetWithText(TextButton, 'Finish'));
await Future<void>.delayed(const Duration(milliseconds: 200));
await tester.pump();
await tester.tap(find.widgetWithText(TextButton, 'Finish').last);
await Future<void>.delayed(const Duration(milliseconds: 800));
await tester.pump();
});
await tester.pump();
expect(client.failStop, isFalse, reason: 'the failing stop really ran');
expect(tester.takeException(), isNull);
// The summary's PC line: this test host is never paired.
expect(
find.text('PC: will credit within 60 s (via sync)'),
findsOneWidget,
);
});
}

View File

@ -0,0 +1,143 @@
// PcPairing: the adb-parked key moves into secure storage, and only then is
// the parked copy deleted -- a failure anywhere loses nothing.
import 'package:flutter/services.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:workout_app/sandbox/sandbox_http.dart';
import 'package:workout_app/services/pc_pairing.dart';
import 'package:workout_app/services/pc_poke_wire.dart';
import 'package:workout_app/services/storage_service.dart';
import '../fake_secure_storage.dart';
final String _key = 'b2' * 32;
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
late List<String> calls;
setUpAll(() {
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
});
setUp(() async {
StorageService.resetForTesting();
await StorageService.init();
PcPairing.resetForTesting();
calls = [];
});
tearDown(() {
SandboxHttpOverrides.pokeHost = null;
messenger.setMockMethodCallHandler(PcPairing.channel, null);
});
/// Answers `takePending` with [pending] (or throws [error]) and records
/// every call.
void parked(Map<String, String>? pending, {Exception? error}) {
messenger.setMockMethodCallHandler(PcPairing.channel, (call) async {
calls.add(call.method);
if (call.method != 'takePending') return null;
if (error != null) throw error;
return pending;
});
}
Future<String?> storedKey() =>
const FlutterSecureStorage().read(key: PcPairing.keyName);
test('nothing parked: default host, not paired, sandbox host set', () async {
installFakeSecureStorage();
parked(null);
final state = await PcPairing.load();
expect(state.host, kDefaultPcHost);
expect(state.paired, isFalse);
expect(SandboxHttpOverrides.pokeHost, kDefaultPcHost);
expect(calls, ['takePending']);
});
test('a parked key is stored lowercase, host saved, then cleared', () async {
installFakeSecureStorage();
parked({'key': _key.toUpperCase(), 'host': '192.168.1.50'});
expect(await PcPairing.absorbPending(), 'paired');
expect(calls, ['takePending', 'clearPending']);
expect(await storedKey(), _key);
final state = await PcPairing.load();
expect(state.host, '192.168.1.50');
expect(state.keyHex, _key);
expect(state.paired, isTrue);
});
test('an invalid parked host is ignored, the key still pairs', () async {
installFakeSecureStorage();
parked({'key': _key, 'host': '-bad host'});
expect(await PcPairing.absorbPending(), 'paired');
expect(await StorageService.instance.getPcHost(), isNull);
});
test('a malformed parked key is discarded, never stored', () async {
installFakeSecureStorage();
parked({'key': 'nope'});
expect(await PcPairing.absorbPending(), 'parked pairing key is malformed');
expect(calls, ['takePending', 'clearPending']);
expect(await storedKey(), isNull);
});
test('a failed secure write keeps the parked copy', () async {
installFakeSecureStorage(throwing: true);
parked({'key': _key});
final outcome = await PcPairing.absorbPending();
expect(outcome, startsWith('storing the pairing key failed'));
expect(calls, ['takePending'], reason: 'clearPending must not run');
});
test('an unreadable parked file is reported, not thrown', () async {
installFakeSecureStorage();
parked(null, error: PlatformException(code: 'pairing_unreadable'));
final outcome = await PcPairing.absorbPending();
expect(outcome, startsWith('reading the parked pairing failed'));
});
test('no channel (desktop): said once, then not asked again', () async {
installFakeSecureStorage();
// No handler: the call raises MissingPluginException.
expect(await PcPairing.absorbPending(), 'no pairing channel on this platform');
parked(null);
expect(await PcPairing.absorbPending(), 'no pairing channel on this platform');
expect(calls, isEmpty);
});
test('a corrupt stored key reads as not paired', () async {
installFakeSecureStorage(initial: {PcPairing.keyName: 'garbage'});
parked(null);
expect((await PcPairing.load()).paired, isFalse);
});
test('a keystore that throws reads as not paired', () async {
installFakeSecureStorage(throwing: true);
parked(null);
final state = await PcPairing.load();
expect(state.paired, isFalse);
expect(state.host, kDefaultPcHost);
});
test('saveHost persists and moves the sandbox exception', () async {
await PcPairing.saveHost('pc.lan');
expect(await StorageService.instance.getPcHost(), 'pc.lan');
expect(SandboxHttpOverrides.pokeHost, 'pc.lan');
});
test('isValidPcHost accepts IPv4 and hostnames only', () {
expect(isValidPcHost('192.168.1.43'), isTrue);
expect(isValidPcHost('pc.lan'), isTrue);
expect(isValidPcHost(''), isFalse);
expect(isValidPcHost('-x'), isFalse);
expect(isValidPcHost('a b'), isFalse);
expect(isValidPcHost('http://x'), isFalse);
});
}

View File

@ -0,0 +1,91 @@
// Maps every PC reply shape the poke contract allows onto the finish line.
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:workout_app/services/pc_poke_result.dart';
String _reply({
bool ok = true,
bool credited = false,
bool duplicate = false,
bool sandbox = false,
String? shutdown = '21:00',
int? gaming = 90,
String reason = 'because',
}) => jsonEncode({
'ok': ok,
'credited': credited,
'duplicate': duplicate,
'sandbox': sandbox,
'shutdown': shutdown,
'gaming_budget_minutes': gaming,
'pc_ms': 3,
'reason': reason,
});
void main() {
test('credited shows shutdown and gaming hours', () {
final r = PokeResult.fromReply(200, _reply(credited: true), 40);
expect(r.outcome, PokeOutcome.credited);
expect(r.label, 'PC ✓ shutdown 21:00 · gaming 1.5h');
});
test('credited with null shutdown/budget still renders', () {
final r = PokeResult.fromReply(
200,
_reply(credited: true, shutdown: null, gaming: null),
40,
);
expect(r.label, 'PC ✓');
});
test('duplicate, already-paid and sandbox replies', () {
expect(
PokeResult.fromReply(200, _reply(duplicate: true), 1).label,
'PC: already credited',
);
expect(
PokeResult.fromReply(200, _reply(), 1).label,
'PC: already credited today',
);
expect(
PokeResult.fromReply(200, _reply(sandbox: true), 25).label,
'PC ✓ (sandbox, not credited) · 25 ms',
);
});
test('200 ok:false is "not counted" with the PC reason', () {
final r = PokeResult.fromReply(
200,
_reply(ok: false, reason: 'session too short', shutdown: null),
1,
);
expect(r.outcome, PokeOutcome.notCounted);
expect(r.label, 'PC: not counted — session too short');
});
test('every non-200 status falls back to the sync line', () {
for (final status in [400, 401, 404, 405, 409, 411, 413, 500]) {
final r = PokeResult.fromReply(
status,
_reply(ok: false, shutdown: null, gaming: null, reason: 'nope'),
1,
);
expect(r.outcome, PokeOutcome.failed, reason: 'HTTP $status');
expect(r.label, 'PC: will credit within 60 s (via sync)');
expect(r.reason, contains('HTTP $status'));
}
});
test('non-JSON and non-object bodies fail with a reason', () {
expect(PokeResult.fromReply(200, '<html>', 1).reason, contains('non-JSON'));
expect(PokeResult.fromReply(200, '[1]', 1).reason, contains('not an'));
});
test('toString names the outcome, round trip and reason', () {
expect(
const PokeResult.failed('down', roundTripMs: 12).toString(),
'PokeResult(failed, rtt=12ms, reason: down)',
);
});
}

View File

@ -0,0 +1,209 @@
// PcPokeService against a fake PC: signing, every failure shape, and the
// bounded budget. Plain `test()` so `.timeout()` runs on real timers.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:workout_app/models/exercise.dart';
import 'package:workout_app/models/exercise_result.dart';
import 'package:workout_app/models/set_result.dart';
import 'package:workout_app/models/workout_session.dart';
import 'package:workout_app/services/pc_pairing.dart';
import 'package:workout_app/services/pc_poke_service.dart';
import 'package:workout_app/services/pc_poke_wire.dart';
final String _keyHex = 'a1' * 32;
WorkoutSession _session() {
final start = DateTime(2026, 10, 9, 7, 30);
return WorkoutSession(
workoutType: 'A',
startTime: start,
endTime: start.add(const Duration(minutes: 40)),
exercises: [
ExerciseResult(
exercise: const Exercise(name: 'Squat', sets: 1, reps: 5, weight: 60),
sets: [SetResult(targetReps: 5, doneReps: 5, weight: 60)],
),
],
);
}
/// A MockClient that records whether it was closed.
class _ClosingClient extends MockClient {
_ClosingClient(super.fn);
bool closed = false;
@override
void close() {
closed = true;
super.close();
}
}
String _reply({
bool ok = true,
bool credited = false,
bool sandbox = false,
Object? shutdown,
Object? gaming,
String reason = 'fine',
}) => jsonEncode({
'ok': ok,
'credited': credited,
'duplicate': false,
'sandbox': sandbox,
'shutdown': shutdown,
'gaming_budget_minutes': gaming,
'reason': reason,
});
PcPokeService _service(
http.Client client, {
String? keyHex,
bool? sandbox,
Future<PcPairingState> Function()? pairing,
Duration timeout = const Duration(seconds: 2),
}) => PcPokeService(
clientFactory: () => client,
pairingLoader:
pairing ??
() async => PcPairingState(host: '10.0.0.7', keyHex: keyHex ?? _keyHex),
clock: () => DateTime.fromMillisecondsSinceEpoch(1760000000000),
nonce: () => 'nonce-1',
sandboxOverride: sandbox,
timeout: timeout,
);
void main() {
test('signs the exact body bytes and parses a credited reply', () async {
late http.Request seen;
final client = _ClosingClient((request) async {
seen = request;
return http.Response(
_reply(credited: true, shutdown: '22:00', gaming: 120),
200,
);
});
final result = await _service(client, sandbox: false).poke(_session());
expect(result.outcome, PokeOutcome.credited);
expect(result.label, 'PC ✓ shutdown 22:00 · gaming 2.0h');
expect(result.roundTripMs, isNotNull);
expect(seen.url.toString(), 'http://10.0.0.7:$kPcPokePort$kPcPokePath');
expect(
seen.headers[kPcPokeSignatureHeader],
signPokeBody(decodePokeKeyHex(_keyHex), seen.bodyBytes),
);
final body = jsonDecode(utf8.decode(seen.bodyBytes)) as Map;
expect(body['record_id'], _session().startTime.toIso8601String());
expect(body['sandbox'], isFalse);
expect(body['nonce'], 'nonce-1');
expect(body['sent_at_ms'], 1760000000000);
expect(client.closed, isTrue);
});
test('a sandbox poke is flagged and reads back as sandbox', () async {
late Map<String, dynamic> body;
final client = MockClient((request) async {
body = jsonDecode(request.body) as Map<String, dynamic>;
return http.Response(_reply(sandbox: true), 200);
});
final result = await _service(client, sandbox: true).poke(_session());
expect(body['sandbox'], isTrue);
expect(result.outcome, PokeOutcome.sandbox);
expect(result.label, startsWith('PC ✓ (sandbox, not credited) · '));
});
test('null shutdown and budget are carried as null, not guessed', () async {
final client = MockClient(
(_) async => http.Response(_reply(credited: true), 200),
);
final result = await _service(client).poke(_session());
expect(result.shutdown, isNull);
expect(result.gamingBudgetMinutes, isNull);
expect(result.label, 'PC ✓');
});
test('a 200 ok:false is "not counted" with the PC reason', () async {
final client = MockClient(
(_) async => http.Response(_reply(ok: false, reason: 'too short'), 200),
);
final result = await _service(client).poke(_session());
expect(result.outcome, PokeOutcome.notCounted);
expect(result.label, 'PC: not counted — too short');
});
test('a non-200 falls back to the sync line', () async {
final client = MockClient(
(_) async => http.Response(_reply(ok: false, reason: 'bad mac'), 401),
);
final result = await _service(client).poke(_session());
expect(result.outcome, PokeOutcome.failed);
expect(result.reason, contains('HTTP 401'));
expect(result.label, 'PC: will credit within 60 s (via sync)');
});
test('a connection reset is a failure with the cause, not a throw', () async {
final client = _ClosingClient(
(_) async => throw http.ClientException('Connection reset by peer'),
);
final result = await _service(client).poke(_session());
expect(result.outcome, PokeOutcome.failed);
expect(result.reason, contains('could not reach the PC'));
expect(result.reason, contains('Connection reset by peer'));
expect(client.closed, isTrue);
});
test('a refused socket is a failure too', () async {
final client = MockClient(
(_) async => throw const SocketException('Connection refused'),
);
final result = await _service(client).poke(_session());
expect(result.reason, contains('Connection refused'));
});
test('a PC that never answers is cut off and its socket closed', () async {
final never = Completer<http.Response>();
final client = _ClosingClient((_) => never.future);
final result = await _service(
client,
timeout: const Duration(milliseconds: 50),
).poke(_session());
expect(result.outcome, PokeOutcome.failed);
expect(result.reason, 'no reply from the PC within 50 ms');
// Timer and Stopwatch clocks differ by a millisecond either way.
expect(result.roundTripMs, inInclusiveRange(40, 1000));
expect(client.closed, isTrue);
});
test('a hanging keystore counts against the same budget', () async {
final client = _ClosingClient((_) async => http.Response('{}', 200));
final result = await _service(
client,
pairing: () => Completer<PcPairingState>().future,
timeout: const Duration(milliseconds: 30),
).poke(_session());
expect(result.reason, 'no reply from the PC within 30 ms');
expect(client.closed, isFalse, reason: 'no client was ever built');
});
test('an unpaired phone never sends anything', () async {
var sent = false;
final client = MockClient((_) async {
sent = true;
return http.Response('{}', 200);
});
final result = await _service(
client,
pairing: () async => const PcPairingState(host: '10.0.0.7'),
).poke(_session());
expect(sent, isFalse);
expect(result.reason, contains('not paired'));
expect(result.roundTripMs, isNull);
});
}

View File

@ -0,0 +1,70 @@
// PcPokeLine: a spinner while the poke is in flight, then one coloured line.
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:workout_app/services/pc_poke_result.dart';
import 'package:workout_app/ui/theme.dart';
import 'package:workout_app/widgets/pc_poke_line.dart';
void main() {
final theme = buildAppTheme();
final status = theme.extension<AppStatusColors>()!;
Future<void> show(WidgetTester tester, Future<PokeResult> poke) async {
await tester.pumpWidget(
MaterialApp(
theme: theme,
home: Scaffold(body: PcPokeLine(poke: poke)),
),
);
await tester.pump();
}
Color? colorOf(WidgetTester tester) =>
tester.widget<Text>(find.byKey(const Key('pc-poke-result'))).style!.color;
testWidgets('pending shows "applying" with a spinner', (tester) async {
final never = Completer<PokeResult>();
await show(tester, never.future);
expect(find.byKey(const Key('pc-poke-pending')), findsOneWidget);
expect(find.text('PC: applying…'), findsOneWidget);
expect(find.byType(CircularProgressIndicator), findsOneWidget);
});
testWidgets('a sandbox round trip is a success line with its time', (
tester,
) async {
await show(
tester,
Future.value(
const PokeResult(
outcome: PokeOutcome.sandbox,
reason: 'sandbox',
roundTripMs: 77,
),
),
);
expect(find.text('PC ✓ (sandbox, not credited) · 77 ms'), findsOneWidget);
expect(colorOf(tester), status.success);
});
testWidgets('a duplicate is neutral', (tester) async {
await show(
tester,
Future.value(
const PokeResult(outcome: PokeOutcome.duplicate, reason: 'dup'),
),
);
expect(find.text('PC: already credited'), findsOneWidget);
expect(colorOf(tester), theme.colorScheme.onSurfaceVariant);
});
testWidgets('a failure is a warning naming the sync fallback', (
tester,
) async {
await show(tester, Future.value(const PokeResult.failed('timed out')));
expect(find.text('PC: will credit within 60 s (via sync)'), findsOneWidget);
expect(colorOf(tester), status.warning);
});
}