From 9a228cb3cef3cdc6d411339dcdd4e8f9307205eb Mon Sep 17 00:00:00 2001 From: Hirdaya Shrestha Date: Wed, 15 Jul 2026 15:38:05 +0545 Subject: [PATCH 01/10] feat: added customizable page indicator widget --- example/lib/main.dart | 154 +++++++----------------- example/pubspec.lock | 2 +- lib/coverflow_carousel.dart | 1 + lib/src/coverflow_page_indicator.dart | 90 ++++++++++++++ test/coverflow_page_indicator_test.dart | 113 +++++++++++++++++ 5 files changed, 247 insertions(+), 113 deletions(-) create mode 100644 lib/src/coverflow_page_indicator.dart create mode 100644 test/coverflow_page_indicator_test.dart diff --git a/example/lib/main.dart b/example/lib/main.dart index fb80c13..c0b7800 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -53,6 +53,11 @@ class _CoverflowDemoScreenState extends State { 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 +306,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 +488,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( @@ -709,116 +749,6 @@ 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; - - const _CoverflowPageIndicator({ - required this.itemCount, - required this.pageListenable, - required this.onTap, - }); - - @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; - - 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; - } - - 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), - ), - ), - ), - ); - }), - 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, - ), - ], - ), - ), - ), - ], - ), - ), - ); - }, - ); - } -} - /// Premium Play Button with dynamic scale on press class _PremiumPlayButton extends StatefulWidget { final String title; diff --git a/example/pubspec.lock b/example/pubspec.lock index cdbe35d..d44d941 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -47,7 +47,7 @@ packages: path: ".." relative: true source: path - version: "2.0.0" + version: "2.0.1" fake_async: dependency: transitive description: 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_page_indicator.dart b/lib/src/coverflow_page_indicator.dart new file mode 100644 index 0000000..5e66957 --- /dev/null +++ b/lib/src/coverflow_page_indicator.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'coverflow_carousel_controller.dart'; + +/// 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, + }); + + 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 clamped = page % count; + final floor = clamped.floor(); + final t = clamped - floor; + + final activeLeft = + floor * step + (t < 0.5 ? 0 : ((t - 0.5) / 0.5) * step); + final activeWidth = + dotSize + + (t < 0.5 ? (t / 0.5) * step : (1.0 - (t - 0.5) / 0.5) * step); + + return SizedBox( + width: count * dotSize + (count - 1) * dotSpacing, + height: dotSize + 8, + child: Stack( + clipBehavior: Clip.none, + children: [ + ...List.generate(count, (i) { + return Positioned( + left: i * step, + top: 4, + width: dotSize, + height: dotSize, + child: GestureDetector( + onTap: onTap != null ? () => onTap!(i) : null, + behavior: HitTestBehavior.opaque, + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: inactiveColor, + ), + ), + ), + ); + }), + Positioned( + left: activeLeft, + top: 4, + width: activeWidth, + height: dotSize, + child: Container( + decoration: BoxDecoration( + color: activeColor, + borderRadius: BorderRadius.circular(dotSize / 2), + ), + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/test/coverflow_page_indicator_test.dart b/test/coverflow_page_indicator_test.dart new file mode 100644 index 0000000..c1b5406 --- /dev/null +++ b/test/coverflow_page_indicator_test.dart @@ -0,0 +1,113 @@ +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 dots', (tester) async { + final controller = CoverflowCarouselController(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CoverflowPageIndicator(controller: controller, itemCount: 5), + ), + ), + ); + + expect(find.byType(GestureDetector), findsNWidgets(5)); + }); + + 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); + expect(find.byType(GestureDetector), findsNWidgets(5)); + }); + + 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, + ), + ), + ), + ), + ); + + final detectors = find.byType(GestureDetector); + await tester.tap(detectors.last); + 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.byType(GestureDetector), findsNWidgets(3)); + }); + + 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); + }); + }); +} From d418ca79a0b495fb2d581cac60bee8f2155b0489 Mon Sep 17 00:00:00 2001 From: Hirdaya Shrestha Date: Wed, 15 Jul 2026 21:28:55 +0545 Subject: [PATCH 02/10] feat: added controller methods extension --- example/lib/main.dart | 140 ++++++++++++++++- lib/src/coverflow_carousel.dart | 52 +++++-- lib/src/coverflow_carousel_controller.dart | 45 ++++++ test/coverflow_carousel_test.dart | 169 +++++++++++++++++++++ 4 files changed, 396 insertions(+), 10 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index c0b7800..8a374c0 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -49,6 +49,7 @@ 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; @@ -532,7 +533,7 @@ class _CoverflowDemoScreenState extends State { onChanged: (val) => setState(() => _autoplay = val), ), - if (_autoplay) + if (_autoplay) ...[ _GlassSlider( title: 'Autoplay Speed (Interval)', value: _autoplayIntervalSeconds, @@ -544,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, @@ -551,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( @@ -749,6 +784,109 @@ class _AmbientBackdrop extends StatelessWidget { } } +/// Row of buttons for programmatic autoplay control via the controller. +class _ProgrammaticAutoplayControls extends StatefulWidget { + final CoverflowCarouselController controller; + + const _ProgrammaticAutoplayControls({required this.controller}); + + @override + State<_ProgrammaticAutoplayControls> createState() => + _ProgrammaticAutoplayControlsState(); +} + +class _ProgrammaticAutoplayControlsState + extends State<_ProgrammaticAutoplayControls> { + bool _running = false; + + @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), + ), + ), + 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, + ), + ), + ), + ), + ), + ], + ), + ], + ); + } +} + /// Premium Play Button with dynamic scale on press class _PremiumPlayButton extends StatefulWidget { final String title; diff --git a/lib/src/coverflow_carousel.dart b/lib/src/coverflow_carousel.dart index 4f51f5a..1feb945 100644 --- a/lib/src/coverflow_carousel.dart +++ b/lib/src/coverflow_carousel.dart @@ -283,10 +283,11 @@ class _CoverflowCarouselState extends State Timer? _autoplayTimer; bool _isHovering = false; bool _isUserDragging = false; + bool _autoplayControllerOverride = false; bool _disposed = false; void _resumeAutoplay() { - if (!widget.autoplay) return; + if (!widget.autoplay && !_autoplayControllerOverride) return; if (_isUserDragging) return; if (_isHovering && widget.autoplayPauseOnHover) return; if (widget.itemCount <= 1) return; @@ -307,26 +308,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 +526,7 @@ class _CoverflowCarouselState extends State if (oldWidget.autoplay != widget.autoplay || oldWidget.autoplayInterval != widget.autoplayInterval) { - if (widget.autoplay) { + if (widget.autoplay || _autoplayControllerOverride) { _resumeAutoplay(); } else { _pauseAutoplay(); @@ -554,6 +574,20 @@ class _CoverflowCarouselState extends State curve: widget.animationCurve, ); }, + jumpTo: (index) { + 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/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); + }); + }); } From d642bec20fe0ca5498c251f459f9118120f6bdee Mon Sep 17 00:00:00 2001 From: Hirdaya Shrestha Date: Sat, 18 Jul 2026 13:58:43 +0545 Subject: [PATCH 03/10] fix: autoplay override state and page indicator modulo --- lib/src/coverflow_carousel.dart | 7 ++++--- lib/src/coverflow_page_indicator.dart | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/src/coverflow_carousel.dart b/lib/src/coverflow_carousel.dart index 1feb945..2ad632e 100644 --- a/lib/src/coverflow_carousel.dart +++ b/lib/src/coverflow_carousel.dart @@ -283,11 +283,12 @@ class _CoverflowCarouselState extends State Timer? _autoplayTimer; bool _isHovering = false; bool _isUserDragging = false; - bool _autoplayControllerOverride = false; + bool? _autoplayControllerOverride; bool _disposed = false; void _resumeAutoplay() { - if (!widget.autoplay && !_autoplayControllerOverride) return; + final shouldAutoplay = _autoplayControllerOverride ?? widget.autoplay; + if (!shouldAutoplay) return; if (_isUserDragging) return; if (_isHovering && widget.autoplayPauseOnHover) return; if (widget.itemCount <= 1) return; @@ -526,7 +527,7 @@ class _CoverflowCarouselState extends State if (oldWidget.autoplay != widget.autoplay || oldWidget.autoplayInterval != widget.autoplayInterval) { - if (widget.autoplay || _autoplayControllerOverride) { + if (_autoplayControllerOverride ?? widget.autoplay) { _resumeAutoplay(); } else { _pauseAutoplay(); diff --git a/lib/src/coverflow_page_indicator.dart b/lib/src/coverflow_page_indicator.dart index 5e66957..b2db890 100644 --- a/lib/src/coverflow_page_indicator.dart +++ b/lib/src/coverflow_page_indicator.dart @@ -35,7 +35,7 @@ class CoverflowPageIndicator extends StatelessWidget { if (itemCount <= 0) return const SizedBox.shrink(); final count = itemCount; - final clamped = page % count; + final clamped = ((page % count) + count) % count; final floor = clamped.floor(); final t = clamped - floor; From f428e378020319b6d5b30dc9ab2c45a86987a27a Mon Sep 17 00:00:00 2001 From: Hirdaya Shrestha Date: Sat, 18 Jul 2026 14:06:15 +0545 Subject: [PATCH 04/10] fix: page indicator boundary wrap, touch targets, ignore pointer --- lib/src/coverflow_page_indicator.dart | 85 ++++++++++++++++++--------- 1 file changed, 56 insertions(+), 29 deletions(-) diff --git a/lib/src/coverflow_page_indicator.dart b/lib/src/coverflow_page_indicator.dart index b2db890..b607a0d 100644 --- a/lib/src/coverflow_page_indicator.dart +++ b/lib/src/coverflow_page_indicator.dart @@ -1,6 +1,8 @@ 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 @@ -38,12 +40,8 @@ class CoverflowPageIndicator extends StatelessWidget { final clamped = ((page % count) + count) % count; final floor = clamped.floor(); final t = clamped - floor; - - final activeLeft = - floor * step + (t < 0.5 ? 0 : ((t - 0.5) / 0.5) * step); - final activeWidth = - dotSize + - (t < 0.5 ? (t / 0.5) * step : (1.0 - (t - 0.5) / 0.5) * step); + final indexB = (floor + 1) % count; + final isWrapping = count > 1 && indexB == 0 && floor == count - 1; return SizedBox( width: count * dotSize + (count - 1) * dotSpacing, @@ -51,40 +49,69 @@ class CoverflowPageIndicator extends StatelessWidget { child: Stack( clipBehavior: Clip.none, children: [ - ...List.generate(count, (i) { - return Positioned( - left: i * step, - top: 4, - width: dotSize, - height: dotSize, + for (final i in List.generate(count, (i) => i)) + Positioned( + left: i * step - (_tapTargetSize - dotSize) / 2, + top: 4 - (_tapTargetSize - dotSize) / 2, + width: _tapTargetSize, + height: _tapTargetSize, child: GestureDetector( onTap: onTap != null ? () => onTap!(i) : null, behavior: HitTestBehavior.opaque, - child: Container( - decoration: BoxDecoration( - shape: BoxShape.circle, - color: inactiveColor, + child: Center( + child: Container( + width: dotSize, + height: dotSize, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: inactiveColor, + ), ), ), ), - ); - }), - Positioned( - left: activeLeft, - top: 4, - width: activeWidth, - height: dotSize, - child: Container( - decoration: BoxDecoration( - color: activeColor, - borderRadius: BorderRadius.circular(dotSize / 2), - ), ), - ), + if (isWrapping && t > 0.5) ...[ + _buildActivePill( + left: 0, + width: dotSize * ((t - 0.5) / 0.5), + ), + _buildActivePill( + left: floor * step, + width: dotSize * (1.0 - (t - 0.5) / 0.5), + ), + ] else + _buildActivePill( + left: 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: 4, + width: width, + height: dotSize, + child: IgnorePointer( + child: Container( + decoration: BoxDecoration( + color: activeColor, + borderRadius: BorderRadius.circular(dotSize / 2), + ), + ), + ), + ); + } } From c5fbae92f1e03b61a0113e4a2d1ed557e0028e40 Mon Sep 17 00:00:00 2001 From: Hirdaya Shrestha Date: Sat, 18 Jul 2026 14:56:48 +0545 Subject: [PATCH 05/10] style: dart format --- lib/src/coverflow_page_indicator.dart | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/lib/src/coverflow_page_indicator.dart b/lib/src/coverflow_page_indicator.dart index b607a0d..f1514b0 100644 --- a/lib/src/coverflow_page_indicator.dart +++ b/lib/src/coverflow_page_indicator.dart @@ -71,19 +71,16 @@ class CoverflowPageIndicator extends StatelessWidget { ), ), if (isWrapping && t > 0.5) ...[ - _buildActivePill( - left: 0, - width: dotSize * ((t - 0.5) / 0.5), - ), + _buildActivePill(left: 0, width: dotSize * ((t - 0.5) / 0.5)), _buildActivePill( left: floor * step, width: dotSize * (1.0 - (t - 0.5) / 0.5), ), ] else _buildActivePill( - left: floor * step + - (t < 0.5 ? 0 : ((t - 0.5) / 0.5) * step), - width: dotSize + + left: 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), @@ -95,10 +92,7 @@ class CoverflowPageIndicator extends StatelessWidget { ); } - Widget _buildActivePill({ - required double left, - required double width, - }) { + Widget _buildActivePill({required double left, required double width}) { return Positioned( left: left, top: 4, From 32244493c2af924a9caec5374cac3429e1fb01ab Mon Sep 17 00:00:00 2001 From: Hirdaya Shrestha Date: Sat, 18 Jul 2026 17:08:00 +0545 Subject: [PATCH 06/10] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- lib/src/coverflow_page_indicator.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/src/coverflow_page_indicator.dart b/lib/src/coverflow_page_indicator.dart index f1514b0..9a387e4 100644 --- a/lib/src/coverflow_page_indicator.dart +++ b/lib/src/coverflow_page_indicator.dart @@ -37,7 +37,8 @@ class CoverflowPageIndicator extends StatelessWidget { if (itemCount <= 0) return const SizedBox.shrink(); final count = itemCount; - final clamped = ((page % count) + count) % count; + final clamped = + page.clamp(0.0, (count - 1).toDouble()).toDouble(); final floor = clamped.floor(); final t = clamped - floor; final indexB = (floor + 1) % count; From 8c07ece861a8e5582e18e5a99eebaaf26a1b58b7 Mon Sep 17 00:00:00 2001 From: Hirdaya Shrestha Date: Sat, 18 Jul 2026 21:51:58 +0545 Subject: [PATCH 07/10] style: dart format --- lib/src/coverflow_page_indicator.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/src/coverflow_page_indicator.dart b/lib/src/coverflow_page_indicator.dart index 9a387e4..73acd0b 100644 --- a/lib/src/coverflow_page_indicator.dart +++ b/lib/src/coverflow_page_indicator.dart @@ -37,8 +37,7 @@ class CoverflowPageIndicator extends StatelessWidget { if (itemCount <= 0) return const SizedBox.shrink(); final count = itemCount; - final clamped = - page.clamp(0.0, (count - 1).toDouble()).toDouble(); + final clamped = page.clamp(0.0, (count - 1).toDouble()).toDouble(); final floor = clamped.floor(); final t = clamped - floor; final indexB = (floor + 1) % count; From 82d17742d36962348f90339dc72524762f50ed05 Mon Sep 17 00:00:00 2001 From: Hirdaya Shrestha Date: Sun, 19 Jul 2026 04:35:15 +0545 Subject: [PATCH 08/10] fix: restore proper modulo for wrapping animation and add edge offset --- lib/src/coverflow_page_indicator.dart | 67 ++++++++++++++++++--------- 1 file changed, 46 insertions(+), 21 deletions(-) diff --git a/lib/src/coverflow_page_indicator.dart b/lib/src/coverflow_page_indicator.dart index 73acd0b..7cd004f 100644 --- a/lib/src/coverflow_page_indicator.dart +++ b/lib/src/coverflow_page_indicator.dart @@ -37,31 +37,53 @@ class CoverflowPageIndicator extends StatelessWidget { if (itemCount <= 0) return const SizedBox.shrink(); final count = itemCount; - final clamped = page.clamp(0.0, (count - 1).toDouble()).toDouble(); - final floor = clamped.floor(); - final t = clamped - floor; + 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: count * dotSize + (count - 1) * dotSpacing, - height: dotSize + 8, + 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: i * step - (_tapTargetSize - dotSize) / 2, - top: 4 - (_tapTargetSize - dotSize) / 2, - width: _tapTargetSize, - height: _tapTargetSize, - child: GestureDetector( - onTap: onTap != null ? () => onTap!(i) : null, - behavior: HitTestBehavior.opaque, - child: Center( + 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( - width: dotSize, - height: dotSize, decoration: BoxDecoration( shape: BoxShape.circle, color: inactiveColor, @@ -70,15 +92,18 @@ class CoverflowPageIndicator extends StatelessWidget { ), ), ), - if (isWrapping && t > 0.5) ...[ - _buildActivePill(left: 0, width: dotSize * ((t - 0.5) / 0.5)), + if (isWrapping) ...[ + _buildActivePill(left: pad, width: dotSize * t), _buildActivePill( - left: floor * step, - width: dotSize * (1.0 - (t - 0.5) / 0.5), + left: pad + floor * step, + width: dotSize * (1.0 - t), ), ] else _buildActivePill( - left: floor * step + (t < 0.5 ? 0 : ((t - 0.5) / 0.5) * step), + left: + pad + + floor * step + + (t < 0.5 ? 0 : ((t - 0.5) / 0.5) * step), width: dotSize + (t < 0.5 @@ -95,7 +120,7 @@ class CoverflowPageIndicator extends StatelessWidget { Widget _buildActivePill({required double left, required double width}) { return Positioned( left: left, - top: 4, + top: (_tapTargetSize - dotSize) / 2, width: width, height: dotSize, child: IgnorePointer( From 255f612a59bf0c7b3ccb9fbbbf8dce27fc9cef9b Mon Sep 17 00:00:00 2001 From: Hirdaya Shrestha Date: Sun, 19 Jul 2026 04:41:18 +0545 Subject: [PATCH 09/10] fix: update tests for single GestureDetector indicator --- test/coverflow_page_indicator_test.dart | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/test/coverflow_page_indicator_test.dart b/test/coverflow_page_indicator_test.dart index c1b5406..66d307a 100644 --- a/test/coverflow_page_indicator_test.dart +++ b/test/coverflow_page_indicator_test.dart @@ -4,7 +4,7 @@ import 'package:coverflow_carousel/coverflow_carousel.dart'; void main() { group('CoverflowPageIndicator', () { - testWidgets('renders correct number of dots', (tester) async { + testWidgets('renders correct number of inactive dots', (tester) async { final controller = CoverflowCarouselController(); await tester.pumpWidget( @@ -15,7 +15,12 @@ void main() { ), ); - expect(find.byType(GestureDetector), findsNWidgets(5)); + expect( + find.byWidgetPredicate( + (w) => w is Container && w.decoration is BoxDecoration, + ), + findsNWidgets(6), + ); }); testWidgets('updates on controller page change', (tester) async { @@ -33,7 +38,6 @@ void main() { await tester.pump(); expect(find.byType(CoverflowPageIndicator), findsOneWidget); - expect(find.byType(GestureDetector), findsNWidgets(5)); }); testWidgets('calls onTap with correct index', (tester) async { @@ -54,8 +58,9 @@ void main() { ), ); - final detectors = find.byType(GestureDetector); - await tester.tap(detectors.last); + // 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); }); @@ -78,7 +83,12 @@ void main() { ); expect(find.byType(CoverflowPageIndicator), findsOneWidget); - expect(find.byType(GestureDetector), findsNWidgets(3)); + expect( + find.byWidgetPredicate( + (w) => w is Container && w.decoration is BoxDecoration, + ), + findsNWidgets(4), + ); }); testWidgets('returns empty box for zero items', (tester) async { From 20769933f9c6cf920210484448d09eef0b069536 Mon Sep 17 00:00:00 2001 From: Hirdaya Shrestha Date: Sun, 19 Jul 2026 18:54:15 +0545 Subject: [PATCH 10/10] fix: add hasClients guards and indicator assertions --- lib/src/coverflow_carousel.dart | 4 ++++ lib/src/coverflow_page_indicator.dart | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/src/coverflow_carousel.dart b/lib/src/coverflow_carousel.dart index 2ad632e..9a138ec 100644 --- a/lib/src/coverflow_carousel.dart +++ b/lib/src/coverflow_carousel.dart @@ -554,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; @@ -576,6 +579,7 @@ class _CoverflowCarouselState extends State ); }, jumpTo: (index) { + if (!_controller.hasClients) return; final targetPage = widget.isInfinite ? _getNearestVirtualPage(index, currentPage, widget.itemCount) : index; diff --git a/lib/src/coverflow_page_indicator.dart b/lib/src/coverflow_page_indicator.dart index 7cd004f..8834be0 100644 --- a/lib/src/coverflow_page_indicator.dart +++ b/lib/src/coverflow_page_indicator.dart @@ -17,7 +17,8 @@ class CoverflowPageIndicator extends StatelessWidget { 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;