feat: remember uploaded file name on web and use it when downloading

This commit is contained in:
insleker 2025-09-18 22:30:22 +08:00
parent 0c38178502
commit 5a03793b54
5 changed files with 107 additions and 12 deletions

View File

@ -31,7 +31,11 @@ final routerProvider = Provider<GoRouter>((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<GoRouter>((ref) {
onPickPdf: () => sessionVm.pickAndOpenPdf(),
onClosePdf: () => sessionVm.closePdf(),
currentFile: sessionVm.currentFile,
currentFileName: sessionVm.displayFileName,
);
},
),

View File

@ -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<String?> Function() _savePathPicker;
// Preferred picker that accepts a suggested filename.
final Future<String?> Function(String suggestedName)
_savePathPickerWithSuggestedName;
PdfExportViewModel(
this.ref, {
ExportService? exporter,
Future<String?> Function()? savePathPicker,
Future<String?> 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<String?> pickSavePathWithSuggestedName(String suggestedName) async {
return _savePathPickerWithSuggestedName(suggestedName);
}
static Future<String?> _defaultSavePathPicker() async {
return _defaultSavePathPickerWithSuggestedName('signed.pdf');
}
static Future<String?> _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

View File

@ -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/<UUID>, 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_<original>.pdf".
String _displayFileName = '';
PdfSessionViewModel({required this.ref, required this.router});
fs.XFile get currentFile => _currentFile;
String get displayFileName => _displayFileName;
Future<void> 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<void> openPdf({String? path, Uint8List? bytes}) async {
Future<void> 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();
}

View File

@ -20,12 +20,18 @@ class PdfSignatureHomePage extends ConsumerStatefulWidget {
final Future<void> 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<PdfSignatureHomePage> {
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<PdfSignatureHomePage> {
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<PdfSignatureHomePage> {
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) {

View File

@ -46,7 +46,7 @@ Future<void> 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 {