[client] File preview page

This commit is contained in:
Abhishek Shroff
2025-07-04 14:15:36 +05:30
parent ff9209179a
commit 04e16e7b8d
15 changed files with 508 additions and 371 deletions

View File

@@ -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'));
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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),

View 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,
);

View 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]),
),
),
);
}
}

View 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')),
],
);
}
}

View 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()}';
}

View File

@@ -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;
})
},

View File

@@ -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')),
],
);
}

View File

@@ -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) {

View File

@@ -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,
);

View File

@@ -1 +0,0 @@
export 'base_uri_stub.dart' if (dart.library.js_interop) 'base_uri_web.dart';