From 0f773563f4180e93642f6873f5f3df73019ff516 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Mon, 16 Nov 2020 19:03:10 +0900 Subject: [PATCH] SVG: view source XML --- .../aves/model/provider/ImageProvider.kt | 2 +- .../model/provider/MediaStoreImageProvider.kt | 3 +- lib/model/image_entry.dart | 1 + lib/utils/constants.dart | 6 ++ lib/widgets/collection/app_bar.dart | 1 + .../entry_action_delegate.dart | 43 +++++--- lib/widgets/common/aves_highlight.dart | 101 ++++++++++++++++++ lib/widgets/common/entry_actions.dart | 6 ++ lib/widgets/fullscreen/overlay/top.dart | 4 + .../fullscreen/source_viewer_page.dart | 77 +++++++++++++ pubspec.lock | 14 +++ pubspec.yaml | 1 + 12 files changed, 245 insertions(+), 14 deletions(-) create mode 100644 lib/widgets/common/aves_highlight.dart create mode 100644 lib/widgets/fullscreen/source_viewer_page.dart diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt index cbf6d5e2b..c1eeca279 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt @@ -182,7 +182,7 @@ abstract class ImageProvider { } if (newFields.isEmpty()) { - cont.resumeWithException(Exception("failed to get item details from provider at contentUri=$contentUri")) + cont.resumeWithException(Exception("failed to get item details from provider at contentUri=$contentUri (from newUri=$newUri)")) } else { cont.resume(newFields) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt index 30dae1737..5c285de53 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt @@ -321,7 +321,8 @@ class MediaStoreImageProvider : ImageProvider() { MediaStore.MediaColumns._ID, MediaColumns.PATH, MediaStore.MediaColumns.MIME_TYPE, - MediaStore.MediaColumns.SIZE, // TODO TLAD use `DISPLAY_NAME` instead/along `TITLE`? + MediaStore.MediaColumns.SIZE, + // TODO TLAD use `DISPLAY_NAME` instead/along `TITLE`? MediaStore.MediaColumns.TITLE, MediaStore.MediaColumns.WIDTH, MediaStore.MediaColumns.HEIGHT, diff --git a/lib/model/image_entry.dart b/lib/model/image_entry.dart index a4b55a66d..07b64f7e7 100644 --- a/lib/model/image_entry.dart +++ b/lib/model/image_entry.dart @@ -23,6 +23,7 @@ class ImageEntry { String _path, _directory, _filename, _extension; int contentId; final String sourceMimeType; + // TODO TLAD use SVG viewport as width/height int width; int height; int sourceRotationDegrees; diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index e7e88d044..8cf139144 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -107,6 +107,12 @@ class Constants { licenseUrl: 'https://github.com/AndreHaueisen/flushbar/blob/master/LICENSE', sourceUrl: 'https://github.com/AndreHaueisen/flushbar', ), + Dependency( + name: 'Flutter Highlight', + license: 'MIT', + licenseUrl: 'https://github.com/git-touch/highlight/blob/master/LICENSE', + sourceUrl: 'https://github.com/git-touch/highlight', + ), Dependency( name: 'Flutter ijkplayer', license: 'MIT', diff --git a/lib/widgets/collection/app_bar.dart b/lib/widgets/collection/app_bar.dart index 5e67c0bf3..6329c68f4 100644 --- a/lib/widgets/collection/app_bar.dart +++ b/lib/widgets/collection/app_bar.dart @@ -282,6 +282,7 @@ class _CollectionAppBarState extends State with SingleTickerPr void _onCollectionActionSelected(CollectionAction action) async { // wait for the popup menu to hide before proceeding with the action await Future.delayed(Durations.popupMenuAnimation * timeDilation); + switch (action) { case CollectionAction.copy: case CollectionAction.move: diff --git a/lib/widgets/common/action_delegates/entry_action_delegate.dart b/lib/widgets/common/action_delegates/entry_action_delegate.dart index e62b4631e..de7a06885 100644 --- a/lib/widgets/common/action_delegates/entry_action_delegate.dart +++ b/lib/widgets/common/action_delegates/entry_action_delegate.dart @@ -2,6 +2,7 @@ import 'package:aves/model/image_entry.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/image_file_service.dart'; +import 'package:aves/utils/durations.dart'; import 'package:aves/widgets/common/action_delegates/feedback.dart'; import 'package:aves/widgets/common/action_delegates/permission_aware.dart'; import 'package:aves/widgets/common/action_delegates/rename_entry_dialog.dart'; @@ -9,7 +10,9 @@ import 'package:aves/widgets/common/aves_dialog.dart'; import 'package:aves/widgets/common/entry_actions.dart'; import 'package:aves/widgets/common/image_providers/uri_image_provider.dart'; import 'package:aves/widgets/fullscreen/fullscreen_debug_page.dart'; +import 'package:aves/widgets/fullscreen/source_viewer_page.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:pdf/pdf.dart'; @@ -28,46 +31,52 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin { bool get hasCollection => collection != null; - void onActionSelected(BuildContext context, ImageEntry entry, EntryAction action) { + void onActionSelected(BuildContext context, ImageEntry entry, EntryAction action) async { + // wait for the popup menu to hide before proceeding with the action + await Future.delayed(Durations.popupMenuAnimation * timeDilation); + switch (action) { case EntryAction.toggleFavourite: entry.toggleFavourite(); break; case EntryAction.delete: - _showDeleteDialog(context, entry); + unawaited(_showDeleteDialog(context, entry)); break; case EntryAction.edit: - AndroidAppService.edit(entry.uri, entry.mimeType); + unawaited(AndroidAppService.edit(entry.uri, entry.mimeType)); break; case EntryAction.info: showInfo(); break; case EntryAction.rename: - _showRenameDialog(context, entry); + unawaited(_showRenameDialog(context, entry)); break; case EntryAction.open: - AndroidAppService.open(entry.uri, entry.mimeTypeAnySubtype); + unawaited(AndroidAppService.open(entry.uri, entry.mimeTypeAnySubtype)); break; case EntryAction.openMap: - AndroidAppService.openMap(entry.geoUri); + unawaited(AndroidAppService.openMap(entry.geoUri)); break; case EntryAction.print: - _print(entry); + unawaited(_print(entry)); break; case EntryAction.rotateCCW: - _rotate(context, entry, clockwise: false); + unawaited(_rotate(context, entry, clockwise: false)); break; case EntryAction.rotateCW: - _rotate(context, entry, clockwise: true); + unawaited(_rotate(context, entry, clockwise: true)); break; case EntryAction.flip: - _flip(context, entry); + unawaited(_flip(context, entry)); break; case EntryAction.setAs: - AndroidAppService.setAs(entry.uri, entry.mimeType); + unawaited(AndroidAppService.setAs(entry.uri, entry.mimeType)); break; case EntryAction.share: - AndroidAppService.share({entry}); + unawaited(AndroidAppService.share({entry})); + break; + case EntryAction.viewSource: + _goToSourceViewer(context, entry); break; case EntryAction.debug: _goToDebug(context, entry); @@ -181,6 +190,16 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin { showFeedback(context, await entry.rename(newName) ? 'Done!' : 'Failed'); } + void _goToSourceViewer(BuildContext context, ImageEntry entry) { + Navigator.push( + context, + MaterialPageRoute( + settings: RouteSettings(name: SourceViewerPage.routeName), + builder: (context) => SourceViewerPage(entry: entry), + ), + ); + } + void _goToDebug(BuildContext context, ImageEntry entry) { Navigator.push( context, diff --git a/lib/widgets/common/aves_highlight.dart b/lib/widgets/common/aves_highlight.dart new file mode 100644 index 000000000..13df2ac11 --- /dev/null +++ b/lib/widgets/common/aves_highlight.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:highlight/highlight.dart' show highlight, Node; + +// TODO TLAD use the TextSpan getter instead of this modified `HighlightView` when this is fixed: https://github.com/git-touch/highlight/issues/6 + +/// Highlight Flutter Widget +class AvesHighlightView extends StatelessWidget { + /// The original code to be highlighted + final String source; + + /// Highlight language + /// + /// It is recommended to give it a value for performance + /// + /// [All available languages](https://github.com/pd4d10/highlight/tree/master/highlight/lib/languages) + final String language; + + /// Highlight theme + /// + /// [All available themes](https://github.com/pd4d10/highlight/blob/master/flutter_highlight/lib/themes) + final Map theme; + + /// Padding + final EdgeInsetsGeometry padding; + + /// Text styles + /// + /// Specify text styles such as font family and font size + final TextStyle textStyle; + + AvesHighlightView( + String input, { + this.language, + this.theme = const {}, + this.padding, + this.textStyle, + int tabSize = 8, // TODO: https://github.com/flutter/flutter/issues/50087 + }) : source = input.replaceAll('\t', ' ' * tabSize); + + List _convert(List nodes) { + final spans = []; + var currentSpans = spans; + final stack = >[]; + + void _traverse(Node node) { + if (node.value != null) { + currentSpans.add(node.className == null ? TextSpan(text: node.value) : TextSpan(text: node.value, style: theme[node.className])); + } else if (node.children != null) { + final tmp = []; + currentSpans.add(TextSpan(children: tmp, style: theme[node.className])); + stack.add(currentSpans); + currentSpans = tmp; + + node.children.forEach((n) { + _traverse(n); + if (n == node.children.last) { + currentSpans = stack.isEmpty ? spans : stack.removeLast(); + } + }); + } + } + + for (var node in nodes) { + _traverse(node); + } + + return spans; + } + + static const _rootKey = 'root'; + static const _defaultFontColor = Color(0xff000000); + static const _defaultBackgroundColor = Color(0xffffffff); + + // TODO: dart:io is not available at web platform currently + // See: https://github.com/flutter/flutter/issues/39998 + // So we just use monospace here for now + static const _defaultFontFamily = 'monospace'; + + @override + Widget build(BuildContext context) { + var _textStyle = TextStyle( + fontFamily: _defaultFontFamily, + color: theme[_rootKey]?.color ?? _defaultFontColor, + ); + if (textStyle != null) { + _textStyle = _textStyle.merge(textStyle); + } + + return Container( + color: theme[_rootKey]?.backgroundColor ?? _defaultBackgroundColor, + padding: padding, + child: SelectableText.rich( + TextSpan( + style: _textStyle, + children: _convert(highlight.parse(source, language: language).nodes), + ), + ), + ); + } +} diff --git a/lib/widgets/common/entry_actions.dart b/lib/widgets/common/entry_actions.dart index 2fadd1889..0b9e70b2f 100644 --- a/lib/widgets/common/entry_actions.dart +++ b/lib/widgets/common/entry_actions.dart @@ -15,6 +15,7 @@ enum EntryAction { setAs, share, toggleFavourite, + viewSource, debug, } @@ -31,6 +32,7 @@ class EntryActions { EntryAction.delete, EntryAction.rename, EntryAction.print, + EntryAction.viewSource, ]; static const externalApp = [ @@ -64,6 +66,8 @@ extension ExtraEntryAction on EntryAction { return 'Print'; case EntryAction.share: return 'Share'; + case EntryAction.viewSource: + return 'View source'; // external app actions case EntryAction.edit: return 'Edit with…'; @@ -101,6 +105,8 @@ extension ExtraEntryAction on EntryAction { return AIcons.print; case EntryAction.share: return AIcons.share; + case EntryAction.viewSource: + return AIcons.vector; // external app actions case EntryAction.edit: case EntryAction.open: diff --git a/lib/widgets/fullscreen/overlay/top.dart b/lib/widgets/fullscreen/overlay/top.dart index bfd209e36..db02ebf4b 100644 --- a/lib/widgets/fullscreen/overlay/top.dart +++ b/lib/widgets/fullscreen/overlay/top.dart @@ -109,6 +109,8 @@ class FullscreenTopOverlay extends StatelessWidget { return entry.canPrint; case EntryAction.openMap: return entry.hasGps; + case EntryAction.viewSource: + return entry.isSvg; case EntryAction.share: case EntryAction.info: case EntryAction.open: @@ -191,6 +193,7 @@ class _TopOverlayRow extends StatelessWidget { case EntryAction.rotateCW: case EntryAction.flip: case EntryAction.print: + case EntryAction.viewSource: child = IconButton( icon: Icon(action.getIcon()), onPressed: onPressed, @@ -233,6 +236,7 @@ class _TopOverlayRow extends StatelessWidget { case EntryAction.rotateCW: case EntryAction.flip: case EntryAction.print: + case EntryAction.viewSource: case EntryAction.debug: child = MenuRow(text: action.getText(), icon: action.getIcon()); break; diff --git a/lib/widgets/fullscreen/source_viewer_page.dart b/lib/widgets/fullscreen/source_viewer_page.dart new file mode 100644 index 000000000..d6a5a0557 --- /dev/null +++ b/lib/widgets/fullscreen/source_viewer_page.dart @@ -0,0 +1,77 @@ +import 'dart:convert'; + +import 'package:aves/model/image_entry.dart'; +import 'package:aves/services/image_file_service.dart'; +import 'package:aves/widgets/common/aves_highlight.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_highlight/themes/darcula.dart'; + +class SourceViewerPage extends StatefulWidget { + static const routeName = '/fullscreen/source'; + + final ImageEntry entry; + + const SourceViewerPage({ + @required this.entry, + }); + + @override + _SourceViewerPageState createState() => _SourceViewerPageState(); +} + +class _SourceViewerPageState extends State { + Future _loader; + + ImageEntry get entry => widget.entry; + + @override + void initState() { + super.initState(); + _loader = ImageFileService.getImage(entry.uri, entry.mimeType, 0, false).then(utf8.decode); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('Source'), + ), + body: SafeArea( + child: FutureBuilder( + future: _loader, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Text(snapshot.error.toString()); + } + if (snapshot.connectionState != ConnectionState.done) { + return SizedBox.shrink(); + } + + final source = snapshot.data; + final highlightView = AvesHighlightView( + source, + language: 'xml', + theme: darculaTheme, + padding: EdgeInsets.all(8), + textStyle: TextStyle( + fontSize: 12, + ), + tabSize: 4, + ); + return Container( + constraints: BoxConstraints.expand(), + child: Scrollbar( + child: SingleChildScrollView( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: highlightView, + ), + ), + ), + ); + }, + ), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index cc04bfa10..d6168ff0a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -288,6 +288,13 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_highlight: + dependency: "direct main" + description: + name: flutter_highlight + url: "https://pub.dartlang.org" + source: hosted + version: "0.6.0" flutter_ijkplayer: dependency: "direct main" description: @@ -389,6 +396,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.4" + highlight: + dependency: transitive + description: + name: highlight + url: "https://pub.dartlang.org" + source: hosted + version: "0.6.0" http: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 1eef71a3a..a6b8ddc1f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -59,6 +59,7 @@ dependencies: firebase_analytics: firebase_crashlytics: flushbar: + flutter_highlight: flutter_ijkplayer: # path: ../flutter_ijkplayer git: