Skip to content

[material_ui] expand Stepper documentation to explain why step.state is final and add example of usage - #13113

Open
AbdeMohlbi wants to merge 1 commit into
flutter:mainfrom
AbdeMohlbi:issue_18303
Open

AbdeMohlbi wants to merge 1 commit into
flutter:mainfrom
AbdeMohlbi:issue_18303

Conversation

@AbdeMohlbi

@AbdeMohlbi AbdeMohlbi commented Oct 2, 2026 •

Copy link
Copy Markdown
Member

Fixes #18303

Pre-Review Checklist

If you need help, consider asking for advice on the #hackers-new channel on Discord.

Note: The Flutter team is currently trialing the use of Gemini Code Assist for GitHub. Comments from the gemini-code-assist bot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed.

Footnotes

  1. Regular contributors who have demonstrated familiarity with the repository guidelines only need to comment if the PR is not auto-exempted by repo tooling. ↩ ↩2

@AbdeMohlbi AbdeMohlbi added the CICD Run CI/CD label Oct 2, 2026
@github-actions github-actions Bot added p: material_ui triage-design Should be looked at in design triage labels Oct 2, 2026
@AbdeMohlbi AbdeMohlbi changed the title expand Stepper documentation to explain why step.state is final and add example of usage [material_ui] expand Stepper documentation to explain why step.state is final and add example of usage Oct 2, 2026
@AbdeMohlbi
AbdeMohlbi marked this pull request as ready for review October 2, 2026 21:19

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request adds a new example application and widget test demonstrating how to manage and rebuild steps in a Stepper widget when state changes, along with updating the Stepper documentation and changelog. The review feedback suggests defining the steps list locally within the build method to avoid redundant allocations from a getter, and refactoring the widget test to simulate user interactions with tester.tap rather than invoking callbacks directly on the widget.

Comment on lines +40 to +111
List<Step> get _steps => [
Step(
title: const Text('Create account title'),
content: ElevatedButton(
onPressed: () {
setState(() {
_accountCreated = true;
});
},
child: const Text('Create account button'),
),
state: _accountCreated ? StepState.complete : StepState.indexed,
isActive: true,
),
Step(
title: const Text('Complete profile'),
content: ElevatedButton(
onPressed: _accountCreated
? () {
setState(() {
_profileCompleted = true;
});
}
: null,
child: const Text('Complete profile button'),
),
state: _profileCompleted ? StepState.complete : StepState.indexed,
isActive: _accountCreated,
),
Step(
title: const Text('Finish setup'),
content: ElevatedButton(
onPressed: _profileCompleted
? () {
setState(() {
_setupCompleted = true;
});
}
: null,
child: const Text('Finish setup button'),
),
state: _setupCompleted ? StepState.complete : StepState.indexed,
isActive: _profileCompleted,
),
];

