2026-08-05 20:27:35 +08:00
|
|
|
// 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,
|
|
|
|
|
),
|
2026-08-06 16:01:01 +08:00
|
|
|
subtitle: Text(_kindLabel(member.kind)),
|
|
|
|
|
trailing: const Icon(Icons.chevron_right),
|
2026-08-05 20:27:35 +08:00
|
|
|
onTap: onTap,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 16:01:01 +08:00
|
|
|
String _kindLabel(NotebookMemberKind kind) => switch (kind) {
|
|
|
|
|
NotebookMemberKind.note => '空白页 · 手写',
|
|
|
|
|
NotebookMemberKind.pdf => 'PDF · 批注',
|
|
|
|
|
NotebookMemberKind.pptx => 'PPTX · 幻灯片',
|
|
|
|
|
NotebookMemberKind.ppt => 'PPT · 幻灯片',
|
|
|
|
|
NotebookMemberKind.docx => 'DOCX · 文档',
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-05 20:27:35 +08:00
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
}
|