feat: OneNote-style notebooks, text fonts, and page navigation
All checks were successful
CI / Windows build (push) Successful in 8m42s
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:
@@ -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});
|
||||
|
||||
239
lib/screens/notebook_screen.dart
Normal file
239
lib/screens/notebook_screen.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user