Files
BadNote/lib/main.dart
Akiba So d346cc2670
All checks were successful
CI / Windows build (push) Successful in 14m22s
feat: unified shell, diagnostics pack, native Office, sticky board
Make Surface remote debugging and classroom workflows viable: always-on
structured logs with one-click zip export, a single AppShell chrome,
OOXML PPTX/DOCX annotation without LibreOffice, and a first-class sticky
board. Also drop spike/legacy ink widgets and tighten pen feel
(predictor, PenInfoHistory, page-tile layer).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 17:55:27 +08:00

219 lines
7.3 KiB
Dart

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:pdfrx/pdfrx.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'editor/persistence/sidecar_flush_observer.dart';
import 'theme/app_theme.dart';
import 'diagnostics/badnote_log.dart';
import 'l10n/app_localizations.dart';
import 'providers/settings_provider.dart';
import 'screens/app_shell.dart';
import 'screens/vault_setup_screen.dart';
import 'services/database_service.dart';
import 'services/vault_service.dart';
import 'services/webdav_sync_service.dart';
import 'storage/sqlite_to_sidecar_migrator.dart';
Future<void> main() async {
WidgetsFlutterBinding.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();
// Always-on structured diagnostics (Surface remote debugging).
await BadNoteLog.instance.start();
BadNoteLog.instance.info(LogSubsystem.shell, 'app_start');
runApp(const ProviderScope(child: BadNoteApp()));
}
class BadNoteApp extends ConsumerStatefulWidget {
const BadNoteApp({super.key});
@override
ConsumerState<BadNoteApp> createState() => _BadNoteAppState();
}
class _BadNoteAppState extends ConsumerState<BadNoteApp> {
// 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: AppTheme.fromScheme(lightScheme),
darkTheme: AppTheme.fromScheme(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(),
);
},
);
}
}
/// 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<VaultGate> createState() => _VaultGateState();
}
class _VaultGateState extends State<VaultGate> {
VaultService? _vault;
bool _valid = false;
bool _hadStoredPath = false;
bool _loading = true;
bool _migrating = false;
@override
void initState() {
super.initState();
_check();
}
Future<void> _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);
_maybeAutoSync(vault); // fire-and-forget; never blocks the UI
}
}
/// Optionally kick off a WebDAV sync on launch when the user has enabled
/// auto-sync (default OFF). Deliberately NON-blocking and failure-tolerant: a
/// bad config or offline server must never delay or crash startup. Results
/// are surfaced in Settings (last-synced time) rather than interrupting here.
Future<void> _maybeAutoSync(VaultService vault) async {
try {
final prefs = await SharedPreferences.getInstance();
final sync = WebDavSyncService(prefs);
final config = sync.config;
if (!config.autoSync || !config.isConfigured) return;
final root = vault.vaultRoot;
if (root == null || root.isEmpty) return;
final client = sync.buildClient();
if (client == null) return;
try {
await sync.syncNow(vaultRoot: root, client: client);
} finally {
client.close();
}
} catch (_) {
// Auto-sync is best-effort; swallow everything so launch is unaffected.
}
}
/// 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<void> _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 AppShell();
return VaultSetupScreen(
vaultService: _vault!,
missing: _hadStoredPath,
onVaultReady: () async {
if (_vault != null) await _maybeMigrate(_vault!);
if (mounted) setState(() => _valid = true);
},
);
}
}