#98 info: owner value alignment, empty icon collapse

This commit is contained in:
Thibault Deckers 2022-05-23 18:22:00 +09:00
parent 44ed934a8c
commit 988bc0093e
9 changed files with 183 additions and 185 deletions

View file

@ -759,7 +759,7 @@
"viewerInfoLabelUri": "URI", "viewerInfoLabelUri": "URI",
"viewerInfoLabelPath": "Path", "viewerInfoLabelPath": "Path",
"viewerInfoLabelDuration": "Duration", "viewerInfoLabelDuration": "Duration",
"viewerInfoLabelOwner": "Owned by", "viewerInfoLabelOwner": "Owner",
"viewerInfoLabelCoordinates": "Coordinates", "viewerInfoLabelCoordinates": "Coordinates",
"viewerInfoLabelAddress": "Address", "viewerInfoLabelAddress": "Address",

View file

@ -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/actions/entry_info_actions.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/favourites.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/rating.dart';
import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/filters/type.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/model/source/collection_lens.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/theme/colors.dart'; import 'package:aves/theme/colors.dart';
import 'package:aves/theme/format.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/utils/file_utils.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/aves_filter_chip.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/action/entry_info_action_delegate.dart';
import 'package:aves/widgets/viewer/info/common.dart'; import 'package:aves/widgets/viewer/info/common.dart';
import 'package:aves/widgets/viewer/info/owner.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -36,46 +41,15 @@ class BasicSection extends StatelessWidget {
required this.onFilter, required this.onFilter,
}); });
int get megaPixels => entry.megaPixels;
bool get showMegaPixels => entry.isPhoto && megaPixels > 0;
String get rasterResolutionText => '${entry.resolutionText}${showMegaPixels ? '$megaPixels MP' : ''}';
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = context.l10n;
final infoUnknown = l10n.viewerInfoUnknown;
final locale = l10n.localeName;
final use24hour = context.select<MediaQueryData, bool>((v) => v.alwaysUse24HourFormat);
return AnimatedBuilder( return AnimatedBuilder(
animation: entry.metadataChangeNotifier, animation: entry.metadataChangeNotifier,
builder: (context, child) { 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( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
InfoRowGroup( _BasicInfo(entry: entry),
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),
_buildChips(context), _buildChips(context),
_buildEditButtons(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<String?> _ownerPackageLoader = SynchronousFuture(null);
Future<void> _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<ValueNotifier<AppMode>>().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<MediaQueryData, bool>((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<String?>(
future: _ownerPackageLoader,
builder: (context, snapshot) {
final ownerPackage = snapshot.data;
return FutureBuilder<void>(
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<String, String> _buildVideoRows(BuildContext context) { Map<String, String> _buildVideoRows(BuildContext context) {
return { return {
context.l10n.viewerInfoLabelDuration: entry.durationText, 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,
),
];
}
} }

View file

@ -43,7 +43,7 @@ class SectionRow extends StatelessWidget {
class InfoRowGroup extends StatefulWidget { class InfoRowGroup extends StatefulWidget {
final Map<String, String> info; final Map<String, String> info;
final int maxValueLength; final int maxValueLength;
final Map<String, InfoLinkHandler>? linkHandlers; final Map<String, InfoValueSpanBuilder> spanBuilders;
static const keyValuePadding = 16; static const keyValuePadding = 16;
static const fontSize = 13.0; static const fontSize = 13.0;
@ -56,11 +56,28 @@ class InfoRowGroup extends StatefulWidget {
super.key, super.key,
required this.info, required this.info,
this.maxValueLength = 0, this.maxValueLength = 0,
this.linkHandlers, Map<String, InfoValueSpanBuilder>? spanBuilders,
}); }) : spanBuilders = spanBuilders ?? const {};
@override @override
State<InfoRowGroup> createState() => _InfoRowGroupState(); State<InfoRowGroup> 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<InfoRowGroup> { class _InfoRowGroupState extends State<InfoRowGroup> {
@ -70,7 +87,7 @@ class _InfoRowGroupState extends State<InfoRowGroup> {
int get maxValueLength => widget.maxValueLength; int get maxValueLength => widget.maxValueLength;
Map<String, InfoLinkHandler>? get linkHandlers => widget.linkHandlers; Map<String, InfoValueSpanBuilder> get spanBuilders => widget.spanBuilders;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -94,34 +111,8 @@ class _InfoRowGroupState extends State<InfoRowGroup> {
children: keyValues.entries.expand( children: keyValues.entries.expand(
(kv) { (kv) {
final key = kv.key; final key = kv.key;
String value; final value = kv.value;
TextStyle? style; final spanBuilder = spanBuilders[key] ?? _buildTextValueSpans;
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 thisSpaceSize = max(0.0, (baseValueX - keySizes[key]!)) + InfoRowGroup.keyValuePadding; final thisSpaceSize = max(0.0, (baseValueX - keySizes[key]!)) + InfoRowGroup.keyValuePadding;
// each text span embeds and pops a Bidi isolate, // each text span embeds and pops a Bidi isolate,
@ -138,7 +129,8 @@ class _InfoRowGroupState extends State<InfoRowGroup> {
child: const Text(''), 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(), ).toList(),
@ -157,14 +149,20 @@ class _InfoRowGroupState extends State<InfoRowGroup> {
)..layout(const BoxConstraints(), parentUsesSize: true); )..layout(const BoxConstraints(), parentUsesSize: true);
return para.getMaxIntrinsicWidth(double.infinity); return para.getMaxIntrinsicWidth(double.infinity);
} }
List<InlineSpan> _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 { typedef InfoValueSpanBuilder = List<InlineSpan> Function(BuildContext context, String key, String value);
final String Function(BuildContext context) linkText;
final void Function(BuildContext context) onTap;
const InfoLinkHandler({
required this.linkText,
required this.onTap,
});
}

View file

@ -50,7 +50,7 @@ class MetadataDirTile extends StatelessWidget {
initiallyExpanded: initiallyExpanded, initiallyExpanded: initiallyExpanded,
); );
} else { } else {
Map<String, InfoLinkHandler>? linkHandlers; Map<String, InfoValueSpanBuilder>? linkHandlers;
switch (dirName) { switch (dirName) {
case SvgMetadataService.metadataDirectory: case SvgMetadataService.metadataDirectory:
linkHandlers = getSvgLinkHandlers(tags); linkHandlers = getSvgLinkHandlers(tags);
@ -79,7 +79,7 @@ class MetadataDirTile extends StatelessWidget {
child: InfoRowGroup( child: InfoRowGroup(
info: tags, info: tags,
maxValueLength: Constants.infoGroupMaxValueLength, maxValueLength: Constants.infoGroupMaxValueLength,
linkHandlers: linkHandlers, spanBuilders: linkHandlers,
), ),
), ),
], ],
@ -87,9 +87,9 @@ class MetadataDirTile extends StatelessWidget {
} }
} }
static Map<String, InfoLinkHandler> getSvgLinkHandlers(SplayTreeMap<String, String> tags) { static Map<String, InfoValueSpanBuilder> getSvgLinkHandlers(SplayTreeMap<String, String> tags) {
return { return {
'Metadata': InfoLinkHandler( 'Metadata': InfoRowGroup.linkSpanBuilder(
linkText: (context) => context.l10n.viewerInfoViewXmlLinkText, linkText: (context) => context.l10n.viewerInfoViewXmlLinkText,
onTap: (context) { onTap: (context) {
Navigator.push( Navigator.push(
@ -106,9 +106,9 @@ class MetadataDirTile extends StatelessWidget {
}; };
} }
static Map<String, InfoLinkHandler> getVideoCoverLinkHandlers(SplayTreeMap<String, String> tags) { static Map<String, InfoValueSpanBuilder> getVideoCoverLinkHandlers(SplayTreeMap<String, String> tags) {
return { return {
'Image': InfoLinkHandler( 'Image': InfoRowGroup.linkSpanBuilder(
linkText: (context) => context.l10n.viewerInfoOpenLinkText, linkText: (context) => context.l10n.viewerInfoOpenLinkText,
onTap: (context) => OpenEmbeddedDataNotification.videoCover().dispatch(context), onTap: (context) => OpenEmbeddedDataNotification.videoCover().dispatch(context),
), ),

View file

@ -145,7 +145,7 @@ class XmpNamespace extends Equatable {
InfoRowGroup( InfoRowGroup(
info: Map.fromEntries(props.map((prop) => MapEntry(prop.displayKey, formatValue(prop)))), info: Map.fromEntries(props.map((prop) => MapEntry(prop.displayKey, formatValue(prop)))),
maxValueLength: Constants.infoGroupMaxValueLength, maxValueLength: Constants.infoGroupMaxValueLength,
linkHandlers: linkifyValues(props), spanBuilders: linkifyValues(props),
), ),
...buildFromExtractedData(), ...buildFromExtractedData(),
]; ];
@ -194,7 +194,7 @@ class XmpNamespace extends Equatable {
String formatValue(XmpProp prop) => prop.value; String formatValue(XmpProp prop) => prop.value;
Map<String, InfoLinkHandler> linkifyValues(List<XmpProp> props) => {}; Map<String, InfoValueSpanBuilder> linkifyValues(List<XmpProp> props) => {};
} }
class XmpProp { class XmpProp {

View file

@ -13,7 +13,7 @@ abstract class XmpGoogleNamespace extends XmpNamespace {
List<Tuple2<String, String>> get dataProps; List<Tuple2<String, String>> get dataProps;
@override @override
Map<String, InfoLinkHandler> linkifyValues(List<XmpProp> props) { Map<String, InfoValueSpanBuilder> linkifyValues(List<XmpProp> props) {
return Map.fromEntries(dataProps.map((t) { return Map.fromEntries(dataProps.map((t) {
final dataPropPath = t.item1; final dataPropPath = t.item1;
final mimePropPath = t.item2; final mimePropPath = t.item2;
@ -22,7 +22,7 @@ abstract class XmpGoogleNamespace extends XmpNamespace {
return (dataProp != null && mimeProp != null) return (dataProp != null && mimeProp != null)
? MapEntry( ? MapEntry(
dataProp.displayKey, dataProp.displayKey,
InfoLinkHandler( InfoRowGroup.linkSpanBuilder(
linkText: (context) => context.l10n.viewerInfoOpenLinkText, linkText: (context) => context.l10n.viewerInfoOpenLinkText,
onTap: (context) => OpenEmbeddedDataNotification.xmp( onTap: (context) => OpenEmbeddedDataNotification.xmp(
propPath: dataProp.path, propPath: dataProp.path,

View file

@ -29,7 +29,7 @@ class XmpBasicNamespace extends XmpNamespace {
final struct = thumbnails[index]!; final struct = thumbnails[index]!;
return { return {
if (struct.containsKey(thumbnailDataDisplayKey)) if (struct.containsKey(thumbnailDataDisplayKey))
thumbnailDataDisplayKey: InfoLinkHandler( thumbnailDataDisplayKey: InfoRowGroup.linkSpanBuilder(
linkText: (context) => context.l10n.viewerInfoOpenLinkText, linkText: (context) => context.l10n.viewerInfoOpenLinkText,
onTap: (context) => OpenEmbeddedDataNotification.xmp( onTap: (context) => OpenEmbeddedDataNotification.xmp(
propPath: 'xmp:Thumbnails[$index]/xmpGImg:image', propPath: 'xmp:Thumbnails[$index]/xmpGImg:image',

View file

@ -13,7 +13,7 @@ import 'package:flutter/material.dart';
class XmpStructArrayCard extends StatefulWidget { class XmpStructArrayCard extends StatefulWidget {
final String title; final String title;
late final List<Map<String, String>> structs; late final List<Map<String, String>> structs;
final Map<String, InfoLinkHandler> Function(int index)? linkifier; final Map<String, InfoValueSpanBuilder> Function(int index)? linkifier;
XmpStructArrayCard({ XmpStructArrayCard({
super.key, super.key,
@ -95,7 +95,7 @@ class _XmpStructArrayCardState extends State<XmpStructArrayCard> {
child: InfoRowGroup( child: InfoRowGroup(
info: structs[_index], info: structs[_index],
maxValueLength: Constants.infoGroupMaxValueLength, maxValueLength: Constants.infoGroupMaxValueLength,
linkHandlers: widget.linkifier?.call(_index + 1), spanBuilders: widget.linkifier?.call(_index + 1),
), ),
), ),
), ),
@ -108,7 +108,7 @@ class _XmpStructArrayCardState extends State<XmpStructArrayCard> {
class XmpStructCard extends StatelessWidget { class XmpStructCard extends StatelessWidget {
final String title; final String title;
final Map<String, String> struct; final Map<String, String> struct;
final Map<String, InfoLinkHandler> Function()? linkifier; final Map<String, InfoValueSpanBuilder> Function()? linkifier;
static const cardMargin = EdgeInsets.symmetric(vertical: 8, horizontal: 0); static const cardMargin = EdgeInsets.symmetric(vertical: 8, horizontal: 0);
@ -137,7 +137,7 @@ class XmpStructCard extends StatelessWidget {
InfoRowGroup( InfoRowGroup(
info: struct, info: struct,
maxValueLength: Constants.infoGroupMaxValueLength, maxValueLength: Constants.infoGroupMaxValueLength,
linkHandlers: linkifier?.call(), spanBuilders: linkifier?.call(),
), ),
], ],
), ),

View file

@ -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<OwnerProp> createState() => _OwnerPropState();
}
class _OwnerPropState extends State<OwnerProp> {
Future<String?> _ownerPackageLoader = SynchronousFuture(null);
Future<void> _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<ValueNotifier<AppMode>>().value == AppMode.view;
if (isViewerMode && settings.isInstalledAppAccessAllowed) {
_appNameLoader = androidFileUtils.initAppNames();
}
}
}
@override
Widget build(BuildContext context) {
return FutureBuilder<String?>(
future: _ownerPackageLoader,
builder: (context, snapshot) {
final ownerPackage = snapshot.data;
if (ownerPackage == null) return const SizedBox();
return FutureBuilder<void>(
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,
),
],
),
);
},
);
},
);
}
}