E-commerce85 views

How to Build a Reset Password Screen with a Live Requirements Checklist in Flutter (Full Code + Preview)

Password rules are frustrating when they're only revealed after you press Save. This tutorial builds a Flutter reset screen that shows them the whole time: three named getters — `_hasLength`, `_hasCase`, `_hasNumber` — feed a checklist that ticks green as you type, and those same getters compose into both the strength meter's score and the Save button's enabled state. A confirm field adds a match check that only complains once you've actually started typing in it.

Reset Password — E-commerce Flutter UI screen
Live preview — Reset Password, built in pure Flutter.

Watch the Flutter UI walkthrough

A short screen recording of Reset Password running, if you'd rather see it before you read the code. The written tutorial below covers everything in it.

Can't see the video? Watch it on YouTube.

What you'll build

  • Named boolean getters that drive the checklist, the meter and validity at once
  • A requirements checklist that ticks green live, per rule, as the user types
  • A four-segment painted strength meter with no charting or password package
  • A mismatch warning suppressed until the confirm field has content
  • One obscure toggle shared by both password fields, so they reveal together
  • A Save button gated on all three rules plus a confirmed match

Step-by-step build

1

Create the file

Add a new file at lib/ecom_auth_reset/ecom_auth_reset_screen.dart in your Flutter project.

2

Register the bundled fonts

No external packages — this is pure Flutter. It does bundle its design font (Manrope), so drop the font file into fonts/ and declare it in pubspec.yaml:

pubspec.yaml
flutter:
  fonts:
    - family: Manrope
      fonts:
        - asset: fonts/Manrope-Regular.ttf
3

Build it, piece by piece

Here's how the screen goes together. Each block below is a real slice of the code with a plain-English explanation — paste them in order, or grab the whole file from the next section.

Two controllers, both rebuilding on change

ecom_auth_reset_screen.dart
class _EcomAuthResetScreenState extends State<EcomAuthResetScreen> {
  static const String _font = 'Manrope';
  static const Color _canvas = Color(0xFFFFFFFF);
  static const Color _ink = Color(0xFF222222);
  static const Color _muted = Color(0xFF6A6A6A);
  static const Color _faint = Color(0xFFC1C1C1);
  static const Color _brand = Color(0xFFFF385C);
  static const Color _surface = Color(0xFFF2F2F2);
  static const Color _hairline = Color(0xFFEBEBEB);
  static const Color _success = Color(0xFF2E9E5B);
  static const Color _warn = Color(0xFFF5A623);
  static const Color _danger = Color(0xFFE5484D);

  final TextEditingController _password = TextEditingController();
  final TextEditingController _confirm = TextEditingController();
  bool _obscure = true;

  @override
  void initState() {
    super.initState();
    _password.addListener(() => setState(() {}));
    _confirm.addListener(() => setState(() {}));
  }

  @override
  void dispose() {
    _password.dispose();
    _confirm.dispose();
    super.dispose();
  }

Unlike a sign-up form where only the password needs live feedback, *both* controllers here get `addListener(() => setState(() {}))` in `initState` — the new password drives the checklist and meter, and the confirm field drives the match warning, so both must trigger rebuilds on every keystroke. `dispose()` releases both. `bool _obscure` is a single field shared by the two fields rather than one each, which is deliberate: when you're re-typing a password to confirm it, you want to see both or neither.

One getter per rule, composed three ways

ecom_auth_reset_screen.dart
  String get _p => _password.text;
  bool get _hasLength => _p.length >= 8;
  bool get _hasCase =>
      RegExp(r'[A-Z]').hasMatch(_p) && RegExp(r'[a-z]').hasMatch(_p);
  bool get _hasNumber => RegExp(r'[0-9]').hasMatch(_p);
  bool get _matches => _confirm.text.isNotEmpty && _confirm.text == _p;

  int get _strength {
    if (_p.isEmpty) return 0;
    int s = 0;
    if (_hasLength) s++;
    if (_hasCase) s++;
    if (_hasNumber) s++;
    if (RegExp(r'[^A-Za-z0-9]').hasMatch(_p)) s++;
    return s;
  }

