diff --git a/example/lib/main.dart b/example/lib/main.dart index fab6c3d..d2bcc93 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -49,10 +49,16 @@ class _CoverflowDemoScreenState extends State { double _carouselHeight = 360.0; bool _autoplay = false; double _autoplayIntervalSeconds = 3.0; + bool _autoplayDirectionForward = true; bool _enableShadow = true; double _shadowElevation = 8.0; double _cardCornerRadiusValue = 24.0; + // Page indicator settings + double _indicatorDotSize = 8.0; + double _indicatorDotSpacing = 12.0; + bool _indicatorUseThemeColor = true; + Axis _scrollDirection = Axis.horizontal; bool _useCustomWidth = false; double _carouselWidth = 340.0; @@ -301,9 +307,14 @@ class _CoverflowDemoScreenState extends State { const SizedBox(height: 24), // Dynamic Liquid Page Indicator - _CoverflowPageIndicator( + CoverflowPageIndicator( + controller: _controller, itemCount: _demoCards.length, - pageListenable: _controller.pageListenable, + activeColor: _indicatorUseThemeColor + ? Colors.pinkAccent + : Colors.white, + dotSize: _indicatorDotSize, + dotSpacing: _indicatorDotSpacing, onTap: (index) { _controller.animateTo(index); }, @@ -478,6 +489,36 @@ class _CoverflowDemoScreenState extends State { ), suffix: 'px', ), + const Divider( + height: 32, + color: Colors.white24, + ), + _GlassSlider( + title: 'Indicator Dot Size', + value: _indicatorDotSize, + min: 4.0, + max: 16.0, + onChanged: (val) => + setState(() => _indicatorDotSize = val), + suffix: 'px', + ), + _GlassSlider( + title: 'Indicator Dot Spacing', + value: _indicatorDotSpacing, + min: 4.0, + max: 24.0, + onChanged: (val) => setState( + () => _indicatorDotSpacing = val, + ), + suffix: 'px', + ), + _GlassSwitch( + title: 'Theme-colored Active Dot', + value: _indicatorUseThemeColor, + onChanged: (val) => setState( + () => _indicatorUseThemeColor = val, + ), + ), ] else if (_configTab == 1) ...[ // Tab 1: Motion settings _GlassSwitch( @@ -492,7 +533,7 @@ class _CoverflowDemoScreenState extends State { onChanged: (val) => setState(() => _autoplay = val), ), - if (_autoplay) + if (_autoplay) ...[ _GlassSlider( title: 'Autoplay Speed (Interval)', value: _autoplayIntervalSeconds, @@ -504,6 +545,17 @@ class _CoverflowDemoScreenState extends State { ), suffix: 's', ), + _GlassSwitch( + title: 'Autoplay Direction', + value: _autoplayDirectionForward, + onChanged: (val) { + setState( + () => _autoplayDirectionForward = val, + ); + _controller.setAutoplayDirection(val); + }, + ), + ], _GlassSwitch( title: 'Mouse Scroll Wheel Navigation', value: _enableScrollWheel, @@ -511,6 +563,29 @@ class _CoverflowDemoScreenState extends State { () => _enableScrollWheel = val, ), ), + const Divider( + height: 20, + color: Colors.white12, + ), + _ProgrammaticAutoplayControls( + controller: _controller, + ), + const Divider( + height: 20, + color: Colors.white12, + ), + _GlassDropdown( + title: 'Jump to Page', + value: _activePage, + items: List.generate( + _demoCards.length, + (i) => i, + ), + onChanged: (val) { + if (val != null) _controller.jumpTo(val); + }, + labelBuilder: (i) => 'Card #$i', + ), ] else ...[ // Tab 2: VFX settings _GlassDropdown( @@ -709,112 +784,105 @@ class _AmbientBackdrop extends StatelessWidget { } } -/// Liquid sliding active pill page indicator -class _CoverflowPageIndicator extends StatelessWidget { - final int itemCount; - final ValueNotifier pageListenable; - final void Function(int) onTap; +/// Row of buttons for programmatic autoplay control via the controller. +class _ProgrammaticAutoplayControls extends StatefulWidget { + final CoverflowCarouselController controller; - const _CoverflowPageIndicator({ - required this.itemCount, - required this.pageListenable, - required this.onTap, - }); + const _ProgrammaticAutoplayControls({required this.controller}); @override - Widget build(BuildContext context) { - const double dotSize = 8.0; - const double spacing = 12.0; - const double step = dotSize + spacing; - - return ValueListenableBuilder( - valueListenable: pageListenable, - builder: (context, page, _) { - final double t = page - page.floor(); - final int floor = page.floor(); - - final double activeLeft; - final double activeWidth; - - if (t < 0.5) { - activeLeft = (floor % itemCount) * step; - activeWidth = dotSize + (t / 0.5) * step; - } else { - activeLeft = (floor % itemCount) * step + ((t - 0.5) / 0.5) * step; - activeWidth = dotSize + (1.0 - (t - 0.5) / 0.5) * step; - } - - final int indexA = floor % itemCount; - final int indexB = (floor + 1) % itemCount; - - final double left; - final double width; + State<_ProgrammaticAutoplayControls> createState() => + _ProgrammaticAutoplayControlsState(); +} - if (indexB == 0 && t > 0.0) { - if (t < 0.5) { - left = indexA * step; - width = dotSize + (t / 0.5) * step; - } else { - left = 0; - width = dotSize + (1.0 - (t - 0.5) / 0.5) * step; - } - } else { - left = activeLeft; - width = activeWidth; - } +class _ProgrammaticAutoplayControlsState + extends State<_ProgrammaticAutoplayControls> { + bool _running = false; - return Container( - height: 24, - alignment: Alignment.center, - child: SizedBox( - width: itemCount * dotSize + (itemCount - 1) * spacing, - height: dotSize, - child: Stack( - clipBehavior: Clip.none, - children: [ - ...List.generate(itemCount, (i) { - return Positioned( - left: i * step, - top: 0, - width: dotSize, - height: dotSize, - child: GestureDetector( - onTap: () => onTap(i), - child: Container( - decoration: BoxDecoration( - shape: BoxShape.circle, - color: Colors.white.withValues(alpha: 0.15), - ), - ), + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Controller Autoplay Override', + style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: _running + ? null + : () { + widget.controller.startAutoplay(); + setState(() => _running = true); + }, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + color: _running + ? Colors.white.withValues(alpha: 0.05) + : Colors.greenAccent.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: _running + ? Colors.white12 + : Colors.greenAccent.withValues(alpha: 0.3), ), - ); - }), - Positioned( - left: left, - top: 0, - width: width, - height: dotSize, - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(dotSize / 2), - gradient: const LinearGradient( - colors: [Colors.pinkAccent, Colors.purpleAccent], - ), - boxShadow: [ - BoxShadow( - color: Colors.pinkAccent.withValues(alpha: 0.4), - blurRadius: 8, - spreadRadius: 1, - ), - ], + ), + alignment: Alignment.center, + child: Text( + 'Start', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: _running ? Colors.white38 : Colors.greenAccent, ), ), ), - ], + ), ), - ), - ); - }, + const SizedBox(width: 8), + Expanded( + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: !_running + ? null + : () { + widget.controller.stopAutoplay(); + setState(() => _running = false); + }, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + color: !_running + ? Colors.white.withValues(alpha: 0.05) + : Colors.redAccent.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: !_running + ? Colors.white12 + : Colors.redAccent.withValues(alpha: 0.3), + ), + ), + alignment: Alignment.center, + child: Text( + 'Stop', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: !_running ? Colors.white38 : Colors.redAccent, + ), + ), + ), + ), + ), + ], + ), + ], ); } } diff --git a/lib/coverflow_carousel.dart b/lib/coverflow_carousel.dart index 6a4dd61..c89bd1d 100644 --- a/lib/coverflow_carousel.dart +++ b/lib/coverflow_carousel.dart @@ -6,3 +6,4 @@ library; export 'src/coverflow_carousel.dart'; export 'src/coverflow_carousel_controller.dart'; +export 'src/coverflow_page_indicator.dart'; diff --git a/lib/src/coverflow_carousel.dart b/lib/src/coverflow_carousel.dart index 4f51f5a..9a138ec 100644 --- a/lib/src/coverflow_carousel.dart +++ b/lib/src/coverflow_carousel.dart @@ -283,10 +283,12 @@ class _CoverflowCarouselState extends State Timer? _autoplayTimer; bool _isHovering = false; bool _isUserDragging = false; + bool? _autoplayControllerOverride; bool _disposed = false; void _resumeAutoplay() { - if (!widget.autoplay) return; + final shouldAutoplay = _autoplayControllerOverride ?? widget.autoplay; + if (!shouldAutoplay) return; if (_isUserDragging) return; if (_isHovering && widget.autoplayPauseOnHover) return; if (widget.itemCount <= 1) return; @@ -307,26 +309,45 @@ class _CoverflowCarouselState extends State if (!_controller.hasClients) return; if (widget.itemCount <= 1) return; + final forward = widget.controller?.autoplayForward ?? true; final page = _controller.page ?? 0.0; - final targetPage = page.round() + 1; + final targetPage = page.round() + (forward ? 1 : -1); if (widget.isInfinite) { - _controller.nextPage( - duration: widget.animationDuration, - curve: widget.animationCurve, - ); - } else { - if (targetPage < widget.itemCount) { + if (forward) { _controller.nextPage( duration: widget.animationDuration, curve: widget.animationCurve, ); } else { + _controller.previousPage( + duration: widget.animationDuration, + curve: widget.animationCurve, + ); + } + } else { + if (forward && targetPage < widget.itemCount) { + _controller.nextPage( + duration: widget.animationDuration, + curve: widget.animationCurve, + ); + } else if (forward) { _controller.animateToPage( 0, duration: widget.animationDuration, curve: widget.animationCurve, ); + } else if (targetPage >= 0) { + _controller.previousPage( + duration: widget.animationDuration, + curve: widget.animationCurve, + ); + } else { + _controller.animateToPage( + widget.itemCount - 1, + duration: widget.animationDuration, + curve: widget.animationCurve, + ); } } } @@ -506,7 +527,7 @@ class _CoverflowCarouselState extends State if (oldWidget.autoplay != widget.autoplay || oldWidget.autoplayInterval != widget.autoplayInterval) { - if (widget.autoplay) { + if (_autoplayControllerOverride ?? widget.autoplay) { _resumeAutoplay(); } else { _pauseAutoplay(); @@ -533,18 +554,21 @@ class _CoverflowCarouselState extends State void _attachController() { widget.controller?.attach( next: () { + if (!_controller.hasClients) return; _controller.nextPage( duration: widget.animationDuration, curve: widget.animationCurve, ); }, previous: () { + if (!_controller.hasClients) return; _controller.previousPage( duration: widget.animationDuration, curve: widget.animationCurve, ); }, animateTo: (index) { + if (!_controller.hasClients) return; final targetPage = widget.isInfinite ? _getNearestVirtualPage(index, currentPage, widget.itemCount) : index; @@ -554,6 +578,21 @@ class _CoverflowCarouselState extends State curve: widget.animationCurve, ); }, + jumpTo: (index) { + if (!_controller.hasClients) return; + final targetPage = widget.isInfinite + ? _getNearestVirtualPage(index, currentPage, widget.itemCount) + : index; + _controller.jumpToPage(targetPage); + }, + startAutoplay: () { + _autoplayControllerOverride = true; + _resumeAutoplay(); + }, + stopAutoplay: () { + _autoplayControllerOverride = false; + _pauseAutoplay(); + }, ); _updateControllerMetrics(); } diff --git a/lib/src/coverflow_carousel_controller.dart b/lib/src/coverflow_carousel_controller.dart index 4d6af97..cd43e45 100644 --- a/lib/src/coverflow_carousel_controller.dart +++ b/lib/src/coverflow_carousel_controller.dart @@ -7,11 +7,17 @@ import 'dart:async'; /// to trigger scroll animations programmatically (e.g., jumping/animating to a page, /// transitioning to the next or previous card) and listen to real-time scroll progress. /// +/// Also exposes autoplay controls — [startAutoplay], [stopAutoplay], and +/// [setAutoplayDirection] — plus instant [jumpTo] navigation without animation. +/// /// Remember to call [dispose] when discarding the controller to clean up stream subscriptions. class CoverflowCarouselController { void Function()? _next; void Function()? _previous; void Function(int)? _animateTo; + void Function(int)? _jumpTo; + void Function()? _startAutoplay; + void Function()? _stopAutoplay; final ValueNotifier _pageNotifier = ValueNotifier(0.0); final ValueNotifier _rawPageNotifier = ValueNotifier(0.0); @@ -21,6 +27,8 @@ class CoverflowCarouselController { final StreamController _rawPageStreamController = StreamController.broadcast(); + bool _autoplayForward = true; + /// A [ValueNotifier] that emits the current normalized fractional page index. /// /// If the carousel is infinite, this value is normalized to the range `[0, itemCount)` @@ -42,6 +50,11 @@ class CoverflowCarouselController { /// A broadcast stream emitting the current raw fractional page index on every scroll update. Stream get rawPageStream => _rawPageStreamController.stream; + /// Whether autoplay is currently moving forward (`true`) or backward (`false`). + /// + /// Defaults to `true`. Use [setAutoplayDirection] to change at runtime. + bool get autoplayForward => _autoplayForward; + /// Programmatically transitions the carousel to the next card. /// /// Uses the default animation duration and curve specified on the carousel. @@ -58,6 +71,29 @@ class CoverflowCarouselController { /// (shortest-path animation) to transition to the target [index]. void animateTo(int index) => _animateTo?.call(index); + /// Instantly jumps to the card at [index] with no slide animation. + /// + /// On infinite carousels this picks the nearest virtual page, so the + /// carousel wraps around the shortest way. + void jumpTo(int index) => _jumpTo?.call(index); + + /// Forces autoplay to start, even if the carousel widget was created + /// with `autoplay: false`. + void startAutoplay() => _startAutoplay?.call(); + + /// Stops autoplay entirely. The carousel will not auto-advance until + /// [startAutoplay] is called again or the widget's `autoplay` property + /// is `true`. + void stopAutoplay() => _stopAutoplay?.call(); + + /// Sets the autoplay scroll direction. + /// + /// Pass `true` for forward (next card), `false` for backward (previous card). + /// The change takes effect on the next autoplay tick. + void setAutoplayDirection(bool forward) { + _autoplayForward = forward; + } + /// Attaches the controller to a [CoverflowCarousel] state. /// /// Called internally by the carousel state; do not call this method directly. @@ -65,10 +101,16 @@ class CoverflowCarouselController { required VoidCallback next, required VoidCallback previous, required ValueChanged animateTo, + void Function(int)? jumpTo, + VoidCallback? startAutoplay, + VoidCallback? stopAutoplay, }) { _next = next; _previous = previous; _animateTo = animateTo; + _jumpTo = jumpTo; + _startAutoplay = startAutoplay; + _stopAutoplay = stopAutoplay; } /// Detaches the controller from the carousel. @@ -79,6 +121,9 @@ class CoverflowCarouselController { _next = null; _previous = null; _animateTo = null; + _jumpTo = null; + _startAutoplay = null; + _stopAutoplay = null; } /// Updates the internal metrics of the controller. diff --git a/lib/src/coverflow_page_indicator.dart b/lib/src/coverflow_page_indicator.dart new file mode 100644 index 0000000..8834be0 --- /dev/null +++ b/lib/src/coverflow_page_indicator.dart @@ -0,0 +1,137 @@ +import 'package:flutter/material.dart'; +import 'coverflow_carousel_controller.dart'; + +const double _tapTargetSize = 40.0; + +/// Shows the current page as a sliding pill over dots. +/// +/// Listens to [controller]'s [ValueNotifier] and animates the active +/// indicator between pages as the carousel scrolls. +class CoverflowPageIndicator extends StatelessWidget { + const CoverflowPageIndicator({ + super.key, + required this.controller, + required this.itemCount, + this.activeColor = Colors.white, + this.inactiveColor = Colors.white38, + this.dotSize = 8.0, + this.dotSpacing = 12.0, + this.onTap, + }) : assert(dotSize > 0, 'dotSize must be greater than zero'), + assert(dotSpacing >= 0, 'dotSpacing must be non-negative'); + + final CoverflowCarouselController controller; + final int itemCount; + final Color activeColor; + final Color inactiveColor; + final double dotSize; + final double dotSpacing; + final void Function(int index)? onTap; + + @override + Widget build(BuildContext context) { + final step = dotSize + dotSpacing; + + return ValueListenableBuilder( + valueListenable: controller.pageListenable, + builder: (context, page, _) { + if (itemCount <= 0) return const SizedBox.shrink(); + + final count = itemCount; + final normalized = count > 1 ? page % count : 0.0; + final floor = normalized.floor(); + final t = normalized - floor; + final indexB = (floor + 1) % count; + final isWrapping = count > 1 && indexB == 0 && floor == count - 1; + + final rowWidth = count * dotSize + (count - 1) * dotSpacing; + // Symmetric padding so the tap surface is dotSize + 2*pad == + // _tapTargetSize in both directions, matching the requested 40px. + final pad = (_tapTargetSize - dotSize) / 2; + final totalWidth = rowWidth + pad * 2; + const totalHeight = _tapTargetSize; + + return SizedBox( + width: totalWidth, + height: totalHeight, + child: Stack( + clipBehavior: Clip.none, + children: [ + // Single tap surface for the whole strip. Resolves taps by + // nearest-dot-center instead of per-dot overlapping regions, + // so there's no z-order ambiguity between neighbors. + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTapUp: onTap == null + ? null + : (details) { + final dx = + details.localPosition.dx - pad - dotSize / 2; + final index = (dx / step).round().clamp(0, count - 1); + onTap!(index); + }, + ), + ), + for (final i in List.generate(count, (i) => i)) + Positioned( + left: pad + i * step, + top: (totalHeight - dotSize) / 2, + width: dotSize, + height: dotSize, + child: IgnorePointer( + child: Semantics( + button: true, + label: 'Page ${i + 1} of $count', + onTap: onTap == null ? null : () => onTap!(i), + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: inactiveColor, + ), + ), + ), + ), + ), + if (isWrapping) ...[ + _buildActivePill(left: pad, width: dotSize * t), + _buildActivePill( + left: pad + floor * step, + width: dotSize * (1.0 - t), + ), + ] else + _buildActivePill( + left: + pad + + floor * step + + (t < 0.5 ? 0 : ((t - 0.5) / 0.5) * step), + width: + dotSize + + (t < 0.5 + ? (t / 0.5) * step + : (1.0 - (t - 0.5) / 0.5) * step), + ), + ], + ), + ); + }, + ); + } + + Widget _buildActivePill({required double left, required double width}) { + return Positioned( + left: left, + top: (_tapTargetSize - dotSize) / 2, + width: width, + height: dotSize, + child: IgnorePointer( + child: Container( + decoration: BoxDecoration( + color: activeColor, + borderRadius: BorderRadius.circular(dotSize / 2), + ), + ), + ), + ); + } +} diff --git a/test/coverflow_carousel_test.dart b/test/coverflow_carousel_test.dart index 3bbb114..0f65798 100644 --- a/test/coverflow_carousel_test.dart +++ b/test/coverflow_carousel_test.dart @@ -1106,4 +1106,173 @@ void main() { await sub.cancel(); controller.dispose(); }); + + group('CoverflowCarouselController jumpTo', () { + testWidgets('jumpTo changes page instantly without animation', ( + WidgetTester tester, + ) async { + final controller = CoverflowCarouselController(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CoverflowCarousel.builder( + controller: controller, + itemCount: 5, + itemWidth: 200, + itemHeight: 300, + scrollDirection: Axis.horizontal, + initialPage: 0, + itemBuilder: (context, index) { + return Text('Item $index'); + }, + ), + ), + ), + ); + + expect(controller.page, 0.0); + + controller.jumpTo(2); + await tester.pump(); + + expect(controller.page, 2.0); + }); + + testWidgets('jumpTo works on infinite carousel', ( + WidgetTester tester, + ) async { + final controller = CoverflowCarouselController(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CoverflowCarousel.builder( + controller: controller, + itemCount: 5, + itemWidth: 200, + itemHeight: 300, + scrollDirection: Axis.horizontal, + isInfinite: true, + initialPage: 0, + itemBuilder: (context, index) { + return Text('Item $index'); + }, + ), + ), + ), + ); + + controller.jumpTo(4); + await tester.pump(); + + expect(controller.page, 4.0); + }); + + test('jumpTo does nothing before attach', () { + final controller = CoverflowCarouselController(); + expect(() => controller.jumpTo(2), returnsNormally); + }); + }); + + group('CoverflowCarouselController autoplay controls', () { + testWidgets('startAutoplay and stopAutoplay work', ( + WidgetTester tester, + ) async { + final controller = CoverflowCarouselController(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CoverflowCarousel.builder( + controller: controller, + itemCount: 5, + itemWidth: 200, + itemHeight: 300, + scrollDirection: Axis.horizontal, + autoplay: false, + onPageChanged: (_) {}, + itemBuilder: (context, index) { + return Text('Item $index'); + }, + ), + ), + ), + ); + + expect(controller.page, 0.0); + + controller.startAutoplay(); + await tester.pump(const Duration(seconds: 4)); + await tester.pump(const Duration(milliseconds: 500)); + + expect(controller.page, 1.0); + + controller.stopAutoplay(); + final pageAfterStop = controller.page; + + await tester.pump(const Duration(seconds: 4)); + await tester.pump(const Duration(milliseconds: 500)); + + expect(controller.page, pageAfterStop); + }); + + test('methods do nothing before attach', () { + final controller = CoverflowCarouselController(); + expect(() => controller.startAutoplay(), returnsNormally); + expect(() => controller.stopAutoplay(), returnsNormally); + }); + }); + + group('CoverflowCarouselController autoplay direction', () { + testWidgets('setAutoplayDirection changes tick direction', ( + WidgetTester tester, + ) async { + final controller = CoverflowCarouselController(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CoverflowCarousel.builder( + controller: controller, + itemCount: 5, + itemWidth: 200, + itemHeight: 300, + scrollDirection: Axis.horizontal, + initialPage: 2, + autoplay: true, + onPageChanged: (_) {}, + itemBuilder: (context, index) { + return Text('Item $index'); + }, + ), + ), + ), + ); + + expect(controller.page, 2.0); + + await tester.pump(const Duration(seconds: 4)); + await tester.pump(const Duration(milliseconds: 500)); + expect(controller.page, 3.0); + + controller.setAutoplayDirection(false); + await tester.pump(const Duration(seconds: 4)); + await tester.pump(const Duration(milliseconds: 500)); + expect(controller.page, 2.0); + }); + + test('default direction is forward', () { + final controller = CoverflowCarouselController(); + expect(controller.autoplayForward, isTrue); + }); + + test('setAutoplayDirection updates autoplayForward', () { + final controller = CoverflowCarouselController(); + controller.setAutoplayDirection(false); + expect(controller.autoplayForward, isFalse); + controller.setAutoplayDirection(true); + expect(controller.autoplayForward, isTrue); + }); + }); } diff --git a/test/coverflow_page_indicator_test.dart b/test/coverflow_page_indicator_test.dart new file mode 100644 index 0000000..66d307a --- /dev/null +++ b/test/coverflow_page_indicator_test.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:coverflow_carousel/coverflow_carousel.dart'; + +void main() { + group('CoverflowPageIndicator', () { + testWidgets('renders correct number of inactive dots', (tester) async { + final controller = CoverflowCarouselController(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CoverflowPageIndicator(controller: controller, itemCount: 5), + ), + ), + ); + + expect( + find.byWidgetPredicate( + (w) => w is Container && w.decoration is BoxDecoration, + ), + findsNWidgets(6), + ); + }); + + testWidgets('updates on controller page change', (tester) async { + final controller = CoverflowCarouselController(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CoverflowPageIndicator(controller: controller, itemCount: 5), + ), + ), + ); + + controller.updateMetrics(rawPage: 2.0, normalizedPage: 2.0); + await tester.pump(); + + expect(find.byType(CoverflowPageIndicator), findsOneWidget); + }); + + testWidgets('calls onTap with correct index', (tester) async { + final controller = CoverflowCarouselController(); + int? tappedIndex; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: CoverflowPageIndicator( + controller: controller, + itemCount: 3, + onTap: (index) => tappedIndex = index, + ), + ), + ), + ), + ); + + // Single GestureDetector covers the whole strip; tap near the last dot. + final indicator = find.byType(CoverflowPageIndicator); + await tester.tapAt(tester.getCenter(indicator) + const Offset(15, 0)); + expect(tappedIndex, 2); + }); + + testWidgets('applies custom colors and sizes', (tester) async { + final controller = CoverflowCarouselController(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CoverflowPageIndicator( + controller: controller, + itemCount: 3, + activeColor: Colors.red, + inactiveColor: Colors.grey, + dotSize: 12.0, + dotSpacing: 20.0, + ), + ), + ), + ); + + expect(find.byType(CoverflowPageIndicator), findsOneWidget); + expect( + find.byWidgetPredicate( + (w) => w is Container && w.decoration is BoxDecoration, + ), + findsNWidgets(4), + ); + }); + + testWidgets('returns empty box for zero items', (tester) async { + final controller = CoverflowCarouselController(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CoverflowPageIndicator(controller: controller, itemCount: 0), + ), + ), + ); + + expect(find.byType(SizedBox), findsOneWidget); + expect(find.byType(GestureDetector), findsNothing); + }); + + testWidgets('handles single item gracefully', (tester) async { + final controller = CoverflowCarouselController(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CoverflowPageIndicator(controller: controller, itemCount: 1), + ), + ), + ); + + expect(find.byType(GestureDetector), findsOneWidget); + }); + }); +}