From 9fcac47ef264d65cd1a6b27671abd60234f979ba Mon Sep 17 00:00:00 2001 From: Akiba So Date: Wed, 24 Jun 2026 20:45:16 +0800 Subject: [PATCH] feat(vault): pick a notebook vault folder on first run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0 of the file-based storage plan (docs/plans/ 2026-06-24-file-based-storage.md). Foundation only — no editor or DB change yet. - VaultService (SharedPreferences): stores the vault root path, vaultRootValid() = path set AND directory exists. - VaultSetupScreen: first-run folder picker (file_picker, Windows). - main.dart gates HomeScreen behind a valid vault, re-prompting if the saved folder is gone. - Settings: a Vault section to change the folder. Editors still use SQLite; later phases move annotations into per-file sidecars under the vault. analyze clean, tests green. --- lib/l10n/app_en.arb | 18 +++- lib/l10n/app_localizations.dart | 78 ++++++++++++++ lib/l10n/app_localizations_en.dart | 44 ++++++++ lib/l10n/app_localizations_zh.dart | 42 ++++++++ lib/l10n/app_zh.arb | 15 ++- lib/main.dart | 57 +++++++++- lib/screens/settings_screen.dart | 95 +++++++++++++++++ lib/screens/vault_setup_screen.dart | 158 ++++++++++++++++++++++++++++ lib/services/vault_service.dart | 70 ++++++++++++ test/vault_service_test.dart | 81 ++++++++++++++ 10 files changed, 655 insertions(+), 3 deletions(-) create mode 100644 lib/screens/vault_setup_screen.dart create mode 100644 lib/services/vault_service.dart create mode 100644 test/vault_service_test.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 06956d7..e01f140 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -99,5 +99,21 @@ "@boardDanglingLink": { "placeholders": { "target": { "type": "String" } } }, - "close": "Close" + "close": "Close", + "vaultSetupTitle": "Choose your vault", + "vaultSetupHeadline": "Pick a folder for your notebooks", + "vaultSetupBody": "BadNote stores your notebooks inside one folder you choose — like an Obsidian vault. Pick a folder you control (e.g. a synced folder) so your notes travel with their files.", + "vaultChooseFolder": "Choose folder", + "vaultMissingTitle": "Your vault folder is missing", + "vaultMissingBody": "The folder you picked can't be found (it may have been moved, deleted, or on a drive that's unplugged). Relocate it or pick a new one.", + "vaultPickFailed": "Couldn't open the folder picker: {error}", + "@vaultPickFailed": { + "placeholders": { "error": { "type": "String" } } + }, + "vaultNotWritable": "That folder isn't writable. Please choose another.", + "vaultSection": "Vault", + "vaultFolderLabel": "Vault folder", + "vaultNoneSelected": "No folder selected", + "vaultChangeFolder": "Change vault folder", + "vaultUpdated": "Vault folder updated" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index bb0fc94..9277fc8 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -571,6 +571,84 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Close'** String get close; + + /// No description provided for @vaultSetupTitle. + /// + /// In en, this message translates to: + /// **'Choose your vault'** + String get vaultSetupTitle; + + /// No description provided for @vaultSetupHeadline. + /// + /// In en, this message translates to: + /// **'Pick a folder for your notebooks'** + String get vaultSetupHeadline; + + /// No description provided for @vaultSetupBody. + /// + /// In en, this message translates to: + /// **'BadNote stores your notebooks inside one folder you choose — like an Obsidian vault. Pick a folder you control (e.g. a synced folder) so your notes travel with their files.'** + String get vaultSetupBody; + + /// No description provided for @vaultChooseFolder. + /// + /// In en, this message translates to: + /// **'Choose folder'** + String get vaultChooseFolder; + + /// No description provided for @vaultMissingTitle. + /// + /// In en, this message translates to: + /// **'Your vault folder is missing'** + String get vaultMissingTitle; + + /// No description provided for @vaultMissingBody. + /// + /// In en, this message translates to: + /// **'The folder you picked can\'t be found (it may have been moved, deleted, or on a drive that\'s unplugged). Relocate it or pick a new one.'** + String get vaultMissingBody; + + /// No description provided for @vaultPickFailed. + /// + /// In en, this message translates to: + /// **'Couldn\'t open the folder picker: {error}'** + String vaultPickFailed(String error); + + /// No description provided for @vaultNotWritable. + /// + /// In en, this message translates to: + /// **'That folder isn\'t writable. Please choose another.'** + String get vaultNotWritable; + + /// No description provided for @vaultSection. + /// + /// In en, this message translates to: + /// **'Vault'** + String get vaultSection; + + /// No description provided for @vaultFolderLabel. + /// + /// In en, this message translates to: + /// **'Vault folder'** + String get vaultFolderLabel; + + /// No description provided for @vaultNoneSelected. + /// + /// In en, this message translates to: + /// **'No folder selected'** + String get vaultNoneSelected; + + /// No description provided for @vaultChangeFolder. + /// + /// In en, this message translates to: + /// **'Change vault folder'** + String get vaultChangeFolder; + + /// No description provided for @vaultUpdated. + /// + /// In en, this message translates to: + /// **'Vault folder updated'** + String get vaultUpdated; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 1bc24c4..9fb5e58 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -257,4 +257,48 @@ class AppLocalizationsEn extends AppLocalizations { @override String get close => 'Close'; + + @override + String get vaultSetupTitle => 'Choose your vault'; + + @override + String get vaultSetupHeadline => 'Pick a folder for your notebooks'; + + @override + String get vaultSetupBody => + 'BadNote stores your notebooks inside one folder you choose — like an Obsidian vault. Pick a folder you control (e.g. a synced folder) so your notes travel with their files.'; + + @override + String get vaultChooseFolder => 'Choose folder'; + + @override + String get vaultMissingTitle => 'Your vault folder is missing'; + + @override + String get vaultMissingBody => + 'The folder you picked can\'t be found (it may have been moved, deleted, or on a drive that\'s unplugged). Relocate it or pick a new one.'; + + @override + String vaultPickFailed(String error) { + return 'Couldn\'t open the folder picker: $error'; + } + + @override + String get vaultNotWritable => + 'That folder isn\'t writable. Please choose another.'; + + @override + String get vaultSection => 'Vault'; + + @override + String get vaultFolderLabel => 'Vault folder'; + + @override + String get vaultNoneSelected => 'No folder selected'; + + @override + String get vaultChangeFolder => 'Change vault folder'; + + @override + String get vaultUpdated => 'Vault folder updated'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index daab903..1398787 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -256,4 +256,46 @@ class AppLocalizationsZh extends AppLocalizations { @override String get close => '关闭'; + + @override + String get vaultSetupTitle => '选择笔记库'; + + @override + String get vaultSetupHeadline => '为你的笔记本选择一个文件夹'; + + @override + String get vaultSetupBody => + 'BadNote 会把你的笔记本都存放在你选择的一个文件夹里——就像 Obsidian 的库(vault)。请选择一个你能掌控的文件夹(例如同步盘),这样你的笔记会跟着文件一起走。'; + + @override + String get vaultChooseFolder => '选择文件夹'; + + @override + String get vaultMissingTitle => '笔记库文件夹不见了'; + + @override + String get vaultMissingBody => '找不到你选择的文件夹(可能被移动、删除,或所在磁盘已拔出)。请重新定位或另选一个。'; + + @override + String vaultPickFailed(String error) { + return '无法打开文件夹选择器:$error'; + } + + @override + String get vaultNotWritable => '该文件夹不可写,请另选一个。'; + + @override + String get vaultSection => '笔记库'; + + @override + String get vaultFolderLabel => '笔记库文件夹'; + + @override + String get vaultNoneSelected => '尚未选择文件夹'; + + @override + String get vaultChangeFolder => '更改笔记库文件夹'; + + @override + String get vaultUpdated => '笔记库文件夹已更新'; } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 247d5c8..5b19546 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -78,5 +78,18 @@ "boardBacklinks": "哪些链接到这里", "boardNoBacklinks": "暂无其他便利贴链接到这里", "boardDanglingLink": "没有名为“{target}”的便利贴", - "close": "关闭" + "close": "关闭", + "vaultSetupTitle": "选择笔记库", + "vaultSetupHeadline": "为你的笔记本选择一个文件夹", + "vaultSetupBody": "BadNote 会把你的笔记本都存放在你选择的一个文件夹里——就像 Obsidian 的库(vault)。请选择一个你能掌控的文件夹(例如同步盘),这样你的笔记会跟着文件一起走。", + "vaultChooseFolder": "选择文件夹", + "vaultMissingTitle": "笔记库文件夹不见了", + "vaultMissingBody": "找不到你选择的文件夹(可能被移动、删除,或所在磁盘已拔出)。请重新定位或另选一个。", + "vaultPickFailed": "无法打开文件夹选择器:{error}", + "vaultNotWritable": "该文件夹不可写,请另选一个。", + "vaultSection": "笔记库", + "vaultFolderLabel": "笔记库文件夹", + "vaultNoneSelected": "尚未选择文件夹", + "vaultChangeFolder": "更改笔记库文件夹", + "vaultUpdated": "笔记库文件夹已更新" } diff --git a/lib/main.dart b/lib/main.dart index f40ff2c..ec3a9ec 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,7 +10,9 @@ 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'; Future main() async { // Kind-aware binding (extends WidgetsFlutterBinding) must be the active @@ -64,7 +66,7 @@ class BadNoteApp extends ConsumerWidget { GlobalCupertinoLocalizations.delegate, ], supportedLocales: AppLocalizations.supportedLocales, - home: const HomeScreen(), + home: const VaultGate(), ); }, ); @@ -78,3 +80,56 @@ class BadNoteApp extends ConsumerWidget { ), ); } + +/// 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; + + @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; + }); + } + + @override + Widget build(BuildContext context) { + if (_loading) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } + if (_valid) return const HomeScreen(); + return VaultSetupScreen( + vaultService: _vault!, + missing: _hadStoredPath, + onVaultReady: () => setState(() => _valid = true), + ); + } +} diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index c6111fd..14f249b 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1,3 +1,4 @@ +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_colorpicker/flutter_colorpicker.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -6,6 +7,7 @@ import '../l10n/app_localizations.dart'; import '../models/pen_tool.dart'; import '../models/pressure_curve.dart'; import '../providers/settings_provider.dart'; +import '../services/vault_service.dart'; import '../utils/stroke_stabilizer.dart'; /// Material 3 settings screen for BadNote. @@ -282,6 +284,12 @@ class SettingsScreen extends ConsumerWidget { ), ), const Divider(), + _SectionHeader(title: 'Vault', icon: Icons.folder_special), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: _VaultSettings(), + ), + const Divider(), _SectionHeader(title: 'About', icon: Icons.info), Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), @@ -320,6 +328,93 @@ class SettingsScreen extends ConsumerWidget { } } +/// Shows the current vault folder path and lets the user re-pick it. Re-uses +/// the same `getDirectoryPath` flow as the first-run [VaultSetupScreen], +/// persisting the choice through [VaultService.setVaultRoot]. +class _VaultSettings extends StatefulWidget { + const _VaultSettings(); + + @override + State<_VaultSettings> createState() => _VaultSettingsState(); +} + +class _VaultSettingsState extends State<_VaultSettings> { + VaultService? _vault; + String? _path; + bool _busy = false; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final vault = await VaultService.getInstance(); + if (!mounted) return; + setState(() { + _vault = vault; + _path = vault.vaultRoot; + }); + } + + Future _changeFolder() async { + final vault = _vault; + if (vault == null) return; + final l = AppLocalizations.of(context); + final messenger = ScaffoldMessenger.of(context); + setState(() => _busy = true); + try { + final path = await FilePicker.platform.getDirectoryPath( + dialogTitle: l.vaultSetupTitle, + lockParentWindow: true, + ); + if (path != null) { + await vault.setVaultRoot(path); + if (!mounted) return; + setState(() => _path = path); + messenger.showSnackBar(SnackBar(content: Text(l.vaultUpdated))); + } + } catch (e) { + if (!mounted) return; + messenger.showSnackBar( + SnackBar(content: Text(l.vaultPickFailed(e.toString()))), + ); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final l = AppLocalizations.of(context); + final colorScheme = Theme.of(context).colorScheme; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l.vaultFolderLabel, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + const SizedBox(height: 4), + Text( + (_path == null || _path!.isEmpty) ? l.vaultNoneSelected : _path!, + style: TextStyle( + fontSize: 13, + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: _busy ? null : _changeFolder, + icon: const Icon(Icons.drive_folder_upload), + label: Text(l.vaultChangeFolder), + ), + ], + ); + } +} + class _SectionHeader extends StatelessWidget { final String title; final IconData icon; diff --git a/lib/screens/vault_setup_screen.dart b/lib/screens/vault_setup_screen.dart new file mode 100644 index 0000000..f0e610d --- /dev/null +++ b/lib/screens/vault_setup_screen.dart @@ -0,0 +1,158 @@ +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:path/path.dart' as p; + +import '../l10n/app_localizations.dart'; +import '../services/vault_service.dart'; + +/// First-run (and re-prompt) gate that asks the user to pick a vault root +/// folder — the single folder under which all notebooks will live, like an +/// Obsidian vault. +/// +/// On a successful, writable selection the chosen path is persisted via +/// [VaultService] and [onVaultReady] is invoked so the host can proceed to the +/// home screen. When [missing] is true the screen shows a "your vault folder is +/// missing" message instead of the first-run copy (the saved folder no longer +/// exists). +class VaultSetupScreen extends StatefulWidget { + const VaultSetupScreen({ + super.key, + required this.vaultService, + required this.onVaultReady, + this.missing = false, + }); + + final VaultService vaultService; + + /// Called after a valid, writable vault root has been persisted. + final VoidCallback onVaultReady; + + /// Whether a previously-chosen folder went missing (changes the copy). + final bool missing; + + @override + State createState() => _VaultSetupScreenState(); +} + +class _VaultSetupScreenState extends State { + bool _busy = false; + String? _error; + + Future _pickFolder() async { + final l = AppLocalizations.of(context); + setState(() { + _busy = true; + _error = null; + }); + try { + // getDirectoryPath is Desktop/Windows supported by file_picker. + // lockParentWindow makes the native Windows dialog modal. + final path = await FilePicker.platform.getDirectoryPath( + dialogTitle: l.vaultSetupTitle, + lockParentWindow: true, + ); + if (path == null) { + // User cancelled the native dialog. + if (mounted) setState(() => _busy = false); + return; + } + if (!await _isWritable(path)) { + if (mounted) { + setState(() { + _busy = false; + _error = l.vaultNotWritable; + }); + } + return; + } + await widget.vaultService.setVaultRoot(path); + if (mounted) widget.onVaultReady(); + } catch (e) { + if (mounted) { + setState(() { + _busy = false; + _error = l.vaultPickFailed(e.toString()); + }); + } + } + } + + /// Probe writability by creating and deleting a temp file in [path]. The + /// native folder dialog can hand back a read-only location on Windows, so we + /// validate before committing it as the vault. + Future _isWritable(String path) async { + final probe = File(p.join(path, '.badnote-write-probe')); + try { + await probe.writeAsString('ok', flush: true); + await probe.delete(); + return true; + } catch (_) { + return false; + } + } + + @override + Widget build(BuildContext context) { + final l = AppLocalizations.of(context); + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + return Scaffold( + body: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 480), + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + widget.missing + ? Icons.folder_off_outlined + : Icons.folder_special_outlined, + size: 56, + color: colorScheme.primary, + ), + const SizedBox(height: 24), + Text( + widget.missing ? l.vaultMissingTitle : l.vaultSetupHeadline, + style: textTheme.headlineSmall + ?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 12), + Text( + widget.missing ? l.vaultMissingBody : l.vaultSetupBody, + style: textTheme.bodyMedium + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + if (_error != null) ...[ + const SizedBox(height: 16), + Text( + _error!, + style: textTheme.bodyMedium + ?.copyWith(color: colorScheme.error), + ), + ], + const SizedBox(height: 32), + FilledButton.icon( + onPressed: _busy ? null : _pickFolder, + icon: _busy + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.folder_open), + label: Text(l.vaultChooseFolder), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/services/vault_service.dart b/lib/services/vault_service.dart new file mode 100644 index 0000000..e7f0c62 --- /dev/null +++ b/lib/services/vault_service.dart @@ -0,0 +1,70 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart' show visibleForTesting; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Records the user-picked vault root folder (an Obsidian-style vault) and +/// gates app startup behind a valid choice. +/// +/// The vault root is the single folder under which all notebooks will live. +/// Phase 0 only persists the path and validates it exists; no data is moved +/// into the vault yet (later phases do that). +/// +/// Persistence is SharedPreferences-backed under [vaultRootKey]. The service is +/// easily mockable: inject a [SharedPreferences] (e.g. from +/// `SharedPreferences.setMockInitialValues`) via the constructor for tests. +class VaultService { + /// SharedPreferences key under which the vault root path is stored. + static const String vaultRootKey = 'vaultRoot'; + + final SharedPreferences _prefs; + + VaultService._(this._prefs); + + static VaultService? _instance; + + /// Singleton accessor, mirroring [DatabaseService.getInstance]. Lazily reads + /// the shared [SharedPreferences] instance. + static Future getInstance() async { + if (_instance != null) return _instance!; + final prefs = await SharedPreferences.getInstance(); + final service = VaultService._(prefs); + _instance = service; + return service; + } + + /// Test-only constructor: inject a (typically mock) [SharedPreferences] so + /// the vault root can be exercised without platform channels. + @visibleForTesting + VaultService.forTest(SharedPreferences prefs) : _prefs = prefs; + + /// Test-only: drop the cached singleton so the next [getInstance] rebuilds. + @visibleForTesting + static void resetForTest() { + _instance = null; + } + + /// The currently stored vault root path, or null if none has been chosen. + String? get vaultRoot => _prefs.getString(vaultRootKey); + + /// Persist [path] as the vault root. + Future setVaultRoot(String path) async { + await _prefs.setString(vaultRootKey, path); + } + + /// Forget the stored vault root (e.g. to re-prompt the user). + Future clearVaultRoot() async { + await _prefs.remove(vaultRootKey); + } + + /// True iff a vault root is set AND that directory currently exists. + /// + /// Returns false when no path is stored or when the stored path no longer + /// resolves to a directory (external drive unplugged, folder deleted) — the + /// caller then re-prompts rather than silently scattering data elsewhere. + Future vaultRootValid() async { + final path = vaultRoot; + if (path == null || path.isEmpty) return false; + return Directory(path).exists(); + } +} diff --git a/test/vault_service_test.dart b/test/vault_service_test.dart new file mode 100644 index 0000000..bcbd7f5 --- /dev/null +++ b/test/vault_service_test.dart @@ -0,0 +1,81 @@ +// Proves the Phase-0 vault root recording: the path persists through +// SharedPreferences, vaultRootValid() is true ONLY when a path is set AND its +// directory exists, and clearVaultRoot() forgets it. Uses a real temp dir for +// existence checks and a mock SharedPreferences for persistence. + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:badnote/services/vault_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + tempDir = await Directory.systemTemp.createTemp('vault_service_test'); + }); + + tearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + Future makeService() async { + final prefs = await SharedPreferences.getInstance(); + return VaultService.forTest(prefs); + } + + test('starts with no vault root and is invalid', () async { + final vault = await makeService(); + expect(vault.vaultRoot, isNull); + expect(await vault.vaultRootValid(), isFalse); + }); + + test('setVaultRoot persists the path', () async { + final vault = await makeService(); + await vault.setVaultRoot(tempDir.path); + expect(vault.vaultRoot, tempDir.path); + + // Re-read through a fresh service over the SAME prefs store: persisted. + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString(VaultService.vaultRootKey), tempDir.path); + final reopened = VaultService.forTest(prefs); + expect(reopened.vaultRoot, tempDir.path); + }); + + test('vaultRootValid is true only when set AND the dir exists', () async { + final vault = await makeService(); + + // Set to an existing dir → valid. + await vault.setVaultRoot(tempDir.path); + expect(await vault.vaultRootValid(), isTrue); + + // Point at a path that does not exist → invalid (folder went missing). + final missing = '${tempDir.path}${Platform.pathSeparator}gone'; + await vault.setVaultRoot(missing); + expect(vault.vaultRoot, missing); + expect(await vault.vaultRootValid(), isFalse); + }); + + test('clearVaultRoot forgets the path and becomes invalid', () async { + final vault = await makeService(); + await vault.setVaultRoot(tempDir.path); + expect(await vault.vaultRootValid(), isTrue); + + await vault.clearVaultRoot(); + expect(vault.vaultRoot, isNull); + expect(await vault.vaultRootValid(), isFalse); + }); + + test('empty stored path is treated as invalid', () async { + final vault = await makeService(); + await vault.setVaultRoot(''); + expect(await vault.vaultRootValid(), isFalse); + }); +}