  bool get _valid => _hasLength && _hasCase && _hasNumber && _matches;

This is the structural idea of the screen. `_hasLength`, `_hasCase` and `_hasNumber` each express exactly one rule, and `_matches` checks the confirm field is both non-empty and equal. `_strength` then *reuses* those three (plus a symbol test) to build its 0–4 score, and `_valid` reuses them again for the button. Because each rule is defined once, the checklist can never say a rule is satisfied while the button disagrees. The `_p` getter is just shorthand for `_password.text`, keeping the rules readable.

Header and the new-password field

ecom_auth_reset_screen.dart
  @override
  Widget build(BuildContext context) {
    final int s = _strength;
    final Color barColor = s <= 1 ? _danger : (s == 2 ? _warn : _success);

    return Theme(
      data: ThemeData.light(useMaterial3: true),
      child: Scaffold(
        backgroundColor: _canvas,
        body: SafeArea(
          child: Column(
            children: <Widget>[
              Expanded(
                child: SingleChildScrollView(
                  padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.stretch,
                    children: <Widget>[
                      Align(
                        alignment: Alignment.centerLeft,
                        child: GestureDetector(
                          onTap: () => Navigator.maybePop(context),
                          child: Container(
                            width: 40,
                            height: 40,
                            decoration: const BoxDecoration(
                              color: _surface,
                              shape: BoxShape.circle,
                            ),
                            child: const Icon(Icons.arrow_back_ios_new_rounded,
                                size: 18, color: _ink),
                          ),
                        ),
                      ),
                      const SizedBox(height: 24),
                      const Text(
                        'Set a new password',
                        style: TextStyle(
                          fontFamily: _font,
                          fontSize: 28,
                          fontWeight: FontWeight.w800,
                          letterSpacing: -0.6,
                          color: _ink,
                        ),
                      ),
                      const SizedBox(height: 8),
                      const Text(
                        'Choose a strong password you haven’t used before.',
                        style: TextStyle(
                          fontFamily: _font,
                          fontSize: 15,
                          height: 1.45,
                          fontWeight: FontWeight.w500,
                          color: _muted,
                        ),
                      ),
                      const SizedBox(height: 28),
                      const _Label('New password'),
                      _PwField(
                        controller: _password,
                        hint: 'New password',
                        obscure: _obscure,
                        onToggle: () => setState(() => _obscure = !_obscure),
                      ),
                      const SizedBox(height: 12),
                      Row(
                        children: <Widget>[
                          Expanded(
                            child: ClipRRect(
                              borderRadius: BorderRadius.circular(99),
                              child: SizedBox(
                                height: 6,
                                child: CustomPaint(
                                  painter: _StrengthPainter(
                                    score: s,
                                    color: barColor,
                                    track: _surface,
                                  ),
                                ),
                              ),
                            ),
                          ),
                        ],
                      ),

`barColor` is derived from the score at the top of `build`, stepping danger → warn → success at the 1/2/3 boundaries. The back button is a 40px `_surface` circle rather than an `IconButton`, giving the flat page a tap target with visible edges. Below the field, the meter is an `Expanded` `ClipRRect(99)` around a 6px `CustomPaint` — the painter draws square-ish rounded rects and the clip rounds the outer ends. Note there's no verdict word beside the bar as you'd find on a sign-up form; here the checklist below carries that job in more detail.

The live requirements checklist

ecom_auth_reset_screen.dart
                      const SizedBox(height: 16),
                      _Req(text: 'At least 8 characters', met: _hasLength),
                      _Req(text: 'Upper & lowercase letters', met: _hasCase),
                      _Req(text: 'At least one number', met: _hasNumber),
                      const SizedBox(height: 18),

Three `_Req` widgets, each handed its rule's current boolean: `_hasLength`, `_hasCase`, `_hasNumber`. Because they take the value rather than computing it, the rules stay in one place and the widget stays a pure render function. The 8px gap between them and the checklist's position *directly under* the meter is what makes the pairing work — the bar shows how far along you are, the list says exactly what's still missing.

Confirm field and a warning that waits its turn

ecom_auth_reset_screen.dart
                      const _Label('Confirm password'),
                      _PwField(
                        controller: _confirm,
                        hint: 'Re-enter password',
                        obscure: _obscure,
                        onToggle: () => setState(() => _obscure = !_obscure),
                      ),
                      if (_confirm.text.isNotEmpty && !_matches) ...<Widget>[
                        const SizedBox(height: 8),
                        const Padding(
                          padding: EdgeInsets.only(left: 2),
                          child: Text(
                            'Passwords don’t match yet',
                            style: TextStyle(
                              fontFamily: _font,
                              fontSize: 13,
                              fontWeight: FontWeight.w700,
                              color: _danger,
                            ),
                          ),
                        ),
                      ],

The confirm field reuses `_PwField` and shares the same `_obscure` and `onToggle`, so tapping either eye reveals both. The mismatch warning is spliced in with `if (_confirm.text.isNotEmpty && !_matches) ...<Widget>[…]` — that first condition is what makes the screen feel civil: an empty confirm field is *incomplete*, not *wrong*, so no error appears until the user has typed something. The copy reinforces it — 'Passwords don't match yet' rather than 'Passwords don't match'.

The gated Save button

ecom_auth_reset_screen.dart
              Container(
                padding: const EdgeInsets.fromLTRB(24, 12, 24, 16),
                decoration: const BoxDecoration(
                  color: _canvas,
                  border: Border(top: BorderSide(color: _hairline)),
                ),
                child: SizedBox(
                  height: 56,
                  width: double.infinity,
                  child: FilledButton(
                    onPressed: _valid ? widget.onSave : null,
                    style: FilledButton.styleFrom(
                      backgroundColor: _brand,
                      foregroundColor: _canvas,
                      disabledBackgroundColor: _brand.withValues(alpha: 0.35),
                      disabledForegroundColor: _canvas,
                      shape: const StadiumBorder(),
                      textStyle: const TextStyle(
                        fontFamily: _font,
                        fontSize: 16,
                        fontWeight: FontWeight.w800,
                      ),
                    ),
                    child: const Text('Save password'),
                  ),
                ),
              ),

The footer sits outside the `Expanded` scroll area with a top hairline border, so it stays put while the form scrolls. `onPressed: _valid ? widget.onSave : null` — passing literal `null` is what disables a `FilledButton`, ripple included. `disabledBackgroundColor: _brand.withValues(alpha: 0.35)` keeps it visibly brand-coloured while disabled, so it reads as 'not yet' rather than broken; paired with the checklist above, the user can always see exactly which rule is holding them up.

The requirement row and the meter painter

ecom_auth_reset_screen.dart
class _Req extends StatelessWidget {
  const _Req({required this.text, required this.met});
  final String text;
  final bool met;

  @override
  Widget build(BuildContext context) {
    final Color c = met
        ? _EcomAuthResetScreenState._success
        : _EcomAuthResetScreenState._faint;
    return Padding(
      padding: const EdgeInsets.only(bottom: 8, left: 2),
      child: Row(
        children: <Widget>[
          Icon(
            met ? Icons.check_circle_rounded : Icons.circle_outlined,
            size: 18,
            color: c,
          ),
          const SizedBox(width: 8),
          Text(
            text,
            style: TextStyle(
              fontFamily: _EcomAuthResetScreenState._font,
              fontSize: 13.5,
              fontWeight: FontWeight.w600,
              color: met
                  ? _EcomAuthResetScreenState._ink
                  : _EcomAuthResetScreenState._muted,
            ),
          ),
        ],
      ),
    );
  }
}

/// Paints a 4-segment password-strength meter — filled segments use [color].
class _StrengthPainter extends CustomPainter {
  const _StrengthPainter({
    required this.score,
    required this.color,
    required this.track,
  });

  final int score;
  final Color color;
  final Color track;

  @override
  void paint(Canvas c, Size size) {
    const int segments = 4;
    const double gap = 4;
    final double segW = (size.width - gap * (segments - 1)) / segments;
    for (int i = 0; i < segments; i++) {
      final double x = i * (segW + gap);
      final Paint p = Paint()..color = i < score ? color : track;
      c.drawRRect(
        RRect.fromRectAndRadius(
          Rect.fromLTWH(x, 0, segW, size.height),
          const Radius.circular(3),
        ),
        p,
      );
    }
  }

  @override
  bool shouldRepaint(_StrengthPainter oldDelegate) =>
      oldDelegate.score != score || oldDelegate.color != color;
}

`_Req` swaps two things on `met`: the icon goes from `circle_outlined` to `check_circle_rounded`, and the text colour lifts from `_muted` to `_ink` while the icon turns `_success` green. Darkening the *text* as well as ticking the icon is what makes a satisfied rule recede as completed rather than just decorated. `_StrengthPainter` divides its width into four segments with 4px gaps — `(size.width - gap * (segments - 1)) / segments` subtracts the three gaps before dividing, so the segments are equal and the row ends flush — and fills segment `i` with the live colour when `i < score`.

Full code

The complete, ready-to-paste source. Free to use in your projects — one click copies it all.

import 'package:flutter/material.dart';

/// StyleCart — Reset Password.
///
/// New + confirm password with a painted strength meter, a live requirements
/// checklist and match validation gating the save CTA. Self-contained per
/// CONVENTIONS.md: pure Flutter, bundled Manrope, inline Airbnb-style tokens, own
/// light theme + SafeArea. The strength meter is a CustomPainter (no asset, no
/// emoji glyph). Exposes callbacks only — the gallery wires navigation.
class EcomAuthResetScreen extends StatefulWidget {
  const EcomAuthResetScreen({super.key, this.onSave});

  /// Submitted a valid, matching new password.
  final VoidCallback? onSave;

  @override
  State<EcomAuthResetScreen> createState() => _EcomAuthResetScreenState();
}

class _EcomAuthResetScreenState extends State<EcomAuthResetScreen> {
  static const String _font = 'Manrope';
  static const Color _canvas = Color(0xFFFFFFFF);
  static const Color _ink = Color(0xFF222222);
  static const Color _muted = Color(0xFF6A6A6A);
  static const Color _faint = Color(0xFFC1C1C1);
  static const Color _brand = Color(0xFFFF385C);
  static const Color _surface = Color(0xFFF2F2F2);
  static const Color _hairline = Color(0xFFEBEBEB);
  static const Color _success = Color(0xFF2E9E5B);
  static const Color _warn = Color(0xFFF5A623);
  static const Color _danger = Color(0xFFE5484D);

  final TextEditingController _password = TextEditingController();
  final TextEditingController _confirm = TextEditingController();
  bool _obscure = true;

  @override
  void initState() {
    super.initState();
    _password.addListener(() => setState(() {}));
    _confirm.addListener(() => setState(() {}));
  }

  @override
  void dispose() {
    _password.dispose();
    _confirm.dispose();
    super.dispose();
  }

  String get _p => _password.text;
  bool get _hasLength => _p.length >= 8;
  bool get _hasCase =>
      RegExp(r'[A-Z]').hasMatch(_p) && RegExp(r'[a-z]').hasMatch(_p);
  bool get _hasNumber => RegExp(r'[0-9]').hasMatch(_p);
  bool get _matches => _confirm.text.isNotEmpty && _confirm.text == _p;

  int get _strength {
    if (_p.isEmpty) return 0;
    int s = 0;
    if (_hasLength) s++;
    if (_hasCase) s++;
    if (_hasNumber) s++;
    if (RegExp(r'[^A-Za-z0-9]').hasMatch(_p)) s++;
    return s;
  }

  bool get _valid => _hasLength && _hasCase && _hasNumber && _matches;

  @override
  Widget build(BuildContext context) {
    final int s = _strength;
    final Color barColor = s <= 1 ? _danger : (s == 2 ? _warn : _success);

    return Theme(
      data: ThemeData.light(useMaterial3: true),
      child: Scaffold(
        backgroundColor: _canvas,
        body: SafeArea(
          child: Column(
            children: <Widget>[
              Expanded(
                child: SingleChildScrollView(
                  padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.stretch,
                    children: <Widget>[
                      Align(
                        alignment: Alignment.centerLeft,
                        child: GestureDetector(
                          onTap: () => Navigator.maybePop(context),
                          child: Container(
                            width: 40,
                            height: 40,
                            decoration: const BoxDecoration(
                              color: _surface,
                              shape: BoxShape.circle,
                            ),
                            child: const Icon(Icons.arrow_back_ios_new_rounded,
                                size: 18, color: _ink),
                          ),
                        ),
                      ),
                      const SizedBox(height: 24),
                      const Text(
                        'Set a new password',
                        style: TextStyle(
                          fontFamily: _font,
                          fontSize: 28,
                          fontWeight: FontWeight.w800,
                          letterSpacing: -0.6,
                          color: _ink,
                        ),
                      ),
                      const SizedBox(height: 8),
                      const Text(
                        'Choose a strong password you haven’t used before.',
                        style: TextStyle(
                          fontFamily: _font,
                          fontSize: 15,
                          height: 1.45,
                          fontWeight: FontWeight.w500,
                          color: _muted,
                        ),
                      ),
                      const SizedBox(height: 28),
                      const _Label('New password'),
                      _PwField(
                        controller: _password,
                        hint: 'New password',
                        obscure: _obscure,
                        onToggle: () => setState(() => _obscure = !_obscure),
                      ),
                      const SizedBox(height: 12),
                      Row(
                        children: <Widget>[
                          Expanded(
                            child: ClipRRect(
                              borderRadius: BorderRadius.circular(99),
                              child: SizedBox(
                                height: 6,
                                child: CustomPaint(
                                  painter: _StrengthPainter(
                                    score: s,
                                    color: barColor,
                                    track: _surface,
                                  ),
                                ),
                              ),
                            ),
                          ),
                        ],
                      ),
                      const SizedBox(height: 16),
                      _Req(text: 'At least 8 characters', met: _hasLength),
                      _Req(text: 'Upper & lowercase letters', met: _hasCase),
                      _Req(text: 'At least one number', met: _hasNumber),
                      const SizedBox(height: 18),
                      const _Label('Confirm password'),
                      _PwField(
                        controller: _confirm,
                        hint: 'Re-enter password',
                        obscure: _obscure,
                        onToggle: () => setState(() => _obscure = !_obscure),
                      ),
                      if (_confirm.text.isNotEmpty && !_matches) ...<Widget>[
                        const SizedBox(height: 8),
                        const Padding(
                          padding: EdgeInsets.only(left: 2),
                          child: Text(
                            'Passwords don’t match yet',
                            style: TextStyle(
                              fontFamily: _font,
                              fontSize: 13,
                              fontWeight: FontWeight.w700,
                              color: _danger,
                            ),
                          ),
                        ),
                      ],
                    ],
                  ),
                ),
              ),
              Container(
                padding: const EdgeInsets.fromLTRB(24, 12, 24, 16),
                decoration: const BoxDecoration(
                  color: _canvas,
                  border: Border(top: BorderSide(color: _hairline)),
                ),
                child: SizedBox(
                  height: 56,
                  width: double.infinity,
                  child: FilledButton(
                    onPressed: _valid ? widget.onSave : null,
                    style: FilledButton.styleFrom(
                      backgroundColor: _brand,
                      foregroundColor: _canvas,
                      disabledBackgroundColor: _brand.withValues(alpha: 0.35),
                      disabledForegroundColor: _canvas,
                      shape: const StadiumBorder(),
                      textStyle: const TextStyle(
                        fontFamily: _font,
                        fontSize: 16,
                        fontWeight: FontWeight.w800,
                      ),
                    ),
                    child: const Text('Save password'),
                  ),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

class _Label extends StatelessWidget {
  const _Label(this.text);
  final String text;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.only(bottom: 8, left: 2),
      child: Text(
        text,
        style: const TextStyle(
          fontFamily: _EcomAuthResetScreenState._font,
          fontSize: 14,
          fontWeight: FontWeight.w700,
          color: _EcomAuthResetScreenState._ink,
        ),
      ),
    );
  }
}

class _PwField extends StatelessWidget {
  const _PwField({
    required this.controller,
    required this.hint,
    required this.obscure,
    required this.onToggle,
  });

  final TextEditingController controller;
  final String hint;
  final bool obscure;
  final VoidCallback onToggle;

  @override
  Widget build(BuildContext context) {
    return Container(
      height: 56,
      padding: const EdgeInsets.symmetric(horizontal: 16),
      decoration: BoxDecoration(
        color: _EcomAuthResetScreenState._surface,
        borderRadius: BorderRadius.circular(14),
        border: Border.all(color: _EcomAuthResetScreenState._hairline),
      ),
      child: Row(
        children: <Widget>[
          const Icon(Icons.lock_outline_rounded,
              size: 20, color: _EcomAuthResetScreenState._muted),
          const SizedBox(width: 12),
          Expanded(
            child: TextField(
              controller: controller,
              obscureText: obscure,
              style: const TextStyle(
                fontFamily: _EcomAuthResetScreenState._font,
                fontSize: 15,
                fontWeight: FontWeight.w600,
                color: _EcomAuthResetScreenState._ink,
              ),
              cursorColor: _EcomAuthResetScreenState._brand,
              decoration: InputDecoration(
                isCollapsed: true,
                border: InputBorder.none,
                hintText: hint,
                hintStyle: const TextStyle(
                  fontFamily: _EcomAuthResetScreenState._font,
                  fontSize: 15,
                  fontWeight: FontWeight.w500,
                  color: _EcomAuthResetScreenState._faint,
                ),
              ),
            ),
          ),
          const SizedBox(width: 8),
          GestureDetector(
            onTap: onToggle,
            child: Icon(
              obscure
                  ? Icons.visibility_outlined
                  : Icons.visibility_off_outlined,
              size: 20,
              color: _EcomAuthResetScreenState._muted,
            ),
          ),
        ],
      ),
    );
  }
}

class _Req extends StatelessWidget {
  const _Req({required this.text, required this.met});
  final String text;
  final bool met;

  @override
  Widget build(BuildContext context) {
    final Color c = met
        ? _EcomAuthResetScreenState._success
        : _EcomAuthResetScreenState._faint;
    return Padding(
      padding: const EdgeInsets.only(bottom: 8, left: 2),
      child: Row(
        children: <Widget>[
          Icon(
            met ? Icons.check_circle_rounded : Icons.circle_outlined,
            size: 18,
            color: c,
          ),
          const SizedBox(width: 8),
          Text(
            text,
            style: TextStyle(
              fontFamily: _EcomAuthResetScreenState._font,
              fontSize: 13.5,
              fontWeight: FontWeight.w600,
              color: met
                  ? _EcomAuthResetScreenState._ink
                  : _EcomAuthResetScreenState._muted,
            ),
          ),
        ],
      ),
    );
  }
}

/// Paints a 4-segment password-strength meter — filled segments use [color].
class _StrengthPainter extends CustomPainter {
  const _StrengthPainter({
    required this.score,
    required this.color,
    required this.track,
  });

  final int score;
  final Color color;
  final Color track;

  @override
  void paint(Canvas c, Size size) {
    const int segments = 4;
    const double gap = 4;
    final double segW = (size.width - gap * (segments - 1)) / segments;
    for (int i = 0; i < segments; i++) {
      final double x = i * (segW + gap);
      final Paint p = Paint()..color = i < score ? color : track;
      c.drawRRect(
        RRect.fromRectAndRadius(
          Rect.fromLTWH(x, 0, segW, size.height),
          const Radius.circular(3),
        ),
        p,
      );
    }
  }

  @override
  bool shouldRepaint(_StrengthPainter oldDelegate) =>
      oldDelegate.score != score || oldDelegate.color != color;
}

Plus bundled 5 binary assets (fonts/images). The CLI and MCP install those for you automatically.

Two faster ways to add it

Copy-paste works, but you can skip it entirely.

1. FlutterKit CLI

One command drops this screen — and its fonts — straight into your project.

$ flutterkit add ecom-auth-reset

2. AI agent (MCP)

Connect FlutterKit's MCP server in Claude or Cursor and just ask your agent to install ecom-auth-reset — it fetches and writes the files for you.

FAQ

Is this reset password screen free to use?

Yes. The complete Dart source on this page is free for personal and commercial apps. Copy it from the code block, install it with the FlutterKit CLI (flutterkit add ecom-auth-reset), or add it through an AI agent over MCP.

How do I change the password rules?

Edit the getters — _hasLength, _hasCase, _hasNumber — and add a matching _Req row for any new one. Because _strength and _valid both compose those same getters, the meter, checklist and button all update together with no other changes.

Why doesn't the mismatch warning show immediately?

The condition is _confirm.text.isNotEmpty && !_matches. An untouched confirm field is incomplete rather than wrong, so warning about it before the user has typed would be noise. The wording ('don't match yet') carries the same intent.

Does it need any packages?

No. It's pure Flutter on the material library — the strength meter is a small CustomPainter and the rules are plain RegExp tests. The only asset is the bundled Manrope font, which the CLI and MCP install for you.

Which Flutter version does it target?

Target Flutter 3.27+ with Dart 3. Only one line depends on a recent SDK — the disabled button's withValues(alpha: 0.35) — so swapping it for withOpacity(0.35) is enough to build on older versions. The RegExp rules, the getters and the painter all work unchanged much further back.

Related screens