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() {
return [
if (collection.isBrowsing)
SearchButton(
CollectionSearchButton(
source,
parentCollection: collection,
),
@ -361,7 +361,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
Navigator.push(
context,
SearchPageRoute(
delegate: ImageSearchDelegate(
delegate: CollectionSearchDelegate(
source: collection.source,
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 {
final String title;
final Color color;
final List<Widget> children;
final ValueNotifier<String> expandedNotifier;
final bool initiallyExpanded;
final List<Widget> 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],
),
);
}

View file

@ -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<String> 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<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
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<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(),
),
),
)
],
child: QueryBar(
filterNotifier: filterNotifier,
),
);
}

View file

@ -104,7 +104,7 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
List<Widget> _buildActions(BuildContext context) {
return [
SearchButton(source),
CollectionSearchButton(source),
PopupMenuButton<ChipSetAction>(
key: Key('appbar-menu-button'),
itemBuilder: (context) {
@ -137,7 +137,7 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
Navigator.push(
context,
SearchPageRoute(
delegate: ImageSearchDelegate(
delegate: CollectionSearchDelegate(
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/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<InfoPage> {
@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<InfoPage> {
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<InfoPage> {
_scrollStartFromTop = false;
} else if (notification is OverscrollNotification) {
if (notification.overscroll < 0) {
_goToImage();
_goToViewer();
_scrollStartFromTop = false;
}
}
@ -106,7 +95,7 @@ class InfoPageState extends State<InfoPage> {
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<bool> 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<Map<String, MetadataDirectory>> _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,

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 '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<bool> visibleNotifier;
final ValueNotifier<Map<String, MetadataDirectory>> 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<MetadataSectionSliver> with AutomaticKeepAliveClientMixin {
Map<String, _MetadataDirectory> _metadata = {};
final ValueNotifier<String> _loadedMetadataUri = 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;
// 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<Map<String, MetadataDirectory>> get metadataNotifier => widget.metadataNotifier;
Map<String, MetadataDirectory> 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<MetadataSectionSliver> with Auto
void initState() {
super.initState();
_registerWidget(widget);
metadataNotifier.value = {};
_getMetadata();
}
@ -96,7 +91,7 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> 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<MetadataSectionSliver> 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<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() {
_loadedMetadataUri.value = null;
_metadata = {};
metadataNotifier.value = {};
_getMetadata();
}
@ -211,7 +156,7 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> 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<MetadataSectionSliver> 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<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
bool get wantKeepAlive => true;
}
class _MetadataDirectory {
class MetadataDirectory {
final String name;
final String parent;
final SplayTreeMap<String, String> allTags;
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
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: InfoRowGroup(
structs[_index],
structs[_index] ?? {},
maxValueLength: Constants.infoGroupMaxValueLength,
linkHandlers: widget.linkifier?.call(_index + 1),
),

View file

@ -25,11 +25,13 @@ class XmpDirTile extends StatefulWidget {
final ImageEntry entry;
final SplayTreeMap<String, String> tags;
final ValueNotifier<String> 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<XmpDirTile> with FeedbackMixin {
return AvesExpansionTile(
title: 'XMP',
expandedNotifier: widget.expandedNotifier,
initiallyExpanded: widget.initiallyExpanded,
children: [
NotificationListener<OpenEmbeddedDataNotification>(
onNotification: (notification) {

View file

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

View file

@ -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,
),

View file

@ -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<String> 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<T> extends PageRoute<T> {
delegate.route = this;
}
final ImageSearchDelegate delegate;
final CollectionSearchDelegate delegate;
@override
Color get barrierColor => null;

View file

@ -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<double> animation;
const SearchPage({
@ -115,7 +115,7 @@ class _SearchPageState extends State<SearchPage> {
onSubmitted: (_) => widget.delegate.showResults(context),
decoration: InputDecoration(
border: InputBorder.none,
hintText: MaterialLocalizations.of(context).searchFieldLabel,
hintText: 'Search collection',
hintStyle: theme.inputDecorationTheme.hintStyle,
),
),