stepper.dart 26 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4 5 6
// 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';

7 8
import 'button_style.dart';
import 'color_scheme.dart';
9 10 11 12 13
import 'colors.dart';
import 'debug.dart';
import 'icons.dart';
import 'ink_well.dart';
import 'material.dart';
14
import 'material_localizations.dart';
15 16
import 'material_state.dart';
import 'text_button.dart';
17
import 'text_theme.dart';
18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
import 'theme.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,
34

35 36
  /// A step that displays a pencil icon in its circle.
  editing,
37

38 39
  /// A step that displays a tick icon in its circle.
  complete,
40

41 42
  /// A step that is disabled and does not to react to taps.
  disabled,
43

44
  /// A step that is currently having an error. e.g. the user has submitted wrong
45
  /// input.
46
  error,
47 48 49 50 51 52
}

/// Defines the [Stepper]'s main axis.
enum StepperType {
  /// A vertical layout of the steps with their content in-between the titles.
  vertical,
53

54
  /// A horizontal layout of the steps with their content below the titles.
55
  horizontal,
56 57
}

58
/// Container for all the information necessary to build a Stepper widget's
59
/// forward and backward controls for any given step.
60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108
///
/// Used by [Stepper.controlsBuilder].
@immutable
class ControlsDetails {
  /// Creates a set of details describing the Stepper.
  const ControlsDetails({
    required this.currentStep,
    required this.stepIndex,
    this.onStepCancel,
    this.onStepContinue,
  });
  /// Index that is active for the surrounding [Stepper] widget. This may be
  /// different from [stepIndex] if the user has just changed steps and we are
  /// currently animating toward that step.
  final int currentStep;

  /// Index of the step for which these controls are being built. This is
  /// not necessarily the active index, if the user has just changed steps and
  /// this step is animating away. To determine whether a given builder is building
  /// the active step or the step being navigated away from, see [isActive].
  final int stepIndex;

  /// 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;

  /// True if the indicated step is also the current active step. If the user has
  /// just activated the transition to a new step, some [Stepper.type] values will
  /// lead to both steps being rendered for the duration of the animation shifting
  /// between steps.
  bool get isActive => currentStep == stepIndex;
}

/// A builder that creates a widget given the two callbacks `onStepContinue` and
/// `onStepCancel`.
///
/// Used by [Stepper.controlsBuilder].
///
/// See also:
///
///  * [WidgetBuilder], which is similar but only takes a [BuildContext].
typedef ControlsWidgetBuilder = Widget Function(BuildContext context, ControlsDetails details);

109
const TextStyle _kStepStyle = TextStyle(
110
  fontSize: 12.0,
111
  color: Colors.white,
112
);
113
const Color _kErrorLight = Colors.red;
114
final Color _kErrorDark = Colors.red.shade400;
115 116 117
const Color _kCircleActiveLight = Colors.white;
const Color _kCircleActiveDark = Colors.black87;
const Color _kDisabledLight = Colors.black38;
118
const Color _kDisabledDark = Colors.white38;
119
const double _kStepSize = 24.0;
Josh Soref's avatar
Josh Soref committed
120
const double _kTriangleHeight = _kStepSize * 0.866025; // Triangle height. sqrt(3.0) / 2.0
121 122 123 124 125 126 127 128

/// 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]
129
///  * <https://material.io/archive/guidelines/components/steppers.html>
130
@immutable
131 132 133 134
class Step {
  /// Creates a step for a [Stepper].
  ///
  /// The [title], [content], and [state] arguments must not be null.
135
  const Step({
136
    required this.title,
137
    this.subtitle,
138
    required this.content,
139 140
    this.state = StepState.indexed,
    this.isActive = false,
141 142 143
  }) : assert(title != null),
       assert(content != null),
       assert(state != null);
144 145 146 147 148 149 150 151

  /// 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.
152
  final Widget? subtitle;
153 154 155 156 157 158

