mirror of
https://codeberg.org/shroff/phylum.git
synced 2026-01-06 03:31:02 -06:00
[client] File preview page
This commit is contained in:
@@ -1,7 +1,5 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:phylum/util/base_uri.dart';
|
||||
import 'package:phylum/util/logging.dart';
|
||||
|
||||
import 'routes.dart';
|
||||
|
||||
@@ -106,19 +104,9 @@ class PhylumRouteInformationParser extends RouteInformationParser<PhylumRoute> {
|
||||
return SynchronousFuture(const ExplorerRouteTrash());
|
||||
}
|
||||
if (segments[0] == 'reset_password') {
|
||||
Uri? instanceUrl = Uri.tryParse(uri.queryParameters['instance'] ?? '');
|
||||
|
||||
if (instanceUrl == null) {
|
||||
logger.w('Unable to parse instance URL "${uri.queryParameters['instance']}"');
|
||||
} else if (kIsWeb) {
|
||||
if (kDebugMode && instanceUrl != webAppBaseUri) {
|
||||
logger.w('Instance URL "${uri.queryParameters['instance']}" does not match Uri.base $webAppBaseUri');
|
||||
}
|
||||
instanceUrl = Uri();
|
||||
}
|
||||
|
||||
return SynchronousFuture(ResetPasswordRoute(
|
||||
instanceUri: instanceUrl ?? Uri(),
|
||||
// TODO: Don't fallback to empty instance uri
|
||||
instanceUri: Uri.tryParse(uri.queryParameters['instance'] ?? '') ?? Uri(),
|
||||
email: uri.queryParameters['email'] ?? '',
|
||||
token: uri.queryParameters['token'] ?? '',
|
||||
));
|
||||
@@ -133,6 +121,7 @@ class PhylumRouteInformationParser extends RouteInformationParser<PhylumRoute> {
|
||||
} else if (segments.length == 2) {
|
||||
if (segments[0] == 'login' && segments[1] == 'token') {
|
||||
return SynchronousFuture(TokenLoginRoute(
|
||||
// TODO: Rename to instance
|
||||
instanceUrl: uri.queryParameters['instance_url'] ?? '',
|
||||
loginToken: uri.queryParameters['login_token'] ?? '',
|
||||
));
|
||||
@@ -140,6 +129,9 @@ class PhylumRouteInformationParser extends RouteInformationParser<PhylumRoute> {
|
||||
if (segments[0] == 'folder') {
|
||||
return SynchronousFuture(ExplorerRouteFolder(folderId: segments[1]));
|
||||
}
|
||||
if (segments[0] == 'file') {
|
||||
return SynchronousFuture(FilePreviewRoute(fileId: segments[1]));
|
||||
}
|
||||
}
|
||||
return SynchronousFuture(ExplorerRouteFolder(folderId: 'unknown'));
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import 'package:phylum/ui/login/login_page.dart';
|
||||
import 'package:phylum/ui/login/token_login_page.dart';
|
||||
import 'package:phylum/ui/login/reset_password_page.dart';
|
||||
import 'package:phylum/ui/open/open_resource_page.dart';
|
||||
import 'package:phylum/ui/file_preview/file_preview_page.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
sealed class PhylumRoute {
|
||||
@@ -83,7 +84,7 @@ class OpenResourceRoute extends LoggedInRoute {
|
||||
final String resourceId;
|
||||
|
||||
@override
|
||||
Uri get uri => Uri(path: '/open/$resourceId');
|
||||
Uri get uri => Uri(path: '/open', queryParameters: {'id': resourceId});
|
||||
|
||||
OpenResourceRoute({required this.resourceId}) : super();
|
||||
|
||||
@@ -93,6 +94,20 @@ class OpenResourceRoute extends LoggedInRoute {
|
||||
}
|
||||
}
|
||||
|
||||
class FilePreviewRoute extends LoggedInRoute {
|
||||
final String fileId;
|
||||
|
||||
@override
|
||||
Uri get uri => Uri(path: '/file/$fileId');
|
||||
|
||||
const FilePreviewRoute({required this.fileId}) : super();
|
||||
|
||||
@override
|
||||
Widget buildAccountPage(PhylumAccount account) {
|
||||
return FilePreviewPage.create(account: account, resourceId: fileId);
|
||||
}
|
||||
}
|
||||
|
||||
sealed class ExplorerRoute extends LoggedInRoute {
|
||||
String get title;
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import 'package:phylum/libphylum/phylum_account.dart';
|
||||
import 'package:phylum/ui/app/routes.dart';
|
||||
import 'package:phylum/ui/explorer/paste_helpers.dart';
|
||||
import 'package:phylum/ui/explorer/selection_mode.dart';
|
||||
import 'package:phylum/ui/preview/resource_preview.dart';
|
||||
import 'package:phylum/ui/file_preview/file_preview_dialog.dart';
|
||||
import 'package:phylum/util/dialogs.dart';
|
||||
import 'package:phylum/util/upload_utils.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
@@ -68,7 +68,7 @@ class ExplorerActions extends StatelessWidget {
|
||||
final resources = state.resources.where((r) => !r.dir).toList(growable: false);
|
||||
final index = resources.indexOf(r);
|
||||
if (index >= 0) {
|
||||
ResourcePreview.showResources(context, resources, index);
|
||||
FilePreviewDialog.showResources(context, resources, index);
|
||||
}
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -5,7 +5,7 @@ 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/ui/preview/resource_preview.dart';
|
||||
import 'package:phylum/ui/file_preview/file_preview_dialog.dart';
|
||||
import 'package:phylum/util/file_size.dart';
|
||||
import 'package:phylum/util/time.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
@@ -47,7 +47,7 @@ class ResourceVersionsView extends StatelessWidget {
|
||||
onPressed: () async {
|
||||
final resource = await account.db.getResource(resourceId);
|
||||
if (resource == null || !context.mounted) return;
|
||||
ResourcePreview.showVersions(context, resource, versions, i);
|
||||
FilePreviewDialog.showVersions(context, resource, versions, i);
|
||||
}),
|
||||
IconButton(
|
||||
icon: Icon(Icons.download),
|
||||
|
||||
260
client/lib/ui/file_preview/file_preview.dart
Normal file
260
client/lib/ui/file_preview/file_preview.dart
Normal file
@@ -0,0 +1,260 @@
|
||||
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/resource_helpers.dart';
|
||||
import 'package:phylum/libphylum/phylum_account.dart';
|
||||
import 'package:phylum/libphylum/requests/resource_contents_request.dart';
|
||||
import 'package:phylum/libphylum/responses/responses.dart';
|
||||
import 'package:phylum/ui/common/responsive_dialog.dart';
|
||||
import 'package:phylum/ui/explorer/resource_info_view.dart';
|
||||
import 'package:phylum/ui/file_preview/versioned_resource.dart';
|
||||
import 'package:printing/printing.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
const maxPreviewSize = 1 * 1024 * 1024;
|
||||
|
||||
class FilePreview extends StatefulWidget {
|
||||
final bool transparent;
|
||||
final VersionedResource resource;
|
||||
const FilePreview({super.key, required this.transparent, required this.resource});
|
||||
|
||||
@override
|
||||
State<FilePreview> createState() => _FilePreviewState();
|
||||
}
|
||||
|
||||
class _FilePreviewState extends State<FilePreview> {
|
||||
late VersionedResource _resource;
|
||||
String? _error;
|
||||
Widget Function(Uint8List)? _buildPreview;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_updateResource(widget.resource);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant FilePreview oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
setState(() {
|
||||
_updateResource(widget.resource);
|
||||
});
|
||||
}
|
||||
|
||||
void _updateResource(VersionedResource resource) {
|
||||
_resource = resource;
|
||||
if (_resource.r.dir) {
|
||||
_buildPreview = null;
|
||||
_error = 'Cannot preview directory';
|
||||
} else {
|
||||
_buildPreview = null;
|
||||
if (_resource.v.mimeType.startsWith('image/')) {
|
||||
_buildPreview = buildImagePreview;
|
||||
}
|
||||
if (_resource.v.mimeType.startsWith('text/')) {
|
||||
_buildPreview = buildTextPreview;
|
||||
}
|
||||
if (_resource.v.mimeType == 'application/pdf') {
|
||||
_buildPreview = buildPdfPreview;
|
||||
}
|
||||
if (_buildPreview == null) {
|
||||
_error = 'Cannot generate preview';
|
||||
} else if (_resource.v.size > maxPreviewSize) {
|
||||
_buildPreview = null;
|
||||
_error = 'File too large to preview';
|
||||
} else {
|
||||
context.read<PhylumAccount>().db.markResourceAccess(_resource.r.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Scaffold(
|
||||
backgroundColor: widget.transparent ? Colors.black45 : Colors.blueGrey[900],
|
||||
appBar: AppBar(
|
||||
foregroundColor: theme.colorScheme.onInverseSurface,
|
||||
leading: const CloseButton(),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.info_outline),
|
||||
onPressed: () => showReponsiveDialog(
|
||||
context,
|
||||
_resource.r.name,
|
||||
(context) => ResourceInfoView(resource: _resource.r),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: (_resource.r.dir) ? null : () => downloadResource(context, _resource.r, version: _resource.v),
|
||||
icon: const Icon(Icons.download),
|
||||
)
|
||||
],
|
||||
title: Text(_resource.name),
|
||||
backgroundColor: Colors.transparent,
|
||||
),
|
||||
body: Center(
|
||||
child: _buildPreview == null
|
||||
? Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text(_error ?? 'Unknown error', style: TextStyle(fontSize: 18)),
|
||||
))
|
||||
: ResourcePreviewDownloader(
|
||||
resourceId: _resource.r.id,
|
||||
versionId: _resource.v.id,
|
||||
buildPreview: _buildPreview!,
|
||||
)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ResourcePreviewDownloader extends StatefulWidget {
|
||||
final String resourceId;
|
||||
final String versionId;
|
||||
final Widget Function(Uint8List data) buildPreview;
|
||||
|
||||
ResourcePreviewDownloader({required this.resourceId, required this.buildPreview, required this.versionId})
|
||||
: super(key: ValueKey(versionId));
|
||||
|
||||
@override
|
||||
State<ResourcePreviewDownloader> createState() => _ResourcePreviewDownloaderState();
|
||||
}
|
||||
|
||||
class _ResourcePreviewDownloaderState extends State<ResourcePreviewDownloader> {
|
||||
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<void> _runDownloadTask() async {
|
||||
try {
|
||||
setState(() {
|
||||
_downloading = true;
|
||||
_error = null;
|
||||
_progress = null;
|
||||
});
|
||||
final account = context.read<PhylumAccount>();
|
||||
final response = await account.apiClient.dispatchRequestRaw(ResourceContentsRequest(
|
||||
widget.resourceId,
|
||||
versionId: widget.versionId,
|
||||
));
|
||||
if (response.statusCode < 200 || response.statusCode > 300) {
|
||||
final error = PhylumApiErrorResponse.fromResponseBody(await response.bodyString()).message;
|
||||
_error = error;
|
||||
_downloading = false;
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final expected = response.contentLength?.toDouble();
|
||||
ByteStream stream = response.stream;
|
||||
if (expected != null) {
|
||||
int received = 0;
|
||||
_progress = 0;
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
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<List<int>> stream) {
|
||||
if (stream is ByteStream) return stream;
|
||||
return ByteStream(stream);
|
||||
}
|
||||
|
||||
static Future<Uint8List> _toBytes(ByteStream stream) {
|
||||
var completer = Completer<Uint8List>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Widget buildImagePreview(Uint8List data) => Image.memory(data);
|
||||
|
||||
Widget buildTextPreview(Uint8List data) => Material(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: TextFormField(
|
||||
decoration: const InputDecoration(border: InputBorder.none),
|
||||
initialValue: String.fromCharCodes(data),
|
||||
readOnly: true,
|
||||
maxLines: 30,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget buildPdfPreview(Uint8List data) => PdfPreview(
|
||||
build: (_) => data,
|
||||
previewPageMargin: EdgeInsets.zero,
|
||||
);
|
||||
97
client/lib/ui/file_preview/file_preview_dialog.dart
Normal file
97
client/lib/ui/file_preview/file_preview_dialog.dart
Normal file
@@ -0,0 +1,97 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:phylum/libphylum/db/db.dart';
|
||||
import 'package:phylum/libphylum/phylum_account.dart';
|
||||
import 'package:phylum/ui/app/shortcuts.dart';
|
||||
import 'package:phylum/ui/file_preview/file_preview.dart';
|
||||
import 'package:phylum/ui/file_preview/versioned_resource.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class FilePreviewDialog extends StatefulWidget {
|
||||
final List<VersionedResource> resources;
|
||||
final int index;
|
||||
|
||||
const FilePreviewDialog({super.key, required this.resources, required this.index});
|
||||
|
||||
static Future<void> showResources(BuildContext context, List<Resource> resources, int index) async {
|
||||
final account = context.read<PhylumAccount>();
|
||||
final r = await Future.wait(resources.map((r) async =>
|
||||
VersionedResource(r: r, v: await account.db.latestVersion(r.id).getSingle(), showTimestamp: true)));
|
||||
if (!context.mounted) return;
|
||||
_show(context, r, index);
|
||||
}
|
||||
|
||||
static Future<void> showVersions(
|
||||
BuildContext context, Resource resource, Iterable<ResourceVersion> versions, int index) async {
|
||||
final r = versions.map((v) => VersionedResource(r: resource, v: v, showTimestamp: false)).toList(growable: false);
|
||||
_show(context, r, index);
|
||||
}
|
||||
|
||||
static Future<void> _show(BuildContext context, List<VersionedResource> resources, int index) async {
|
||||
final account = context.read<PhylumAccount>();
|
||||
return showDialog(
|
||||
context: context,
|
||||
builder: (context) => MultiProvider(
|
||||
providers: [
|
||||
Provider.value(value: account),
|
||||
Provider.value(value: account.actionQueue),
|
||||
],
|
||||
builder: (context, child) {
|
||||
return FilePreviewDialog(resources: resources, index: index);
|
||||
}));
|
||||
}
|
||||
|
||||
@override
|
||||
State<FilePreviewDialog> createState() => _FilePreviewDialogState();
|
||||
}
|
||||
|
||||
class _FilePreviewDialogState extends State<FilePreviewDialog> {
|
||||
late int index;
|
||||
final _focusNode = FocusNode();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
index = widget.index;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Actions(
|
||||
actions: {
|
||||
DismissIntent: CallbackAction<DismissIntent>(
|
||||
onInvoke: (i) => Navigator.of(context).pop(),
|
||||
),
|
||||
FocusLeftIntent: CallbackAction<FocusLeftIntent>(onInvoke: (FocusLeftIntent i) {
|
||||
return setState(() {
|
||||
if (index <= 0) {
|
||||
index = widget.resources.length;
|
||||
}
|
||||
index--;
|
||||
});
|
||||
}),
|
||||
FocusRightIntent: CallbackAction<FocusRightIntent>(onInvoke: (FocusRightIntent i) {
|
||||
return setState(() {
|
||||
index++;
|
||||
if (index >= widget.resources.length) {
|
||||
index = 0;
|
||||
}
|
||||
});
|
||||
}),
|
||||
},
|
||||
child: GestureDetector(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
child: Focus(
|
||||
focusNode: _focusNode,
|
||||
autofocus: true,
|
||||
child: FilePreview(transparent: true, resource: widget.resources[index]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
107
client/lib/ui/file_preview/file_preview_page.dart
Normal file
107
client/lib/ui/file_preview/file_preview_page.dart
Normal file
@@ -0,0 +1,107 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:offtheline/offtheline.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/ui/app/router.dart';
|
||||
import 'package:phylum/ui/app/routes.dart';
|
||||
import 'package:phylum/ui/file_preview/file_preview.dart';
|
||||
import 'package:phylum/ui/file_preview/versioned_resource.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class FilePreviewPage extends StatefulWidget {
|
||||
final String resourceId;
|
||||
|
||||
const FilePreviewPage._({required this.resourceId});
|
||||
|
||||
static Widget create({required PhylumAccount account, required String resourceId}) {
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
Provider.value(value: account),
|
||||
Provider.value(value: account.actionQueue),
|
||||
],
|
||||
child: FilePreviewPage._(resourceId: resourceId),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<FilePreviewPage> createState() => _ResourcePreviewDialogState();
|
||||
}
|
||||
|
||||
class _ResourcePreviewDialogState extends State<FilePreviewPage> {
|
||||
String? error;
|
||||
Resource? resource;
|
||||
ResourceVersion? version;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final account = context.read<PhylumAccount>();
|
||||
account.db.watchResource(widget.resourceId).listen((r) => setState(() => resource = r));
|
||||
account.db.latestVersion(widget.resourceId).watchSingleOrNull().listen((v) => setState(() => version = v));
|
||||
_loadResourceDetails();
|
||||
}
|
||||
|
||||
void _loadResourceDetails() async {
|
||||
final account = context.read<PhylumAccount>();
|
||||
final response = await account.resourceRepository.requestResource(widget.resourceId);
|
||||
if (response is ApiErrorResponse) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
error = response.description;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (resource != null && version != null) {
|
||||
return FilePreview(
|
||||
transparent: true,
|
||||
resource: VersionedResource(r: resource!, v: version!, showTimestamp: false),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: error == null ? _buildLoading(context) : _buildError(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoading(BuildContext context) {
|
||||
return const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 6.0,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
Text('Loading Details'),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildError(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 6.0,
|
||||
children: [
|
||||
Text('Error Loading Details: $error'),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.read<PhylumRouterDelegate>().go(const ExplorerRouteHome());
|
||||
},
|
||||
child: Text(
|
||||
'Go Home',
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
)),
|
||||
ElevatedButton(onPressed: _loadResourceDetails, child: Text('Retry')),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
12
client/lib/ui/file_preview/versioned_resource.dart
Normal file
12
client/lib/ui/file_preview/versioned_resource.dart
Normal file
@@ -0,0 +1,12 @@
|
||||
import 'package:phylum/libphylum/db/db.dart';
|
||||
import 'package:phylum/util/time.dart';
|
||||
|
||||
class VersionedResource {
|
||||
final Resource r;
|
||||
final ResourceVersion v;
|
||||
final bool showTimestamp;
|
||||
|
||||
VersionedResource({required this.r, required this.v, required this.showTimestamp});
|
||||
|
||||
String get name => showTimestamp ? r.name : '${r.name} - ${v.created.formatNumericDateTime()}';
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import 'package:phylum/ui/app/shortcuts.dart';
|
||||
import 'package:phylum/ui/app/router.dart';
|
||||
import 'package:phylum/ui/layout/search.dart';
|
||||
import 'package:phylum/ui/app/routes.dart';
|
||||
import 'package:phylum/ui/preview/resource_preview.dart';
|
||||
import 'package:phylum/ui/file_preview/file_preview_dialog.dart';
|
||||
import 'package:phylum/util/upload_utils.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
@@ -84,7 +84,7 @@ class AppActions extends StatelessWidget {
|
||||
context.read<PhylumRouterDelegate>().go(ExplorerRouteFolder(folderId: resource.id));
|
||||
return null;
|
||||
}
|
||||
ResourcePreview.showResources(context, [resource], 0);
|
||||
FilePreviewDialog.showResources(context, [resource], 0);
|
||||
return null;
|
||||
})
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@ import 'package:phylum/ui/login/instance_config.dart';
|
||||
import 'package:phylum/util/dialogs.dart';
|
||||
import 'package:uri/uri.dart';
|
||||
|
||||
import 'package:phylum/util/base_uri_stub.dart' if (dart.library.js_interop) 'package:phylum/util/base_uri_web.dart';
|
||||
import 'base_uri_stub.dart' if (dart.library.js_interop) 'base_uri_web.dart';
|
||||
|
||||
class InstanceUrlFragment extends StatefulWidget {
|
||||
final Function(InstanceConfig) onInstanceSelected;
|
||||
@@ -24,10 +24,7 @@ class _InstanceUrlFragmentState extends State<InstanceUrlFragment> {
|
||||
spacing: 12.0,
|
||||
children: [
|
||||
TextFormField(
|
||||
decoration: InputDecoration(
|
||||
label: const Text('Instance URL'),
|
||||
hintText: 'https://phylum.example.com',
|
||||
),
|
||||
decoration: InputDecoration(label: const Text('Instance URL'), hintText: 'https://phylum.example.com'),
|
||||
initialValue: webAppBaseUri?.toString(),
|
||||
autofocus: true,
|
||||
autofillHints: const [AutofillHints.url],
|
||||
@@ -41,7 +38,7 @@ class _InstanceUrlFragmentState extends State<InstanceUrlFragment> {
|
||||
},
|
||||
onFieldSubmitted: _url == null ? null : (value) => checkConfig(_url!),
|
||||
),
|
||||
ElevatedButton(onPressed: _url == null ? null : () => checkConfig(_url!), child: Text('Next'))
|
||||
ElevatedButton(onPressed: _url == null ? null : () => checkConfig(_url!), child: Text('Next')),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import 'package:phylum/libphylum/responses/responses.dart';
|
||||
import 'package:phylum/ui/app/dialog_scaffold.dart';
|
||||
import 'package:phylum/ui/app/router.dart';
|
||||
import 'package:phylum/ui/app/routes.dart';
|
||||
import 'package:phylum/ui/preview/resource_preview.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class OpenResourcePage extends StatefulWidget {
|
||||
@@ -43,12 +42,7 @@ class _OpenResourcePageState extends State<OpenResourcePage> {
|
||||
if (r.dir) {
|
||||
router.go(ExplorerRouteFolder(folderId: r.id));
|
||||
} else {
|
||||
if (r.parent != null) {
|
||||
router.go(ExplorerRouteFolder(folderId: r.parent!));
|
||||
} else {
|
||||
router.go(const ExplorerRouteHome());
|
||||
}
|
||||
ResourcePreview.showResources(context, [r], 0);
|
||||
router.go(FilePreviewRoute(fileId: r.id));
|
||||
}
|
||||
} else if (response is ApiErrorResponse) {
|
||||
if (mounted) {
|
||||
|
||||
@@ -1,336 +0,0 @@
|
||||
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/ui/app/shortcuts.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/requests/resource_contents_request.dart';
|
||||
import 'package:phylum/libphylum/responses/responses.dart';
|
||||
import 'package:phylum/ui/common/responsive_dialog.dart';
|
||||
import 'package:phylum/ui/explorer/resource_info_view.dart';
|
||||
import 'package:phylum/util/time.dart';
|
||||
import 'package:printing/printing.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
const maxPreviewSize = 1 * 1024 * 1024;
|
||||
|
||||
class VersionedResource {
|
||||
final Resource r;
|
||||
final ResourceVersion v;
|
||||
final bool latest;
|
||||
|
||||
VersionedResource({required this.r, required this.v, required this.latest});
|
||||
|
||||
String get name => latest ? r.name : '${r.name} - ${v.created.formatNumericDateTime()}';
|
||||
}
|
||||
|
||||
class ResourcePreview extends StatefulWidget {
|
||||
final List<VersionedResource> resources;
|
||||
final int index;
|
||||
|
||||
const ResourcePreview({super.key, required this.resources, required this.index});
|
||||
|
||||
static Future<void> showResources(BuildContext context, List<Resource> resources, int index) async {
|
||||
final account = context.read<PhylumAccount>();
|
||||
final r = await Future.wait(resources
|
||||
.map((r) async => VersionedResource(r: r, v: await account.db.latestVersion(r.id).getSingle(), latest: true)));
|
||||
if (!context.mounted) return;
|
||||
show(context, r, index);
|
||||
}
|
||||
|
||||
static Future<void> showVersions(
|
||||
BuildContext context, Resource resource, Iterable<ResourceVersion> versions, int index) async {
|
||||
final r = versions.map((v) => VersionedResource(r: resource, v: v, latest: false)).toList(growable: false);
|
||||
show(context, r, index);
|
||||
}
|
||||
|
||||
static Future<void> show(BuildContext context, List<VersionedResource> resources, int index) async {
|
||||
final account = context.read<PhylumAccount>();
|
||||
account.db.markResourceAccess(resources[index].r.id);
|
||||
return showDialog(
|
||||
context: context,
|
||||
builder: (context) => MultiProvider(
|
||||
providers: [
|
||||
Provider.value(value: account),
|
||||
Provider.value(value: account.actionQueue),
|
||||
],
|
||||
builder: (context, child) {
|
||||
return ResourcePreview(resources: resources, index: index);
|
||||
}));
|
||||
}
|
||||
|
||||
@override
|
||||
State<ResourcePreview> createState() => _ResourcePreviewState();
|
||||
}
|
||||
|
||||
class _ResourcePreviewState extends State<ResourcePreview> {
|
||||
late int index;
|
||||
final _focusNode = FocusNode();
|
||||
String? _error;
|
||||
Widget Function(Uint8List)? _buildPreview;
|
||||
late VersionedResource _resource;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
index = widget.index;
|
||||
_updateResource();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _updateResource() {
|
||||
_resource = widget.resources[index];
|
||||
if (_resource.r.dir) {
|
||||
_buildPreview = null;
|
||||
_error = 'Cannot preview directory';
|
||||
} else {
|
||||
setState(() {
|
||||
_buildPreview = null;
|
||||
if (_resource.v.mimeType.startsWith('image/')) {
|
||||
_buildPreview = buildImagePreview;
|
||||
}
|
||||
if (_resource.v.mimeType.startsWith('text/')) {
|
||||
_buildPreview = buildTextPreview;
|
||||
}
|
||||
if (_resource.v.mimeType == 'application/pdf') {
|
||||
_buildPreview = buildPdfPreview;
|
||||
}
|
||||
if (_buildPreview == null) {
|
||||
_error = 'Cannot generate preview';
|
||||
} else if (_resource.v.size > maxPreviewSize) {
|
||||
_buildPreview = null;
|
||||
_error = 'File too large to preview';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Actions(
|
||||
actions: {
|
||||
DismissIntent: CallbackAction<DismissIntent>(
|
||||
onInvoke: (i) => Navigator.of(context).pop(),
|
||||
),
|
||||
FocusLeftIntent: CallbackAction<FocusLeftIntent>(onInvoke: (FocusLeftIntent i) {
|
||||
return setState(() {
|
||||
if (index <= 0) {
|
||||
index = widget.resources.length;
|
||||
}
|
||||
index--;
|
||||
_updateResource();
|
||||
});
|
||||
}),
|
||||
FocusRightIntent: CallbackAction<FocusRightIntent>(onInvoke: (FocusRightIntent i) {
|
||||
return setState(() {
|
||||
index++;
|
||||
if (index >= widget.resources.length) {
|
||||
index = 0;
|
||||
}
|
||||
_updateResource();
|
||||
});
|
||||
}),
|
||||
},
|
||||
child: GestureDetector(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
child: Focus(
|
||||
focusNode: _focusNode,
|
||||
autofocus: true,
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.black45,
|
||||
appBar: AppBar(
|
||||
foregroundColor: theme.colorScheme.onInverseSurface,
|
||||
leading: const CloseButton(),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.info_outline),
|
||||
onPressed: () => showReponsiveDialog(
|
||||
context,
|
||||
_resource.r.name,
|
||||
(context) => ResourceInfoView(resource: _resource.r),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed:
|
||||
(_resource.r.dir) ? null : () => downloadResource(context, _resource.r, version: _resource.v),
|
||||
icon: const Icon(Icons.download),
|
||||
)
|
||||
],
|
||||
title: Text(_resource.name),
|
||||
backgroundColor: Colors.transparent,
|
||||
),
|
||||
body: Center(
|
||||
child: _buildPreview == null
|
||||
? Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text(_error ?? 'Unknown error', style: TextStyle(fontSize: 18)),
|
||||
))
|
||||
: ResourcePreviewDownloader(
|
||||
resourceId: _resource.r.id,
|
||||
versionId: _resource.v.id,
|
||||
buildPreview: _buildPreview!,
|
||||
)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ResourcePreviewDownloader extends StatefulWidget {
|
||||
final String resourceId;
|
||||
final String versionId;
|
||||
final Widget Function(Uint8List data) buildPreview;
|
||||
|
||||
ResourcePreviewDownloader({required this.resourceId, required this.buildPreview, required this.versionId})
|
||||
: super(key: ValueKey(versionId));
|
||||
|
||||
@override
|
||||
State<ResourcePreviewDownloader> createState() => _ResourcePreviewDownloaderState();
|
||||
}
|
||||
|
||||
class _ResourcePreviewDownloaderState extends State<ResourcePreviewDownloader> {
|
||||
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<void> _runDownloadTask() async {
|
||||
try {
|
||||
setState(() {
|
||||
_downloading = true;
|
||||
_error = null;
|
||||
_progress = null;
|
||||
});
|
||||
final account = context.read<PhylumAccount>();
|
||||
final response = await account.apiClient.dispatchRequestRaw(ResourceContentsRequest(
|
||||
widget.resourceId,
|
||||
versionId: widget.versionId,
|
||||
));
|
||||
if (response.statusCode < 200 || response.statusCode > 300) {
|
||||
final error = PhylumApiErrorResponse.fromResponseBody(await response.bodyString()).message;
|
||||
_error = error;
|
||||
_downloading = false;
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final expected = response.contentLength?.toDouble();
|
||||
ByteStream stream = response.stream;
|
||||
if (expected != null) {
|
||||
int received = 0;
|
||||
_progress = 0;
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
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<List<int>> stream) {
|
||||
if (stream is ByteStream) return stream;
|
||||
return ByteStream(stream);
|
||||
}
|
||||
|
||||
static Future<Uint8List> _toBytes(ByteStream stream) {
|
||||
var completer = Completer<Uint8List>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Widget buildImagePreview(Uint8List data) => Image.memory(data);
|
||||
|
||||
Widget buildTextPreview(Uint8List data) => Material(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: TextFormField(
|
||||
decoration: const InputDecoration(border: InputBorder.none),
|
||||
initialValue: String.fromCharCodes(data),
|
||||
readOnly: true,
|
||||
maxLines: 30,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget buildPdfPreview(Uint8List data) => PdfPreview(
|
||||
build: (_) => data,
|
||||
previewPageMargin: EdgeInsets.zero,
|
||||
);
|
||||
@@ -1 +0,0 @@
|
||||
export 'base_uri_stub.dart' if (dart.library.js_interop) 'base_uri_web.dart';
|
||||
Reference in New Issue
Block a user