From 69349e2b2cb315133fdeae88684c9c7aee4280b7 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Wed, 30 Dec 2020 12:47:27 +0900 Subject: [PATCH] info: metadata search --- lib/widgets/collection/app_bar.dart | 4 +- lib/widgets/common/basic/query_bar.dart | 79 +++++++++++ .../common/identity/aves_expansion_tile.dart | 9 +- lib/widgets/filter_grids/album_pick.dart | 74 +--------- .../filter_grids/common/filter_nav_page.dart | 4 +- lib/widgets/fullscreen/info/info_app_bar.dart | 53 ++++++++ lib/widgets/fullscreen/info/info_page.dart | 32 ++--- lib/widgets/fullscreen/info/info_search.dart | 109 +++++++++++++++ .../info/metadata/metadata_dir_tile.dart | 113 ++++++++++++++++ .../info/metadata/metadata_section.dart | 127 +++++------------- .../fullscreen/info/metadata/xmp_structs.dart | 2 +- .../fullscreen/info/metadata/xmp_tile.dart | 3 + lib/widgets/home_page.dart | 2 +- lib/widgets/search/search_button.dart | 6 +- lib/widgets/search/search_delegate.dart | 6 +- lib/widgets/search/search_page.dart | 4 +- 16 files changed, 431 insertions(+), 196 deletions(-) create mode 100644 lib/widgets/common/basic/query_bar.dart create mode 100644 lib/widgets/fullscreen/info/info_app_bar.dart create mode 100644 lib/widgets/fullscreen/info/info_search.dart create mode 100644 lib/widgets/fullscreen/info/metadata/metadata_dir_tile.dart diff --git a/lib/widgets/collection/app_bar.dart b/lib/widgets/collection/app_bar.dart index ff4601a4c..2beeb4360 100644 --- a/lib/widgets/collection/app_bar.dart +++ b/lib/widgets/collection/app_bar.dart @@ -172,7 +172,7 @@ class _CollectionAppBarState extends State with SingleTickerPr List _buildActions() { return [ if (collection.isBrowsing) - SearchButton( + CollectionSearchButton( source, parentCollection: collection, ), @@ -361,7 +361,7 @@ class _CollectionAppBarState extends State with SingleTickerPr Navigator.push( context, SearchPageRoute( - delegate: ImageSearchDelegate( + delegate: CollectionSearchDelegate( source: collection.source, parentCollection: collection, ), diff --git a/lib/widgets/common/basic/query_bar.dart b/lib/widgets/common/basic/query_bar.dart new file mode 100644 index 000000000..570e4589f --- /dev/null +++ b/lib/widgets/common/basic/query_bar.dart @@ -0,0 +1,79 @@ +import 'package:aves/theme/durations.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/utils/debouncer.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; + +class QueryBar extends StatefulWidget { + final ValueNotifier filterNotifier; + + const QueryBar({@required this.filterNotifier}); + + @override + _QueryBarState createState() => _QueryBarState(); +} + +class _QueryBarState extends State { + final Debouncer _debouncer = Debouncer(delay: Durations.searchDebounceDelay); + TextEditingController _controller; + + ValueNotifier get filterNotifier => widget.filterNotifier; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: filterNotifier.value); + } + + @override + Widget build(BuildContext context) { + final clearButton = IconButton( + icon: Icon(AIcons.clear), + onPressed: () { + _controller.clear(); + filterNotifier.value = ''; + }, + tooltip: 'Clear', + ); + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: TextField( + controller: _controller, + decoration: InputDecoration( + icon: Padding( + padding: EdgeInsetsDirectional.only(start: 16), + child: Icon(AIcons.search), + ), + hintText: MaterialLocalizations.of(context).searchFieldLabel, + hintStyle: Theme.of(context).inputDecorationTheme.hintStyle, + ), + textInputAction: TextInputAction.search, + onChanged: (s) => _debouncer(() => filterNotifier.value = s), + ), + ), + ConstrainedBox( + constraints: BoxConstraints(minWidth: 16), + child: ValueListenableBuilder( + valueListenable: _controller, + builder: (context, value, child) => AnimatedSwitcher( + duration: Durations.appBarActionChangeAnimation, + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: SizeTransition( + axis: Axis.horizontal, + sizeFactor: animation, + child: child, + ), + ), + child: value.text.isNotEmpty ? clearButton : SizedBox.shrink(), + ), + ), + ) + ], + ); + } +} diff --git a/lib/widgets/common/identity/aves_expansion_tile.dart b/lib/widgets/common/identity/aves_expansion_tile.dart index 554d02e9b..e82b64955 100644 --- a/lib/widgets/common/identity/aves_expansion_tile.dart +++ b/lib/widgets/common/identity/aves_expansion_tile.dart @@ -5,13 +5,15 @@ import 'package:flutter/material.dart'; class AvesExpansionTile extends StatelessWidget { final String title; final Color color; - final List children; final ValueNotifier expandedNotifier; + final bool initiallyExpanded; + final List children; const AvesExpansionTile({ @required this.title, this.color, this.expandedNotifier, + this.initiallyExpanded = false, @required this.children, }); @@ -33,6 +35,9 @@ class AvesExpansionTile extends StatelessWidget { enabled: enabled, ), expandable: enabled, + initiallyExpanded: initiallyExpanded, + baseColor: Colors.grey[900], + expandedColor: Colors.grey[850], child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -41,8 +46,6 @@ class AvesExpansionTile extends StatelessWidget { if (enabled) ...children, ], ), - baseColor: Colors.grey[900], - expandedColor: Colors.grey[850], ), ); } diff --git a/lib/widgets/filter_grids/album_pick.dart b/lib/widgets/filter_grids/album_pick.dart index c1ce9da15..965d9841e 100644 --- a/lib/widgets/filter_grids/album_pick.dart +++ b/lib/widgets/filter_grids/album_pick.dart @@ -3,10 +3,9 @@ import 'package:aves/model/filters/album.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/enums.dart'; -import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; -import 'package:aves/utils/debouncer.dart'; import 'package:aves/widgets/collection/empty.dart'; +import 'package:aves/widgets/common/basic/query_bar.dart'; import 'package:aves/widgets/dialogs/create_album_dialog.dart'; import 'package:aves/widgets/filter_grids/albums_page.dart'; import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart'; @@ -116,84 +115,25 @@ class AlbumPickAppBar extends StatelessWidget { } } -class AlbumFilterBar extends StatefulWidget implements PreferredSizeWidget { +class AlbumFilterBar extends StatelessWidget implements PreferredSizeWidget { final ValueNotifier filterNotifier; static const preferredHeight = kToolbarHeight; - const AlbumFilterBar({@required this.filterNotifier}); + const AlbumFilterBar({ + @required this.filterNotifier, + }); @override Size get preferredSize => Size.fromHeight(preferredHeight); - @override - _AlbumFilterBarState createState() => _AlbumFilterBarState(); -} - -class _AlbumFilterBarState extends State { - final Debouncer _debouncer = Debouncer(delay: Durations.searchDebounceDelay); - TextEditingController _controller; - - ValueNotifier get filterNotifier => widget.filterNotifier; - - @override - void initState() { - super.initState(); - _controller = TextEditingController(text: filterNotifier.value); - } - @override Widget build(BuildContext context) { - final clearButton = IconButton( - icon: Icon(AIcons.clear), - onPressed: () { - _controller.clear(); - filterNotifier.value = ''; - }, - tooltip: 'Clear', - ); return Container( height: AlbumFilterBar.preferredHeight, alignment: Alignment.topCenter, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Icon(AIcons.search), - Expanded( - child: TextField( - controller: _controller, - decoration: InputDecoration( - icon: Padding( - padding: EdgeInsetsDirectional.only(start: 16), - child: Icon(AIcons.search), - ), - // border: OutlineInputBorder(), - hintText: MaterialLocalizations.of(context).searchFieldLabel, - hintStyle: Theme.of(context).inputDecorationTheme.hintStyle, - ), - textInputAction: TextInputAction.search, - onChanged: (s) => _debouncer(() => filterNotifier.value = s), - ), - ), - ConstrainedBox( - constraints: BoxConstraints(minWidth: 16), - child: ValueListenableBuilder( - valueListenable: _controller, - builder: (context, value, child) => AnimatedSwitcher( - duration: Durations.appBarActionChangeAnimation, - transitionBuilder: (child, animation) => FadeTransition( - opacity: animation, - child: SizeTransition( - axis: Axis.horizontal, - sizeFactor: animation, - child: child, - ), - ), - child: value.text.isNotEmpty ? clearButton : SizedBox.shrink(), - ), - ), - ) - ], + child: QueryBar( + filterNotifier: filterNotifier, ), ); } diff --git a/lib/widgets/filter_grids/common/filter_nav_page.dart b/lib/widgets/filter_grids/common/filter_nav_page.dart index 6640f8d89..525a5bc5d 100644 --- a/lib/widgets/filter_grids/common/filter_nav_page.dart +++ b/lib/widgets/filter_grids/common/filter_nav_page.dart @@ -104,7 +104,7 @@ class FilterNavigationPage extends StatelessWidget { List _buildActions(BuildContext context) { return [ - SearchButton(source), + CollectionSearchButton(source), PopupMenuButton( key: Key('appbar-menu-button'), itemBuilder: (context) { @@ -137,7 +137,7 @@ class FilterNavigationPage extends StatelessWidget { Navigator.push( context, SearchPageRoute( - delegate: ImageSearchDelegate( + delegate: CollectionSearchDelegate( source: source, ), )); diff --git a/lib/widgets/fullscreen/info/info_app_bar.dart b/lib/widgets/fullscreen/info/info_app_bar.dart new file mode 100644 index 000000000..a993b40cb --- /dev/null +++ b/lib/widgets/fullscreen/info/info_app_bar.dart @@ -0,0 +1,53 @@ +import 'package:aves/model/image_entry.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/common/app_bar_title.dart'; +import 'package:aves/widgets/fullscreen/info/info_search.dart'; +import 'package:aves/widgets/fullscreen/info/metadata/metadata_section.dart'; +import 'package:flutter/material.dart'; + +class InfoAppBar extends StatelessWidget { + final ImageEntry entry; + final ValueNotifier> metadataNotifier; + final VoidCallback onBackPressed; + + const InfoAppBar({ + @required this.entry, + @required this.metadataNotifier, + @required this.onBackPressed, + }); + + @override + Widget build(BuildContext context) { + return SliverAppBar( + leading: IconButton( + key: Key('back-button'), + icon: Icon(AIcons.goUp), + onPressed: onBackPressed, + tooltip: 'Back to viewer', + ), + title: TappableAppBarTitle( + onTap: () => _goToSearch(context), + child: Text('Info'), + ), + actions: [ + IconButton( + icon: Icon(AIcons.search), + onPressed: () => _goToSearch(context), + tooltip: 'Search', + ), + ], + titleSpacing: 0, + floating: true, + ); + } + + void _goToSearch(BuildContext context) { + showSearch( + context: context, + delegate: InfoSearchDelegate( + entry: entry, + metadataNotifier: metadataNotifier, + ), + ); + } +} diff --git a/lib/widgets/fullscreen/info/info_page.dart b/lib/widgets/fullscreen/info/info_page.dart index bf9f10780..8117cdea2 100644 --- a/lib/widgets/fullscreen/info/info_page.dart +++ b/lib/widgets/fullscreen/info/info_page.dart @@ -2,9 +2,9 @@ import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/image_entry.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/theme/durations.dart'; -import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/fullscreen/info/basic_section.dart'; +import 'package:aves/widgets/fullscreen/info/info_app_bar.dart'; import 'package:aves/widgets/fullscreen/info/location_section.dart'; import 'package:aves/widgets/fullscreen/info/metadata/metadata_section.dart'; import 'package:aves/widgets/fullscreen/info/notifications.dart'; @@ -38,17 +38,6 @@ class InfoPageState extends State { @override Widget build(BuildContext context) { - final appBar = SliverAppBar( - leading: IconButton( - key: Key('back-button'), - icon: Icon(AIcons.goUp), - onPressed: _goToImage, - tooltip: 'Back to viewer', - ), - title: Text('Info'), - floating: true, - ); - return MediaQueryDataProvider( child: Scaffold( body: SafeArea( @@ -68,9 +57,9 @@ class InfoPageState extends State { entry: entry, visibleNotifier: widget.visibleNotifier, scrollController: _scrollController, - appBar: appBar, split: mqWidth > 400, mqViewInsetsBottom: mqViewInsetsBottom, + goToViewer: _goToViewer, ) : SizedBox.shrink(); }, @@ -97,7 +86,7 @@ class InfoPageState extends State { _scrollStartFromTop = false; } else if (notification is OverscrollNotification) { if (notification.overscroll < 0) { - _goToImage(); + _goToViewer(); _scrollStartFromTop = false; } } @@ -106,7 +95,7 @@ class InfoPageState extends State { return false; } - void _goToImage() { + void _goToViewer() { BackUpNotification().dispatch(context); _scrollController.animateTo( 0, @@ -121,9 +110,9 @@ class _InfoPageContent extends StatefulWidget { final ImageEntry entry; final ValueNotifier visibleNotifier; final ScrollController scrollController; - final SliverAppBar appBar; final bool split; final double mqViewInsetsBottom; + final VoidCallback goToViewer; const _InfoPageContent({ Key key, @@ -131,9 +120,9 @@ class _InfoPageContent extends StatefulWidget { @required this.entry, @required this.visibleNotifier, @required this.scrollController, - @required this.appBar, @required this.split, @required this.mqViewInsetsBottom, + @required this.goToViewer, }) : super(key: key); @override @@ -143,6 +132,8 @@ class _InfoPageContent extends StatefulWidget { class _InfoPageContentState extends State<_InfoPageContent> { static const horizontalPadding = EdgeInsets.symmetric(horizontal: 8); + final ValueNotifier> _metadataNotifier = ValueNotifier({}); + CollectionLens get collection => widget.collection; ImageEntry get entry => widget.entry; @@ -178,13 +169,18 @@ class _InfoPageContentState extends State<_InfoPageContent> { ); final metadataSliver = MetadataSectionSliver( entry: entry, + metadataNotifier: _metadataNotifier, visibleNotifier: widget.visibleNotifier, ); return CustomScrollView( controller: widget.scrollController, slivers: [ - widget.appBar, + InfoAppBar( + entry: entry, + metadataNotifier: _metadataNotifier, + onBackPressed: widget.goToViewer, + ), SliverPadding( padding: horizontalPadding + EdgeInsets.only(top: 8), sliver: basicAndLocationSliver, diff --git a/lib/widgets/fullscreen/info/info_search.dart b/lib/widgets/fullscreen/info/info_search.dart new file mode 100644 index 000000000..6533af12d --- /dev/null +++ b/lib/widgets/fullscreen/info/info_search.dart @@ -0,0 +1,109 @@ +import 'package:aves/model/image_entry.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/collection/empty.dart'; +import 'package:aves/widgets/fullscreen/info/metadata/metadata_dir_tile.dart'; +import 'package:aves/widgets/fullscreen/info/metadata/metadata_section.dart'; +import 'package:flutter/material.dart'; + +class InfoSearchDelegate extends SearchDelegate { + final ImageEntry entry; + final ValueNotifier> metadataNotifier; + + Map get metadata => metadataNotifier.value; + + static const suggestions = { + 'Date & time': 'date or time or when', + 'Description': 'abtract or description or comment', + 'Dimensions': 'width or height or dimension or framesize', + 'Resolution': 'resolution', + 'Rights': 'rights or copyright or artist or creator or by-line or credit', + }; + + InfoSearchDelegate({ + @required this.entry, + @required this.metadataNotifier, + }) : super( + searchFieldLabel: 'Search metadata', + ); + + @override + ThemeData appBarTheme(BuildContext context) { + return Theme.of(context); + } + + @override + Widget buildLeading(BuildContext context) { + return IconButton( + icon: AnimatedIcon( + icon: AnimatedIcons.menu_arrow, + progress: transitionAnimation, + ), + onPressed: () => Navigator.pop(context), + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + ); + } + + @override + List buildActions(BuildContext context) { + return [ + if (query.isNotEmpty) + IconButton( + icon: Icon(AIcons.clear), + onPressed: () { + query = ''; + showSuggestions(context); + }, + tooltip: 'Clear', + ), + ]; + } + + @override + Widget buildSuggestions(BuildContext context) => ListView( + children: suggestions.entries + .map((kv) => ListTile( + title: Text(kv.key), + onTap: () { + query = kv.value; + showResults(context); + }, + )) + .toList(), + ); + + @override + Widget buildResults(BuildContext context) { + if (query.isEmpty) { + showSuggestions(context); + return SizedBox(); + } + + final effectiveQueries = query.toUpperCase().split(' OR ').map((query) => query.trim()); + bool testKey(String key) => effectiveQueries.any(key.toUpperCase().contains); + final filteredMetadata = Map.fromEntries(metadata.entries.map((kv) { + final filteredDir = kv.value.filterKeys(testKey); + return MapEntry(kv.key, filteredDir); + })); + + final tiles = filteredMetadata.entries + .where((kv) => kv.value.tags.isNotEmpty) + .map((kv) => MetadataDirTile( + entry: entry, + title: kv.key, + dir: kv.value, + initiallyExpanded: true, + showPrefixChildren: false, + )) + .toList(); + return tiles.isEmpty + ? EmptyContent( + icon: AIcons.info, + text: 'No matching keys', + ) + : ListView.builder( + padding: EdgeInsets.all(8), + itemBuilder: (context, index) => tiles[index], + itemCount: tiles.length, + ); + } +} diff --git a/lib/widgets/fullscreen/info/metadata/metadata_dir_tile.dart b/lib/widgets/fullscreen/info/metadata/metadata_dir_tile.dart new file mode 100644 index 000000000..af980220f --- /dev/null +++ b/lib/widgets/fullscreen/info/metadata/metadata_dir_tile.dart @@ -0,0 +1,113 @@ +import 'dart:collection'; + +import 'package:aves/model/image_entry.dart'; +import 'package:aves/ref/brand_colors.dart'; +import 'package:aves/services/svg_metadata_service.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/utils/color_utils.dart'; +import 'package:aves/utils/constants.dart'; +import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; +import 'package:aves/widgets/fullscreen/info/common.dart'; +import 'package:aves/widgets/fullscreen/info/metadata/metadata_section.dart'; +import 'package:aves/widgets/fullscreen/info/metadata/metadata_thumbnail.dart'; +import 'package:aves/widgets/fullscreen/info/metadata/xmp_tile.dart'; +import 'package:aves/widgets/fullscreen/source_viewer_page.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class MetadataDirTile extends StatelessWidget { + final ImageEntry entry; + final String title; + final MetadataDirectory dir; + final ValueNotifier expandedDirectoryNotifier; + final bool initiallyExpanded, showPrefixChildren; + + const MetadataDirTile({ + @required this.entry, + @required this.title, + @required this.dir, + this.expandedDirectoryNotifier, + this.initiallyExpanded = false, + this.showPrefixChildren = true, + }); + + @override + Widget build(BuildContext context) { + final tags = dir.tags; + if (tags.isEmpty) return SizedBox.shrink(); + + final dirName = dir.name; + if (dirName == MetadataDirectory.xmpDirectory) { + return XmpDirTile( + entry: entry, + tags: tags, + expandedNotifier: expandedDirectoryNotifier, + initiallyExpanded: initiallyExpanded, + ); + } + + Widget thumbnail; + final prefixChildren = []; + if (showPrefixChildren) { + switch (dirName) { + case MetadataDirectory.exifThumbnailDirectory: + thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.exif, entry: entry); + break; + case MetadataDirectory.mediaDirectory: + thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.embedded, entry: entry); + Widget builder(IconData data) => Padding( + padding: EdgeInsets.symmetric(vertical: 4, horizontal: 8), + child: Icon(data), + ); + if (tags['Has Video'] == 'yes') prefixChildren.add(builder(AIcons.video)); + if (tags['Has Audio'] == 'yes') prefixChildren.add(builder(AIcons.audio)); + if (tags['Has Image'] == 'yes') { + int count; + if (tags.containsKey('Image Count')) { + count = int.tryParse(tags['Image Count']); + } + prefixChildren.addAll(List.generate(count ?? 1, (i) => builder(AIcons.image))); + } + break; + } + } + + return AvesExpansionTile( + title: title, + color: BrandColors.get(dirName) ?? stringToColor(dirName), + expandedNotifier: expandedDirectoryNotifier, + initiallyExpanded: initiallyExpanded, + children: [ + if (prefixChildren.isNotEmpty) Wrap(children: prefixChildren), + if (thumbnail != null) thumbnail, + Padding( + padding: EdgeInsets.only(left: 8, right: 8, bottom: 8), + child: InfoRowGroup( + tags, + maxValueLength: Constants.infoGroupMaxValueLength, + linkHandlers: dirName == SvgMetadataService.metadataDirectory ? getSvgLinkHandlers(tags) : null, + ), + ), + ], + ); + } + + static Map getSvgLinkHandlers(SplayTreeMap tags) { + return { + 'Metadata': InfoLinkHandler( + linkText: 'View XML', + onTap: (context) { + Navigator.push( + context, + MaterialPageRoute( + settings: RouteSettings(name: SourceViewerPage.routeName), + builder: (context) => SourceViewerPage( + loader: () => SynchronousFuture(tags['Metadata']), + ), + ), + ); + }, + ), + }; + } +} diff --git a/lib/widgets/fullscreen/info/metadata/metadata_section.dart b/lib/widgets/fullscreen/info/metadata/metadata_section.dart index e12c8822d..96a6475b5 100644 --- a/lib/widgets/fullscreen/info/metadata/metadata_section.dart +++ b/lib/widgets/fullscreen/info/metadata/metadata_section.dart @@ -1,18 +1,12 @@ import 'dart:collection'; import 'package:aves/model/image_entry.dart'; -import 'package:aves/ref/brand_colors.dart'; import 'package:aves/services/metadata_service.dart'; import 'package:aves/services/svg_metadata_service.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; -import 'package:aves/utils/color_utils.dart'; -import 'package:aves/utils/constants.dart'; -import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/fullscreen/info/common.dart'; -import 'package:aves/widgets/fullscreen/info/metadata/metadata_thumbnail.dart'; -import 'package:aves/widgets/fullscreen/info/metadata/xmp_tile.dart'; -import 'package:aves/widgets/fullscreen/source_viewer_page.dart'; +import 'package:aves/widgets/fullscreen/info/metadata/metadata_dir_tile.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -21,10 +15,12 @@ import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; class MetadataSectionSliver extends StatefulWidget { final ImageEntry entry; final ValueNotifier visibleNotifier; + final ValueNotifier> metadataNotifier; const MetadataSectionSliver({ @required this.entry, @required this.visibleNotifier, + @required this.metadataNotifier, }); @override @@ -32,7 +28,6 @@ class MetadataSectionSliver extends StatefulWidget { } class _MetadataSectionSliverState extends State with AutomaticKeepAliveClientMixin { - Map _metadata = {}; final ValueNotifier _loadedMetadataUri = ValueNotifier(null); final ValueNotifier _expandedDirectoryNotifier = ValueNotifier(null); @@ -40,10 +35,9 @@ class _MetadataSectionSliverState extends State with Auto bool get isVisible => widget.visibleNotifier.value; - // special directory names - static const exifThumbnailDirectory = 'Exif Thumbnail'; // from metadata-extractor - static const xmpDirectory = 'XMP'; // from metadata-extractor - static const mediaDirectory = 'Media'; // additional media (video/audio/images) directory + ValueNotifier> get metadataNotifier => widget.metadataNotifier; + + Map get metadata => metadataNotifier.value; // directory names may contain the name of their parent directory // if so, they are separated by this character @@ -53,6 +47,7 @@ class _MetadataSectionSliverState extends State with Auto void initState() { super.initState(); _registerWidget(widget); + metadataNotifier.value = {}; _getMetadata(); } @@ -96,7 +91,7 @@ class _MetadataSectionSliverState extends State with Auto valueListenable: _loadedMetadataUri, builder: (context, uri, child) { Widget content; - if (_metadata.isEmpty) { + if (metadata.isEmpty) { content = SizedBox.shrink(); } else { content = Column( @@ -111,7 +106,12 @@ class _MetadataSectionSliverState extends State with Auto ), children: [ SectionRow(AIcons.info), - ..._metadata.entries.map((kv) => _buildDirTile(kv.key, kv.value)), + ...metadata.entries.map((kv) => MetadataDirTile( + entry: entry, + title: kv.key, + dir: kv.value, + expandedDirectoryNotifier: _expandedDirectoryNotifier, + )), ], ), ); @@ -128,64 +128,9 @@ class _MetadataSectionSliverState extends State with Auto ); } - Widget _buildDirTile(String title, _MetadataDirectory dir) { - if (dir.tags.isEmpty) return SizedBox.shrink(); - - final dirName = dir.name; - if (dirName == xmpDirectory) { - return XmpDirTile( - entry: entry, - tags: dir.tags, - expandedNotifier: _expandedDirectoryNotifier, - ); - } - - Widget thumbnail; - final prefixChildren = []; - switch (dirName) { - case exifThumbnailDirectory: - thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.exif, entry: entry); - break; - case mediaDirectory: - thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.embedded, entry: entry); - Widget builder(IconData data) => Padding( - padding: EdgeInsets.symmetric(vertical: 4, horizontal: 8), - child: Icon(data), - ); - if (dir.tags['Has Video'] == 'yes') prefixChildren.add(builder(AIcons.video)); - if (dir.tags['Has Audio'] == 'yes') prefixChildren.add(builder(AIcons.audio)); - if (dir.tags['Has Image'] == 'yes') { - int count; - if (dir.tags.containsKey('Image Count')) { - count = int.tryParse(dir.tags['Image Count']); - } - prefixChildren.addAll(List.generate(count ?? 1, (i) => builder(AIcons.image))); - } - break; - } - - return AvesExpansionTile( - title: title, - color: BrandColors.get(dirName) ?? stringToColor(dirName), - expandedNotifier: _expandedDirectoryNotifier, - children: [ - if (prefixChildren.isNotEmpty) Wrap(children: prefixChildren), - if (thumbnail != null) thumbnail, - Padding( - padding: EdgeInsets.only(left: 8, right: 8, bottom: 8), - child: InfoRowGroup( - dir.tags, - maxValueLength: Constants.infoGroupMaxValueLength, - linkHandlers: dirName == SvgMetadataService.metadataDirectory ? getSvgLinkHandlers(dir.tags) : null, - ), - ), - ], - ); - } - void _onMetadataChanged() { _loadedMetadataUri.value = null; - _metadata = {}; + metadataNotifier.value = {}; _getMetadata(); } @@ -211,7 +156,7 @@ class _MetadataSectionSliverState extends State with Auto final tagName = tagKV.key as String ?? ''; return MapEntry(tagName, value); }).where((kv) => kv != null))); - return _MetadataDirectory(directoryName, parent, tags); + return MetadataDirectory(directoryName, parent, tags); }).toList(); final titledDirectories = directories.map((dir) { @@ -222,42 +167,36 @@ class _MetadataSectionSliverState extends State with Auto return MapEntry(title, dir); }).toList() ..sort((a, b) => compareAsciiUpperCase(a.key, b.key)); - _metadata = Map.fromEntries(titledDirectories); + metadataNotifier.value = Map.fromEntries(titledDirectories); _loadedMetadataUri.value = entry.uri; } else { - _metadata = {}; + metadataNotifier.value = {}; _loadedMetadataUri.value = null; } _expandedDirectoryNotifier.value = null; } - static Map getSvgLinkHandlers(SplayTreeMap tags) { - return { - 'Metadata': InfoLinkHandler( - linkText: 'View XML', - onTap: (context) { - Navigator.push( - context, - MaterialPageRoute( - settings: RouteSettings(name: SourceViewerPage.routeName), - builder: (context) => SourceViewerPage( - loader: () => SynchronousFuture(tags['Metadata']), - ), - ), - ); - }, - ), - }; - } - @override bool get wantKeepAlive => true; } -class _MetadataDirectory { +class MetadataDirectory { final String name; final String parent; + final SplayTreeMap allTags; final SplayTreeMap tags; - const _MetadataDirectory(this.name, this.parent, this.tags); + // special directory names + static const exifThumbnailDirectory = 'Exif Thumbnail'; // from metadata-extractor + static const xmpDirectory = 'XMP'; // from metadata-extractor + static const mediaDirectory = 'Media'; // additional media (video/audio/images) directory + + const MetadataDirectory(this.name, this.parent, SplayTreeMap allTags, {SplayTreeMap tags}) + : allTags = allTags, + tags = tags ?? allTags; + + MetadataDirectory filterKeys(bool Function(String key) testKey) { + final filteredTags = SplayTreeMap.of(Map.fromEntries(allTags.entries.where((kv) => testKey(kv.key)))); + return MetadataDirectory(name, parent, tags, tags: filteredTags); + } } diff --git a/lib/widgets/fullscreen/info/metadata/xmp_structs.dart b/lib/widgets/fullscreen/info/metadata/xmp_structs.dart index 836dab6ec..efb9aceba 100644 --- a/lib/widgets/fullscreen/info/metadata/xmp_structs.dart +++ b/lib/widgets/fullscreen/info/metadata/xmp_structs.dart @@ -89,7 +89,7 @@ class _XmpStructArrayCardState extends State { // without clipping the text padding: EdgeInsets.only(left: 8, right: 8, bottom: 8), child: InfoRowGroup( - structs[_index], + structs[_index] ?? {}, maxValueLength: Constants.infoGroupMaxValueLength, linkHandlers: widget.linkifier?.call(_index + 1), ), diff --git a/lib/widgets/fullscreen/info/metadata/xmp_tile.dart b/lib/widgets/fullscreen/info/metadata/xmp_tile.dart index 8a7338b2c..b0f8f0bf5 100644 --- a/lib/widgets/fullscreen/info/metadata/xmp_tile.dart +++ b/lib/widgets/fullscreen/info/metadata/xmp_tile.dart @@ -25,11 +25,13 @@ class XmpDirTile extends StatefulWidget { final ImageEntry entry; final SplayTreeMap tags; final ValueNotifier expandedNotifier; + final bool initiallyExpanded; const XmpDirTile({ @required this.entry, @required this.tags, @required this.expandedNotifier, + @required this.initiallyExpanded, }); @override @@ -76,6 +78,7 @@ class _XmpDirTileState extends State with FeedbackMixin { return AvesExpansionTile( title: 'XMP', expandedNotifier: widget.expandedNotifier, + initiallyExpanded: widget.initiallyExpanded, children: [ NotificationListener( onNotification: (notification) { diff --git a/lib/widgets/home_page.dart b/lib/widgets/home_page.dart index e848d0a0b..d2d19c90d 100644 --- a/lib/widgets/home_page.dart +++ b/lib/widgets/home_page.dart @@ -142,7 +142,7 @@ class _HomePageState extends State { ); case SearchPage.routeName: return SearchPageRoute( - delegate: ImageSearchDelegate(source: _mediaStore), + delegate: CollectionSearchDelegate(source: _mediaStore), ); case CollectionPage.routeName: default: diff --git a/lib/widgets/search/search_button.dart b/lib/widgets/search/search_button.dart index 70fbefb06..4e99cc457 100644 --- a/lib/widgets/search/search_button.dart +++ b/lib/widgets/search/search_button.dart @@ -4,11 +4,11 @@ import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/search/search_delegate.dart'; import 'package:flutter/material.dart'; -class SearchButton extends StatelessWidget { +class CollectionSearchButton extends StatelessWidget { final CollectionSource source; final CollectionLens parentCollection; - const SearchButton(this.source, {this.parentCollection}); + const CollectionSearchButton(this.source, {this.parentCollection}); @override Widget build(BuildContext context) { @@ -24,7 +24,7 @@ class SearchButton extends StatelessWidget { Navigator.push( context, SearchPageRoute( - delegate: ImageSearchDelegate( + delegate: CollectionSearchDelegate( source: source, parentCollection: parentCollection, ), diff --git a/lib/widgets/search/search_delegate.dart b/lib/widgets/search/search_delegate.dart index febeec7b5..f1d008e86 100644 --- a/lib/widgets/search/search_delegate.dart +++ b/lib/widgets/search/search_delegate.dart @@ -20,14 +20,14 @@ import 'package:aves/widgets/search/search_page.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -class ImageSearchDelegate { +class CollectionSearchDelegate { final CollectionSource source; final CollectionLens parentCollection; final ValueNotifier expandedSectionNotifier = ValueNotifier(null); static const searchHistoryCount = 10; - ImageSearchDelegate({@required this.source, this.parentCollection}); + CollectionSearchDelegate({@required this.source, this.parentCollection}); ThemeData appBarTheme(BuildContext context) { return Theme.of(context); @@ -289,7 +289,7 @@ class SearchPageRoute extends PageRoute { delegate.route = this; } - final ImageSearchDelegate delegate; + final CollectionSearchDelegate delegate; @override Color get barrierColor => null; diff --git a/lib/widgets/search/search_page.dart b/lib/widgets/search/search_page.dart index 171f0a8b6..e0e464e51 100644 --- a/lib/widgets/search/search_page.dart +++ b/lib/widgets/search/search_page.dart @@ -7,7 +7,7 @@ import 'package:flutter/material.dart'; class SearchPage extends StatefulWidget { static const routeName = '/search'; - final ImageSearchDelegate delegate; + final CollectionSearchDelegate delegate; final Animation animation; const SearchPage({ @@ -115,7 +115,7 @@ class _SearchPageState extends State { onSubmitted: (_) => widget.delegate.showResults(context), decoration: InputDecoration( border: InputBorder.none, - hintText: MaterialLocalizations.of(context).searchFieldLabel, + hintText: 'Search collection', hintStyle: theme.inputDecorationTheme.hintStyle, ), ),