All checks were successful
CI / Windows build (push) Successful in 12m32s
Two-way sync of the vault folder to a user-configured WebDAV server, so annotations (which travel with the file) sync with the file. - WebDavSyncService.syncNow: per-file decision — local-only uploads, remote-only downloads, and when BOTH sides changed since the last sync it keeps the loser as <file>.conflict-<mtime> on both sides (last-write-wins by mtime) so no data is ever lost. Creates dirs as needed; deletes are conservative. - The decision logic is pure and unit-tested against a fake WebDAV client; the real client is a thin http adapter (no dio dependency). - Settings: WebDAV URL / user / password / remote folder, Test connection, Sync now (with status + last-synced), and an auto-sync toggle (default OFF). Real server round-trips are device/server-validated. Credentials are in SharedPreferences for now (TODO secure-storage). analyze clean, 432 tests.
227 lines
7.7 KiB
Dart
227 lines
7.7 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: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 'services/webdav_sync_service.dart';
|
|
import 'storage/sqlite_to_sidecar_migrator.dart';
|
|
|
|
Future<void> 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<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: _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<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 HomeScreen();
|
|
return VaultSetupScreen(
|
|
vaultService: _vault!,
|
|
missing: _hadStoredPath,
|
|
onVaultReady: () async {
|
|
if (_vault != null) await _maybeMigrate(_vault!);
|
|
if (mounted) setState(() => _valid = true);
|
|
},
|
|
);
|
|
}
|
|
}
|