From a2d0f72e34443eedef6de901e771ec4383fcab8a Mon Sep 17 00:00:00 2001 From: qysp Date: Sat, 3 Aug 2024 20:19:17 +0200 Subject: [PATCH] add ability to download scanned files directly to the end device (#17) --- app/lib/pages/home.dart | 132 +++++++++++++----- app/lib/pages/settings.dart | 18 ++- app/lib/providers/settings_provider.dart | 9 ++ .../Flutter/GeneratedPluginRegistrant.swift | 2 + app/pubspec.lock | 32 +++++ app/pubspec.yaml | 2 + src/routes/api/scan/index.ts | 28 +++- src/server/services/photosmart-service.ts | 2 + 8 files changed, 179 insertions(+), 46 deletions(-) diff --git a/app/lib/pages/home.dart b/app/lib/pages/home.dart index f8dc945..bbeb6d0 100644 --- a/app/lib/pages/home.dart +++ b/app/lib/pages/home.dart @@ -1,5 +1,10 @@ +import 'dart:io'; + import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:open_file/open_file.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:photosmarter/components/scan_options.dart'; import 'package:photosmarter/extensions/string_formatting.dart'; import 'package:photosmarter/models/scan_result.dart'; @@ -22,8 +27,10 @@ class HomePage extends StatefulWidget { class _HomePageState extends State { bool _isScanning = false; - Future _scan( - String baseUrl, String fileName, OptionsProvider optionsProvider) async { + Future> _scan(String fileName) async { + final optionsProvider = context.read(); + final settingsProvider = context.read(); + final formData = FormData.fromMap({ 'type': optionsProvider.type.name.toUpperCase(), 'dimension': optionsProvider.dimension.name.capitalize(), @@ -34,21 +41,31 @@ class _HomePageState extends State { }); final response = await dio.post( - '$baseUrl/api/scan', + '${settingsProvider.baseUrl}/api/scan', + options: Options( + headers: { + 'Accept': settingsProvider.directDownload + ? 'application/pdf, image/jpeg' + : 'application/json', + }, + responseType: settingsProvider.directDownload + ? ResponseType.bytes + : ResponseType.json, + ), data: formData, ); - return ScanResult.fromJson(response.data); + return response; } - SnackBar _createSnackBar( + void _showSnackBar( String content, { Color? backgroundColor, Color? textColor, Duration? duration, SnackBarAction? action, }) { - return SnackBar( + ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text(content, style: TextStyle(color: textColor)), backgroundColor: backgroundColor, padding: const EdgeInsets.symmetric( @@ -61,7 +78,7 @@ class _HomePageState extends State { ), duration: duration ?? const Duration(seconds: 5), action: action, - ); + )); } Future _promptFileName() async { @@ -103,10 +120,48 @@ class _HomePageState extends State { return fileName?.text; } + Future _saveFileLocally(Response response) async { + final theme = Theme.of(context); + + final safeFileName = + response.headers['x-photosmarter-filename']?.firstOrNull; + if (safeFileName == null) { + _showSnackBar( + 'No file name provided by server', + backgroundColor: theme.colorScheme.errorContainer, + textColor: theme.colorScheme.onErrorContainer, + ); + return; + } + + final dir = await getApplicationDocumentsDirectory(); + final file = File('${dir.path}/$safeFileName'); + if (await file.exists()) { + _showSnackBar( + 'File $safeFileName already exists', + backgroundColor: theme.colorScheme.errorContainer, + textColor: theme.colorScheme.onErrorContainer, + ); + return; + } + await file.writeAsBytes(response.data); + + _showSnackBar( + 'File saved as $safeFileName', + backgroundColor: theme.colorScheme.primaryContainer, + textColor: theme.colorScheme.onPrimaryContainer, + action: SnackBarAction( + label: 'Open', + textColor: theme.colorScheme.onPrimaryContainer, + // TODO: why can the file not be opened? + onPressed: () => OpenFile.open(file.path), + ), + ); + } + @override Widget build(BuildContext context) { final settingsProvider = context.watch(); - final messenger = ScaffoldMessenger.of(context); final theme = Theme.of(context); return Scaffold( @@ -137,7 +192,7 @@ class _HomePageState extends State { child: FloatingActionButton.extended( onPressed: !_isScanning ? () async { - final optionsProvider = context.read(); + await HapticFeedback.heavyImpact(); final fileName = await _promptFileName(); if (fileName == null) { @@ -149,11 +204,13 @@ class _HomePageState extends State { }); try { - final result = await _scan( - settingsProvider.baseUrl, fileName, optionsProvider); + final response = await _scan(fileName); - messenger.showSnackBar( - _createSnackBar( + if (settingsProvider.directDownload) { + await _saveFileLocally(response); + } else { + final result = ScanResult.fromJson(response.data); + _showSnackBar( result.message, backgroundColor: result.success ? theme.colorScheme.primaryContainer @@ -161,32 +218,31 @@ class _HomePageState extends State { textColor: result.success ? theme.colorScheme.onPrimaryContainer : theme.colorScheme.onErrorContainer, - ), - ); + ); + } + + await HapticFeedback.vibrate(); } catch (error) { - messenger.showSnackBar( - _createSnackBar( - 'Scan failed, is the address correct?', - backgroundColor: theme.colorScheme.errorContainer, - textColor: theme.colorScheme.onErrorContainer, - action: SnackBarAction( - label: 'Details', - textColor: theme.colorScheme.onErrorContainer, - onPressed: () => showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - content: Text(error.toString()), - actions: [ - TextButton( - onPressed: () => - Navigator.pop(context), - child: const Text('OK'), - ), - ], - ); - })), - ), + _showSnackBar( + 'Scan failed, is the address correct?', + backgroundColor: theme.colorScheme.errorContainer, + textColor: theme.colorScheme.onErrorContainer, + action: SnackBarAction( + label: 'Details', + textColor: theme.colorScheme.onErrorContainer, + onPressed: () => showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + content: Text(error.toString()), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('OK'), + ), + ], + ); + })), ); } finally { setState(() { diff --git a/app/lib/pages/settings.dart b/app/lib/pages/settings.dart index 4deb097..71dce71 100644 --- a/app/lib/pages/settings.dart +++ b/app/lib/pages/settings.dart @@ -10,12 +10,15 @@ class SettingsPage extends StatefulWidget { } class _SettingsPageState extends State { - final _baseUrlTextController = TextEditingController(); + TextEditingController? _baseUrlTextController; @override Widget build(BuildContext context) { - final settingsProvider = context.read(); - _baseUrlTextController.text = settingsProvider.baseUrl; + final settingsProvider = context.watch(); + if (_baseUrlTextController == null) { + _baseUrlTextController = TextEditingController(); + _baseUrlTextController!.text = settingsProvider.baseUrl; + } return Scaffold( appBar: AppBar( @@ -37,6 +40,15 @@ class _SettingsPageState extends State { }, ), ), + CheckboxListTile( + title: const Text('Direct download'), + value: settingsProvider.directDownload, + onChanged: (value) { + if (value != null) { + settingsProvider.directDownload = value; + } + }, + ) ], ), ); diff --git a/app/lib/providers/settings_provider.dart b/app/lib/providers/settings_provider.dart index eb62ed0..e44bb52 100644 --- a/app/lib/providers/settings_provider.dart +++ b/app/lib/providers/settings_provider.dart @@ -21,4 +21,13 @@ class SettingsProvider extends ChangeNotifier { prefs?.setString('baseUrl', url); notifyListeners(); } + + bool get directDownload { + return prefs?.getBool('directDownload') ?? false; + } + + set directDownload(bool download) { + prefs?.setBool('directDownload', download); + notifyListeners(); + } } diff --git a/app/macos/Flutter/GeneratedPluginRegistrant.swift b/app/macos/Flutter/GeneratedPluginRegistrant.swift index 724bb2a..b8e2b22 100644 --- a/app/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,8 +5,10 @@ import FlutterMacOS import Foundation +import path_provider_foundation import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) } diff --git a/app/pubspec.lock b/app/pubspec.lock index e21e16b..8a359aa 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -152,6 +152,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + open_file: + dependency: "direct main" + description: + name: open_file + sha256: a5a32d44acb7c899987d0999e1e3cbb0a0f1adebbf41ac813ec6d2d8faa0af20 + url: "https://pub.dev" + source: hosted + version: "3.3.2" path: dependency: transitive description: @@ -160,6 +168,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.8.3" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: a1aa8aaa2542a6bc57e381f132af822420216c80d4781f7aa085ca3229208aaa + url: "https://pub.dev" + source: hosted + version: "2.1.1" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "477184d672607c0a3bf68fbbf601805f92ef79c82b64b4d6eb318cbca4c48668" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "19314d595120f82aca0ba62787d58dde2cc6b5df7d2f0daf72489e38d1b57f2d" + url: "https://pub.dev" + source: hosted + version: "2.3.1" path_provider_linux: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index bc38252..2845b91 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -38,6 +38,8 @@ dependencies: shared_preferences: ^2.2.2 provider: ^6.1.1 dio: ^5.4.0 + path_provider: ^2.1.1 + open_file: ^3.3.2 dev_dependencies: flutter_test: diff --git a/src/routes/api/scan/index.ts b/src/routes/api/scan/index.ts index 67227b6..b44994d 100644 --- a/src/routes/api/scan/index.ts +++ b/src/routes/api/scan/index.ts @@ -53,17 +53,35 @@ export async function POST({ request }: APIEvent): Promise { }); } - const { data, extension } = result; + const { data, extension, contentType } = result; const safeFileName = !!preferredFileName?.trim() ? sanitize(preferredFileName) : format(new Date(), 'yyyyMMdd_HHmmss'); const safeExtension = extension ?? 'unknown'; + const fileName = safeFileName.concat( + !safeFileName.includes('.') ? `.${safeExtension}` : '', + ); + + // No automatic upload desired - instead download the scanned document. + const accept = request.headers.get('accept'); + if ( + accept !== null && + contentType !== undefined && + accept.includes(contentType) + ) { + return new Response(data, { + headers: new Headers({ + 'Content-Type': contentType, + 'Content-Disposition': `attachment; filename="${fileName}"`, + 'Content-Length': data.byteLength.toString(10), + 'X-Photosmarter-Filename': fileName, + }), + }); + } + try { - const name = safeFileName.concat( - !safeFileName.includes('.') ? `.${safeExtension}` : '', - ); - await fileService.save(name, data); + await fileService.save(fileName, data); } catch (error) { return json({ success: false, diff --git a/src/server/services/photosmart-service.ts b/src/server/services/photosmart-service.ts index a8edffd..3161704 100644 --- a/src/server/services/photosmart-service.ts +++ b/src/server/services/photosmart-service.ts @@ -57,6 +57,7 @@ export type PhotosmartScanOptions = { export type PhotosmartStatus = 'Idle' | 'BusyWithScanJob'; export type PhotosmartScanResult = { + contentType?: string; extension?: string; data: ArrayBuffer; }; @@ -131,6 +132,7 @@ class PhotosmartService { const contentType = binaryResponse.headers['content-type']; return { + contentType, extension: typeof contentType === 'string' ? extension(contentType) || undefined