From 988bc0093e4bca281875ce7fe21c80525fee2332 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Mon, 23 May 2022 18:22:00 +0900 Subject: [PATCH] #98 info: owner value alignment, empty icon collapse --- lib/l10n/app_en.arb | 2 +- lib/widgets/viewer/info/basic_section.dart | 160 ++++++++++++++---- lib/widgets/viewer/info/common.dart | 82 +++++---- .../info/metadata/metadata_dir_tile.dart | 12 +- .../viewer/info/metadata/xmp_namespaces.dart | 4 +- .../viewer/info/metadata/xmp_ns/google.dart | 4 +- .../viewer/info/metadata/xmp_ns/xmp.dart | 2 +- .../viewer/info/metadata/xmp_structs.dart | 8 +- lib/widgets/viewer/info/owner.dart | 94 ---------- 9 files changed, 183 insertions(+), 185 deletions(-) delete mode 100644 lib/widgets/viewer/info/owner.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 2a1adc515..0dc847cfa 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -759,7 +759,7 @@ "viewerInfoLabelUri": "URI", "viewerInfoLabelPath": "Path", "viewerInfoLabelDuration": "Duration", - "viewerInfoLabelOwner": "Owned by", + "viewerInfoLabelOwner": "Owner", "viewerInfoLabelCoordinates": "Coordinates", "viewerInfoLabelAddress": "Address", diff --git a/lib/widgets/viewer/info/basic_section.dart b/lib/widgets/viewer/info/basic_section.dart index cb0701554..a7c11a944 100644 --- a/lib/widgets/viewer/info/basic_section.dart +++ b/lib/widgets/viewer/info/basic_section.dart @@ -1,3 +1,5 @@ +import 'package:aves/app_mode.dart'; +import 'package:aves/image_providers/app_icon_image_provider.dart'; import 'package:aves/model/actions/entry_info_actions.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/favourites.dart'; @@ -7,16 +9,19 @@ import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/filters/rating.dart'; import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/filters/type.dart'; +import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; +import 'package:aves/services/common/services.dart'; import 'package:aves/theme/colors.dart'; import 'package:aves/theme/format.dart'; +import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/file_utils.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:aves/widgets/viewer/action/entry_info_action_delegate.dart'; import 'package:aves/widgets/viewer/info/common.dart'; -import 'package:aves/widgets/viewer/info/owner.dart'; import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -36,46 +41,15 @@ class BasicSection extends StatelessWidget { required this.onFilter, }); - int get megaPixels => entry.megaPixels; - - bool get showMegaPixels => entry.isPhoto && megaPixels > 0; - - String get rasterResolutionText => '${entry.resolutionText}${showMegaPixels ? ' • $megaPixels MP' : ''}'; - @override Widget build(BuildContext context) { - final l10n = context.l10n; - final infoUnknown = l10n.viewerInfoUnknown; - final locale = l10n.localeName; - final use24hour = context.select((v) => v.alwaysUse24HourFormat); - return AnimatedBuilder( animation: entry.metadataChangeNotifier, builder: (context, child) { - // TODO TLAD line break on all characters for the following fields when this is fixed: https://github.com/flutter/flutter/issues/61081 - // inserting ZWSP (\u200B) between characters does help, but it messes with width and height computation (another Flutter issue) - final title = entry.bestTitle ?? infoUnknown; - final date = entry.bestDate; - final dateText = date != null ? formatDateTime(date, locale, use24hour) : infoUnknown; - final showResolution = !entry.isSvg && entry.isSized; - final sizeText = entry.sizeBytes != null ? formatFileSize(locale, entry.sizeBytes!) : infoUnknown; - final path = entry.path; - return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - InfoRowGroup( - info: { - l10n.viewerInfoLabelTitle: title, - l10n.viewerInfoLabelDate: dateText, - if (entry.isVideo) ..._buildVideoRows(context), - if (showResolution) l10n.viewerInfoLabelResolution: rasterResolutionText, - l10n.viewerInfoLabelSize: sizeText, - if (!entry.trashed) l10n.viewerInfoLabelUri: entry.uri, - if (path != null) l10n.viewerInfoLabelPath: path, - }, - ), - if (!entry.trashed) OwnerProp(entry: entry), + _BasicInfo(entry: entry), _buildChips(context), _buildEditButtons(context), ], @@ -184,10 +158,130 @@ class BasicSection extends StatelessWidget { }, ); } +} + +class _BasicInfo extends StatefulWidget { + final AvesEntry entry; + + const _BasicInfo({ + required this.entry, + }); + + @override + State<_BasicInfo> createState() => _BasicInfoState(); +} + +class _BasicInfoState extends State<_BasicInfo> { + Future _ownerPackageLoader = SynchronousFuture(null); + Future _appNameLoader = SynchronousFuture(null); + + AvesEntry get entry => widget.entry; + + int get megaPixels => entry.megaPixels; + + bool get showMegaPixels => entry.isPhoto && megaPixels > 0; + + String get rasterResolutionText => '${entry.resolutionText}${showMegaPixels ? ' • $megaPixels MP' : ''}'; + + static const ownerPackageNamePropKey = 'owner_package_name'; + static const iconSize = 20.0; + + @override + void initState() { + super.initState(); + if (!entry.trashed) { + final isMediaContent = entry.uri.startsWith('content://media/external/'); + if (isMediaContent) { + _ownerPackageLoader = metadataFetchService.hasContentResolverProp(ownerPackageNamePropKey).then((exists) { + return exists ? metadataFetchService.getContentResolverProp(entry, ownerPackageNamePropKey) : SynchronousFuture(null); + }); + final isViewerMode = context.read>().value == AppMode.view; + if (isViewerMode && settings.isInstalledAppAccessAllowed) { + _appNameLoader = androidFileUtils.initAppNames(); + } + } + } + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final infoUnknown = l10n.viewerInfoUnknown; + final locale = l10n.localeName; + final use24hour = context.select((v) => v.alwaysUse24HourFormat); + + // TODO TLAD line break on all characters for the following fields when this is fixed: https://github.com/flutter/flutter/issues/61081 + // inserting ZWSP (\u200B) between characters does help, but it messes with width and height computation (another Flutter issue) + final title = entry.bestTitle ?? infoUnknown; + final date = entry.bestDate; + final dateText = date != null ? formatDateTime(date, locale, use24hour) : infoUnknown; + final showResolution = !entry.isSvg && entry.isSized; + final sizeText = entry.sizeBytes != null ? formatFileSize(locale, entry.sizeBytes!) : infoUnknown; + final path = entry.path; + + return FutureBuilder( + future: _ownerPackageLoader, + builder: (context, snapshot) { + final ownerPackage = snapshot.data; + return FutureBuilder( + future: _appNameLoader, + builder: (context, snapshot) { + return InfoRowGroup( + info: { + l10n.viewerInfoLabelTitle: title, + l10n.viewerInfoLabelDate: dateText, + if (entry.isVideo) ..._buildVideoRows(context), + if (showResolution) l10n.viewerInfoLabelResolution: rasterResolutionText, + l10n.viewerInfoLabelSize: sizeText, + if (!entry.trashed) l10n.viewerInfoLabelUri: entry.uri, + if (path != null) l10n.viewerInfoLabelPath: path, + if (ownerPackage != null) l10n.viewerInfoLabelOwner: ownerPackage, + }, + spanBuilders: { + l10n.viewerInfoLabelOwner: _ownerHandler(ownerPackage), + }, + ); + }, + ); + }, + ); + } Map _buildVideoRows(BuildContext context) { return { context.l10n.viewerInfoLabelDuration: entry.durationText, }; } + + InfoValueSpanBuilder _ownerHandler(String? ownerPackage) { + if (ownerPackage == null) return (context, key, value) => []; + + final appName = androidFileUtils.getCurrentAppName(ownerPackage) ?? ownerPackage; + return (context, key, value) => [ + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Padding( + padding: const EdgeInsetsDirectional.only(end: 4), + child: ConstrainedBox( + // use constraints instead of sizing `Image`, + // so that it can collapse when handling an empty image + constraints: const BoxConstraints( + maxWidth: iconSize, + maxHeight: iconSize, + ), + child: Image( + image: AppIconImage( + packageName: ownerPackage, + size: iconSize, + ), + ), + ), + ), + ), + TextSpan( + text: appName, + style: InfoRowGroup.valueStyle, + ), + ]; + } } diff --git a/lib/widgets/viewer/info/common.dart b/lib/widgets/viewer/info/common.dart index d15eed12e..134dda62b 100644 --- a/lib/widgets/viewer/info/common.dart +++ b/lib/widgets/viewer/info/common.dart @@ -43,7 +43,7 @@ class SectionRow extends StatelessWidget { class InfoRowGroup extends StatefulWidget { final Map info; final int maxValueLength; - final Map? linkHandlers; + final Map spanBuilders; static const keyValuePadding = 16; static const fontSize = 13.0; @@ -56,11 +56,28 @@ class InfoRowGroup extends StatefulWidget { super.key, required this.info, this.maxValueLength = 0, - this.linkHandlers, - }); + Map? spanBuilders, + }) : spanBuilders = spanBuilders ?? const {}; @override State createState() => _InfoRowGroupState(); + + static InfoValueSpanBuilder linkSpanBuilder({ + required String Function(BuildContext context) linkText, + required void Function(BuildContext context) onTap, + }) { + return (context, key, value) { + value = linkText(context); + // open link on tap + final recognizer = TapGestureRecognizer()..onTap = () => onTap(context); + // `colorScheme.secondary` is overridden upstream as an `ExpansionTileCard` theming workaround, + // so we use `colorScheme.primary` instead + final linkColor = Theme.of(context).colorScheme.primary; + final style = InfoRowGroup.valueStyle.copyWith(color: linkColor, decoration: TextDecoration.underline); + + return [TextSpan(text: '${Constants.fsi}$value${Constants.pdi}', style: style, recognizer: recognizer)]; + }; + } } class _InfoRowGroupState extends State { @@ -70,7 +87,7 @@ class _InfoRowGroupState extends State { int get maxValueLength => widget.maxValueLength; - Map? get linkHandlers => widget.linkHandlers; + Map get spanBuilders => widget.spanBuilders; @override Widget build(BuildContext context) { @@ -94,34 +111,8 @@ class _InfoRowGroupState extends State { children: keyValues.entries.expand( (kv) { final key = kv.key; - String value; - TextStyle? style; - GestureRecognizer? recognizer; - - if (linkHandlers?.containsKey(key) == true) { - final handler = linkHandlers![key]!; - value = handler.linkText(context); - // open link on tap - recognizer = TapGestureRecognizer()..onTap = () => handler.onTap(context); - // `colorScheme.secondary` is overridden upstream as an `ExpansionTileCard` theming workaround, - // so we use `colorScheme.primary` instead - final linkColor = Theme.of(context).colorScheme.primary; - style = InfoRowGroup.valueStyle.copyWith(color: linkColor, decoration: TextDecoration.underline); - } else { - value = kv.value; - // long values are clipped, and made expandable by tapping them - final showPreviewOnly = maxValueLength > 0 && value.length > maxValueLength && !_expandedKeys.contains(key); - if (showPreviewOnly) { - value = '${value.substring(0, maxValueLength)}…'; - // show full value on tap - recognizer = TapGestureRecognizer()..onTap = () => setState(() => _expandedKeys.add(key)); - } - } - - if (key != lastKey) { - value = '$value\n'; - } - + final value = kv.value; + final spanBuilder = spanBuilders[key] ?? _buildTextValueSpans; final thisSpaceSize = max(0.0, (baseValueX - keySizes[key]!)) + InfoRowGroup.keyValuePadding; // each text span embeds and pops a Bidi isolate, @@ -138,7 +129,8 @@ class _InfoRowGroupState extends State { child: const Text(''), ), ), - TextSpan(text: '${Constants.fsi}$value${Constants.pdi}', style: style, recognizer: recognizer), + ...spanBuilder(context, key, value), + if (key != lastKey) const TextSpan(text: '\n'), ]; }, ).toList(), @@ -157,14 +149,20 @@ class _InfoRowGroupState extends State { )..layout(const BoxConstraints(), parentUsesSize: true); return para.getMaxIntrinsicWidth(double.infinity); } + + List _buildTextValueSpans(BuildContext context, String key, String value) { + GestureRecognizer? recognizer; + + // long values are clipped, and made expandable by tapping them + final showPreviewOnly = maxValueLength > 0 && value.length > maxValueLength && !_expandedKeys.contains(key); + if (showPreviewOnly) { + value = '${value.substring(0, maxValueLength)}…'; + // show full value on tap + recognizer = TapGestureRecognizer()..onTap = () => setState(() => _expandedKeys.add(key)); + } + + return [TextSpan(text: '${Constants.fsi}$value${Constants.pdi}', recognizer: recognizer)]; + } } -class InfoLinkHandler { - final String Function(BuildContext context) linkText; - final void Function(BuildContext context) onTap; - - const InfoLinkHandler({ - required this.linkText, - required this.onTap, - }); -} +typedef InfoValueSpanBuilder = List Function(BuildContext context, String key, String value); diff --git a/lib/widgets/viewer/info/metadata/metadata_dir_tile.dart b/lib/widgets/viewer/info/metadata/metadata_dir_tile.dart index 06314655b..51b1502e8 100644 --- a/lib/widgets/viewer/info/metadata/metadata_dir_tile.dart +++ b/lib/widgets/viewer/info/metadata/metadata_dir_tile.dart @@ -50,7 +50,7 @@ class MetadataDirTile extends StatelessWidget { initiallyExpanded: initiallyExpanded, ); } else { - Map? linkHandlers; + Map? linkHandlers; switch (dirName) { case SvgMetadataService.metadataDirectory: linkHandlers = getSvgLinkHandlers(tags); @@ -79,7 +79,7 @@ class MetadataDirTile extends StatelessWidget { child: InfoRowGroup( info: tags, maxValueLength: Constants.infoGroupMaxValueLength, - linkHandlers: linkHandlers, + spanBuilders: linkHandlers, ), ), ], @@ -87,9 +87,9 @@ class MetadataDirTile extends StatelessWidget { } } - static Map getSvgLinkHandlers(SplayTreeMap tags) { + static Map getSvgLinkHandlers(SplayTreeMap tags) { return { - 'Metadata': InfoLinkHandler( + 'Metadata': InfoRowGroup.linkSpanBuilder( linkText: (context) => context.l10n.viewerInfoViewXmlLinkText, onTap: (context) { Navigator.push( @@ -106,9 +106,9 @@ class MetadataDirTile extends StatelessWidget { }; } - static Map getVideoCoverLinkHandlers(SplayTreeMap tags) { + static Map getVideoCoverLinkHandlers(SplayTreeMap tags) { return { - 'Image': InfoLinkHandler( + 'Image': InfoRowGroup.linkSpanBuilder( linkText: (context) => context.l10n.viewerInfoOpenLinkText, onTap: (context) => OpenEmbeddedDataNotification.videoCover().dispatch(context), ), diff --git a/lib/widgets/viewer/info/metadata/xmp_namespaces.dart b/lib/widgets/viewer/info/metadata/xmp_namespaces.dart index 9752f011f..8a25664cc 100644 --- a/lib/widgets/viewer/info/metadata/xmp_namespaces.dart +++ b/lib/widgets/viewer/info/metadata/xmp_namespaces.dart @@ -145,7 +145,7 @@ class XmpNamespace extends Equatable { InfoRowGroup( info: Map.fromEntries(props.map((prop) => MapEntry(prop.displayKey, formatValue(prop)))), maxValueLength: Constants.infoGroupMaxValueLength, - linkHandlers: linkifyValues(props), + spanBuilders: linkifyValues(props), ), ...buildFromExtractedData(), ]; @@ -194,7 +194,7 @@ class XmpNamespace extends Equatable { String formatValue(XmpProp prop) => prop.value; - Map linkifyValues(List props) => {}; + Map linkifyValues(List props) => {}; } class XmpProp { diff --git a/lib/widgets/viewer/info/metadata/xmp_ns/google.dart b/lib/widgets/viewer/info/metadata/xmp_ns/google.dart index 8081111c0..950241428 100644 --- a/lib/widgets/viewer/info/metadata/xmp_ns/google.dart +++ b/lib/widgets/viewer/info/metadata/xmp_ns/google.dart @@ -13,7 +13,7 @@ abstract class XmpGoogleNamespace extends XmpNamespace { List> get dataProps; @override - Map linkifyValues(List props) { + Map linkifyValues(List props) { return Map.fromEntries(dataProps.map((t) { final dataPropPath = t.item1; final mimePropPath = t.item2; @@ -22,7 +22,7 @@ abstract class XmpGoogleNamespace extends XmpNamespace { return (dataProp != null && mimeProp != null) ? MapEntry( dataProp.displayKey, - InfoLinkHandler( + InfoRowGroup.linkSpanBuilder( linkText: (context) => context.l10n.viewerInfoOpenLinkText, onTap: (context) => OpenEmbeddedDataNotification.xmp( propPath: dataProp.path, diff --git a/lib/widgets/viewer/info/metadata/xmp_ns/xmp.dart b/lib/widgets/viewer/info/metadata/xmp_ns/xmp.dart index 2d41cc381..2c4e0fe65 100644 --- a/lib/widgets/viewer/info/metadata/xmp_ns/xmp.dart +++ b/lib/widgets/viewer/info/metadata/xmp_ns/xmp.dart @@ -29,7 +29,7 @@ class XmpBasicNamespace extends XmpNamespace { final struct = thumbnails[index]!; return { if (struct.containsKey(thumbnailDataDisplayKey)) - thumbnailDataDisplayKey: InfoLinkHandler( + thumbnailDataDisplayKey: InfoRowGroup.linkSpanBuilder( linkText: (context) => context.l10n.viewerInfoOpenLinkText, onTap: (context) => OpenEmbeddedDataNotification.xmp( propPath: 'xmp:Thumbnails[$index]/xmpGImg:image', diff --git a/lib/widgets/viewer/info/metadata/xmp_structs.dart b/lib/widgets/viewer/info/metadata/xmp_structs.dart index 315cd538b..88f66e790 100644 --- a/lib/widgets/viewer/info/metadata/xmp_structs.dart +++ b/lib/widgets/viewer/info/metadata/xmp_structs.dart @@ -13,7 +13,7 @@ import 'package:flutter/material.dart'; class XmpStructArrayCard extends StatefulWidget { final String title; late final List> structs; - final Map Function(int index)? linkifier; + final Map Function(int index)? linkifier; XmpStructArrayCard({ super.key, @@ -95,7 +95,7 @@ class _XmpStructArrayCardState extends State { child: InfoRowGroup( info: structs[_index], maxValueLength: Constants.infoGroupMaxValueLength, - linkHandlers: widget.linkifier?.call(_index + 1), + spanBuilders: widget.linkifier?.call(_index + 1), ), ), ), @@ -108,7 +108,7 @@ class _XmpStructArrayCardState extends State { class XmpStructCard extends StatelessWidget { final String title; final Map struct; - final Map Function()? linkifier; + final Map Function()? linkifier; static const cardMargin = EdgeInsets.symmetric(vertical: 8, horizontal: 0); @@ -137,7 +137,7 @@ class XmpStructCard extends StatelessWidget { InfoRowGroup( info: struct, maxValueLength: Constants.infoGroupMaxValueLength, - linkHandlers: linkifier?.call(), + spanBuilders: linkifier?.call(), ), ], ), diff --git a/lib/widgets/viewer/info/owner.dart b/lib/widgets/viewer/info/owner.dart deleted file mode 100644 index 1d8015752..000000000 --- a/lib/widgets/viewer/info/owner.dart +++ /dev/null @@ -1,94 +0,0 @@ -import 'package:aves/app_mode.dart'; -import 'package:aves/image_providers/app_icon_image_provider.dart'; -import 'package:aves/model/entry.dart'; -import 'package:aves/model/settings/settings.dart'; -import 'package:aves/services/common/services.dart'; -import 'package:aves/utils/android_file_utils.dart'; -import 'package:aves/widgets/common/extensions/build_context.dart'; -import 'package:aves/widgets/viewer/info/common.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -class OwnerProp extends StatefulWidget { - final AvesEntry entry; - - const OwnerProp({ - super.key, - required this.entry, - }); - - @override - State createState() => _OwnerPropState(); -} - -class _OwnerPropState extends State { - Future _ownerPackageLoader = SynchronousFuture(null); - Future _appNameLoader = SynchronousFuture(null); - - AvesEntry get entry => widget.entry; - - static const ownerPackageNamePropKey = 'owner_package_name'; - static const iconSize = 20.0; - - @override - void initState() { - super.initState(); - final isMediaContent = entry.uri.startsWith('content://media/external/'); - if (isMediaContent) { - _ownerPackageLoader = metadataFetchService.hasContentResolverProp(ownerPackageNamePropKey).then((exists) { - return exists ? metadataFetchService.getContentResolverProp(entry, ownerPackageNamePropKey) : SynchronousFuture(null); - }); - final isViewerMode = context.read>().value == AppMode.view; - if (isViewerMode && settings.isInstalledAppAccessAllowed) { - _appNameLoader = androidFileUtils.initAppNames(); - } - } - } - - @override - Widget build(BuildContext context) { - return FutureBuilder( - future: _ownerPackageLoader, - builder: (context, snapshot) { - final ownerPackage = snapshot.data; - if (ownerPackage == null) return const SizedBox(); - - return FutureBuilder( - future: _appNameLoader, - builder: (context, snapshot) { - final appName = androidFileUtils.getCurrentAppName(ownerPackage) ?? ownerPackage; - return SelectableText.rich( - TextSpan( - children: [ - TextSpan( - text: context.l10n.viewerInfoLabelOwner, - style: InfoRowGroup.keyStyle(context), - ), - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: Image( - image: AppIconImage( - packageName: ownerPackage, - size: iconSize, - ), - width: iconSize, - height: iconSize, - ), - ), - ), - TextSpan( - text: appName, - style: InfoRowGroup.valueStyle, - ), - ], - ), - ); - }, - ); - }, - ); - } -}