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 3, 2024
1 parent 862f14a commit a2d0f72
Show file tree
Hide file tree
Showing 8 changed files with 179 additions and 46 deletions.
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
28 changes: 23 additions & 5 deletions src/routes/api/scan/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,17 +53,35 @@ export async function POST({ request }: APIEvent): Promise<Response> {
});
}

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,
Expand Down
2 changes: 2 additions & 0 deletions src/server/services/photosmart-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export type PhotosmartScanOptions = {
export type PhotosmartStatus = 'Idle' | 'BusyWithScanJob';

export type PhotosmartScanResult = {
contentType?: string;
extension?: string;
data: ArrayBuffer;
};
Expand Down Expand Up @@ -131,6 +132,7 @@ class PhotosmartService {
const contentType = binaryResponse.headers['content-type'];

return {
contentType,
extension:
typeof contentType === 'string'
? extension(contentType) || undefined
Expand Down

0 comments on commit a2d0f72

Please sign in to comment.