filter grid scaling (WIP)
This commit is contained in:
parent
a4fab7339d
commit
e218afc6b6
15 changed files with 469 additions and 323 deletions
|
@ -29,11 +29,11 @@ class Settings extends ChangeNotifier {
|
|||
static const keepScreenOnKey = 'keep_screen_on';
|
||||
static const homePageKey = 'home_page';
|
||||
static const catalogTimeZoneKey = 'catalog_time_zone';
|
||||
static const tileExtentPrefixKey = 'tile_extent_';
|
||||
|
||||
// collection
|
||||
static const collectionGroupFactorKey = 'collection_group_factor';
|
||||
static const collectionSortFactorKey = 'collection_sort_factor';
|
||||
static const collectionTileExtentKey = 'collection_tile_extent';
|
||||
static const showThumbnailLocationKey = 'show_thumbnail_location';
|
||||
static const showThumbnailRawKey = 'show_thumbnail_raw';
|
||||
static const showThumbnailVideoDurationKey = 'show_thumbnail_video_duration';
|
||||
|
@ -112,6 +112,12 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
set catalogTimeZone(String newValue) => setAndNotify(catalogTimeZoneKey, newValue);
|
||||
|
||||
double getTileExtent(String routeName) => _prefs.getDouble(tileExtentPrefixKey + routeName) ?? 0;
|
||||
|
||||
// do not notify, as tile extents are only used internally by `TileExtentManager`
|
||||
// and should not trigger rebuilding by change notification
|
||||
void setTileExtent(String routeName, double newValue) => setAndNotify(tileExtentPrefixKey + routeName, newValue, notify: false);
|
||||
|
||||
// collection
|
||||
|
||||
EntryGroupFactor get collectionGroupFactor => getEnumOrDefault(collectionGroupFactorKey, EntryGroupFactor.month, EntryGroupFactor.values);
|
||||
|
@ -122,12 +128,6 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
set collectionSortFactor(EntrySortFactor newValue) => setAndNotify(collectionSortFactorKey, newValue.toString());
|
||||
|
||||
double get collectionTileExtent => _prefs.getDouble(collectionTileExtentKey) ?? 0;
|
||||
|
||||
// do not notify, as `collectionTileExtent` is only used internally by `TileExtentManager`
|
||||
// and should not trigger rebuilding by change notification
|
||||
set collectionTileExtent(double newValue) => setAndNotify(collectionTileExtentKey, newValue, notify: false);
|
||||
|
||||
bool get showThumbnailLocation => getBoolOrDefault(showThumbnailLocationKey, true);
|
||||
|
||||
set showThumbnailLocation(bool newValue) => setAndNotify(showThumbnailLocationKey, newValue);
|
||||
|
|
|
@ -3,7 +3,7 @@ import 'dart:math';
|
|||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/widgets/collection/grid/header_generic.dart';
|
||||
import 'package:aves/widgets/collection/grid/tile_extent_manager.dart';
|
||||
import 'package:aves/widgets/collection/thumbnail_collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
|
@ -22,7 +22,7 @@ class SectionedListLayoutProvider extends StatelessWidget {
|
|||
@required this.thumbnailBuilder,
|
||||
@required this.child,
|
||||
}) : assert(scrollableWidth != 0),
|
||||
columnCount = max((scrollableWidth / tileExtent).round(), TileExtentManager.columnCountMin);
|
||||
columnCount = max((scrollableWidth / tileExtent).round(), ThumbnailCollection.columnCountMin);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
|
@ -4,7 +4,7 @@ import 'package:aves/model/source/collection_lens.dart';
|
|||
import 'package:aves/services/viewer_service.dart';
|
||||
import 'package:aves/widgets/collection/grid/list_known_extent.dart';
|
||||
import 'package:aves/widgets/collection/grid/list_section_layout.dart';
|
||||
import 'package:aves/widgets/collection/grid/scaling.dart';
|
||||
import 'package:aves/widgets/common/scaling.dart';
|
||||
import 'package:aves/widgets/collection/thumbnail/decorated.dart';
|
||||
import 'package:aves/widgets/common/behaviour/routes.dart';
|
||||
import 'package:aves/widgets/fullscreen/fullscreen_page.dart';
|
||||
|
|
|
@ -1,46 +0,0 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class TileExtentManager {
|
||||
static const int columnCountMin = 2;
|
||||
static const int columnCountDefault = 4;
|
||||
static const double tileExtentMin = 46.0;
|
||||
static const viewportSizeMin = Size.square(tileExtentMin * columnCountMin);
|
||||
|
||||
static double applyTileExtent(
|
||||
Size viewportSize,
|
||||
ValueNotifier<double> extentNotifier, {
|
||||
double userPreferredExtent = 0,
|
||||
}) {
|
||||
// sanitize screen size (useful when reloading while screen is off, reporting a 0,0 size)
|
||||
viewportSize = Size(max(viewportSize.width, viewportSizeMin.width), max(viewportSize.height, viewportSizeMin.height));
|
||||
|
||||
final oldUserPreferredExtent = settings.collectionTileExtent;
|
||||
final currentExtent = extentNotifier.value;
|
||||
final targetExtent = userPreferredExtent > 0
|
||||
? userPreferredExtent
|
||||
: oldUserPreferredExtent > 0
|
||||
? oldUserPreferredExtent
|
||||
: currentExtent;
|
||||
|
||||
int columnCount;
|
||||
if (targetExtent > 0) {
|
||||
columnCount = max(columnCountMin, (viewportSize.width / targetExtent.clamp(tileExtentMin, extentMaxForSize(viewportSize))).round());
|
||||
} else {
|
||||
columnCount = columnCountDefault;
|
||||
}
|
||||
final newExtent = viewportSize.width / columnCount;
|
||||
|
||||
if (userPreferredExtent > 0 || oldUserPreferredExtent == 0) {
|
||||
settings.collectionTileExtent = newExtent;
|
||||
}
|
||||
if (extentNotifier.value != newExtent) {
|
||||
extentNotifier.value = newExtent;
|
||||
}
|
||||
return newExtent;
|
||||
}
|
||||
|
||||
static double extentMaxForSize(Size viewportSize) => viewportSize.shortestSide / columnCountMin;
|
||||
}
|
|
@ -3,19 +3,20 @@ import 'dart:async';
|
|||
import 'package:aves/model/filters/favourite.dart';
|
||||
import 'package:aves/model/filters/mime.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/ref/mime_types.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/ref/mime_types.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/widgets/collection/app_bar.dart';
|
||||
import 'package:aves/widgets/collection/empty.dart';
|
||||
import 'package:aves/widgets/collection/grid/list_section_layout.dart';
|
||||
import 'package:aves/widgets/collection/grid/list_sliver.dart';
|
||||
import 'package:aves/widgets/collection/grid/scaling.dart';
|
||||
import 'package:aves/widgets/collection/grid/tile_extent_manager.dart';
|
||||
import 'package:aves/widgets/common/scaling.dart';
|
||||
import 'package:aves/widgets/common/tile_extent_manager.dart';
|
||||
import 'package:aves/widgets/collection/thumbnail/decorated.dart';
|
||||
import 'package:aves/widgets/common/behaviour/routes.dart';
|
||||
import 'package:aves/widgets/common/behaviour/sloppy_scroll_physics.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/widgets/common/identity/scroll_thumb.dart';
|
||||
import 'package:draggable_scrollbar/draggable_scrollbar.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
@ -28,6 +29,10 @@ class ThumbnailCollection extends StatelessWidget {
|
|||
final ValueNotifier<bool> _isScrollingNotifier = ValueNotifier(false);
|
||||
final GlobalKey _scrollableKey = GlobalKey();
|
||||
|
||||
static const columnCountMin = 2;
|
||||
static const columnCountDefault = 4;
|
||||
static const extentMin = 46.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
|
@ -37,8 +42,15 @@ class ThumbnailCollection extends StatelessWidget {
|
|||
assert(viewportSize.isFinite, 'Cannot layout collection with unbounded constraints.');
|
||||
if (viewportSize.isEmpty) return SizedBox.shrink();
|
||||
|
||||
TileExtentManager.applyTileExtent(viewportSize, _tileExtentNotifier);
|
||||
final cacheExtent = TileExtentManager.extentMaxForSize(viewportSize) * 2;
|
||||
final tileExtentManager = TileExtentManager(
|
||||
routeName: context.currentRouteName,
|
||||
columnCountMin: columnCountMin,
|
||||
columnCountDefault: columnCountDefault,
|
||||
extentMin: extentMin,
|
||||
extentNotifier: _tileExtentNotifier,
|
||||
spacing: 0,
|
||||
)..applyTileExtent(viewportSize: viewportSize);
|
||||
final cacheExtent = tileExtentManager.getEffectiveExtentMax(viewportSize) * 2;
|
||||
|
||||
// do not replace by Provider.of<CollectionLens>
|
||||
// so that view updates on collection filter changes
|
||||
|
@ -58,16 +70,17 @@ class ThumbnailCollection extends StatelessWidget {
|
|||
);
|
||||
|
||||
final scaler = GridScaleGestureDetector<ImageEntry>(
|
||||
tileExtentManager: tileExtentManager,
|
||||
scrollableKey: _scrollableKey,
|
||||
appBarHeightNotifier: _appBarHeightNotifier,
|
||||
extentNotifier: _tileExtentNotifier,
|
||||
viewportSize: viewportSize,
|
||||
showScaledGrid: true,
|
||||
scaledBuilder: (entry, extent) => DecoratedThumbnail(
|
||||
entry: entry,
|
||||
extent: extent,
|
||||
showOverlay: false,
|
||||
),
|
||||
getScaledItemTileRect: (entry) {
|
||||
getScaledItemTileRect: (context, entry) {
|
||||
final sectionedListLayout = Provider.of<SectionedListLayout>(context, listen: false);
|
||||
return sectionedListLayout.getTileRect(entry) ?? Rect.zero;
|
||||
},
|
||||
|
|
|
@ -14,6 +14,7 @@ class AvesFilterChip extends StatefulWidget {
|
|||
final bool showGenericIcon;
|
||||
final Widget background;
|
||||
final Widget details;
|
||||
final double padding;
|
||||
final HeroType heroType;
|
||||
final FilterCallback onTap;
|
||||
final OffsetFilterCallback onLongPress;
|
||||
|
@ -24,7 +25,6 @@ class AvesFilterChip extends StatefulWidget {
|
|||
static const double minChipWidth = 80;
|
||||
static const double maxChipWidth = 160;
|
||||
static const double iconSize = 20;
|
||||
static const double padding = 6;
|
||||
|
||||
const AvesFilterChip({
|
||||
Key key,
|
||||
|
@ -33,8 +33,9 @@ class AvesFilterChip extends StatefulWidget {
|
|||
this.showGenericIcon = true,
|
||||
this.background,
|
||||
this.details,
|
||||
this.padding = 6.0,
|
||||
this.heroType = HeroType.onTap,
|
||||
@required this.onTap,
|
||||
this.onTap,
|
||||
this.onLongPress,
|
||||
}) : assert(filter != null),
|
||||
super(key: key);
|
||||
|
@ -51,6 +52,8 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
|
|||
|
||||
CollectionFilter get filter => widget.filter;
|
||||
|
||||
double get padding => widget.padding;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
@ -80,9 +83,11 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const iconSize = AvesFilterChip.iconSize;
|
||||
|
||||
final hasBackground = widget.background != null;
|
||||
final leading = filter.iconBuilder(context, AvesFilterChip.iconSize, showGenericIcon: widget.showGenericIcon, embossed: hasBackground);
|
||||
final trailing = widget.removable ? Icon(AIcons.clear, size: AvesFilterChip.iconSize) : null;
|
||||
final leading = filter.iconBuilder(context, iconSize, showGenericIcon: widget.showGenericIcon, embossed: hasBackground);
|
||||
final trailing = widget.removable ? Icon(AIcons.clear, size: iconSize) : null;
|
||||
|
||||
Widget content = Row(
|
||||
mainAxisSize: hasBackground ? MainAxisSize.max : MainAxisSize.min,
|
||||
|
@ -90,7 +95,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
|
|||
children: [
|
||||
if (leading != null) ...[
|
||||
leading,
|
||||
SizedBox(width: AvesFilterChip.padding),
|
||||
SizedBox(width: padding),
|
||||
],
|
||||
Flexible(
|
||||
child: Text(
|
||||
|
@ -101,7 +106,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
|
|||
),
|
||||
),
|
||||
if (trailing != null) ...[
|
||||
SizedBox(width: AvesFilterChip.padding),
|
||||
SizedBox(width: padding),
|
||||
trailing,
|
||||
],
|
||||
],
|
||||
|
@ -118,7 +123,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
|
|||
}
|
||||
|
||||
content = Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: AvesFilterChip.padding * 2, vertical: 2),
|
||||
padding: EdgeInsets.symmetric(horizontal: padding * 2, vertical: 2),
|
||||
child: content,
|
||||
);
|
||||
|
||||
|
|
|
@ -2,9 +2,9 @@ import 'dart:math';
|
|||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/widgets/collection/grid/tile_extent_manager.dart';
|
||||
import 'package:aves/widgets/collection/thumbnail/decorated.dart';
|
||||
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
||||
import 'package:aves/widgets/common/tile_extent_manager.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
|
||||
|
@ -16,20 +16,22 @@ class ScalerMetadata<T> {
|
|||
}
|
||||
|
||||
class GridScaleGestureDetector<T> extends StatefulWidget {
|
||||
final TileExtentManager tileExtentManager;
|
||||
final GlobalKey scrollableKey;
|
||||
final ValueNotifier<double> appBarHeightNotifier;
|
||||
final ValueNotifier<double> extentNotifier;
|
||||
final Size viewportSize;
|
||||
final bool showScaledGrid;
|
||||
final Widget Function(T item, double extent) scaledBuilder;
|
||||
final Rect Function(T item) getScaledItemTileRect;
|
||||
final Rect Function(BuildContext context, T item) getScaledItemTileRect;
|
||||
final void Function(T item) onScaled;
|
||||
final Widget child;
|
||||
|
||||
const GridScaleGestureDetector({
|
||||
this.scrollableKey,
|
||||
@required this.tileExtentManager,
|
||||
@required this.scrollableKey,
|
||||
@required this.appBarHeightNotifier,
|
||||
@required this.extentNotifier,
|
||||
@required this.viewportSize,
|
||||
@required this.showScaledGrid,
|
||||
@required this.scaledBuilder,
|
||||
@required this.getScaledItemTileRect,
|
||||
@required this.onScaled,
|
||||
|
@ -40,14 +42,16 @@ class GridScaleGestureDetector<T> extends StatefulWidget {
|
|||
_GridScaleGestureDetectorState createState() => _GridScaleGestureDetectorState<T>();
|
||||
}
|
||||
|
||||
class _GridScaleGestureDetectorState<T> extends State<GridScaleGestureDetector> {
|
||||
class _GridScaleGestureDetectorState<T> extends State<GridScaleGestureDetector<T>> {
|
||||
double _startExtent, _extentMin, _extentMax;
|
||||
bool _applyingScale = false;
|
||||
ValueNotifier<double> _scaledExtentNotifier;
|
||||
OverlayEntry _overlayEntry;
|
||||
ScalerMetadata<T> _metadata;
|
||||
|
||||
ValueNotifier<double> get tileExtentNotifier => widget.extentNotifier;
|
||||
TileExtentManager get tileExtentManager => widget.tileExtentManager;
|
||||
|
||||
Size get viewportSize => widget.viewportSize;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
@ -72,13 +76,15 @@ class _GridScaleGestureDetectorState<T> extends State<GridScaleGestureDetector>
|
|||
// abort if we cannot find an image to show on overlay
|
||||
if (renderMetaData == null) return;
|
||||
_metadata = renderMetaData.metaData;
|
||||
_startExtent = tileExtentNotifier.value;
|
||||
_startExtent = renderMetaData.size.width;
|
||||
_scaledExtentNotifier = ValueNotifier(_startExtent);
|
||||
|
||||
// not the same as `MediaQuery.size.width`, because of screen insets/padding
|
||||
final gridWidth = scrollableBox.size.width;
|
||||
_extentMin = gridWidth / (gridWidth / TileExtentManager.tileExtentMin).round();
|
||||
_extentMax = gridWidth / (gridWidth / TileExtentManager.extentMaxForSize(widget.viewportSize)).round();
|
||||
|
||||
_extentMin = tileExtentManager.getEffectiveExtentMin(viewportSize);
|
||||
_extentMax = tileExtentManager.getEffectiveExtentMax(viewportSize);
|
||||
|
||||
final halfExtent = _startExtent / 2;
|
||||
final thumbnailCenter = renderMetaData.localToGlobal(Offset(halfExtent, halfExtent));
|
||||
_overlayEntry = OverlayEntry(
|
||||
|
@ -87,6 +93,7 @@ class _GridScaleGestureDetectorState<T> extends State<GridScaleGestureDetector>
|
|||
center: thumbnailCenter,
|
||||
gridWidth: gridWidth,
|
||||
scaledExtentNotifier: _scaledExtentNotifier,
|
||||
showScaledGrid: widget.showScaledGrid,
|
||||
),
|
||||
);
|
||||
Overlay.of(scrollableContext).insert(_overlayEntry);
|
||||
|
@ -104,11 +111,10 @@ class _GridScaleGestureDetectorState<T> extends State<GridScaleGestureDetector>
|
|||
}
|
||||
|
||||
_applyingScale = true;
|
||||
final oldExtent = tileExtentNotifier.value;
|
||||
final oldExtent = tileExtentManager.extentNotifier.value;
|
||||
// sanitize and update grid layout if necessary
|
||||
final newExtent = TileExtentManager.applyTileExtent(
|
||||
widget.viewportSize,
|
||||
tileExtentNotifier,
|
||||
final newExtent = tileExtentManager.applyTileExtent(
|
||||
viewportSize: widget.viewportSize,
|
||||
userPreferredExtent: _scaledExtentNotifier.value,
|
||||
);
|
||||
_scaledExtentNotifier = null;
|
||||
|
@ -137,7 +143,7 @@ class _GridScaleGestureDetectorState<T> extends State<GridScaleGestureDetector>
|
|||
void _scrollToItem(T item) {
|
||||
final scrollableContext = widget.scrollableKey.currentContext;
|
||||
final scrollableHeight = (scrollableContext.findRenderObject() as RenderBox).size.height;
|
||||
final tileRect = widget.getScaledItemTileRect(item);
|
||||
final tileRect = widget.getScaledItemTileRect(context, item);
|
||||
// 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 = widget.appBarHeightNotifier.value;
|
||||
|
@ -152,12 +158,14 @@ class ScaleOverlay extends StatefulWidget {
|
|||
final Offset center;
|
||||
final double gridWidth;
|
||||
final ValueNotifier<double> scaledExtentNotifier;
|
||||
final bool showScaledGrid;
|
||||
|
||||
const ScaleOverlay({
|
||||
@required this.builder,
|
||||
@required this.center,
|
||||
@required this.gridWidth,
|
||||
@required this.scaledExtentNotifier,
|
||||
@required this.showScaledGrid,
|
||||
});
|
||||
|
||||
@override
|
||||
|
@ -217,24 +225,29 @@ class _ScaleOverlayState extends State<ScaleOverlay> {
|
|||
}
|
||||
final clampedCenter = center.translate(dx, 0);
|
||||
|
||||
return CustomPaint(
|
||||
painter: GridPainter(
|
||||
center: clampedCenter,
|
||||
extent: extent,
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
left: clampedCenter.dx - extent / 2,
|
||||
top: clampedCenter.dy - extent / 2,
|
||||
child: DefaultTextStyle(
|
||||
style: TextStyle(),
|
||||
child: widget.builder(extent),
|
||||
),
|
||||
var child = widget.builder(extent);
|
||||
child = Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
left: clampedCenter.dx - extent / 2,
|
||||
top: clampedCenter.dy - extent / 2,
|
||||
child: DefaultTextStyle(
|
||||
style: TextStyle(),
|
||||
child: child,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
if (widget.showScaledGrid) {
|
||||
child = CustomPaint(
|
||||
painter: GridPainter(
|
||||
center: clampedCenter,
|
||||
extent: extent,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
return child;
|
||||
},
|
||||
),
|
||||
),
|
70
lib/widgets/common/tile_extent_manager.dart
Normal file
70
lib/widgets/common/tile_extent_manager.dart
Normal file
|
@ -0,0 +1,70 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class TileExtentManager {
|
||||
final String routeName;
|
||||
final int columnCountMin, columnCountDefault;
|
||||
final double spacing, extentMin;
|
||||
final ValueNotifier<double> extentNotifier;
|
||||
|
||||
const TileExtentManager({
|
||||
@required this.routeName,
|
||||
@required this.columnCountMin,
|
||||
@required this.columnCountDefault,
|
||||
@required this.extentMin,
|
||||
@required this.extentNotifier,
|
||||
@required this.spacing,
|
||||
});
|
||||
|
||||
double applyTileExtent({
|
||||
@required Size viewportSize,
|
||||
double userPreferredExtent = 0,
|
||||
}) {
|
||||
// sanitize screen size (useful when reloading while screen is off, reporting a 0,0 size)
|
||||
final viewportSizeMin = Size.square(extentMin * columnCountMin);
|
||||
viewportSize = Size(max(viewportSize.width, viewportSizeMin.width), max(viewportSize.height, viewportSizeMin.height));
|
||||
|
||||
final oldUserPreferredExtent = settings.getTileExtent(routeName);
|
||||
final currentExtent = extentNotifier.value;
|
||||
final targetExtent = userPreferredExtent > 0
|
||||
? userPreferredExtent
|
||||
: oldUserPreferredExtent > 0
|
||||
? oldUserPreferredExtent
|
||||
: currentExtent;
|
||||
|
||||
final columnCount = getEffectiveColumnCountForExtent(viewportSize, targetExtent);
|
||||
final newExtent = _extentForColumnCount(viewportSize, columnCount);
|
||||
|
||||
if (userPreferredExtent > 0 || oldUserPreferredExtent == 0) {
|
||||
settings.setTileExtent(routeName, newExtent);
|
||||
}
|
||||
if (extentNotifier.value != newExtent) {
|
||||
extentNotifier.value = newExtent;
|
||||
}
|
||||
return newExtent;
|
||||
}
|
||||
|
||||
double _extentMax(Size viewportSize) => (viewportSize.shortestSide - spacing * (columnCountMin - 1)) / columnCountMin;
|
||||
|
||||
double _columnCountForExtent(Size viewportSize, double extent) => (viewportSize.width + spacing) / (extent + spacing);
|
||||
|
||||
double _extentForColumnCount(Size viewportSize, int columnCount) => (viewportSize.width - spacing * (columnCount - 1)) / columnCount;
|
||||
|
||||
int _effectiveColumnCountMin(Size viewportSize) => _columnCountForExtent(viewportSize, _extentMax(viewportSize)).ceil();
|
||||
|
||||
int _effectiveColumnCountMax(Size viewportSize) => _columnCountForExtent(viewportSize, extentMin).floor();
|
||||
|
||||
double getEffectiveExtentMin(Size viewportSize) => _extentForColumnCount(viewportSize, _effectiveColumnCountMax(viewportSize));
|
||||
|
||||
double getEffectiveExtentMax(Size viewportSize) => _extentForColumnCount(viewportSize, _effectiveColumnCountMin(viewportSize));
|
||||
|
||||
int getEffectiveColumnCountForExtent(Size viewportSize, double extent) {
|
||||
if (extent > 0) {
|
||||
final columnCount = _columnCountForExtent(viewportSize, extent);
|
||||
return columnCount.clamp(_effectiveColumnCountMin(viewportSize), _effectiveColumnCountMax(viewportSize)).round();
|
||||
}
|
||||
return columnCountDefault;
|
||||
}
|
||||
}
|
|
@ -1,5 +1,9 @@
|
|||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/widgets/collection/collection_page.dart';
|
||||
import 'package:aves/widgets/common/identity/aves_expansion_tile.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:aves/widgets/fullscreen/info/common.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
@ -27,7 +31,10 @@ class DebugSettingsSection extends StatelessWidget {
|
|||
Padding(
|
||||
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
|
||||
child: InfoRowGroup({
|
||||
'collectionTileExtent': '${settings.collectionTileExtent}',
|
||||
'tileExtent - Collection': '${settings.getTileExtent(CollectionPage.routeName)}',
|
||||
'tileExtent - Albums': '${settings.getTileExtent(AlbumListPage.routeName)}',
|
||||
'tileExtent - Countries': '${settings.getTileExtent(CountryListPage.routeName)}',
|
||||
'tileExtent - Tags': '${settings.getTileExtent(TagListPage.routeName)}',
|
||||
'infoMapZoom': '${settings.infoMapZoom}',
|
||||
'pinnedFilters': toMultiline(settings.pinnedFilters),
|
||||
'searchHistory': toMultiline(settings.searchHistory),
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
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/image_entry.dart';
|
||||
|
@ -5,13 +6,12 @@ import 'package:aves/model/settings/settings.dart';
|
|||
import 'package:aves/model/source/album.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/source/enums.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:aves/widgets/collection/empty.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart';
|
||||
import 'package:aves/model/actions/chip_actions.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/filter_nav_page.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
|
|
@ -1,16 +1,17 @@
|
|||
import 'dart:math';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:aves/model/filters/album.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:aves/utils/constants.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/widgets/collection/thumbnail/raster.dart';
|
||||
import 'package:aves/widgets/collection/thumbnail/vector.dart';
|
||||
import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart';
|
||||
import 'package:decorated_icon/decorated_icon.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
@ -19,6 +20,7 @@ class DecoratedFilterChip extends StatelessWidget {
|
|||
final CollectionSource source;
|
||||
final CollectionFilter filter;
|
||||
final ImageEntry entry;
|
||||
final double extent;
|
||||
final bool pinned;
|
||||
final FilterCallback onTap;
|
||||
final OffsetFilterCallback onLongPress;
|
||||
|
@ -28,8 +30,9 @@ class DecoratedFilterChip extends StatelessWidget {
|
|||
@required this.source,
|
||||
@required this.filter,
|
||||
@required this.entry,
|
||||
@required this.extent,
|
||||
this.pinned = false,
|
||||
@required this.onTap,
|
||||
this.onTap,
|
||||
this.onLongPress,
|
||||
}) : super(key: key);
|
||||
|
||||
|
@ -40,57 +43,60 @@ class DecoratedFilterChip extends StatelessWidget {
|
|||
: entry.isSvg
|
||||
? ThumbnailVectorImage(
|
||||
entry: entry,
|
||||
extent: FilterGridPage.maxCrossAxisExtent,
|
||||
extent: extent,
|
||||
)
|
||||
: ThumbnailRasterImage(
|
||||
entry: entry,
|
||||
extent: FilterGridPage.maxCrossAxisExtent,
|
||||
extent: extent,
|
||||
);
|
||||
final titlePadding = min<double>(6.0, extent / 16);
|
||||
return AvesFilterChip(
|
||||
filter: filter,
|
||||
showGenericIcon: false,
|
||||
background: backgroundImage,
|
||||
details: _buildDetails(filter),
|
||||
padding: titlePadding,
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetails(CollectionFilter filter) {
|
||||
final count = Text(
|
||||
'${source.count(filter)}',
|
||||
style: TextStyle(color: FilterGridPage.detailColor),
|
||||
);
|
||||
final padding = min<double>(8.0, extent / 16);
|
||||
final iconSize = min<double>(14.0, extent / 8);
|
||||
final fontSize = min<double>(14.0, (extent / 6).roundToDouble());
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AnimatedCrossFade(
|
||||
firstChild: Padding(
|
||||
padding: EdgeInsets.only(right: 8),
|
||||
if (pinned)
|
||||
AnimatedPadding(
|
||||
padding: EdgeInsets.only(right: padding),
|
||||
child: DecoratedIcon(
|
||||
AIcons.pin,
|
||||
color: FilterGridPage.detailColor,
|
||||
shadows: [Constants.embossShadow],
|
||||
size: 16,
|
||||
size: iconSize,
|
||||
),
|
||||
duration: Durations.chipDecorationAnimation,
|
||||
),
|
||||
secondChild: SizedBox.shrink(),
|
||||
sizeCurve: Curves.easeInOutCubic,
|
||||
alignment: AlignmentDirectional.centerEnd,
|
||||
crossFadeState: pinned ? CrossFadeState.showFirst : CrossFadeState.showSecond,
|
||||
duration: Durations.chipDecorationAnimation,
|
||||
),
|
||||
if (filter is AlbumFilter && androidFileUtils.isOnRemovableStorage(filter.album))
|
||||
Padding(
|
||||
padding: EdgeInsets.only(right: 8),
|
||||
AnimatedPadding(
|
||||
padding: EdgeInsets.only(right: padding),
|
||||
child: DecoratedIcon(
|
||||
AIcons.removableStorage,
|
||||
color: FilterGridPage.detailColor,
|
||||
shadows: [Constants.embossShadow],
|
||||
size: 16,
|
||||
size: iconSize,
|
||||
),
|
||||
duration: Durations.chipDecorationAnimation,
|
||||
),
|
||||
count,
|
||||
Text(
|
||||
'${source.count(filter)}',
|
||||
style: TextStyle(
|
||||
color: FilterGridPage.detailColor,
|
||||
fontSize: fontSize,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,193 +1,59 @@
|
|||
import 'dart:ui';
|
||||
|
||||
import 'package:aves/main.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/widgets/collection/collection_page.dart';
|
||||
import 'package:aves/widgets/common/app_bar_subtitle.dart';
|
||||
import 'package:aves/widgets/common/app_bar_title.dart';
|
||||
import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
|
||||
import 'package:aves/widgets/common/behaviour/double_back_pop.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/widgets/common/basic/menu_row.dart';
|
||||
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
||||
import 'package:aves/widgets/common/behaviour/routes.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/media_query_data_provider.dart';
|
||||
import 'package:aves/widgets/common/scaling.dart';
|
||||
import 'package:aves/widgets/common/tile_extent_manager.dart';
|
||||
import 'package:aves/widgets/drawer/app_drawer.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart';
|
||||
import 'package:aves/model/actions/chip_actions.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/decorated_filter_chip.dart';
|
||||
import 'package:aves/widgets/search/search_button.dart';
|
||||
import 'package:aves/widgets/search/search_delegate.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:draggable_scrollbar/draggable_scrollbar.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class FilterNavigationPage extends StatelessWidget {
|
||||
final CollectionSource source;
|
||||
final String title;
|
||||
final ChipSetActionDelegate chipSetActionDelegate;
|
||||
final ChipActionDelegate chipActionDelegate;
|
||||
final Map<String, ImageEntry> filterEntries;
|
||||
final CollectionFilter Function(String key) filterBuilder;
|
||||
final Widget Function() emptyBuilder;
|
||||
final List<ChipAction> Function(CollectionFilter filter) chipActionsBuilder;
|
||||
|
||||
const FilterNavigationPage({
|
||||
@required this.source,
|
||||
@required this.title,
|
||||
@required this.chipSetActionDelegate,
|
||||
@required this.chipActionDelegate,
|
||||
@required this.chipActionsBuilder,
|
||||
@required this.filterEntries,
|
||||
@required this.filterBuilder,
|
||||
@required this.emptyBuilder,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FilterGridPage(
|
||||
source: source,
|
||||
appBar: SliverAppBar(
|
||||
title: TappableAppBarTitle(
|
||||
onTap: () => _goToSearch(context),
|
||||
child: SourceStateAwareAppBarTitle(
|
||||
title: Text(title),
|
||||
source: source,
|
||||
),
|
||||
),
|
||||
actions: _buildActions(context),
|
||||
titleSpacing: 0,
|
||||
floating: true,
|
||||
),
|
||||
filterEntries: filterEntries,
|
||||
filterBuilder: filterBuilder,
|
||||
emptyBuilder: () => ValueListenableBuilder<SourceState>(
|
||||
valueListenable: source.stateNotifier,
|
||||
builder: (context, sourceState, child) {
|
||||
return sourceState != SourceState.loading && emptyBuilder != null ? emptyBuilder() : SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
onTap: (filter) => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
settings: RouteSettings(name: CollectionPage.routeName),
|
||||
builder: (context) => CollectionPage(CollectionLens(
|
||||
source: source,
|
||||
filters: [filter],
|
||||
groupFactor: settings.collectionGroupFactor,
|
||||
sortFactor: settings.collectionSortFactor,
|
||||
)),
|
||||
),
|
||||
),
|
||||
onLongPress: AvesApp.mode == AppMode.main ? (filter, tapPosition) => _showMenu(context, filter, tapPosition) : null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showMenu(BuildContext context, CollectionFilter filter, Offset tapPosition) async {
|
||||
final RenderBox overlay = Overlay.of(context).context.findRenderObject();
|
||||
final touchArea = Size(40, 40);
|
||||
final selectedAction = await showMenu<ChipAction>(
|
||||
context: context,
|
||||
position: RelativeRect.fromRect(tapPosition & touchArea, Offset.zero & overlay.size),
|
||||
items: chipActionsBuilder(filter)
|
||||
.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));
|
||||
}
|
||||
}
|
||||
|
||||
List<Widget> _buildActions(BuildContext context) {
|
||||
return [
|
||||
SearchButton(source),
|
||||
PopupMenuButton<ChipSetAction>(
|
||||
key: Key('appbar-menu-button'),
|
||||
itemBuilder: (context) {
|
||||
return [
|
||||
PopupMenuItem(
|
||||
key: Key('menu-sort'),
|
||||
value: ChipSetAction.sort,
|
||||
child: MenuRow(text: 'Sort…', icon: AIcons.sort),
|
||||
),
|
||||
if (kDebugMode)
|
||||
PopupMenuItem(
|
||||
value: ChipSetAction.refresh,
|
||||
child: MenuRow(text: 'Refresh', icon: AIcons.refresh),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: ChipSetAction.stats,
|
||||
child: MenuRow(text: 'Stats', icon: AIcons.stats),
|
||||
),
|
||||
];
|
||||
},
|
||||
onSelected: (action) {
|
||||
// wait for the popup menu to hide before proceeding with the action
|
||||
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => chipSetActionDelegate.onActionSelected(context, action));
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
void _goToSearch(BuildContext context) {
|
||||
Navigator.push(
|
||||
context,
|
||||
SearchPageRoute(
|
||||
delegate: ImageSearchDelegate(
|
||||
source: source,
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
static int compareChipsByDate(MapEntry<String, ImageEntry> a, MapEntry<String, ImageEntry> b) {
|
||||
final c = b.value.bestDate?.compareTo(a.value.bestDate) ?? -1;
|
||||
return c != 0 ? c : compareAsciiUpperCase(a.key, b.key);
|
||||
}
|
||||
|
||||
static int compareChipsByEntryCount(MapEntry<String, num> a, MapEntry<String, num> b) {
|
||||
final c = b.value.compareTo(a.value) ?? -1;
|
||||
return c != 0 ? c : compareAsciiUpperCase(a.key, b.key);
|
||||
}
|
||||
}
|
||||
|
||||
class FilterGridPage extends StatelessWidget {
|
||||
final CollectionSource source;
|
||||
final Widget appBar;
|
||||
final Map<String, ImageEntry> filterEntries;
|
||||
final CollectionFilter Function(String key) filterBuilder;
|
||||
final Widget Function() emptyBuilder;
|
||||
final double appBarHeight;
|
||||
final FilterCallback onTap;
|
||||
final OffsetFilterCallback onLongPress;
|
||||
|
||||
const FilterGridPage({
|
||||
final ValueNotifier<double> _appBarHeightNotifier = ValueNotifier(0);
|
||||
final ValueNotifier<double> _tileExtentNotifier = ValueNotifier(0);
|
||||
final GlobalKey _scrollableKey = GlobalKey();
|
||||
|
||||
static const spacing = 8.0;
|
||||
|
||||
FilterGridPage({
|
||||
@required this.source,
|
||||
@required this.appBar,
|
||||
@required this.filterEntries,
|
||||
@required this.filterBuilder,
|
||||
@required this.emptyBuilder,
|
||||
this.appBarHeight = kToolbarHeight,
|
||||
double appBarHeight = kToolbarHeight,
|
||||
@required this.onTap,
|
||||
this.onLongPress,
|
||||
});
|
||||
}) {
|
||||
_appBarHeightNotifier.value = appBarHeight;
|
||||
}
|
||||
|
||||
List<String> get filterKeys => filterEntries.keys.toList();
|
||||
|
||||
static const Color detailColor = Color(0xFFE0E0E0);
|
||||
static const double maxCrossAxisExtent = 180;
|
||||
|
||||
// TODO TLAD enforce max extent?
|
||||
// static const double maxCrossAxisExtent = 180;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
@ -195,13 +61,59 @@ class FilterGridPage extends StatelessWidget {
|
|||
child: Scaffold(
|
||||
body: DoubleBackPopScope(
|
||||
child: SafeArea(
|
||||
child: Selector<MediaQueryData, double>(
|
||||
selector: (c, mq) => mq.size.width,
|
||||
builder: (c, mqWidth, child) {
|
||||
final columnCount = (mqWidth / maxCrossAxisExtent).ceil();
|
||||
final scrollView = _buildScrollView(context, columnCount);
|
||||
return AnimationLimiter(
|
||||
child: _buildDraggableScrollView(scrollView),
|
||||
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(
|
||||
routeName: context.currentRouteName,
|
||||
columnCountMin: 2,
|
||||
columnCountDefault: 2,
|
||||
extentMin: 60,
|
||||
extentNotifier: _tileExtentNotifier,
|
||||
spacing: spacing,
|
||||
)..applyTileExtent(viewportSize: viewportSize);
|
||||
|
||||
return ValueListenableBuilder<double>(
|
||||
valueListenable: _tileExtentNotifier,
|
||||
builder: (context, tileExtent, child) {
|
||||
final columnCount = tileExtentManager.getEffectiveColumnCountForExtent(viewportSize, tileExtent);
|
||||
final scrollView = AnimationLimiter(
|
||||
child: _buildDraggableScrollView(_buildScrollView(context, columnCount)),
|
||||
);
|
||||
|
||||
return GridScaleGestureDetector<FilterGridItem>(
|
||||
tileExtentManager: tileExtentManager,
|
||||
scrollableKey: _scrollableKey,
|
||||
appBarHeightNotifier: _appBarHeightNotifier,
|
||||
viewportSize: viewportSize,
|
||||
showScaledGrid: false,
|
||||
scaledBuilder: (item, extent) {
|
||||
final filter = item.filter;
|
||||
return SizedBox(
|
||||
width: extent,
|
||||
height: extent,
|
||||
child: DecoratedFilterChip(
|
||||
source: source,
|
||||
filter: filter,
|
||||
entry: item.entry,
|
||||
extent: extent,
|
||||
pinned: settings.pinnedFilters.contains(filter),
|
||||
),
|
||||
);
|
||||
},
|
||||
getScaledItemTileRect: (context, item) {
|
||||
// TODO TLAD
|
||||
return Rect.zero;
|
||||
},
|
||||
onScaled: (item) {
|
||||
// TODO TLAD
|
||||
},
|
||||
child: scrollView,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
|
@ -228,7 +140,7 @@ class FilterGridPage extends StatelessWidget {
|
|||
controller: PrimaryScrollController.of(context),
|
||||
padding: EdgeInsets.only(
|
||||
// padding to keep scroll thumb between app bar above and nav bar below
|
||||
top: appBarHeight,
|
||||
top: _appBarHeightNotifier.value,
|
||||
bottom: mqViewInsetsBottom,
|
||||
),
|
||||
child: scrollView,
|
||||
|
@ -239,6 +151,7 @@ class FilterGridPage extends StatelessWidget {
|
|||
ScrollView _buildScrollView(BuildContext context, int columnCount) {
|
||||
final pinnedFilters = settings.pinnedFilters;
|
||||
return CustomScrollView(
|
||||
key: _scrollableKey,
|
||||
controller: PrimaryScrollController.of(context),
|
||||
slivers: [
|
||||
appBar,
|
||||
|
@ -255,42 +168,44 @@ class FilterGridPage extends StatelessWidget {
|
|||
),
|
||||
hasScrollBody: false,
|
||||
)
|
||||
: SliverPadding(
|
||||
padding: EdgeInsets.all(AvesFilterChip.outlineWidth),
|
||||
sliver: SliverGrid(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, i) {
|
||||
final key = filterKeys[i];
|
||||
final filter = filterBuilder(key);
|
||||
final child = DecoratedFilterChip(
|
||||
: SliverGrid(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, i) {
|
||||
final key = filterKeys[i];
|
||||
final filter = filterBuilder(key);
|
||||
final entry = filterEntries[key];
|
||||
final child = MetaData(
|
||||
metaData: ScalerMetadata(FilterGridItem(filter, entry)),
|
||||
child: DecoratedFilterChip(
|
||||
key: Key(key),
|
||||
source: source,
|
||||
filter: filter,
|
||||
entry: filterEntries[key],
|
||||
entry: entry,
|
||||
extent: _tileExtentNotifier.value,
|
||||
pinned: pinnedFilters.contains(filter),
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
);
|
||||
return AnimationConfiguration.staggeredGrid(
|
||||
position: i,
|
||||
columnCount: columnCount,
|
||||
duration: Durations.staggeredAnimation,
|
||||
delay: Durations.staggeredAnimationDelay,
|
||||
child: SlideAnimation(
|
||||
verticalOffset: 50.0,
|
||||
child: FadeInAnimation(
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
return AnimationConfiguration.staggeredGrid(
|
||||
position: i,
|
||||
columnCount: columnCount,
|
||||
duration: Durations.staggeredAnimation,
|
||||
delay: Durations.staggeredAnimationDelay,
|
||||
child: SlideAnimation(
|
||||
verticalOffset: 50.0,
|
||||
child: FadeInAnimation(
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
childCount: filterKeys.length,
|
||||
),
|
||||
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: maxCrossAxisExtent,
|
||||
mainAxisSpacing: 8,
|
||||
crossAxisSpacing: 8,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
childCount: filterKeys.length,
|
||||
),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: columnCount,
|
||||
mainAxisSpacing: spacing,
|
||||
crossAxisSpacing: spacing,
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
|
@ -305,3 +220,10 @@ class FilterGridPage extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FilterGridItem {
|
||||
final CollectionFilter filter;
|
||||
final ImageEntry entry;
|
||||
|
||||
const FilterGridItem(this.filter, this.entry);
|
||||
}
|
||||
|
|
156
lib/widgets/filter_grids/common/filter_nav_page.dart
Normal file
156
lib/widgets/filter_grids/common/filter_nav_page.dart
Normal file
|
@ -0,0 +1,156 @@
|
|||
import 'dart:ui';
|
||||
|
||||
import 'package:aves/main.dart';
|
||||
import 'package:aves/model/actions/chip_actions.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/widgets/collection/collection_page.dart';
|
||||
import 'package:aves/widgets/common/app_bar_subtitle.dart';
|
||||
import 'package:aves/widgets/common/app_bar_title.dart';
|
||||
import 'package:aves/widgets/common/basic/menu_row.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart';
|
||||
import 'package:aves/widgets/search/search_button.dart';
|
||||
import 'package:aves/widgets/search/search_delegate.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
|
||||
class FilterNavigationPage extends StatelessWidget {
|
||||
final CollectionSource source;
|
||||
final String title;
|
||||
final ChipSetActionDelegate chipSetActionDelegate;
|
||||
final ChipActionDelegate chipActionDelegate;
|
||||
final Map<String, ImageEntry> filterEntries;
|
||||
final CollectionFilter Function(String key) filterBuilder;
|
||||
final Widget Function() emptyBuilder;
|
||||
final List<ChipAction> Function(CollectionFilter filter) chipActionsBuilder;
|
||||
|
||||
const FilterNavigationPage({
|
||||
@required this.source,
|
||||
@required this.title,
|
||||
@required this.chipSetActionDelegate,
|
||||
@required this.chipActionDelegate,
|
||||
@required this.chipActionsBuilder,
|
||||
@required this.filterEntries,
|
||||
@required this.filterBuilder,
|
||||
@required this.emptyBuilder,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FilterGridPage(
|
||||
source: source,
|
||||
appBar: SliverAppBar(
|
||||
title: TappableAppBarTitle(
|
||||
onTap: () => _goToSearch(context),
|
||||
child: SourceStateAwareAppBarTitle(
|
||||
title: Text(title),
|
||||
source: source,
|
||||
),
|
||||
),
|
||||
actions: _buildActions(context),
|
||||
titleSpacing: 0,
|
||||
floating: true,
|
||||
),
|
||||
filterEntries: filterEntries,
|
||||
filterBuilder: filterBuilder,
|
||||
emptyBuilder: () => ValueListenableBuilder<SourceState>(
|
||||
valueListenable: source.stateNotifier,
|
||||
builder: (context, sourceState, child) {
|
||||
return sourceState != SourceState.loading && emptyBuilder != null ? emptyBuilder() : SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
onTap: (filter) => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
settings: RouteSettings(name: CollectionPage.routeName),
|
||||
builder: (context) => CollectionPage(CollectionLens(
|
||||
source: source,
|
||||
filters: [filter],
|
||||
groupFactor: settings.collectionGroupFactor,
|
||||
sortFactor: settings.collectionSortFactor,
|
||||
)),
|
||||
),
|
||||
),
|
||||
onLongPress: AvesApp.mode == AppMode.main ? (filter, tapPosition) => _showMenu(context, filter, tapPosition) : null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showMenu(BuildContext context, CollectionFilter filter, Offset tapPosition) async {
|
||||
final RenderBox overlay = Overlay.of(context).context.findRenderObject();
|
||||
final touchArea = Size(40, 40);
|
||||
final selectedAction = await showMenu<ChipAction>(
|
||||
context: context,
|
||||
position: RelativeRect.fromRect(tapPosition & touchArea, Offset.zero & overlay.size),
|
||||
items: chipActionsBuilder(filter)
|
||||
.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));
|
||||
}
|
||||
}
|
||||
|
||||
List<Widget> _buildActions(BuildContext context) {
|
||||
return [
|
||||
SearchButton(source),
|
||||
PopupMenuButton<ChipSetAction>(
|
||||
key: Key('appbar-menu-button'),
|
||||
itemBuilder: (context) {
|
||||
return [
|
||||
PopupMenuItem(
|
||||
key: Key('menu-sort'),
|
||||
value: ChipSetAction.sort,
|
||||
child: MenuRow(text: 'Sort…', icon: AIcons.sort),
|
||||
),
|
||||
if (kDebugMode)
|
||||
PopupMenuItem(
|
||||
value: ChipSetAction.refresh,
|
||||
child: MenuRow(text: 'Refresh', icon: AIcons.refresh),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: ChipSetAction.stats,
|
||||
child: MenuRow(text: 'Stats', icon: AIcons.stats),
|
||||
),
|
||||
];
|
||||
},
|
||||
onSelected: (action) {
|
||||
// wait for the popup menu to hide before proceeding with the action
|
||||
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => chipSetActionDelegate.onActionSelected(context, action));
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
void _goToSearch(BuildContext context) {
|
||||
Navigator.push(
|
||||
context,
|
||||
SearchPageRoute(
|
||||
delegate: ImageSearchDelegate(
|
||||
source: source,
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
static int compareChipsByDate(MapEntry<String, ImageEntry> a, MapEntry<String, ImageEntry> b) {
|
||||
final c = b.value.bestDate?.compareTo(a.value.bestDate) ?? -1;
|
||||
return c != 0 ? c : compareAsciiUpperCase(a.key, b.key);
|
||||
}
|
||||
|
||||
static int compareChipsByEntryCount(MapEntry<String, num> a, MapEntry<String, num> b) {
|
||||
final c = b.value.compareTo(a.value) ?? -1;
|
||||
return c != 0 ? c : compareAsciiUpperCase(a.key, b.key);
|
||||
}
|
||||
}
|
|
@ -10,7 +10,7 @@ import 'package:aves/theme/icons.dart';
|
|||
import 'package:aves/widgets/collection/empty.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/filter_nav_page.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:aves/model/actions/chip_actions.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/filters/tag.dart';
|
||||
import 'package:aves/model/image_entry.dart';
|
||||
|
@ -5,12 +6,11 @@ import 'package:aves/model/settings/settings.dart';
|
|||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/source/enums.dart';
|
||||
import 'package:aves/model/source/tag.dart';
|
||||
import 'package:aves/widgets/collection/empty.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/widgets/collection/empty.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart';
|
||||
import 'package:aves/model/actions/chip_actions.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/filter_nav_page.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
|
Loading…
Reference in a new issue