import 'dart:async'; import 'package:aves/app_mode.dart'; import 'package:aves/model/entry/entry.dart'; import 'package:aves/model/favourites.dart'; import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/selection.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/model/source/enums/enums.dart'; import 'package:aves/model/source/section_keys.dart'; import 'package:aves/ref/mime_types.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/collection/app_bar.dart'; import 'package:aves/widgets/collection/draggable_thumb_label.dart'; import 'package:aves/widgets/collection/grid/list_details_theme.dart'; import 'package:aves/widgets/collection/grid/section_layout.dart'; import 'package:aves/widgets/collection/grid/tile.dart'; import 'package:aves/widgets/common/basic/draggable_scrollbar/scrollbar.dart'; import 'package:aves/widgets/common/basic/insets.dart'; import 'package:aves/widgets/common/behaviour/routes.dart'; import 'package:aves/widgets/common/behaviour/sloppy_scroll_physics.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/media_query.dart'; import 'package:aves/widgets/common/grid/draggable_thumb_label.dart'; import 'package:aves/widgets/common/grid/item_tracker.dart'; import 'package:aves/widgets/common/grid/scaling.dart'; import 'package:aves/widgets/common/grid/sections/fixed/scale_grid.dart'; import 'package:aves/widgets/common/grid/sections/list_layout.dart'; import 'package:aves/widgets/common/grid/sections/section_layout.dart'; import 'package:aves/widgets/common/grid/selector.dart'; import 'package:aves/widgets/common/grid/sliver.dart'; import 'package:aves/widgets/common/grid/theme.dart'; import 'package:aves/widgets/common/identity/buttons/outlined_button.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/thumbnail/decorated.dart'; import 'package:aves/widgets/common/thumbnail/image.dart'; import 'package:aves/widgets/common/thumbnail/notifications.dart'; import 'package:aves/widgets/common/tile_extent_controller.dart'; import 'package:aves/widgets/navigation/nav_bar/nav_bar.dart'; import 'package:aves/widgets/viewer/entry_viewer_page.dart'; import 'package:collection/collection.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; import 'package:intl/intl.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:provider/provider.dart'; import 'package:tuple/tuple.dart'; class CollectionGrid extends StatefulWidget { final String settingsRouteKey; static const double extentMin = 46; static const double extentMax = 300; static const double fixedExtentLayoutSpacing = 2; static const double mosaicLayoutSpacing = 4; static int get columnCountDefault => settings.useTvLayout ? 6 : 4; const CollectionGrid({ super.key, required this.settingsRouteKey, }); @override State createState() => _CollectionGridState(); } class _CollectionGridState extends State { TileExtentController? _tileExtentController; String get settingsRouteKey => widget.settingsRouteKey; @override void dispose() { _tileExtentController?.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final spacing = context.select((s) => s.getTileLayout(settingsRouteKey) == TileLayout.mosaic ? CollectionGrid.mosaicLayoutSpacing : CollectionGrid.fixedExtentLayoutSpacing); if (_tileExtentController?.spacing != spacing) { _tileExtentController = TileExtentController( settingsRouteKey: settingsRouteKey, columnCountDefault: CollectionGrid.columnCountDefault, extentMin: CollectionGrid.extentMin, extentMax: CollectionGrid.extentMax, spacing: spacing, horizontalPadding: 2, ); } return TileExtentControllerProvider( controller: _tileExtentController!, child: const _CollectionGridContent(), ); } } class _CollectionGridContent extends StatefulWidget { const _CollectionGridContent(); @override State<_CollectionGridContent> createState() => _CollectionGridContentState(); } class _CollectionGridContentState extends State<_CollectionGridContent> { final ValueNotifier _focusedItemNotifier = ValueNotifier(null); final ValueNotifier _isScrollingNotifier = ValueNotifier(false); @override void dispose() { _focusedItemNotifier.dispose(); _isScrollingNotifier.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final selectable = context.select, bool>((v) => v.value.canSelectMedia); final settingsRouteKey = context.read().settingsRouteKey; final tileLayout = context.select((s) => s.getTileLayout(settingsRouteKey)); return Consumer( builder: (context, collection, child) { final sectionedListLayoutProvider = ValueListenableBuilder( valueListenable: context.select>((controller) => controller.extentNotifier), builder: (context, thumbnailExtent, child) { assert(thumbnailExtent > 0); return Selector>( selector: (context, c) => Tuple4(c.viewportSize.width, c.columnCount, c.spacing, c.horizontalPadding), builder: (context, c, child) { final scrollableWidth = c.item1; final columnCount = c.item2; final tileSpacing = c.item3; final horizontalPadding = c.item4; final source = collection.source; return GridTheme( extent: thumbnailExtent, child: EntryListDetailsTheme( extent: thumbnailExtent, child: ValueListenableBuilder( valueListenable: source.stateNotifier, builder: (context, sourceState, child) { late final Duration tileAnimationDelay; if (sourceState == SourceState.ready) { // do not listen for animation delay change final target = context.read().staggeredAnimationPageTarget; tileAnimationDelay = context.read().getTileAnimationDelay(target); } else { tileAnimationDelay = Duration.zero; } return NotificationListener( onNotification: (notification) { _goToViewer(collection, notification.entry); return true; }, child: StreamBuilder( stream: source.eventBus.on(), builder: (context, snapshot) => SectionedEntryListLayoutProvider( collection: collection, selectable: selectable, scrollableWidth: scrollableWidth, tileLayout: tileLayout, columnCount: columnCount, spacing: tileSpacing, horizontalPadding: horizontalPadding, tileExtent: thumbnailExtent, tileBuilder: (entry, tileSize) { final extent = tileSize.shortestSide; return AnimatedBuilder( animation: favourites, builder: (context, child) { Widget tile = InteractiveTile( key: ValueKey(entry.id), collection: collection, entry: entry, thumbnailExtent: extent, tileLayout: tileLayout, isScrollingNotifier: _isScrollingNotifier, ); if (!settings.useTvLayout) return tile; return Focus( onFocusChange: (focused) { if (focused) { _focusedItemNotifier.value = entry; } else if (_focusedItemNotifier.value == entry) { _focusedItemNotifier.value = null; } }, child: ValueListenableBuilder( valueListenable: _focusedItemNotifier, builder: (context, focusedItem, child) { return AnimatedScale( scale: focusedItem == entry ? 1 : .9, curve: Curves.fastOutSlowIn, duration: context.select((v) => v.tvImageFocusAnimation), child: child!, ); }, child: tile, ), ); }, ); }, tileAnimationDelay: tileAnimationDelay, child: child!, ), ), ); }, child: child, ), ), ); }, child: child, ); }, child: _CollectionSectionedContent( collection: collection, isScrollingNotifier: _isScrollingNotifier, scrollController: PrimaryScrollController.of(context), tileLayout: tileLayout, selectable: selectable, ), ); return sectionedListLayoutProvider; }, ); } void _goToViewer(CollectionLens collection, AvesEntry entry) { final selection = context.read>(); Navigator.maybeOf(context)?.push( TransparentMaterialPageRoute( settings: const RouteSettings(name: EntryViewerPage.routeName), pageBuilder: (context, a, sa) { final viewerCollection = collection.copyWith( listenToSource: false, ); Widget child = EntryViewerPage( collection: viewerCollection, initialEntry: entry, ); if (selection.isSelecting) { child = MultiProvider( providers: [ ListenableProvider>.value(value: ValueNotifier(AppMode.pickMediaInternal)), ChangeNotifierProvider>.value(value: selection), ], child: child, ); } return child; }, ), ); } } class _CollectionSectionedContent extends StatefulWidget { final CollectionLens collection; final ValueNotifier isScrollingNotifier; final ScrollController scrollController; final TileLayout tileLayout; final bool selectable; const _CollectionSectionedContent({ required this.collection, required this.isScrollingNotifier, required this.scrollController, required this.tileLayout, required this.selectable, }); @override State<_CollectionSectionedContent> createState() => _CollectionSectionedContentState(); } class _CollectionSectionedContentState extends State<_CollectionSectionedContent> { final ValueNotifier _appBarHeightNotifier = ValueNotifier(0); final GlobalKey _scrollableKey = GlobalKey(debugLabel: 'thumbnail-collection-scrollable'); CollectionLens get collection => widget.collection; TileLayout get tileLayout => widget.tileLayout; ScrollController get scrollController => widget.scrollController; @override Widget build(BuildContext context) { final scrollView = AnimationLimiter( child: _CollectionScrollView( scrollableKey: _scrollableKey, collection: collection, appBar: CollectionAppBar( appBarHeightNotifier: _appBarHeightNotifier, collection: collection, ), appBarHeightNotifier: _appBarHeightNotifier, isScrollingNotifier: widget.isScrollingNotifier, scrollController: scrollController, ), ); final scaler = _CollectionScaler( scrollableKey: _scrollableKey, appBarHeightNotifier: _appBarHeightNotifier, tileLayout: tileLayout, child: scrollView, ); final selector = GridSelectionGestureDetector( scrollableKey: _scrollableKey, selectable: widget.selectable, items: collection.sortedEntries, scrollController: scrollController, appBarHeightNotifier: _appBarHeightNotifier, child: scaler, ); return GridItemTracker( scrollableKey: _scrollableKey, tileLayout: tileLayout, appBarHeightNotifier: _appBarHeightNotifier, scrollController: scrollController, child: selector, ); } } class _CollectionScaler extends StatelessWidget { final GlobalKey scrollableKey; final ValueNotifier appBarHeightNotifier; final TileLayout tileLayout; final Widget child; const _CollectionScaler({ required this.scrollableKey, required this.appBarHeightNotifier, required this.tileLayout, required this.child, }); @override Widget build(BuildContext context) { final metrics = context.select>((v) => Tuple2(v.spacing, v.horizontalPadding)); final tileSpacing = metrics.item1; final horizontalPadding = metrics.item2; final brightness = Theme.of(context).brightness; return GridScaleGestureDetector( scrollableKey: scrollableKey, tileLayout: tileLayout, heightForWidth: (width) => width, gridBuilder: (center, tileSize, child) => CustomPaint( painter: FixedExtentGridPainter( tileLayout: tileLayout, tileCenter: center, tileSize: tileSize, spacing: tileSpacing, horizontalPadding: horizontalPadding, borderWidth: DecoratedThumbnail.borderWidth, borderRadius: Radius.zero, color: DecoratedThumbnail.borderColor, textDirection: Directionality.of(context), ), child: child, ), scaledItemBuilder: (entry, tileSize) => EntryListDetailsTheme( extent: tileSize.height, child: Tile( entry: entry, thumbnailExtent: context.read().effectiveExtentMax, tileLayout: tileLayout, ), ), mosaicItemBuilder: (index, targetExtent) => DecoratedBox( decoration: BoxDecoration( color: ThumbnailImage.computeLoadingBackgroundColor(index * 10, brightness).withOpacity(.9), border: Border.all( color: DecoratedThumbnail.borderColor, width: DecoratedThumbnail.borderWidth, ), ), ), child: child, ); } } class _CollectionScrollView extends StatefulWidget { final GlobalKey scrollableKey; final CollectionLens collection; final Widget appBar; final ValueNotifier appBarHeightNotifier; final ValueNotifier isScrollingNotifier; final ScrollController scrollController; const _CollectionScrollView({ required this.scrollableKey, required this.collection, required this.appBar, required this.appBarHeightNotifier, required this.isScrollingNotifier, required this.scrollController, }); @override State<_CollectionScrollView> createState() => _CollectionScrollViewState(); } class _CollectionScrollViewState extends State<_CollectionScrollView> with WidgetsBindingObserver { Timer? _scrollMonitoringTimer; bool _checkingStoragePermission = false; @override void initState() { super.initState(); _registerWidget(widget); WidgetsBinding.instance.addObserver(this); } @override void didUpdateWidget(covariant _CollectionScrollView oldWidget) { super.didUpdateWidget(oldWidget); _unregisterWidget(oldWidget); _registerWidget(widget); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); _unregisterWidget(widget); _stopScrollMonitoringTimer(); super.dispose(); } void _registerWidget(_CollectionScrollView widget) { widget.collection.filterChangeNotifier.addListener(_scrollToTop); widget.collection.sortSectionChangeNotifier.addListener(_scrollToTop); widget.scrollController.addListener(_onScrollChanged); } void _unregisterWidget(_CollectionScrollView widget) { widget.collection.filterChangeNotifier.removeListener(_scrollToTop); widget.collection.sortSectionChangeNotifier.removeListener(_scrollToTop); widget.scrollController.removeListener(_onScrollChanged); } @override void didChangeAppLifecycleState(AppLifecycleState state) { switch (state) { case AppLifecycleState.inactive: case AppLifecycleState.paused: case AppLifecycleState.detached: break; case AppLifecycleState.resumed: if (_checkingStoragePermission) { _checkingStoragePermission = false; _isStoragePermissionGranted.then((granted) { if (granted) { widget.collection.source.init(); } }); } break; } } @override Widget build(BuildContext context) { final scrollView = _buildScrollView(widget.appBar, widget.collection); return settings.useTvLayout ? scrollView : _buildDraggableScrollView(scrollView, widget.collection); } Widget _buildDraggableScrollView(ScrollView scrollView, CollectionLens collection) { return ValueListenableBuilder( valueListenable: widget.appBarHeightNotifier, builder: (context, appBarHeight, child) { return Selector( selector: (context, mq) => mq.effectiveBottomPadding, builder: (context, mqPaddingBottom, child) { return Selector( selector: (context, s) => s.enableBottomNavigationBar, builder: (context, enableBottomNavigationBar, child) { final canNavigate = context.select, bool>((v) => v.value.canNavigate); final showBottomNavigationBar = canNavigate && enableBottomNavigationBar; final navBarHeight = showBottomNavigationBar ? AppBottomNavBar.height : 0; return Selector, List>( selector: (context, layout) => layout.sectionLayouts, builder: (context, sectionLayouts, child) { final scrollController = widget.scrollController; final offsetIncrementSnapThreshold = context.select((v) => (v.extentNotifier.value + v.spacing) / 4); return DraggableScrollbar( backgroundColor: Colors.white, scrollThumbSize: Size(avesScrollThumbWidth, avesScrollThumbHeight), scrollThumbBuilder: avesScrollThumbBuilder( height: avesScrollThumbHeight, backgroundColor: Colors.white, ), controller: scrollController, dragOffsetSnapper: (scrollOffset, offsetIncrement) { if (offsetIncrement > offsetIncrementSnapThreshold && scrollOffset < scrollController.position.maxScrollExtent) { final section = sectionLayouts.firstWhereOrNull((section) => section.hasChildAtOffset(scrollOffset)); if (section != null) { if (section.maxOffset - section.minOffset < scrollController.position.viewportDimension) { // snap to section header return section.minOffset; } else { // snap to content row final index = section.getMinChildIndexForScrollOffset(scrollOffset); return section.indexToLayoutOffset(index); } } } return scrollOffset; }, crumbsBuilder: () => _getCrumbs(sectionLayouts), padding: EdgeInsets.only( // padding to keep scroll thumb between app bar above and nav bar below top: appBarHeight, bottom: navBarHeight + mqPaddingBottom, ), labelTextBuilder: (offsetY) => CollectionDraggableThumbLabel( collection: collection, offsetY: offsetY, ), crumbTextBuilder: (label) => DraggableCrumbLabel(label: label), child: scrollView, ); }, ); }, ); }, ); }, ); } ScrollView _buildScrollView(Widget appBar, CollectionLens collection) { return CustomScrollView( key: widget.scrollableKey, primary: true, // workaround to prevent scrolling the app bar away // when there is no content and we use `SliverFillRemaining` physics: collection.isEmpty ? const NeverScrollableScrollPhysics() : SloppyScrollPhysics( gestureSettings: context.select((mq) => mq.gestureSettings), parent: const AlwaysScrollableScrollPhysics(), ), cacheExtent: context.select((controller) => controller.effectiveExtentMax), slivers: [ appBar, collection.isEmpty ? SliverFillRemaining( hasScrollBody: false, child: _buildEmptyCollectionPlaceholder(collection), ) : const SectionedListSliver(), const NavBarPaddingSliver(), const BottomPaddingSliver(), const TvTileGridBottomPaddingSliver(), ], ); } Widget _buildEmptyCollectionPlaceholder(CollectionLens collection) { return ValueListenableBuilder( valueListenable: collection.source.stateNotifier, builder: (context, sourceState, child) { if (sourceState == SourceState.loading) { return const SizedBox(); } return FutureBuilder( future: _isStoragePermissionGranted, builder: (context, snapshot) { final granted = snapshot.data ?? true; Widget? bottom = granted ? null : Padding( padding: const EdgeInsets.only(top: 16), child: AvesOutlinedButton( label: context.l10n.collectionEmptyGrantAccessButtonLabel, onPressed: () async { if (await openAppSettings()) { _checkingStoragePermission = true; } }, ), ); if (collection.filters.any((filter) => filter is FavouriteFilter)) { return EmptyContent( icon: AIcons.favourite, text: context.l10n.collectionEmptyFavourites, bottom: bottom, ); } if (collection.filters.any((filter) => filter is MimeFilter && filter.mime == MimeTypes.anyVideo)) { return EmptyContent( icon: AIcons.video, text: context.l10n.collectionEmptyVideos, bottom: bottom, ); } return EmptyContent( icon: AIcons.image, text: context.l10n.collectionEmptyImages, bottom: bottom, ); }, ); }, ); } void _scrollToTop() => widget.scrollController.jumpTo(0); void _onScrollChanged() { widget.isScrollingNotifier.value = true; _stopScrollMonitoringTimer(); _scrollMonitoringTimer = Timer(Durations.collectionScrollMonitoringTimerDelay, () { widget.isScrollingNotifier.value = false; }); } void _stopScrollMonitoringTimer() => _scrollMonitoringTimer?.cancel(); Map _getCrumbs(List sectionLayouts) { final crumbs = {}; if (sectionLayouts.length <= 1) return crumbs; final maxOffset = sectionLayouts.last.maxOffset; void addAlbums(CollectionLens collection, List sectionLayouts, Map crumbs) { final source = collection.source; sectionLayouts.forEach((section) { final directory = (section.sectionKey as EntryAlbumSectionKey).directory; if (directory != null) { final label = source.getAlbumDisplayName(context, directory); crumbs[section.minOffset / maxOffset] = label; } }); } final collection = widget.collection; switch (collection.sortFactor) { case EntrySortFactor.date: switch (collection.sectionFactor) { case EntryGroupFactor.album: addAlbums(collection, sectionLayouts, crumbs); break; case EntryGroupFactor.month: case EntryGroupFactor.day: final firstKey = sectionLayouts.first.sectionKey; final lastKey = sectionLayouts.last.sectionKey; if (firstKey is EntryDateSectionKey && lastKey is EntryDateSectionKey) { final newest = firstKey.date; final oldest = lastKey.date; if (newest != null && oldest != null) { final localeName = context.l10n.localeName; final dateFormat = (newest.difference(oldest).inDays).abs() > 365 ? DateFormat.y(localeName) : DateFormat.MMM(localeName); String? lastLabel; sectionLayouts.forEach((section) { final date = (section.sectionKey as EntryDateSectionKey).date; if (date != null) { final label = dateFormat.format(date); if (label != lastLabel) { crumbs[section.minOffset / maxOffset] = label; lastLabel = label; } } }); } } break; case EntryGroupFactor.none: break; } break; case EntrySortFactor.name: addAlbums(collection, sectionLayouts, crumbs); break; case EntrySortFactor.rating: case EntrySortFactor.size: break; } return crumbs; } Future get _isStoragePermissionGranted => Future.wait(Constants.storagePermissions.map((v) => v.status)).then((v) => v.any((status) => status.isGranted)); }