import 'dart:async'; import 'dart:math'; import 'dart:ui' show FlutterView; import 'package:aves/model/highlight.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/common/grid/sections/list_layout.dart'; import 'package:aves_model/aves_model.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; class GridItemTracker extends StatefulWidget { final GlobalKey scrollableKey; final ValueNotifier appBarHeightNotifier; final TileLayout tileLayout; final ScrollController scrollController; final Widget child; const GridItemTracker({ super.key, required this.scrollableKey, required this.appBarHeightNotifier, required this.tileLayout, required this.scrollController, required this.child, }); @override State> createState() => _GridItemTrackerState(); } class _GridItemTrackerState extends State> with WidgetsBindingObserver { ValueNotifier get appBarHeightNotifier => widget.appBarHeightNotifier; ScrollController get scrollController => widget.scrollController; final Set _subscriptions = {}; FlutterView? _view; ModalRoute? _route; HighlightInfo? _highlightInfo; SectionedListLayout? _sectionedListLayout; bool _disposed = false; bool _hasSavedLayoutMetrics = false; late SectionedListLayout _lastSectionedListLayout; late Size _lastScrollableSize; Orientation _lastOrientation = Orientation.portrait; @override Widget build(BuildContext context) { return widget.child; } Size? get scrollableSize { final scrollableContext = widget.scrollableKey.currentContext; if (scrollableContext == null) return null; try { final renderObject = scrollableContext.findRenderObject(); if (renderObject is! RenderBox) return null; if (!renderObject.hasSize) return null; return renderObject.size; } catch (e, st) { debugPrint('GridItemTracker scrollableSize ignored: $e'); debugPrint('$st'); return null; } } bool get _canUseTracker { if (_disposed || !mounted) return false; final route = _route; if (route != null && !route.isCurrent) return false; return true; } @override void didChangeDependencies() { super.didChangeDependencies(); _view = View.maybeOf(context); _route = ModalRoute.of(context); try { final highlightInfo = Provider.of( context, listen: false, ); if (!identical(_highlightInfo, highlightInfo)) { _subscriptions ..forEach((sub) => sub.cancel()) ..clear(); _highlightInfo = highlightInfo; _subscriptions.add( highlightInfo.eventBus.on>().listen(_trackItem), ); } } catch (e, st) { debugPrint('GridItemTracker HighlightInfo lookup ignored: $e'); debugPrint('$st'); } try { _sectionedListLayout = Provider.of>( context, listen: false, ); } catch (e, st) { debugPrint('GridItemTracker SectionedListLayout lookup ignored: $e'); debugPrint('$st'); } _saveLayoutMetrics(); } @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); } @override void didUpdateWidget(covariant GridItemTracker oldWidget) { super.didUpdateWidget(oldWidget); if (!_canUseTracker) return; if (oldWidget.tileLayout != widget.tileLayout) { _onLayoutChanged(); } _saveLayoutMetrics(); } @override void didChangeMetrics() { if (!_canUseTracker) return; final view = _view; if (view == null) return; final size = view.physicalSize; final orientation = size.width > size.height ? Orientation.landscape : Orientation.portrait; if (_lastOrientation == orientation) return; _lastOrientation = orientation; WidgetsBinding.instance.addPostFrameCallback((_) { if (!_canUseTracker) return; if (widget.scrollableKey.currentContext == null) return; try { _onLayoutChanged(); _saveLayoutMetrics(); } catch (e, st) { debugPrint('GridItemTracker didChangeMetrics ignored: $e'); debugPrint('$st'); } }); } @override void dispose() { _disposed = true; WidgetsBinding.instance.removeObserver(this); _view = null; _route = null; _highlightInfo = null; _sectionedListLayout = null; _subscriptions ..forEach((sub) => sub.cancel()) ..clear(); super.dispose(); } Future _trackItem(TrackEvent event) async { if (!_canUseTracker) return; final sectionedListLayout = _sectionedListLayout; if (sectionedListLayout == null) return; try { final tileRect = sectionedListLayout.getTileRect(event.item); if (tileRect == null) return; final currentScrollableSize = scrollableSize; if (currentScrollableSize == null) return; if (scrollController.positions.length != 1) return; final viewportRect = Offset(0, scrollController.offset) & currentScrollableSize; final itemVisibility = max(0, tileRect.intersect(viewportRect).height) / tileRect.height; if (!event.predicate(itemVisibility)) return; double scrollOffset = tileRect.top + (tileRect.height - currentScrollableSize.height) * ((event.alignment.y + 1) / 2); scrollOffset += appBarHeightNotifier.value; scrollOffset = scrollOffset.clamp(0, scrollController.position.maxScrollExtent); if (scrollOffset > 0) { if (event.animate) { await scrollController.animateTo( scrollOffset, duration: Duration( milliseconds: (scrollOffset / 2).round().clamp( ADurations.highlightScrollAnimationMinMillis, ADurations.highlightScrollAnimationMaxMillis, ), ), curve: Curves.easeInOutCubic, ); } else { scrollController.jumpTo(scrollOffset); await Future.delayed(ADurations.highlightJumpDelay); } } if (!_canUseTracker) return; final highlightItem = event.highlightItem; if (highlightItem != null) { _highlightInfo?.set(highlightItem); } } catch (e, st) { debugPrint('GridItemTracker _trackItem ignored: $e'); debugPrint('$st'); } } Future _saveLayoutMetrics() async { await Future.delayed(const Duration(milliseconds: 500)); if (!_canUseTracker) return; if (widget.scrollableKey.currentContext == null) return; final currentScrollableSize = scrollableSize; if (currentScrollableSize == null) return; final sectionedListLayout = _sectionedListLayout; if (sectionedListLayout == null) return; try { _lastSectionedListLayout = sectionedListLayout; _lastScrollableSize = currentScrollableSize; _hasSavedLayoutMetrics = true; } catch (e, st) { debugPrint('GridItemTracker _saveLayoutMetrics ignored: $e'); debugPrint('$st'); } } void _onLayoutChanged() { if (!_canUseTracker) return; if (!_hasSavedLayoutMetrics) return; if (scrollController.positions.length != 1) return; if (scrollController.offset == 0) return; try { final layout = _lastSectionedListLayout; final halfSize = _lastScrollableSize / 2; final center = Offset( halfSize.width, halfSize.height + scrollController.offset - appBarHeightNotifier.value, ); var pivotItem = layout.getItemAt(center) ?? layout.getItemAt(Offset(0, center.dy)); if (pivotItem == null) { final pivotSectionKey = layout.getSectionAt(center.dy)?.sectionKey; if (pivotSectionKey != null) { pivotItem = layout.sections[pivotSectionKey]?.firstOrNull; } } if (pivotItem != null) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!_canUseTracker) return; try { _highlightInfo?.trackItem( pivotItem, animate: false, ); } catch (e, st) { debugPrint('GridItemTracker _onLayoutChanged ignored: $e'); debugPrint('$st'); } }); } } catch (e, st) { debugPrint('GridItemTracker _onLayoutChanged ignored: $e'); debugPrint('$st'); } } }