All checks were successful
CI / Windows build (push) Successful in 14m22s
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>
219 lines
7.3 KiB
Dart
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);
|
|
},
|
|
);
|
|
}
|
|
}
|