feat(import): one Import-file entry + vault notebooks
All checks were successful
CI / Windows build (push) Successful in 14m14s
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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user