info: metadata search

This commit is contained in:
Thibault Deckers 2020-12-30 12:47:27 +09:00
parent 3a18f16d7c
commit 69349e2b2c
16 changed files with 431 additions and 196 deletions

View file

@ -172,7 +172,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
List<Widget> _buildActions() { List<Widget> _buildActions() {
return [ return [
if (collection.isBrowsing) if (collection.isBrowsing)
SearchButton( CollectionSearchButton(
source, source,
parentCollection: collection, parentCollection: collection,
), ),
@ -361,7 +361,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
Navigator.push( Navigator.push(
context, context,
SearchPageRoute( SearchPageRoute(
delegate: ImageSearchDelegate( delegate: CollectionSearchDelegate(
source: collection.source, source: collection.source,
parentCollection: collection, parentCollection: collection,
), ),

View file

@ -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<String> filterNotifier;
const QueryBar({@required this.filterNotifier});
@override
_QueryBarState createState() => _QueryBarState();
}
class _QueryBarState extends State<QueryBar> {
final Debouncer _debouncer = Debouncer(delay: Durations.searchDebounceDelay);
TextEditingController _controller;
ValueNotifier<String> 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<TextEditingValue>(
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(),
),
),
)
],
);
}
}

View file

@ -5,13 +5,15 @@ import 'package:flutter/material.dart';
class AvesExpansionTile extends StatelessWidget { class AvesExpansionTile extends StatelessWidget {
final String title; final String title;
final Color color; final Color color;
final List<Widget> children;
final ValueNotifier<String> expandedNotifier; final ValueNotifier<String> expandedNotifier;
final bool initiallyExpanded;
final List<Widget> children;
const AvesExpansionTile({ const AvesExpansionTile({
@required this.title, @required this.title,
this.color, this.color,
this.expandedNotifier, this.expandedNotifier,
this.initiallyExpanded = false,
@required this.children, @required this.children,
}); });
@ -33,6 +35,9 @@ class AvesExpansionTile extends StatelessWidget {
enabled: enabled, enabled: enabled,
), ),
expandable: enabled, expandable: enabled,
initiallyExpanded: initiallyExpanded,
baseColor: Colors.grey[900],
expandedColor: Colors.grey[850],
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -41,8 +46,6 @@ class AvesExpansionTile extends StatelessWidget {
if (enabled) ...children, if (enabled) ...children,
], ],
), ),
baseColor: Colors.grey[900],
expandedColor: Colors.grey[850],
), ),
); );
} }

View file

@ -3,10 +3,9 @@ import 'package:aves/model/filters/album.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/enums.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/utils/debouncer.dart';
import 'package:aves/widgets/collection/empty.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/dialogs/create_album_dialog.dart';
import 'package:aves/widgets/filter_grids/albums_page.dart'; import 'package:aves/widgets/filter_grids/albums_page.dart';
import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.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<String> filterNotifier; final ValueNotifier<String> filterNotifier;
static const preferredHeight = kToolbarHeight; static const preferredHeight = kToolbarHeight;
const AlbumFilterBar({@required this.filterNotifier}); const AlbumFilterBar({
@required this.filterNotifier,
});
@override @override
Size get preferredSize => Size.fromHeight(preferredHeight); Size get preferredSize => Size.fromHeight(preferredHeight);
@override
_AlbumFilterBarState createState() => _AlbumFilterBarState();
}
class _AlbumFilterBarState extends State<AlbumFilterBar> {
final Debouncer _debouncer = Debouncer(delay: Durations.searchDebounceDelay);
TextEditingController _controller;
ValueNotifier<String> get filterNotifier => widget.filterNotifier;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: filterNotifier.value);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final clearButton = IconButton(
icon: Icon(AIcons.clear),
onPressed: () {
_controller.clear();
filterNotifier.value = '';
},
tooltip: 'Clear',
);
return Container( return Container(
height: AlbumFilterBar.preferredHeight, height: AlbumFilterBar.preferredHeight,
alignment: Alignment.topCenter, alignment: Alignment.topCenter,
child: Row( child: QueryBar(
crossAxisAlignment: CrossAxisAlignment.start, filterNotifier: filterNotifier,
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<TextEditingValue>(
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(),
),
),
)
],
), ),
); );
} }

