feat(storage): notes are vault sidecar notebooks
All checks were successful
CI / Windows build (push) Successful in 12m55s

Phase 4. Standalone notes move off SQLite into the vault, like the
PDF annotations.

- "Create notebook" makes a vault folder with a notebook.badnote.json
  (BadnoteSidecar docType 'notebook' + a title field), opened via
  SidecarRepository.
- PenNoteScreen loads/saves its strokes (page 0) + title to that
  sidecar instead of the SQLite Note model.
- note_provider lists notes from a vault scan (VaultService.scanNotes
  = folders with notebook.badnote.json and no source file); the doc
  scan still excludes them. Delete removes the folder.

PDF/slide editors unchanged; pre-existing SQLite notes migrate in
Phase 5. analyze clean, tests green.
This commit is contained in:
2026-06-24 22:48:18 +08:00
parent 2f0fda5f95
commit f4f0853eae
14 changed files with 598 additions and 70 deletions

View File

@@ -119,7 +119,7 @@ class HomeScreen extends ConsumerWidget {
),
child: Center(
child: Text(
'No ink notes yet — tap + to create one',
l.noNotesYetHint,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(
color: Theme.of(
@@ -180,8 +180,18 @@ class HomeScreen extends ConsumerWidget {
);
}
/// "Create notebook": prompt a title (defaulting to Untitled), create the
/// standalone notebook FOLDER + `notebook.badnote.json` via
/// `VaultService.createEmptyNotebook`, then open the editor on the new note.
Future<void> _createAndOpenNote(BuildContext context, WidgetRef ref) async {
final note = await ref.read(noteListProvider.notifier).createNote();
final title = await _promptNotebookTitle(context);
if (title == null) return; // cancelled
final l = context.mounted ? AppLocalizations.of(context) : null;
final resolved = title.trim().isEmpty
? (l?.untitledNote ?? 'Untitled')
: title.trim();
final note =
await ref.read(noteListProvider.notifier).createNote(title: resolved);
if (context.mounted) {
Navigator.of(
context,
@@ -189,6 +199,35 @@ class HomeScreen extends ConsumerWidget {
}
}
/// Ask for a notebook title. Returns the entered string (possibly empty →
/// caller defaults it), or null if the user cancelled.
Future<String?> _promptNotebookTitle(BuildContext context) {
final l = AppLocalizations.of(context);
final controller = TextEditingController(text: l.untitledNote);
return showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(l.newNotebookTitle),
content: TextField(
controller: controller,
autofocus: true,
decoration: InputDecoration(hintText: l.notebookTitleHint),
onSubmitted: (v) => Navigator.of(ctx).pop(v),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text(l.cancel),
),
TextButton(
onPressed: () => Navigator.of(ctx).pop(controller.text),
child: Text(l.create),
),
],
),
);
}
/// Single top-level "Import file" action (sibling of "Create notebook"):
/// pick a pdf/docx/pptx/ppt, copy it into a new vault notebook folder, then
/// open the IN-VAULT copy in the right editor (routed by extension).
@@ -386,8 +425,10 @@ class _NoteTileState extends ConsumerState<_NoteTile> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Stroke count is no longer cached in the vault scan (strokes
// load lazily in the editor), so the tile shows only the date.
Text(
'${note.strokes.length} stroke${note.strokes.length == 1 ? '' : 's'} · $dateStr',
dateStr,
style: Theme.of(context).textTheme.bodySmall,
),
if (note.tags.isNotEmpty)