diff --git a/lib/leaf_widgets.dart b/lib/leaf_widgets.dart index 5cbb364..b99bdf8 100644 --- a/lib/leaf_widgets.dart +++ b/lib/leaf_widgets.dart @@ -7,6 +7,7 @@ export 'src/misc/animated_favorite_button.dart'; export 'src/misc/animated_star.dart'; export 'src/misc/leaf_async_chip.dart'; export 'src/popover/leaf_popover.dart'; +export 'src/viewer/leaf_chrome.dart'; export 'src/viewer/leaf_viewer.dart'; export 'src/viewer/leaf_zoom_controller.dart'; export 'src/viewer/leaf_zoomable.dart'; diff --git a/lib/src/viewer/leaf_chrome.dart b/lib/src/viewer/leaf_chrome.dart new file mode 100644 index 0000000..cd697dc --- /dev/null +++ b/lib/src/viewer/leaf_chrome.dart @@ -0,0 +1,96 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'leaf_viewer_internal.dart'; + +/// Reveal state for everything that hides/shows on tap inside a [LeafViewer]: +/// video controls, post action rails, the viewer's own paging chrome. +/// +/// One state, one timer. The auto-hide timer runs whenever chrome is visible +/// and not held — [hold] only *suppresses* hiding (e.g. a paused video keeps +/// controls up); it never shows anything and never cancels hiding while +/// playing. Media pages drive [hold]; passive layers just listen. +class LeafViewerChrome { + LeafViewerChrome({this.hideDelay = const Duration(seconds: 3), this.autoHide = true}) { + // Chrome starts visible — arm the hide timer right away, or entry state + // would never auto-hide until the first tap. + _restartTimer(); + } + + /// How long chrome stays up before auto-hiding while not held. + final Duration hideDelay; + + /// Whether chrome auto-hides at all (off on desktop — hiding there is + /// tap-driven only). + final bool autoHide; + + final ValueNotifier _visible = ValueNotifier(true); + bool _hold = false; + Timer? _timer; + + ValueListenable get visibleListenable => _visible; + bool get visible => _visible.value; + + void toggle() => _visible.value ? hide() : show(); + + void show() { + _visible.value = true; + _restartTimer(); + } + + void hide() { + _visible.value = false; + _cancelTimer(); + } + + /// While held, the auto-hide timer is suppressed. Releasing restarts it. + set hold(bool value) { + if (_hold == value) return; + _hold = value; + value ? _cancelTimer() : _restartTimer(); + } + + void _restartTimer() { + _cancelTimer(); + if (!autoHide || _hold || !_visible.value) return; + _timer = Timer(hideDelay, () => _visible.value = false); + } + + void _cancelTimer() { + _timer?.cancel(); + _timer = null; + } + + void dispose() { + _cancelTimer(); + _visible.dispose(); + } +} + +/// A passive chrome layer: fades with the viewer's reveal state and stops +/// receiving pointers while hidden. Taps outside of actual controls pass +/// through to the media underneath. +/// +/// Outside a chrome-enabled [LeafViewer] the layer renders permanently +/// visible. +class LeafChrome extends StatelessWidget { + const LeafChrome({super.key, required this.builder, this.duration = const Duration(milliseconds: 200)}); + + final WidgetBuilder builder; + final Duration duration; + + @override + Widget build(BuildContext context) { + final chrome = LeafViewerScope.maybeOf(context)?.chrome; + if (chrome == null) return builder(context); + return ValueListenableBuilder( + valueListenable: chrome.visibleListenable, + builder: (context, visible, _) => IgnorePointer( + ignoring: !visible, + child: AnimatedOpacity(duration: duration, opacity: visible ? 1.0 : 0.0, child: builder(context)), + ), + ); + } +} diff --git a/lib/src/viewer/leaf_viewer.dart b/lib/src/viewer/leaf_viewer.dart index 98d330f..2d35cfe 100644 --- a/lib/src/viewer/leaf_viewer.dart +++ b/lib/src/viewer/leaf_viewer.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/physics.dart'; @@ -5,6 +6,7 @@ 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 @@ -46,6 +48,8 @@ class LeafViewer extends StatefulWidget { this.controlsBuilder, this.enableSwipeGestures, this.enableKeyboard = true, + this.enableChrome = false, + this.showPageCounter = true, }); final int pageCount; @@ -75,11 +79,31 @@ class LeafViewer extends StatefulWidget { /// 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; } + /// 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(); } @@ -108,6 +132,7 @@ class _LeafViewerState extends State with TickerProviderStateMixin i int _currentIndex = 0; bool _dismissing = false; Size _viewerSize = Size.zero; + LeafViewerChrome? _chrome; LeafZoomController? get _activeController => _controllers[_currentIndex]; @@ -151,6 +176,9 @@ class _LeafViewerState extends State with TickerProviderStateMixin i ..addListener(() { if (mounted) setState(() {}); }); + if (widget.enableChrome) { + _chrome = LeafViewerChrome(autoHide: !leafIsDesktopPlatform()); + } } @override @@ -158,6 +186,7 @@ class _LeafViewerState extends State with TickerProviderStateMixin i _focusNode.dispose(); _dismissCtrl.dispose(); _indexNotifier.dispose(); + _chrome?.dispose(); if (widget.pageController == null) _pageController.dispose(); super.dispose(); } @@ -256,6 +285,21 @@ class _LeafViewerState extends State with TickerProviderStateMixin i }); } + // ── 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 @@ -271,6 +315,8 @@ class _LeafViewerState extends State with TickerProviderStateMixin i onKeyEvent: widget.enableKeyboard ? _onKeyEvent : null, child: LeafViewerScope( registry: this, + currentIndex: _indexNotifier, + chrome: _chrome, child: LayoutBuilder( builder: (context, constraints) { _viewerSize = constraints.biggest; @@ -319,18 +365,24 @@ class _LeafViewerState extends State with TickerProviderStateMixin i ), ), if (widget.controlsBuilder != null) - ValueListenableBuilder( - valueListenable: _indexNotifier, - builder: (context, index, _) => widget.controlsBuilder!( - context, - LeafViewerControls(pageCount: widget.pageCount, currentIndex: index, goToPage: _goToPage), + _chromeRevealed( + ValueListenableBuilder( + valueListenable: _indexNotifier, + builder: (context, index, _) => widget.controlsBuilder!( + context, + LeafViewerControls(pageCount: widget.pageCount, currentIndex: index, goToPage: _goToPage), + ), ), ) else if (desktop) - ValueListenableBuilder( - valueListenable: _indexNotifier, - builder: (context, index, _) => - _LeafViewerDefaultControls(controls: LeafViewerControls(pageCount: widget.pageCount, currentIndex: index, goToPage: _goToPage)), + _chromeRevealed( + ValueListenableBuilder( + valueListenable: _indexNotifier, + builder: (context, index, _) => _LeafViewerDefaultControls( + controls: LeafViewerControls(pageCount: widget.pageCount, currentIndex: index, goToPage: _goToPage), + showPageCounter: widget.showPageCounter, + ), + ), ), ], ); @@ -342,9 +394,10 @@ class _LeafViewerState extends State with TickerProviderStateMixin i } class _LeafViewerDefaultControls extends StatelessWidget { - const _LeafViewerDefaultControls({required this.controls}); + const _LeafViewerDefaultControls({required this.controls, this.showPageCounter = true}); final LeafViewerControls controls; + final bool showPageCounter; @override Widget build(BuildContext context) { @@ -377,18 +430,19 @@ class _LeafViewerDefaultControls extends StatelessWidget { right: 8, child: _ControlButton(icon: Icons.close, onTap: () => Navigator.maybePop(context)), ), - 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()]), + 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()]), + ), ), ), - ), ], ); } diff --git a/lib/src/viewer/leaf_viewer_internal.dart b/lib/src/viewer/leaf_viewer_internal.dart index 42ec0d2..598b296 100644 --- a/lib/src/viewer/leaf_viewer_internal.dart +++ b/lib/src/viewer/leaf_viewer_internal.dart @@ -1,6 +1,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; +import 'leaf_chrome.dart'; import 'leaf_zoom_controller.dart'; /// Whether the current platform is a desktop OS. Desktop viewers swap swipe @@ -23,12 +24,20 @@ abstract class LeafViewerRegistry { bool get swipeGesturesEnabled; } -/// Internal: registry scope shared between [LeafViewer] and [LeafZoomable]. +/// Internal: registry scope shared between [LeafViewer], [LeafZoomable] and +/// the video pages. Carries the viewer's outward state: the active page index +/// and, when enabled, the chrome reveal controller. class LeafViewerScope extends InheritedWidget { - const LeafViewerScope({required this.registry, super.key, required super.child}); + const LeafViewerScope({required this.registry, required this.currentIndex, this.chrome, super.key, required super.child}); final LeafViewerRegistry registry; + /// The currently settled page index (reactive; also drives the controls). + final ValueListenable currentIndex; + + /// Reveal state for chrome layers; null unless the viewer enabled chrome. + final LeafViewerChrome? chrome; + static LeafViewerScope? maybeOf(BuildContext context) { return context.dependOnInheritedWidgetOfExactType(); } diff --git a/lib/src/viewer/leaf_zoomable.dart b/lib/src/viewer/leaf_zoomable.dart index 6b0ba2c..b9cfa4f 100644 --- a/lib/src/viewer/leaf_zoomable.dart +++ b/lib/src/viewer/leaf_zoomable.dart @@ -227,6 +227,17 @@ class _LeafZoomableState extends State with TickerProviderStateMix (instance) => instance.onDoubleTapDown = (details) => _controller.doubleTap(details.localPosition), ); } + // Chrome-enabled viewers: single tap toggles chrome. Registered on + // THIS detector so the tap/double-tap arena coordination holds — + // a cross-detector tap would fire on the first tap of every + // double-tap-zoom. + final chrome = LeafViewerScope.maybeOf(context)?.chrome; + if (chrome != null) { + gestures[TapGestureRecognizer] = GestureRecognizerFactoryWithHandlers( + () => TapGestureRecognizer(debugOwner: this), + (instance) => instance.onTap = chrome.toggle, + ); + } return Listener( onPointerSignal: _onPointerSignal, child: RawGestureDetector( diff --git a/test/viewer/leaf_chrome_test.dart b/test/viewer/leaf_chrome_test.dart new file mode 100644 index 0000000..9114270 --- /dev/null +++ b/test/viewer/leaf_chrome_test.dart @@ -0,0 +1,149 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:leaf_widgets/leaf_widgets.dart'; + +void main() { + group('LeafViewerChrome', () { + testWidgets('auto-hide fires after hideDelay', (tester) async { + final chrome = LeafViewerChrome(); + addTearDown(chrome.dispose); + expect(chrome.visible, isTrue); + await tester.pump(const Duration(seconds: 4)); + expect(chrome.visible, isFalse); + }); + + testWidgets('hold suppresses the timer until released', (tester) async { + final chrome = LeafViewerChrome(); + addTearDown(chrome.dispose); + chrome.hold = true; + await tester.pump(const Duration(seconds: 4)); + expect(chrome.visible, isTrue); + chrome.hold = false; + await tester.pump(const Duration(seconds: 4)); + expect(chrome.visible, isFalse); + }); + + testWidgets('hide cancels a pending timer', (tester) async { + final chrome = LeafViewerChrome(); + await tester.pump(const Duration(seconds: 2)); + chrome.hide(); + await tester.pump(const Duration(seconds: 4)); + expect(chrome.visible, isFalse); + chrome.show(); + expect(chrome.visible, isTrue); + chrome.dispose(); + }); + + testWidgets('autoHide: false never hides', (tester) async { + final chrome = LeafViewerChrome(autoHide: false); + addTearDown(chrome.dispose); + await tester.pump(const Duration(minutes: 1)); + expect(chrome.visible, isTrue); + }); + }); + + group('viewer chrome integration', () { + testWidgets('tap on a zoomable page toggles chrome', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: LeafViewer( + pageCount: 1, + enableChrome: true, + pageBuilder: (context, i) => Stack( + children: [ + LeafZoomable(childSize: const Size(400, 300), child: Container(color: const Color(0xFF112233))), + LeafChrome(builder: (context) => Text(chromeState(context))), + ], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('chrome: visible'), findsOneWidget); + + await tester.tap(find.byType(LeafZoomable)); + await tester.pump(const Duration(milliseconds: 350)); + expect(find.text('chrome: hidden'), findsOneWidget); + + await tester.tap(find.byType(LeafZoomable)); + await tester.pump(const Duration(milliseconds: 350)); + expect(find.text('chrome: visible'), findsOneWidget); + }); + + testWidgets('LeafChrome stops hit-testing while hidden', (tester) async { + var pressed = false; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: LeafViewer( + pageCount: 1, + enableChrome: true, + pageBuilder: (context, i) => Stack( + children: [ + LeafZoomable(childSize: const Size(400, 300), child: Container(color: const Color(0xFF112233))), + LeafChrome( + builder: (context) => Align( + alignment: Alignment.topRight, + child: IconButton(onPressed: () => pressed = true, icon: const Icon(Icons.ac_unit)), + ), + ), + ], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(LeafZoomable)); + await tester.pump(const Duration(milliseconds: 350)); + await tester.tap(find.byIcon(Icons.ac_unit), warnIfMissed: false); + expect(pressed, isFalse); + + await tester.tap(find.byType(LeafZoomable)); + await tester.pump(const Duration(milliseconds: 350)); + await tester.tap(find.byIcon(Icons.ac_unit)); + expect(pressed, isTrue); + }); + + testWidgets('default desktop controls join the reveal and honour showPageCounter', (tester) async { + Widget build({required bool showPageCounter}) => MaterialApp( + home: Scaffold( + body: LeafViewer( + pageCount: 3, + enableChrome: true, + showPageCounter: showPageCounter, + pageBuilder: (context, i) => + LeafZoomable(childSize: const Size(400, 300), child: Container(color: const Color(0xFF112233))), + ), + ), + ); + + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + await tester.pumpWidget(build(showPageCounter: true)); + await tester.pumpAndSettle(); + expect(find.text('1 / 3'), findsOneWidget); + + final chrome = LeafViewer.chromeOf(tester.element(find.byType(LeafZoomable)))!; + await tester.tap(find.byType(LeafZoomable)); + await tester.pump(const Duration(milliseconds: 350)); + await tester.pump(const Duration(milliseconds: 200)); + expect(chrome.visible, isFalse); + + await tester.pumpWidget(build(showPageCounter: false)); + await tester.pumpAndSettle(); + expect(find.text('1 / 3'), findsNothing); + expect(find.byIcon(Icons.chevron_left), findsOneWidget); + debugDefaultTargetPlatformOverride = null; + }); + }); +} + +String chromeState(BuildContext context) { + final chrome = LeafViewer.chromeOf(context)!; + return chrome.visible ? 'chrome: visible' : 'chrome: hidden'; +}