View file

@ -104,7 +104,7 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
List<Widget> _buildActions(BuildContext context) { List<Widget> _buildActions(BuildContext context) {
return [ return [
SearchButton(source), CollectionSearchButton(source),
PopupMenuButton<ChipSetAction>( PopupMenuButton<ChipSetAction>(
key: Key('appbar-menu-button'), key: Key('appbar-menu-button'),
itemBuilder: (context) { itemBuilder: (context) {
@ -137,7 +137,7 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
Navigator.push( Navigator.push(
context, context,
SearchPageRoute( SearchPageRoute(
delegate: ImageSearchDelegate( delegate: CollectionSearchDelegate(
source: source, source: source,
), ),
)); ));

View file

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

View file

@ -2,9 +2,9 @@ import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/theme/durations.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/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/fullscreen/info/basic_section.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/location_section.dart';
import 'package:aves/widgets/fullscreen/info/metadata/metadata_section.dart'; import 'package:aves/widgets/fullscreen/info/metadata/metadata_section.dart';
import 'package:aves/widgets/fullscreen/info/notifications.dart'; import 'package:aves/widgets/fullscreen/info/notifications.dart';
@ -38,17 +38,6 @@ class InfoPageState extends State<InfoPage> {
@override @override
Widget build(BuildContext context) { 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( return MediaQueryDataProvider(
child: Scaffold( child: Scaffold(
body: SafeArea( body: SafeArea(
@ -68,9 +57,9 @@ class InfoPageState extends State<InfoPage> {
entry: entry, entry: entry,
visibleNotifier: widget.visibleNotifier, visibleNotifier: widget.visibleNotifier,
scrollController: _scrollController, scrollController: _scrollController,
appBar: appBar,
split: mqWidth > 400, split: mqWidth > 400,
mqViewInsetsBottom: mqViewInsetsBottom, mqViewInsetsBottom: mqViewInsetsBottom,
goToViewer: _goToViewer,
) )
: SizedBox.shrink(); : SizedBox.shrink();
}, },
@ -97,7 +86,7 @@ class InfoPageState extends State<InfoPage> {
_scrollStartFromTop = false; _scrollStartFromTop = false;
} else if (notification is OverscrollNotification) { } else if (notification is OverscrollNotification) {
if (notification.overscroll < 0) { if (notification.overscroll < 0) {
_goToImage(); _goToViewer();
_scrollStartFromTop = false; _scrollStartFromTop = false;
} }
} }
@ -106,7 +95,7 @@ class InfoPageState extends State<InfoPage> {
return false; return false;
} }
void _goToImage() { void _goToViewer() {
BackUpNotification().dispatch(context); BackUpNotification().dispatch(context);
_scrollController.animateTo( _scrollController.animateTo(
0, 0,
@ -121,9 +110,9 @@ class _InfoPageContent extends StatefulWidget {
final ImageEntry entry; final ImageEntry entry;
final ValueNotifier<bool> visibleNotifier; final ValueNotifier<bool> visibleNotifier;
final ScrollController scrollController; final ScrollController scrollController;
final SliverAppBar appBar;
final bool split; final bool split;
final double mqViewInsetsBottom; final double mqViewInsetsBottom;
final VoidCallback goToViewer;
const _InfoPageContent({ const _InfoPageContent({
Key key, Key key,
@ -131,9 +120,9 @@ class _InfoPageContent extends StatefulWidget {
@required this.entry, @required this.entry,
@required this.visibleNotifier, @required this.visibleNotifier,
@required this.scrollController, @required this.scrollController,
@required this.appBar,
@required this.split, @required this.split,
@required this.mqViewInsetsBottom, @required this.mqViewInsetsBottom,
@required this.goToViewer,
}) : super(key: key); }) : super(key: key);
@override @override
@ -143,6 +132,8 @@ class _InfoPageContent extends StatefulWidget {
class _InfoPageContentState extends State<_InfoPageContent> { class _InfoPageContentState extends State<_InfoPageContent> {
static const horizontalPadding = EdgeInsets.symmetric(horizontal: 8); static const horizontalPadding = EdgeInsets.symmetric(horizontal: 8);
final ValueNotifier<Map<String, MetadataDirectory>> _metadataNotifier = ValueNotifier({});
CollectionLens get collection => widget.collection; CollectionLens get collection => widget.collection;
ImageEntry get entry => widget.entry; ImageEntry get entry => widget.entry;
@ -178,13 +169,18 @@ class _InfoPageContentState extends State<_InfoPageContent> {
); );
final metadataSliver = MetadataSectionSliver( final metadataSliver = MetadataSectionSliver(
entry: entry, entry: entry,
metadataNotifier: _metadataNotifier,
visibleNotifier: widget.visibleNotifier, visibleNotifier: widget.visibleNotifier,
); );
return CustomScrollView( return CustomScrollView(
controller: widget.scrollController, controller: widget.scrollController,
slivers: [ slivers: [
widget.appBar, InfoAppBar(
entry: entry,
metadataNotifier: _metadataNotifier,
onBackPressed: widget.goToViewer,
),
SliverPadding( SliverPadding(
padding: horizontalPadding + EdgeInsets.only(top: 8), padding: horizontalPadding + EdgeInsets.only(top: 8),
sliver: basicAndLocationSliver, sliver: basicAndLocationSliver,

View file

@ -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<Map<String, MetadataDirectory>> metadataNotifier;
Map<String, MetadataDirectory> 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<Widget> 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,
);
}
}

View file

@ -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<String> 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 = <Widget>[];
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<String, InfoLinkHandler> getSvgLinkHandlers(SplayTreeMap<String, String> 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']),
),
),
);
},
),
};
}
}

View file

@ -1,18 +1,12 @@
import 'dart:collection'; import 'dart:collection';
import 'package:aves/model/image_entry.dart'; 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/metadata_service.dart';
import 'package:aves/services/svg_metadata_service.dart'; import 'package:aves/services/svg_metadata_service.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.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/common.dart';
import 'package:aves/widgets/fullscreen/info/metadata/metadata_thumbnail.dart'; import 'package:aves/widgets/fullscreen/info/metadata/metadata_dir_tile.dart';
import 'package:aves/widgets/fullscreen/info/metadata/xmp_tile.dart';
import 'package:aves/widgets/fullscreen/source_viewer_page.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -21,10 +15,12 @@ import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
class MetadataSectionSliver extends StatefulWidget { class MetadataSectionSliver extends StatefulWidget {
final ImageEntry entry; final ImageEntry entry;
final ValueNotifier<bool> visibleNotifier; final ValueNotifier<bool> visibleNotifier;
final ValueNotifier<Map<String, MetadataDirectory>> metadataNotifier;
const MetadataSectionSliver({ const MetadataSectionSliver({
@required this.entry, @required this.entry,
@required this.visibleNotifier, @required this.visibleNotifier,
@required this.metadataNotifier,
}); });
@override @override
@ -32,7 +28,6 @@ class MetadataSectionSliver extends StatefulWidget {
} }
class _MetadataSectionSliverState extends State<MetadataSectionSliver> with AutomaticKeepAliveClientMixin { class _MetadataSectionSliverState extends State<MetadataSectionSliver> with AutomaticKeepAliveClientMixin {
Map<String, _MetadataDirectory> _metadata = {};
final ValueNotifier<String> _loadedMetadataUri = ValueNotifier(null); final ValueNotifier<String> _loadedMetadataUri = ValueNotifier(null);
final ValueNotifier<String> _expandedDirectoryNotifier = ValueNotifier(null); final ValueNotifier<String> _expandedDirectoryNotifier = ValueNotifier(null);
@ -40,10 +35,9 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
bool get isVisible => widget.visibleNotifier.value; bool get isVisible => widget.visibleNotifier.value;
// special directory names ValueNotifier<Map<String, MetadataDirectory>> get metadataNotifier => widget.metadataNotifier;
static const exifThumbnailDirectory = 'Exif Thumbnail'; // from metadata-extractor
static const xmpDirectory = 'XMP'; // from metadata-extractor Map<String, MetadataDirectory> get metadata => metadataNotifier.value;
static const mediaDirectory = 'Media'; // additional media (video/audio/images) directory
// directory names may contain the name of their parent directory // directory names may contain the name of their parent directory
// if so, they are separated by this character // if so, they are separated by this character
@ -53,6 +47,7 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
void initState() { void initState() {
super.initState(); super.initState();
_registerWidget(widget); _registerWidget(widget);
metadataNotifier.value = {};
_getMetadata(); _getMetadata();
} }
@ -96,7 +91,7 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
valueListenable: _loadedMetadataUri, valueListenable: _loadedMetadataUri,
builder: (context, uri, child) { builder: (context, uri, child) {
Widget content; Widget content;
if (_metadata.isEmpty) { if (metadata.isEmpty) {
content = SizedBox.shrink(); content = SizedBox.shrink();
} else { } else {
content = Column( content = Column(
@ -111,7 +106,12 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
), ),
children: [ children: [
SectionRow(AIcons.info), 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<MetadataSectionSliver> 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 = <Widget>[];
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() { void _onMetadataChanged() {
_loadedMetadataUri.value = null; _loadedMetadataUri.value = null;
_metadata = {}; metadataNotifier.value = {};
_getMetadata(); _getMetadata();
} }
@ -211,7 +156,7 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
final tagName = tagKV.key as String ?? ''; final tagName = tagKV.key as String ?? '';
return MapEntry(tagName, value); return MapEntry(tagName, value);
}).where((kv) => kv != null))); }).where((kv) => kv != null)));
return _MetadataDirectory(directoryName, parent, tags); return MetadataDirectory(directoryName, parent, tags);
}).toList(); }).toList();
final titledDirectories = directories.map((dir) { final titledDirectories = directories.map((dir) {
@ -222,42 +167,36 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
return MapEntry(title, dir); return MapEntry(title, dir);
}).toList() }).toList()
..sort((a, b) => compareAsciiUpperCase(a.key, b.key)); ..sort((a, b) => compareAsciiUpperCase(a.key, b.key));
_metadata = Map.fromEntries(titledDirectories); metadataNotifier.value = Map.fromEntries(titledDirectories);
_loadedMetadataUri.value = entry.uri; _loadedMetadataUri.value = entry.uri;
} else { } else {
_metadata = {}; metadataNotifier.value = {};
_loadedMetadataUri.value = null; _loadedMetadataUri.value = null;
} }
_expandedDirectoryNotifier.value = null; _expandedDirectoryNotifier.value = null;
} }
static Map<String, InfoLinkHandler> getSvgLinkHandlers(SplayTreeMap<String, String> 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 @override
bool get wantKeepAlive => true; bool get wantKeepAlive => true;
} }
class _MetadataDirectory { class MetadataDirectory {
final String name; final String name;
final String parent; final String parent;
final SplayTreeMap<String, String> allTags;
final SplayTreeMap<String, String> tags; final SplayTreeMap<String, String> 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<String, String> allTags, {SplayTreeMap<String, String> 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);
}
} }

View file

@ -89,7 +89,7 @@ class _XmpStructArrayCardState extends State<XmpStructArrayCard> {
// without clipping the text // without clipping the text
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8), padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: InfoRowGroup( child: InfoRowGroup(
structs[_index], structs[_index] ?? {},
maxValueLength: Constants.infoGroupMaxValueLength, maxValueLength: Constants.infoGroupMaxValueLength,
linkHandlers: widget.linkifier?.call(_index + 1), linkHandlers: widget.linkifier?.call(_index + 1),
), ),

View file

@ -25,11 +25,13 @@ class XmpDirTile extends StatefulWidget {
final ImageEntry entry; final ImageEntry entry;
final SplayTreeMap<String, String> tags; final SplayTreeMap<String, String> tags;
final ValueNotifier<String> expandedNotifier; final ValueNotifier<String> expandedNotifier;
final bool initiallyExpanded;
const XmpDirTile({ const XmpDirTile({
@required this.entry, @required this.entry,
@required this.tags, @required this.tags,
@required this.expandedNotifier, @required this.expandedNotifier,
@required this.initiallyExpanded,
}); });
@override @override
@ -76,6 +78,7 @@ class _XmpDirTileState extends State<XmpDirTile> with FeedbackMixin {
return AvesExpansionTile( return AvesExpansionTile(
title: 'XMP', title: 'XMP',
expandedNotifier: widget.expandedNotifier, expandedNotifier: widget.expandedNotifier,
initiallyExpanded: widget.initiallyExpanded,
children: [ children: [
NotificationListener<OpenEmbeddedDataNotification>( NotificationListener<OpenEmbeddedDataNotification>(
onNotification: (notification) { onNotification: (notification) {

View file

@ -142,7 +142,7 @@ class _HomePageState extends State<HomePage> {
); );
case SearchPage.routeName: case SearchPage.routeName:
return SearchPageRoute( return SearchPageRoute(
delegate: ImageSearchDelegate(source: _mediaStore), delegate: CollectionSearchDelegate(source: _mediaStore),
); );
case CollectionPage.routeName: case CollectionPage.routeName:
default: default:

View file

@ -4,11 +4,11 @@ import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/search/search_delegate.dart'; import 'package:aves/widgets/search/search_delegate.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class SearchButton extends StatelessWidget { class CollectionSearchButton extends StatelessWidget {
final CollectionSource source; final CollectionSource source;
final CollectionLens parentCollection; final CollectionLens parentCollection;
const SearchButton(this.source, {this.parentCollection}); const CollectionSearchButton(this.source, {this.parentCollection});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -24,7 +24,7 @@ class SearchButton extends StatelessWidget {
Navigator.push( Navigator.push(
context, context,
SearchPageRoute( SearchPageRoute(
delegate: ImageSearchDelegate( delegate: CollectionSearchDelegate(
source: source, source: source,
parentCollection: parentCollection, parentCollection: parentCollection,
), ),

View file

@ -20,14 +20,14 @@ import 'package:aves/widgets/search/search_page.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
class ImageSearchDelegate { class CollectionSearchDelegate {
final CollectionSource source; final CollectionSource source;
final CollectionLens parentCollection; final CollectionLens parentCollection;
final ValueNotifier<String> expandedSectionNotifier = ValueNotifier(null); final ValueNotifier<String> expandedSectionNotifier = ValueNotifier(null);
static const searchHistoryCount = 10; static const searchHistoryCount = 10;
ImageSearchDelegate({@required this.source, this.parentCollection}); CollectionSearchDelegate({@required this.source, this.parentCollection});
ThemeData appBarTheme(BuildContext context) { ThemeData appBarTheme(BuildContext context) {
return Theme.of(context); return Theme.of(context);
@ -289,7 +289,7 @@ class SearchPageRoute<T> extends PageRoute<T> {
delegate.route = this; delegate.route = this;
} }
final ImageSearchDelegate delegate; final CollectionSearchDelegate delegate;
@override @override
Color get barrierColor => null; Color get barrierColor => null;

View file

@ -7,7 +7,7 @@ import 'package:flutter/material.dart';
class SearchPage extends StatefulWidget { class SearchPage extends StatefulWidget {
static const routeName = '/search'; static const routeName = '/search';
final ImageSearchDelegate delegate; final CollectionSearchDelegate delegate;
final Animation<double> animation; final Animation<double> animation;
const SearchPage({ const SearchPage({
@ -115,7 +115,7 @@ class _SearchPageState extends State<SearchPage> {
onSubmitted: (_) => widget.delegate.showResults(context), onSubmitted: (_) => widget.delegate.showResults(context),
decoration: InputDecoration( decoration: InputDecoration(
border: InputBorder.none, border: InputBorder.none,
hintText: MaterialLocalizations.of(context).searchFieldLabel, hintText: 'Search collection',
hintStyle: theme.inputDecorationTheme.hintStyle, hintStyle: theme.inputDecorationTheme.hintStyle,
), ),
), ),