mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-11 20:13:56 +01:00
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>
214 lines
9.6 KiB
Dart
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!,
|
|
);
|
|
}
|
|
}
|