  /// 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;

159
  /// The state of the step which determines the styling of its components
160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175
  /// 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.
///
176 177 178
/// {@tool dartpad}
/// An example the shows how to use the [Stepper], and the [Stepper] UI
/// appearance.
179
///
180
/// ** See code in examples/api/lib/material/stepper/stepper.0.dart **
181 182
/// {@end-tool}
///
183 184 185
/// See also:
///
///  * [Step]
186
///  * <https://material.io/archive/guidelines/components/steppers.html>
187 188 189 190 191 192 193 194
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.
195
  const Stepper({
196
    super.key,
197
    required this.steps,
198
    this.physics,
199 200
    this.type = StepperType.vertical,
    this.currentStep = 0,
201 202
    this.onStepTapped,
    this.onStepContinue,
203
    this.onStepCancel,
204
    this.controlsBuilder,
205
    this.elevation,
206
    this.margin,
207 208 209
  }) : assert(steps != null),
       assert(type != null),
       assert(currentStep != null),
210
       assert(0 <= currentStep && currentStep < steps.length);
211 212 213 214 215 216

  /// The steps of the stepper whose titles, subtitles, icons always get shown.
  ///
  /// The length of [steps] must not change.
  final List<Step> steps;

217 218 219 220 221 222 223
  /// How the stepper's scroll view should respond to user input.
  ///
  /// For example, determines how the scroll view continues to
  /// animate after the user stops dragging the scroll view.
  ///
  /// If the stepper is contained within another scrollable it
  /// can be helpful to set this property to [ClampingScrollPhysics].
224
  final ScrollPhysics? physics;
225

226 227 228 229 230 231 232 233 234 235 236
  /// 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.
237
  final ValueChanged<int>? onStepTapped;
238 239 240 241

  /// The callback called when the 'continue' button is tapped.
  ///
  /// If null, the 'continue' button will be disabled.
242
  final VoidCallback? onStepContinue;
243 244 245 246

  /// The callback called when the 'cancel' button is tapped.
  ///
  /// If null, the 'cancel' button will be disabled.
247
  final VoidCallback? onStepCancel;
248

249 250 251 252
  /// The callback for creating custom controls.
  ///
  /// If null, the default controls from the current theme will be used.
  ///
253 254 255 256 257
  /// This callback which takes in a context and a [ControlsDetails] object, which
  /// contains step information and two functions: [onStepContinue] and [onStepCancel].
  /// These can be used to control the stepper. For example, reading the
  /// [ControlsDetails.currentStep] value within the callback can change the text
  /// of the continue or cancel button depending on which step users are at.
258
  ///
259
  /// {@tool dartpad}
260 261
  /// Creates a stepper control with custom buttons.
  ///
262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298
  /// ```dart
  /// Widget build(BuildContext context) {
  ///   return Stepper(
  ///     controlsBuilder:
  ///       (BuildContext context, ControlsDetails details) {
  ///          return Row(
  ///            children: <Widget>[
  ///              TextButton(
  ///                onPressed: details.onStepContinue,
  ///                child: Text('Continue to Step ${details.stepIndex + 1}'),
  ///              ),
  ///              TextButton(
  ///                onPressed: details.onStepCancel,
  ///                child: Text('Back to Step ${details.stepIndex - 1}'),
  ///              ),
  ///            ],
  ///          );
  ///       },
  ///     steps: const <Step>[
  ///       Step(
  ///         title: Text('A'),
  ///         content: SizedBox(
  ///           width: 100.0,
  ///           height: 100.0,
  ///         ),
  ///       ),
  ///       Step(
  ///         title: Text('B'),
  ///         content: SizedBox(
  ///           width: 100.0,
  ///           height: 100.0,
  ///         ),
  ///       ),
  ///     ],
  ///   );
  /// }
  /// ```
299
  /// ** See code in examples/api/lib/material/stepper/stepper.controls_builder.0.dart **
300
  /// {@end-tool}
301
  final ControlsWidgetBuilder? controlsBuilder;
302

303 304 305
  /// The elevation of this stepper's [Material] when [type] is [StepperType.horizontal].
  final double? elevation;

306 307 308
  /// custom margin on vertical stepper.
  final EdgeInsetsGeometry? margin;

309
  @override
310
  State<Stepper> createState() => _StepperState();
311 312
}

313
class _StepperState extends State<Stepper> with TickerProviderStateMixin {
314
  late List<GlobalKey> _keys;
315
  final Map<int, StepState> _oldStates = <int, StepState>{};
316 317 318 319

  @override
  void initState() {
    super.initState();
320
    _keys = List<GlobalKey>.generate(
321
      widget.steps.length,
322
      (int i) => GlobalKey(),
323 324
    );

325
    for (int i = 0; i < widget.steps.length; i += 1) {
326
      _oldStates[i] = widget.steps[i].state;
327
    }
328 329 330
  }