@override
Widget build(BuildContext context) {
return Stepper(
currentStep: _currentStep,
steps: _steps,
onStepTapped: (step) {
setState(() {
_currentStep = step;
});
},
onStepContinue: () {
if (_currentStep < _steps.length - 1) {
setState(() {
_currentStep++;
});
}
},
onStepCancel: () {
if (_currentStep > 0) {
setState(() {
_currentStep--;
});
}
},
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Instead of using a getter _steps that allocates a new list and new Step instances every time it is accessed (which happens multiple times during build and callback execution), it is more idiomatic and efficient to define the steps list locally inside the build method.

  @override
  Widget build(BuildContext context) {
    final List<Step> steps = [
      Step(
        title: const Text('Create account title'),
        content: ElevatedButton(
          onPressed: () {
            setState(() {
              _accountCreated = true;
            });
          },
          child: const Text('Create account button'),
        ),
        state: _accountCreated ? StepState.complete : StepState.indexed,
        isActive: true,
      ),
      Step(
        title: const Text('Complete profile'),
        content: ElevatedButton(
          onPressed: _accountCreated
              ? () {
                  setState(() {
                    _profileCompleted = true;
                  });
                }
              : null,
          child: const Text('Complete profile button'),
        ),
        state: _profileCompleted ? StepState.complete : StepState.indexed,
        isActive: _accountCreated,
      ),
      Step(
        title: const Text('Finish setup'),
        content: ElevatedButton(
          onPressed: _profileCompleted
              ? () {
                  setState(() {
                    _setupCompleted = true;
                  });
                }
              : null,
          child: const Text('Finish setup button'),
        ),
        state: _setupCompleted ? StepState.complete : StepState.indexed,
        isActive: _profileCompleted,
      ),
    ];

    return Stepper(
      currentStep: _currentStep,
      steps: steps,
      onStepTapped: (step) {
        setState(() {
          _currentStep = step;
        });
      },
      onStepContinue: () {
        if (_currentStep < steps.length - 1) {
          setState(() {
            _currentStep++;
          });
        }
      },
      onStepCancel: () {
        if (_currentStep > 0) {
          setState(() {
            _currentStep--;
          });
        }
      },
    );
  }

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so you are saying that doing final List<Step> steps each time inside build is faster ? that's wrong (no way did gemini try to gaslight me ?)

Comment on lines +23 to +74
final Stepper stepper = tester.widget<Stepper>(find.byType(Stepper));
await tester.pumpAndSettle();
expect(find.text('Create account title').hitTestable(), findsOneWidget);

// current: 0 and taps the 0th step
stepper.onStepTapped?.call(0);
await tester.pumpAndSettle();
expect(find.text('Create account title').hitTestable(), findsOneWidget);

// Create the account so the next step becomes active.
await tester.tap(
find.widgetWithText(ElevatedButton, 'Create account button'),
);
await tester.pumpAndSettle();

// current: 0 and clicks continue
stepper.onStepContinue?.call();
await tester.pumpAndSettle();
expect(find.text('Complete profile').hitTestable(), findsOneWidget);
expect(find.text('Complete profile button').hitTestable(), findsOneWidget);

// current: 1 and taps the 1st step
stepper.onStepTapped?.call(1);
await tester.pumpAndSettle();
expect(find.text('Complete profile').hitTestable(), findsOneWidget);

// Complete the profile so the next step becomes active.
await tester.tap(
find.widgetWithText(ElevatedButton, 'Complete profile button'),
);
await tester.pumpAndSettle();

// current: 1 and clicks continue
stepper.onStepContinue?.call();
await tester.pumpAndSettle();
expect(find.text('Finish setup').hitTestable(), findsOneWidget);
expect(find.text('Finish setup button').hitTestable(), findsOneWidget);

// current: 2 and clicks cancel
stepper.onStepCancel?.call();
await tester.pumpAndSettle();
expect(find.text('Complete profile').hitTestable(), findsOneWidget);

// current: 1 and taps the 0th step
stepper.onStepTapped?.call(0);
await tester.pumpAndSettle();
expect(find.text('Create account title').hitTestable(), findsOneWidget);

// current: 0 and taps the 2nd step
stepper.onStepTapped?.call(2);
await tester.pumpAndSettle();
expect(find.text('Finish setup').hitTestable(), findsOneWidget);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Invoking callbacks directly on the widget retrieved via tester.widget is an anti-pattern in Flutter widget tests. It bypasses hit-testing, gesture detection, and disabled state checks (e.g., a disabled step header should not trigger onStepTapped when tapped, but calling the callback directly will still execute it). Instead, simulate actual user interactions by using tester.tap on the corresponding UI elements (such as the step titles, 'Continue', and 'Cancel' buttons). This also allows you to completely remove the stepper widget reference.

    await tester.pumpAndSettle();
    expect(find.text('Create account title').hitTestable(), findsOneWidget);

    // current: 0 and taps the 0th step
    await tester.tap(find.text('Create account title'));
    await tester.pumpAndSettle();
    expect(find.text('Create account title').hitTestable(), findsOneWidget);

    // Create the account so the next step becomes active.
    await tester.tap(
      find.widgetWithText(ElevatedButton, 'Create account button'),
    );
    await tester.pumpAndSettle();

    // current: 0 and clicks continue
    await tester.tap(find.text('Continue'));
    await tester.pumpAndSettle();
    expect(find.text('Complete profile').hitTestable(), findsOneWidget);
    expect(find.text('Complete profile button').hitTestable(), findsOneWidget);

    // current: 1 and taps the 1st step
    await tester.tap(find.text('Complete profile'));
    await tester.pumpAndSettle();
    expect(find.text('Complete profile').hitTestable(), findsOneWidget);

    // Complete the profile so the next step becomes active.
    await tester.tap(
      find.widgetWithText(ElevatedButton, 'Complete profile button'),
    );
    await tester.pumpAndSettle();

    // current: 1 and clicks continue
    await tester.tap(find.text('Continue'));
    await tester.pumpAndSettle();
    expect(find.text('Finish setup').hitTestable(), findsOneWidget);
    expect(find.text('Finish setup button').hitTestable(), findsOneWidget);

    // current: 2 and clicks cancel
    await tester.tap(find.text('Cancel'));
    await tester.pumpAndSettle();
    expect(find.text('Complete profile').hitTestable(), findsOneWidget);

    // current: 1 and taps the 0th step
    await tester.tap(find.text('Create account title'));
    await tester.pumpAndSettle();
    expect(find.text('Create account title').hitTestable(), findsOneWidget);

    // current: 0 and taps the 2nd step
    await tester.tap(find.text('Finish setup'));
    await tester.pumpAndSettle();
    expect(find.text('Finish setup').hitTestable(), findsOneWidget);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that's a good point i will update it accordingly

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CICD Run CI/CD p: material_ui triage-design Should be looked at in design triage

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Step.state should not be final

1 participant