import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/physics.dart'; import 'package:flutter/rendering.dart' show RenderViewportBase; import 'package:flutter/services.dart'; import 'leaf_viewer_internal.dart'; import 'leaf_chrome.dart'; import 'leaf_zoom_controller.dart'; /// Fullscreen pager with per-page zoom, boundary-aware edge release and a /// viewer-level dismiss gesture. /// /// Pages are built by [pageBuilder]. Wrap a page's content in [LeafZoomable] /// to give it zoom/pan/double-tap; pages without one get paging + dismiss /// only. /// /// Gesture arbitration happens in the gesture arena (no raw pointer routing): /// - [LeafZoomable]'s pan/zoom recognizer claims drags only when the child /// can actually move (zoomed + a free axis); otherwise the PageView's drag /// (horizontal) or the dismiss drag (perpendicular) wins — edge-release /// - the mouse wheel zooms cursor-anchored on all platforms; a plain wheel /// over a scrollable under the viewer scrolls that scrollable instead /// - a quick tap outside the active image fires [onTapOutside] /// - keyboard (when the viewer has focus): the arrows page prev/next, /// Esc dismisses (via [onDismissed] or pop); disabled with /// [enableKeyboard] /// /// Desktop (Windows/Linux/macOS) differs from touch: no mouse drag paging, no /// drag dismiss and no double-tap ([enableSwipeGestures] forces the touch /// behaviour anywhere — the close button / wheel / keyboard replace them). /// When [controlsBuilder] is null on desktop a default control set /// (previous/next, close, page counter) is shown. class LeafViewer extends StatefulWidget { const LeafViewer({ super.key, required this.pageCount, required this.pageBuilder, this.pageController, this.initialPage = 0, this.scrollDirection = Axis.horizontal, this.onPageChanged, this.dismissEnabled = true, this.onDismissed, this.onTapOutside, this.backgroundBuilder, this.controlsBuilder, this.enableSwipeGestures, this.enableKeyboard = true, this.enableChrome = false, this.showPageCounter = true, }); final int pageCount; final Widget Function(BuildContext context, int index) pageBuilder; final PageController? pageController; final int initialPage; final Axis scrollDirection; final ValueChanged? onPageChanged; final bool dismissEnabled; final VoidCallback? onDismissed; final VoidCallback? onTapOutside; /// Builds the layer behind the pager. Receives the dismiss progress in /// `[0, 1]` for backdrop fading. final Widget Function(BuildContext context, double progress)? backgroundBuilder; /// Builds the chrome overlay on top of the pager (media_kit `controlsBuilder` /// style). When null, a default control set is shown on desktop and nothing /// on touch platforms. final Widget Function(BuildContext context, LeafViewerControls controls)? controlsBuilder; /// Force the touch gesture set (swipe paging, drag dismiss, double-tap) on /// any platform. Null = platform default (off on desktop, on elsewhere). final bool? enableSwipeGestures; /// Whether the viewer grabs keyboard focus and handles the arrows for /// paging and Esc for dismissal (default true). final bool enableKeyboard; /// Opt-in chrome reveal: when true the viewer owns a [LeafViewerChrome] /// (reachable via [chromeOf]) whose visibility tap-toggles on the media and /// drives every [LeafChrome] layer plus the paging chrome below. Auto-hide /// applies on touch platforms only; on desktop chrome hides on tap only. final bool enableChrome; /// Whether the default control set shows the "current / total" counter. /// Meaningless with huge page counts — turn off there. final bool showPageCounter; /// The zoom controller of the currently active page, if any. static LeafZoomController? controllerOf(BuildContext context) { return LeafViewerScope.maybeOf(context)?.registry.activeController; } /// The index of the page this context belongs to, if inside a viewer. static int? pageIndexOf(BuildContext context) { return LeafPageIndexScope.maybeOf(context); } /// Reactive index of the currently settled page, if inside a viewer. static ValueListenable? currentIndexListOf(BuildContext context) { return LeafViewerScope.maybeOf(context)?.currentIndex; } /// The viewer's chrome reveal controller, if inside a chrome-enabled viewer. static LeafViewerChrome? chromeOf(BuildContext context) { return LeafViewerScope.maybeOf(context)?.chrome; } @override State createState() => _LeafViewerState(); } /// State passed to [LeafViewer.controlsBuilder]. class LeafViewerControls { const LeafViewerControls({required this.pageCount, required this.currentIndex, required this.goToPage}); final int pageCount; final int currentIndex; /// Animates to the given page index (clamped by [pageCount]). final void Function(int index) goToPage; bool get canGoPrevious => currentIndex > 0; bool get canGoNext => currentIndex < pageCount - 1; } class _LeafViewerState extends State with TickerProviderStateMixin implements LeafViewerRegistry { late final PageController _pageController; late final AnimationController _dismissCtrl; final Map _controllers = {}; final ValueNotifier _indexNotifier = ValueNotifier(0); final FocusNode _focusNode = FocusNode(); int _currentIndex = 0; bool _dismissing = false; Size _viewerSize = Size.zero; LeafViewerChrome? _chrome; LeafZoomController? get _activeController => _controllers[_currentIndex]; bool get _swipeMode => widget.enableSwipeGestures ?? !leafIsDesktopPlatform(); @override bool get swipeGesturesEnabled => _swipeMode; KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) { if (event is! KeyDownEvent && event is! KeyRepeatEvent) return KeyEventResult.ignored; final horizontal = widget.scrollDirection == Axis.horizontal; final prev = horizontal ? LogicalKeyboardKey.arrowLeft : LogicalKeyboardKey.arrowUp; final next = horizontal ? LogicalKeyboardKey.arrowRight : LogicalKeyboardKey.arrowDown; final key = event.logicalKey; if (key == prev) { _goToPage(_currentIndex - 1); return KeyEventResult.handled; } if (key == next) { _goToPage(_currentIndex + 1); return KeyEventResult.handled; } if (key == LogicalKeyboardKey.escape) { if (widget.onDismissed != null) { widget.onDismissed!(); } else { Navigator.maybePop(context); } return KeyEventResult.handled; } return KeyEventResult.ignored; } @override void initState() { super.initState(); _currentIndex = widget.pageController?.initialPage ?? widget.initialPage; _indexNotifier.value = _currentIndex; _pageController = widget.pageController ?? PageController(initialPage: _currentIndex); _dismissCtrl = AnimationController.unbounded(vsync: this) ..addListener(() { if (mounted) setState(() {}); }); if (widget.enableChrome) { _chrome = LeafViewerChrome(autoHide: !leafIsDesktopPlatform()); } } @override void dispose() { _focusNode.dispose(); _dismissCtrl.dispose(); _indexNotifier.dispose(); _chrome?.dispose(); if (widget.pageController == null) _pageController.dispose(); super.dispose(); } // ── Controller registry ───────────────────────────────────────────────── @override void register(int index, LeafZoomController ctrl) { _controllers[index] = ctrl; } @override void unregister(int index, LeafZoomController ctrl) { if (_controllers[index] == ctrl) _controllers.remove(index); } @override LeafZoomController? get activeController => _controllers[_currentIndex]; void _onPageIndexChanged(int index) { _currentIndex = index; _indexNotifier.value = index; } void _goToPage(int index) { if (index < 0 || index >= widget.pageCount) return; _pageController.animateToPage(index, duration: const Duration(milliseconds: 200), curve: Curves.easeOut); } // ── Dismiss ───────────────────────────────────────────────────────────── double get _dismissOffset => _dismissCtrl.value; double get _dismissExtent => widget.scrollDirection == Axis.horizontal ? _viewerSize.height : _viewerSize.width; double get _dismissProgress => _dismissExtent > 0 ? (_dismissOffset.abs() / (_dismissExtent * 0.5)).clamp(0.0, 1.0) : 0.0; Offset get _dismissOffsetVector => widget.scrollDirection == Axis.horizontal ? Offset(0, _dismissOffset) : Offset(_dismissOffset, 0); void _onDismissStart(DragStartDetails details) { _activeController?.cancelGesture(); } void _onDismissUpdate(DragUpdateDetails details) { _dismissCtrl.value += widget.scrollDirection == Axis.horizontal ? details.delta.dy : details.delta.dx; } void _onDismissEnd(DragEndDetails details) { _endDismissDrag(details.primaryVelocity ?? 0); } void _endDismissDrag(double velocity) { if (_dismissing || !widget.dismissEnabled) return; final offset = _dismissCtrl.value; final extent = _dismissExtent; if (velocity.abs() > 800 || offset.abs() > extent * 0.25) { _dismissing = true; final dir = velocity.abs() > 50 ? velocity.sign : (offset.sign == 0 ? 1.0 : offset.sign); _dismissCtrl.animateTo(dir * extent * 1.5, duration: const Duration(milliseconds: 200), curve: Curves.easeIn).then((_) { if (!mounted) return; final onDismissed = widget.onDismissed; if (onDismissed != null) { onDismissed(); } else { Navigator.maybePop(context); } }); } else { _dismissCtrl.animateWith(SpringSimulation(const SpringDescription(mass: 1, stiffness: 600, damping: 50), offset, 0, velocity)); } } void _onTapUp(TapUpDetails details) { final ctrl = _activeController; if (ctrl != null && !ctrl.zoomed && !ctrl.imageRect.contains(details.localPosition)) { widget.onTapOutside?.call(); } } void _onPointerSignal(PointerSignalEvent e) { if (e is! PointerScrollEvent) return; final ctrl = _activeController; if (ctrl == null || _viewerSize == Size.zero) return; // Plain wheel over a scrollable under the viewer scrolls that scrollable // (it registers deeper, so the resolver picks it first); everywhere else, // and on Ctrl+wheel or once zoomed, the wheel zooms cursor-anchored. if (!HardwareKeyboard.instance.isControlPressed && !ctrl.zoomed) { final hit = HitTestResult(); GestureBinding.instance.hitTestInView(hit, e.position, e.viewId); final overScrollable = hit.path.any((entry) => entry.target is RenderViewportBase && (entry.target as RenderViewportBase).axis == Axis.vertical); if (overScrollable) return; } GestureBinding.instance.pointerSignalResolver.register(e, (event) { final scroll = event as PointerScrollEvent; ctrl.wheelZoom(scroll.scrollDelta.dy, scroll.localPosition); }); } // ── Chrome reveal ──────────────────────────────────────────────────────── /// Wraps the viewer's paging chrome in the chrome reveal, when chrome is on. Widget _chromeRevealed(Widget child) { final chrome = _chrome; if (chrome == null) return child; return ValueListenableBuilder( valueListenable: chrome.visibleListenable, builder: (context, visible, _) => IgnorePointer( ignoring: !visible, child: AnimatedOpacity(duration: const Duration(milliseconds: 200), opacity: visible ? 1.0 : 0.0, child: child), ), ); } // ── Build ─────────────────────────────────────────────────────────────── @override Widget build(BuildContext context) { final progress = _dismissProgress; final behavior = ScrollConfiguration.of(context); final horizontal = widget.scrollDirection == Axis.horizontal; final desktop = leafIsDesktopPlatform(); final swipeMode = _swipeMode; return Focus( focusNode: _focusNode, autofocus: widget.enableKeyboard, onKeyEvent: widget.enableKeyboard ? _onKeyEvent : null, child: LeafViewerScope( registry: this, currentIndex: _indexNotifier, chrome: _chrome, child: LayoutBuilder( builder: (context, constraints) { _viewerSize = constraints.biggest; return Stack( fit: StackFit.expand, children: [ if (widget.backgroundBuilder != null) widget.backgroundBuilder!(context, progress), Listener( behavior: HitTestBehavior.opaque, onPointerSignal: _onPointerSignal, child: GestureDetector( onVerticalDragStart: horizontal && widget.dismissEnabled && swipeMode ? _onDismissStart : null, onVerticalDragUpdate: horizontal && widget.dismissEnabled && swipeMode ? _onDismissUpdate : null, onVerticalDragEnd: horizontal && widget.dismissEnabled && swipeMode ? _onDismissEnd : null, onHorizontalDragStart: !horizontal && widget.dismissEnabled && swipeMode ? _onDismissStart : null, onHorizontalDragUpdate: !horizontal && widget.dismissEnabled && swipeMode ? _onDismissUpdate : null, onHorizontalDragEnd: !horizontal && widget.dismissEnabled && swipeMode ? _onDismissEnd : null, onTapUp: _onTapUp, child: Transform.translate( offset: _dismissOffsetVector, child: Transform.scale( scale: 1.0 - _dismissProgress * 0.15, child: ScrollConfiguration( behavior: behavior.copyWith( dragDevices: swipeMode ? {...behavior.dragDevices, PointerDeviceKind.mouse} : behavior.dragDevices, ), child: PageView.builder( controller: _pageController, physics: const PageScrollPhysics(), scrollDirection: widget.scrollDirection, // Keep adjacent pages alive so their zoom state // survives swipes (and preloads neighbors). allowImplicitScrolling: true, itemCount: widget.pageCount, onPageChanged: (index) { widget.onPageChanged?.call(index); _onPageIndexChanged(index); }, itemBuilder: (context, index) { return LeafPageIndexScope(index: index, child: widget.pageBuilder(context, index)); }, ), ), ), ), ), ), if (widget.controlsBuilder != null) _chromeRevealed( ValueListenableBuilder( valueListenable: _indexNotifier, builder: (context, index, _) => widget.controlsBuilder!( context, LeafViewerControls(pageCount: widget.pageCount, currentIndex: index, goToPage: _goToPage), ), ), ) else if (desktop) _chromeRevealed( ValueListenableBuilder( valueListenable: _indexNotifier, builder: (context, index, _) => _LeafViewerDefaultControls( controls: LeafViewerControls(pageCount: widget.pageCount, currentIndex: index, goToPage: _goToPage), showPageCounter: widget.showPageCounter, ), ), ), ], ); }, ), ), ); } } /// A [Hero] that only participates on the viewer's currently active page. /// /// LeafViewer keeps neighbour pages alive, so an unconditional hero on every /// page registers several per route — popping the viewer then flies the /// active page AND its neighbours back to the grid. This gates the hero on /// "this page is the settled one", so exactly one flight happens. The child /// instance is passed through untouched, so providers stay stable across /// page changes. Outside a viewer it behaves like a plain [Hero]. class LeafViewerHero extends StatelessWidget { const LeafViewerHero({super.key, required this.tag, required this.child}); final Object tag; final Widget child; @override Widget build(BuildContext context) { final index = LeafViewer.pageIndexOf(context); final currentIndex = LeafViewer.currentIndexListOf(context); if (index == null || currentIndex == null) return Hero(tag: tag, child: child); return ValueListenableBuilder( valueListenable: currentIndex, child: child, builder: (context, current, child) => current == index ? Hero(tag: tag, child: child!) : child!, ); } } class _LeafViewerDefaultControls extends StatelessWidget { const _LeafViewerDefaultControls({required this.controls, this.showPageCounter = true}); final LeafViewerControls controls; final bool showPageCounter; @override Widget build(BuildContext context) { return Stack( children: [ Positioned( left: 8, top: 0, bottom: 0, child: Center( child: _ControlButton( icon: Icons.chevron_left, onTap: controls.canGoPrevious ? () => controls.goToPage(controls.currentIndex - 1) : null, ), ), ), Positioned( right: 8, top: 0, bottom: 0, child: Center( child: _ControlButton( icon: Icons.chevron_right, onTap: controls.canGoNext ? () => controls.goToPage(controls.currentIndex + 1) : null, ), ), ), Positioned( top: 8, right: 8, child: _ControlButton(icon: Icons.close, onTap: () => Navigator.maybePop(context)), ), if (showPageCounter) Positioned( left: 0, right: 0, bottom: 16, child: IgnorePointer( child: Text( '${controls.currentIndex + 1} / ${controls.pageCount}', textAlign: TextAlign.center, style: const TextStyle(color: Colors.white70, fontSize: 14, fontFeatures: [FontFeature.tabularFigures()]), ), ), ), ], ); } } class _ControlButton extends StatelessWidget { const _ControlButton({required this.icon, required this.onTap}); final IconData icon; final VoidCallback? onTap; @override Widget build(BuildContext context) { return IconButton( onPressed: onTap, icon: Icon(icon), tooltip: null, style: IconButton.styleFrom( backgroundColor: Colors.black.withValues(alpha: 0.45), foregroundColor: Colors.white, ), ); } }