added popup menu to all filters: hide, go to page
info: added mime filter
This commit is contained in:
parent
1a69749539
commit
652405d375
27 changed files with 568 additions and 327 deletions
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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') {
|
||||
|
|
73
lib/model/filters/type.dart
Normal file
73
lib/model/filters/type.dart
Normal 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}';
|
||||
}
|
|
@ -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}';
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -155,7 +155,7 @@ class _ThumbnailHighlightOverlayState extends State<ThumbnailHighlightOverlay> {
|
|||
toggledNotifier: _highlightedNotifier,
|
||||
startAngle: pi * -3 / 4,
|
||||
centerSweep: false,
|
||||
onSweepEnd: () => highlightInfo.remove(entry),
|
||||
onSweepEnd: highlightInfo.clear,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -44,7 +44,7 @@ class _ChipHighlightOverlayState extends State<ChipHighlightOverlay> {
|
|||
toggledNotifier: _highlightedNotifier,
|
||||
startAngle: pi * -3 / 4,
|
||||
centerSweep: false,
|
||||
onSweepEnd: () => highlightInfo.remove(filter),
|
||||
onSweepEnd: highlightInfo.clear,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -69,6 +69,7 @@ class HiddenFilterPage extends StatelessWidget {
|
|||
filter: filter,
|
||||
removable: true,
|
||||
onTap: (filter) => context.read<CollectionSource>().changeFilterVisibility(filter, true),
|
||||
onLongPress: null,
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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));
|
||||
|
||||
|
|
Loading…
Reference in a new issue