diff --git a/lib/theme/durations.dart b/lib/theme/durations.dart index 2edc3b3bf..b76217d9a 100644 --- a/lib/theme/durations.dart +++ b/lib/theme/durations.dart @@ -43,8 +43,8 @@ class Durations { static const viewerVerticalPageScrollAnimation = Duration(milliseconds: 500); static const viewerOverlayAnimation = Duration(milliseconds: 200); static const viewerOverlayChangeAnimation = Duration(milliseconds: 150); - static const viewerOverlayPageScrollAnimation = Duration(milliseconds: 200); - static const viewerOverlayPageShadeAnimation = Duration(milliseconds: 150); + static const thumbnailScrollerScrollAnimation = Duration(milliseconds: 200); + static const thumbnailScrollerShadeAnimation = Duration(milliseconds: 150); static const viewerVideoPlayerTransition = Duration(milliseconds: 500); // info animations diff --git a/lib/widgets/collection/collection_grid.dart b/lib/widgets/collection/collection_grid.dart index 8a66526f6..ef44fe919 100644 --- a/lib/widgets/collection/collection_grid.dart +++ b/lib/widgets/collection/collection_grid.dart @@ -13,7 +13,6 @@ import 'package:aves/widgets/collection/app_bar.dart'; import 'package:aves/widgets/collection/draggable_thumb_label.dart'; import 'package:aves/widgets/collection/grid/section_layout.dart'; import 'package:aves/widgets/collection/grid/thumbnail.dart'; -import 'package:aves/widgets/collection/thumbnail/decorated.dart'; import 'package:aves/widgets/common/basic/draggable_scrollbar.dart'; import 'package:aves/widgets/common/basic/insets.dart'; import 'package:aves/widgets/common/behaviour/sloppy_scroll_physics.dart'; @@ -27,6 +26,7 @@ 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/thumbnail/decorated.dart'; import 'package:aves/widgets/common/tile_extent_controller.dart'; import 'package:flutter/material.dart'; import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; diff --git a/lib/widgets/collection/grid/thumbnail.dart b/lib/widgets/collection/grid/thumbnail.dart index 28b24b3f9..4e2bb857f 100644 --- a/lib/widgets/collection/grid/thumbnail.dart +++ b/lib/widgets/collection/grid/thumbnail.dart @@ -3,9 +3,9 @@ import 'package:aves/model/entry.dart'; import 'package:aves/model/selection.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/services/viewer_service.dart'; -import 'package:aves/widgets/collection/thumbnail/decorated.dart'; import 'package:aves/widgets/common/behaviour/routes.dart'; import 'package:aves/widgets/common/scaling.dart'; +import 'package:aves/widgets/common/thumbnail/decorated.dart'; import 'package:aves/widgets/viewer/entry_viewer_page.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; diff --git a/lib/widgets/common/identity/aves_icons.dart b/lib/widgets/common/identity/aves_icons.dart index b05dcaf87..20b713d53 100644 --- a/lib/widgets/common/identity/aves_icons.dart +++ b/lib/widgets/common/identity/aves_icons.dart @@ -117,7 +117,7 @@ class MultiPageIcon extends StatelessWidget { if (entry.isMotionPhoto) { icon = AIcons.motionPhoto; } else { - if(entry.isBurst) { + if (entry.isBurst) { text = '${entry.burstEntries?.length}'; } icon = AIcons.multiPage; diff --git a/lib/widgets/common/map/geo_map.dart b/lib/widgets/common/map/geo_map.dart index 140876e75..48d1fa68d 100644 --- a/lib/widgets/common/map/geo_map.dart +++ b/lib/widgets/common/map/geo_map.dart @@ -27,6 +27,7 @@ class GeoMap extends StatefulWidget { final double? mapHeight; final ValueNotifier isAnimatingNotifier; final UserZoomChangeCallback? onUserZoomChange; + final GeoEntryTapCallback? onEntryTap; static const markerImageExtent = 48.0; static const pointerSize = Size(8, 6); @@ -38,6 +39,7 @@ class GeoMap extends StatefulWidget { this.mapHeight, required this.isAnimatingNotifier, this.onUserZoomChange, + this.onEntryTap, }) : super(key: key); @override @@ -130,6 +132,7 @@ class _GeoMapState extends State with TickerProviderStateMixin { markerCluster: markerCluster, markerEntries: entries, onUserZoomChange: widget.onUserZoomChange, + onEntryTap: widget.onEntryTap, ) : EntryLeafletMap( boundsNotifier: boundsNotifier, @@ -143,6 +146,7 @@ class _GeoMapState extends State with TickerProviderStateMixin { GeoMap.markerImageExtent + ImageMarker.outerBorderWidth * 2 + GeoMap.pointerSize.height, ), onUserZoomChange: widget.onUserZoomChange, + onEntryTap: widget.onEntryTap, ); child = Column( @@ -214,3 +218,4 @@ class MarkerKey extends LocalKey with EquatableMixin { typedef EntryMarkerBuilder = Widget Function(MarkerKey key); typedef UserZoomChangeCallback = void Function(double zoom); +typedef GeoEntryTapCallback = void Function(List geoEntries); diff --git a/lib/widgets/common/map/google/map.dart b/lib/widgets/common/map/google/map.dart index 922c7c458..eccfff2e7 100644 --- a/lib/widgets/common/map/google/map.dart +++ b/lib/widgets/common/map/google/map.dart @@ -25,6 +25,7 @@ class EntryGoogleMap extends StatefulWidget { final Fluster markerCluster; final List markerEntries; final UserZoomChangeCallback? onUserZoomChange; + final GeoEntryTapCallback? onEntryTap; const EntryGoogleMap({ Key? key, @@ -35,6 +36,7 @@ class EntryGoogleMap extends StatefulWidget { required this.markerCluster, required this.markerEntries, this.onUserZoomChange, + this.onEntryTap, }) : super(key: key); @override @@ -93,8 +95,8 @@ class _EntryGoogleMapState extends State with WidgetsBindingObse valueListenable: boundsNotifier, builder: (context, visibleRegion, child) { final allEntries = widget.markerEntries; - final clusters = visibleRegion != null ? widget.markerCluster.clusters(visibleRegion.boundingBox, visibleRegion.zoom.round()) : []; - final clusterByMarkerKey = Map.fromEntries(clusters.map((v) { + final geoEntries = visibleRegion != null ? widget.markerCluster.clusters(visibleRegion.boundingBox, visibleRegion.zoom.round()) : []; + final geoEntryByMarkerKey = Map.fromEntries(geoEntries.map((v) { if (v.isCluster!) { final uri = v.childMarkerId; final entry = allEntries.firstWhere((v) => v.uri == uri); @@ -106,7 +108,7 @@ class _EntryGoogleMapState extends State with WidgetsBindingObse return Stack( children: [ MarkerGeneratorWidget( - markers: clusterByMarkerKey.keys.map(widget.markerBuilder).toList(), + markers: geoEntryByMarkerKey.keys.map(widget.markerBuilder).toList(), isReadyToRender: (key) => key.entry.isThumbnailReady(extent: GeoMap.markerImageExtent), onRendered: (key, bitmap) { _markerBitmaps[key] = bitmap; @@ -115,7 +117,7 @@ class _EntryGoogleMapState extends State with WidgetsBindingObse ), MapDecorator( interactive: widget.interactive, - child: _buildMap(clusterByMarkerKey), + child: _buildMap(geoEntryByMarkerKey), ), MapButtonPanel( latLng: bounds.center, @@ -127,19 +129,26 @@ class _EntryGoogleMapState extends State with WidgetsBindingObse ); } - Widget _buildMap(Map clusterByMarkerKey) { + Widget _buildMap(Map geoEntryByMarkerKey) { return AnimatedBuilder( animation: _markerBitmapChangeNotifier, builder: (context, child) { final markers = {}; - clusterByMarkerKey.forEach((markerKey, cluster) { + final onTap = widget.onEntryTap; + geoEntryByMarkerKey.forEach((markerKey, geoEntry) { final bytes = _markerBitmaps[markerKey]; if (bytes != null) { - final latLng = LatLng(cluster.latitude!, cluster.longitude!); + final latLng = LatLng(geoEntry.latitude!, geoEntry.longitude!); markers.add(Marker( - markerId: MarkerId(cluster.markerId!), + markerId: MarkerId(geoEntry.markerId!), icon: BitmapDescriptor.fromBytes(bytes), position: latLng, + onTap: onTap != null + ? () { + final clusterId = geoEntry.clusterId; + onTap(clusterId != null ? widget.markerCluster.points(clusterId) : [geoEntry]); + } + : null, )); } }); diff --git a/lib/widgets/common/map/leaflet/map.dart b/lib/widgets/common/map/leaflet/map.dart index 51ad8e06f..54495311d 100644 --- a/lib/widgets/common/map/leaflet/map.dart +++ b/lib/widgets/common/map/leaflet/map.dart @@ -24,6 +24,7 @@ class EntryLeafletMap extends StatefulWidget { final List markerEntries; final Size markerSize; final UserZoomChangeCallback? onUserZoomChange; + final GeoEntryTapCallback? onEntryTap; const EntryLeafletMap({ Key? key, @@ -35,6 +36,7 @@ class EntryLeafletMap extends StatefulWidget { required this.markerEntries, required this.markerSize, this.onUserZoomChange, + this.onEntryTap, }) : super(key: key); @override @@ -80,8 +82,8 @@ class _EntryLeafletMapState extends State with TickerProviderSt valueListenable: boundsNotifier, builder: (context, visibleRegion, child) { final allEntries = widget.markerEntries; - final clusters = visibleRegion != null ? widget.markerCluster.clusters(visibleRegion.boundingBox, visibleRegion.zoom.round()) : []; - final clusterByMarkerKey = Map.fromEntries(clusters.map((v) { + final geoEntries = visibleRegion != null ? widget.markerCluster.clusters(visibleRegion.boundingBox, visibleRegion.zoom.round()) : []; + final geoEntryByMarkerKey = Map.fromEntries(geoEntries.map((v) { if (v.isCluster!) { final uri = v.childMarkerId; final entry = allEntries.firstWhere((v) => v.uri == uri); @@ -94,7 +96,7 @@ class _EntryLeafletMapState extends State with TickerProviderSt children: [ MapDecorator( interactive: widget.interactive, - child: _buildMap(clusterByMarkerKey), + child: _buildMap(geoEntryByMarkerKey), ), MapButtonPanel( latLng: bounds.center, @@ -106,16 +108,20 @@ class _EntryLeafletMapState extends State with TickerProviderSt ); } - Widget _buildMap(Map clusterByMarkerKey) { + Widget _buildMap(Map geoEntryByMarkerKey) { final markerSize = widget.markerSize; - final markers = clusterByMarkerKey.entries.map((kv) { + final markers = geoEntryByMarkerKey.entries.map((kv) { final markerKey = kv.key; - final cluster = kv.value; - final latLng = LatLng(cluster.latitude!, cluster.longitude!); + final geoEntry = kv.value; + final latLng = LatLng(geoEntry.latitude!, geoEntry.longitude!); return Marker( point: latLng, builder: (context) => GestureDetector( - onTap: () => _moveTo(latLng), + onTap: () { + final clusterId = geoEntry.clusterId; + widget.onEntryTap?.call(clusterId != null ? widget.markerCluster.points(clusterId) : [geoEntry]); + _moveTo(latLng); + }, child: widget.markerBuilder(markerKey), ), width: markerSize.width, diff --git a/lib/widgets/common/map/marker.dart b/lib/widgets/common/map/marker.dart index c8e781c80..4b8abf894 100644 --- a/lib/widgets/common/map/marker.dart +++ b/lib/widgets/common/map/marker.dart @@ -1,5 +1,5 @@ import 'package:aves/model/entry.dart'; -import 'package:aves/widgets/collection/thumbnail/image.dart'; +import 'package:aves/widgets/common/thumbnail/image.dart'; import 'package:custom_rounded_rectangle_border/custom_rounded_rectangle_border.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; diff --git a/lib/widgets/collection/thumbnail/decorated.dart b/lib/widgets/common/thumbnail/decorated.dart similarity index 94% rename from lib/widgets/collection/thumbnail/decorated.dart rename to lib/widgets/common/thumbnail/decorated.dart index af7da2574..93f16b6d0 100644 --- a/lib/widgets/collection/thumbnail/decorated.dart +++ b/lib/widgets/common/thumbnail/decorated.dart @@ -1,9 +1,9 @@ import 'package:aves/model/entry.dart'; import 'package:aves/model/source/collection_lens.dart'; -import 'package:aves/widgets/collection/thumbnail/image.dart'; -import 'package:aves/widgets/collection/thumbnail/overlay.dart'; import 'package:aves/widgets/common/fx/borders.dart'; import 'package:aves/widgets/common/grid/overlay.dart'; +import 'package:aves/widgets/common/thumbnail/image.dart'; +import 'package:aves/widgets/common/thumbnail/overlay.dart'; import 'package:flutter/material.dart'; class DecoratedThumbnail extends StatelessWidget { diff --git a/lib/widgets/collection/thumbnail/error.dart b/lib/widgets/common/thumbnail/error.dart similarity index 100% rename from lib/widgets/collection/thumbnail/error.dart rename to lib/widgets/common/thumbnail/error.dart diff --git a/lib/widgets/collection/thumbnail/image.dart b/lib/widgets/common/thumbnail/image.dart similarity index 99% rename from lib/widgets/collection/thumbnail/image.dart rename to lib/widgets/common/thumbnail/image.dart index 20965d397..15f5c16c8 100644 --- a/lib/widgets/collection/thumbnail/image.dart +++ b/lib/widgets/common/thumbnail/image.dart @@ -8,11 +8,11 @@ import 'package:aves/model/settings/entry_background.dart'; import 'package:aves/model/settings/enums.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/services/services.dart'; -import 'package:aves/widgets/collection/thumbnail/error.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/fx/checkered_decoration.dart'; import 'package:aves/widgets/common/fx/transition_image.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; +import 'package:aves/widgets/common/thumbnail/error.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; diff --git a/lib/widgets/collection/thumbnail/overlay.dart b/lib/widgets/common/thumbnail/overlay.dart similarity index 100% rename from lib/widgets/collection/thumbnail/overlay.dart rename to lib/widgets/common/thumbnail/overlay.dart diff --git a/lib/widgets/common/thumbnail/scroller.dart b/lib/widgets/common/thumbnail/scroller.dart new file mode 100644 index 000000000..28b5f03e8 --- /dev/null +++ b/lib/widgets/common/thumbnail/scroller.dart @@ -0,0 +1,152 @@ +import 'dart:math'; + +import 'package:aves/model/entry.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/widgets/common/grid/theme.dart'; +import 'package:aves/widgets/common/thumbnail/decorated.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class ThumbnailScroller extends StatefulWidget { + final double availableWidth; + final int entryCount; + final AvesEntry? Function(int index) entryBuilder; + final int? initialIndex; + final bool Function(int page) isCurrentIndex; + final void Function(int index) onIndexChange; + + const ThumbnailScroller({ + Key? key, + required this.availableWidth, + required this.entryCount, + required this.entryBuilder, + required this.initialIndex, + required this.isCurrentIndex, + required this.onIndexChange, + }) : super(key: key); + + @override + _ThumbnailScrollerState createState() => _ThumbnailScrollerState(); +} + +class _ThumbnailScrollerState extends State { + final _cancellableNotifier = ValueNotifier(true); + late ScrollController _scrollController; + bool _syncScroll = true; + + static const double extent = 48; + static const double separatorWidth = 2; + + int get entryCount => widget.entryCount; + + @override + void initState() { + super.initState(); + _registerWidget(); + } + + @override + void didUpdateWidget(covariant ThumbnailScroller oldWidget) { + super.didUpdateWidget(oldWidget); + + if (oldWidget.initialIndex != widget.initialIndex) { + _unregisterWidget(); + _registerWidget(); + } + } + + @override + void dispose() { + _unregisterWidget(); + super.dispose(); + } + + void _registerWidget() { + final scrollOffset = indexToScrollOffset(widget.initialIndex ?? 0); + _scrollController = ScrollController(initialScrollOffset: scrollOffset); + _scrollController.addListener(_onScrollChange); + } + + void _unregisterWidget() { + _scrollController.removeListener(_onScrollChange); + _scrollController.dispose(); + } + + @override + Widget build(BuildContext context) { + final marginWidth = max(0.0, (widget.availableWidth - extent) / 2 - separatorWidth); + final horizontalMargin = SizedBox(width: marginWidth); + const separator = SizedBox(width: separatorWidth); + + return GridTheme( + extent: extent, + showLocation: false, + child: SizedBox( + height: extent, + child: ListView.separated( + scrollDirection: Axis.horizontal, + controller: _scrollController, + // default padding in scroll direction matches `MediaQuery.viewPadding`, + // but we already accommodate for it, so make sure horizontal padding is 0 + padding: EdgeInsets.zero, + itemBuilder: (context, index) { + if (index == 0 || index == entryCount + 1) return horizontalMargin; + final page = index - 1; + final pageEntry = widget.entryBuilder(page); + if (pageEntry == null) return const SizedBox(); + + return Stack( + children: [ + GestureDetector( + onTap: () => _goTo(page), + child: DecoratedThumbnail( + entry: pageEntry, + tileExtent: extent, + // the retrieval task queue can pile up for thumbnails of heavy pages + // (e.g. thumbnails of 15MP HEIF images inside 100MB+ HEIC containers) + // so we cancel these requests when possible + cancellableNotifier: _cancellableNotifier, + selectable: false, + highlightable: false, + hero: false, + ), + ), + IgnorePointer( + child: AnimatedContainer( + color: widget.isCurrentIndex(page) ? Colors.transparent : Colors.black45, + width: extent, + height: extent, + duration: Durations.thumbnailScrollerShadeAnimation, + ), + ) + ], + ); + }, + separatorBuilder: (context, index) => separator, + itemCount: entryCount + 2, + ), + ), + ); + } + + Future _goTo(int index) async { + _syncScroll = false; + widget.onIndexChange(index); + await _scrollController.animateTo( + indexToScrollOffset(index), + duration: Durations.thumbnailScrollerScrollAnimation, + curve: Curves.easeOutCubic, + ); + _syncScroll = true; + } + + void _onScrollChange() { + if (_syncScroll) { + widget.onIndexChange(scrollOffsetToIndex(_scrollController.offset)); + } + } + + double indexToScrollOffset(int index) => index * (extent + separatorWidth); + + int scrollOffsetToIndex(double offset) => (offset / (extent + separatorWidth)).round(); +} diff --git a/lib/widgets/dialogs/add_shortcut_dialog.dart b/lib/widgets/dialogs/add_shortcut_dialog.dart index 95385a67f..213b6ee24 100644 --- a/lib/widgets/dialogs/add_shortcut_dialog.dart +++ b/lib/widgets/dialogs/add_shortcut_dialog.dart @@ -2,9 +2,9 @@ import 'package:aves/model/covers.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/source/collection_lens.dart'; -import 'package:aves/widgets/collection/thumbnail/image.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; +import 'package:aves/widgets/common/thumbnail/image.dart'; import 'package:aves/widgets/dialogs/item_pick_dialog.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; diff --git a/lib/widgets/filter_grids/common/covered_filter_chip.dart b/lib/widgets/filter_grids/common/covered_filter_chip.dart index 693476e09..7181bd4d3 100644 --- a/lib/widgets/filter_grids/common/covered_filter_chip.dart +++ b/lib/widgets/filter_grids/common/covered_filter_chip.dart @@ -13,8 +13,8 @@ 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/widgets/collection/thumbnail/image.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; +import 'package:aves/widgets/common/thumbnail/image.dart'; import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart'; import 'package:decorated_icon/decorated_icon.dart'; import 'package:flutter/material.dart'; diff --git a/lib/widgets/map/map_page.dart b/lib/widgets/map/map_page.dart index 24204f58f..1a518c8c9 100644 --- a/lib/widgets/map/map_page.dart +++ b/lib/widgets/map/map_page.dart @@ -5,9 +5,11 @@ import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/map/geo_map.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; +import 'package:aves/widgets/common/thumbnail/scroller.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; +import 'package:provider/provider.dart'; class MapPage extends StatefulWidget { static const routeName = '/collection/map'; @@ -25,6 +27,9 @@ class MapPage extends StatefulWidget { class _MapPageState extends State { late final ValueNotifier _isAnimatingNotifier; + int _selectedIndex = 0; + + List get entries => widget.entries; @override void initState() { @@ -48,10 +53,34 @@ class _MapPageState extends State { title: Text(context.l10n.mapPageTitle), ), body: SafeArea( - child: GeoMap( - entries: widget.entries, - interactive: true, - isAnimatingNotifier: _isAnimatingNotifier, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: GeoMap( + entries: entries, + interactive: true, + isAnimatingNotifier: _isAnimatingNotifier, + onEntryTap: (geoEntries) { + debugPrint('TLAD count=${geoEntries.length} entry=${geoEntries.first.entry}'); + }, + ), + ), + const Divider(), + Selector( + selector: (c, mq) => mq.size.width, + builder: (c, mqWidth, child) { + return ThumbnailScroller( + availableWidth: mqWidth, + entryCount: entries.length, + entryBuilder: (index) => entries[index], + initialIndex: _selectedIndex, + isCurrentIndex: (index) => _selectedIndex == index, + onIndexChange: (index) => _selectedIndex = index, + ); + }, + ), + ], ), ), ), diff --git a/lib/widgets/viewer/overlay/bottom/common.dart b/lib/widgets/viewer/overlay/bottom/common.dart index 60c2343fd..181d69277 100644 --- a/lib/widgets/viewer/overlay/bottom/common.dart +++ b/lib/widgets/viewer/overlay/bottom/common.dart @@ -107,7 +107,7 @@ class _ViewerBottomOverlayState extends State { _lastDetails = snapshot.data; _lastEntry = entry; } - if (_lastEntry == null) return const SizedBox.shrink(); + if (_lastEntry == null) return const SizedBox(); final mainEntry = _lastEntry!; Widget _buildContent({AvesEntry? pageEntry}) => _BottomOverlayContent( @@ -261,7 +261,7 @@ class _BottomOverlayContent extends AnimatedWidget { padding: const EdgeInsets.only(top: _interRowPadding), child: _LocationRow(entry: pageEntry), ) - : const SizedBox.shrink(), + : const SizedBox(), ); Widget _buildSoloShootingRow(double subRowWidth, bool hasShootingDetails) => AnimatedSwitcher( @@ -275,7 +275,7 @@ class _BottomOverlayContent extends AnimatedWidget { width: subRowWidth, child: _ShootingRow(details!), ) - : const SizedBox.shrink(), + : const SizedBox(), ); Widget _buildDuoShootingRow(double subRowWidth, bool hasShootingDetails) => AnimatedSwitcher( @@ -291,7 +291,7 @@ class _BottomOverlayContent extends AnimatedWidget { width: subRowWidth, child: _ShootingRow(details!), ) - : const SizedBox.shrink(), + : const SizedBox(), ); static Widget _soloTransition(Widget child, Animation animation) => FadeTransition( diff --git a/lib/widgets/viewer/overlay/bottom/multipage.dart b/lib/widgets/viewer/overlay/bottom/multipage.dart index e599f9a61..96a4f57e8 100644 --- a/lib/widgets/viewer/overlay/bottom/multipage.dart +++ b/lib/widgets/viewer/overlay/bottom/multipage.dart @@ -1,12 +1,7 @@ -import 'dart:math'; - import 'package:aves/model/multipage.dart'; -import 'package:aves/theme/durations.dart'; -import 'package:aves/widgets/collection/thumbnail/decorated.dart'; -import 'package:aves/widgets/common/grid/theme.dart'; +import 'package:aves/widgets/common/thumbnail/scroller.dart'; import 'package:aves/widgets/viewer/multipage/controller.dart'; import 'package:aves/widgets/viewer/visual/conductor.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -25,18 +20,10 @@ class MultiPageOverlay extends StatefulWidget { } class _MultiPageOverlayState extends State { - final _cancellableNotifier = ValueNotifier(true); - late ScrollController _scrollController; - bool _syncScroll = true; int? _initControllerPage; - static const double extent = 48; - static const double separatorWidth = 2; - MultiPageController get controller => widget.controller; - double get availableWidth => widget.availableWidth; - @override void initState() { super.initState(); @@ -48,23 +35,12 @@ class _MultiPageOverlayState extends State { super.didUpdateWidget(oldWidget); if (oldWidget.controller != controller) { - _unregisterWidget(); _registerWidget(); } } - @override - void dispose() { - _unregisterWidget(); - super.dispose(); - } - void _registerWidget() { _initControllerPage = controller.page; - final scrollOffset = pageToScrollOffset(_initControllerPage ?? 0); - _scrollController = ScrollController(initialScrollOffset: scrollOffset); - _scrollController.addListener(_onScrollChange); - if (_initControllerPage == null) { _correctDefaultPageScroll(); } @@ -75,79 +51,26 @@ class _MultiPageOverlayState extends State { void _correctDefaultPageScroll() async { await controller.infoStream.first; if (_initControllerPage == null) { - _initControllerPage = controller.page; - if (_initControllerPage != null && _initControllerPage != 0) { - WidgetsBinding.instance!.addPostFrameCallback((_) => _goToPage(_initControllerPage!)); - } + setState(() => _initControllerPage = controller.page); } } - void _unregisterWidget() { - _scrollController.removeListener(_onScrollChange); - _scrollController.dispose(); - } - @override Widget build(BuildContext context) { - final marginWidth = max(0.0, (availableWidth - extent) / 2 - separatorWidth); - final horizontalMargin = SizedBox(width: marginWidth); - const separator = SizedBox(width: separatorWidth); - - return GridTheme( - extent: extent, - showLocation: false, - child: StreamBuilder( - stream: controller.infoStream, - builder: (context, snapshot) { - final multiPageInfo = controller.info; - final pageCount = multiPageInfo?.pageCount ?? 0; - return SizedBox( - height: extent, - child: ListView.separated( - key: ValueKey(multiPageInfo), - scrollDirection: Axis.horizontal, - controller: _scrollController, - // default padding in scroll direction matches `MediaQuery.viewPadding`, - // but we already accommodate for it, so make sure horizontal padding is 0 - padding: EdgeInsets.zero, - itemBuilder: (context, index) { - if (index == 0 || index == pageCount + 1) return horizontalMargin; - final page = index - 1; - final pageEntry = multiPageInfo!.getPageEntryByIndex(page); - - return Stack( - children: [ - GestureDetector( - onTap: () => _goToPage(page), - child: DecoratedThumbnail( - entry: pageEntry, - tileExtent: extent, - // the retrieval task queue can pile up for thumbnails of heavy pages - // (e.g. thumbnails of 15MP HEIF images inside 100MB+ HEIC containers) - // so we cancel these requests when possible - cancellableNotifier: _cancellableNotifier, - selectable: false, - highlightable: false, - hero: false, - ), - ), - IgnorePointer( - child: AnimatedContainer( - color: controller.page == page ? Colors.transparent : Colors.black45, - width: extent, - height: extent, - duration: Durations.viewerOverlayPageShadeAnimation, - ), - ) - ], - ); - }, - separatorBuilder: (context, index) => separator, - itemCount: pageCount + 2, - ), - ); - }, - ), + return StreamBuilder( + stream: controller.infoStream, + builder: (context, snapshot) { + final multiPageInfo = controller.info; + return ThumbnailScroller( + key: ValueKey(multiPageInfo), + availableWidth: widget.availableWidth, + entryCount: multiPageInfo?.pageCount ?? 0, + entryBuilder: (page) => multiPageInfo?.getPageEntryByIndex(page), + initialIndex: _initControllerPage, + isCurrentIndex: (page) => controller.page == page, + onIndexChange: _setPage, + ); + }, ); } @@ -159,25 +82,4 @@ class _MultiPageOverlayState extends State { controller.page = newPage; context.read().reset(oldPageEntry); } - - Future _goToPage(int page) async { - _syncScroll = false; - _setPage(page); - await _scrollController.animateTo( - pageToScrollOffset(page), - duration: Durations.viewerOverlayPageScrollAnimation, - curve: Curves.easeOutCubic, - ); - _syncScroll = true; - } - - void _onScrollChange() { - if (_syncScroll) { - _setPage(scrollOffsetToPage(_scrollController.offset)); - } - } - - double pageToScrollOffset(int page) => page * (extent + separatorWidth); - - int scrollOffsetToPage(double offset) => (offset / (extent + separatorWidth)).round(); } diff --git a/lib/widgets/viewer/visual/entry_page_view.dart b/lib/widgets/viewer/visual/entry_page_view.dart index a85e663fe..452d65f07 100644 --- a/lib/widgets/viewer/visual/entry_page_view.dart +++ b/lib/widgets/viewer/visual/entry_page_view.dart @@ -3,13 +3,13 @@ import 'dart:async'; import 'package:aves/model/entry.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/durations.dart'; -import 'package:aves/widgets/collection/thumbnail/image.dart'; import 'package:aves/widgets/common/magnifier/controller/controller.dart'; import 'package:aves/widgets/common/magnifier/controller/state.dart'; import 'package:aves/widgets/common/magnifier/magnifier.dart'; import 'package:aves/widgets/common/magnifier/scale/scale_boundaries.dart'; import 'package:aves/widgets/common/magnifier/scale/scale_level.dart'; import 'package:aves/widgets/common/magnifier/scale/state.dart'; +import 'package:aves/widgets/common/thumbnail/image.dart'; import 'package:aves/widgets/viewer/hero.dart'; import 'package:aves/widgets/viewer/overlay/notifications.dart'; import 'package:aves/widgets/viewer/video/conductor.dart';