  @override
331 332 333
  void didUpdateWidget(Stepper oldWidget) {
    super.didUpdateWidget(oldWidget);
    assert(widget.steps.length == oldWidget.steps.length);
334

335
    for (int i = 0; i < oldWidget.steps.length; i += 1) {
336
      _oldStates[i] = oldWidget.steps[i].state;
337
    }
338 339 340 341 342 343 344
  }

  bool _isFirst(int index) {
    return index == 0;
  }

  bool _isLast(int index) {
345
    return widget.steps.length - 1 == index;
346 347 348
  }

  bool _isCurrent(int index) {
349
    return widget.currentStep == index;
350 351 352
  }

  bool _isDark() {
353
    return Theme.of(context).brightness == Brightness.dark;
354 355 356
  }

  Widget _buildLine(bool visible) {
357
    return Container(
358 359
      width: visible ? 1.0 : 0.0,
      height: 16.0,
360
      color: Colors.grey.shade400,
361 362 363 364
    );
  }

  Widget _buildCircleChild(int index, bool oldState) {
365
    final StepState state = oldState ? _oldStates[index]! : widget.steps[index].state;
366
    final bool isDarkActive = _isDark() && widget.steps[index].isActive;
367 368 369 370
    assert(state != null);
    switch (state) {
      case StepState.indexed:
      case StepState.disabled:
371
        return Text(
372
          '${index + 1}',
373
          style: isDarkActive ? _kStepStyle.copyWith(color: Colors.black87) : _kStepStyle,
374 375
        );
      case StepState.editing:
376
        return Icon(
377
          Icons.edit,
378
          color: isDarkActive ? _kCircleActiveDark : _kCircleActiveLight,
379
          size: 18.0,
380 381
        );
      case StepState.complete:
382
        return Icon(
383
          Icons.check,
384
          color: isDarkActive ? _kCircleActiveDark : _kCircleActiveLight,
385
          size: 18.0,
386 387
        );
      case StepState.error:
388
        return const Text('!', style: _kStepStyle);
389 390 391 392
    }
  }

  Color _circleColor(int index) {
393
    final ColorScheme colorScheme = Theme.of(context).colorScheme;
394
    if (!_isDark()) {
395
      return widget.steps[index].isActive ? colorScheme.primary : colorScheme.onSurface.withOpacity(0.38);
396
    } else {
397
      return widget.steps[index].isActive ? colorScheme.secondary : colorScheme.background;
398 399 400 401
    }
  }

  Widget _buildCircle(int index, bool oldState) {
402
    return Container(
403 404 405
      margin: const EdgeInsets.symmetric(vertical: 8.0),
      width: _kStepSize,
      height: _kStepSize,
406
      child: AnimatedContainer(
407 408
        curve: Curves.fastOutSlowIn,
        duration: kThemeAnimationDuration,
409
        decoration: BoxDecoration(
410
          color: _circleColor(index),
411
          shape: BoxShape.circle,
412
        ),
413
        child: Center(
414
          child: _buildCircleChild(index, oldState && widget.steps[index].state == StepState.error),
415 416
        ),
      ),
417 418 419 420
    );
  }

  Widget _buildTriangle(int index, bool oldState) {
421
    return Container(
422 423 424
      margin: const EdgeInsets.symmetric(vertical: 8.0),
      width: _kStepSize,
      height: _kStepSize,
425 426
      child: Center(
        child: SizedBox(
427 428
          width: _kStepSize,
          height: _kTriangleHeight, // Height of 24dp-long-sided equilateral triangle.
429 430
          child: CustomPaint(
            painter: _TrianglePainter(
431
              color: _isDark() ? _kErrorDark : _kErrorLight,
432
            ),
433
            child: Align(
434
              alignment: const Alignment(0.0, 0.8), // 0.8 looks better than the geometrical 0.33.
435
              child: _buildCircleChild(index, oldState && widget.steps[index].state != StepState.error),
436 437 438 439
            ),
          ),
        ),
      ),
440 441 442 443
    );
  }

