added popup menu to all filters: hide, go to page

info: added mime filter
This commit is contained in:
Thibault Deckers 2021-02-24 14:00:12 +09:00
parent 1a69749539
commit 652405d375
27 changed files with 568 additions and 327 deletions

View file

@ -47,7 +47,6 @@ class ThumbnailFetcher internal constructor(
fun fetch() {
var bitmap: Bitmap? = null
var recycle = true
var exception: Exception? = null
try {
@ -66,14 +65,13 @@ class ThumbnailFetcher internal constructor(
if (bitmap == null) {
try {
bitmap = getByGlide()
recycle = false
} catch (e: Exception) {
exception = e
}
}
if (bitmap != null) {
result.success(bitmap.getBytes(MimeTypes.canHaveAlpha(mimeType), recycle = recycle))
result.success(bitmap.getBytes(MimeTypes.canHaveAlpha(mimeType), recycle = false))
} else {
var errorDetails: String? = exception?.message
if (errorDetails?.isNotEmpty() == true) {

View file

@ -9,6 +9,7 @@ import 'package:aves/theme/icons.dart';
import 'package:aves/utils/debouncer.dart';
import 'package:aves/widgets/common/behaviour/route_tracker.dart';
import 'package:aves/widgets/common/behaviour/routes.dart';
import 'package:aves/widgets/common/providers/highlight_info_provider.dart';
import 'package:aves/widgets/home_page.dart';
import 'package:aves/widgets/welcome_page.dart';
import 'package:firebase_analytics/firebase_analytics.dart';
@ -114,24 +115,26 @@ class _AvesAppState extends State<AvesApp> {
value: settings,
child: Provider<CollectionSource>.value(
value: _mediaStoreSource,
child: OverlaySupport(
child: FutureBuilder<void>(
future: _appSetup,
builder: (context, snapshot) {
final home = (!snapshot.hasError && snapshot.connectionState == ConnectionState.done)
? getFirstPage()
: Scaffold(
body: snapshot.hasError ? _buildError(snapshot.error) : SizedBox.shrink(),
);
return MaterialApp(
navigatorKey: _navigatorKey,
home: home,
navigatorObservers: _navigatorObservers,
title: 'Aves',
darkTheme: darkTheme,
themeMode: ThemeMode.dark,
);
},
child: HighlightInfoProvider(
child: OverlaySupport(
child: FutureBuilder<void>(
future: _appSetup,
builder: (context, snapshot) {
final home = (!snapshot.hasError && snapshot.connectionState == ConnectionState.done)
? getFirstPage()
: Scaffold(
body: snapshot.hasError ? _buildError(snapshot.error) : SizedBox.shrink(),
);
return MaterialApp(
navigatorKey: _navigatorKey,
home: home,
navigatorObservers: _navigatorObservers,
title: 'Aves',
darkTheme: darkTheme,
themeMode: ThemeMode.dark,
);
},
),
),
),
),

View file

@ -14,6 +14,9 @@ enum ChipAction {
pin,
unpin,
rename,
goToAlbumPage,
goToCountryPage,
goToTagPage,
}
extension ExtraChipAction on ChipAction {
@ -21,6 +24,12 @@ extension ExtraChipAction on ChipAction {
switch (this) {
case ChipAction.delete:
return 'Delete';
case ChipAction.goToAlbumPage:
return 'Show in Albums';
case ChipAction.goToCountryPage:
return 'Show in Countries';
case ChipAction.goToTagPage:
return 'Show in Tags';
case ChipAction.hide:
return 'Hide';
case ChipAction.pin:
@ -37,6 +46,12 @@ extension ExtraChipAction on ChipAction {
switch (this) {
case ChipAction.delete:
return AIcons.delete;
case ChipAction.goToAlbumPage:
return AIcons.album;
case ChipAction.goToCountryPage:
return AIcons.location;
case ChipAction.goToTagPage:
return AIcons.tag;
case ChipAction.hide:
return AIcons.hide;
case ChipAction.pin:

View file

@ -7,6 +7,7 @@ import 'package:aves/model/filters/location.dart';
import 'package:aves/model/filters/mime.dart';
import 'package:aves/model/filters/query.dart';
import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/filters/type.dart';
import 'package:aves/utils/color_utils.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
@ -17,6 +18,7 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
QueryFilter.type,
FavouriteFilter.type,
MimeFilter.type,
TypeFilter.type,
AlbumFilter.type,
LocationFilter.type,
TagFilter.type,
@ -32,6 +34,8 @@ abstract class CollectionFilter implements Comparable<CollectionFilter> {
return FavouriteFilter();
case LocationFilter.type:
return LocationFilter.fromMap(jsonMap);
case TypeFilter.type:
return TypeFilter.fromMap(jsonMap);
case MimeFilter.type:
return MimeFilter.fromMap(jsonMap);
case QueryFilter.type:

View file

@ -55,7 +55,13 @@ class LocationFilter extends CollectionFilter {
final flag = countryCodeToFlag(_countryCode);
// as of Flutter v1.22.3, emoji shadows are rendered as colorful duplicates,
// not filled with the shadow color as expected, so we remove them
if (flag != null) return Text(flag, style: TextStyle(fontSize: size, shadows: []), textScaleFactor: 1.0,);
if (flag != null) {
return Text(
flag,
style: TextStyle(fontSize: size, shadows: []),
textScaleFactor: 1.0,
);
}
return Icon(_location.isEmpty ? AIcons.locationOff : AIcons.location, size: size);
}

View file

@ -7,12 +7,6 @@ import 'package:flutter/widgets.dart';
class MimeFilter extends CollectionFilter {
static const type = 'mime';
// fake mime type
static const animated = 'aves/animated'; // subset of `image/gif` and `image/webp`
static const panorama = 'aves/panorama'; // subset of images
static const sphericalVideo = 'aves/spherical_video'; // subset of videos
static const geotiff = 'aves/geotiff'; // subset of `image/tiff`
final String mime;
EntryFilter _test;
String _label;
@ -20,23 +14,7 @@ class MimeFilter extends CollectionFilter {
MimeFilter(this.mime) {
var lowMime = mime.toLowerCase();
if (mime == animated) {
_test = (entry) => entry.isAnimated;
_label = 'Animated';
_icon = AIcons.animated;
} else if (mime == panorama) {
_test = (entry) => entry.isImage && entry.is360;
_label = 'Panorama';
_icon = AIcons.threesixty;
} else if (mime == sphericalVideo) {
_test = (entry) => entry.isVideo && entry.is360;
_label = '360° Video';
_icon = AIcons.threesixty;
} else if (mime == geotiff) {
_test = (entry) => entry.isGeotiff;
_label = 'GeoTIFF';
_icon = AIcons.geo;
} else if (lowMime.endsWith('/*')) {
if (lowMime.endsWith('/*')) {
lowMime = lowMime.substring(0, lowMime.length - 2);
_test = (entry) => entry.mimeType.startsWith(lowMime);
if (lowMime == 'video') {

View file

@ -0,0 +1,73 @@
import 'package:aves/model/filters/filters.dart';
import 'package:aves/theme/icons.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
class TypeFilter extends CollectionFilter {
static const type = 'type';
static const animated = 'animated'; // subset of `image/gif` and `image/webp`
static const geotiff = 'geotiff'; // subset of `image/tiff`
static const panorama = 'panorama'; // subset of images
static const sphericalVideo = 'spherical_video'; // subset of videos
final String itemType;
EntryFilter _test;
String _label;
IconData _icon;
TypeFilter(this.itemType) {
if (itemType == animated) {
_test = (entry) => entry.isAnimated;
_label = 'Animated';
_icon = AIcons.animated;
} else if (itemType == panorama) {
_test = (entry) => entry.isImage && entry.is360;
_label = 'Panorama';
_icon = AIcons.threesixty;
} else if (itemType == sphericalVideo) {
_test = (entry) => entry.isVideo && entry.is360;
_label = '360° Video';
_icon = AIcons.threesixty;
} else if (itemType == geotiff) {
_test = (entry) => entry.isGeotiff;
_label = 'GeoTIFF';
_icon = AIcons.geo;
}
}
TypeFilter.fromMap(Map<String, dynamic> json)
: this(
json['itemType'],
);
@override
Map<String, dynamic> toMap() => {
'type': type,
'itemType': itemType,
};
@override
EntryFilter get test => _test;
@override
String get label => _label;
@override
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => Icon(_icon, size: size);
@override
String get typeKey => type;
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is TypeFilter && other.itemType == itemType;
}
@override
int get hashCode => hashValues(type, itemType);
@override
String toString() => '$runtimeType#${shortHash(this)}{itemType=$itemType}';
}

View file

@ -3,22 +3,24 @@ import 'dart:collection';
import 'package:flutter/foundation.dart';
class HighlightInfo extends ChangeNotifier {
final Queue<Object> _items = Queue();
Object _item;
void add(Object item) {
if (_items.contains(item)) return;
_items.addFirst(item);
while (_items.length > 5) {
_items.removeLast();
}
void set(Object item) {
if (_item == item) return;
_item = item;
notifyListeners();
}
void remove(Object item) {
_items.removeWhere((element) => element == item);
Object clear() {
if (_item == null) return null;
final item = _item;
_item = null;
notifyListeners();
return item;
}
bool contains(Object item) => _items.contains(item);
bool contains(Object item) => _item == item;
@override
String toString() => '$runtimeType#${shortHash(this)}{item=$_item}';
}

View file

@ -17,6 +17,8 @@ class Durations {
// filter grids animations
static const chipDecorationAnimation = Duration(milliseconds: 200);
static const highlightScrollAnimationMinMillis = 400;
static const highlightScrollAnimationMaxMillis = 2000;
// collection animations
static const filterBarRemovalAnimation = Duration(milliseconds: 400);
@ -44,6 +46,7 @@ class Durations {
static const opToastDisplay = Duration(seconds: 2);
static const collectionScrollMonitoringTimerDelay = Duration(milliseconds: 100);
static const collectionScalingCompleteNotificationDelay = Duration(milliseconds: 300);
static const highlightScrollInitDelay = Duration(milliseconds: 800);
static const videoProgressTimerInterval = Duration(milliseconds: 300);
static Duration staggeredAnimationDelay = Durations.staggeredAnimation ~/ 6 * timeDilation;
static const doubleBackTimerDelay = Duration(milliseconds: 1000);

View file

@ -155,7 +155,7 @@ class _ThumbnailHighlightOverlayState extends State<ThumbnailHighlightOverlay> {
toggledNotifier: _highlightedNotifier,
startAngle: pi * -3 / 4,
centerSweep: false,
onSweepEnd: () => highlightInfo.remove(entry),
onSweepEnd: highlightInfo.clear,
);
}
}

View file

@ -24,7 +24,6 @@ import 'package:aves/widgets/common/extensions/media_query.dart';
import 'package:aves/widgets/common/grid/section_layout.dart';
import 'package:aves/widgets/common/grid/sliver.dart';
import 'package:aves/widgets/common/identity/scroll_thumb.dart';
import 'package:aves/widgets/common/providers/highlight_info_provider.dart';
import 'package:aves/widgets/common/scaling.dart';
import 'package:aves/widgets/common/tile_extent_manager.dart';
import 'package:flutter/material.dart';
@ -44,105 +43,103 @@ class ThumbnailCollection extends StatelessWidget {
@override
Widget build(BuildContext context) {
return HighlightInfoProvider(
child: SafeArea(
bottom: false,
child: LayoutBuilder(
builder: (context, constraints) {
final viewportSize = constraints.biggest;
assert(viewportSize.isFinite, 'Cannot layout collection with unbounded constraints.');
if (viewportSize.isEmpty) return SizedBox.shrink();
return SafeArea(
bottom: false,
child: LayoutBuilder(
builder: (context, constraints) {
final viewportSize = constraints.biggest;
assert(viewportSize.isFinite, 'Cannot layout collection with unbounded constraints.');
if (viewportSize.isEmpty) return SizedBox.shrink();
final tileExtentManager = TileExtentManager(
settingsRouteKey: context.currentRouteName,
extentNotifier: _tileExtentNotifier,
columnCountDefault: columnCountDefault,
extentMin: extentMin,
spacing: spacing,
)..applyTileExtent(viewportSize: viewportSize);
final cacheExtent = tileExtentManager.getEffectiveExtentMax(viewportSize) * 2;
final scrollController = PrimaryScrollController.of(context);
final tileExtentManager = TileExtentManager(
settingsRouteKey: context.currentRouteName,
extentNotifier: _tileExtentNotifier,
columnCountDefault: columnCountDefault,
extentMin: extentMin,
spacing: spacing,
)..applyTileExtent(viewportSize: viewportSize);
final cacheExtent = tileExtentManager.getEffectiveExtentMax(viewportSize) * 2;
final scrollController = PrimaryScrollController.of(context);
// do not replace by Provider.of<CollectionLens>
// so that view updates on collection filter changes
return Consumer<CollectionLens>(
builder: (context, collection, child) {
final scrollView = AnimationLimiter(
child: CollectionScrollView(
scrollableKey: _scrollableKey,
collection: collection,
appBar: CollectionAppBar(
appBarHeightNotifier: _appBarHeightNotifier,
collection: collection,
),
appBarHeightNotifier: _appBarHeightNotifier,
isScrollingNotifier: _isScrollingNotifier,
scrollController: scrollController,
cacheExtent: cacheExtent,
),
);
final scaler = GridScaleGestureDetector<AvesEntry>(
tileExtentManager: tileExtentManager,
// do not replace by Provider.of<CollectionLens>
// so that view updates on collection filter changes
return Consumer<CollectionLens>(
builder: (context, collection, child) {
final scrollView = AnimationLimiter(
child: CollectionScrollView(
scrollableKey: _scrollableKey,
appBarHeightNotifier: _appBarHeightNotifier,
viewportSize: viewportSize,
gridBuilder: (center, extent, child) => CustomPaint(
// painting the thumbnail half-border on top of the grid yields artifacts,
// so we use a `foregroundPainter` to cover them instead
foregroundPainter: GridPainter(
center: center,
extent: extent,
spacing: tileExtentManager.spacing,
strokeWidth: DecoratedThumbnail.borderWidth * 2,
color: DecoratedThumbnail.borderColor,
),
child: child,
),
scaledBuilder: (entry, extent) => DecoratedThumbnail(
entry: entry,
extent: extent,
selectable: false,
highlightable: false,
),
getScaledItemTileRect: (context, entry) {
final sectionedListLayout = context.read<SectionedListLayout<AvesEntry>>();
return sectionedListLayout.getTileRect(entry) ?? Rect.zero;
},
onScaled: (entry) => context.read<HighlightInfo>().add(entry),
child: scrollView,
);
final selector = GridSelectionGestureDetector(
selectable: AvesApp.mode == AppMode.main,
collection: collection,
scrollController: scrollController,
appBarHeightNotifier: _appBarHeightNotifier,
child: scaler,
);
final sectionedListLayoutProvider = ValueListenableBuilder<double>(
valueListenable: _tileExtentNotifier,
builder: (context, tileExtent, child) => SectionedEntryListLayoutProvider(
appBar: CollectionAppBar(
appBarHeightNotifier: _appBarHeightNotifier,
collection: collection,
scrollableWidth: viewportSize.width,
tileExtent: tileExtent,
columnCount: tileExtentManager.getEffectiveColumnCountForExtent(viewportSize, tileExtent),
tileBuilder: (entry) => InteractiveThumbnail(
key: ValueKey(entry.contentId),
collection: collection,
entry: entry,
tileExtent: tileExtent,
isScrollingNotifier: _isScrollingNotifier,
),
child: selector,
),
);
return sectionedListLayoutProvider;
},
);
},
),
appBarHeightNotifier: _appBarHeightNotifier,
isScrollingNotifier: _isScrollingNotifier,
scrollController: scrollController,
cacheExtent: cacheExtent,
),
);
final scaler = GridScaleGestureDetector<AvesEntry>(
tileExtentManager: tileExtentManager,
scrollableKey: _scrollableKey,
appBarHeightNotifier: _appBarHeightNotifier,
viewportSize: viewportSize,
gridBuilder: (center, extent, child) => CustomPaint(
// painting the thumbnail half-border on top of the grid yields artifacts,
// so we use a `foregroundPainter` to cover them instead
foregroundPainter: GridPainter(
center: center,
extent: extent,
spacing: tileExtentManager.spacing,
strokeWidth: DecoratedThumbnail.borderWidth * 2,
color: DecoratedThumbnail.borderColor,
),
child: child,
),
scaledBuilder: (entry, extent) => DecoratedThumbnail(
entry: entry,
extent: extent,
selectable: false,
highlightable: false,
),
getScaledItemTileRect: (context, entry) {
final sectionedListLayout = context.read<SectionedListLayout<AvesEntry>>();
return sectionedListLayout.getTileRect(entry) ?? Rect.zero;
},
onScaled: (entry) => context.read<HighlightInfo>().set(entry),
child: scrollView,
);
final selector = GridSelectionGestureDetector(
selectable: AvesApp.mode == AppMode.main,
collection: collection,
scrollController: scrollController,
appBarHeightNotifier: _appBarHeightNotifier,
child: scaler,
);
final sectionedListLayoutProvider = ValueListenableBuilder<double>(
valueListenable: _tileExtentNotifier,
builder: (context, tileExtent, child) => SectionedEntryListLayoutProvider(
collection: collection,
scrollableWidth: viewportSize.width,
tileExtent: tileExtent,
columnCount: tileExtentManager.getEffectiveColumnCountForExtent(viewportSize, tileExtent),
tileBuilder: (entry) => InteractiveThumbnail(
key: ValueKey(entry.contentId),
collection: collection,
entry: entry,
tileExtent: tileExtent,
isScrollingNotifier: _isScrollingNotifier,
),
child: selector,
),
);
return sectionedListLayoutProvider;
},
);
},
),
);
}

View file

@ -26,7 +26,7 @@ abstract class SectionedListLayoutProvider<T> extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ProxyProvider0<SectionedListLayout<T>>(
update: (context, __) => _updateLayouts(context),
update: (context, _) => _updateLayouts(context),
child: child,
);
}

View file

@ -1,10 +1,19 @@
import 'package:aves/main.dart';
import 'package:aves/model/actions/chip_actions.dart';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/filters/location.dart';
import 'package:aves/model/filters/tag.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/basic/menu_row.dart';
import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
typedef FilterCallback = void Function(CollectionFilter filter);
typedef OffsetFilterCallback = void Function(CollectionFilter filter, Offset tapPosition);
typedef OffsetFilterCallback = void Function(BuildContext context, CollectionFilter filter, Offset tapPosition);
enum HeroType { always, onTap, never }
@ -38,10 +47,43 @@ class AvesFilterChip extends StatefulWidget {
this.padding = 6.0,
this.heroType = HeroType.onTap,
this.onTap,
this.onLongPress,
this.onLongPress = showDefaultLongPressMenu,
}) : assert(filter != null),
super(key: key);
static Future<void> showDefaultLongPressMenu(BuildContext context, CollectionFilter filter, Offset tapPosition) async {
if (AvesApp.mode == AppMode.main) {
final actions = [
if (filter is AlbumFilter) ChipAction.goToAlbumPage,
if ((filter is LocationFilter && filter.level == LocationLevel.country)) ChipAction.goToCountryPage,
if (filter is TagFilter) ChipAction.goToTagPage,
ChipAction.hide,
];
// remove focus, if any, to prevent the keyboard from showing up
// after the user is done with the popup menu
FocusManager.instance.primaryFocus?.unfocus();
final RenderBox overlay = Overlay.of(context).context.findRenderObject();
final touchArea = Size(40, 40);
// TODO TLAD show menu within safe area
final selectedAction = await showMenu<ChipAction>(
context: context,
position: RelativeRect.fromRect(tapPosition & touchArea, Offset.zero & overlay.size),
items: actions
.map((action) => PopupMenuItem(
value: action,
child: MenuRow(text: action.getText(), icon: action.getIcon()),
))
.toList(),
);
if (selectedAction != null) {
// wait for the popup menu to hide before proceeding with the action
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => ChipActionDelegate().onActionSelected(context, filter, selectedAction));
}
}
}
@override
_AvesFilterChipState createState() => _AvesFilterChipState();
}
@ -178,7 +220,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
setState(() => _tapped = true);
}
: null,
onLongPress: widget.onLongPress != null ? () => widget.onLongPress(filter, _tapPosition) : null,
onLongPress: widget.onLongPress != null ? () => widget.onLongPress(context, filter, _tapPosition) : null,
borderRadius: borderRadius,
child: FutureBuilder<Color>(
future: _colorFuture,

View file

@ -47,7 +47,7 @@ class _AlbumPickPageState extends State<AlbumPickPage> {
Widget appBar = AlbumPickAppBar(
source: source,
moveType: widget.moveType,
actionDelegate: AlbumChipSetActionDelegate(source: source),
actionDelegate: AlbumChipSetActionDelegate(),
queryNotifier: _queryNotifier,
);
@ -57,7 +57,6 @@ class _AlbumPickPageState extends State<AlbumPickPage> {
return StreamBuilder(
stream: source.eventBus.on<AlbumsChangedEvent>(),
builder: (context, snapshot) => FilterGridPage<AlbumFilter>(
source: source,
appBar: appBar,
filterSections: AlbumListPage.getAlbumEntries(source),
showHeaders: settings.albumGroupFactor != AlbumChipGroupFactor.none,

View file

@ -35,8 +35,8 @@ class AlbumListPage extends StatelessWidget {
title: 'Albums',
groupable: true,
showHeaders: settings.albumGroupFactor != AlbumChipGroupFactor.none,
chipSetActionDelegate: AlbumChipSetActionDelegate(source: source),
chipActionDelegate: AlbumChipActionDelegate(source: source),
chipSetActionDelegate: AlbumChipSetActionDelegate(),
chipActionDelegate: AlbumChipActionDelegate(),
chipActionsBuilder: (filter) => [
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
ChipAction.rename,

View file

@ -2,6 +2,7 @@ import 'package:aves/model/actions/chip_actions.dart';
import 'package:aves/model/actions/move_type.dart';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/highlight.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/image_file_service.dart';
@ -11,17 +12,15 @@ import 'package:aves/widgets/common/action_mixins/permission_aware.dart';
import 'package:aves/widgets/common/action_mixins/size_aware.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:aves/widgets/dialogs/rename_album_dialog.dart';
import 'package:aves/widgets/filter_grids/albums_page.dart';
import 'package:aves/widgets/filter_grids/countries_page.dart';
import 'package:aves/widgets/filter_grids/tags_page.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:path/path.dart' as path;
import 'package:provider/provider.dart';
class ChipActionDelegate {
final CollectionSource source;
ChipActionDelegate({
@required this.source,
});
void onActionSelected(BuildContext context, CollectionFilter filter, ChipAction action) {
switch (action) {
case ChipAction.pin:
@ -31,19 +30,67 @@ class ChipActionDelegate {
settings.pinnedFilters = settings.pinnedFilters..remove(filter);
break;
case ChipAction.hide:
source.changeFilterVisibility(filter, false);
_hide(context, filter);
break;
case ChipAction.goToAlbumPage:
_goTo(context, filter, AlbumListPage.routeName, (context) => AlbumListPage());
break;
case ChipAction.goToCountryPage:
_goTo(context, filter, CountryListPage.routeName, (context) => CountryListPage());
break;
case ChipAction.goToTagPage:
_goTo(context, filter, TagListPage.routeName, (context) => TagListPage());
break;
default:
break;
}
}
Future<void> _hide(BuildContext context, CollectionFilter filter) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) {
return AvesDialog(
context: context,
content: Text('Matching photos and videos will be hidden from your collection. You can show them again from the “Privacy” settings.\n\nAre you sure that you want to hide this?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('Cancel'.toUpperCase()),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text('Hide'.toUpperCase()),
),
],
);
},
);
if (confirmed == null || !confirmed) return;
final source = context.read<CollectionSource>();
source.changeFilterVisibility(filter, false);
}
void _goTo(
BuildContext context,
CollectionFilter filter,
String routeName,
WidgetBuilder pageBuilder,
) {
context.read<HighlightInfo>().set(filter);
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(
settings: RouteSettings(name: routeName),
builder: pageBuilder,
),
(route) => false,
);
}
}
class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
AlbumChipActionDelegate({
@required CollectionSource source,
}) : super(source: source);
@override
void onActionSelected(BuildContext context, CollectionFilter filter, ChipAction action) {
super.onActionSelected(context, filter, action);
@ -60,6 +107,7 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per
}
Future<void> _showDeleteDialog(BuildContext context, AlbumFilter filter) async {
final source = context.read<CollectionSource>();
final selection = source.visibleEntries.where(filter.test).toSet();
final count = selection.length;
@ -106,6 +154,7 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per
}
Future<void> _showRenameDialog(BuildContext context, AlbumFilter filter) async {
final source = context.read<CollectionSource>();
final album = filter.album;
final newName = await showDialog<String>(
context: context,

View file

@ -5,15 +5,15 @@ import 'package:aves/model/source/enums.dart';
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
import 'package:aves/widgets/stats/stats.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
abstract class ChipSetActionDelegate {
CollectionSource get source;
ChipSortFactor get sortFactor;
set sortFactor(ChipSortFactor factor);
void onActionSelected(BuildContext context, ChipSetAction action) {
final source = context.read<CollectionSource>();
switch (action) {
case ChipSetAction.sort:
_showSortDialog(context);
@ -48,6 +48,7 @@ abstract class ChipSetActionDelegate {
}
void _goToStats(BuildContext context) {
final source = context.read<CollectionSource>();
Navigator.push(
context,
MaterialPageRoute(
@ -61,13 +62,6 @@ abstract class ChipSetActionDelegate {
}
class AlbumChipSetActionDelegate extends ChipSetActionDelegate {
@override
final CollectionSource source;
AlbumChipSetActionDelegate({
@required this.source,
});
@override
ChipSortFactor get sortFactor => settings.albumSortFactor;
@ -106,13 +100,6 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate {
}
class CountryChipSetActionDelegate extends ChipSetActionDelegate {
@override
final CollectionSource source;
CountryChipSetActionDelegate({
@required this.source,
});
@override
ChipSortFactor get sortFactor => settings.countrySortFactor;
@ -121,13 +108,6 @@ class CountryChipSetActionDelegate extends ChipSetActionDelegate {
}
class TagChipSetActionDelegate extends ChipSetActionDelegate {
@override
final CollectionSource source;
TagChipSetActionDelegate({
@required this.source,
});
@override
ChipSortFactor get sortFactor => settings.tagSortFactor;

View file

@ -1,9 +1,9 @@
import 'dart:math';
import 'dart:ui';
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
@ -16,9 +16,9 @@ import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart';
import 'package:aves/widgets/filter_grids/common/overlay.dart';
import 'package:decorated_icon/decorated_icon.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class DecoratedFilterChip extends StatelessWidget {
final CollectionSource source;
final CollectionFilter filter;
final AvesEntry entry;
final double extent;
@ -28,7 +28,6 @@ class DecoratedFilterChip extends StatelessWidget {
const DecoratedFilterChip({
Key key,
@required this.source,
@required this.filter,
@required this.entry,
@required this.extent,
@ -116,11 +115,13 @@ class DecoratedFilterChip extends StatelessWidget {
size: iconSize,
),
),
Text(
'${source.count(filter)}',
style: TextStyle(
color: FilterGridPage.detailColor,
fontSize: fontSize,
Consumer<CollectionSource>(
builder: (context, source, child) => Text(
'${source.count(filter)}',
style: TextStyle(
color: FilterGridPage.detailColor,
fontSize: fontSize,
),
),
),
],

View file

@ -3,7 +3,7 @@ import 'dart:ui';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/highlight.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/common/basic/draggable_scrollbar.dart';
import 'package:aves/widgets/common/basic/insets.dart';
import 'package:aves/widgets/common/behaviour/double_back_pop.dart';
@ -13,7 +13,6 @@ import 'package:aves/widgets/common/grid/section_layout.dart';
import 'package:aves/widgets/common/grid/sliver.dart';
import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
import 'package:aves/widgets/common/identity/scroll_thumb.dart';
import 'package:aves/widgets/common/providers/highlight_info_provider.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/common/scaling.dart';
import 'package:aves/widgets/common/tile_extent_manager.dart';
@ -27,7 +26,6 @@ import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
import 'package:provider/provider.dart';
class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
final CollectionSource source;
final Widget appBar;
final Map<ChipSectionKey, List<FilterGridItem<T>>> filterSections;
final bool showHeaders;
@ -40,7 +38,6 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
final ValueNotifier<double> _appBarHeightNotifier = ValueNotifier(0);
final ValueNotifier<double> _tileExtentNotifier = ValueNotifier(0);
final GlobalKey _scrollableKey = GlobalKey(debugLabel: 'filter-grid-page-scrollable');
static const columnCountDefault = 2;
static const extentMin = 60.0;
@ -48,7 +45,6 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
FilterGridPage({
Key key,
@required this.source,
@required this.appBar,
@required this.filterSections,
this.showHeaders = false,
@ -70,112 +66,80 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
return MediaQueryDataProvider(
child: Scaffold(
body: DoubleBackPopScope(
child: HighlightInfoProvider(
child: GestureAreaProtectorStack(
child: SafeArea(
bottom: false,
child: LayoutBuilder(
builder: (context, constraints) {
final viewportSize = constraints.biggest;
assert(viewportSize.isFinite, 'Cannot layout collection with unbounded constraints.');
if (viewportSize.isEmpty) return SizedBox.shrink();
child: GestureAreaProtectorStack(
child: SafeArea(
bottom: false,
child: LayoutBuilder(
builder: (context, constraints) {
final viewportSize = constraints.biggest;
assert(viewportSize.isFinite, 'Cannot layout collection with unbounded constraints.');
if (viewportSize.isEmpty) return SizedBox.shrink();
final tileExtentManager = TileExtentManager(
settingsRouteKey: settingsRouteKey ?? context.currentRouteName,
extentNotifier: _tileExtentNotifier,
columnCountDefault: columnCountDefault,
extentMin: extentMin,
spacing: spacing,
)..applyTileExtent(viewportSize: viewportSize);
final tileExtentManager = TileExtentManager(
settingsRouteKey: settingsRouteKey ?? context.currentRouteName,
extentNotifier: _tileExtentNotifier,
columnCountDefault: columnCountDefault,
extentMin: extentMin,
spacing: spacing,
)..applyTileExtent(viewportSize: viewportSize);
final pinnedFilters = settings.pinnedFilters;
return ValueListenableBuilder<String>(
valueListenable: queryNotifier,
builder: (context, query, child) {
Map<ChipSectionKey, List<FilterGridItem<T>>> visibleFilterSections;
if (applyQuery == null) {
visibleFilterSections = filterSections;
} else {
visibleFilterSections = {};
filterSections.forEach((sectionKey, sectionFilters) {
final visibleFilters = applyQuery(sectionFilters, query);
if (visibleFilters.isNotEmpty) {
visibleFilterSections[sectionKey] = visibleFilters.toList();
}
});
}
return ValueListenableBuilder<String>(
valueListenable: queryNotifier,
builder: (context, query, child) {
Map<ChipSectionKey, List<FilterGridItem<T>>> visibleFilterSections;
if (applyQuery == null) {
visibleFilterSections = filterSections;
} else {
visibleFilterSections = {};
filterSections.forEach((sectionKey, sectionFilters) {
final visibleFilters = applyQuery(sectionFilters, query);
if (visibleFilters.isNotEmpty) {
visibleFilterSections[sectionKey] = visibleFilters.toList();
}
});
}
final scrollView = AnimationLimiter(
child: _buildDraggableScrollView(_buildScrollView(context, visibleFilterSections.isEmpty)),
);
final scaler = GridScaleGestureDetector<FilterGridItem<T>>(
tileExtentManager: tileExtentManager,
scrollableKey: _scrollableKey,
appBarHeightNotifier: _appBarHeightNotifier,
viewportSize: viewportSize,
gridBuilder: (center, extent, child) => CustomPaint(
painter: GridPainter(
center: center,
extent: extent,
spacing: tileExtentManager.spacing,
color: Colors.grey.shade700,
),
child: child,
),
scaledBuilder: (item, extent) {
final filter = item.filter;
return DecoratedFilterChip(
source: source,
filter: filter,
entry: item.entry,
extent: extent,
pinned: pinnedFilters.contains(filter),
highlightable: false,
final pinnedFilters = settings.pinnedFilters;
final sectionedListLayoutProvider = ValueListenableBuilder<double>(
valueListenable: _tileExtentNotifier,
builder: (context, tileExtent, child) => SectionedFilterListLayoutProvider<T>(
sections: visibleFilterSections,
showHeaders: showHeaders,
scrollableWidth: viewportSize.width,
tileExtent: tileExtent,
columnCount: tileExtentManager.getEffectiveColumnCountForExtent(viewportSize, tileExtent),
spacing: spacing,
tileBuilder: (gridItem) {
final filter = gridItem.filter;
final entry = gridItem.entry;
return MetaData(
metaData: ScalerMetadata(FilterGridItem<T>(filter, entry)),
child: DecoratedFilterChip(
key: Key(filter.key),
filter: filter,
entry: entry,
extent: _tileExtentNotifier.value,
pinned: pinnedFilters.contains(filter),
onTap: onTap,
onLongPress: onLongPress,
),
);
},
getScaledItemTileRect: (context, item) {
final sectionedListLayout = context.read<SectionedListLayout<FilterGridItem<T>>>();
return sectionedListLayout.getTileRect(item) ?? Rect.zero;
},
onScaled: (item) => context.read<HighlightInfo>().add(item.filter),
child: scrollView,
);
final sectionedListLayoutProvider = ValueListenableBuilder<double>(
valueListenable: _tileExtentNotifier,
builder: (context, tileExtent, child) => SectionedFilterListLayoutProvider<T>(
sections: visibleFilterSections,
showHeaders: showHeaders,
scrollableWidth: viewportSize.width,
tileExtent: tileExtent,
columnCount: tileExtentManager.getEffectiveColumnCountForExtent(viewportSize, tileExtent),
spacing: spacing,
tileBuilder: (gridItem) {
final filter = gridItem.filter;
final entry = gridItem.entry;
return MetaData(
metaData: ScalerMetadata(FilterGridItem<T>(filter, entry)),
child: DecoratedFilterChip(
key: Key(filter.key),
source: source,
filter: filter,
entry: entry,
extent: _tileExtentNotifier.value,
pinned: pinnedFilters.contains(filter),
onTap: onTap,
onLongPress: onLongPress,
),
);
},
child: scaler,
child: _SectionedContent<T>(
appBar: appBar,
appBarHeightNotifier: _appBarHeightNotifier,
visibleFilterSections: visibleFilterSections,
emptyBuilder: emptyBuilder,
viewportSize: viewportSize,
tileExtentManager: tileExtentManager,
scrollController: PrimaryScrollController.of(context),
),
);
return sectionedListLayoutProvider;
},
);
},
),
),
);
return sectionedListLayoutProvider;
},
);
},
),
),
),
@ -185,6 +149,125 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
),
);
}
}
class _SectionedContent<T extends CollectionFilter> extends StatefulWidget {
final Widget appBar;
final ValueNotifier<double> appBarHeightNotifier;
final Map<ChipSectionKey, List<FilterGridItem<T>>> visibleFilterSections;
final Widget Function() emptyBuilder;
final Size viewportSize;
final TileExtentManager tileExtentManager;
final ScrollController scrollController;
const _SectionedContent({
@required this.appBar,
@required this.appBarHeightNotifier,
@required this.visibleFilterSections,
@required this.emptyBuilder,
@required this.viewportSize,
@required this.tileExtentManager,
@required this.scrollController,
});
@override
_SectionedContentState createState() => _SectionedContentState<T>();
}
class _SectionedContentState<T extends CollectionFilter> extends State<_SectionedContent<T>> {
Widget get appBar => widget.appBar;
ValueNotifier<double> get appBarHeightNotifier => widget.appBarHeightNotifier;
Map<ChipSectionKey, List<FilterGridItem<T>>> get visibleFilterSections => widget.visibleFilterSections;
Widget Function() get emptyBuilder => widget.emptyBuilder;
Size get viewportSize => widget.viewportSize;
TileExtentManager get tileExtentManager => widget.tileExtentManager;
ScrollController get scrollController => widget.scrollController;
final GlobalKey _scrollableKey = GlobalKey(debugLabel: 'filter-grid-page-scrollable');
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _checkInitHighlight());
}
Future<void> _checkInitHighlight() async {
final highlightInfo = context.read<HighlightInfo>();
final filter = highlightInfo.clear();
if (filter is T) {
final gridItem = visibleFilterSections.values.expand((list) => list).firstWhere((gridItem) => gridItem.filter == filter, orElse: () => null);
if (gridItem != null) {
await Future.delayed(Durations.highlightScrollInitDelay);
final sectionedListLayout = context.read<SectionedListLayout<FilterGridItem<T>>>();
final tileRect = sectionedListLayout.getTileRect(gridItem);
await _scrollToItem(tileRect);
highlightInfo.set(filter);
}
}
}
Future<void> _scrollToItem(Rect tileRect) async {
final scrollableContext = _scrollableKey.currentContext;
final scrollableHeight = (scrollableContext.findRenderObject() as RenderBox).size.height;
// most of the time the app bar will be scrolled away after scaling,
// so we compensate for it to center the focal point thumbnail
final appBarHeight = appBarHeightNotifier.value;
final scrollOffset = tileRect.top + (tileRect.height - scrollableHeight) / 2 + appBarHeight;
if (scrollOffset > 0) {
await scrollController.animateTo(
scrollOffset,
duration: Duration(milliseconds: (scrollOffset / 2).round().clamp(Durations.highlightScrollAnimationMinMillis, Durations.highlightScrollAnimationMaxMillis)),
curve: Curves.easeInOutCubic,
);
}
}
@override
Widget build(BuildContext context) {
final pinnedFilters = settings.pinnedFilters;
return GridScaleGestureDetector<FilterGridItem<T>>(
tileExtentManager: tileExtentManager,
scrollableKey: _scrollableKey,
appBarHeightNotifier: appBarHeightNotifier,
viewportSize: viewportSize,
gridBuilder: (center, extent, child) => CustomPaint(
painter: GridPainter(
center: center,
extent: extent,
spacing: tileExtentManager.spacing,
color: Colors.grey.shade700,
),
child: child,
),
scaledBuilder: (item, extent) {
final filter = item.filter;
return DecoratedFilterChip(
filter: filter,
entry: item.entry,
extent: extent,
pinned: pinnedFilters.contains(filter),
highlightable: false,
);
},
getScaledItemTileRect: (context, item) {
final sectionedListLayout = context.read<SectionedListLayout<FilterGridItem<T>>>();
return sectionedListLayout.getTileRect(item) ?? Rect.zero;
},
onScaled: (item) => context.read<HighlightInfo>().set(item.filter),
child: AnimationLimiter(
child: _buildDraggableScrollView(_buildScrollView(context, visibleFilterSections.isEmpty)),
),
);
}
Widget _buildDraggableScrollView(ScrollView scrollView) {
return Selector<MediaQueryData, double>(
@ -196,10 +279,10 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
height: avesScrollThumbHeight,
backgroundColor: Colors.white,
),
controller: PrimaryScrollController.of(context),
controller: scrollController,
padding: EdgeInsets.only(
// padding to keep scroll thumb between app bar above and nav bar below
top: _appBarHeightNotifier.value,
top: appBarHeightNotifier.value,
bottom: mqPaddingBottom,
),
child: scrollView,
@ -228,7 +311,7 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
return CustomScrollView(
key: _scrollableKey,
controller: PrimaryScrollController.of(context),
controller: scrollController,
slivers: [
appBar,
content,

View file

@ -48,7 +48,6 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
Widget build(BuildContext context) {
return FilterGridPage<T>(
key: ValueKey('filter-grid-page'),
source: source,
appBar: SliverAppBar(
title: TappableAppBarTitle(
onTap: () => _goToSearch(context),
@ -80,11 +79,11 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
)),
),
),
onLongPress: AvesApp.mode == AppMode.main ? (filter, tapPosition) => _showMenu(context, filter, tapPosition) : null,
onLongPress: AvesApp.mode == AppMode.main ? _showMenu : null,
);
}
Future<void> _showMenu(BuildContext context, T filter, Offset tapPosition) async {
void _showMenu(BuildContext context, T filter, Offset tapPosition) async {
final RenderBox overlay = Overlay.of(context).context.findRenderObject();
final touchArea = Size(40, 40);
// TODO TLAD show menu within safe area

View file

@ -44,7 +44,7 @@ class _ChipHighlightOverlayState extends State<ChipHighlightOverlay> {
toggledNotifier: _highlightedNotifier,
startAngle: pi * -3 / 4,
centerSweep: false,
onSweepEnd: () => highlightInfo.remove(filter),
onSweepEnd: highlightInfo.clear,
);
}
}

View file

@ -30,8 +30,8 @@ class CountryListPage extends StatelessWidget {
builder: (context, snapshot) => FilterNavigationPage<LocationFilter>(
source: source,
title: 'Countries',
chipSetActionDelegate: CountryChipSetActionDelegate(source: source),
chipActionDelegate: ChipActionDelegate(source: source),
chipSetActionDelegate: CountryChipSetActionDelegate(),
chipActionDelegate: ChipActionDelegate(),
chipActionsBuilder: (filter) => [
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
ChipAction.hide,

View file

@ -30,8 +30,8 @@ class TagListPage extends StatelessWidget {
builder: (context, snapshot) => FilterNavigationPage<TagFilter>(
source: source,
title: 'Tags',
chipSetActionDelegate: TagChipSetActionDelegate(source: source),
chipActionDelegate: ChipActionDelegate(source: source),
chipSetActionDelegate: TagChipSetActionDelegate(),
chipActionDelegate: ChipActionDelegate(),
chipActionsBuilder: (filter) => [
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
ChipAction.hide,

View file

@ -5,6 +5,7 @@ import 'package:aves/model/filters/location.dart';
import 'package:aves/model/filters/mime.dart';
import 'package:aves/model/filters/query.dart';
import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/filters/type.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/album.dart';
import 'package:aves/model/source/collection_lens.dart';
@ -81,11 +82,11 @@ class CollectionSearchDelegate {
FavouriteFilter(),
MimeFilter(MimeTypes.anyImage),
MimeFilter(MimeTypes.anyVideo),
MimeFilter(MimeFilter.animated),
MimeFilter(MimeFilter.panorama),
MimeFilter(MimeFilter.sphericalVideo),
MimeFilter(MimeFilter.geotiff),
MimeFilter(MimeTypes.svg),
TypeFilter(TypeFilter.animated),
TypeFilter(TypeFilter.panorama),
TypeFilter(TypeFilter.sphericalVideo),
TypeFilter(TypeFilter.geotiff),
].where((f) => f != null && containQuery(f.label)).toList(),
// usually perform hero animation only on tapped chips,
// but we also need to animate the query chip when it is selected by submitting the search query

View file

@ -69,6 +69,7 @@ class HiddenFilterPage extends StatelessWidget {
filter: filter,
removable: true,
onTap: (filter) => context.read<CollectionSource>().changeFilterVisibility(filter, true),
onLongPress: null,
))
.toList(),
);

View file

@ -5,6 +5,7 @@ import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/favourite.dart';
import 'package:aves/model/filters/mime.dart';
import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/filters/type.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/ref/mime_types.dart';
import 'package:aves/services/metadata_service.dart';
@ -72,14 +73,16 @@ class BasicSection extends StatelessWidget {
Widget _buildChips() {
final tags = entry.xmpSubjects..sort(compareAsciiUpperCase);
final album = entry.directory;
final filters = [
if (entry.isAnimated) MimeFilter(MimeFilter.animated),
if (entry.isImage && entry.is360) MimeFilter(MimeFilter.panorama),
if (entry.isVideo) MimeFilter(entry.is360 ? MimeFilter.sphericalVideo : MimeTypes.anyVideo),
if (entry.isGeotiff) MimeFilter(MimeFilter.geotiff),
final filters = {
MimeFilter(entry.mimeType),
if (entry.isAnimated) TypeFilter(TypeFilter.animated),
if (entry.isGeotiff) TypeFilter(TypeFilter.geotiff),
if (entry.isImage && entry.is360) TypeFilter(TypeFilter.panorama),
if (entry.isVideo && entry.is360) TypeFilter(TypeFilter.sphericalVideo),
if (entry.isVideo && !entry.is360) MimeFilter(MimeTypes.anyVideo),
if (album != null) AlbumFilter(album, collection?.source?.getUniqueAlbumName(album)),
...tags.map((tag) => TagFilter(tag)),
];
};
return AnimatedBuilder(
animation: favourites.changeNotifier,
builder: (context, child) {

View file

@ -5,6 +5,7 @@ import 'package:aves/model/filters/location.dart';
import 'package:aves/model/filters/mime.dart';
import 'package:aves/model/filters/query.dart';
import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/filters/type.dart';
import 'package:aves/ref/mime_types.dart';
import 'package:test/test.dart';
@ -21,6 +22,9 @@ void main() {
final location = LocationFilter(LocationLevel.country, 'France${LocationFilter.locationSeparator}FR');
expect(location, jsonRoundTrip(location));
final type = TypeFilter(TypeFilter.sphericalVideo);
expect(type, jsonRoundTrip(type));
final mime = MimeFilter(MimeTypes.anyVideo);
expect(mime, jsonRoundTrip(mime));