screen-locker/stronglift_replacement/workout_app/lib/main.dart
Krzysztof kuhy Rudnicki 83d77884a0 Workout screen never moves when a rest starts; a sandbox flavor to prove it on the phone
The rest banner used to be inserted into the column only while a break ran,
so its arrival took height from the exercise list, shifted every tile down
and pushed the last one into scroll range. The strip is now a permanently
reserved 64 dp slot (muted "Rest / 00:00" while idle, Skip keeps its space),
the list is a non-scrolling column that scales down as a whole if it would
not fit (0.96 on the Pixel 6a with four tiles, pinned by a widget test at
the phone's logical size with the status bar simulated), and the activity is
locked to portrait. Verified on the real phone: only the tapped circle's
pixels change when a rest starts.

Verifying that needed a workout screen that today's already-logged workout
did not offer, so the app gained a `sandbox` product flavor beside `daily`:
com.kuhy.workout_app.sandbox installs side by side with its own data, refuses
every network connection at the socket layer, starts no LAN server for the
PC to pull from, keeps its /sdcard files beside the daily build's, wears a
red SANDBOX ribbon, and offers a SANDBOX section in Settings (wipe, mark
today not done, rest length in seconds). MainActivity answers the flavor and
carries a `WorkoutSandbox` logcat trace of every tap, break, save and
navigation. On Linux the same mode is WORKOUT_SANDBOX=1, driven under Xvfb by
scripts/run_sandbox_xvfb.sh. CI builds --flavor daily; .phone-deploy-flavor
tells phone_deploy.sh the same.

Set circles and the home icon buttons carry semantics labels so they can be
addressed by element (and by screen readers).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YbBZqUaNWyhHYfzz6zGC7q
2026-09-20 18:55:08 +02:00

193 lines
8.3 KiB
Dart

import 'dart:async';
import 'dart:io' show HttpOverrides, Platform;
import 'package:flutter/material.dart';
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:workout_app/sandbox/sandbox.dart';
import 'package:workout_app/sandbox/sandbox_http.dart';
import 'package:workout_app/sandbox/sandbox_log.dart';
import 'package:workout_app/screens/home_screen.dart';
import 'package:workout_app/services/backup_service.dart';
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/progression_sync_service.dart';
import 'package:workout_app/services/storage_service.dart';
import 'package:workout_app/services/sync_device_id.dart';
import 'package:workout_app/services/sync_service.dart';
import 'package:workout_app/ui/theme.dart';
// coverage:ignore-start
// App bootstrap: permission request, DB init, and a real listening socket —
// platform-channel work that can't run on the CI test host. The pieces it wires
// up are unit-tested individually; the entry point itself is not.
void main(List<String> args) async {
WidgetsFlutterBinding.ensureInitialized();
// The UI end of the channel the break foreground service talks back on.
// Must precede runApp, and must happen even on Linux: the call is a no-op
// where there is no service, and skipping it would mean the workout screen
// silently never hears a notification button press.
FlutterForegroundTask.initCommunicationPort();
// Must precede runApp: the UI reads this to decide whether to offer
// any way out of the workout.
lockModeEnabled = parseLockMode(args);
// Before any storage or network work: the sandbox flavor is offline by
// construction and keeps its /sdcard files beside the daily build's, and
// both facts have to hold before the first write, not after.
await Sandbox.init();
if (Sandbox.enabled) {
HttpOverrides.global = SandboxHttpOverrides();
BackupService.baseDir = Sandbox.backupDir;
SyncService.filePath = Sandbox.syncFilePath;
SandboxLog.event('startup', {'args': args});
}
// Linux desktop has no sqflite plugin. The FFI factory runs the same
// schema and migrations against the same SQL, so this is a transport
// swap, not a second storage implementation. Must precede any DB open.
if (Platform.isLinux) {
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
}
// Before anything that stamps an Hlc or syncs: until this resolves, the
// device id falls back to the pre-migration constant.
await initSyncDeviceId();
// Storage permission is settled before the DB is opened: opening it seeds
// factory defaults for any missing exercise, and restoring afterwards is a
// no-op once rows exist. Losing that race is what wiped months of
// progression on 2026-08-05.
//
// CHECKED, never requested. `request()` throws the user into a system
// Settings page on every launch where the permission is absent, and since
// Firebase now carries progression the `/sdcard` backup is a second copy
// rather than the only one. The grant prompt lives in Settings, where the
// user asks for it.
final storageGranted = await BackupService.instance.hasStoragePermission();
if (!storageGranted) {
debugPrint(
'WorkoutApp: no storage permission — the backup at $kBackupPath can '
'neither be read nor written, so progression rides on Firebase alone. '
'Grant it in Settings to keep a second, offline copy.',
);
}
await StorageService.init();
if (Sandbox.enabled) {
Sandbox.restSecs = await StorageService.instance.getSandboxRestSecs(
Sandbox.defaultRestSecs,
);
SandboxLog.event('rest override loaded', {'secs': Sandbox.restSecs});
}
// Before the UI exists: a service left over from a crashed or force-stopped
// workout must not sit in the status bar counting down nothing.
await stopStaleBreakService(
FlutterForegroundBreakClient(),
hasActiveWorkout: () async =>
await StorageService.instance.loadActiveSession() != null,
);
await StorageService.instance.restoreFromBackupIfNeeded();
// The permission-free restore path, and the reason storage is now optional.
// `backup.json` is unreadable without the grant, so on a denied reinstall the
// line above is a no-op and this is the ONLY thing between the user and
// factory defaults. Safe to run unconditionally: it applies remote records
// only to a freshly-installed DB, so it restores a wiped install but never
// overwrites progression — or a deliberate reset — made on this phone.
//
// Bounded: this is N sequential network reads on the launch path, so a slow
// or half-open connection would otherwise hold the UI on a blank screen.
// Timing out is safe — it leaves `progression_synced_at` unset, so the next
// launch retries and, until it succeeds, pushProgression refuses to
// overwrite the remote copy.
final restored = await ProgressionSyncService().pullProgression().timeout(
const Duration(seconds: 20),
onTimeout: () => const ProgressionSyncResult(
changed: false,
reason:
'progression pull timed out after 20s — starting on local state; '
'the next launch will retry',
),
);
debugPrint('WorkoutApp: ${restored.reason}');
// Android-only transport: it exists so the PC can pull today's workout off
// the phone over LAN. On the PC itself it would bind the very port the PC
// scans, so it is skipped rather than serving the machine to itself.
//
// Never in the sandbox: the PC scans the LAN for this port and would pull
// a sandbox workout as if it were real.
if (!Platform.isLinux && !Sandbox.enabled) {
await HttpServerService.instance.start();
}
runApp(const WorkoutApp());
}
// coverage:ignore-end
/// Root widget that bootstraps the app with Material 3 dark theming.
///
/// Also owns the app-lifecycle observer that stops [HttpServerService]'s
/// listening socket while the app is backgrounded, so the process (and its
/// open socket) doesn't sit alive indefinitely and block Doze/App-Standby.
class WorkoutApp extends StatefulWidget {
/// Creates the root app widget.
const WorkoutApp({super.key});
@override
State<WorkoutApp> createState() => _WorkoutAppState();
}
class _WorkoutAppState extends State<WorkoutApp> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
// coverage:ignore-start
// Platform edge, same shape as storage_service_schema's: the suite runs on
// Linux, where this returns on the first line, so the switch below is
// unreachable from any test this repo can run. Covering it would mean
// threading a platform seam through the nine Platform.isLinux checks in
// this app purely for test reachability.
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// The socket is never started on Linux (see main()), so there is nothing
// to stop or restart here either.
if (Platform.isLinux || Sandbox.enabled) return;
switch (state) {
case AppLifecycleState.paused:
case AppLifecycleState.detached:
unawaited(HttpServerService.instance.stop());
case AppLifecycleState.resumed:
unawaited(HttpServerService.instance.start());
case AppLifecycleState.inactive:
case AppLifecycleState.hidden:
break;
}
}
// coverage:ignore-end
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Workout Tracker',
debugShowCheckedModeBanner: false,
theme: buildAppTheme(),
home: const HomeScreen(),
// The one thing that must never be mistaken for the daily build: a
// ribbon on every screen, in the error colour, on the sandbox only.
builder: (context, child) => Sandbox.enabled
? Banner(
message: 'SANDBOX',
location: BannerLocation.topEnd,
color: Theme.of(context).colorScheme.error,
child: child,
)
: child!,
);
}
}