  Widget _buildIcon(int index) {
444
    if (widget.steps[index].state != _oldStates[index]) {
445
      return AnimatedCrossFade(
446 447
        firstChild: _buildCircle(index, true),
        secondChild: _buildTriangle(index, true),
448 449
        firstCurve: const Interval(0.0, 0.6, curve: Curves.fastOutSlowIn),
        secondCurve: const Interval(0.4, 1.0, curve: Curves.fastOutSlowIn),
450
        sizeCurve: Curves.fastOutSlowIn,
451
        crossFadeState: widget.steps[index].state == StepState.error ? CrossFadeState.showSecond : CrossFadeState.showFirst,
452 453 454
        duration: kThemeAnimationDuration,
      );
    } else {
455
      if (widget.steps[index].state != StepState.error) {
456
        return _buildCircle(index, false);
457
      } else {
458
        return _buildTriangle(index, false);
459
      }
460 461 462
    }
  }

463
  Widget _buildVerticalControls(int stepIndex) {
464
    if (widget.controlsBuilder != null) {
465 466 467 468 469 470 471 472 473
      return widget.controlsBuilder!(
        context,
        ControlsDetails(
          currentStep: widget.currentStep,
          onStepContinue: widget.onStepContinue,
          onStepCancel: widget.onStepCancel,
          stepIndex: stepIndex,
        ),
      );
474
    }
475

476
    final Color cancelColor;
477
    switch (Theme.of(context).brightness) {
478 479 480 481 482 483 484 485
      case Brightness.light:
        cancelColor = Colors.black54;
        break;
      case Brightness.dark:
        cancelColor = Colors.white70;
        break;
    }

486
    final ThemeData themeData = Theme.of(context);
487
    final ColorScheme colorScheme = themeData.colorScheme;
488
    final MaterialLocalizations localizations = MaterialLocalizations.of(context);
489

490 491 492
    const OutlinedBorder buttonShape = RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(2)));
    const EdgeInsets buttonPadding = EdgeInsets.symmetric(horizontal: 16.0);

493
    return Container(
494
      margin: const EdgeInsets.only(top: 16.0),
495
      child: ConstrainedBox(
496
        constraints: const BoxConstraints.tightFor(height: 48.0),
497
        child: Row(
498 499 500
          // The Material spec no longer includes a Stepper widget. The continue
          // and cancel button styles have been configured to match the original
          // version of this widget.
501
          children: <Widget>[
502
            TextButton(
503
              onPressed: widget.onStepContinue,
504
              style: ButtonStyle(
505
                foregroundColor: MaterialStateProperty.resolveWith<Color?>((Set<MaterialState> states) {
506 507
                  return states.contains(MaterialState.disabled) ? null : (_isDark() ? colorScheme.onSurface : colorScheme.onPrimary);
                }),
508
                backgroundColor: MaterialStateProperty.resolveWith<Color?>((Set<MaterialState> states) {
509 510
                  return _isDark() || states.contains(MaterialState.disabled) ? null : colorScheme.primary;
                }),
511 512
                padding: const MaterialStatePropertyAll<EdgeInsetsGeometry>(buttonPadding),
                shape: const MaterialStatePropertyAll<OutlinedBorder>(buttonShape),
513
              ),
514
              child: Text(localizations.continueButtonLabel),
515
            ),
516
            Container(
517
              margin: const EdgeInsetsDirectional.only(start: 8.0),
518
              child: TextButton(
519
                onPressed: widget.onStepCancel,
520 521 522 523 524
                style: TextButton.styleFrom(
                  primary: cancelColor,
                  padding: buttonPadding,
                  shape: buttonShape,
                ),
525
                child: Text(localizations.cancelButtonLabel),
526 527 528 529 530
              ),
            ),
          ],
        ),
      ),
531 532 533 534
    );
  }

  TextStyle _titleStyle(int index) {
535
    final ThemeData themeData = Theme.of(context);
536 537
    final TextTheme textTheme = themeData.textTheme;

538 539
    assert(widget.steps[index].state != null);
    switch (widget.steps[index].state) {
540 541 542
      case StepState.indexed:
      case StepState.editing:
      case StepState.complete:
543
        return textTheme.bodyText1!;
544
      case StepState.disabled:
545
        return textTheme.bodyText1!.copyWith(
546
          color: _isDark() ? _kDisabledDark : _kDisabledLight,
547 548
        );
      case StepState.error:
549
        return textTheme.bodyText1!.copyWith(
550
          color: _isDark() ? _kErrorDark : _kErrorLight,
551 552 553 554 555
        );
    }
  }

  TextStyle _subtitleStyle(int index) {
556
    final ThemeData themeData = Theme.of(context);
557 558
    final TextTheme textTheme = themeData.textTheme;

559 560
    assert(widget.steps[index].state != null);
    switch (widget.steps[index].state) {
561 562 563
      case StepState.indexed:
      case StepState.editing:
      case StepState.complete:
564
        return textTheme.caption!;
565
      case StepState.disabled:
566
        return textTheme.caption!.copyWith(
567
          color: _isDark() ? _kDisabledDark : _kDisabledLight,
568 569
        );
      case StepState.error:
570
        return textTheme.caption!.copyWith(
571
          color: _isDark() ? _kErrorDark : _kErrorLight,
572 573 574 575 576
        );
    }
  }

  Widget _buildHeaderText(int index) {
577
    return Column(
578 579
      crossAxisAlignment: CrossAxisAlignment.start,
      mainAxisSize: MainAxisSize.min,
580 581 582 583 584 585 586 587 588 589 590 591 592 593
      children: <Widget>[
        AnimatedDefaultTextStyle(
          style: _titleStyle(index),
          duration: kThemeAnimationDuration,
          curve: Curves.fastOutSlowIn,
          child: widget.steps[index].title,
        ),
        if (widget.steps[index].subtitle != null)
          Container(
            margin: const EdgeInsets.only(top: 2.0),
            child: AnimatedDefaultTextStyle(
              style: _subtitleStyle(index),
              duration: kThemeAnimationDuration,
              curve: Curves.fastOutSlowIn,
594
              child: widget.steps[index].subtitle!,
595 596 597
            ),
          ),
      ],
598 599 600 601
    );
  }

  Widget _buildVerticalHeader(int index) {
602
    return Container(
603
      margin: const EdgeInsets.symmetric(horizontal: 24.0),
604
      child: Row(
605
        children: <Widget>[
606
          Column(
607 608 609 610 611 612
            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)),
613
            ],
614
          ),
615 616 617 618
          Expanded(
            child: Container(
              margin: const EdgeInsetsDirectional.only(start: 12.0),
              child: _buildHeaderText(index),
619
            ),
620 621 622
          ),
        ],
      ),
