filter grid scaling (WIP)

This commit is contained in:
Thibault Deckers 2020-11-26 14:44:22 +09:00
parent a4fab7339d
commit e218afc6b6
15 changed files with 469 additions and 323 deletions

View file

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

View file

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

View file

@ -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';

View file

@ -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;
}

View file

@ -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;
},

View file

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

View file

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

View 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;
}
}

View file

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

View file

@ -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';

View file

@ -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,
),
),
],
);
}

View file

@ -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);
}

View 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);
}
}

View file

@ -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';

View file

@ -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';