info: metadata search
This commit is contained in:
parent
3a18f16d7c
commit
69349e2b2c
16 changed files with 431 additions and 196 deletions
|
@ -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,
|
||||
),
|
||||
|
|
79
lib/widgets/common/basic/query_bar.dart
Normal file
79
lib/widgets/common/basic/query_bar.dart
Normal 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(),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
));
|
||||
|
|
53
lib/widgets/fullscreen/info/info_app_bar.dart
Normal file
53
lib/widgets/fullscreen/info/info_app_bar.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
109
lib/widgets/fullscreen/info/info_search.dart
Normal file
109
lib/widgets/fullscreen/info/info_search.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
113
lib/widgets/fullscreen/info/metadata/metadata_dir_tile.dart
Normal file
113
lib/widgets/fullscreen/info/metadata/metadata_dir_tile.dart
Normal 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']),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
|
|
Loading…
Reference in a new issue