623 624 625 626
    );
  }

  Widget _buildVerticalBody(int index) {
627
    return Stack(
628
      children: <Widget>[
629
        PositionedDirectional(
630
          start: 24.0,
631 632
          top: 0.0,
          bottom: 0.0,
633
          child: SizedBox(
634
            width: 24.0,
635 636
            child: Center(
              child: SizedBox(
637
                width: _isLast(index) ? 0.0 : 1.0,
638
                child: Container(
639
                  color: Colors.grey.shade400,
640 641 642 643
                ),
              ),
            ),
          ),
644
        ),
645 646 647
        AnimatedCrossFade(
          firstChild: Container(height: 0.0),
          secondChild: Container(
648
            margin: widget.margin ?? const EdgeInsetsDirectional.only(
649 650
              start: 60.0,
              end: 24.0,
651
              bottom: 24.0,
652
            ),
653
            child: Column(
654
              children: <Widget>[
655
                widget.steps[index].content,
656
                _buildVerticalControls(index),
657 658
              ],
            ),
659
          ),
660 661
          firstCurve: const Interval(0.0, 0.6, curve: Curves.fastOutSlowIn),
          secondCurve: const Interval(0.4, 1.0, curve: Curves.fastOutSlowIn),
662 663 664
          sizeCurve: Curves.fastOutSlowIn,
          crossFadeState: _isCurrent(index) ? CrossFadeState.showSecond : CrossFadeState.showFirst,
          duration: kThemeAnimationDuration,
665 666
        ),
      ],
667 668 669 670
    );
  }

  Widget _buildVertical() {
671
    return ListView(
672
      shrinkWrap: true,
673
      physics: widget.physics,
674 675 676 677 678 679 680 681 682 683
      children: <Widget>[
        for (int i = 0; i < widget.steps.length; i += 1)
          Column(
            key: _keys[i],
            children: <Widget>[
              InkWell(
                onTap: widget.steps[i].state != StepState.disabled ? () {
                  // In the vertical case we need to scroll to the newly tapped
                  // step.
                  Scrollable.ensureVisible(
684
                    _keys[i].currentContext!,
685 686 687 688
                    curve: Curves.fastOutSlowIn,
                    duration: kThemeAnimationDuration,
                  );

689
                  widget.onStepTapped?.call(i);
690
                } : null,
691
                canRequestFocus: widget.steps[i].state != StepState.disabled,
692 693 694 695 696 697
                child: _buildVerticalHeader(i),
              ),
              _buildVerticalBody(i),
            ],
          ),
      ],
698 699 700 701
    );
  }

  Widget _buildHorizontal() {
702 703
    final List<Widget> children = <Widget>[
      for (int i = 0; i < widget.steps.length; i += 1) ...<Widget>[
704
        InkResponse(
705
          onTap: widget.steps[i].state != StepState.disabled ? () {
706
            widget.onStepTapped?.call(i);
707
          } : null,
708
          canRequestFocus: widget.steps[i].state != StepState.disabled,
709
          child: Row(
710
            children: <Widget>[
711
              SizedBox(
712
                height: 72.0,
713
                child: Center(
714 715
                  child: _buildIcon(i),
                ),
716
              ),
717
              Container(
718
                margin: const EdgeInsetsDirectional.only(start: 12.0),
719 720 721 722 723
                child: _buildHeaderText(i),
              ),
            ],
          ),
        ),
724
        if (!_isLast(i))
725 726
          Expanded(
            child: Container(
727 728
              margin: const EdgeInsets.symmetric(horizontal: 8.0),
              height: 1.0,
729
              color: Colors.grey.shade400,
730 731
            ),
          ),
732 733
      ],
    ];
734

735 736 737 738 739 740 741 742 743 744 745
    final List<Widget> stepPanels = <Widget>[];
    for (int i = 0; i < widget.steps.length; i += 1) {
      stepPanels.add(
        Visibility(
          maintainState: true,
          visible: i == widget.currentStep,
          child: widget.steps[i].content,
        ),
      );
    }

746
    return Column(
747
      children: <Widget>[
748
        Material(
749
          elevation: widget.elevation ?? 2,
750
          child: Container(
751
            margin: const EdgeInsets.symmetric(horizontal: 24.0),
752
            child: Row(
753 754 755
              children: children,
            ),
          ),
756
        ),
757 758
        Expanded(
          child: ListView(
TheBirb's avatar
TheBirb committed
759
            physics: widget.physics,
760 761
            padding: const EdgeInsets.all(24.0),
            children: <Widget>[
762
              AnimatedSize(
763 764
                curve: Curves.fastOutSlowIn,
                duration: kThemeAnimationDuration,
765
                child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: stepPanels),
766
              ),
767
              _buildVerticalControls(widget.currentStep),
768 769 770 771
            ],
          ),
        ),
      ],
772 773 774 775 776 777
    );
  }

  @override
  Widget build(BuildContext context) {
    assert(debugCheckHasMaterial(context));
778
    assert(debugCheckHasMaterialLocalizations(context));
779
    assert(() {
780
      if (context.findAncestorWidgetOfExactType<Stepper>() != null) {
781
        throw FlutterError(
782 783 784
          'Steppers must not be nested.\n'
          'The material specification advises that one should avoid embedding '
          'steppers within steppers. '
785
          'https://material.io/archive/guidelines/components/steppers.html#steppers-usage',
786
        );
787
      }
788
      return true;
789
    }());
790 791
    assert(widget.type != null);
    switch (widget.type) {
792 793 794 795 796 797 798 799 800 801 802 803
      case StepperType.vertical:
        return _buildVertical();
      case StepperType.horizontal:
        return _buildHorizontal();
    }
  }
}

// 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({
804
    required this.color,
805 806 807 808 809
  });

  final Color color;

  @override
810
  bool hitTest(Offset point) => true; // Hitting the rectangle is fine enough.
811 812 813 814 815 816 817 818 819 820 821

  @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;
822
    final List<Offset> points = <Offset>[
823 824 825
      Offset(0.0, height),
      Offset(base, height),
      Offset(halfBase, 0.0),
826 827 828
    ];

    canvas.drawPath(
829 830
      Path()..addPolygon(points, true),
      Paint()..color = color,
831 832 833
    );
  }
}