Added Material steppers. (#5765)
Added a widget that gives fine control over the use of steppers.
At any single moment, one step is shown with it own controls and
content.
diff --git a/packages/flutter/lib/material.dart b/packages/flutter/lib/material.dart
index 7d33f3d..dddf24e 100644
--- a/packages/flutter/lib/material.dart
+++ b/packages/flutter/lib/material.dart
@@ -67,6 +67,7 @@
export 'src/material/shadows.dart';
export 'src/material/slider.dart';
export 'src/material/snack_bar.dart';
+export 'src/material/stepper.dart';
export 'src/material/switch.dart';
export 'src/material/tabs.dart';
export 'src/material/theme.dart';
diff --git a/packages/flutter/lib/src/material/expansion_panel.dart b/packages/flutter/lib/src/material/expansion_panel.dart
index 8bad204..a0b305a 100644
--- a/packages/flutter/lib/src/material/expansion_panel.dart
+++ b/packages/flutter/lib/src/material/expansion_panel.dart
@@ -24,7 +24,7 @@
/// [ExpansionPanel] needs to rebuild.
typedef Widget ExpansionPanelHeaderBuilder(BuildContext context, bool isExpanded);
-/// A Material expansion panel. It has a header and a body and can be either
+/// A material expansion panel. It has a header and a body and can be either
/// expanded or collapsed. The body of the panel is only visible when it is
/// expanded.
///
@@ -63,7 +63,7 @@
final bool isExpanded;
}
-/// A Material expansion panel list that lays out its children and animates
+/// A material expansion panel list that lays out its children and animates
/// expansions.
///
/// See also:
diff --git a/packages/flutter/lib/src/material/stepper.dart b/packages/flutter/lib/src/material/stepper.dart
new file mode 100644
index 0000000..1472c45
--- /dev/null
+++ b/packages/flutter/lib/src/material/stepper.dart
@@ -0,0 +1,679 @@
+// Copyright 2016 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/widgets.dart';
+import 'package:meta/meta.dart';
+
+import 'button.dart';
+import 'colors.dart';
+import 'debug.dart';
+import 'flat_button.dart';
+import 'icon.dart';
+import 'icons.dart';
+import 'ink_well.dart';
+import 'material.dart';
+import 'theme.dart';
+import 'typography.dart';
+
+// TODO(dragostis): Missing functionality:
+// * mobile horizontal mode with adding/removing steps
+// * alternative labeling
+// * stepper feedback in the case of high-latency interactions
+
+/// The state of a [Step] which is used to control the style of the circle and
+/// text.
+///
+/// See also:
+///
+/// * [Step]
+enum StepState {
+ /// A step that displays its index in its circle.
+ indexed,
+ /// A step that displays a pencil icon in its circle.
+ editing,
+ /// A step that displays a tick icon in its circle.
+ complete,
+ /// A step that is disabled and does not to react to taps.
+ disabled,
+ /// A step that is currently having an error. e.g. the use has submitted wrong
+ /// input.
+ error
+}
+
+/// Defines the [Stepper]'s main axis.
+enum StepperType {
+ /// A vertical layout of the steps with their content in-between the titles.
+ vertical,
+ /// A horizontal layout of the steps with their content below the titles.
+ horizontal
+}
+
+const TextStyle _kStepStyle = const TextStyle(
+ fontSize: 12.0,
+ color: Colors.white
+);
+final Color _kErrorLight = Colors.red[500];
+final Color _kErrorDark = Colors.red[400];
+const Color _kCircleActiveLight = Colors.white;
+const Color _kCircleActiveDark = Colors.black87;
+const Color _kDisabledLight = Colors.black38;
+const Color _kDisabledDark = Colors.white30;
+const double _kStepSize = 24.0;
+const double _kTriangleHeight = _kStepSize * 0.866025; // Traingle height. sqrt(3.0) / 2.0
+
+/// A material step used in [Stepper]. The step can have a title and subtitle,
+/// an icon within its circle, some content and a state that governs its
+/// styling.
+///
+/// See also:
+///
+/// * [Stepper]
+/// * <https://material.google.com/components/steppers.html>
+class Step {
+ /// Creates a step for a [Stepper].
+ ///
+ /// The [title], [content], and [state] arguments must not be null.
+ Step({
+ @required this.title,
+ this.subtitle,
+ @required this.content,
+ this.state: StepState.indexed,
+ this.isActive: false
+ }) {
+ assert(this.title != null);
+ assert(this.content != null);
+ assert(this.state != null);
+ }
+
+ /// The title of the step that typically describes it.
+ final Widget title;
+
+ /// The subtitle of the step that appears below the title and has a smaller
+ /// font size. It typically gives more details that complement the title.
+ ///
+ /// If null, the subtitle is not shown.
+ final Widget subtitle;
+
+ /// The content of the step that appears below the [title] and [subtitle].
+ ///
+ /// Below the content, every step has a 'continue' and 'cancel' button.
+ final Widget content;
+
+ /// The state of the step which determines the styling of its componenents
+ /// and whether steps are interactive.
+ final StepState state;
+
+ /// Whether or not the step is active. The flag only influences styling.
+ final bool isActive;
+}
+
+/// A material stepper widget that displays progress through a sequence of
+/// steps. Steppers are particularly useful in the case of forms where one step
+/// requires the completion of another one, or where multiple steps need to be
+/// completed in order to submit the whole form.
+///
+/// The widget is a flexible wrapper. A parent class should pass [currentStep]
+/// to this widget based on some logic triggered by the three callbacks that it
+/// provides.
+///
+/// See also:
+///
+/// * [Step]
+/// * <https://material.google.com/components/steppers.html>
+class Stepper extends StatefulWidget {
+ /// Creates a stepper from a list of steps.
+ ///
+ /// This widget is not meant to be rebuilt with a different list of steps
+ /// unless a key is provided in order to distinguish the old stepper from the
+ /// new one.
+ ///
+ /// The [steps], [type], and [currentStep] arguments must not be null.
+ Stepper({
+ Key key,
+ this.steps,
+ this.type: StepperType.vertical,
+ this.currentStep: 0,
+ this.onStepTapped,
+ this.onStepContinue,
+ this.onStepCancel
+ }) : super(key: key) {
+ assert(this.steps != null);
+ assert(this.type != null);
+ assert(this.currentStep != null);
+ assert(0 <= currentStep && currentStep < this.steps.length);
+ }
+
+ /// The steps of the stepper whose titles, subtitles, icons always get shown.
+ ///
+ /// The length of [steps] must not change.
+ final List<Step> steps;
+
+ /// The type of stepper that determines the layout. In the case of
+ /// [StepperType.horizontal], the content of the current step is displayed
+ /// underneath as opposed to the [StepperType.vertical] case where it is
+ /// displayed in-between.
+ final StepperType type;
+
+ /// The index into [steps] of the current step whose content is displayed.
+ final int currentStep;
+
+ /// The callback called when a step is tapped, with its index passed as
+ /// an argument.
+ final ValueChanged<int> onStepTapped;
+
+ /// The callback called when the 'continue' button is tapped.
+ ///
+ /// If null, the 'continue' button will be disabled.
+ final VoidCallback onStepContinue;
+
+ /// The callback called when the 'cancel' button is tapped.
+ ///
+ /// If null, the 'cancel' button will be disabled.
+ final VoidCallback onStepCancel;
+
+ @override
+ _StepperState createState() => new _StepperState();
+}
+
+class _StepperState extends State<Stepper> {
+ List<GlobalKey> _keys;
+ final Map<int, StepState> _oldStates = new Map<int, StepState>();
+
+ @override
+ void initState() {
+ super.initState();
+ _keys = new List<GlobalKey>.generate(
+ config.steps.length,
+ (int i) => new GlobalKey()
+ );
+
+ for (int i = 0; i < config.steps.length; i += 1)
+ _oldStates[i] = config.steps[i].state;
+ }
+
+ @override
+ void didUpdateConfig(Stepper oldConfig) {
+ super.didUpdateConfig(oldConfig);
+ assert(config.steps.length == oldConfig.steps.length);
+
+ for (int i = 0; i < oldConfig.steps.length; i += 1)
+ _oldStates[i] = oldConfig.steps[i].state;
+ }
+
+ bool _isFirst(int index) {
+ return index == 0;
+ }
+
+ bool _isLast(int index) {
+ return config.steps.length - 1 == index;
+ }
+
+ bool _isCurrent(int index) {
+ return config.currentStep == index;
+ }
+
+ bool _isDark() {
+ return Theme.of(context).brightness == Brightness.dark;
+ }
+
+ Widget _buildLine(bool visible) {
+ return new Container(
+ width: visible ? 1.0 : 0.0,
+ height: 16.0,
+ decoration: new BoxDecoration(
+ backgroundColor: Colors.grey[400]
+ )
+ );
+ }
+
+ Widget _buildCircleChild(int index, bool oldState) {
+ final StepState state = oldState ? _oldStates[index] : config.steps[index].state;
+ final bool isDarkActive = _isDark() && config.steps[index].isActive;
+ assert(state != null);
+ switch (state) {
+ case StepState.indexed:
+ case StepState.disabled:
+ return new Text(
+ '${index + 1}',
+ style: isDarkActive ? _kStepStyle.copyWith(color: Colors.black87) : _kStepStyle
+ );
+ case StepState.editing:
+ return new Icon(
+ Icons.edit,
+ color: isDarkActive ? _kCircleActiveDark : _kCircleActiveLight
+ );
+ case StepState.complete:
+ return new Icon(
+ Icons.check,
+ color: isDarkActive ? _kCircleActiveDark : _kCircleActiveLight
+ );
+ case StepState.error:
+ return new Text('!', style: _kStepStyle);
+ }
+ return null;
+ }
+
+ Color _circleColor(int index) {
+ final ThemeData themeData = Theme.of(context);
+ if (!_isDark()) {
+ return config.steps[index].isActive ? themeData.primaryColor : Colors.black38;
+ } else {
+ return config.steps[index].isActive ? themeData.accentColor : themeData.backgroundColor;
+ }
+ }
+
+ Widget _buildCircle(int index, bool oldState) {
+ return new Container(
+ margin: const EdgeInsets.symmetric(vertical: 8.0),
+ width: _kStepSize,
+ height: _kStepSize,
+ child: new AnimatedContainer(
+ curve: Curves.fastOutSlowIn,
+ duration: kThemeAnimationDuration,
+ decoration: new BoxDecoration(
+ backgroundColor: _circleColor(index),
+ shape: BoxShape.circle
+ ),
+ child: new Center(
+ child: _buildCircleChild(index, oldState && config.steps[index].state == StepState.error)
+ )
+ )
+ );
+ }
+
+ Widget _buildTriangle(int index, bool oldState) {
+ return new Container(
+ margin: const EdgeInsets.symmetric(vertical: 8.0),
+ width: _kStepSize,
+ height: _kStepSize,
+ child: new Center(
+ child: new SizedBox(
+ width: _kStepSize,
+ height: _kTriangleHeight, // Height of 24dp-long-sided equilateral triangle.
+ child: new CustomPaint(
+ painter: new _TrianglePainter(
+ color: _isDark() ? _kErrorDark : _kErrorLight
+ ),
+ child: new Align(
+ alignment: const FractionalOffset(0.5, 0.9), // 0.9 looks better than the geometrical 0.66.
+ child: _buildCircleChild(index, oldState && config.steps[index].state != StepState.error)
+ )
+ )
+ )
+ )
+ );
+ }
+
+ Widget _buildIcon(int index) {
+ if (config.steps[index].state != _oldStates[index]) {
+ return new AnimatedCrossFade(
+ firstChild: _buildCircle(index, true),
+ secondChild: _buildTriangle(index, true),
+ firstCurve: new Interval(0.0, 0.6, curve: Curves.fastOutSlowIn),
+ secondCurve: new Interval(0.4, 1.0, curve: Curves.fastOutSlowIn),
+ sizeCurve: Curves.fastOutSlowIn,
+ crossFadeState: config.steps[index].state == StepState.error ? CrossFadeState.showSecond : CrossFadeState.showFirst,
+ duration: kThemeAnimationDuration,
+ );
+ } else {
+ if (config.steps[index].state != StepState.error)
+ return _buildCircle(index, false);
+ else
+ return _buildTriangle(index, false);
+ }
+ }
+
+ Widget _buildVerticalControls() {
+ Color cancelColor;
+
+ switch (Theme.of(context).brightness) {
+ case Brightness.light:
+ cancelColor = Colors.black54;
+ break;
+ case Brightness.dark:
+ cancelColor = Colors.white70;
+ break;
+ }
+
+ assert(cancelColor != null);
+
+ final ThemeData themeData = Theme.of(context);
+
+ return new Container(
+ margin: const EdgeInsets.only(top: 16.0),
+ child: new ConstrainedBox(
+ constraints: const BoxConstraints.tightFor(height: 48.0),
+ child: new Row(
+ children: <Widget>[
+ new FlatButton(
+ onPressed: config.onStepContinue,
+ color: _isDark() ? themeData.backgroundColor : themeData.primaryColor,
+ textColor: Colors.white,
+ textTheme: ButtonTextTheme.normal,
+ child: new Text('CONTINUE')
+ ),
+ new Container(
+ margin: const EdgeInsets.only(left: 8.0),
+ child: new FlatButton(
+ onPressed: config.onStepCancel,
+ textColor: cancelColor,
+ textTheme: ButtonTextTheme.normal,
+ child: new Text('CANCEL')
+ )
+ )
+ ]
+ )
+ )
+ );
+ }
+
+ TextStyle _titleStyle(int index) {
+ final ThemeData themeData = Theme.of(context);
+ final TextTheme textTheme = themeData.textTheme;
+
+ assert(config.steps[index].state != null);
+ switch (config.steps[index].state) {
+ case StepState.indexed:
+ case StepState.editing:
+ case StepState.complete:
+ return textTheme.body2;
+ case StepState.disabled:
+ return textTheme.body2.copyWith(
+ color: _isDark() ? _kDisabledDark : _kDisabledLight
+ );
+ case StepState.error:
+ return textTheme.body2.copyWith(
+ color: _isDark() ? _kErrorDark : _kErrorLight
+ );
+ }
+ return null;
+ }
+
+ TextStyle _subtitleStyle(int index) {
+ final ThemeData themeData = Theme.of(context);
+ final TextTheme textTheme = themeData.textTheme;
+
+ assert(config.steps[index].state != null);
+ switch (config.steps[index].state) {
+ case StepState.indexed:
+ case StepState.editing:
+ case StepState.complete:
+ return textTheme.caption;
+ case StepState.disabled:
+ return textTheme.caption.copyWith(
+ color: _isDark() ? _kDisabledDark : _kDisabledLight
+ );
+ case StepState.error:
+ return textTheme.caption.copyWith(
+ color: _isDark() ? _kErrorDark : _kErrorLight
+ );
+ }
+ return null;
+ }
+
+ Widget _buildHeaderText(int index) {
+ final List<Widget> children = <Widget>[
+ new AnimatedDefaultTextStyle(
+ style: _titleStyle(index),
+ duration: kThemeAnimationDuration,
+ curve: Curves.fastOutSlowIn,
+ child: config.steps[index].title
+ )
+ ];
+
+ if (config.steps[index].subtitle != null)
+ children.add(
+ new Container(
+ margin: const EdgeInsets.only(top: 2.0),
+ child: new AnimatedDefaultTextStyle(
+ style: _subtitleStyle(index),
+ duration: kThemeAnimationDuration,
+ curve: Curves.fastOutSlowIn,
+ child: config.steps[index].subtitle
+ )
+ )
+ );
+
+ return new Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisSize: MainAxisSize.min,
+ children: children
+ );
+ }
+
+ Widget _buildVerticalHeader(int index) {
+ return new Container(
+ margin: const EdgeInsets.symmetric(horizontal: 24.0),
+ child: new Row(
+ children: <Widget>[
+ new Column(
+ children: <Widget>[
+ // Line parts are always added in order for the ink splash to
+ // flood the tips of the connector lines.
+ _buildLine(!_isFirst(index)),
+ _buildIcon(index),
+ _buildLine(!_isLast(index)),
+ ]
+ ),
+ new Container(
+ margin: const EdgeInsets.only(
+ left: 12.0
+ ),
+ child: _buildHeaderText(index)
+ )
+ ]
+ )
+ );
+ }
+
+ Widget _buildVerticalBody(int index) {
+ return new Stack(
+ children: <Widget>[
+ new Positioned(
+ left: 24.0,
+ top: 0.0,
+ bottom: 0.0,
+ child: new SizedBox(
+ width: 24.0,
+ child: new Center(
+ child: new SizedBox(
+ width: _isLast(index) ? 0.0 : 1.0,
+ child: new Container(
+ decoration: new BoxDecoration(
+ backgroundColor: Colors.grey[400]
+ )
+ )
+ )
+ )
+ )
+ ),
+ new AnimatedCrossFade(
+ firstChild: new Container(height: 0.0),
+ secondChild: new Container(
+ margin: const EdgeInsets.only(
+ left: 60.0,
+ right: 24.0,
+ bottom: 24.0
+ ),
+ child: new Column(
+ children: <Widget>[
+ config.steps[index].content,
+ _buildVerticalControls()
+ ]
+ )
+ ),
+ firstCurve: new Interval(0.0, 0.6, curve: Curves.fastOutSlowIn),
+ secondCurve: new Interval(0.4, 1.0, curve: Curves.fastOutSlowIn),
+ sizeCurve: Curves.fastOutSlowIn,
+ crossFadeState: _isCurrent(index) ? CrossFadeState.showSecond : CrossFadeState.showFirst,
+ duration: kThemeAnimationDuration,
+ )
+ ]
+ );
+ }
+
+ Widget _buildVertical() {
+ List<Widget> children = <Widget>[];
+
+ for (int i = 0; i < config.steps.length; i += 1) {
+ children.add(
+ new Column(
+ key: _keys[i],
+ children: <Widget>[
+ new InkWell(
+ onTap: config.steps[i].state != StepState.disabled ? () {
+ // In the vertical case we need to scroll to the newly tapped
+ // step.
+ Scrollable.ensureVisible(
+ _keys[i].currentContext,
+ curve: Curves.fastOutSlowIn,
+ duration: kThemeAnimationDuration
+ );
+
+ if (config.onStepTapped != null)
+ config.onStepTapped(i);
+ } : null,
+ child: _buildVerticalHeader(i)
+ ),
+ _buildVerticalBody(i)
+ ]
+ )
+ );
+ }
+
+ return new Block(
+ children: children
+ );
+ }
+
+ Widget _buildHorizontal() {
+ final List<Widget> children = <Widget>[];
+
+ for (int i = 0; i < config.steps.length; i += 1) {
+ children.add(
+ new InkResponse(
+ onTap: config.steps[i].state != StepState.disabled ? () {
+ if (config.onStepTapped != null)
+ config.onStepTapped(i);
+ } : null,
+ child: new Row(
+ children: <Widget>[
+ new Container(
+ height: 72.0,
+ child: new Center(
+ child: _buildIcon(i)
+ )
+ ),
+ new Container(
+ margin: const EdgeInsets.only(left: 12.0),
+ child: _buildHeaderText(i)
+ )
+ ]
+ )
+ )
+ );
+
+ if (!_isLast(i))
+ children.add(
+ new Flexible(
+ child: new Container(
+ margin: const EdgeInsets.symmetric(horizontal: 8.0),
+ height: 1.0,
+ decoration: new BoxDecoration(
+ backgroundColor: Colors.grey[400]
+ )
+ )
+ )
+ );
+ }
+
+ return new Column(
+ children: <Widget>[
+ new Material(
+ elevation: 2,
+ child: new Container(
+ margin: const EdgeInsets.symmetric(horizontal: 24.0),
+ child: new Row(
+ children: children
+ )
+ )
+ ),
+ new Flexible(
+ child: new ScrollableViewport(
+ child: new Container(
+ margin: const EdgeInsets.all(24.0),
+ child: new Column(
+ children: <Widget>[
+ new AnimatedSize(
+ curve: Curves.fastOutSlowIn,
+ duration: kThemeAnimationDuration,
+ child: config.steps[config.currentStep].content
+ ),
+ _buildVerticalControls()
+ ]
+ )
+ )
+ )
+ )
+ ]
+ );
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ assert(debugCheckHasMaterial(context));
+ assert(() {
+ if (context.ancestorWidgetOfExactType(Stepper) != null)
+ throw new FlutterError(
+ 'Steppers must not be nested. The material specification advises '
+ 'that one should avoid embedding steppers within steppers. '
+ 'https://material.google.com/components/steppers.html#steppers-usage\n'
+ );
+ return true;
+ });
+ assert(config.type != null);
+ switch (config.type) {
+ case StepperType.vertical:
+ return _buildVertical();
+ case StepperType.horizontal:
+ return _buildHorizontal();
+ }
+ return null;
+ }
+}
+
+// Paints a triangle whose base is the bottom of the bounding rectangle and its
+// top vertex the middle of its top.
+class _TrianglePainter extends CustomPainter {
+ _TrianglePainter({
+ this.color
+ });
+
+ final Color color;
+
+ @override
+ bool hitTest(Point point) => true; // Hitting the rectangle is fine enough.
+
+ @override
+ bool shouldRepaint(_TrianglePainter oldPainter) {
+ return oldPainter.color != color;
+ }
+
+ @override
+ void paint(Canvas canvas, Size size) {
+ final double base = size.width;
+ final double halfBase = size.width / 2.0;
+ final double height = size.height;
+ final List<Point> points = <Point>[
+ new Point(0.0, height),
+ new Point(base, height),
+ new Point(halfBase, 0.0)
+ ];
+
+ canvas.drawPath(
+ new Path()..addPolygon(points, true),
+ new Paint()..color = color
+ );
+ }
+}
diff --git a/packages/flutter/test/material/stepper_test.dart b/packages/flutter/test/material/stepper_test.dart
new file mode 100644
index 0000000..2819c12
--- /dev/null
+++ b/packages/flutter/test/material/stepper_test.dart
@@ -0,0 +1,372 @@
+// Copyright 2016 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+ testWidgets('Stepper tap callback test', (WidgetTester tester) async {
+ int index = 0;
+
+ await tester.pumpWidget(
+ new Material(
+ child: new Stepper(
+ onStepTapped: (int i) {
+ index = i;
+ },
+ steps: <Step>[
+ new Step(
+ title: new Text('Step 1'),
+ content: new SizedBox(
+ width: 100.0,
+ height: 100.0
+ )
+ ),
+ new Step(
+ title: new Text('Step 2'),
+ content: new SizedBox(
+ width: 100.0,
+ height: 100.0
+ )
+ )
+ ]
+ )
+ )
+ );
+
+ await tester.tap(find.text('Step 2'));
+ expect(index, 1);
+ });
+
+ testWidgets('Stepper expansion test', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ new Center(
+ child: new Material(
+ child: new Stepper(
+ steps: <Step>[
+ new Step(
+ title: new Text('Step 1'),
+ content: new SizedBox(
+ width: 100.0,
+ height: 100.0
+ )
+ ),
+ new Step(
+ title: new Text('Step 2'),
+ content: new SizedBox(
+ width: 200.0,
+ height: 200.0
+ )
+ )
+ ]
+ )
+ )
+ )
+ );
+
+ RenderBox box = tester.renderObject(find.byType(Stepper));
+ expect(box.size.height, 332.0);
+
+ await tester.pumpWidget(
+ new Center(
+ child: new Material(
+ child: new Stepper(
+ currentStep: 1,
+ steps: <Step>[
+ new Step(
+ title: new Text('Step 1'),
+ content: new SizedBox(
+ width: 100.0,
+ height: 100.0
+ )
+ ),
+ new Step(
+ title: new Text('Step 2'),
+ content: new SizedBox(
+ width: 200.0,
+ height: 200.0
+ )
+ )
+ ]
+ )
+ )
+ )
+ );
+
+ await tester.pump(const Duration(milliseconds: 100));
+ box = tester.renderObject(find.byType(Stepper));
+ expect(box.size.height, greaterThan(332.0));
+ await tester.pump(const Duration(milliseconds: 100));
+ box = tester.renderObject(find.byType(Stepper));
+ expect(box.size.height, 432.0);
+ });
+
+ testWidgets('Stepper horizontal size test', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ new Center(
+ child: new Material(
+ child: new Stepper(
+ type: StepperType.horizontal,
+ steps: <Step>[
+ new Step(
+ title: new Text('Step 1'),
+ content: new SizedBox(
+ width: 100.0,
+ height: 100.0
+ )
+ )
+ ]
+ )
+ )
+ )
+ );
+
+ RenderBox box = tester.renderObject(find.byType(Stepper));
+ expect(box.size.height, 600.0);
+ });
+
+ testWidgets('Stepper visibility test', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ new Material(
+ child: new Stepper(
+ type: StepperType.horizontal,
+ steps: <Step>[
+ new Step(
+ title: new Text('Step 1'),
+ content: new Text('A')
+ ),
+ new Step(
+ title: new Text('Step 2'),
+ content: new Text('B')
+ )
+ ]
+ )
+ )
+ );
+
+ expect(find.text('A'), findsOneWidget);
+ expect(find.text('B'), findsNothing);
+
+ await tester.pumpWidget(
+ new Material(
+ child: new Stepper(
+ currentStep: 1,
+ type: StepperType.horizontal,
+ steps: <Step>[
+ new Step(
+ title: new Text('Step 1'),
+ content: new Text('A')
+ ),
+ new Step(
+ title: new Text('Step 2'),
+ content: new Text('B')
+ )
+ ]
+ )
+ )
+ );
+
+ expect(find.text('A'), findsNothing);
+ expect(find.text('B'), findsOneWidget);
+ });
+
+ testWidgets('Stepper button test', (WidgetTester tester) async {
+ bool continuePressed = false;
+ bool cancelPressed = false;
+
+ await tester.pumpWidget(
+ new Material(
+ child: new Stepper(
+ type: StepperType.horizontal,
+ onStepContinue: () {
+ continuePressed = true;
+ },
+ onStepCancel: () {
+ cancelPressed = true;
+ },
+ steps: <Step>[
+ new Step(
+ title: new Text('Step 1'),
+ content: new SizedBox(
+ width: 100.0,
+ height: 100.0
+ )
+ ),
+ new Step(
+ title: new Text('Step 2'),
+ content: new SizedBox(
+ width: 200.0,
+ height: 200.0
+ )
+ )
+ ]
+ )
+ )
+ );
+
+ await tester.tap(find.text('CONTINUE'));
+ await tester.tap(find.text('CANCEL'));
+
+ expect(continuePressed, isTrue);
+ expect(cancelPressed, isTrue);
+ });
+
+ testWidgets('Stepper disabled step test', (WidgetTester tester) async {
+ int index = 0;
+
+ await tester.pumpWidget(
+ new Material(
+ child: new Stepper(
+ onStepTapped: (int i) {
+ index = i;
+ },
+ steps: <Step>[
+ new Step(
+ title: new Text('Step 1'),
+ content: new SizedBox(
+ width: 100.0,
+ height: 100.0
+ )
+ ),
+ new Step(
+ title: new Text('Step 2'),
+ state: StepState.disabled,
+ content: new SizedBox(
+ width: 100.0,
+ height: 100.0
+ )
+ )
+ ]
+ )
+ )
+ );
+
+ await tester.tap(find.text('Step 2'));
+ expect(index, 0);
+ });
+
+ testWidgets('Stepper scroll test', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ new Material(
+ child: new Stepper(
+ steps: <Step>[
+ new Step(
+ title: new Text('Step 1'),
+ content: new SizedBox(
+ width: 100.0,
+ height: 300.0
+ )
+ ),
+ new Step(
+ title: new Text('Step 2'),
+ content: new SizedBox(
+ width: 100.0,
+ height: 300.0
+ )
+ ),
+ new Step(
+ title: new Text('Step 3'),
+ content: new SizedBox(
+ width: 100.0,
+ height: 100.0
+ )
+ ),
+ ]
+ )
+ )
+ );
+
+ ScrollableState scrollableState = tester.firstState(find.byType(Scrollable));
+ expect(scrollableState.scrollOffset, 0.0);
+
+ await tester.tap(find.text('Step 3'));
+ await tester.pumpWidget(
+ new Material(
+ child: new Stepper(
+ currentStep: 2,
+ steps: <Step>[
+ new Step(
+ title: new Text('Step 1'),
+ content: new SizedBox(
+ width: 100.0,
+ height: 300.0
+ )
+ ),
+ new Step(
+ title: new Text('Step 2'),
+ content: new SizedBox(
+ width: 100.0,
+ height: 300.0
+ )
+ ),
+ new Step(
+ title: new Text('Step 3'),
+ content: new SizedBox(
+ width: 100.0,
+ height: 100.0
+ )
+ ),
+ ]
+ )
+ )
+ );
+
+ await tester.pump(new Duration(milliseconds: 100));
+ expect(scrollableState.scrollOffset, greaterThan(0.0));
+ });
+
+ testWidgets('Stepper index test', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ new Center(
+ child: new Material(
+ child: new Stepper(
+ steps: <Step>[
+ new Step(
+ title: new Text('A'),
+ state: StepState.complete,
+ content: new SizedBox(
+ width: 100.0,
+ height: 100.0
+ )
+ ),
+ new Step(
+ title: new Text('B'),
+ content: new SizedBox(
+ width: 100.0,
+ height: 100.0
+ )
+ )
+ ]
+ )
+ )
+ )
+ );
+
+ expect(find.text('1'), findsNothing);
+ expect(find.text('2'), findsOneWidget);
+ });
+
+ testWidgets('Stepper error test', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ new Center(
+ child: new Material(
+ child: new Stepper(
+ steps: <Step>[
+ new Step(
+ title: new Text('A'),
+ state: StepState.error,
+ content: new SizedBox(
+ width: 100.0,
+ height: 100.0
+ )
+ )
+ ]
+ )
+ )
+ )
+ );
+
+ expect(find.text('!'), findsOneWidget);
+ });
+}