feat: OneNote-style notebooks, text fonts, and page navigation
All checks were successful
CI / Windows build (push) Successful in 8m42s

Add notebook.json containers with multi-member pages, fix PDF text
editing (size/bold/drag/double-tap), index SidecarText in search, and
share keyboard page shortcuts plus a PDF scrubber.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-05 20:27:35 +08:00
parent 4a6fe7d05e
commit 2b1c6ba7e0
18 changed files with 1344 additions and 112 deletions

View File

@@ -9,6 +9,7 @@ import '../models/document.dart';
import '../models/note.dart';
import '../providers/document_provider.dart';
import '../providers/note_provider.dart';
import '../providers/notebook_container_provider.dart';
import '../providers/ocr_provider.dart';
import '../providers/search_provider.dart';
import '../editor/canvas/pen_editor_screen.dart';
@@ -17,6 +18,7 @@ import '../services/pptx_service.dart';
import '../services/vault_service.dart';
import '../editor/canvas/pen_note_screen.dart';
import '../editor/canvas/pen_slide_screen.dart';
import 'notebook_screen.dart';
import 'search_screen.dart';
import 'settings_screen.dart';
@@ -88,8 +90,10 @@ class HomeScreen extends ConsumerWidget {
data: (notes) {
final documentsAsync = ref.watch(documentListProvider);
final documents = documentsAsync.valueOrNull ?? [];
final containersAsync = ref.watch(notebookContainerListProvider);
final containers = containersAsync.valueOrNull ?? [];
if (notes.isEmpty && documents.isEmpty) {
if (notes.isEmpty && documents.isEmpty && containers.isEmpty) {
return _buildEmptyState(context, ref);
}
return RefreshIndicator(
@@ -97,10 +101,32 @@ class HomeScreen extends ConsumerWidget {
await Future.wait([
ref.read(noteListProvider.notifier).loadNotes(),
ref.read(documentListProvider.notifier).loadDocuments(),
ref
.read(notebookContainerListProvider.notifier)
.loadContainers(),
]);
},
child: CustomScrollView(
slivers: [
if (containers.isNotEmpty) ...[
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text(
l.notebooksSection,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) =>
_ContainerTile(container: containers[index]),
childCount: containers.length,
),
),
],
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
@@ -189,9 +215,10 @@ 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.
/// "Create notebook" (OneNote style): prompt a title (defaulting to
/// Untitled), create the notebook container FOLDER + `notebook.json` (with
/// one blank ink page) via `VaultService.createNotebookContainer`, then open
/// [NotebookScreen] on it.
Future<void> _createAndOpenNote(BuildContext context, WidgetRef ref) async {
final title = await _promptNotebookTitle(context);
if (title == null) return; // cancelled
@@ -199,12 +226,18 @@ class HomeScreen extends ConsumerWidget {
final resolved = title.trim().isEmpty
? (l?.untitledNote ?? 'Untitled')
: title.trim();
final note =
await ref.read(noteListProvider.notifier).createNote(title: resolved);
final container = await ref
.read(notebookContainerListProvider.notifier)
.createContainer(resolved);
if (context.mounted) {
Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => PenNoteScreen(note: note)));
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => NotebookScreen(
folderPath: container.folderPath,
title: container.title,
),
),
);
}
}
@@ -397,6 +430,45 @@ class HomeScreen extends ConsumerWidget {
}
}
/// Simple tile for a OneNote-style notebook container on the home screen.
/// Tapping opens [NotebookScreen] on the container's folder.
class _ContainerTile extends StatelessWidget {
const _ContainerTile({required this.container});
final VaultContainer container;
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
final dateStr = _formatDate(context, container.modified);
return ListTile(
leading: Icon(
Icons.menu_book,
color: Theme.of(context).colorScheme.primary,
),
title: Text(
container.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
'${l.memberCount(container.memberCount)} · $dateStr',
style: Theme.of(context).textTheme.bodySmall,
),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => NotebookScreen(
folderPath: container.folderPath,
title: container.title,
),
),
);
},
);
}
}
class _NoteTile extends ConsumerStatefulWidget {
final Note note;
const _NoteTile({required this.note});

View File

@@ -0,0 +1,239 @@
// lib/screens/notebook_screen.dart
//
// OneNote-style notebook container screen: lists the members (pages/imported
// documents) of one vault folder holding a `notebook.json` manifest, and
// routes taps to the matching editor by `NotebookMemberKind`. The open-by-type
// routing intentionally MIRRORS `home_screen.dart`'s `_DocumentTile._openDocument`
// so a member opens in exactly the editor its extension would on the home
// screen (pdf → PenEditorScreen; pptx/docx → OfficeDocumentScreen; legacy ppt →
// image-converted PenSlideScreen; note → PenNoteScreen).
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../editor/canvas/office_document_screen.dart';
import '../editor/canvas/pen_editor_screen.dart';
import '../editor/canvas/pen_note_screen.dart';
import '../editor/canvas/pen_slide_screen.dart';
import '../l10n/app_localizations.dart';
import '../models/note.dart';
import '../providers/document_provider.dart' show vaultServiceProvider;
import '../services/pptx_service.dart';
import '../services/vault_service.dart';
import '../storage/notebook_manifest.dart';
/// Shows one notebook container's table of contents and lets the user add a
/// blank ink page or import a file into it.
class NotebookScreen extends ConsumerStatefulWidget {
const NotebookScreen({
super.key,
required this.folderPath,
required this.title,
});
/// Absolute path to the notebook container folder (holds `notebook.json`).
final String folderPath;
/// Title shown before the manifest loads (and as a fallback if it has none).
final String title;
@override
ConsumerState<NotebookScreen> createState() => _NotebookScreenState();
}
class _NotebookScreenState extends ConsumerState<NotebookScreen> {
NotebookManifest? _manifest;
bool _loading = true;
@override
void initState() {
super.initState();
_reload();
}
Future<void> _reload() async {
final manifest = await NotebookManifest.read(widget.folderPath);
if (!mounted) return;
setState(() {
_manifest = manifest;
_loading = false;
});
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
final members = _manifest?.members ?? const [];
final title = (_manifest?.title.trim().isNotEmpty ?? false)
? _manifest!.title.trim()
: widget.title;
return Scaffold(
appBar: AppBar(
title: Text(title),
actions: [
IconButton(
icon: const Icon(Icons.note_add_outlined),
tooltip: l.addBlankPage,
onPressed: _addBlankPage,
),
IconButton(
icon: const Icon(Icons.file_open),
tooltip: l.importIntoNotebook,
onPressed: _importFile,
),
],
),
body: _loading
? const Center(child: CircularProgressIndicator())
: members.isEmpty
? Center(
child: Text(
l.notebookMembersEmpty,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
)
: RefreshIndicator(
onRefresh: _reload,
child: ListView.builder(
itemCount: members.length,
itemBuilder: (context, index) {
final member = members[index];
return _MemberTile(
member: member,
onTap: () => _openMember(member),
);
},
),
),
);
}
Future<void> _addBlankPage() async {
final vault = await ref.read(vaultServiceProvider.future);
await vault.addBlankPageToContainer(widget.folderPath);
await _reload();
}
Future<void> _importFile() async {
final l = AppLocalizations.of(context);
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: VaultService.importableExtensions.toList(),
);
final pickedPath = result?.files.first.path;
if (pickedPath == null || !mounted) return;
final messenger = ScaffoldMessenger.of(context);
messenger.showSnackBar(SnackBar(content: Text(l.processingImport)));
try {
final vault = await ref.read(vaultServiceProvider.future);
await vault.importFileIntoContainer(widget.folderPath, pickedPath);
await _reload();
} catch (e) {
messenger.showSnackBar(SnackBar(content: Text(l.importFailed('$e'))));
}
}
/// Route [member] to the correct editor by kind — same switch as
/// `home_screen.dart`'s `_DocumentTile._openDocument`.
Future<void> _openMember(NotebookMember member) async {
final vault = await ref.read(vaultServiceProvider.future);
final absolutePath = vault.memberAbsolutePath(widget.folderPath, member);
if (!mounted) return;
switch (member.kind) {
case NotebookMemberKind.note:
final now = DateTime.now();
final note = Note(
id: absolutePath,
title: member.title,
createdAt: now,
updatedAt: now,
);
await Navigator.of(context).push(
MaterialPageRoute(builder: (_) => PenNoteScreen(note: note)),
);
case NotebookMemberKind.pdf:
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => PenEditorScreen(pdfPath: absolutePath),
),
);
case NotebookMemberKind.pptx:
case NotebookMemberKind.docx:
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => OfficeDocumentScreen(filePath: absolutePath),
),
);
case NotebookMemberKind.ppt:
// Legacy binary PPT — same image-fallback path as the home screen.
await _openLegacyPpt(absolutePath);
}
}
Future<void> _openLegacyPpt(String filePath) async {
final l = AppLocalizations.of(context);
final pptxService = PptxService();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.processingPresentation)),
);
final slideImages = await pptxService.convertToImages(filePath);
final extractedText = await pptxService.extractText(filePath);
if (!mounted) return;
if (slideImages.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.couldNotOpenPresentation)),
);
return;
}
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => PenSlideScreen(
filePath: filePath,
slideImagePaths: slideImages,
extractedText: extractedText.isEmpty ? null : extractedText,
),
),
);
}
}
class _MemberTile extends StatelessWidget {
const _MemberTile({required this.member, required this.onTap});
final NotebookMember member;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return ListTile(
leading: Icon(_iconFor(member.kind), color: _colorFor(member.kind)),
title: Text(
member.title.isEmpty ? 'Untitled' : member.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(member.kind.name.toUpperCase()),
onTap: onTap,
);
}
IconData _iconFor(NotebookMemberKind kind) => switch (kind) {
NotebookMemberKind.note => Icons.edit_note,
NotebookMemberKind.pdf => Icons.picture_as_pdf,
NotebookMemberKind.pptx || NotebookMemberKind.ppt => Icons.slideshow,
NotebookMemberKind.docx => Icons.description,
};
Color _colorFor(NotebookMemberKind kind) => switch (kind) {
NotebookMemberKind.note => Colors.blue,
NotebookMemberKind.pdf => Colors.red,
NotebookMemberKind.pptx || NotebookMemberKind.ppt => Colors.orange,
NotebookMemberKind.docx => Colors.indigo,
};
}