feat(import): one Import-file entry + vault notebooks
All checks were successful
CI / Windows build (push) Successful in 14m14s

Phase 3. Import becomes a single top-level action beside "Create
notebook" and the library is vault-backed.

- VaultService.createNotebook copies a picked file into a fresh
  (de-duplicated) notebook folder under the vault; its sidecar lives
  beside it, so annotations travel with the file.
- Home screen: one "Import file" action with a multi-extension picker
  (pdf / docx / pptx); routes to the editor by extension.
- The document list is now a vault scan (folders with a source file),
  not the SQLite documents table — no cache, always correct.
- PPTX soffice detection fix; DOCX convert-on-import is best-effort
  and fails gracefully when LibreOffice is unavailable.

analyze clean, tests green.
This commit is contained in:
2026-06-24 21:12:38 +08:00
parent 978111eeff
commit 2f0fda5f95
10 changed files with 654 additions and 97 deletions

View File

@@ -1,5 +1,7 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path/path.dart' as p;
import '../l10n/app_localizations.dart';
import '../models/document.dart';
import '../models/note.dart';
@@ -7,8 +9,8 @@ import '../providers/document_provider.dart';
import '../providers/note_provider.dart';
import '../providers/ocr_provider.dart';
import '../editor/canvas/pen_editor_screen.dart';
import '../services/pdf_service.dart';
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 'search_screen.dart';
@@ -50,14 +52,9 @@ class HomeScreen extends ConsumerWidget {
},
),
IconButton(
icon: const Icon(Icons.picture_as_pdf),
tooltip: l.importPdf,
onPressed: () => _importPdf(context),
),
IconButton(
icon: const Icon(Icons.slideshow),
tooltip: l.importPpt,
onPressed: () => _importPptx(context),
icon: const Icon(Icons.file_open),
tooltip: l.importFile,
onPressed: () => _importFile(context, ref),
),
IconButton(
icon: const Icon(Icons.search),
@@ -163,7 +160,7 @@ class HomeScreen extends ConsumerWidget {
),
child: Center(
child: Text(
'No documents yet — import a PDF or PPT',
l.noDocumentsYet,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(
color: Theme.of(
@@ -192,43 +189,112 @@ class HomeScreen extends ConsumerWidget {
}
}
Future<void> _importPdf(BuildContext context) async {
final pdfService = PdfService();
final filePath = await pdfService.pickPdfFile();
if (filePath != null && context.mounted) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => PenEditorScreen(pdfPath: filePath),
),
);
/// 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).
Future<void> _importFile(BuildContext context, WidgetRef ref) async {
final l = AppLocalizations.of(context);
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: VaultService.importableExtensions.toList(),
);
final picked = result?.files;
if (picked == null || picked.isEmpty) return;
final pickedPath = picked.first.path;
if (pickedPath == null) return;
final messenger = context.mounted ? ScaffoldMessenger.of(context) : null;
messenger?.showSnackBar(SnackBar(content: Text(l.processingImport)));
try {
final vault = await ref.read(vaultServiceProvider.future);
final vaultPath = await vault.createNotebook(pickedPath);
// Refresh the documents list so the new notebook shows on return.
await ref.read(documentListProvider.notifier).loadDocuments();
if (!context.mounted) return;
await _openVaultFile(context, ref, vaultPath);
} catch (e) {
messenger?.showSnackBar(SnackBar(content: Text(l.importFailed('$e'))));
}
}
Future<void> _importPptx(BuildContext context) async {
final pptxService = PptxService();
final filePath = await pptxService.openPptxFile();
if (filePath == null || !context.mounted) return;
/// Route an in-vault [filePath] to the correct editor by extension:
/// pdf → [PenEditorScreen]; pptx/ppt → [PenSlideScreen]; docx → convert to
/// PDF (best-effort, LibreOffice) then open as PDF. Unsupported / failed
/// conversions surface a friendly message instead of crashing.
Future<void> _openVaultFile(
BuildContext context,
WidgetRef ref,
String filePath,
) async {
final l = AppLocalizations.of(context);
final ext = p.extension(filePath).replaceFirst('.', '').toLowerCase();
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Processing PPTX...')));
}
final slideImages = await pptxService.convertToImages(filePath);
final extractedText = await pptxService.extractText(filePath);
if (context.mounted) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => PenSlideScreen(
filePath: filePath,
slideImagePaths: slideImages,
extractedText: extractedText.isEmpty ? null : extractedText,
switch (ext) {
case 'pdf':
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => PenEditorScreen(pdfPath: filePath),
),
),
);
case 'pptx':
case 'ppt':
await _openPresentation(context, filePath);
case 'docx':
final pptxService = PptxService();
final pdfPath = await pptxService.convertToPdf(filePath);
if (!context.mounted) return;
if (pdfPath == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.convertNeedsLibreOffice)),
);
return;
}
// The converted PDF lives next to the docx in the notebook folder, so
// it becomes the annotatable artifact; re-scan picks it up.
await ref.read(documentListProvider.notifier).loadDocuments();
if (!context.mounted) return;
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => PenEditorScreen(pdfPath: pdfPath),
),
);
default:
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.unsupportedFileType(ext))),
);
}
}
Future<void> _openPresentation(
BuildContext context,
String filePath,
) async {
final l = AppLocalizations.of(context);
final pptxService = PptxService();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.processingPresentation)),
);
}
final slideImages = await pptxService.convertToImages(filePath);
final extractedText = await pptxService.extractText(filePath);
if (!context.mounted) return;
if (slideImages.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.couldNotOpenPresentation)),
);
return;
}
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => PenSlideScreen(
filePath: filePath,
slideImagePaths: slideImages,
extractedText: extractedText.isEmpty ? null : extractedText,
),
),
);
}
Widget _buildEmptyState(BuildContext context, WidgetRef ref) {
@@ -259,19 +325,13 @@ class HomeScreen extends ConsumerWidget {
FilledButton.icon(
onPressed: () => _createAndOpenNote(context, ref),
icon: const Icon(Icons.add),
label: Text(AppLocalizations.of(context).newNote),
label: Text(AppLocalizations.of(context).createNotebook),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: () => _importPdf(context),
icon: const Icon(Icons.picture_as_pdf),
label: Text(AppLocalizations.of(context).importPdf),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: () => _importPptx(context),
icon: const Icon(Icons.slideshow),
label: Text(AppLocalizations.of(context).importPpt),
onPressed: () => _importFile(context, ref),
icon: const Icon(Icons.file_open),
label: Text(AppLocalizations.of(context).importFile),
),
],
),
@@ -512,22 +572,45 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> {
);
}
// [L2] Route by docType: pdf → PenEditorScreen (pen-first), ppt/pptx → PenSlideScreen
// Route by docType: pdf → PenEditorScreen (pen-first), ppt/pptx → PenSlideScreen,
// docx → best-effort convert-to-PDF then open as PDF.
Future<void> _openDocument(BuildContext context) async {
final document = widget.document;
final isPdf = document.docType == 'pdf';
final l = AppLocalizations.of(context);
if (isPdf) {
if (document.docType == 'pdf') {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => PenEditorScreen(pdfPath: document.filePath),
),
);
} else {
return;
}
if (document.docType == 'docx') {
final pdfPath = await PptxService().convertToPdf(document.filePath);
if (!mounted) return;
if (pdfPath == null) {
ScaffoldMessenger.of(this.context).showSnackBar(
SnackBar(content: Text(l.convertNeedsLibreOffice)),
);
return;
}
await ref.read(documentListProvider.notifier).loadDocuments();
if (!mounted) return;
Navigator.of(this.context).push(
MaterialPageRoute(
builder: (_) => PenEditorScreen(pdfPath: pdfPath),
),
);
return;
}
{
// PPT/PPTX: convert to images then push PenSlideScreen
if (mounted) {
ScaffoldMessenger.of(this.context).showSnackBar(
const SnackBar(content: Text('Processing presentation...')),
SnackBar(content: Text(l.processingPresentation)),
);
}
final pptxService = PptxService();
@@ -536,7 +619,7 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> {
if (!mounted) return;
if (slideImages.isEmpty) {
ScaffoldMessenger.of(this.context).showSnackBar(
const SnackBar(content: Text('Could not open presentation.')),
SnackBar(content: Text(l.couldNotOpenPresentation)),
);
return;
}