feat(vault): pick a notebook vault folder on first run
Some checks failed
CI / Windows build (push) Has been cancelled

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.
This commit is contained in:
2026-06-24 20:45:16 +08:00
parent 875dabcd89
commit 9fcac47ef2
10 changed files with 655 additions and 3 deletions

View File

@@ -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"
}

View File

@@ -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

View File

@@ -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';
}

View File

@@ -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 => '笔记库文件夹已更新';
}

View File

@@ -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": "笔记库文件夹已更新"
}

View File

@@ -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<void> 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<VaultGate> createState() => _VaultGateState();
}
class _VaultGateState extends State<VaultGate> {
VaultService? _vault;
bool _valid = false;
bool _hadStoredPath = false;
bool _loading = true;
@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;
});
}
@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),
);
}
}

View File

@@ -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<void> _load() async {
final vault = await VaultService.getInstance();
if (!mounted) return;
setState(() {
_vault = vault;
_path = vault.vaultRoot;
});
}
Future<void> _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;

View File

@@ -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<VaultSetupScreen> createState() => _VaultSetupScreenState();
}
class _VaultSetupScreenState extends State<VaultSetupScreen> {
bool _busy = false;
String? _error;
Future<void> _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<bool> _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),
),
],
),
),
),
),
);
}
}

View File

@@ -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<VaultService> 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<void> setVaultRoot(String path) async {
await _prefs.setString(vaultRootKey, path);
}
/// Forget the stored vault root (e.g. to re-prompt the user).
Future<void> 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<bool> vaultRootValid() async {
final path = vaultRoot;
if (path == null || path.isEmpty) return false;
return Directory(path).exists();
}
}