diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 050073f..621e4cf 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -27,7 +27,7 @@ jobs: - name: Set up Flutter uses: subosito/flutter-action@v2 with: - flutter-version: '3.32.0' + flutter-version: '3.32.6' - name: Install dependencies run: flutter pub get diff --git a/README.md b/README.md index 2b5f080..aedf5a8 100644 --- a/README.md +++ b/README.md @@ -715,6 +715,37 @@ StepProgress( ``` +### Example 21: Instagram Story Stepper +![StepProgress-horizontal-instagram-story-stepper](https://raw.githubusercontent.com/TalebRafiepour/showcase/main/step_progress/sample-21-stepprogress-instagaram-story-stepper.gif) + +
+ Show Implementation + +```dart +StepProgress( + totalSteps: 5, + padding: const EdgeInsets.all(10), + controller: stepProgressController, + visibilityOptions: StepProgressVisibilityOptions.lineOnly, + autoStartProgress: true, + onStepChanged: (currentIndex) { + // Notice that the currentIndex starts from 1 in the LineOnly mode + debugPrint('Current step changed to: $currentIndex'); + }, + theme: const StepProgressThemeData( + activeForegroundColor: Color.fromARGB(255, 255, 255, 255), + defaultForegroundColor: Color.fromARGB(255, 171, 168, 168), + stepLineSpacing: 3, + stepLineStyle: StepLineStyle( + lineThickness: 5, + animationDuration: Duration(seconds: 3), + borderRadius: Radius.circular(5), + ), + ), +), +``` +
+ ## Installation To use StepProgress, add it to your `pubspec.yaml` file: diff --git a/example/lib/example_eighteen.dart b/example/lib/example_eighteen.dart index bd2af12..8f6da7e 100644 --- a/example/lib/example_eighteen.dart +++ b/example/lib/example_eighteen.dart @@ -16,13 +16,7 @@ class ExampleEighteen extends StatelessWidget { padding: const EdgeInsets.all(10), controller: stepProgressController, theme: const StepProgressThemeData( - stepLineStyle: StepLineStyle( - borderRadius: Radius.circular(8), - borderStyle: OuterBorderStyle( - isDotted: true, - borderWidth: 3, - ), - ), + enableRippleEffect: true, ), ), bottomNavigationBar: SafeArea( diff --git a/example/lib/example_fifteen.dart b/example/lib/example_fifteen.dart index 3e90949..2d6c387 100644 --- a/example/lib/example_fifteen.dart +++ b/example/lib/example_fifteen.dart @@ -6,43 +6,80 @@ class ExampleFifteen extends StatelessWidget { @override Widget build(BuildContext context) { - final stepProgressController = StepProgressController(totalSteps: 4); + final stepProgressController = StepProgressController(totalSteps: 6); return Scaffold( + backgroundColor: const Color(0xFF444444), appBar: AppBar( - title: const Text('StepProgress - LineLabel'), + title: const Text('StepProgress - Custom Vertical Timeline'), ), body: StepProgress( - totalSteps: 4, + totalSteps: 6, padding: const EdgeInsets.all(10), - lineTitles: const [ - 'line title 1', - 'line title 2', - 'line title 3', - ], + axis: Axis.vertical, + reversed: true, controller: stepProgressController, nodeIconBuilder: (index, completedStepIndex) { if (index <= completedStepIndex) { + //step completed return const Icon( Icons.check, + size: 18, color: Colors.white, ); - } else { - return const Icon( - Icons.more_horiz, - color: Colors.white, + } + return null; + }, + lineLabelBuilder: (index, completedStepIndex) { + // here index is index of current line + // (numbers of lines is equal to toalSteps - 1) + if (index.isEven) { + return Text( + 'December ${index + 10} 2020', + style: Theme.of(context) + .textTheme + .titleSmall + ?.copyWith(color: Colors.white), ); } + return null; + }, + nodeLabelBuilder: (index, completedStepIndex) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 2, + children: [ + Text( + 'Invisalign ClinCheck $index', + maxLines: 3, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + decorationColor: const Color(0xFF4e97fc), + color: const Color(0xFF4e97fc), + decoration: TextDecoration.underline, + ), + ), + Text( + '9:20 AM - 9:40 AM', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: const Color(0xFF7e7971), + ), + ), + ], + ); }, theme: const StepProgressThemeData( - lineLabelAlignment: Alignment.bottomCenter, - lineLabelStyle: StepLabelStyle( - defualtColor: Colors.grey, - activeColor: Colors.green, + defaultForegroundColor: Color(0xFF666666), + activeForegroundColor: Color(0xFF4e97fc), + lineLabelAlignment: Alignment.topLeft, + nodeLabelStyle: StepLabelStyle( + maxWidth: double.infinity, + margin: EdgeInsets.all(4), ), - stepLineSpacing: 20, - stepLineStyle: StepLineStyle( - lineThickness: 3, - borderRadius: Radius.circular(4), + lineLabelStyle: StepLabelStyle( + maxWidth: double.infinity, + margin: EdgeInsets.only( + right: 18, + ), ), ), ), diff --git a/example/lib/example_fourteen.dart b/example/lib/example_fourteen.dart index 691b27a..380faf5 100644 --- a/example/lib/example_fourteen.dart +++ b/example/lib/example_fourteen.dart @@ -9,114 +9,47 @@ class ExampleFourteen extends StatelessWidget { final stepProgressController = StepProgressController(totalSteps: 4); return Scaffold( appBar: AppBar( - title: const Text('StepProgress - large labels'), + title: const Text('StepProgress - LineLabel'), ), - body: SingleChildScrollView( - child: Column( - spacing: 18, - children: [ - StepProgress( - totalSteps: 4, - stepSize: 28, - controller: stepProgressController, - nodeTitles: const [ - 'Step 1', - 'Step 2', - 'Step 3', - 'Step 4 and a long title', - ], - theme: const StepProgressThemeData( - nodeLabelAlignment: StepLabelAlignment.top, - ), - ), - StepProgress( - totalSteps: 4, - stepSize: 28, - controller: stepProgressController, - nodeTitles: const [ - 'Step 1', - 'Step 2', - 'Step 3', - 'Step 4 and a long title', - ], - theme: const StepProgressThemeData( - nodeLabelAlignment: StepLabelAlignment.bottom, - ), - ), - StepProgress( - totalSteps: 4, - stepSize: 28, - controller: stepProgressController, - nodeTitles: const [ - 'Step 1 and a long title here', - 'Step 2', - 'Step 3', - 'Step 4 and a long title', - ], - theme: const StepProgressThemeData( - nodeLabelAlignment: StepLabelAlignment.topBottom, - ), - ), - Row( - spacing: 18, - children: [ - StepProgress( - totalSteps: 4, - stepSize: 28, - height: 390, - axis: Axis.vertical, - controller: stepProgressController, - nodeTitles: const [ - 'Step 1', - 'Step 2', - 'Step 3', - 'Step 4 and a long title', - ], - theme: const StepProgressThemeData( - nodeLabelAlignment: StepLabelAlignment.left, - ), - ), - StepProgress( - totalSteps: 4, - stepSize: 28, - height: 390, - axis: Axis.vertical, - controller: stepProgressController, - nodeTitles: const [ - 'Step 1', - 'Step 2', - 'Step 3', - 'Step 4 and a long title', - ], - theme: const StepProgressThemeData( - nodeLabelAlignment: StepLabelAlignment.right, - ), - ), - StepProgress( - totalSteps: 4, - stepSize: 28, - height: 390, - axis: Axis.vertical, - controller: stepProgressController, - nodeTitles: const [ - 'Step 1', - 'Step 2', - 'Step 3', - 'Step 4 and a long title', - ], - theme: const StepProgressThemeData( - nodeLabelAlignment: StepLabelAlignment.leftRight, - ), - ), - ], - ), - ], + body: StepProgress( + totalSteps: 4, + padding: const EdgeInsets.all(10), + lineTitles: const [ + 'line title 1', + 'line title 2', + 'line title 3', + ], + controller: stepProgressController, + nodeIconBuilder: (index, completedStepIndex) { + if (index <= completedStepIndex) { + return const Icon( + Icons.check, + color: Colors.white, + ); + } else { + return const Icon( + Icons.more_horiz, + color: Colors.white, + ); + } + }, + theme: const StepProgressThemeData( + lineLabelAlignment: Alignment.bottomCenter, + lineLabelStyle: StepLabelStyle( + defualtColor: Colors.grey, + activeColor: Colors.green, + ), + stepLineSpacing: 20, + stepLineStyle: StepLineStyle( + lineThickness: 3, + borderRadius: Radius.circular(4), + ), ), ), bottomNavigationBar: SafeArea( child: Row( mainAxisAlignment: MainAxisAlignment.center, - spacing: 38, + spacing: 40, children: [ ElevatedButton( onPressed: stepProgressController.previousStep, diff --git a/example/lib/example_nineteen.dart b/example/lib/example_nineteen.dart index 20662be..927086d 100644 --- a/example/lib/example_nineteen.dart +++ b/example/lib/example_nineteen.dart @@ -9,15 +9,13 @@ class ExampleNineteen extends StatelessWidget { final stepProgressController = StepProgressController(totalSteps: 5); return Scaffold( appBar: AppBar( - title: const Text('StepProgress - Dotted Line'), + title: const Text('StepProgress - HighlitCurrentStepNode'), ), body: StepProgress( totalSteps: 5, padding: const EdgeInsets.all(10), controller: stepProgressController, - theme: const StepProgressThemeData( - enableRippleEffect: true, - ), + highlightOptions: StepProgressHighlightOptions.highlightCurrentNode, ), bottomNavigationBar: SafeArea( child: Row( diff --git a/example/lib/example_seventeen.dart b/example/lib/example_seventeen.dart index d556135..b5dacf9 100644 --- a/example/lib/example_seventeen.dart +++ b/example/lib/example_seventeen.dart @@ -9,23 +9,19 @@ class ExampleSeventeen extends StatelessWidget { final stepProgressController = StepProgressController(totalSteps: 5); return Scaffold( appBar: AppBar( - title: const Text('StepProgress - BreadCrumb Line'), + title: const Text('StepProgress - Dotted Line'), ), body: StepProgress( totalSteps: 5, padding: const EdgeInsets.all(10), controller: stepProgressController, - lineSubTitles: const [ - 'Step 2', - 'Step 3', - 'Step 4', - 'Step 5', - ], theme: const StepProgressThemeData( - stepLineSpacing: 28, stepLineStyle: StepLineStyle( - lineThickness: 10, - isBreadcrumb: true, + borderRadius: Radius.circular(8), + borderStyle: OuterBorderStyle( + isDotted: true, + borderWidth: 3, + ), ), ), ), diff --git a/example/lib/example_sixteen.dart b/example/lib/example_sixteen.dart index 5d5bda4..bbb0742 100644 --- a/example/lib/example_sixteen.dart +++ b/example/lib/example_sixteen.dart @@ -6,80 +6,26 @@ class ExampleSixteen extends StatelessWidget { @override Widget build(BuildContext context) { - final stepProgressController = StepProgressController(totalSteps: 6); + final stepProgressController = StepProgressController(totalSteps: 5); return Scaffold( - backgroundColor: const Color(0xFF444444), appBar: AppBar( - title: const Text('StepProgress - Custom Vertical Timeline'), + title: const Text('StepProgress - BreadCrumb Line'), ), body: StepProgress( - totalSteps: 6, + totalSteps: 5, padding: const EdgeInsets.all(10), - axis: Axis.vertical, - reversed: true, controller: stepProgressController, - nodeIconBuilder: (index, completedStepIndex) { - if (index <= completedStepIndex) { - //step completed - return const Icon( - Icons.check, - size: 18, - color: Colors.white, - ); - } - return null; - }, - lineLabelBuilder: (index, completedStepIndex) { - // here index is index of current line - // (numbers of lines is equal to toalSteps - 1) - if (index.isEven) { - return Text( - 'December ${index + 10} 2020', - style: Theme.of(context) - .textTheme - .titleSmall - ?.copyWith(color: Colors.white), - ); - } - return null; - }, - nodeLabelBuilder: (index, completedStepIndex) { - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 2, - children: [ - Text( - 'Invisalign ClinCheck $index', - maxLines: 3, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - decorationColor: const Color(0xFF4e97fc), - color: const Color(0xFF4e97fc), - decoration: TextDecoration.underline, - ), - ), - Text( - '9:20 AM - 9:40 AM', - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: const Color(0xFF7e7971), - ), - ), - ], - ); - }, + lineSubTitles: const [ + 'Step 2', + 'Step 3', + 'Step 4', + 'Step 5', + ], theme: const StepProgressThemeData( - defaultForegroundColor: Color(0xFF666666), - activeForegroundColor: Color(0xFF4e97fc), - lineLabelAlignment: Alignment.topLeft, - nodeLabelStyle: StepLabelStyle( - maxWidth: double.infinity, - margin: EdgeInsets.all(4), - ), - lineLabelStyle: StepLabelStyle( - maxWidth: double.infinity, - margin: EdgeInsets.only( - right: 18, - ), + stepLineSpacing: 28, + stepLineStyle: StepLineStyle( + lineThickness: 10, + isBreadcrumb: true, ), ), ), diff --git a/example/lib/example_twenty.dart b/example/lib/example_twenty.dart index fb962dc..265e1ac 100644 --- a/example/lib/example_twenty.dart +++ b/example/lib/example_twenty.dart @@ -7,15 +7,34 @@ class ExampleTwenty extends StatelessWidget { @override Widget build(BuildContext context) { final stepProgressController = StepProgressController(totalSteps: 5); + const nodeIcons = [ + Icon(Icons.home), + Icon(Icons.star), + Icon(Icons.settings), + Icon(Icons.person), + Icon(Icons.check) + ]; return Scaffold( + backgroundColor: Colors.grey[200], appBar: AppBar( - title: const Text('StepProgress - HighlitCurrentStepNode'), + title: const Text('StepProgress - Custom Stepper Without Lines'), ), body: StepProgress( totalSteps: 5, + stepSize: 75, padding: const EdgeInsets.all(10), controller: stepProgressController, - highlightOptions: StepProgressHighlightOptions.highlightCurrentNode, + visibilityOptions: StepProgressVisibilityOptions.nodeOnly, + nodeIconBuilder: (index, completedStepIndex) => nodeIcons[index], + theme: const StepProgressThemeData( + stepAnimationDuration: Duration.zero, + stepNodeStyle: StepNodeStyle( + iconColor: Color(0xfffdfdfd), + activeIconColor: Color(0xff72479e), + ), + activeForegroundColor: Color(0xFF181818), + defaultForegroundColor: Color(0xff4c4c4c), + ), ), bottomNavigationBar: SafeArea( child: Row( diff --git a/example/lib/example_twenty_one.dart b/example/lib/example_twenty_one.dart index 176325c..d43a1b3 100644 --- a/example/lib/example_twenty_one.dart +++ b/example/lib/example_twenty_one.dart @@ -7,33 +7,30 @@ class ExampleTwentyOne extends StatelessWidget { @override Widget build(BuildContext context) { final stepProgressController = StepProgressController(totalSteps: 5); - const nodeIcons = [ - Icon(Icons.home), - Icon(Icons.star), - Icon(Icons.settings), - Icon(Icons.person), - Icon(Icons.check) - ]; return Scaffold( - backgroundColor: Colors.grey[200], + backgroundColor: Colors.black45, appBar: AppBar( - title: const Text('StepProgress - Custom Stepper Without Lines'), + title: const Text('StepProgress - Instagram Story Like Stepper'), ), body: StepProgress( totalSteps: 5, - stepSize: 75, padding: const EdgeInsets.all(10), controller: stepProgressController, - visibilityOptions: StepProgressVisibilityOptions.nodeOnly, - nodeIconBuilder: (index, completedStepIndex) => nodeIcons[index], + visibilityOptions: StepProgressVisibilityOptions.lineOnly, + autoStartProgress: true, + onStepChanged: (currentIndex) { + // Notice that the currentIndex starts from 1 in the LineOnly mode + debugPrint('Current step changed to: $currentIndex'); + }, theme: const StepProgressThemeData( - stepAnimationDuration: Duration.zero, - stepNodeStyle: StepNodeStyle( - iconColor: Color(0xfffdfdfd), - activeIconColor: Color(0xff72479e), + activeForegroundColor: Color.fromARGB(255, 255, 255, 255), + defaultForegroundColor: Color.fromARGB(255, 171, 168, 168), + stepLineSpacing: 3, + stepLineStyle: StepLineStyle( + lineThickness: 5, + animationDuration: Duration(seconds: 3), + borderRadius: Radius.circular(5), ), - activeForegroundColor: Color(0xFF181818), - defaultForegroundColor: Color(0xff4c4c4c), ), ), bottomNavigationBar: SafeArea( @@ -43,11 +40,27 @@ class ExampleTwentyOne extends StatelessWidget { children: [ ElevatedButton( onPressed: stepProgressController.previousStep, - child: const Text('Prev'), + child: const Icon(Icons.arrow_back, size: 20), + ), + ElevatedButton( + onPressed: () { + if (stepProgressController.isAnimating()) { + stepProgressController.pauseAnimation(); + } + }, + child: const Icon(Icons.pause, size: 20), + ), + ElevatedButton( + onPressed: () { + if (!stepProgressController.isAnimating()) { + stepProgressController.playAnimation(); + } + }, + child: const Icon(Icons.play_arrow, size: 20), ), ElevatedButton( onPressed: stepProgressController.nextStep, - child: const Text('Next'), + child: const Icon(Icons.arrow_forward, size: 20), ), ], ), diff --git a/example/lib/main.dart b/example/lib/main.dart index 64d4710..b56ec69 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -8,7 +8,6 @@ import 'package:example/example_fourteen.dart'; import 'package:example/example_nine.dart'; import 'package:example/example_nineteen.dart'; import 'package:example/example_one.dart'; -import 'package:example/example_reproduce_issues.dart'; import 'package:example/example_seven.dart'; import 'package:example/example_seventeen.dart'; import 'package:example/example_six.dart'; @@ -20,6 +19,8 @@ import 'package:example/example_twelve.dart'; import 'package:example/example_twenty.dart'; import 'package:example/example_twenty_one.dart'; import 'package:example/example_two.dart'; +import 'package:example/reproduce_issues.dart'; +import 'package:example/reproduce_large_labels.dart'; import 'package:flutter/material.dart'; void main() { @@ -64,7 +65,7 @@ class HomePage extends StatelessWidget { ), ); }, - child: const Text('Example One (Vertical)'), + child: const Text('1-Vertical'), ), ElevatedButton( onPressed: () { @@ -75,7 +76,7 @@ class HomePage extends StatelessWidget { ), ); }, - child: const Text('Example Two (Horizontal - without labels)'), + child: const Text('2-HorizontalWithoutLabels'), ), ElevatedButton( onPressed: () { @@ -86,7 +87,7 @@ class HomePage extends StatelessWidget { ), ); }, - child: const Text('Example Three (LineOnly-Custom)'), + child: const Text('3-LineOnlyCustom'), ), ElevatedButton( onPressed: () { @@ -97,7 +98,7 @@ class HomePage extends StatelessWidget { ), ); }, - child: const Text('Example Four (LineOnly Custom)'), + child: const Text('4-LineOnlyCustom'), ), ElevatedButton( onPressed: () { @@ -108,7 +109,7 @@ class HomePage extends StatelessWidget { ), ); }, - child: const Text('Example Five (Border)'), + child: const Text('5-Border'), ), ElevatedButton( onPressed: () { @@ -119,7 +120,7 @@ class HomePage extends StatelessWidget { ), ); }, - child: const Text('Example Six (Positioned Labels)'), + child: const Text('6-PositionedLabels'), ), ElevatedButton( onPressed: () { @@ -130,7 +131,7 @@ class HomePage extends StatelessWidget { ), ); }, - child: const Text('Example Seven (NodeOnly)'), + child: const Text('7-NodeOnly'), ), ElevatedButton( onPressed: () { @@ -141,7 +142,7 @@ class HomePage extends StatelessWidget { ), ); }, - child: const Text('Example Eight (LineSpacing)'), + child: const Text('8-LineSpacing'), ), ElevatedButton( onPressed: () { @@ -152,7 +153,7 @@ class HomePage extends StatelessWidget { ), ); }, - child: const Text('Example Nine (SquareShape)'), + child: const Text('9-SquareShape'), ), ElevatedButton( onPressed: () { @@ -163,7 +164,7 @@ class HomePage extends StatelessWidget { ), ); }, - child: const Text('Example Ten (TriangleShape)'), + child: const Text('10-TriangleShape'), ), ElevatedButton( onPressed: () { @@ -174,7 +175,7 @@ class HomePage extends StatelessWidget { ), ); }, - child: const Text('Example Elevn (DiamondShape)'), + child: const Text('11-DiamondShape'), ), ElevatedButton( onPressed: () { @@ -185,7 +186,7 @@ class HomePage extends StatelessWidget { ), ); }, - child: const Text('Example Twelve (HexagonShape)'), + child: const Text('12-HexagonShape'), ), ElevatedButton( onPressed: () { @@ -196,7 +197,7 @@ class HomePage extends StatelessWidget { ), ); }, - child: const Text('Example Thirteen (StarShape)'), + child: const Text('13-StarShape'), ), ElevatedButton( onPressed: () { @@ -207,7 +208,7 @@ class HomePage extends StatelessWidget { ), ); }, - child: const Text('Example Fourteen (LargeLabel)'), + child: const Text('14-LineLabels'), ), ElevatedButton( onPressed: () { @@ -218,7 +219,7 @@ class HomePage extends StatelessWidget { ), ); }, - child: const Text('Example Fifteen (LineLabel)'), + child: const Text('15-CustomVerticalTimeLine'), ), ElevatedButton( onPressed: () { @@ -229,7 +230,7 @@ class HomePage extends StatelessWidget { ), ); }, - child: const Text('Example Sixteen (CustomVerticalTimeLine)'), + child: const Text('16-BreadCrumbLine'), ), ElevatedButton( onPressed: () { @@ -240,7 +241,7 @@ class HomePage extends StatelessWidget { ), ); }, - child: const Text('Example Seventeen (BreadCrumbLine)'), + child: const Text('17-DottedLine'), ), ElevatedButton( onPressed: () { @@ -251,7 +252,7 @@ class HomePage extends StatelessWidget { ), ); }, - child: const Text('Example Eighteen (DottedLine)'), + child: const Text('18-RippleEffectNode'), ), ElevatedButton( onPressed: () { @@ -262,7 +263,7 @@ class HomePage extends StatelessWidget { ), ); }, - child: const Text('Example Nineteen (RippleEffect node)'), + child: const Text('19-HighlitCurrentStepNode'), ), ElevatedButton( onPressed: () { @@ -273,7 +274,9 @@ class HomePage extends StatelessWidget { ), ); }, - child: const Text('Example Twenty (HighlitCurrentStepNode)'), + child: const Text( + '20-CustomStepperWithoutLines', + ), ), ElevatedButton( onPressed: () { @@ -285,7 +288,7 @@ class HomePage extends StatelessWidget { ); }, child: const Text( - 'Example Twenty One (CustomStepperWithoutLines)', + '21-InstagramStoryLikeStepper', ), ), ElevatedButton( @@ -293,7 +296,18 @@ class HomePage extends StatelessWidget { Navigator.push( context, MaterialPageRoute( - builder: (_) => const ExampleReproduceIssues(), + builder: (_) => const ReproduceLargeLabels(), + ), + ); + }, + child: const Text('ReproduceLargeLabels'), + ), + ElevatedButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const ReproduceIssues(), ), ); }, diff --git a/example/lib/example_reproduce_issues.dart b/example/lib/reproduce_issues.dart similarity index 93% rename from example/lib/example_reproduce_issues.dart rename to example/lib/reproduce_issues.dart index 2f4eb6b..7fa91c4 100644 --- a/example/lib/example_reproduce_issues.dart +++ b/example/lib/reproduce_issues.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:step_progress/step_progress.dart'; -class ExampleReproduceIssues extends StatelessWidget { - const ExampleReproduceIssues({super.key}); +class ReproduceIssues extends StatelessWidget { + const ReproduceIssues({super.key}); @override Widget build(BuildContext context) { diff --git a/example/lib/reproduce_large_labels.dart b/example/lib/reproduce_large_labels.dart new file mode 100644 index 0000000..24bdc36 --- /dev/null +++ b/example/lib/reproduce_large_labels.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart'; +import 'package:step_progress/step_progress.dart'; + +class ReproduceLargeLabels extends StatelessWidget { + const ReproduceLargeLabels({super.key}); + + @override + Widget build(BuildContext context) { + final stepProgressController = StepProgressController(totalSteps: 4); + return Scaffold( + appBar: AppBar( + title: const Text('StepProgress - large labels'), + ), + body: SingleChildScrollView( + child: Column( + spacing: 18, + children: [ + StepProgress( + totalSteps: 4, + stepSize: 28, + controller: stepProgressController, + nodeTitles: const [ + 'Step 1', + 'Step 2', + 'Step 3', + 'Step 4 and a long title', + ], + theme: const StepProgressThemeData( + nodeLabelAlignment: StepLabelAlignment.top, + ), + ), + StepProgress( + totalSteps: 4, + stepSize: 28, + controller: stepProgressController, + nodeTitles: const [ + 'Step 1', + 'Step 2', + 'Step 3', + 'Step 4 and a long title', + ], + theme: const StepProgressThemeData( + nodeLabelAlignment: StepLabelAlignment.bottom, + ), + ), + StepProgress( + totalSteps: 4, + stepSize: 28, + controller: stepProgressController, + nodeTitles: const [ + 'Step 1 and a long title here', + 'Step 2', + 'Step 3', + 'Step 4 and a long title', + ], + theme: const StepProgressThemeData( + nodeLabelAlignment: StepLabelAlignment.topBottom, + ), + ), + Row( + spacing: 18, + children: [ + StepProgress( + totalSteps: 4, + stepSize: 28, + height: 390, + axis: Axis.vertical, + controller: stepProgressController, + nodeTitles: const [ + 'Step 1', + 'Step 2', + 'Step 3', + 'Step 4 and a long title', + ], + theme: const StepProgressThemeData( + nodeLabelAlignment: StepLabelAlignment.left, + ), + ), + StepProgress( + totalSteps: 4, + stepSize: 28, + height: 390, + axis: Axis.vertical, + controller: stepProgressController, + nodeTitles: const [ + 'Step 1', + 'Step 2', + 'Step 3', + 'Step 4 and a long title', + ], + theme: const StepProgressThemeData( + nodeLabelAlignment: StepLabelAlignment.right, + ), + ), + StepProgress( + totalSteps: 4, + stepSize: 28, + height: 390, + axis: Axis.vertical, + controller: stepProgressController, + nodeTitles: const [ + 'Step 1', + 'Step 2', + 'Step 3', + 'Step 4 and a long title', + ], + theme: const StepProgressThemeData( + nodeLabelAlignment: StepLabelAlignment.leftRight, + ), + ), + ], + ), + ], + ), + ), + bottomNavigationBar: SafeArea( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 38, + children: [ + ElevatedButton( + onPressed: stepProgressController.previousStep, + child: const Text('Prev'), + ), + ElevatedButton( + onPressed: stepProgressController.nextStep, + child: const Text('Next'), + ), + ], + ), + ), + ); + } +} diff --git a/example/pubspec.lock b/example/pubspec.lock index 008a53d..b9526f3 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -158,7 +158,7 @@ packages: path: ".." relative: true source: path - version: "2.5.2" + version: "2.5.3" stream_channel: dependency: transitive description: diff --git a/lib/src/step_line/step_line.dart b/lib/src/step_line/step_line.dart index f8d6129..baa9a2c 100644 --- a/lib/src/step_line/step_line.dart +++ b/lib/src/step_line/step_line.dart @@ -1,6 +1,7 @@ import 'package:dotted_border/dotted_border.dart'; import 'package:flutter/material.dart'; import 'package:step_progress/src/step_line/breadcrumb_clipper.dart'; +import 'package:step_progress/src/step_line/step_value_line.dart'; import 'package:step_progress/step_progress.dart'; /// A widget that represents a step line in a step progress indicator. @@ -25,16 +26,32 @@ import 'package:step_progress/step_progress.dart'; /// The [isReversed] parameter indicates whether the step line is displayed in /// reverse order. It defaults to `false`. /// +/// The [isCurrentStep] parameter indicates whether this step is the current +/// active step. It defaults to `false`. +/// +/// The [isAutoStepChange] parameter determines if the step progression advances +/// automatically. It defaults to `false`. +/// +/// The [controller] parameter is an optional [StepProgressController] that can +/// be used to manage and update the step progress state. It defaults to `null`. +/// /// The [onTap] parameter is a callback function that is executed when the step /// line is tapped. It is optional and defaults to `null`. /// +/// The [onStepLineAnimationCompleted] parameter is a callback that is +/// triggered when the step line's animation completes. It is optional +/// and defaults to `null`. +/// /// Example usage: /// /// ```dart /// StepLine( /// axis: Axis.vertical, -/// isActive: true, +/// highlighted: true, /// isReversed: true, +/// isCurrentStep: true, +/// isAutoStepChange: false, +/// controller: myStepProgressController, /// stepLineStyle: StepLineStyle( /// color: Colors.blue, /// thickness: 2.0, @@ -42,14 +59,21 @@ import 'package:step_progress/step_progress.dart'; /// onTap: () { /// print('Step line tapped'); /// }, +/// onStepLineAnimationCompleted: () { +/// print('Line animation completed'); +/// }, /// ) /// ``` class StepLine extends StatelessWidget { const StepLine({ + this.isCurrentStep = false, + this.isAutoStepChange = false, this.axis = Axis.horizontal, this.stepLineStyle, this.highlighted = false, this.isReversed = false, + this.onStepLineAnimationCompleted, + this.controller, this.onTap, super.key, }); @@ -69,6 +93,18 @@ class StepLine extends StatelessWidget { /// Indicates whether the step line is displayed in reverse order. final bool isReversed; + /// Callback triggered when the line animation completed. + final VoidCallback? onStepLineAnimationCompleted; + + /// Indicates whether this step is the current active step. + final bool isCurrentStep; + + /// Determines if step changes occur automatically. + final bool isAutoStepChange; + + /// Optional controller to manage and update the step progress state. + final StepProgressController? controller; + @override Widget build(BuildContext context) { final theme = StepProgressTheme.of(context)!.data; @@ -76,6 +112,8 @@ class StepLine extends StatelessWidget { final isBreadcrumb = style.isBreadcrumb; final borderStyle = style.borderStyle ?? theme.borderStyle; final borderRadius = style.borderRadius; + // + final activeColor = style.activeColor ?? theme.activeForegroundColor; final lineSpacing = EdgeInsets.symmetric( horizontal: _isHorizontal ? theme.stepLineSpacing : 0, @@ -106,21 +144,37 @@ class StepLine extends StatelessWidget { width: lineSize.width, height: lineSize.height, decoration: containerDecoration, - alignment: AlignmentDirectional.centerStart, - child: AnimatedContainer( - width: _isHorizontal - ? (highlighted ? lineSize.width : 0) - : lineSize.width, - height: !_isHorizontal - ? (highlighted ? lineSize.height : 0) - : lineSize.height, - decoration: BoxDecoration( - color: style.activeColor ?? theme.activeForegroundColor, - borderRadius: BorderRadius.all(borderRadius), - ), - curve: Curves.fastLinearToSlowEaseIn, - duration: style.animationDuration ?? theme.stepAnimationDuration, - ), + alignment: _isHorizontal + ? (isReversed + ? AlignmentDirectional.centerEnd + : AlignmentDirectional.centerStart) + : (isReversed + ? AlignmentDirectional.bottomEnd + : AlignmentDirectional.topStart), + child: isCurrentStep && isAutoStepChange + ? StepValueLine( + controller: controller, + duration: + style.animationDuration ?? theme.stepAnimationDuration, + activeColor: activeColor, + borderRadius: borderRadius, + lineSize: lineSize, + highlighted: highlighted, + isHorizontal: _isHorizontal, + onAnimationCompleted: onStepLineAnimationCompleted, + ) + : Container( + width: _isHorizontal + ? (highlighted ? lineSize.width : 0) + : lineSize.width, + height: !_isHorizontal + ? (highlighted ? lineSize.height : 0) + : lineSize.height, + decoration: BoxDecoration( + color: activeColor, + borderRadius: BorderRadius.all(borderRadius), + ), + ), ); if (isBreadcrumb) { diff --git a/lib/src/step_line/step_value_line.dart b/lib/src/step_line/step_value_line.dart new file mode 100644 index 0000000..007e94c --- /dev/null +++ b/lib/src/step_line/step_value_line.dart @@ -0,0 +1,120 @@ +import 'package:flutter/material.dart'; +import 'package:step_progress/src/step_progress_controller.dart'; + +/// A widget that displays a step progress line with customizable appearance. +/// Supports animation, orientation, and completion callbacks. +/// Useful for visualizing progress in multi-step processes. +class StepValueLine extends StatefulWidget { + const StepValueLine({ + required this.activeColor, + required this.borderRadius, + required this.lineSize, + required this.highlighted, + required this.isHorizontal, + required this.duration, + this.onAnimationCompleted, + this.controller, + super.key, + }); + + /// Callback triggered when the animation is completed. + final VoidCallback? onAnimationCompleted; + + /// The color used for the active state of the step line. + final Color activeColor; + + /// The border radius applied to the step line. + final Radius borderRadius; + + /// The size of the step line. + final Size lineSize; + + /// Whether the step line is highlighted. + final bool highlighted; + + /// Whether the step line is oriented horizontally. + final bool isHorizontal; + + /// The duration of the animation. + final Duration duration; + + /// Optional controller to manage and update the step progress state. + final StepProgressController? controller; + + @override + State createState() => _StepValueLineState(); +} + +class _StepValueLineState extends State + with SingleTickerProviderStateMixin { + late final AnimationController animationController; + @override + void initState() { + animationController = AnimationController( + vsync: this, + duration: widget.duration, + ); + animationController.forward(); + if (widget.onAnimationCompleted != null) { + animationController.addStatusListener(statusListener); + } + widget.controller?.playAnimation = playAnimation; + widget.controller?.pauseAnimation = pauseAnimation; + widget.controller?.isAnimating = () => animationController.isAnimating; + super.initState(); + } + + void statusListener(AnimationStatus status) { + if (status == AnimationStatus.completed) { + widget.onAnimationCompleted!(); + } + } + + @override + void dispose() { + if (widget.onAnimationCompleted != null) { + animationController.removeStatusListener(statusListener); + } + animationController.dispose(); + super.dispose(); + } + + /// Starts the animation if it is not already running. + void playAnimation() { + if (!animationController.isAnimating) { + animationController.forward(); + } + } + + /// Pauses the animation if it is currently running. + void pauseAnimation() { + if (animationController.isAnimating) { + animationController.stop(); + } + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: animationController, + builder: (context, child) { + return Container( + width: widget.isHorizontal + ? (widget.highlighted + ? widget.lineSize.width * animationController.value + : 0) + : widget.lineSize.width, + height: !widget.isHorizontal + ? (widget.highlighted + ? widget.lineSize.height * animationController.value + : 0) + : widget.lineSize.height, + decoration: BoxDecoration( + color: widget.activeColor, + borderRadius: BorderRadius.all(widget.borderRadius), + ), + ); + }, + ); + } +} diff --git a/lib/src/step_progress.dart b/lib/src/step_progress.dart index e6c38f1..36a4d10 100644 --- a/lib/src/step_progress.dart +++ b/lib/src/step_progress.dart @@ -110,6 +110,9 @@ typedef OnStepNodeTapped = void Function(int index); /// /// The [reversed] parameter indicates whether the step progress is displayed /// in reverse order. It defaults to false. +/// +/// The [autoStartProgress] parameter determines whether the progress should +/// automatically start when the widget is initialized. It defaults to false. class StepProgress extends StatefulWidget { const StepProgress({ required this.totalSteps, @@ -124,6 +127,7 @@ class StepProgress extends StatefulWidget { this.padding = EdgeInsets.zero, this.axis = Axis.horizontal, this.reversed = false, + this.autoStartProgress = false, this.visibilityOptions = StepProgressVisibilityOptions.both, this.highlightOptions = StepProgressHighlightOptions.highlightCompletedNodesAndLines, @@ -228,6 +232,9 @@ class StepProgress extends StatefulWidget { /// Indicates whether the step progress is displayed in reverse order. final bool reversed; + /// Whether the progress should start automatically when the widget is built. + final bool autoStartProgress; + @override _StepProgressState createState() { assert( @@ -241,12 +248,19 @@ class StepProgress extends StatefulWidget { class _StepProgressState extends State with SingleTickerProviderStateMixin { late int _currentStep = _getCurrentStep; + late int _previousStep = _getPreviousStep; @override void initState() { widget.controller?.addListener(() { _changeStep(widget.controller!.currentStep); }); + if (widget.autoStartProgress) { + // Because the first step has no line, we start from 1 + if (_currentStep <= 0) { + _currentStep = 1; + } + } super.initState(); } @@ -258,8 +272,6 @@ class _StepProgressState extends State /// is empty. DataCache().clearCache(); - /// Disposes of the controller if it is not null. - widget.controller?.dispose(); super.dispose(); } @@ -289,6 +301,12 @@ class _StepProgressState extends State : widget.currentStep; } + int get _getPreviousStep { + return widget.controller != null + ? widget.controller!.prevStep + : widget.currentStep - 1; + } + /// Changes the current step to the specified [newStep]. /// /// If [newStep] is the same as the current step, less than -1, or greater @@ -305,10 +323,13 @@ class _StepProgressState extends State newStep >= widget.totalSteps) { return; } + _previousStep = _currentStep; + _currentStep = newStep; if (mounted) { - setState(() { - _currentStep = newStep; - }); + setState(() {}); + } + if (_currentStep != widget.controller?.currentStep) { + widget.controller?.setCurrentStep(_currentStep); } widget.onStepChanged?.call(_currentStep); } @@ -323,6 +344,22 @@ class _StepProgressState extends State } } + /// Handles automatic step changes based on the current index and direction. + void _onStepAnimationCompleted({ + int index = 0, + }) { + if (!widget.autoStartProgress) { + return; + } else { + final isForward = _currentStep > _previousStep; + if (isForward) { + _changeStep(index + 1); + } else { + _changeStep(index - 1); + } + } + } + @override Widget build(BuildContext context) { return StepProgressTheme( @@ -337,10 +374,13 @@ class _StepProgressState extends State padding: widget.padding, child: widget.axis == Axis.horizontal ? HorizontalStepProgress( + controller: widget.controller, totalSteps: widget.totalSteps, currentStep: _currentStep, + isAutoStepChange: widget.autoStartProgress, reversed: widget.reversed, highlightOptions: widget.highlightOptions, + onStepLineAnimationCompleted: _onStepAnimationCompleted, needsRebuildWidget: _needsRebuildWidget, nodeTitles: widget.nodeTitles, nodeSubTitles: widget.nodeSubTitles, @@ -355,10 +395,13 @@ class _StepProgressState extends State nodeLabelBuilder: widget.nodeLabelBuilder, ) : VerticalStepProgress( + controller: widget.controller, totalSteps: widget.totalSteps, currentStep: _currentStep, + isAutoStepChange: widget.autoStartProgress, reversed: widget.reversed, highlightOptions: widget.highlightOptions, + onStepLineAnimationCompleted: _onStepAnimationCompleted, needsRebuildWidget: _needsRebuildWidget, nodeTitles: widget.nodeTitles, nodeSubTitles: widget.nodeSubTitles, diff --git a/lib/src/step_progress_controller.dart b/lib/src/step_progress_controller.dart index 305acc4..5e7a27a 100644 --- a/lib/src/step_progress_controller.dart +++ b/lib/src/step_progress_controller.dart @@ -25,6 +25,15 @@ class StepProgressController extends ChangeNotifier { /// Returns current step int get currentStep => _currentStep; + /// Stores the previous step index based on the current step. + late int prevStep = currentStep - 1; + + VoidCallback playAnimation = () {}; + + VoidCallback pauseAnimation = () {}; + + bool Function() isAnimating = () => false; + /// validate newStep and set it to current step void setCurrentStep(int newStep) { assert( @@ -32,6 +41,7 @@ class StepProgressController extends ChangeNotifier { 'new step must be equal or greater than -1 and lower than $totalSteps', ); if (_currentStep != newStep) { + prevStep = _currentStep; _currentStep = newStep; notifyListeners(); } @@ -43,6 +53,7 @@ class StepProgressController extends ChangeNotifier { /// incremented step is still less than the total number of steps. void nextStep() { if (_currentStep + 1 < totalSteps) { + prevStep = _currentStep; _currentStep++; notifyListeners(); } @@ -52,6 +63,7 @@ class StepProgressController extends ChangeNotifier { /// Decrements the `currentStep` by 1 and notifies listeners. void previousStep() { if (_currentStep >= 0) { + prevStep = _currentStep; _currentStep--; notifyListeners(); } diff --git a/lib/src/step_progress_widgets/horizontal_step_progress.dart b/lib/src/step_progress_widgets/horizontal_step_progress.dart index adf6b63..95dfc0a 100644 --- a/lib/src/step_progress_widgets/horizontal_step_progress.dart +++ b/lib/src/step_progress_widgets/horizontal_step_progress.dart @@ -42,6 +42,8 @@ import 'package:step_progress/step_progress.dart'; /// step nodes. /// - [lineLabelBuilder]: A builder for creating custom label widgets for step /// lines. +/// - [isAutoStepChange]: A boolean that determines if the step change should +/// occur automatically. /// - [key]: An optional key for the widget. class HorizontalStepProgress extends StepProgressWidget { const HorizontalStepProgress({ @@ -50,7 +52,10 @@ class HorizontalStepProgress extends StepProgressWidget { required super.stepSize, required super.visibilityOptions, required super.needsRebuildWidget, + super.onStepLineAnimationCompleted, + super.controller, super.highlightOptions, + super.isAutoStepChange, super.reversed, super.nodeTitles, super.nodeSubTitles, @@ -128,8 +133,15 @@ class HorizontalStepProgress extends StepProgressWidget { Widget buildWidget() { List children = List.generate(totalSteps - 1, (index) { return StepLine( + controller: controller, isReversed: reversed, + isCurrentStep: currentStep == index + 1, + isAutoStepChange: isAutoStepChange, highlighted: isHighlightedStepLine(index), + onStepLineAnimationCompleted: () => + onStepLineAnimationCompleted?.call( + index: index + 1, + ), onTap: () => onStepLineTapped?.call(index), ); }); diff --git a/lib/src/step_progress_widgets/step_progress_widget.dart b/lib/src/step_progress_widgets/step_progress_widget.dart index ab459af..32f253d 100644 --- a/lib/src/step_progress_widgets/step_progress_widget.dart +++ b/lib/src/step_progress_widgets/step_progress_widget.dart @@ -4,10 +4,19 @@ import 'package:step_progress/src/step_label/step_label_style.dart'; import 'package:step_progress/src/step_label_alignment.dart'; import 'package:step_progress/src/step_line/step_line_style.dart'; import 'package:step_progress/src/step_progress.dart'; +import 'package:step_progress/src/step_progress_controller.dart'; import 'package:step_progress/src/step_progress_highlight_options.dart'; import 'package:step_progress/src/step_progress_theme.dart'; import 'package:step_progress/src/step_progress_visibility_options.dart'; +/// Signature for a callback that is invoked when the step line animation is +/// completed. +/// +/// [index] is the index of the step whose animation has completed. +typedef OnStepLineAnimationCompleted = void Function({ + int index, +}); + /// An abstract class representing a step progress widget. /// /// This widget displays a progress indicator with multiple steps, allowing for @@ -25,24 +34,28 @@ import 'package:step_progress/src/step_progress_visibility_options.dart'; /// - [lineTitles]: An optional list of titles for each line segment. /// - [lineSubTitles]: An optional list of subtitles for each line segment. /// - [axis]: The axis in which the step progress is laid out -/// (horizontal or vertical). +/// (horizontal or vertical). /// - [visibilityOptions]: Options to control the visibility of elements. /// - [nodeSubTitles]: An optional list of subtitles for each step. /// - [onStepNodeTapped]: An optional callback function triggered when a step -/// node is tapped. +/// node is tapped. /// - [onStepLineTapped]: An optional callback function triggered when a step -/// line is tapped. +/// line is tapped. +/// - [onStepLineAnimationCompleted]: An optional callback function triggered +/// when a step line is highlighted. /// - [nodeIconBuilder]: An optional builder for the icon of a step node. /// - [nodeLabelBuilder]: A builder for creating custom label widgets for -/// step nodes. +/// step nodes. /// - [lineLabelBuilder]: A builder for creating custom label widgets for step -/// lines. +/// lines. /// - [reversed]: Indicates whether the step progress is displayed in reverse -/// order. It defaults to false. +/// order. It defaults to false. /// - [needsRebuildWidget]: Callback to request a rebuild of the parent widget. -/// This is triggered when dynamic size calculations are needed. +/// This is triggered when dynamic size calculations are needed. /// - [highlightOptions]: Options to customize the highlight behavior of the -/// step progress widget. +/// step progress widget. +/// - [controller]: Optional controller to manage and update the step progress +/// state. abstract class StepProgressWidget extends StatelessWidget { const StepProgressWidget({ required this.totalSteps, @@ -53,6 +66,9 @@ abstract class StepProgressWidget extends StatelessWidget { required this.needsRebuildWidget, this.highlightOptions = StepProgressHighlightOptions.highlightCompletedNodesAndLines, + this.controller, + this.onStepLineAnimationCompleted, + this.isAutoStepChange = false, this.reversed = false, this.nodeTitles, this.nodeSubTitles, @@ -87,6 +103,9 @@ abstract class StepProgressWidget extends StatelessWidget { /// The current step that is active or completed. final int currentStep; + /// Determines if the step changes automatically without user interaction. + final bool isAutoStepChange; + /// The size of each step in the progress indicator. final double stepSize; @@ -133,6 +152,12 @@ abstract class StepProgressWidget extends StatelessWidget { /// Options to customize the highlight behavior of the step progress widget. final StepProgressHighlightOptions highlightOptions; + /// Callback triggered when the step line animation completes. + final OnStepLineAnimationCompleted? onStepLineAnimationCompleted; + + /// Optional controller to manage and update the step progress state. + final StepProgressController? controller; + /// Determines if a step line at the given index should be highlighted /// based on the current step and highlight options. bool isHighlightedStepLine(int index) { diff --git a/lib/src/step_progress_widgets/vertical_step_progress.dart b/lib/src/step_progress_widgets/vertical_step_progress.dart index 0b5a54f..8448603 100644 --- a/lib/src/step_progress_widgets/vertical_step_progress.dart +++ b/lib/src/step_progress_widgets/vertical_step_progress.dart @@ -38,6 +38,9 @@ import 'package:step_progress/src/step_progress_widgets/step_progress_widget.dar /// The [needsRebuildWidget] parameter is a [VoidCallback] function that can be /// used to trigger a rebuild of the widget when necessary. /// +/// The [isAutoStepChange] parameter is a [bool] that determines whether the +/// step change should occur automatically (e.g., with animation) or manually. +/// /// Optional parameters include [nodeTitles], [nodeSubTitles], [lineTitles], /// and [lineSubTitles], which allow you to provide titles and subtitles for /// step nodes and lines. The [onStepNodeTapped] callback can be used to handle @@ -57,6 +60,7 @@ import 'package:step_progress/src/step_progress_widgets/step_progress_widget.dar /// stepSize: 30.0, /// visibilityOptions: StepProgressVisibilityOptions.both, /// reversed: false, +/// isAutoStepChange: true, /// lineTitles: ['Line1', 'Line2', 'Line3', 'Line4' ], /// lineSubTitles: ['sub1', 'sub2', 'sub3', 'sub4', ] /// nodeTitles: ['Step 1', 'Step 2', 'Step 3', 'Step 4', 'Step 5'], @@ -89,7 +93,10 @@ class VerticalStepProgress extends StepProgressWidget { required super.stepSize, required super.visibilityOptions, required super.needsRebuildWidget, + super.onStepLineAnimationCompleted, + super.controller, super.highlightOptions, + super.isAutoStepChange, super.reversed, super.nodeTitles, super.nodeSubTitles, @@ -164,9 +171,16 @@ class VerticalStepProgress extends StepProgressWidget { Widget buildWidget() { List children = List.generate(totalSteps - 1, (index) { return StepLine( + controller: controller, axis: Axis.vertical, isReversed: reversed, + isCurrentStep: currentStep == index + 1, + isAutoStepChange: isAutoStepChange, highlighted: isHighlightedStepLine(index), + onStepLineAnimationCompleted: () => + onStepLineAnimationCompleted?.call( + index: index + 1, + ), onTap: () => onStepLineTapped?.call(index), ); }); diff --git a/test/src/step_line/step_line_test.dart b/test/src/step_line/step_line_test.dart index 8fdd9d3..4f91695 100644 --- a/test/src/step_line/step_line_test.dart +++ b/test/src/step_line/step_line_test.dart @@ -5,12 +5,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:step_progress/src/step_line/breadcrumb_clipper.dart'; import 'package:step_progress/src/step_line/step_line.dart'; +import 'package:step_progress/src/step_line/step_value_line.dart'; import 'package:step_progress/step_progress.dart'; import '../helper/test_theme_wrapper.dart'; void main() { - group('StepLine Widget Tests', () { + group('StepLine Widget Fundamental Tests', () { const lineThickness = 5.0; const dummyStepLineStyle = StepLineStyle(lineThickness: lineThickness); testWidgets( @@ -39,18 +40,18 @@ void main() { // Wait for animations to finish. await tester.pumpAndSettle(); - // The AnimatedContainer inside StepLine is used for the active color + // The container inside StepLine is used for the active color // fill. - final animatedContainerFinder = find.descendant( + final containerFinder = find.descendant( of: find.byKey(testKey), - matching: find.byType(AnimatedContainer), + matching: find.byType(Container), ); - expect(animatedContainerFinder, findsOneWidget); + expect(containerFinder, findsNWidgets(2)); // For horizontal active, the animated container width should equal // the parent's width (200) // and its height should equal the defined lineThickness. - final animatedSize = tester.getSize(animatedContainerFinder); + final animatedSize = tester.getSize(containerFinder.last); expect(animatedSize.width, equals(200)); expect(animatedSize.height, equals(lineThickness)); }, @@ -77,14 +78,14 @@ void main() { await tester.pumpAndSettle(); - final animatedContainerFinder = find.descendant( + final containerFinder = find.descendant( of: find.byKey(testKey), - matching: find.byType(AnimatedContainer), + matching: find.byType(Container), ); - expect(animatedContainerFinder, findsOneWidget); + expect(containerFinder, findsNWidgets(2)); - final animatedSize = tester.getSize(animatedContainerFinder); - // For horizontal inactive, the animated container width should be 0 + final animatedSize = tester.getSize(containerFinder.last); + // For horizontal inactive, the active container width should be 0 // while the height remains equal to lineThickness. expect(animatedSize.width, equals(0)); expect(animatedSize.height, equals(lineThickness)); @@ -117,13 +118,13 @@ void main() { await tester.pumpAndSettle(); - final animatedContainerFinder = find.descendant( + final containerFinder = find.descendant( of: find.byKey(testKey), - matching: find.byType(AnimatedContainer), + matching: find.byType(Container), ); - expect(animatedContainerFinder, findsOneWidget); + expect(containerFinder, findsNWidgets(2)); - final animatedSize = tester.getSize(animatedContainerFinder); + final animatedSize = tester.getSize(containerFinder.last); // For vertical active, the width is set to the lineThickness // and height should match parent's height (200). expect(animatedSize.width, equals(lineThickness)); @@ -156,13 +157,13 @@ void main() { await tester.pumpAndSettle(); - final animatedContainerFinder = find.descendant( + final containerFinder = find.descendant( of: find.byKey(testKey), - matching: find.byType(AnimatedContainer), + matching: find.byType(Container), ); - expect(animatedContainerFinder, findsOneWidget); + expect(containerFinder, findsNWidgets(2)); - final animatedSize = tester.getSize(animatedContainerFinder); + final animatedSize = tester.getSize(containerFinder.last); // For vertical inactive, the animated container height should be 0 // while the width remains equal to lineThickness. expect(animatedSize.width, equals(lineThickness)); @@ -407,4 +408,425 @@ void main() { }, ); }); + + group('StepLine Widget AutoStartProgress Tests', () { + testWidgets( + 'StepLine with isAutoStepChange and isCurrentStep triggers animation and ' + 'calls onStepLineAnimationCompleted', + (tester) async { + bool animationCompleted = false; + const testKey = Key('step_line_auto_current'); + const style = StepLineStyle(lineThickness: 10); + + await tester.pumpWidget( + TestThemeWrapper( + child: SizedBox( + width: 100, + height: 20, + child: Row( + children: [ + StepLine( + key: testKey, + stepLineStyle: style, + highlighted: true, + isAutoStepChange: true, + isCurrentStep: true, + onStepLineAnimationCompleted: () { + animationCompleted = true; + }, + ), + ], + ), + ), + ), + ); + + // Wait for animation to complete + await tester.pumpAndSettle(); + + expect(animationCompleted, isTrue); + + // Should contain StepValueLine widget + expect( + find.descendant( + of: find.byKey(testKey), + matching: find.byType(StepValueLine), + ), + findsOneWidget, + ); + }, + ); + + testWidgets( + 'StepLine with isAutoStepChange but not current step does not show' + ' StepValueLine', + (tester) async { + const testKey = Key('step_line_auto_not_current'); + const style = StepLineStyle(lineThickness: 10); + + await tester.pumpWidget( + const TestThemeWrapper( + child: SizedBox( + width: 100, + height: 20, + child: Row( + children: [ + StepLine( + key: testKey, + stepLineStyle: style, + highlighted: true, + isAutoStepChange: true, + isCurrentStep: false, + ), + ], + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // Should not contain StepValueLine widget + expect( + find.descendant( + of: find.byKey(testKey), + matching: find.byType(StepValueLine), + ), + findsNothing, + ); + }, + ); + + testWidgets( + 'StepLine with isCurrentStep but isAutoStepChange false does not show' + ' StepValueLine', + (tester) async { + const testKey = Key('step_line_current_no_auto'); + const style = StepLineStyle(lineThickness: 10); + + await tester.pumpWidget( + const TestThemeWrapper( + child: SizedBox( + width: 100, + height: 20, + child: Row( + children: [ + StepLine( + key: testKey, + stepLineStyle: style, + highlighted: true, + isAutoStepChange: false, + isCurrentStep: true, + ), + ], + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // Should not contain StepValueLine widget + expect( + find.descendant( + of: find.byKey(testKey), + matching: find.byType(StepValueLine), + ), + findsNothing, + ); + }, + ); + + testWidgets( + 'StepLine with isAutoStepChange and isCurrentStep vertical axis triggers' + ' animation', + (tester) async { + bool animationCompleted = false; + const testKey = Key('step_line_auto_current_vertical'); + const style = StepLineStyle(lineThickness: 8); + + await tester.pumpWidget( + TestThemeWrapper( + child: SizedBox( + width: 20, + height: 100, + child: Column( + children: [ + StepLine( + key: testKey, + stepLineStyle: style, + axis: Axis.vertical, + highlighted: true, + isAutoStepChange: true, + isCurrentStep: true, + onStepLineAnimationCompleted: () { + animationCompleted = true; + }, + ), + ], + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(animationCompleted, isTrue); + + // Should contain StepValueLine widget + expect( + find.descendant( + of: find.byKey(testKey), + matching: find.byType(StepValueLine), + ), + findsOneWidget, + ); + }, + ); + + testWidgets( + 'StepLine with isAutoStepChange, isCurrentStep, and reversed ' + 'horizontal axis', + (tester) async { + bool animationCompleted = false; + const testKey = Key('step_line_auto_current_reversed'); + const style = StepLineStyle(lineThickness: 6); + + await tester.pumpWidget( + TestThemeWrapper( + child: SizedBox( + width: 120, + height: 20, + child: Row( + children: [ + StepLine( + key: testKey, + stepLineStyle: style, + highlighted: true, + isAutoStepChange: true, + isCurrentStep: true, + isReversed: true, + onStepLineAnimationCompleted: () { + animationCompleted = true; + }, + ), + ], + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(animationCompleted, isTrue); + + // Should contain StepValueLine widget + expect( + find.descendant( + of: find.byKey(testKey), + matching: find.byType(StepValueLine), + ), + findsOneWidget, + ); + }, + ); + + testWidgets( + 'StepLine with isAutoStepChange, isCurrentStep, and breadcrumb style', + (tester) async { + bool animationCompleted = false; + const testKey = Key('step_line_auto_current_breadcrumb'); + const style = StepLineStyle( + lineThickness: 8, + isBreadcrumb: true, + chevronAngle: 20, + ); + + await tester.pumpWidget( + TestThemeWrapper( + child: SizedBox( + width: 100, + height: 20, + child: Row( + children: [ + StepLine( + key: testKey, + stepLineStyle: style, + highlighted: true, + isAutoStepChange: true, + isCurrentStep: true, + onStepLineAnimationCompleted: () { + animationCompleted = true; + }, + ), + ], + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(animationCompleted, isTrue); + + // Should contain StepValueLine widget + expect( + find.descendant( + of: find.byKey(testKey), + matching: find.byType(StepValueLine), + ), + findsOneWidget, + ); + + // Should contain ClipPath widget for breadcrumb + expect( + find.descendant( + of: find.byKey(testKey), + matching: find.byType(ClipPath), + ), + findsOneWidget, + ); + }, + ); + + testWidgets( + 'StepLine with isAutoStepChange, isCurrentStep, and dotted border style', + (tester) async { + bool animationCompleted = false; + const testKey = Key('step_line_auto_current_dotted'); + const dottedBorderStyle = OuterBorderStyle( + isDotted: true, + borderWidth: 2, + dashPattern: [4, 2], + defaultBorderColor: Colors.grey, + activeBorderColor: Colors.blue, + ); + const style = StepLineStyle( + lineThickness: 8, + borderStyle: dottedBorderStyle, + ); + + await tester.pumpWidget( + TestThemeWrapper( + child: SizedBox( + width: 100, + height: 20, + child: Row( + children: [ + StepLine( + key: testKey, + stepLineStyle: style, + highlighted: true, + isAutoStepChange: true, + isCurrentStep: true, + onStepLineAnimationCompleted: () { + animationCompleted = true; + }, + ), + ], + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(animationCompleted, isTrue); + + // Should contain StepValueLine widget + expect( + find.descendant( + of: find.byKey(testKey), + matching: find.byType(StepValueLine), + ), + findsOneWidget, + ); + + // Should contain DottedBorder widget + expect( + find.descendant( + of: find.byKey(testKey), + matching: find.byType(DottedBorder), + ), + findsOneWidget, + ); + }, + ); + + testWidgets( + 'StepLine with isAutoStepChange, isCurrentStep, dotted border and ' + 'breadcrumb style', + (tester) async { + bool animationCompleted = false; + const testKey = Key('step_line_auto_current_dotted_breadcrumb'); + const dottedBorderStyle = OuterBorderStyle( + isDotted: true, + borderWidth: 2, + dashPattern: [4, 2], + defaultBorderColor: Colors.grey, + activeBorderColor: Colors.blue, + ); + const style = StepLineStyle( + lineThickness: 8, + borderStyle: dottedBorderStyle, + isBreadcrumb: true, + chevronAngle: 25, + ); + + await tester.pumpWidget( + TestThemeWrapper( + child: SizedBox( + width: 100, + height: 20, + child: Row( + children: [ + StepLine( + key: testKey, + stepLineStyle: style, + highlighted: true, + isAutoStepChange: true, + isCurrentStep: true, + onStepLineAnimationCompleted: () { + animationCompleted = true; + }, + ), + ], + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(animationCompleted, isTrue); + + // Should contain StepValueLine widget + expect( + find.descendant( + of: find.byKey(testKey), + matching: find.byType(StepValueLine), + ), + findsOneWidget, + ); + + // Should contain DottedBorder widget + expect( + find.descendant( + of: find.byKey(testKey), + matching: find.byType(DottedBorder), + ), + findsOneWidget, + ); + + // Should contain ClipPath widget for breadcrumb + expect( + find.descendant( + of: find.byKey(testKey), + matching: find.byType(ClipPath), + ), + findsOneWidget, + ); + }, + ); + }); } diff --git a/test/src/step_line/step_value_line_test.dart b/test/src/step_line/step_value_line_test.dart new file mode 100644 index 0000000..ebd5a65 --- /dev/null +++ b/test/src/step_line/step_value_line_test.dart @@ -0,0 +1,167 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:step_progress/src/step_line/step_value_line.dart'; + +void main() { + group('StepValueLine', () { + testWidgets('renders a Container with correct color and borderRadius', + (tester) async { + const color = Colors.red; + const borderRadius = Radius.circular(8); + const size = Size(100, 10); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: StepValueLine( + activeColor: color, + borderRadius: borderRadius, + lineSize: size, + highlighted: true, + isHorizontal: true, + duration: Duration(milliseconds: 300), + ), + ), + ), + ); + + final container = tester.widget(find.byType(Container)); + final decoration = container.decoration! as BoxDecoration; + expect(decoration.color, color); + expect(decoration.borderRadius, const BorderRadius.all(borderRadius)); + }); + + testWidgets('has correct width and height for horizontal orientation', + (tester) async { + const size = Size(120, 8); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: StepValueLine( + activeColor: Colors.blue, + borderRadius: Radius.zero, + lineSize: size, + highlighted: true, + isHorizontal: true, + duration: Duration(milliseconds: 100), + ), + ), + ), + ); + + // At start of animation, width should be 0 + final container0 = tester.widget(find.byType(Container)); + expect(container0.constraints?.minWidth ?? 0, 0); + + // Complete the animation + await tester.pumpAndSettle(); + + final container = tester.widget(find.byType(Container)); + expect(container.constraints?.minWidth ?? 0, size.width); + expect(container.constraints?.minHeight ?? 0, size.height); + }); + + testWidgets('has correct width and height for vertical orientation', + (tester) async { + const size = Size(12, 60); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: StepValueLine( + activeColor: Colors.green, + borderRadius: Radius.zero, + lineSize: size, + highlighted: true, + isHorizontal: false, + duration: Duration(milliseconds: 100), + ), + ), + ), + ); + + // At start of animation, height should be 0 + final container0 = tester.widget(find.byType(Container)); + expect(container0.constraints?.minHeight ?? 0, 0); + + // Complete the animation + await tester.pumpAndSettle(); + + final container = tester.widget(find.byType(Container)); + expect(container.constraints?.minHeight ?? 0, size.height); + expect(container.constraints?.minWidth ?? 0, size.width); + }); + + testWidgets('sets width/height to 0 when highlighted is false', + (tester) async { + const size = Size(50, 20); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: StepValueLine( + activeColor: Colors.black, + borderRadius: Radius.zero, + lineSize: size, + highlighted: false, + isHorizontal: true, + duration: Duration(milliseconds: 100), + ), + ), + ), + ); + + final container = tester.widget(find.byType(Container)); + expect(container.constraints?.minWidth ?? 0, 0); + expect(container.constraints?.minHeight ?? 0, size.height); + }); + + testWidgets('calls onAnimationCompleted when animation finishes', + (tester) async { + bool completed = false; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StepValueLine( + activeColor: Colors.orange, + borderRadius: Radius.zero, + lineSize: const Size(40, 8), + highlighted: true, + isHorizontal: true, + duration: const Duration(milliseconds: 50), + onAnimationCompleted: () { + completed = true; + }, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + expect(completed, isTrue); + }); + + testWidgets('disposes animationController without error', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: StepValueLine( + activeColor: Colors.purple, + borderRadius: Radius.zero, + lineSize: Size(30, 5), + highlighted: true, + isHorizontal: true, + duration: Duration(milliseconds: 10), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + await tester.pumpWidget(Container()); // Unmount widget + // No exceptions should be thrown + }); + }); +} diff --git a/test/src/step_progress_controller_test.dart b/test/src/step_progress_controller_test.dart index 3f60f97..4e42bbe 100644 --- a/test/src/step_progress_controller_test.dart +++ b/test/src/step_progress_controller_test.dart @@ -42,4 +42,78 @@ void main() { expect(notified, true); }); }); + + group('StepProgressController play and pause functions', () { + test('playAnimation is called', () { + final controller = StepProgressController(totalSteps: 3); + bool played = false; + controller.playAnimation = () { + played = true; + }; + controller.playAnimation(); + expect(played, true); + }); + + test('pauseAnimation is called', () { + final controller = StepProgressController(totalSteps: 3); + bool paused = false; + controller.pauseAnimation = () { + paused = true; + }; + controller.pauseAnimation(); + expect(paused, true); + }); + + test('isAnimating returns true when set to true', () { + final controller = StepProgressController(totalSteps: 3) + ..isAnimating = () => true; + expect(controller.isAnimating(), true); + }); + + test('isAnimating returns false when set to false', () { + final controller = StepProgressController(totalSteps: 3) + ..isAnimating = () => false; + expect(controller.isAnimating(), false); + }); + + test('playAnimation and pauseAnimation can be swapped', () { + final controller = StepProgressController(totalSteps: 3); + bool played = false; + bool paused = false; + controller + ..playAnimation = () { + played = true; + } + ..pauseAnimation = () { + paused = true; + }; + controller.playAnimation(); + controller.pauseAnimation(); + expect(played, true); + expect(paused, true); + }); + + test('isAnimating can be dynamically changed', () { + final controller = StepProgressController(totalSteps: 3) + ..isAnimating = () => false; + expect(controller.isAnimating(), false); + controller.isAnimating = () => true; + expect(controller.isAnimating(), true); + }); + + test('playAnimation default does nothing', () { + final controller = StepProgressController(totalSteps: 3); + expect(() => controller.playAnimation(), returnsNormally); + }); + + test('pauseAnimation default does nothing', () { + final controller = StepProgressController(totalSteps: 3); + expect(() => controller.pauseAnimation(), returnsNormally); + }); + + test('isAnimating default returns false', () { + final controller = StepProgressController(totalSteps: 3); + expect(controller.isAnimating(), false); + }); + }); } diff --git a/test/src/step_progress_test.dart b/test/src/step_progress_test.dart index 108d22f..113482d 100644 --- a/test/src/step_progress_test.dart +++ b/test/src/step_progress_test.dart @@ -600,4 +600,137 @@ void main() { ); // Only one text widget should exist }); }); + + group( + 'StepProgress autoStartProgress', + () { + testWidgets('Should rebuild when autoStartProgress is true', + (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: StepProgress( + totalSteps: 3, + currentStep: 0, + autoStartProgress: true, + ), + ), + ), + ); + + // After pump, the widget should auto-advance to step 1 + await tester.pumpAndSettle(); + expect(find.byType(StepNode), findsNWidgets(3)); + }); + + testWidgets('Should not auto-advance if autoStartProgress is false', + (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: StepProgress( + totalSteps: 3, + currentStep: 0, + autoStartProgress: false, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + // The currentStep should remain at 0 + expect(find.byType(StepNode), findsNWidgets(3)); + // Optionally, check that the first node is still the active one + // (implementation detail may vary) + }); + + testWidgets( + 'Should auto-advance only once when autoStartProgress is true', + (tester) async { + int stepChangedCount = 0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StepProgress( + totalSteps: 3, + currentStep: 0, + autoStartProgress: true, + onStepChanged: (_) { + stepChangedCount++; + }, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + expect(stepChangedCount, equals(1)); + }); + + testWidgets('Should not auto-advance if currentStep is last step', + (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: StepProgress( + totalSteps: 3, + currentStep: 2, + autoStartProgress: true, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + // Should not advance beyond last step + expect(find.byType(StepNode), findsNWidgets(3)); + }); + + testWidgets('Should auto-advance to next step if not last step', + (tester) async { + int? changedStep; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StepProgress( + totalSteps: 3, + currentStep: 1, + autoStartProgress: true, + onStepChanged: (step) { + changedStep = step; + }, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + expect(changedStep, equals(2)); + }); + + testWidgets('Should auto-advance with reversed order', (tester) async { + int? changedStep; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StepProgress( + totalSteps: 3, + currentStep: -1, + autoStartProgress: true, + reversed: true, + onStepChanged: (step) { + // in auto start progress, the first step is 1 + changedStep = + step; // the second step triggrered and must be 2 + }, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + expect(changedStep, equals(2)); + }); + }, + ); } diff --git a/test/src/step_progress_widget/horizontal_step_progress_test.dart b/test/src/step_progress_widget/horizontal_step_progress_test.dart index d2172a5..1d620d2 100644 --- a/test/src/step_progress_widget/horizontal_step_progress_test.dart +++ b/test/src/step_progress_widget/horizontal_step_progress_test.dart @@ -503,5 +503,123 @@ void main() { } }, ); + testWidgets( + 'onStepLineAnimationCompleted callback is triggered for each StepLine', + (tester) async { + const int totalSteps = 4; + const int currentStep = 2; + const double stepSize = 50; + const visibilityOptions = StepProgressVisibilityOptions.both; + final List completedIndices = []; + + await tester.pumpWidget( + TestThemeWrapper( + child: Scaffold( + body: HorizontalStepProgress( + totalSteps: totalSteps, + currentStep: currentStep, + stepSize: stepSize, + visibilityOptions: visibilityOptions, + onStepLineAnimationCompleted: ({index = 0}) { + completedIndices.add(index); + }, + needsRebuildWidget: () {}, + ), + ), + ), + ); + + // Simulate triggering the animation completed callback for + // each StepLine. + final stepLineFinder = find.byType(StepLine); + final stepLineWidgets = + tester.widgetList(stepLineFinder).toList(); + + for (var i = 0; i < stepLineWidgets.length; i++) { + // Directly call the callback as StepLine does not animate in tests. + stepLineWidgets[i].onStepLineAnimationCompleted?.call(); + } + + // The callback should be called for each line with correct index. + expect(completedIndices.length, equals(totalSteps - 1)); + expect(completedIndices, containsAll([1, 2, 3])); + }, + ); + + testWidgets( + 'onStepLineAnimationCompleted is not called if not provided', + (tester) async { + const int totalSteps = 3; + const int currentStep = 1; + const double stepSize = 40; + const visibilityOptions = StepProgressVisibilityOptions.both; + + await tester.pumpWidget( + TestThemeWrapper( + child: Scaffold( + body: HorizontalStepProgress( + totalSteps: totalSteps, + currentStep: currentStep, + stepSize: stepSize, + visibilityOptions: visibilityOptions, + needsRebuildWidget: () {}, + ), + ), + ), + ); + + final stepLineFinder = find.byType(StepLine); + final stepLineWidgets = + tester.widgetList(stepLineFinder).toList(); + + for (var i = 0; i < stepLineWidgets.length; i++) { + // Should not throw if callback is null. + expect(() => stepLineWidgets[i].onStepLineAnimationCompleted?.call(), + returnsNormally); + } + }, + ); + + testWidgets( + 'onStepLineAnimationCompleted receives correct index for reversed' + ' HorizontalStepProgress', + (tester) async { + const int totalSteps = 4; + const int currentStep = 2; + const double stepSize = 50; + const visibilityOptions = StepProgressVisibilityOptions.both; + final List completedIndices = []; + + await tester.pumpWidget( + TestThemeWrapper( + child: Scaffold( + body: HorizontalStepProgress( + totalSteps: totalSteps, + currentStep: currentStep, + stepSize: stepSize, + visibilityOptions: visibilityOptions, + reversed: true, + onStepLineAnimationCompleted: ({index = 0}) { + completedIndices.add(index); + }, + needsRebuildWidget: () {}, + ), + ), + ), + ); + + final stepLineFinder = find.byType(StepLine); + final stepLineWidgets = + tester.widgetList(stepLineFinder).toList(); + + for (var i = 0; i < stepLineWidgets.length; i++) { + stepLineWidgets[i].onStepLineAnimationCompleted?.call(); + } + + // Indices should still be [1, 2, 3] even when reversed. + expect(completedIndices.length, equals(totalSteps - 1)); + expect(completedIndices, containsAll([1, 2, 3])); + }, + ); }); } diff --git a/test/src/step_progress_widget/vertical_step_progress_test.dart b/test/src/step_progress_widget/vertical_step_progress_test.dart index b4b5873..43a4f1c 100644 --- a/test/src/step_progress_widget/vertical_step_progress_test.dart +++ b/test/src/step_progress_widget/vertical_step_progress_test.dart @@ -457,5 +457,121 @@ void main() { } }, ); + testWidgets( + 'VerticalStepProgress calls onStepLineAnimationCompleted for ' + 'correct index', (tester) async { + int? completedLineIndex; + final widget = TestThemeWrapper( + child: Scaffold( + body: VerticalStepProgress( + totalSteps: 4, + currentStep: 2, + stepSize: 30, + visibilityOptions: StepProgressVisibilityOptions.both, + nodeTitles: List.generate(4, (i) => 'Step ${i + 1}'), + nodeSubTitles: List.generate(4, (i) => 'Desc ${i + 1}'), + onStepNodeTapped: (_) {}, + onStepLineTapped: (_) {}, + nodeIconBuilder: (step, completedStepIndex) { + return Icon(Icons.circle, key: Key('node_$step')); + }, + onStepLineAnimationCompleted: ({index = 0}) { + completedLineIndex = index; + }, + needsRebuildWidget: () {}, + ), + ), + ); + + await tester.pumpWidget(widget); + + // Find all StepLine widgets + final stepLines = find.byType(StepLine); + final stepLineWidgets = tester.widgetList(stepLines).toList(); + + // Simulate animation completion by calling the callback manually + // (since StepLine is a custom widget, we assume it calls the callback + // when built) We'll trigger the callback for index 1 and verify + (stepLineWidgets[1].onStepLineAnimationCompleted ?? () {})(); + + expect(completedLineIndex, 2); // index passed to callback is index+1 + + // Also test for another index + (stepLineWidgets[0].onStepLineAnimationCompleted ?? () {})(); + expect(completedLineIndex, 1); + }); + + testWidgets( + 'VerticalStepProgress does not call onStepLineAnimationCompleted if not' + ' provided', (tester) async { + final widget = TestThemeWrapper( + child: Scaffold( + body: VerticalStepProgress( + totalSteps: 3, + currentStep: 1, + stepSize: 30, + visibilityOptions: StepProgressVisibilityOptions.both, + nodeTitles: List.generate(3, (i) => 'Step ${i + 1}'), + nodeSubTitles: List.generate(3, (i) => 'Desc ${i + 1}'), + onStepNodeTapped: (_) {}, + onStepLineTapped: (_) {}, + nodeIconBuilder: (step, completedStepIndex) { + return Icon(Icons.circle, key: Key('node_$step')); + }, + needsRebuildWidget: () {}, + ), + ), + ); + + await tester.pumpWidget(widget); + + // Find all StepLine widgets + final stepLines = find.byType(StepLine); + final stepLineWidgets = tester.widgetList(stepLines).toList(); + + // Should not throw if callback is not provided + expect(() { + (stepLineWidgets[0].onStepLineAnimationCompleted ?? () {})(); + }, returnsNormally); + }); + + testWidgets( + 'VerticalStepProgress onStepLineAnimationCompleted is called for' + ' each line', (tester) async { + final calledIndices = []; + final widget = TestThemeWrapper( + child: Scaffold( + body: VerticalStepProgress( + totalSteps: 5, + currentStep: 4, + stepSize: 30, + visibilityOptions: StepProgressVisibilityOptions.both, + nodeTitles: List.generate(5, (i) => 'Step ${i + 1}'), + nodeSubTitles: List.generate(5, (i) => 'Desc ${i + 1}'), + onStepNodeTapped: (_) {}, + onStepLineTapped: (_) {}, + nodeIconBuilder: (step, completedStepIndex) { + return Icon(Icons.circle, key: Key('node_$step')); + }, + onStepLineAnimationCompleted: ({index = 0}) { + calledIndices.add(index); + }, + needsRebuildWidget: () {}, + ), + ), + ); + + await tester.pumpWidget(widget); + + final stepLines = find.byType(StepLine); + final stepLineWidgets = tester.widgetList(stepLines).toList(); + + // Simulate animation completion for all lines + for (var i = 0; i < stepLineWidgets.length; i++) { + (stepLineWidgets[i].onStepLineAnimationCompleted ?? () {})(); + } + + expect(calledIndices, equals([1, 2, 3, 4])); + }); }); }