diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index e8d50a8b0..cf52147ad 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -145,7 +145,7 @@ class Settings extends ChangeNotifier { double getTileExtent(String routeName) => _prefs.getDouble(tileExtentPrefixKey + routeName) ?? 0; - // do not notify, as tile extents are only used internally by `TileExtentManager` + // do not notify, as tile extents are only used internally by `TileExtentController` // and should not trigger rebuilding by change notification void setTileExtent(String routeName, double newValue) => setAndNotify(tileExtentPrefixKey + routeName, newValue, notify: false); diff --git a/lib/widgets/collection/thumbnail_collection.dart b/lib/widgets/collection/thumbnail_collection.dart index 380de3925..292ec7ce4 100644 --- a/lib/widgets/collection/thumbnail_collection.dart +++ b/lib/widgets/collection/thumbnail_collection.dart @@ -24,127 +24,143 @@ import 'package:aves/widgets/common/grid/section_layout.dart'; import 'package:aves/widgets/common/grid/sliver.dart'; import 'package:aves/widgets/common/identity/empty.dart'; import 'package:aves/widgets/common/identity/scroll_thumb.dart'; +import 'package:aves/widgets/common/providers/tile_extent_controller_provider.dart'; import 'package:aves/widgets/common/scaling.dart'; -import 'package:aves/widgets/common/tile_extent_manager.dart'; +import 'package:aves/widgets/common/tile_extent_controller.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; import 'package:provider/provider.dart'; class ThumbnailCollection extends StatelessWidget { - final ValueNotifier _appBarHeightNotifier = ValueNotifier(0); final ValueNotifier _tileExtentNotifier = ValueNotifier(0); - final ValueNotifier _isScrollingNotifier = ValueNotifier(false); - final GlobalKey _scrollableKey = GlobalKey(debugLabel: 'thumbnail-collection-scrollable'); - - static const columnCountDefault = 4; - static const extentMin = 46.0; - static const spacing = 0.0; @override Widget build(BuildContext context) { return SafeArea( bottom: false, - child: LayoutBuilder( - builder: (context, constraints) { - final viewportSize = constraints.biggest; - assert(viewportSize.isFinite, 'Cannot layout collection with unbounded constraints.'); - if (viewportSize.isEmpty) return SizedBox.shrink(); - - final tileExtentManager = TileExtentManager( - settingsRouteKey: context.currentRouteName, - extentNotifier: _tileExtentNotifier, - columnCountDefault: columnCountDefault, - extentMin: extentMin, - spacing: spacing, - )..applyTileExtent(viewportSize: viewportSize); - final cacheExtent = tileExtentManager.getEffectiveExtentMax(viewportSize) * 2; - final scrollController = PrimaryScrollController.of(context); - - // do not replace by Provider.of - // so that view updates on collection filter changes - return Consumer( - builder: (context, collection, child) { - final scrollView = AnimationLimiter( - child: CollectionScrollView( - scrollableKey: _scrollableKey, - collection: collection, - appBar: CollectionAppBar( - appBarHeightNotifier: _appBarHeightNotifier, - collection: collection, - ), - appBarHeightNotifier: _appBarHeightNotifier, - isScrollingNotifier: _isScrollingNotifier, - scrollController: scrollController, - cacheExtent: cacheExtent, - ), - ); - - final scaler = GridScaleGestureDetector( - tileExtentManager: tileExtentManager, - scrollableKey: _scrollableKey, - appBarHeightNotifier: _appBarHeightNotifier, - viewportSize: viewportSize, - gridBuilder: (center, extent, child) => CustomPaint( - // painting the thumbnail half-border on top of the grid yields artifacts, - // so we use a `foregroundPainter` to cover them instead - foregroundPainter: GridPainter( - center: center, - extent: extent, - spacing: tileExtentManager.spacing, - strokeWidth: DecoratedThumbnail.borderWidth * 2, - color: DecoratedThumbnail.borderColor, - ), - child: child, - ), - scaledBuilder: (entry, extent) => DecoratedThumbnail( - entry: entry, - extent: extent, - selectable: false, - highlightable: false, - ), - getScaledItemTileRect: (context, entry) { - final sectionedListLayout = context.read>(); - return sectionedListLayout.getTileRect(entry) ?? Rect.zero; - }, - onScaled: (entry) => context.read().set(entry), - child: scrollView, - ); - - final selector = GridSelectionGestureDetector( - selectable: AvesApp.mode == AppMode.main, - collection: collection, - scrollController: scrollController, - appBarHeightNotifier: _appBarHeightNotifier, - child: scaler, - ); - - final sectionedListLayoutProvider = ValueListenableBuilder( - valueListenable: _tileExtentNotifier, - builder: (context, tileExtent, child) => SectionedEntryListLayoutProvider( - collection: collection, - scrollableWidth: viewportSize.width, - tileExtent: tileExtent, - columnCount: tileExtentManager.getEffectiveColumnCountForExtent(viewportSize, tileExtent), - tileBuilder: (entry) => InteractiveThumbnail( - key: ValueKey(entry.contentId), - collection: collection, - entry: entry, - tileExtent: tileExtent, - isScrollingNotifier: _isScrollingNotifier, - ), - child: selector, - ), - ); - return sectionedListLayoutProvider; - }, - ); - }, + child: TileExtentControllerProvider( + controller: TileExtentController( + settingsRouteKey: context.currentRouteName, + extentNotifier: _tileExtentNotifier, + columnCountDefault: 4, + extentMin: 46, + spacing: 0, + ), + child: _ThumbnailCollectionContent(), ), ); } } +class _ThumbnailCollectionContent extends StatelessWidget { + final ValueNotifier _appBarHeightNotifier = ValueNotifier(0); + final ValueNotifier _isScrollingNotifier = ValueNotifier(false); + final GlobalKey _scrollableKey = GlobalKey(debugLabel: 'thumbnail-collection-scrollable'); + + @override + Widget build(BuildContext context) { + final scrollController = PrimaryScrollController.of(context); + return Consumer( + builder: (context, collection, child) { + final scrollView = AnimationLimiter( + child: CollectionScrollView( + scrollableKey: _scrollableKey, + collection: collection, + appBar: CollectionAppBar( + appBarHeightNotifier: _appBarHeightNotifier, + collection: collection, + ), + appBarHeightNotifier: _appBarHeightNotifier, + isScrollingNotifier: _isScrollingNotifier, + scrollController: scrollController, + ), + ); + + final scaler = _ThumbnailGridScaleGestureDetector( + scrollableKey: _scrollableKey, + appBarHeightNotifier: _appBarHeightNotifier, + child: scrollView, + ); + + final selector = GridSelectionGestureDetector( + selectable: AvesApp.mode == AppMode.main, + collection: collection, + scrollController: scrollController, + appBarHeightNotifier: _appBarHeightNotifier, + child: scaler, + ); + + final sectionedListLayoutProvider = ValueListenableBuilder( + valueListenable: context.select>((controller) => controller.extentNotifier), + builder: (context, tileExtent, child) { + return SectionedEntryListLayoutProvider( + collection: collection, + scrollableWidth: context.select((controller) => controller.viewportSize.width), + tileExtent: tileExtent, + columnCount: context.select((controller) => controller.getEffectiveColumnCountForExtent(tileExtent)), + tileBuilder: (entry) => InteractiveThumbnail( + key: ValueKey(entry.contentId), + collection: collection, + entry: entry, + tileExtent: tileExtent, + isScrollingNotifier: _isScrollingNotifier, + ), + child: selector, + ); + }, + ); + return sectionedListLayoutProvider; + }, + ); + } +} + +class _ThumbnailGridScaleGestureDetector extends StatelessWidget { + final GlobalKey scrollableKey; + final ValueNotifier appBarHeightNotifier; + final Widget child; + + const _ThumbnailGridScaleGestureDetector({ + @required this.scrollableKey, + @required this.appBarHeightNotifier, + @required this.child, + }); + + @override + Widget build(BuildContext context) { + final tileSpacing = context.select((controller) => controller.spacing); + return GridScaleGestureDetector( + scrollableKey: scrollableKey, + appBarHeightNotifier: appBarHeightNotifier, + gridBuilder: (center, extent, child) => CustomPaint( + // painting the thumbnail half-border on top of the grid yields artifacts, + // so we use a `foregroundPainter` to cover them instead + foregroundPainter: GridPainter( + center: center, + extent: extent, + spacing: tileSpacing, + strokeWidth: DecoratedThumbnail.borderWidth * 2, + color: DecoratedThumbnail.borderColor, + ), + child: child, + ), + scaledBuilder: (entry, extent) => DecoratedThumbnail( + entry: entry, + extent: extent, + selectable: false, + highlightable: false, + ), + getScaledItemTileRect: (context, entry) { + final sectionedListLayout = context.read>(); + return sectionedListLayout.getTileRect(entry) ?? Rect.zero; + }, + onScaled: (entry) => context.read().set(entry), + child: child, + ); + } +} + class CollectionScrollView extends StatefulWidget { final GlobalKey scrollableKey; final CollectionLens collection; @@ -152,7 +168,6 @@ class CollectionScrollView extends StatefulWidget { final ValueNotifier appBarHeightNotifier; final ValueNotifier isScrollingNotifier; final ScrollController scrollController; - final double cacheExtent; const CollectionScrollView({ @required this.scrollableKey, @@ -161,7 +176,6 @@ class CollectionScrollView extends StatefulWidget { @required this.appBarHeightNotifier, @required this.isScrollingNotifier, @required this.scrollController, - @required this.cacheExtent, }); @override @@ -216,7 +230,7 @@ class _CollectionScrollViewState extends State { // workaround to prevent scrolling the app bar away // when there is no content and we use `SliverFillRemaining` physics: collection.isEmpty ? NeverScrollableScrollPhysics() : SloppyScrollPhysics(parent: AlwaysScrollableScrollPhysics()), - cacheExtent: widget.cacheExtent, + cacheExtent: context.select((controller) => controller.effectiveExtentMax * 2), slivers: [ appBar, collection.isEmpty diff --git a/lib/widgets/common/fx/borders.dart b/lib/widgets/common/fx/borders.dart index 3c02d47f2..70ba4b054 100644 --- a/lib/widgets/common/fx/borders.dart +++ b/lib/widgets/common/fx/borders.dart @@ -1,9 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; class AvesCircleBorder { static const borderColor = Colors.white30; - static double _borderWidth(BuildContext context) => MediaQuery.of(context).devicePixelRatio > 2 ? 0.5 : 1.0; + static double _borderWidth(BuildContext context) => context.read().devicePixelRatio > 2 ? 0.5 : 1.0; static Border build(BuildContext context) { return Border.fromBorderSide(buildSide(context)); diff --git a/lib/widgets/common/grid/section_layout.dart b/lib/widgets/common/grid/section_layout.dart index 724a5f6b7..b1a6f1657 100644 --- a/lib/widgets/common/grid/section_layout.dart +++ b/lib/widgets/common/grid/section_layout.dart @@ -27,6 +27,11 @@ abstract class SectionedListLayoutProvider extends StatelessWidget { Widget build(BuildContext context) { return ProxyProvider0>( update: (context, _) => _updateLayouts(context), + updateShouldNotify: (previous, current) { + final previousLayouts = previous.sectionLayouts; + final currentLayouts = current.sectionLayouts; + return previousLayouts.length != currentLayouts.length || !previousLayouts.every(currentLayouts.contains); + }, child: child, ); } @@ -138,6 +143,16 @@ abstract class SectionedListLayoutProvider extends StatelessWidget { double getHeaderExtent(BuildContext context, SectionKey sectionKey); Widget buildHeader(BuildContext context, SectionKey sectionKey, double headerExtent); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DoubleProperty('scrollableWidth', scrollableWidth)); + properties.add(IntProperty('columnCount', columnCount)); + properties.add(DoubleProperty('spacing', spacing)); + properties.add(DoubleProperty('tileExtent', tileExtent)); + properties.add(DiagnosticsProperty('showHeaders', showHeaders)); + } } class SectionedListLayout { @@ -237,6 +252,12 @@ class SectionLayout { return bodyFirstIndex + (scrollOffset / mainAxisStride).ceil() - 1; } + @override + bool operator ==(Object other) => identical(this, other) || other is SectionLayout && runtimeType == other.runtimeType && sectionKey == other.sectionKey && firstIndex == other.firstIndex && lastIndex == other.lastIndex && minOffset == other.minOffset && maxOffset == other.maxOffset && headerExtent == other.headerExtent && tileExtent == other.tileExtent && spacing == other.spacing; + + @override + int get hashCode => hashValues(sectionKey, firstIndex, lastIndex, minOffset, maxOffset, headerExtent, tileExtent, spacing); + @override String toString() => '$runtimeType#${shortHash(this)}{sectionKey=$sectionKey, firstIndex=$firstIndex, lastIndex=$lastIndex, minOffset=$minOffset, maxOffset=$maxOffset, headerExtent=$headerExtent, tileExtent=$tileExtent, spacing=$spacing}'; } diff --git a/lib/widgets/common/providers/tile_extent_controller_provider.dart b/lib/widgets/common/providers/tile_extent_controller_provider.dart new file mode 100644 index 000000000..59dd4f31a --- /dev/null +++ b/lib/widgets/common/providers/tile_extent_controller_provider.dart @@ -0,0 +1,32 @@ +import 'package:aves/widgets/common/tile_extent_controller.dart'; +import 'package:flutter/widgets.dart'; +import 'package:provider/provider.dart'; + +class TileExtentControllerProvider extends StatelessWidget { + final TileExtentController controller; + final Widget child; + + const TileExtentControllerProvider({ + @required this.controller, + @required this.child, + }); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + return MultiProvider( + providers: [ + ProxyProvider0( + update: (_, __) => constraints.biggest, + ), + ProxyProvider( + update: (_, viewportSize, __) => controller..applyTileExtent(viewportSize: viewportSize), + ), + ], + child: child, + ); + }, + ); + } +} diff --git a/lib/widgets/common/scaling.dart b/lib/widgets/common/scaling.dart index 10b479fc1..eec50a12d 100644 --- a/lib/widgets/common/scaling.dart +++ b/lib/widgets/common/scaling.dart @@ -2,9 +2,10 @@ import 'dart:ui' as ui; import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; -import 'package:aves/widgets/common/tile_extent_manager.dart'; +import 'package:aves/widgets/common/tile_extent_controller.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import 'package:provider/provider.dart'; // metadata to identify entry from RenderObject hit test during collection scaling class ScalerMetadata { @@ -14,10 +15,8 @@ class ScalerMetadata { } class GridScaleGestureDetector extends StatefulWidget { - final TileExtentManager tileExtentManager; final GlobalKey scrollableKey; final ValueNotifier appBarHeightNotifier; - final Size viewportSize; final Widget Function(Offset center, double extent, Widget child) gridBuilder; final Widget Function(T item, double extent) scaledBuilder; final Rect Function(BuildContext context, T item) getScaledItemTileRect; @@ -25,10 +24,8 @@ class GridScaleGestureDetector extends StatefulWidget { final Widget child; const GridScaleGestureDetector({ - @required this.tileExtentManager, @required this.scrollableKey, @required this.appBarHeightNotifier, - @required this.viewportSize, this.gridBuilder, @required this.scaledBuilder, @required this.getScaledItemTileRect, @@ -47,10 +44,6 @@ class _GridScaleGestureDetectorState extends State _metadata; - TileExtentManager get tileExtentManager => widget.tileExtentManager; - - Size get viewportSize => widget.viewportSize; - @override Widget build(BuildContext context) { return GestureDetector( @@ -76,8 +69,9 @@ class _GridScaleGestureDetectorState extends State(); + _extentMin = tileExtentController.effectiveExtentMin; + _extentMax = tileExtentController.effectiveExtentMax; final halfExtent = _startExtent / 2; final thumbnailCenter = renderMetaData.localToGlobal(Offset(halfExtent, halfExtent)); @@ -105,10 +99,10 @@ class _GridScaleGestureDetectorState extends State(); + final oldExtent = tileExtentController.extentNotifier.value; // sanitize and update grid layout if necessary - final newExtent = tileExtentManager.applyTileExtent( - viewportSize: widget.viewportSize, + final newExtent = tileExtentController.applyTileExtent( userPreferredExtent: _scaledExtentNotifier.value, ); _scaledExtentNotifier = null; @@ -195,59 +189,61 @@ class _ScaleOverlayState extends State { @override Widget build(BuildContext context) { return MediaQueryDataProvider( - child: IgnorePointer( - child: AnimatedContainer( - decoration: _init - ? BoxDecoration( - gradient: RadialGradient( - center: FractionalOffset.fromOffsetAndSize(center, MediaQuery.of(context).size), - radius: 1, - colors: [ - Colors.black, - Colors.black54, - ], - ), - ) - : BoxDecoration( - // provide dummy gradient to lerp to the other one during animation - gradient: RadialGradient( - colors: [ - Colors.transparent, - Colors.transparent, - ], - ), - ), - duration: Durations.collectionScalingBackgroundAnimation, - child: ValueListenableBuilder( - valueListenable: widget.scaledExtentNotifier, - builder: (context, extent, child) { - // keep scaled thumbnail within the screen - final xMin = MediaQuery.of(context).padding.left; - final xMax = xMin + gridWidth; - var dx = .0; - if (center.dx - extent / 2 < xMin) { - dx = xMin - (center.dx - extent / 2); - } else if (center.dx + extent / 2 > xMax) { - dx = xMax - (center.dx + extent / 2); - } - final clampedCenter = center.translate(dx, 0); - - 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, + child: Builder( + builder: (context) => IgnorePointer( + child: AnimatedContainer( + decoration: _init + ? BoxDecoration( + gradient: RadialGradient( + center: FractionalOffset.fromOffsetAndSize(center, context.select((mq) => mq.size)), + radius: 1, + colors: [ + Colors.black, + Colors.black54, + ], + ), + ) + : BoxDecoration( + // provide dummy gradient to lerp to the other one during animation + gradient: RadialGradient( + colors: [ + Colors.transparent, + Colors.transparent, + ], ), ), - ], - ); - child = widget.gridBuilder?.call(clampedCenter, extent, child) ?? child; - return child; - }, + duration: Durations.collectionScalingBackgroundAnimation, + child: ValueListenableBuilder( + valueListenable: widget.scaledExtentNotifier, + builder: (context, extent, child) { + // keep scaled thumbnail within the screen + final xMin = context.select((mq) => mq.padding.left); + final xMax = xMin + gridWidth; + var dx = .0; + if (center.dx - extent / 2 < xMin) { + dx = xMin - (center.dx - extent / 2); + } else if (center.dx + extent / 2 > xMax) { + dx = xMax - (center.dx + extent / 2); + } + final clampedCenter = center.translate(dx, 0); + + 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, + ), + ), + ], + ); + child = widget.gridBuilder?.call(clampedCenter, extent, child) ?? child; + return child; + }, + ), ), ), ), diff --git a/lib/widgets/common/tile_extent_controller.dart b/lib/widgets/common/tile_extent_controller.dart new file mode 100644 index 000000000..de0d8ee5d --- /dev/null +++ b/lib/widgets/common/tile_extent_controller.dart @@ -0,0 +1,77 @@ +import 'dart:math'; + +import 'package:aves/model/settings/settings.dart'; +import 'package:flutter/widgets.dart'; + +class TileExtentController { + final String settingsRouteKey; + final int columnCountMin, columnCountDefault; + final double spacing, extentMin, extentMax; + final ValueNotifier extentNotifier; + + Size _viewportSize; + + Size get viewportSize => _viewportSize; + + TileExtentController({ + @required this.settingsRouteKey, + @required this.extentNotifier, + this.columnCountMin = 2, + @required this.columnCountDefault, + @required this.extentMin, + this.extentMax = 300, + @required this.spacing, + }); + + double applyTileExtent({ + Size viewportSize, + double userPreferredExtent = 0, + }) { + if (viewportSize != null) { + // 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(settingsRouteKey); + final currentExtent = extentNotifier.value; + final targetExtent = userPreferredExtent > 0 + ? userPreferredExtent + : oldUserPreferredExtent > 0 + ? oldUserPreferredExtent + : currentExtent; + + final columnCount = getEffectiveColumnCountForExtent(targetExtent); + final newExtent = _extentForColumnCount(columnCount); + + if (userPreferredExtent > 0 || oldUserPreferredExtent == 0) { + settings.setTileExtent(settingsRouteKey, newExtent); + } + if (extentNotifier.value != newExtent) { + extentNotifier.value = newExtent; + } + return newExtent; + } + + double _extentMax() => min(extentMax, (viewportSize.shortestSide - spacing * (columnCountMin - 1)) / columnCountMin); + + double _columnCountForExtent(double extent) => (viewportSize.width + spacing) / (extent + spacing); + + double _extentForColumnCount(int columnCount) => (viewportSize.width - spacing * (columnCount - 1)) / columnCount; + + int _effectiveColumnCountMin() => _columnCountForExtent(_extentMax()).ceil(); + + int _effectiveColumnCountMax() => _columnCountForExtent(extentMin).floor(); + + double get effectiveExtentMin => _extentForColumnCount(_effectiveColumnCountMax()); + + double get effectiveExtentMax => _extentForColumnCount(_effectiveColumnCountMin()); + + int getEffectiveColumnCountForExtent(double extent) { + if (extent > 0) { + final columnCount = _columnCountForExtent(extent); + return columnCount.clamp(_effectiveColumnCountMin(), _effectiveColumnCountMax()).round(); + } + return columnCountDefault; + } +} diff --git a/lib/widgets/common/tile_extent_manager.dart b/lib/widgets/common/tile_extent_manager.dart deleted file mode 100644 index a5e3bc1ac..000000000 --- a/lib/widgets/common/tile_extent_manager.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'dart:math'; - -import 'package:aves/model/settings/settings.dart'; -import 'package:flutter/widgets.dart'; - -class TileExtentManager { - final String settingsRouteKey; - final int columnCountMin, columnCountDefault; - final double spacing, extentMin, extentMax; - final ValueNotifier extentNotifier; - - const TileExtentManager({ - @required this.settingsRouteKey, - @required this.extentNotifier, - this.columnCountMin = 2, - @required this.columnCountDefault, - @required this.extentMin, - this.extentMax = 300, - @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(settingsRouteKey); - 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(settingsRouteKey, newExtent); - } - if (extentNotifier.value != newExtent) { - extentNotifier.value = newExtent; - } - return newExtent; - } - - double _extentMax(Size viewportSize) => min(extentMax, (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; - } -} diff --git a/lib/widgets/debug/overlay.dart b/lib/widgets/debug/overlay.dart index 463aff4a0..9fb6b0f06 100644 --- a/lib/widgets/debug/overlay.dart +++ b/lib/widgets/debug/overlay.dart @@ -1,5 +1,4 @@ import 'package:aves/services/service_policy.dart'; -import 'package:aves/widgets/common/extensions/media_query.dart'; import 'package:flutter/material.dart'; class DebugTaskQueueOverlay extends StatelessWidget { @@ -13,9 +12,6 @@ class DebugTaskQueueOverlay extends StatelessWidget { child: SafeArea( child: Container( color: Colors.indigo[900].withAlpha(0xCC), - margin: EdgeInsets.only( - bottom: MediaQuery.of(context).effectiveBottomPadding, - ), padding: EdgeInsets.all(8), child: StreamBuilder( stream: servicePolicy.queueStream, diff --git a/lib/widgets/filter_grids/common/filter_grid_page.dart b/lib/widgets/filter_grids/common/filter_grid_page.dart index b7e98e9ea..077e03a71 100644 --- a/lib/widgets/filter_grids/common/filter_grid_page.dart +++ b/lib/widgets/filter_grids/common/filter_grid_page.dart @@ -14,8 +14,9 @@ import 'package:aves/widgets/common/grid/sliver.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:aves/widgets/common/identity/scroll_thumb.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; +import 'package:aves/widgets/common/providers/tile_extent_controller_provider.dart'; import 'package:aves/widgets/common/scaling.dart'; -import 'package:aves/widgets/common/tile_extent_manager.dart'; +import 'package:aves/widgets/common/tile_extent_controller.dart'; import 'package:aves/widgets/drawer/app_drawer.dart'; import 'package:aves/widgets/filter_grids/common/decorated_filter_chip.dart'; import 'package:aves/widgets/filter_grids/common/section_keys.dart'; @@ -25,6 +26,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; import 'package:provider/provider.dart'; +typedef QueryTest = Iterable> Function(Iterable> filters, String query); + class FilterGridPage extends StatelessWidget { final Widget appBar; final Map>> filterSections; @@ -32,17 +35,13 @@ class FilterGridPage extends StatelessWidget { final ValueNotifier queryNotifier; final Widget Function() emptyBuilder; final String settingsRouteKey; - final Iterable> Function(Iterable> filters, String query) applyQuery; + final double appBarHeight; + final QueryTest applyQuery; final FilterCallback onTap; final OffsetFilterCallback onLongPress; - final ValueNotifier _appBarHeightNotifier = ValueNotifier(0); final ValueNotifier _tileExtentNotifier = ValueNotifier(0); - static const columnCountDefault = 2; - static const extentMin = 60.0; - static const spacing = 8.0; - FilterGridPage({ Key key, @required this.appBar, @@ -52,12 +51,10 @@ class FilterGridPage extends StatelessWidget { this.applyQuery, @required this.emptyBuilder, this.settingsRouteKey, - double appBarHeight = kToolbarHeight, + this.appBarHeight = kToolbarHeight, @required this.onTap, this.onLongPress, - }) : super(key: key) { - _appBarHeightNotifier.value = appBarHeight; - } + }) : super(key: key); static const Color detailColor = Color(0xFFE0E0E0); @@ -69,76 +66,25 @@ class FilterGridPage extends StatelessWidget { child: GestureAreaProtectorStack( child: SafeArea( bottom: false, - child: LayoutBuilder( - builder: (context, constraints) { - final viewportSize = constraints.biggest; - assert(viewportSize.isFinite, 'Cannot layout collection with unbounded constraints.'); - if (viewportSize.isEmpty) return SizedBox.shrink(); - - final tileExtentManager = TileExtentManager( - settingsRouteKey: settingsRouteKey ?? context.currentRouteName, - extentNotifier: _tileExtentNotifier, - columnCountDefault: columnCountDefault, - extentMin: extentMin, - spacing: spacing, - )..applyTileExtent(viewportSize: viewportSize); - - return ValueListenableBuilder( - valueListenable: queryNotifier, - builder: (context, query, child) { - Map>> visibleFilterSections; - if (applyQuery == null) { - visibleFilterSections = filterSections; - } else { - visibleFilterSections = {}; - filterSections.forEach((sectionKey, sectionFilters) { - final visibleFilters = applyQuery(sectionFilters, query); - if (visibleFilters.isNotEmpty) { - visibleFilterSections[sectionKey] = visibleFilters.toList(); - } - }); - } - - final pinnedFilters = settings.pinnedFilters; - final sectionedListLayoutProvider = ValueListenableBuilder( - valueListenable: _tileExtentNotifier, - builder: (context, tileExtent, child) => SectionedFilterListLayoutProvider( - sections: visibleFilterSections, - showHeaders: showHeaders, - scrollableWidth: viewportSize.width, - tileExtent: tileExtent, - columnCount: tileExtentManager.getEffectiveColumnCountForExtent(viewportSize, tileExtent), - spacing: spacing, - tileBuilder: (gridItem) { - final filter = gridItem.filter; - final entry = gridItem.entry; - return MetaData( - metaData: ScalerMetadata(FilterGridItem(filter, entry)), - child: DecoratedFilterChip( - key: Key(filter.key), - filter: filter, - extent: _tileExtentNotifier.value, - pinned: pinnedFilters.contains(filter), - onTap: onTap, - onLongPress: onLongPress, - ), - ); - }, - child: _SectionedContent( - appBar: appBar, - appBarHeightNotifier: _appBarHeightNotifier, - visibleFilterSections: visibleFilterSections, - emptyBuilder: emptyBuilder, - viewportSize: viewportSize, - tileExtentManager: tileExtentManager, - scrollController: PrimaryScrollController.of(context), - ), - ), - ); - return sectionedListLayoutProvider; - }, - ); - }, + child: TileExtentControllerProvider( + controller: TileExtentController( + settingsRouteKey: settingsRouteKey ?? context.currentRouteName, + extentNotifier: _tileExtentNotifier, + columnCountDefault: 2, + extentMin: 60, + spacing: 8, + ), + child: _FilterGridPageContent( + appBar: appBar, + filterSections: filterSections, + showHeaders: showHeaders, + queryNotifier: queryNotifier, + applyQuery: applyQuery, + emptyBuilder: emptyBuilder, + appBarHeight: appBarHeight, + onTap: onTap, + onLongPress: onLongPress, + ), ), ), ), @@ -150,13 +96,100 @@ class FilterGridPage extends StatelessWidget { } } +class _FilterGridPageContent extends StatelessWidget { + final Widget appBar; + final Map>> filterSections; + final bool showHeaders; + final ValueNotifier queryNotifier; + final Widget Function() emptyBuilder; + final QueryTest applyQuery; + final FilterCallback onTap; + final OffsetFilterCallback onLongPress; + + final ValueNotifier _appBarHeightNotifier = ValueNotifier(0); + + _FilterGridPageContent({ + Key key, + @required this.appBar, + @required this.filterSections, + @required this.showHeaders, + @required this.queryNotifier, + @required this.applyQuery, + @required this.emptyBuilder, + @required double appBarHeight, + @required this.onTap, + @required this.onLongPress, + }) : super(key: key) { + _appBarHeightNotifier.value = appBarHeight; + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: queryNotifier, + builder: (context, query, child) { + Map>> visibleFilterSections; + if (applyQuery == null) { + visibleFilterSections = filterSections; + } else { + visibleFilterSections = {}; + filterSections.forEach((sectionKey, sectionFilters) { + final visibleFilters = applyQuery(sectionFilters, query); + if (visibleFilters.isNotEmpty) { + visibleFilterSections[sectionKey] = visibleFilters.toList(); + } + }); + } + + final pinnedFilters = settings.pinnedFilters; + final sectionedListLayoutProvider = ValueListenableBuilder( + valueListenable: context.select>((controller) => controller.extentNotifier), + builder: (context, tileExtent, child) { + final columnCount = context.select((controller) => controller.getEffectiveColumnCountForExtent(tileExtent)); + final tileSpacing = context.select((controller) => controller.spacing); + return SectionedFilterListLayoutProvider( + sections: visibleFilterSections, + showHeaders: showHeaders, + scrollableWidth: context.select((controller) => controller.viewportSize.width), + tileExtent: tileExtent, + columnCount: columnCount, + spacing: tileSpacing, + tileBuilder: (gridItem) { + final filter = gridItem.filter; + final entry = gridItem.entry; + return MetaData( + metaData: ScalerMetadata(FilterGridItem(filter, entry)), + child: DecoratedFilterChip( + key: Key(filter.key), + filter: filter, + extent: tileExtent, + pinned: pinnedFilters.contains(filter), + onTap: onTap, + onLongPress: onLongPress, + ), + ); + }, + child: _SectionedContent( + appBar: appBar, + appBarHeightNotifier: _appBarHeightNotifier, + visibleFilterSections: visibleFilterSections, + emptyBuilder: emptyBuilder, + scrollController: PrimaryScrollController.of(context), + ), + ); + }, + ); + return sectionedListLayoutProvider; + }, + ); + } +} + class _SectionedContent extends StatefulWidget { final Widget appBar; final ValueNotifier appBarHeightNotifier; final Map>> visibleFilterSections; final Widget Function() emptyBuilder; - final Size viewportSize; - final TileExtentManager tileExtentManager; final ScrollController scrollController; const _SectionedContent({ @@ -164,8 +197,6 @@ class _SectionedContent extends StatefulWidget { @required this.appBarHeightNotifier, @required this.visibleFilterSections, @required this.emptyBuilder, - @required this.viewportSize, - @required this.tileExtentManager, @required this.scrollController, }); @@ -182,10 +213,6 @@ class _SectionedContentState extends State<_Sectione Widget Function() get emptyBuilder => widget.emptyBuilder; - Size get viewportSize => widget.viewportSize; - - TileExtentManager get tileExtentManager => widget.tileExtentManager; - ScrollController get scrollController => widget.scrollController; final GlobalKey _scrollableKey = GlobalKey(debugLabel: 'filter-grid-page-scrollable'); @@ -232,17 +259,15 @@ class _SectionedContentState extends State<_Sectione @override Widget build(BuildContext context) { final pinnedFilters = settings.pinnedFilters; - + final tileSpacing = context.select((controller) => controller.spacing); return GridScaleGestureDetector>( - tileExtentManager: tileExtentManager, scrollableKey: _scrollableKey, appBarHeightNotifier: appBarHeightNotifier, - viewportSize: viewportSize, gridBuilder: (center, extent, child) => CustomPaint( painter: GridPainter( center: center, extent: extent, - spacing: tileExtentManager.spacing, + spacing: tileSpacing, color: Colors.grey.shade700, ), child: child, diff --git a/lib/widgets/viewer/info/maps/leaflet_map.dart b/lib/widgets/viewer/info/maps/leaflet_map.dart index e233df975..0ac426413 100644 --- a/lib/widgets/viewer/info/maps/leaflet_map.dart +++ b/lib/widgets/viewer/info/maps/leaflet_map.dart @@ -1,3 +1,4 @@ +import 'package:provider/provider.dart'; import 'package:aves/model/settings/enums.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; @@ -170,7 +171,7 @@ class OSMHotLayer extends StatelessWidget { options: TileLayerOptions( urlTemplate: 'https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png', subdomains: ['a', 'b', 'c'], - retinaMode: MediaQuery.of(context).devicePixelRatio > 1, + retinaMode: context.select((mq) => mq.devicePixelRatio) > 1, ), ); } @@ -183,7 +184,7 @@ class StamenTonerLayer extends StatelessWidget { options: TileLayerOptions( urlTemplate: 'https://stamen-tiles-{s}.a.ssl.fastly.net/toner-lite/{z}/{x}/{y}{r}.png', subdomains: ['a', 'b', 'c', 'd'], - retinaMode: MediaQuery.of(context).devicePixelRatio > 1, + retinaMode: context.select((mq) => mq.devicePixelRatio) > 1, ), ); } @@ -196,7 +197,7 @@ class StamenWatercolorLayer extends StatelessWidget { options: TileLayerOptions( urlTemplate: 'https://stamen-tiles-{s}.a.ssl.fastly.net/watercolor/{z}/{x}/{y}.jpg', subdomains: ['a', 'b', 'c', 'd'], - retinaMode: MediaQuery.of(context).devicePixelRatio > 1, + retinaMode: context.select((mq) => mq.devicePixelRatio) > 1, ), ); } diff --git a/lib/widgets/viewer/info/maps/marker.dart b/lib/widgets/viewer/info/maps/marker.dart index c40806c22..ef019964d 100644 --- a/lib/widgets/viewer/info/maps/marker.dart +++ b/lib/widgets/viewer/info/maps/marker.dart @@ -6,6 +6,7 @@ import 'package:aves/widgets/collection/thumbnail/raster.dart'; import 'package:aves/widgets/collection/thumbnail/vector.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import 'package:provider/provider.dart'; class ImageMarker extends StatelessWidget { final AvesEntry entry; @@ -153,7 +154,7 @@ class _MarkerGeneratorWidgetState extends State { @override Widget build(BuildContext context) { return Transform.translate( - offset: Offset(MediaQuery.of(context).size.width, 0), + offset: Offset(context.select((mq) => mq.size.width), 0), child: Material( type: MaterialType.transparency, child: Stack( @@ -171,7 +172,7 @@ class _MarkerGeneratorWidgetState extends State { } Future> _getBitmaps(BuildContext context) async { - final pixelRatio = MediaQuery.of(context).devicePixelRatio; + final pixelRatio = context.read().devicePixelRatio; return Future.wait(_globalKeys.map((key) async { RenderRepaintBoundary boundary = key.currentContext.findRenderObject(); final image = await boundary.toImage(pixelRatio: pixelRatio); diff --git a/lib/widgets/viewer/info/metadata/metadata_thumbnail.dart b/lib/widgets/viewer/info/metadata/metadata_thumbnail.dart index 646bf9736..45ae7744d 100644 --- a/lib/widgets/viewer/info/metadata/metadata_thumbnail.dart +++ b/lib/widgets/viewer/info/metadata/metadata_thumbnail.dart @@ -4,6 +4,7 @@ import 'dart:typed_data'; import 'package:aves/model/entry.dart'; import 'package:aves/services/metadata_service.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; enum MetadataThumbnailSource { embedded, exif } @@ -47,7 +48,6 @@ class _MetadataThumbnailsState extends State { future: _loader, builder: (context, snapshot) { if (!snapshot.hasError && snapshot.connectionState == ConnectionState.done && snapshot.data.isNotEmpty) { - final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; return Container( alignment: AlignmentDirectional.topStart, padding: EdgeInsets.only(left: 8, top: 8, right: 8, bottom: 4), @@ -55,7 +55,7 @@ class _MetadataThumbnailsState extends State { children: snapshot.data.map((bytes) { return Image.memory( bytes, - scale: devicePixelRatio, + scale: context.select((mq) => mq.devicePixelRatio), ); }).toList(), ), diff --git a/lib/widgets/welcome_page.dart b/lib/widgets/welcome_page.dart index 1e66ca5b3..a2c27dee0 100644 --- a/lib/widgets/welcome_page.dart +++ b/lib/widgets/welcome_page.dart @@ -3,12 +3,14 @@ import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/common/basic/labeled_checkbox.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_logo.dart'; +import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/home_page.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; +import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart'; class WelcomePage extends StatefulWidget { @@ -30,12 +32,13 @@ class _WelcomePageState extends State { @override Widget build(BuildContext context) { - return Scaffold( - body: SafeArea( - child: Container( - alignment: Alignment.center, - padding: EdgeInsets.all(16.0), - child: FutureBuilder( + return MediaQueryDataProvider( + child: Scaffold( + body: SafeArea( + child: Container( + alignment: Alignment.center, + padding: EdgeInsets.all(16.0), + child: FutureBuilder( future: _termsLoader, builder: (context, snapshot) { if (snapshot.hasError || snapshot.connectionState != ConnectionState.done) return SizedBox.shrink(); @@ -59,7 +62,9 @@ class _WelcomePageState extends State { ], ), ); - }), + }, + ), + ), ), ), ); @@ -71,7 +76,7 @@ class _WelcomePageState extends State { style: Theme.of(context).textTheme.headline5, ); return [ - ...(MediaQuery.of(context).orientation == Orientation.portrait + ...(context.select((mq) => mq.orientation) == Orientation.portrait ? [ AvesLogo(size: 64), SizedBox(height: 16), @@ -126,7 +131,7 @@ class _WelcomePageState extends State { child: Text(context.l10n.continueButtonLabel), ); - return MediaQuery.of(context).orientation == Orientation.portrait + return context.select((mq) => mq.orientation) == Orientation.portrait ? [ checkboxes, button,