Skip to content

Commit

Permalink
add ability to download scanned files directly to the end device (#17)
Browse files Browse the repository at this point in the history
  • Loading branch information
qysp committed Aug 4, 2024
1 parent 862f14a commit 00465da
Show file tree
Hide file tree
Showing 15 changed files with 256 additions and 48 deletions.
7 changes: 6 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -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

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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)

Expand Down
132 changes: 94 additions & 38 deletions app/lib/pages/home.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -22,8 +27,10 @@ class HomePage extends StatefulWidget {
class _HomePageState extends State<HomePage> {
bool _isScanning = false;

Future<ScanResult> _scan(
String baseUrl, String fileName, OptionsProvider optionsProvider) async {
Future<Response<dynamic>> _scan(String fileName) async {
final optionsProvider = context.read<OptionsProvider>();
final settingsProvider = context.read<SettingsProvider>();

final formData = FormData.fromMap({
'type': optionsProvider.type.name.toUpperCase(),
'dimension': optionsProvider.dimension.name.capitalize(),
Expand All @@ -34,21 +41,31 @@ class _HomePageState extends State<HomePage> {
});

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(
Expand All @@ -61,7 +78,7 @@ class _HomePageState extends State<HomePage> {
),
duration: duration ?? const Duration(seconds: 5),
action: action,
);
));
}

Future<String?> _promptFileName() async {
Expand Down Expand Up @@ -103,10 +120,48 @@ class _HomePageState extends State<HomePage> {
return fileName?.text;
}

Future<void> _saveFileLocally(Response<dynamic> 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<SettingsProvider>();
final messenger = ScaffoldMessenger.of(context);
final theme = Theme.of(context);

return Scaffold(
Expand Down Expand Up @@ -137,7 +192,7 @@ class _HomePageState extends State<HomePage> {
child: FloatingActionButton.extended(
onPressed: !_isScanning
? () async {
final optionsProvider = context.read<OptionsProvider>();
await HapticFeedback.heavyImpact();

final fileName = await _promptFileName();
if (fileName == null) {
Expand All @@ -149,44 +204,45 @@ class _HomePageState extends State<HomePage> {
});

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
: theme.colorScheme.errorContainer,
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<TextEditingValue?>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
content: Text(error.toString()),
actions: <Widget>[
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<TextEditingValue?>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
content: Text(error.toString()),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('OK'),
),
],
);
})),
);
} finally {
setState(() {
Expand Down
18 changes: 15 additions & 3 deletions app/lib/pages/settings.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,15 @@ class SettingsPage extends StatefulWidget {
}

class _SettingsPageState extends State<SettingsPage> {
final _baseUrlTextController = TextEditingController();
TextEditingController? _baseUrlTextController;

@override
Widget build(BuildContext context) {
final settingsProvider = context.read<SettingsProvider>();
_baseUrlTextController.text = settingsProvider.baseUrl;
final settingsProvider = context.watch<SettingsProvider>();
if (_baseUrlTextController == null) {
_baseUrlTextController = TextEditingController();
_baseUrlTextController!.text = settingsProvider.baseUrl;
}

return Scaffold(
appBar: AppBar(
Expand All @@ -37,6 +40,15 @@ class _SettingsPageState extends State<SettingsPage> {
},
),
),
CheckboxListTile(
title: const Text('Direct download'),
value: settingsProvider.directDownload,
onChanged: (value) {
if (value != null) {
settingsProvider.directDownload = value;
}
},
)
],
),
);
Expand Down
9 changes: 9 additions & 0 deletions app/lib/providers/settings_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
2 changes: 2 additions & 0 deletions app/macos/Flutter/GeneratedPluginRegistrant.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
}
32 changes: 32 additions & 0 deletions app/pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions app/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
16 changes: 16 additions & 0 deletions src/client/common/util.ts
Original file line number Diff line number Diff line change
@@ -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);
};
8 changes: 8 additions & 0 deletions src/components/Scan/Scan.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading

0 comments on commit 00465da

Please sign in to comment.