diff --git a/client/lib/integrations/download_manager.dart b/client/lib/integrations/download_manager.dart index 4401c5d3..53e79898 100644 --- a/client/lib/integrations/download_manager.dart +++ b/client/lib/integrations/download_manager.dart @@ -40,20 +40,20 @@ class DownloadManager extends StateNotifier { state = DownloadManagerState([...state.tasks, task]); try { task.status = const DownloadStatusStarting(); - final response = await account.apiClient.dispatchRequestRaw(ResourceContentsRequest(r.id)); + final response = await account.apiClient.dispatchRequestRaw(ResourceContentsRequest(task.resourceId)); if (response.statusCode < 200 || response.statusCode > 300) { final error = PhylumApiErrorResponse.fromResponseString(await response.bodyString()); task.status = DownloadStatusError(error.message); return; } - final output = _createDownloadFile(PhylumDirectories.instance.downloads!, r.name); + final output = _createDownloadFile(PhylumDirectories.instance.downloads!, task.resourceName); if (output == null) { task.status = const DownloadStatusError('Unable to open output file'); return; } final sink = output.openWrite(); - final length = response.contentLength ?? r.contentLength; + final length = response.contentLength ?? task.expectedSize; int received = 0; final stream = response.stream.map((s) { received += s.length; diff --git a/client/lib/ui/explorer/explorer_actions.dart b/client/lib/ui/explorer/explorer_actions.dart index d72325d3..c5bb555b 100644 --- a/client/lib/ui/explorer/explorer_actions.dart +++ b/client/lib/ui/explorer/explorer_actions.dart @@ -23,27 +23,30 @@ class ExplorerActions extends StatelessWidget { Widget build(BuildContext context) { return Actions( actions: { - NextFocusIntent: CallbackAction(onInvoke: (i) => null), - PreviousFocusIntent: CallbackAction(onInvoke: (i) => null), - PasteFromClipboardIntent: CallbackAction(onInvoke: (PasteFromClipboardIntent i) => handlePasteAction(i, context)), - SelectAllIntent: CallbackAction(onInvoke: (i) { - context.read().updateSelection((i) => i, SelectionMode.all, true); - return null; - }), - DismissIntent: CallbackAction(onInvoke: (i) { - context.read().updateSelection((i) => i, SelectionMode.none, true); - return null; - }), - ToggleSelectionIntent: CallbackAction(onInvoke: (i) { - context.read().updateSelection((i) => i, SelectionMode.toggle, true); - return null; - }), + NextFocusIntent: CallbackAction( + onInvoke: (i) => null, + ), + PreviousFocusIntent: CallbackAction( + onInvoke: (i) => null, + ), + PasteFromClipboardIntent: CallbackAction( + onInvoke: (PasteFromClipboardIntent i) => handlePasteAction(i, context), + ), + SelectAllIntent: CallbackAction( + onInvoke: (i) => context.read().updateSelection((i) => i, SelectionMode.all, true), + ), + DismissIntent: CallbackAction( + onInvoke: (i) => context.read().updateSelection((i) => i, SelectionMode.none, true), + ), + ToggleSelectionIntent: CallbackAction( + onInvoke: (i) => context.read().updateSelection((i) => i, SelectionMode.toggle, true), + ), ActivateIntent: CallbackAction(onInvoke: (i) { final state = context.read(); final r = state.selectedSingle ?? state.focussed; if (r == null) return; _openResource(context, r); - return null; + return; }), DeleteIntent: CallbackAction(onInvoke: (i) { final selected = context.read().selected; @@ -82,7 +85,8 @@ class ExplorerActions extends StatelessWidget { final uriData = Formats.uri(NamedUri(uri.build(), name: r.name)); return DataWriterItem(suggestedName: r.name)..add(uriData); }); - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('${items.length} items ${i.cut ? 'cut' : 'copied'} to clipboard'))); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('${items.length} items ${i.cut ? 'cut' : 'copied'} to clipboard'))); await SystemClipboard.instance?.write(items); return null; }), @@ -95,14 +99,20 @@ class ExplorerActions extends StatelessWidget { if (r.dir) { Actions.maybeInvoke(context, NavToFolderIntent(folderId: r.id)); } else { - ResourcePreview.showPreview(context, r.id); + ResourcePreview.showPreview(context, r); } } void _deleteResources(BuildContext context, Iterable resources) async { final account = context.read(); - final confirm = - await showAlertDialog(context, title: 'Delete selected items?', barrierDismissible: true, positiveText: 'YES', negativeText: 'NO') ?? false; + final confirm = await showAlertDialog( + context, + title: 'Delete selected items?', + barrierDismissible: true, + positiveText: 'YES', + negativeText: 'NO', + ) ?? + false; if (!confirm) return; for (final r in resources) { diff --git a/client/lib/ui/menu/menu_option.dart b/client/lib/ui/menu/menu_option.dart index d0b238c4..1d01712f 100644 --- a/client/lib/ui/menu/menu_option.dart +++ b/client/lib/ui/menu/menu_option.dart @@ -107,7 +107,8 @@ void handleOption(BuildContext context, Iterable resources, MenuOption break; case MenuOption.delete: final name = resources.length == 1 ? resources.first.name : '${resources.length} items'; - final confirm = await showAlertDialog(context, title: 'Delete $name?', positiveText: 'YES', negativeText: 'NO') ?? false; + final confirm = + await showAlertDialog(context, title: 'Delete $name?', positiveText: 'YES', negativeText: 'NO') ?? false; if (confirm) { for (final r in resources) { account.addAction(ResourceDeleteAction(r: r)); @@ -170,7 +171,8 @@ bool all(PhylumAccount accout, Iterable resources) => true; bool isFilesOnly(PhylumAccount account, Iterable resources) => resources.every((r) => !r.dir); -Future notAllBookmarked(PhylumAccount account, Iterable resources) => allBookmarked(account, resources).then((b) => !b); +Future notAllBookmarked(PhylumAccount account, Iterable resources) => + allBookmarked(account, resources).then((b) => !b); Future allBookmarked(PhylumAccount account, Iterable resources) => account.myListsRepository.countBookmarks(resources.map((r) => r.id)).getSingle().then((c) => c == resources.length); diff --git a/client/lib/ui/preview/resource_preview.dart b/client/lib/ui/preview/resource_preview.dart index ed5f4042..f908bfdd 100644 --- a/client/lib/ui/preview/resource_preview.dart +++ b/client/lib/ui/preview/resource_preview.dart @@ -1,20 +1,29 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + import 'package:flutter/material.dart'; +import 'package:http/http.dart'; +import 'package:offtheline/offtheline.dart'; import 'package:phylum/integrations/download_manager.dart'; import 'package:phylum/libphylum/db/db.dart'; -import 'package:phylum/libphylum/db/resource_helpers.dart'; import 'package:phylum/libphylum/phylum_account.dart'; +import 'package:phylum/libphylum/phylum_api_types.dart'; +import 'package:phylum/libphylum/requests/resource_contents_request.dart'; +import 'package:printing/printing.dart'; import 'package:provider/provider.dart'; -const maxPreviewSize = 5 * 1024 * 1024; +const maxPreviewSize = 1 * 1024 * 1024; class ResourcePreview extends StatefulWidget { - final String resourceId; + final Resource resource; - ResourcePreview({required this.resourceId}) : super(key: ValueKey(resourceId)); + ResourcePreview({required this.resource}) : super(key: ValueKey(resource.id)); - static Future showPreview(BuildContext context, String resourceId) async { - context.read().myListsRepository.markResourceAccessed(resourceId); - return showDialog(context: context, builder: (context) => ResourcePreview(resourceId: resourceId)); + static Future showPreview(BuildContext context, Resource r) async { + context.read().myListsRepository.markResourceAccessed(r.id); + return showDialog(context: context, builder: (context) => ResourcePreview(resource: r)); } @override @@ -22,74 +31,178 @@ class ResourcePreview extends StatefulWidget { } class _ResourcePreviewState extends State { + String? _error; + Widget Function(Uint8List)? buildPreview; bool loading = true; - Resource? resource; @override void initState() { super.initState(); - context.read().db.getResource(widget.resourceId).then((r) => setState(() { - loading = false; - resource = r; - })); + if (widget.resource.dir) { + _error = 'Cannot preview directory'; + } else if (widget.resource.contentLength > maxPreviewSize) { + _error = 'Resource too large to preview'; + } else { + if (widget.resource.contentType.startsWith('image/')) { + buildPreview = buildImagePreview; + } + if (widget.resource.contentType == 'application/pdf') { + buildPreview = buildPdfPreview; + } + if (buildPreview == null) { + _error = 'Unable to preview ${widget.resource.contentType}'; + } + } } @override Widget build(BuildContext context) { - final r = resource; return Theme( data: ThemeData.dark(), child: Scaffold( backgroundColor: Colors.black45, appBar: AppBar( - leading: CloseButton( - onPressed: () => Navigator.of(context).pop(), - ), - actions: [IconButton(onPressed: (r == null || r.dir) ? null : () => downloadResource(r), icon: const Icon(Icons.download))], - title: (r != null) ? Text(r.name) : null, + leading: const CloseButton(), + actions: [ + IconButton( + onPressed: (widget.resource.dir) ? null : () => downloadResource(), icon: const Icon(Icons.download)) + ], + title: Text(widget.resource.name), backgroundColor: Colors.transparent, ), body: Center( - child: loading - ? const CircularProgressIndicator() - : r == null - ? const Text('Error loading details', style: TextStyle(fontSize: 18)) - : buildPreview(r)), + child: buildPreview == null + ? Text(_error ?? 'Unknown error', style: Theme.of(context).textTheme.bodyLarge) + : ResourcePreviewBuilder( + resource: widget.resource, + buildPreview: buildPreview!, + )), ), ); } - Widget buildPreview(Resource r) { - if (r.dir) { - return const Text( - 'Cannot preview directory', - style: TextStyle(fontSize: 18), - ); - } - if (r.contentLength > maxPreviewSize) { - return const Text( - 'File too large to preview', - style: TextStyle(fontSize: 18), - ); - } - return switch (r.contentType) { - 'image/jpeg' || 'image/webp' || 'image/png' || 'image/gif' => buildImagePreview(r), - _ => const Text('Unable to display preview', style: TextStyle(fontSize: 18)), - }; - } + Widget buildImagePreview(Uint8List data) => Image.memory(data); - Widget buildImagePreview(Resource r) { - final api = context.read().apiClient; - return SizedBox( - width: 800, - child: Image.network( - context.read().apiClient.createUri('/api/v1/fs/cat/${r.id}').toString(), - headers: api.requestHeaders, - ), - ); - } + Widget buildPdfPreview(Uint8List data) => PdfPreview(build: (_) => data); - void downloadResource(Resource r) async { - context.read().downloadResource(r); + Future downloadResource() { + return context.read().downloadResource(widget.resource); + } +} + +class ResourcePreviewBuilder extends StatefulWidget { + final Resource resource; + final Widget Function(Uint8List data) buildPreview; + + const ResourcePreviewBuilder({super.key, required this.resource, required this.buildPreview}); + + @override + State createState() => _ResourcePreviewBuilderState(); +} + +class _ResourcePreviewBuilderState extends State { + bool _downloading = false; + String? _error; + double? _progress; + Uint8List? _data; + + @override + void initState() { + super.initState(); + _runDownloadTask(); + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_data != null) { + return SizedBox( + width: 800, + child: widget.buildPreview(_data!), + ); + } + if (!_downloading) { + return Column( + children: [ + if (_error != null) Text(_error!), + ElevatedButton(onPressed: _runDownloadTask, child: const Text('Retry')) + ], + ); + } + return CircularProgressIndicator.adaptive( + value: _progress, + ); + } + + Future _runDownloadTask() async { + try { + setState(() { + _downloading = true; + _error = null; + _progress = null; + }); + final account = context.read(); + final response = await account.apiClient.dispatchRequestRaw(ResourceContentsRequest(widget.resource.id)); + if (response.statusCode < 200 || response.statusCode > 300) { + final error = PhylumApiErrorResponse.fromResponseString(await response.bodyString()).message; + _error = error; + _downloading = false; + if (mounted) { + setState(() {}); + } + return; + } + + final expected = (response.contentLength ?? widget.resource.contentLength).toDouble(); + int received = 0; + _progress = 0; + if (mounted) { + setState(() {}); + } + final stream = _toByteStream(response.stream.map((s) { + received += s.length; + _progress = received / expected; + if (mounted) { + setState(() {}); + } + return s; + })); + final data = await _toBytes(stream); + _data = data; + _downloading = false; + if (mounted) { + setState(() {}); + } + } on SocketException { + _error = 'Unable to reach server'; + _downloading = false; + if (mounted) { + setState(() {}); + } + } catch (e) { + _error = e.toString(); + _downloading = false; + if (mounted) { + setState(() {}); + } + } + } + + static ByteStream _toByteStream(Stream> stream) { + if (stream is ByteStream) return stream; + return ByteStream(stream); + } + + static Future _toBytes(ByteStream stream) { + var completer = Completer(); + var sink = ByteConversionSink.withCallback( + (bytes) => completer.complete(Uint8List.fromList(bytes)), + ); + stream.listen(sink.add, onError: completer.completeError, onDone: sink.close, cancelOnError: true); + return completer.future; } } diff --git a/client/linux/flutter/generated_plugin_registrant.cc b/client/linux/flutter/generated_plugin_registrant.cc index 6fb9555a..82c61301 100644 --- a/client/linux/flutter/generated_plugin_registrant.cc +++ b/client/linux/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include #include @@ -22,6 +23,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) open_file_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "OpenFileLinuxPlugin"); open_file_linux_plugin_register_with_registrar(open_file_linux_registrar); + g_autoptr(FlPluginRegistrar) printing_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "PrintingPlugin"); + printing_plugin_register_with_registrar(printing_registrar); g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin"); sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar); diff --git a/client/linux/flutter/generated_plugins.cmake b/client/linux/flutter/generated_plugins.cmake index 922a827d..80d046c0 100644 --- a/client/linux/flutter/generated_plugins.cmake +++ b/client/linux/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_linux irondash_engine_context open_file_linux + printing sqlite3_flutter_libs super_native_extensions ) diff --git a/client/macos/Flutter/GeneratedPluginRegistrant.swift b/client/macos/Flutter/GeneratedPluginRegistrant.swift index b23623c3..b49db53e 100644 --- a/client/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/client/macos/Flutter/GeneratedPluginRegistrant.swift @@ -10,6 +10,7 @@ import file_selector_macos import irondash_engine_context import open_file_mac import path_provider_foundation +import printing import sqflite_darwin import sqlite3_flutter_libs import super_native_extensions @@ -20,6 +21,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { IrondashEngineContextPlugin.register(with: registry.registrar(forPlugin: "IrondashEngineContextPlugin")) OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin")) SuperNativeExtensionsPlugin.register(with: registry.registrar(forPlugin: "SuperNativeExtensionsPlugin")) diff --git a/client/pubspec.lock b/client/pubspec.lock index 681042a5..0f5f63e3 100644 --- a/client/pubspec.lock +++ b/client/pubspec.lock @@ -30,6 +30,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.11.3" + archive: + dependency: transitive + description: + name: archive + sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d + url: "https://pub.dev" + source: hosted + version: "3.6.1" args: dependency: transitive description: @@ -46,6 +54,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.11.0" + barcode: + dependency: transitive + description: + name: barcode + sha256: ab180ce22c6555d77d45f0178a523669db67f95856e3378259ef2ffeb43e6003 + url: "https://pub.dev" + source: hosted + version: "2.2.8" + bidi: + dependency: transitive + description: + name: bidi + sha256: "9a712c7ddf708f7c41b1923aa83648a3ed44cfd75b04f72d598c45e5be287f9d" + url: "https://pub.dev" + source: hosted + version: "2.0.12" boolean_selector: dependency: transitive description: @@ -442,6 +466,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.1" + image: + dependency: transitive + description: + name: image + sha256: f31d52537dc417fdcde36088fdf11d191026fd5e4fae742491ebd40e5a8bea7d + url: "https://pub.dev" + source: hosted + version: "4.3.0" intl: dependency: "direct main" description: @@ -675,6 +707,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.0" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" path_provider: dependency: "direct main" description: @@ -723,6 +763,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + pdf: + dependency: transitive + description: + name: pdf + sha256: "05df53f8791587402493ac97b9869d3824eccbc77d97855f4545cf72df3cae07" + url: "https://pub.dev" + source: hosted + version: "3.11.1" + pdf_widget_wrapper: + dependency: transitive + description: + name: pdf_widget_wrapper + sha256: c930860d987213a3d58c7ec3b7ecf8085c3897f773e8dc23da9cae60a5d6d0f5 + url: "https://pub.dev" + source: hosted + version: "1.0.4" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 + url: "https://pub.dev" + source: hosted + version: "6.0.2" pixel_snap: dependency: transitive description: @@ -755,6 +819,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.1" + printing: + dependency: "direct main" + description: + name: printing + sha256: b535d177fc6e8f8908e19b0ff5c1d4a87e3c4d0bf675e05aa2562af1b7853906 + url: "https://pub.dev" + source: hosted + version: "5.13.4" provider: dependency: "direct main" description: @@ -779,6 +851,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + qr: + dependency: transitive + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" quiver: dependency: transitive description: @@ -1096,6 +1176,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + url: "https://pub.dev" + source: hosted + version: "6.5.0" yaml: dependency: transitive description: diff --git a/client/pubspec.yaml b/client/pubspec.yaml index 3189169b..f527698d 100644 --- a/client/pubspec.yaml +++ b/client/pubspec.yaml @@ -28,6 +28,7 @@ dependencies: open_file: path: path_provider: + printing: provider: sqlite3: state_notifier: diff --git a/client/windows/flutter/generated_plugin_registrant.cc b/client/windows/flutter/generated_plugin_registrant.cc index e491861a..c52cd147 100644 --- a/client/windows/flutter/generated_plugin_registrant.cc +++ b/client/windows/flutter/generated_plugin_registrant.cc @@ -8,6 +8,7 @@ #include #include +#include #include #include @@ -16,6 +17,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FileSelectorWindows")); IrondashEngineContextPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("IrondashEngineContextPluginCApi")); + PrintingPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PrintingPlugin")); Sqlite3FlutterLibsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin")); SuperNativeExtensionsPluginCApiRegisterWithRegistrar( diff --git a/client/windows/flutter/generated_plugins.cmake b/client/windows/flutter/generated_plugins.cmake index 23f691d4..7e257a24 100644 --- a/client/windows/flutter/generated_plugins.cmake +++ b/client/windows/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_windows irondash_engine_context + printing sqlite3_flutter_libs super_native_extensions )