import 'dart:async'; import 'package:wonders/common_libs.dart'; import 'package:wonders/logic/data/unsplash_photo_data.dart'; import 'package:wonders/ui/common/controls/app_loading_indicator.dart'; import 'package:wonders/ui/common/controls/eight_way_swipe_detector.dart'; import 'package:wonders/ui/common/hidden_collectible.dart'; import 'package:wonders/ui/common/modals/fullscreen_url_img_viewer.dart'; import 'package:wonders/ui/common/unsplash_photo.dart'; import 'package:wonders/ui/common/utils/app_haptics.dart'; part 'widgets/_animated_cutout_overlay.dart'; class PhotoGallery extends StatefulWidget { const PhotoGallery({Key? key, this.imageSize, required this.collectionId, required this.wonderType}) : super(key: key); final Size? imageSize; final String collectionId; final WonderType wonderType; @override State createState() => _PhotoGalleryState(); } class _PhotoGalleryState extends State { static const int _gridSize = 5; // Index starts in the middle of the grid (eg, 25 items, index will start at 13) int _index = ((_gridSize * _gridSize) / 2).round(); Offset _lastSwipeDir = Offset.zero; final double _scale = 1; bool _skipNextOffsetTween = false; late Duration swipeDuration = $styles.times.med * .4; final _photoIds = ValueNotifier>([]); int get _imgCount => pow(_gridSize, 2).round(); @override void initState() { super.initState(); _initPhotoIds(); } Future _initPhotoIds() async { var ids = unsplashLogic.getCollectionPhotos(widget.collectionId); if (ids != null && ids.isNotEmpty) { // Ensure we have enough images to fill the grid, repeat if necessary while (ids.length < _imgCount) { ids.addAll(List.from(ids)); if (ids.length > _imgCount) ids.length = _imgCount; } } setState(() => _photoIds.value = ids ?? []); } void _setIndex(int value, {bool skipAnimation = false}) { if (value < 0 || value >= _imgCount) return; _skipNextOffsetTween = skipAnimation; setState(() => _index = value); } /// Determine the required offset to show the current selected index. /// index=0 is top-left, and the index=max is bottom-right. Offset _calculateCurrentOffset(double padding, Size size) { double halfCount = (_gridSize / 2).floorToDouble(); Size paddedImageSize = Size(size.width + padding, size.height + padding); // Get the starting offset that would show the top-left image (index 0) final originOffset = Offset(halfCount * paddedImageSize.width, halfCount * paddedImageSize.height); // Add the offset for the row/col int col = _index % _gridSize; int row = (_index / _gridSize).floor(); final indexedOffset = Offset(-paddedImageSize.width * col, -paddedImageSize.height * row); return originOffset + indexedOffset; } /// Used for hiding collectibles around the photo grid. int _getCollectibleIndex() { switch (widget.wonderType) { case WonderType.chichenItza: case WonderType.petra: return 0; case WonderType.colosseum: case WonderType.pyramidsGiza: return _gridSize - 1; case WonderType.christRedeemer: case WonderType.machuPicchu: return _imgCount - 1; case WonderType.greatWall: case WonderType.tajMahal: return _imgCount - _gridSize; } } /// Converts a swipe direction into a new index void _handleSwipe(Offset dir) { // Calculate new index, y swipes move by an entire row, x swipes move one index at a time int newIndex = _index; if (dir.dy != 0) newIndex += _gridSize * (dir.dy > 0 ? -1 : 1); if (dir.dx != 0) newIndex += (dir.dx > 0 ? -1 : 1); // After calculating new index, exit early if we don't like it... if (newIndex < 0 || newIndex > _imgCount - 1) return; // keep the index in range if (dir.dx < 0 && newIndex % _gridSize == 0) return; // prevent right-swipe when at right side if (dir.dx > 0 && newIndex % _gridSize == _gridSize - 1) return; // prevent left-swipe when at left side _lastSwipeDir = dir; AppHaptics.lightImpact(); _setIndex(newIndex); } Future _handleImageTapped(int index) async { if (_index == index) { final urls = _photoIds.value.map((e) { return UnsplashPhotoData.getSelfHostedUrl(e, UnsplashPhotoSize.med); }).toList(); int? newIndex = await appLogic.showFullscreenDialogRoute( context, FullscreenUrlImgViewer(urls: urls, index: _index), ); if (newIndex != null) { _setIndex(newIndex, skipAnimation: true); } } else { _setIndex(index); } } bool _checkCollectibleIndex(int index) { return index == _getCollectibleIndex() && collectiblesLogic.isLost(widget.wonderType, 1); } @override Widget build(BuildContext context) { return ValueListenableBuilder>( valueListenable: _photoIds, builder: (_, value, __) { if (value.isEmpty) { return Center(child: AppLoadingIndicator()); } Size imgSize = context.isLandscape ? Size(context.widthPx * .5, context.heightPx * .66) : Size(context.widthPx * .66, context.heightPx * .5); imgSize = (widget.imageSize ?? imgSize) * _scale; // Get transform offset for the current _index final padding = $styles.insets.md; var gridOffset = _calculateCurrentOffset(padding, imgSize); gridOffset += Offset(0, -context.mq.padding.top / 2); final offsetTweenDuration = _skipNextOffsetTween ? Duration.zero : swipeDuration; final cutoutTweenDuration = _skipNextOffsetTween ? Duration.zero : swipeDuration * .5; return _AnimatedCutoutOverlay( animationKey: ValueKey(_index), cutoutSize: imgSize, swipeDir: _lastSwipeDir, duration: cutoutTweenDuration, opacity: _scale == 1 ? .7 : .5, child: SafeArea( bottom: false, // Place content in overflow box, to allow it to flow outside the parent child: OverflowBox( maxWidth: _gridSize * imgSize.width + padding * (_gridSize - 1), maxHeight: _gridSize * imgSize.height + padding * (_gridSize - 1), alignment: Alignment.center, // Detect swipes in order to change index child: EightWaySwipeDetector( onSwipe: _handleSwipe, threshold: 30, // A tween animation builder moves from image to image based on current offset child: TweenAnimationBuilder( tween: Tween(begin: gridOffset, end: gridOffset), duration: offsetTweenDuration, curve: Curves.easeOut, builder: (_, value, child) => Transform.translate(offset: value, child: child), child: GridView.count( physics: NeverScrollableScrollPhysics(), crossAxisCount: _gridSize, childAspectRatio: imgSize.aspectRatio, mainAxisSpacing: padding, crossAxisSpacing: padding, children: List.generate(_imgCount, (i) => _buildImage(i, swipeDuration, imgSize)), ), ), ), ), ), ); }); } Widget _buildImage(int index, Duration swipeDuration, Size imgSize) { /// Bind to collectibles.statesById because we might need to rebuild if a collectible is found. return ValueListenableBuilder( valueListenable: collectiblesLogic.statesById, builder: (_, __, ___) { bool selected = index == _index; final imgUrl = _photoIds.value[index]; late String semanticLbl; if (_checkCollectibleIndex(index)) { semanticLbl = $strings.collectibleItemSemanticCollectible; } else { semanticLbl = !selected ? $strings.photoGallerySemanticFocus(index + 1, _imgCount) : $strings.photoGallerySemanticFullscreen(index + 1, _imgCount); } return MergeSemantics( child: Semantics( focused: selected, image: !_checkCollectibleIndex(index), liveRegion: selected, onIncrease: () => _handleImageTapped(_index + 1), onDecrease: () => _handleImageTapped(_index - 1), child: AppBtn.basic( semanticLabel: semanticLbl, onPressed: () { if (_checkCollectibleIndex(index) && selected) return; _handleImageTapped(index); }, child: _checkCollectibleIndex(index) ? HiddenCollectible(widget.wonderType, index: 1, size: 100) : ClipRRect( borderRadius: BorderRadius.circular(8), child: SizedBox( width: imgSize.width, height: imgSize.height, child: TweenAnimationBuilder( duration: $styles.times.med, curve: Curves.easeOut, tween: Tween(begin: 1, end: selected ? 1.15 : 1), builder: (_, value, child) => Transform.scale(scale: value, child: child), child: UnsplashPhoto( imgUrl, fit: BoxFit.cover, size: UnsplashPhotoSize.med, ).animate().fade(), ), ), ), ), ), ); }); } }