diff --git a/lib/routing/router.dart b/lib/routing/router.dart index 754e303..f817702 100644 --- a/lib/routing/router.dart +++ b/lib/routing/router.dart @@ -31,7 +31,11 @@ final routerProvider = Provider((ref) { onPickPdf: () => sessionVm.pickAndOpenPdf(), onOpenPdf: ({String? path, Uint8List? bytes, String? fileName}) => - sessionVm.openPdf(path: path, bytes: bytes), + sessionVm.openPdf( + path: path, + bytes: bytes, + fileName: fileName, + ), ); }, ), @@ -43,6 +47,7 @@ final routerProvider = Provider((ref) { onPickPdf: () => sessionVm.pickAndOpenPdf(), onClosePdf: () => sessionVm.closePdf(), currentFile: sessionVm.currentFile, + currentFileName: sessionVm.displayFileName, ); }, ), diff --git a/lib/ui/features/pdf/view_model/pdf_export_view_model.dart b/lib/ui/features/pdf/view_model/pdf_export_view_model.dart index a7bc643..180cac7 100644 --- a/lib/ui/features/pdf/view_model/pdf_export_view_model.dart +++ b/lib/ui/features/pdf/view_model/pdf_export_view_model.dart @@ -10,14 +10,27 @@ class PdfExportViewModel extends ChangeNotifier { // Dependencies (injectable via constructor for tests) final ExportService _exporter; + // Zero-arg picker retained for backward compatibility with tests. final Future Function() _savePathPicker; + // Preferred picker that accepts a suggested filename. + final Future Function(String suggestedName) + _savePathPickerWithSuggestedName; PdfExportViewModel( this.ref, { ExportService? exporter, Future Function()? savePathPicker, + Future Function(String suggestedName)? + savePathPickerWithSuggestedName, }) : _exporter = exporter ?? ExportService(), - _savePathPicker = savePathPicker ?? _defaultSavePathPicker; + _savePathPicker = savePathPicker ?? _defaultSavePathPicker, + // Prefer provided suggested-name picker; otherwise, if only zero-arg + // picker is given (tests), wrap it; else use default that honors name. + _savePathPickerWithSuggestedName = + savePathPickerWithSuggestedName ?? + (savePathPicker != null + ? ((_) => savePathPicker()) + : _defaultSavePathPickerWithSuggestedName); bool get exporting => _exporting; @@ -35,11 +48,22 @@ class PdfExportViewModel extends ChangeNotifier { return _savePathPicker(); } + /// Show save dialog with a suggested name and return the chosen path. + Future pickSavePathWithSuggestedName(String suggestedName) async { + return _savePathPickerWithSuggestedName(suggestedName); + } + static Future _defaultSavePathPicker() async { + return _defaultSavePathPickerWithSuggestedName('signed.pdf'); + } + + static Future _defaultSavePathPickerWithSuggestedName( + String suggestedName, + ) async { final group = fs.XTypeGroup(label: 'PDF', extensions: ['pdf']); final location = await fs.getSaveLocation( acceptedTypeGroups: [group], - suggestedName: 'signed.pdf', + suggestedName: suggestedName, confirmButtonText: 'Save', ); return location?.path; // null if user cancels diff --git a/lib/ui/features/pdf/view_model/pdf_view_model.dart b/lib/ui/features/pdf/view_model/pdf_view_model.dart index 10b9713..f2be07e 100644 --- a/lib/ui/features/pdf/view_model/pdf_view_model.dart +++ b/lib/ui/features/pdf/view_model/pdf_view_model.dart @@ -1,4 +1,5 @@ import 'dart:typed_data'; +import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:pdf_signature/data/repositories/document_repository.dart'; @@ -243,14 +244,21 @@ class PdfSessionViewModel extends ChangeNotifier { final Ref ref; final GoRouter router; fs.XFile _currentFile = fs.XFile(''); + // Keep a human display name in addition to XFile, because on Linux via + // xdg-desktop-portal the path can look like /run/user/.../doc/, and + // XFile.name derives from that basename, yielding a random UUID instead of + // the actual filename the user selected. We preserve the picker/drop name + // here to offer a sensible default like "signed_.pdf". + String _displayFileName = ''; PdfSessionViewModel({required this.ref, required this.router}); fs.XFile get currentFile => _currentFile; + String get displayFileName => _displayFileName; Future pickAndOpenPdf() async { final typeGroup = const fs.XTypeGroup(label: 'PDF', extensions: ['pdf']); - final file = await fs.openFile(acceptedTypeGroups: [typeGroup]); + final XFile? file = await fs.openFile(acceptedTypeGroups: [typeGroup]); if (file != null) { Uint8List? bytes; try { @@ -258,11 +266,15 @@ class PdfSessionViewModel extends ChangeNotifier { } catch (_) { bytes = null; } - await openPdf(path: file.path, bytes: bytes); + await openPdf(path: file.path, bytes: bytes, fileName: file.name); } } - Future openPdf({String? path, Uint8List? bytes}) async { + Future openPdf({ + String? path, + Uint8List? bytes, + String? fileName, + }) async { int pageCount = 1; // default if (bytes != null) { try { @@ -272,8 +284,31 @@ class PdfSessionViewModel extends ChangeNotifier { // ignore invalid bytes } } - if (path != null) { + if (path != null && path.isNotEmpty) { _currentFile = fs.XFile(path); + } else if (bytes != null && (fileName != null && fileName.isNotEmpty)) { + // Keep in-memory XFile so .name is available for suggestion + try { + _currentFile = fs.XFile.fromData( + bytes, + name: fileName, + mimeType: 'application/pdf', + ); + } catch (_) { + _currentFile = fs.XFile(fileName); + } + } else { + _currentFile = fs.XFile(''); + } + + // Update display name: prefer explicit fileName (from picker/drop), + // fall back to basename of path, otherwise empty. + if (fileName != null && fileName.isNotEmpty) { + _displayFileName = fileName; + } else if (path != null && path.isNotEmpty) { + _displayFileName = path.split('/').last.split('\\').last; + } else { + _displayFileName = ''; } ref .read(documentRepositoryProvider.notifier) @@ -287,6 +322,7 @@ class PdfSessionViewModel extends ChangeNotifier { ref.read(documentRepositoryProvider.notifier).close(); ref.read(signatureCardRepositoryProvider.notifier).clearAll(); _currentFile = fs.XFile(''); + _displayFileName = ''; router.go('/'); notifyListeners(); } diff --git a/lib/ui/features/pdf/widgets/pdf_screen.dart b/lib/ui/features/pdf/widgets/pdf_screen.dart index 3045af3..ea76d27 100644 --- a/lib/ui/features/pdf/widgets/pdf_screen.dart +++ b/lib/ui/features/pdf/widgets/pdf_screen.dart @@ -20,12 +20,18 @@ class PdfSignatureHomePage extends ConsumerStatefulWidget { final Future Function() onPickPdf; final VoidCallback onClosePdf; final fs.XFile currentFile; + // Optional display name for the currently opened file. On Linux + // xdg-desktop-portal, XFile.name/path can be a UUID-like value. When + // available, this name preserves the user-selected filename so we can + // suggest a proper "signed_*.pdf" on save. + final String? currentFileName; const PdfSignatureHomePage({ super.key, required this.onPickPdf, required this.onClosePdf, required this.currentFile, + this.currentFileName, }); @override @@ -152,8 +158,23 @@ class _PdfSignatureHomePageState extends ConsumerState { bool ok = false; String? savedPath; + // Derive a suggested filename based on the opened file. Prefer the + // provided display name if available (see Linux portal note above). + final display = widget.currentFileName; + final originalName = + (display != null && display.trim().isNotEmpty) + ? display.trim() + : widget.currentFile.name.isNotEmpty + ? widget.currentFile.name + : widget.currentFile.path.isNotEmpty + ? widget.currentFile.path.split('/').last.split('\\').last + : 'document.pdf'; + final suggested = _suggestSignedName(originalName); + if (!kIsWeb) { - final path = await ref.read(pdfExportViewModelProvider).pickSavePath(); + final path = await ref + .read(pdfExportViewModelProvider) + .pickSavePathWithSuggestedName(suggested); if (path == null || path.trim().isEmpty) return; final fullPath = _ensurePdfExtension(path.trim()); savedPath = fullPath; @@ -179,9 +200,9 @@ class _PdfSignatureHomePageState extends ConsumerState { targetDpi: targetDpi, ); if (out != null) { - // Use a sensible default filename (cannot prompt path on web) - ok = await downloadBytes(out, filename: 'signed.pdf'); - savedPath = 'signed.pdf'; + // Use suggested filename for browser download + ok = await downloadBytes(out, filename: suggested); + savedPath = suggested; } } if (!kIsWeb) { @@ -224,6 +245,15 @@ class _PdfSignatureHomePageState extends ConsumerState { return name; } + String _suggestSignedName(String original) { + // Normalize to a base filename + final base = original.split('/').last.split('\\').last; + if (base.toLowerCase().endsWith('.pdf')) { + return 'signed_' + base; + } + return 'signed_' + base + '.pdf'; + } + void _onControllerChanged() { if (mounted) { if (_viewModel.controller.isReady) { diff --git a/lib/ui/features/welcome/widgets/welcome_screen.dart b/lib/ui/features/welcome/widgets/welcome_screen.dart index 09c59c7..6aab832 100644 --- a/lib/ui/features/welcome/widgets/welcome_screen.dart +++ b/lib/ui/features/welcome/widgets/welcome_screen.dart @@ -46,7 +46,7 @@ Future handleDroppedFiles( bytes = null; } final String path = pdf.path ?? pdf.name; - await onOpenPdf(path: path, bytes: bytes); + await onOpenPdf(path: path, bytes: bytes, fileName: pdf.name); } class WelcomeScreen extends ConsumerStatefulWidget {