feat(vault): pick a notebook vault folder on first run
Some checks failed
CI / Windows build (push) Has been cancelled
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:
@@ -99,5 +99,21 @@
|
|||||||
"@boardDanglingLink": {
|
"@boardDanglingLink": {
|
||||||
"placeholders": { "target": { "type": "String" } }
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -571,6 +571,84 @@ abstract class AppLocalizations {
|
|||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Close'**
|
/// **'Close'**
|
||||||
String get 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
|
class _AppLocalizationsDelegate
|
||||||
|
|||||||
@@ -257,4 +257,48 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get close => 'Close';
|
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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -256,4 +256,46 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get close => '关闭';
|
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 => '笔记库文件夹已更新';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,5 +78,18 @@
|
|||||||
"boardBacklinks": "哪些链接到这里",
|
"boardBacklinks": "哪些链接到这里",
|
||||||
"boardNoBacklinks": "暂无其他便利贴链接到这里",
|
"boardNoBacklinks": "暂无其他便利贴链接到这里",
|
||||||
"boardDanglingLink": "没有名为“{target}”的便利贴",
|
"boardDanglingLink": "没有名为“{target}”的便利贴",
|
||||||
"close": "关闭"
|
"close": "关闭",
|
||||||
|
"vaultSetupTitle": "选择笔记库",
|
||||||
|
"vaultSetupHeadline": "为你的笔记本选择一个文件夹",
|
||||||
|
"vaultSetupBody": "BadNote 会把你的笔记本都存放在你选择的一个文件夹里——就像 Obsidian 的库(vault)。请选择一个你能掌控的文件夹(例如同步盘),这样你的笔记会跟着文件一起走。",
|
||||||
|
"vaultChooseFolder": "选择文件夹",
|
||||||
|
"vaultMissingTitle": "笔记库文件夹不见了",
|
||||||
|
"vaultMissingBody": "找不到你选择的文件夹(可能被移动、删除,或所在磁盘已拔出)。请重新定位或另选一个。",
|
||||||
|
"vaultPickFailed": "无法打开文件夹选择器:{error}",
|
||||||
|
"vaultNotWritable": "该文件夹不可写,请另选一个。",
|
||||||
|
"vaultSection": "笔记库",
|
||||||
|
"vaultFolderLabel": "笔记库文件夹",
|
||||||
|
"vaultNoneSelected": "尚未选择文件夹",
|
||||||
|
"vaultChangeFolder": "更改笔记库文件夹",
|
||||||
|
"vaultUpdated": "笔记库文件夹已更新"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import 'editor/pdf/pen_capture_region.dart';
|
|||||||
import 'l10n/app_localizations.dart';
|
import 'l10n/app_localizations.dart';
|
||||||
import 'providers/settings_provider.dart';
|
import 'providers/settings_provider.dart';
|
||||||
import 'screens/home_screen.dart';
|
import 'screens/home_screen.dart';
|
||||||
|
import 'screens/vault_setup_screen.dart';
|
||||||
import 'services/database_service.dart';
|
import 'services/database_service.dart';
|
||||||
|
import 'services/vault_service.dart';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
// Kind-aware binding (extends WidgetsFlutterBinding) must be the active
|
// Kind-aware binding (extends WidgetsFlutterBinding) must be the active
|
||||||
@@ -64,7 +66,7 @@ class BadNoteApp extends ConsumerWidget {
|
|||||||
GlobalCupertinoLocalizations.delegate,
|
GlobalCupertinoLocalizations.delegate,
|
||||||
],
|
],
|
||||||
supportedLocales: AppLocalizations.supportedLocales,
|
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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_colorpicker/flutter_colorpicker.dart';
|
import 'package:flutter_colorpicker/flutter_colorpicker.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
@@ -6,6 +7,7 @@ import '../l10n/app_localizations.dart';
|
|||||||
import '../models/pen_tool.dart';
|
import '../models/pen_tool.dart';
|
||||||
import '../models/pressure_curve.dart';
|
import '../models/pressure_curve.dart';
|
||||||
import '../providers/settings_provider.dart';
|
import '../providers/settings_provider.dart';
|
||||||
|
import '../services/vault_service.dart';
|
||||||
import '../utils/stroke_stabilizer.dart';
|
import '../utils/stroke_stabilizer.dart';
|
||||||
|
|
||||||
/// Material 3 settings screen for BadNote.
|
/// Material 3 settings screen for BadNote.
|
||||||
@@ -282,6 +284,12 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(),
|
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),
|
_SectionHeader(title: 'About', icon: Icons.info),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
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 {
|
class _SectionHeader extends StatelessWidget {
|
||||||
final String title;
|
final String title;
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
|
|||||||
158
lib/screens/vault_setup_screen.dart
Normal file
158
lib/screens/vault_setup_screen.dart
Normal 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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
70
lib/services/vault_service.dart
Normal file
70
lib/services/vault_service.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
81
test/vault_service_test.dart
Normal file
81
test/vault_service_test.dart
Normal file
@@ -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<VaultService> 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user