diff --git a/example/lib/main.dart b/example/lib/main.dart index fab6c3d..3e70ed4 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,13 @@ class _CoverflowDemoScreenState extends State { const SizedBox(height: 24), // Dynamic Liquid Page Indicator - _CoverflowPageIndicator( + CoverflowPageIndicator( + controller: _controller, itemCount: _demoCards.length, - pageListenable: _controller.pageListenable, + activeColor: _indicatorUseThemeColor + ? Theme.of(context).colorScheme.primary + : Colors.white, + dotSpacing: _indicatorDotSpacing, onTap: (index) { _controller.animateTo(index); }, @@ -478,6 +487,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 +748,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/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..460b64f --- /dev/null +++ b/lib/src/coverflow_page_indicator.dart @@ -0,0 +1,113 @@ +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(itemCount >= 0), + assert(dotSize > 0), + assert(dotSpacing >= 0), + assert(_tapTargetSize >= dotSize); + + 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.clamp(0.0, count.toDouble() - 1e-9).toDouble(); + final floor = clamped.floor(); + final t = clamped - floor; + final indexB = (floor + 1) % count; + final isWrapping = count > 1 && indexB == 0 && floor == count - 1; + + return SizedBox( + width: count * dotSize + (count - 1) * dotSpacing, + height: dotSize + 8, + child: Stack( + clipBehavior: Clip.none, + children: [ + 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( + child: Container( + width: dotSize, + height: dotSize, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: inactiveColor, + ), + ), + ), + ), + ), + if (isWrapping && t > 0.5) + _buildActivePill( + left: 0, + width: dotSize + (1.0 - (t - 0.5) / 0.5) * step, + ) + 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), + ), + ), + ), + ); + } +} diff --git a/test/coverflow_page_indicator_test.dart b/test/coverflow_page_indicator_test.dart new file mode 100644 index 0000000..f6ad6a0 --- /dev/null +++ b/test/coverflow_page_indicator_test.dart @@ -0,0 +1,118 @@ +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.byWidgetPredicate( + (w) => w is SizedBox && w.width == 0 && w.height == 0, + ), + 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); + }); + }); +}