import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:pdfrx/pdfrx.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'editor/persistence/sidecar_flush_observer.dart'; import 'editor/pdf/pen_capture_region.dart'; import 'l10n/app_localizations.dart'; import 'providers/settings_provider.dart'; import 'screens/home_screen.dart'; import 'screens/vault_setup_screen.dart'; import 'services/database_service.dart'; import 'services/vault_service.dart'; import 'storage/sqlite_to_sidecar_migrator.dart'; Future main() async { // Kind-aware binding (extends WidgetsFlutterBinding) must be the active // binding before runApp so the M1 spike's PenCaptureRegion can gate // hit-testing by pointer kind. Safe for the rest of the app: with no pen // region mounted it behaves exactly like the default binding. PenCaptureBinding.ensureInitialized(); // pdfrx native engine init (required before any PdfViewer is built). pdfrxFlutterInitialize(); // Ensure DB is ready before the app starts so providers can use it eagerly await DatabaseService.getInstance(); // Initialize SharedPreferences await SharedPreferences.getInstance(); runApp(const ProviderScope(child: BadNoteApp())); } class BadNoteApp extends ConsumerStatefulWidget { const BadNoteApp({super.key}); @override ConsumerState createState() => _BadNoteAppState(); } class _BadNoteAppState extends ConsumerState { // Phase 6 / §F.3: flush any open sidecar repos when the app is suspended or // closed so the last strokes are never lost to an OS kill. Lives for the whole // app lifetime (attached here, detached on app teardown). final SidecarFlushObserver _flushObserver = SidecarFlushObserver(); @override void initState() { super.initState(); _flushObserver.attach(); } @override void dispose() { _flushObserver.detach(); super.dispose(); } @override Widget build(BuildContext context) { final settings = ref.watch(settingsProvider); // Material You: prefer the OS dynamic color (Windows/Android system accent); // fall back to the user's seed color when the platform provides none. return DynamicColorBuilder( builder: (lightDynamic, darkDynamic) { final lightScheme = lightDynamic?.harmonized() ?? ColorScheme.fromSeed( seedColor: settings.colorSchemeSeed, brightness: Brightness.light, ); final darkScheme = darkDynamic?.harmonized() ?? ColorScheme.fromSeed( seedColor: settings.colorSchemeSeed, brightness: Brightness.dark, ); return MaterialApp( title: 'BadNote', themeMode: settings.themeMode, theme: _theme(lightScheme), darkTheme: _theme(darkScheme), // i18n: follows the OS language (en / zh) via the system locale. localizationsDelegates: const [ AppLocalizations.delegate, GlobalMaterialLocalizations.delegate, GlobalWidgetsLocalizations.delegate, GlobalCupertinoLocalizations.delegate, ], supportedLocales: AppLocalizations.supportedLocales, home: const VaultGate(), ); }, ); } ThemeData _theme(ColorScheme scheme) => ThemeData( colorScheme: scheme, useMaterial3: true, textTheme: GoogleFonts.interTextTheme( ThemeData(brightness: scheme.brightness).textTheme, ), ); } /// Startup gate: shows [HomeScreen] only once a valid vault root folder has been /// chosen. If none is set — or the saved folder no longer exists — it shows /// [VaultSetupScreen] first (re-prompting on a missing folder rather than /// silently scattering data elsewhere). Phase 0: records the vault path only; /// editors still use SQLite. class VaultGate extends StatefulWidget { const VaultGate({super.key}); @override State createState() => _VaultGateState(); } class _VaultGateState extends State { VaultService? _vault; bool _valid = false; bool _hadStoredPath = false; bool _loading = true; bool _migrating = false; @override void initState() { super.initState(); _check(); } Future _check() async { final vault = await VaultService.getInstance(); final valid = await vault.vaultRootValid(); if (!mounted) return; setState(() { _vault = vault; _valid = valid; // A stored-but-invalid path means the chosen folder went missing. _hadStoredPath = (vault.vaultRoot?.isNotEmpty ?? false); _loading = false; }); if (valid) await _maybeMigrate(vault); } /// Run the one-time SQLite→sidecar migration ONCE per vault (Phase 5, §B). /// Gated on [VaultService.vaultMigrationDone]; a fresh install (no legacy DB) /// is a fast no-op. The live DB is first reopened at the vault cache location /// so post-migration reads hit the new index, never the renamed legacy file. Future _maybeMigrate(VaultService vault) async { // Move the live cache DB to the vault location now that the root is valid. await DatabaseService.reopen(); if (vault.vaultMigrationDone) return; if (mounted) setState(() => _migrating = true); try { await SqliteToSidecarMigrator(vault).run(); await vault.setVaultMigrationDone(); } catch (_) { // A failed migration leaves the legacy DB intact (it is only renamed to // `.premigration` after a successful pass) and the flag unset, so the // next launch retries. Never block the user from reaching the app. } if (mounted) setState(() => _migrating = false); } @override Widget build(BuildContext context) { if (_loading) { return const Scaffold( body: Center(child: CircularProgressIndicator()), ); } if (_migrating) { return const Scaffold( body: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ CircularProgressIndicator(), SizedBox(height: 16), Text('Migrating your notebooks…'), ], ), ), ); } if (_valid) return const HomeScreen(); return VaultSetupScreen( vaultService: _vault!, missing: _hadStoredPath, onVaultReady: () async { if (_vault != null) await _maybeMigrate(_vault!); if (mounted) setState(() => _valid = true); }, ); } }