diff --git a/.env.example b/.env.example index ff63aa8..a512c95 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,13 @@ PHOTOSMART_URL=http://127.0.0.1 -# Availeable: filesystem, webdav +# Available adapters: filesystem, webdav, mock +# Prefer using 'mock' when `VITE_ALLOW_DIRECT_DOWNLOAD` is set to 'only'. ADAPTER=filesystem +# Allowed values: yes, no, only +# When 'only' is supplied, scanned files will never be stored on the server. +VITE_ALLOW_DIRECT_DOWNLOAD=no + # Filesystem adapter FILESYSTEM_DIR=/path/to/dir diff --git a/README.md b/README.md index 2c238e5..0421b2d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Photosmarter -This project is a simplified and mobile-friendly web interface for the HP Photosmart scanner. It can be configured to save the scanned photo or document on your **filesystem** or a remote location via **WebDAV** (e.g. Nextcloud). +This project is a simplified and mobile-friendly web interface for the HP Photosmart scanner. It can be configured to save the scanned photo or document on your **filesystem** or a remote location via **WebDAV** (e.g. Nextcloud). Alternatively, it can be configured to only allow downloading the scanned files directly to the end device. ![Screenshot of Desktop UI](pictures/screenshot_ui_desktop.png) 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/client/common/util.ts b/src/client/common/util.ts new file mode 100644 index 0000000..1ddcc79 --- /dev/null +++ b/src/client/common/util.ts @@ -0,0 +1,16 @@ +export const saveFile = async (blob: Blob, fileName: string) => { + const blobUrl = URL.createObjectURL(blob); + + const anchor = document.createElement('a'); + anchor.href = blobUrl; + anchor.download = fileName; + anchor.style.display = 'none'; + document.body.append(anchor); + + anchor.click(); + + setTimeout(() => { + URL.revokeObjectURL(blobUrl); + anchor.remove(); + }, 1_000); +}; diff --git a/src/components/Scan/Scan.css b/src/components/Scan/Scan.css index bee65e7..4d8c841 100644 --- a/src/components/Scan/Scan.css +++ b/src/components/Scan/Scan.css @@ -71,3 +71,11 @@ button.options__scan > span { button.options__scan > span:hover { --inner-bg: rgb(30, 30, 30); } + +div.options__download { + display: flex; + flex-direction: row-reverse; + justify-content: flex-end; + align-items: baseline; + gap: 0.5ch; +} diff --git a/src/components/Scan/Scan.tsx b/src/components/Scan/Scan.tsx index 417a640..2da2db5 100644 --- a/src/components/Scan/Scan.tsx +++ b/src/components/Scan/Scan.tsx @@ -1,9 +1,15 @@ import { createEffect, createSignal, untrack } from 'solid-js'; import { createRouteAction } from 'solid-start'; import toast from 'solid-toast'; +import { saveFile } from '~/client/common/util'; import '~/components/Scan/Scan.css'; export default () => { + const allowDirectDownloadEnv = + import.meta.env.VITE_ALLOW_DIRECT_DOWNLOAD?.toLowerCase(); + const isOnlyDirectDownloadAllowed = allowDirectDownloadEnv === 'only'; + const isDirectDownloadAllowed = allowDirectDownloadEnv === 'yes'; + const [loading, setLoading] = createSignal(); const [quality, setQuality] = createSignal(80); @@ -24,6 +30,12 @@ export default () => { body.set('fileName', fileName); return fetch('/api/scan', { method: 'POST', + headers: { + accept: + isOnlyDirectDownloadAllowed || body.get('download') === 'on' + ? 'application/pdf, image/jpeg' + : 'application/json', + }, body, }); }); @@ -45,6 +57,12 @@ export default () => { return; } + const fileName = scan.result.headers.get('x-photosmarter-filename'); + if (fileName !== null) { + void scan.result.blob().then((blob) => saveFile(blob, fileName)); + return; + } + void scan.result.json().then(({ success, message }) => { if (success) { toast.success(message); @@ -142,6 +160,21 @@ export default () => { required={true} disabled={scan.pending} /> + + {isDirectDownloadAllowed && ( +
+ + +
+ )}