screen-locker/stronglift_replacement/workout_app/lib/main.dart
Krzysztof kuhy Rudnicki 32a735f168 Drop explanation pages and window titles
textgate scan 319 -> 0: docs/explanations (dropped explain-diff
output, not published, nothing links it), Tk window titles, the
MaterialApp title, '+kg' -> '+' plus the weight icon, and
spoken-only Semantics labels hoisted so the gate sees them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 01:04:56 +01:00

214 lines
9.6 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/pc_pairing.dart';
import 'package:workout_app/services/progression_sync_service.dart';
import 'package:workout_app/services/run_upload_credentials.dart';
import 'package:workout_app/services/run_uploader.dart';
import 'package:workout_app/services/runnerup_importer.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();
// 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,
);
SandboxLog.event('rest override loaded', {'secs': Sandbox.restSecs});
Sandbox.runReplayFactor = await StorageService.instance
.getSandboxReplayFactor(Sandbox.defaultReplayFactor);
}
// Run uploads: absorb a login pushed by scripts/provision_run_upload.sh
// (the sandbox never uploads, so it never imports one), then retry any run
// still pending from an earlier launch.
if (!Sandbox.enabled) await RunCredentialStore().importProvisionFile();
unawaited(RunUploader.instance.uploadPending());
// 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();
}
// One-time RunnerUp history import (no-op in the sandbox and off-phone,
// and once it completed). Off the launch path: it is many downloads.
unawaited(RunnerUpImporter().runGuarded());
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());
// The nearest thing to "connectivity came back" without a plugin.
unawaited(RunUploader.instance.uploadPending());
case AppLifecycleState.inactive:
case AppLifecycleState.hidden:
break;
}
}
// coverage:ignore-end
@override
Widget build(BuildContext context) {
return MaterialApp(
// textgate: no title. Was 'Workout Tracker'; the recents card shows
// the launcher icon and the manifest label (android:label) already.
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(
// Was 'SANDBOX': the red corner ribbon alone marks it.
message: '',
location: BannerLocation.topEnd,
color: Theme.of(context).colorScheme.error,
child: child,
)
: child!,
);
}
}