Web331 views

How to Build a Crypto Withdraw Form Screen in Flutter (Full Code + Preview)

Sending crypto is unforgiving: a wrong address or an amount above the balance cannot be clawed back once it hits the chain, so the form has to catch mistakes before the review step. This tutorial builds Aurum's Withdraw USDC screen in Flutter — a recipient field with a clipboard-backed Paste action, an amount input with a Max pill and a live over-balance warning, an ERC20 network row, a fee panel that recomputes what the recipient receives, and a Review button that stays disabled until everything checks out.

Aurum · Withdraw — Web3 Flutter UI screen
Live preview — Aurum · Withdraw, built in pure Flutter.

What you'll build

  • ✓A recipient-address field with an amber Paste action that reads the system clipboard and a QR-scan affordance
  • ✓An amount input in bold tabular figures with a Max pill that fills in the 1200.00 USDC balance
  • ✓Inline over-balance validation that turns the field border red and prints a warning line
  • ✓A fee panel that live-computes 'You will receive' as the amount minus the 1.00 USDC network fee
  • ✓A pinned Review withdrawal button gated on address length and a valid, in-balance amount

Step-by-step build

1

Create the file

Add a new file at lib/web3_withdraw_form/web3_withdraw_form_screen.dart in your Flutter project.

2

Register the bundled fonts

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

pubspec.yaml
flutter:
  fonts:
    - family: Inter
      fonts:
        - asset: fonts/Inter-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.

Palette, demo numbers, and four getters that run the screen

web3_withdraw_form_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

/// Web3 withdraw — Withdraw form. A recipient-address field with paste/scan, an
/// amount field with a max pill, a network selector, a fee line and a review
/// bar. Self-contained per CONVENTIONS.md: pure Flutter, bundled Inter font, no
/// glyphs / no network image, forced dark theme so it renders standalone as a
/// route.
class Web3WithdrawFormScreen extends StatefulWidget {
  const Web3WithdrawFormScreen({
    super.key,
    this.onBack,
    this.onSelectNetwork,
    this.onReview,
  });

  final VoidCallback? onBack;
  final VoidCallback? onSelectNetwork;
  final VoidCallback? onReview;

  @override
  State<Web3WithdrawFormScreen> createState() => _Web3WithdrawFormScreenState();
}

class _Web3WithdrawFormScreenState extends State<Web3WithdrawFormScreen> {
  static const String _font = 'Inter';
  static const Color _bg = Color(0xFF0B0E11);
  static const Color _surface = Color(0xFF161A1E);
  static const Color _surfaceAlt = Color(0xFF1E2329);
  static const Color _brand = Color(0xFFF0B90B);
  static const Color _down = Color(0xFFF6465D);
  static const Color _text = Color(0xFFEAECEF);
  static const Color _muted = Color(0xFF848E9C);
  static const Color _hairline = Color(0xFF2B3139);

  static const double _balance = 1200.00; // USDC
  static const double _fee = 1.0;

  final TextEditingController _address = TextEditingController();
  final TextEditingController _amount = TextEditingController();

  @override
  void dispose() {
    _address.dispose();
    _amount.dispose();
    super.dispose();
  }

  double get _value => double.tryParse(_amount.text.replaceAll(',', '')) ?? 0;
  bool get _over => _value > _balance;
  bool get _valid =>
      _address.text.trim().length >= 8 && _value > 0 && !_over;
  double get _receive => (_value - _fee).clamp(0, double.infinity);

The widget is a `StatefulWidget` taking three callbacks — `onBack`, `onSelectNetwork`, `onReview` — so navigation and network choice belong to the host app. The palette is an exchange-dark set: near-black `#0B0E11` background, `#161A1E` surfaces, `#1E2329` for the alt surface, amber `#F0B90B` as the brand colour, `#F6465D` reserved for the over-balance state, and a `#2B3139` hairline. `_balance` (1200.00 USDC) and `_fee` (1.0) are consts standing in for wallet data. The interesting part is that no validation flags are stored: four getters derive everything from the two `TextEditingController`s. `_value` parses the amount with commas stripped, `_over` compares it to the balance, `_valid` demands a trimmed address of at least 8 characters plus a positive, in-balance amount, and `_receive` clamps `_value - _fee` at zero so the summary never shows a negative while someone is mid-typing. A bare `setState(() {})` on any keystroke re-evaluates all four.

Forcing dark mode and a flat app bar

web3_withdraw_form_screen.dart
  @override
  Widget build(BuildContext context) {
    return Theme(
      data: ThemeData.dark(useMaterial3: true),
      child: Scaffold(
        backgroundColor: _bg,
        appBar: AppBar(
          backgroundColor: _bg,
          elevation: 0,
          centerTitle: true,
          leading: IconButton(
            onPressed: widget.onBack,
            icon: const Icon(Icons.arrow_back_ios_new_rounded,
                size: 18, color: _text),
          ),
          title: const Text(
            'Withdraw USDC',
            style: TextStyle(
              fontFamily: _font,
              fontSize: 17,
              fontWeight: FontWeight.w700,
              color: _text,
            ),
          ),
        ),
        body: SafeArea(
          top: false,
          child: ListView(
            padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
            children: <Widget>[

`build` wraps everything in `Theme(data: ThemeData.dark(useMaterial3: true))`, so the screen renders as a dark exchange page even if it is dropped into a light app — it carries its own theme rather than hoping the host matches. The `AppBar` sets its background to the same `_bg` with `elevation: 0`, which removes any seam between bar and body; the centred 'Withdraw USDC' title at 17px `w700` states the asset up front so nobody withdraws the wrong coin. The body is `SafeArea(top: false)` — the AppBar already handles the top inset — holding a `ListView` with `fromLTRB(20, 8, 20, 20)` padding, and a ListView rather than a Column is what lets the form scroll when the keyboard covers half the screen.

The recipient field: Paste beats typing

web3_withdraw_form_screen.dart
              _label('Recipient address'),
              const SizedBox(height: 8),
              Container(
                padding: const EdgeInsets.symmetric(horizontal: 14),
                decoration: BoxDecoration(
                  color: _surface,
                  borderRadius: BorderRadius.circular(12),
                  border: Border.all(color: _hairline),
                ),
                child: Row(
                  children: <Widget>[
                    Expanded(
                      child: TextField(
                        controller: _address,
                        onChanged: (_) => setState(() {}),
                        style: const TextStyle(
                          fontFamily: _font,
                          fontSize: 14,
                          color: _text,
                        ),
                        cursorColor: _brand,
                        decoration: const InputDecoration(
                          isCollapsed: true,
                          border: InputBorder.none,
                          contentPadding: EdgeInsets.symmetric(vertical: 16),
                          hintText: 'Paste or scan address',
                          hintStyle: TextStyle(
                            fontFamily: _font,
                            fontSize: 14,
                            color: _muted,
                          ),
                        ),
                      ),
                    ),
                    GestureDetector(
                      onTap: () async {
                        final ClipboardData? d =
                            await Clipboard.getData('text/plain');
                        if (d?.text != null) {
                          setState(() => _address.text = d!.text!);
                        }
                      },
                      behavior: HitTestBehavior.opaque,
                      child: const Padding(
                        padding: EdgeInsets.symmetric(horizontal: 6),
                        child: Text(
                          'Paste',
                          style: TextStyle(
                            fontFamily: _font,
                            fontSize: 13,
                            fontWeight: FontWeight.w700,
                            color: _brand,
                          ),
                        ),
                      ),
                    ),
                    const SizedBox(width: 6),
                    const Icon(Icons.qr_code_scanner_rounded,
                        size: 20, color: _muted),
                  ],
                ),
              ),

Rather than styling a `TextFormField`, the address input is a plain `TextField` inside a decorated Container Row — `_surface` fill, 12px radius, hairline border — because the Paste label and scanner icon need to sit inside the same bordered box as the text. `isCollapsed: true` with `contentPadding: EdgeInsets.symmetric(vertical: 16)` strips Material's default input chrome so the Container alone controls the height. The Paste action is the heart of it: a `GestureDetector` with `HitTestBehavior.opaque` (so the whole padded area is tappable) awaits `Clipboard.getData('text/plain')` and writes the result into `_address` inside `setState` — nobody types a 42-character hex address, so a one-tap paste is the real input method, with the muted `qr_code_scanner_rounded` icon signposting the other one. `onChanged` on the field itself just calls `setState`, which is enough because `_valid` is derived.

The amount field: tabular figures, a Max pill, and the red border

web3_withdraw_form_screen.dart
              const SizedBox(height: 18),
              Row(
                children: <Widget>[
                  _label('Amount'),
                  const Spacer(),
                  Text(
                    'Balance ${_balance.toStringAsFixed(2)} USDC',
                    style: const TextStyle(
                      fontFamily: _font,
                      fontSize: 12,
                      color: _muted,
                    ),
                  ),
                ],
              ),
              const SizedBox(height: 8),
              Container(
                padding: const EdgeInsets.symmetric(horizontal: 14),
                decoration: BoxDecoration(
                  color: _surface,
                  borderRadius: BorderRadius.circular(12),
                  border: Border.all(color: _over ? _down : _hairline),
                ),
                child: Row(
                  children: <Widget>[
                    Expanded(
                      child: TextField(
                        controller: _amount,
                        onChanged: (_) => setState(() {}),
                        keyboardType: const TextInputType.numberWithOptions(
                            decimal: true),
                        inputFormatters: <TextInputFormatter>[
                          FilteringTextInputFormatter.allow(RegExp(r'[0-9.]')),
                        ],
                        style: const TextStyle(
                          fontFamily: _font,
                          fontSize: 18,
                          fontWeight: FontWeight.w800,
                          fontFeatures: <FontFeature>[
                            FontFeature.tabularFigures()
                          ],
                          color: _text,
                        ),
                        cursorColor: _brand,
                        decoration: const InputDecoration(
                          isCollapsed: true,
                          border: InputBorder.none,
                          contentPadding: EdgeInsets.symmetric(vertical: 16),
                          hintText: '0.00',
                          hintStyle: TextStyle(
                            fontFamily: _font,
                            fontSize: 18,
                            fontWeight: FontWeight.w800,
                            color: _muted,
                          ),
                        ),
                      ),
                    ),
                    const Text(
                      'USDC',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 13,
                        fontWeight: FontWeight.w700,
                        color: _muted,
                      ),
                    ),
                    const SizedBox(width: 10),
                    GestureDetector(
                      onTap: () => setState(() =>
                          _amount.text = _balance.toStringAsFixed(2)),
                      behavior: HitTestBehavior.opaque,
                      child: Container(
                        padding: const EdgeInsets.symmetric(
                            horizontal: 10, vertical: 5),
                        decoration: BoxDecoration(
                          color: _brand.withValues(alpha: 0.14),
                          borderRadius: BorderRadius.circular(8),
                        ),
                        child: const Text(
                          'Max',
                          style: TextStyle(
                            fontFamily: _font,
                            fontSize: 12,
                            fontWeight: FontWeight.w700,
                            color: _brand,
                          ),
                        ),
                      ),
                    ),
                  ],
                ),
              ),
              if (_over)
                const Padding(
                  padding: EdgeInsets.only(top: 8, left: 4),
                  child: Text(
                    'Amount exceeds your available balance.',
                    style: TextStyle(
                      fontFamily: _font,
                      fontSize: 12,
                      fontWeight: FontWeight.w600,
                      color: _down,
                    ),
                  ),
                ),

The label row uses a `Spacer` to push 'Balance 1200.00 USDC' to the right edge in 12px muted text — the balance is visible before the user commits to an amount. The field's border is the whole error treatment: `Border.all(color: _over ? _down : _hairline)` flips to red the moment the parsed value exceeds the balance. Input is constrained twice — `TextInputType.numberWithOptions(decimal: true)` requests the numeric keyboard and `FilteringTextInputFormatter.allow(RegExp(r'[0-9.]'))` rejects anything else — so `_value` never has to defend against letters. The 18px `w800` style carries `FontFeature.tabularFigures()`, keeping every digit the same width so the number doesn't shimmy as you type. The Max pill sits in `_brand.withValues(alpha: 0.14)` with full-amber text and writes `_balance.toStringAsFixed(2)` into the controller inside `setState` — programmatic writes don't fire `onChanged`, so the explicit `setState` is what refreshes the summary. Finally a collection-`if (_over)` inserts the 12px red 'Amount exceeds your available balance.' line only when it is true.

The network row delegates instead of deciding

web3_withdraw_form_screen.dart
              const SizedBox(height: 18),
              _label('Network'),
              const SizedBox(height: 8),
              GestureDetector(
                onTap: widget.onSelectNetwork,
                behavior: HitTestBehavior.opaque,
                child: Container(
                  padding:
                      const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
                  decoration: BoxDecoration(
                    color: _surface,
                    borderRadius: BorderRadius.circular(12),
                    border: Border.all(color: _hairline),
                  ),
                  child: Row(
                    children: <Widget>[
                      Container(
                        width: 34,
                        height: 34,
                        decoration: BoxDecoration(
                          color: _surfaceAlt,
                          borderRadius: BorderRadius.circular(9),
                        ),
                        child: const Icon(Icons.hub_rounded,
                            size: 18, color: _brand),
                      ),
                      const SizedBox(width: 12),
                      const Expanded(
                        child: Column(
                          crossAxisAlignment: CrossAxisAlignment.start,
                          children: <Widget>[
                            Text(
                              'ERC20',
                              style: TextStyle(
                                fontFamily: _font,
                                fontSize: 14.5,
                                fontWeight: FontWeight.w700,
                                color: _text,
                              ),
                            ),
                            SizedBox(height: 2),
                            Text(
                              'Ethereum network',
                              style: TextStyle(
                                fontFamily: _font,
                                fontSize: 12,
                                color: _muted,
                              ),
                            ),
                          ],
                        ),
                      ),
                      const Icon(Icons.keyboard_arrow_right_rounded,
                          size: 22, color: _muted),
                    ],
                  ),
                ),
              ),

Sending USDC over the wrong chain loses it, so the network gets its own labelled row rather than a footnote. The whole card is a `GestureDetector` wired straight to `widget.onSelectNetwork` — the screen deliberately owns no network list; the picker is the host app's problem. Inside, a 34px `_surfaceAlt` rounded square holds an amber `hub_rounded` icon standing in for a chain logo (the file ships no network images), beside a two-line column: 'ERC20' at 14.5px `w700` over 'Ethereum network' in 12px muted — ticket name first, plain-English chain second. The trailing `keyboard_arrow_right_rounded` chevron is the promise that tapping opens a selector. The card reuses the same `_surface` fill, 12px radius and hairline border as both inputs, so the form reads as one system rather than three styles.

Fee and receive: answering the real question early

web3_withdraw_form_screen.dart
              const SizedBox(height: 18),
              Container(
                padding:
                    const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
                decoration: BoxDecoration(
                  color: _surface,
                  borderRadius: BorderRadius.circular(14),
                  border: Border.all(color: _hairline),
                ),
                child: Column(
                  children: <Widget>[
                    _kv('Network fee', '${_fee.toStringAsFixed(2)} USDC'),
                    _kv('You will receive',
                        '${_receive.toStringAsFixed(2)} USDC'),
                  ],
                ),
              ),
            ],
          ),
        ),

The summary panel is a `_surface` Container at a slightly softer 14px radius with only `vertical: 6` padding — each `_kv` row brings its own vertical 8, so the maths lands at even spacing without doubling up at the edges. Its two rows interpolate live getters: 'Network fee 1.00 USDC' from `_fee`, and 'You will receive' from `_receive`, which means the take-home number updates on every keystroke of the amount field. That is the question a withdrawer actually has — not what they typed, but what lands on the other side — and answering it here, before the review screen, is what prevents the back-and-forth of discovering the fee one step too late.

The pinned Review bar and its disabled state

web3_withdraw_form_screen.dart
        bottomNavigationBar: Container(
          decoration: const BoxDecoration(
            color: _bg,
            border: Border(top: BorderSide(color: _hairline)),
          ),
          child: SafeArea(
            top: false,
            child: Padding(
              padding: const EdgeInsets.fromLTRB(20, 12, 20, 12),
              child: SizedBox(
                width: double.infinity,
                height: 52,
                child: FilledButton(
                  onPressed: _valid ? widget.onReview : null,
                  style: FilledButton.styleFrom(
                    backgroundColor: _brand,
                    disabledBackgroundColor: _surfaceAlt,
                    shape: RoundedRectangleBorder(
                      borderRadius: BorderRadius.circular(14),
                    ),
                  ),
                  child: Text(
                    'Review withdrawal',
                    style: TextStyle(
                      fontFamily: _font,
                      fontSize: 16,
                      fontWeight: FontWeight.w700,
                      color: _valid ? _bg : _muted,
                    ),
                  ),
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }

The action lives in `bottomNavigationBar`: a `_bg` Container with a hairline top border, wrapping `SafeArea(top: false)` inside it so the background paints under the home indicator while the button stays above it. The 52px full-width `FilledButton` uses Flutter's own disabling mechanism — `onPressed: _valid ? widget.onReview : null` — so no separate enabled flag exists to fall out of sync. Its styling handles both states: amber `_brand` fill against `disabledBackgroundColor: _surfaceAlt`, and the label colour flips with the same ternary, `_valid ? _bg : _muted` — near-black text on the enabled amber, because white on `#F0B90B` would wash out. The label says 'Review withdrawal', not 'Withdraw': this screen collects, the next one confirms.

The _label and _kv helpers

web3_withdraw_form_screen.dart
  Widget _label(String s) => Text(
        s,
        style: const TextStyle(
          fontFamily: _font,
          fontSize: 13,
          fontWeight: FontWeight.w700,
          color: _muted,
        ),
      );

  Widget _kv(String k, String v) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 8),
      child: Row(
        children: <Widget>[
          Text(
            k,
            style: const TextStyle(
              fontFamily: _font,
              fontSize: 13.5,
              color: _muted,
            ),
          ),
          const Spacer(),
          Text(
            v,
            style: const TextStyle(
              fontFamily: _font,
              fontSize: 13.5,
              fontWeight: FontWeight.w700,
              fontFeatures: <FontFeature>[FontFeature.tabularFigures()],
              color: _text,
            ),
          ),
        ],
      ),
    );
  }
}

Two private helpers keep the body terse. `_label` is a one-expression arrow widget: 13px `w700` muted text used for all three section headings, so changing the label treatment is a one-line edit. `_kv` builds each summary row as a Row with a `Spacer` — key on the left in 13.5px muted, value on the right in `w700` at the same size, with `FontFeature.tabularFigures()` on the value so '1.00' and '499.00' align digit-for-digit down the column. Notice both helpers are methods, not widget classes: they close over the static colour and font consts, and nothing in them needs its own element lifecycle.

Full code

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

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

/// Web3 withdraw — Withdraw form. A recipient-address field with paste/scan, an
/// amount field with a max pill, a network selector, a fee line and a review
/// bar. Self-contained per CONVENTIONS.md: pure Flutter, bundled Inter font, no
/// glyphs / no network image, forced dark theme so it renders standalone as a
/// route.
class Web3WithdrawFormScreen extends StatefulWidget {
  const Web3WithdrawFormScreen({
    super.key,
    this.onBack,
    this.onSelectNetwork,
    this.onReview,
  });

  final VoidCallback? onBack;
  final VoidCallback? onSelectNetwork;
  final VoidCallback? onReview;

  @override
  State<Web3WithdrawFormScreen> createState() => _Web3WithdrawFormScreenState();
}

class _Web3WithdrawFormScreenState extends State<Web3WithdrawFormScreen> {
  static const String _font = 'Inter';
  static const Color _bg = Color(0xFF0B0E11);
  static const Color _surface = Color(0xFF161A1E);
  static const Color _surfaceAlt = Color(0xFF1E2329);
  static const Color _brand = Color(0xFFF0B90B);
  static const Color _down = Color(0xFFF6465D);
  static const Color _text = Color(0xFFEAECEF);
  static const Color _muted = Color(0xFF848E9C);
  static const Color _hairline = Color(0xFF2B3139);

  static const double _balance = 1200.00; // USDC
  static const double _fee = 1.0;

  final TextEditingController _address = TextEditingController();
  final TextEditingController _amount = TextEditingController();

  @override
  void dispose() {
    _address.dispose();
    _amount.dispose();
    super.dispose();
  }

  double get _value => double.tryParse(_amount.text.replaceAll(',', '')) ?? 0;
  bool get _over => _value > _balance;
  bool get _valid =>
      _address.text.trim().length >= 8 && _value > 0 && !_over;
  double get _receive => (_value - _fee).clamp(0, double.infinity);

  @override
  Widget build(BuildContext context) {
    return Theme(
      data: ThemeData.dark(useMaterial3: true),
      child: Scaffold(
        backgroundColor: _bg,
        appBar: AppBar(
          backgroundColor: _bg,
          elevation: 0,
          centerTitle: true,
          leading: IconButton(
            onPressed: widget.onBack,
            icon: const Icon(Icons.arrow_back_ios_new_rounded,
                size: 18, color: _text),
          ),
          title: const Text(
            'Withdraw USDC',
            style: TextStyle(
              fontFamily: _font,
              fontSize: 17,
              fontWeight: FontWeight.w700,
              color: _text,
            ),
          ),
        ),
        body: SafeArea(
          top: false,
          child: ListView(
            padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
            children: <Widget>[
              _label('Recipient address'),
              const SizedBox(height: 8),
              Container(
                padding: const EdgeInsets.symmetric(horizontal: 14),
                decoration: BoxDecoration(
                  color: _surface,
                  borderRadius: BorderRadius.circular(12),
                  border: Border.all(color: _hairline),
                ),
                child: Row(
                  children: <Widget>[
                    Expanded(
                      child: TextField(
                        controller: _address,
                        onChanged: (_) => setState(() {}),
                        style: const TextStyle(
                          fontFamily: _font,
                          fontSize: 14,
                          color: _text,
                        ),
                        cursorColor: _brand,
                        decoration: const InputDecoration(
                          isCollapsed: true,
                          border: InputBorder.none,
                          contentPadding: EdgeInsets.symmetric(vertical: 16),
                          hintText: 'Paste or scan address',
                          hintStyle: TextStyle(
                            fontFamily: _font,
                            fontSize: 14,
                            color: _muted,
                          ),
                        ),
                      ),
                    ),
                    GestureDetector(
                      onTap: () async {
                        final ClipboardData? d =
                            await Clipboard.getData('text/plain');
                        if (d?.text != null) {
                          setState(() => _address.text = d!.text!);
                        }
                      },
                      behavior: HitTestBehavior.opaque,
                      child: const Padding(
                        padding: EdgeInsets.symmetric(horizontal: 6),
                        child: Text(
                          'Paste',
                          style: TextStyle(
                            fontFamily: _font,
                            fontSize: 13,
                            fontWeight: FontWeight.w700,
                            color: _brand,
                          ),
                        ),
                      ),
                    ),
                    const SizedBox(width: 6),
                    const Icon(Icons.qr_code_scanner_rounded,
                        size: 20, color: _muted),
                  ],
                ),
              ),
              const SizedBox(height: 18),
              Row(
                children: <Widget>[
                  _label('Amount'),
                  const Spacer(),
                  Text(
                    'Balance ${_balance.toStringAsFixed(2)} USDC',
                    style: const TextStyle(
                      fontFamily: _font,
                      fontSize: 12,
                      color: _muted,
                    ),
                  ),
                ],
              ),
              const SizedBox(height: 8),
              Container(
                padding: const EdgeInsets.symmetric(horizontal: 14),
                decoration: BoxDecoration(
                  color: _surface,
                  borderRadius: BorderRadius.circular(12),
                  border: Border.all(color: _over ? _down : _hairline),
                ),
                child: Row(
                  children: <Widget>[
                    Expanded(
                      child: TextField(
                        controller: _amount,
                        onChanged: (_) => setState(() {}),
                        keyboardType: const TextInputType.numberWithOptions(
                            decimal: true),
                        inputFormatters: <TextInputFormatter>[
                          FilteringTextInputFormatter.allow(RegExp(r'[0-9.]')),
                        ],
                        style: const TextStyle(
                          fontFamily: _font,
                          fontSize: 18,
                          fontWeight: FontWeight.w800,
                          fontFeatures: <FontFeature>[
                            FontFeature.tabularFigures()
                          ],
                          color: _text,
                        ),
                        cursorColor: _brand,
                        decoration: const InputDecoration(
                          isCollapsed: true,
                          border: InputBorder.none,
                          contentPadding: EdgeInsets.symmetric(vertical: 16),
                          hintText: '0.00',
                          hintStyle: TextStyle(
                            fontFamily: _font,
                            fontSize: 18,
                            fontWeight: FontWeight.w800,
                            color: _muted,
                          ),
                        ),
                      ),
                    ),
                    const Text(
                      'USDC',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 13,
                        fontWeight: FontWeight.w700,
                        color: _muted,
                      ),
                    ),
                    const SizedBox(width: 10),
                    GestureDetector(
                      onTap: () => setState(() =>
                          _amount.text = _balance.toStringAsFixed(2)),
                      behavior: HitTestBehavior.opaque,
                      child: Container(
                        padding: const EdgeInsets.symmetric(
                            horizontal: 10, vertical: 5),
                        decoration: BoxDecoration(
                          color: _brand.withValues(alpha: 0.14),
                          borderRadius: BorderRadius.circular(8),
                        ),
                        child: const Text(
                          'Max',
                          style: TextStyle(
                            fontFamily: _font,
                            fontSize: 12,
                            fontWeight: FontWeight.w700,
                            color: _brand,
                          ),
                        ),
                      ),
                    ),
                  ],
                ),
              ),
              if (_over)
                const Padding(
                  padding: EdgeInsets.only(top: 8, left: 4),
                  child: Text(
                    'Amount exceeds your available balance.',
                    style: TextStyle(
                      fontFamily: _font,
                      fontSize: 12,
                      fontWeight: FontWeight.w600,
                      color: _down,
                    ),
                  ),
                ),
              const SizedBox(height: 18),
              _label('Network'),
              const SizedBox(height: 8),
              GestureDetector(
                onTap: widget.onSelectNetwork,
                behavior: HitTestBehavior.opaque,
                child: Container(
                  padding:
                      const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
                  decoration: BoxDecoration(
                    color: _surface,
                    borderRadius: BorderRadius.circular(12),
                    border: Border.all(color: _hairline),
                  ),
                  child: Row(
                    children: <Widget>[
                      Container(
                        width: 34,
                        height: 34,
                        decoration: BoxDecoration(
                          color: _surfaceAlt,
                          borderRadius: BorderRadius.circular(9),
                        ),
                        child: const Icon(Icons.hub_rounded,
                            size: 18, color: _brand),
                      ),
                      const SizedBox(width: 12),
                      const Expanded(
                        child: Column(
                          crossAxisAlignment: CrossAxisAlignment.start,
                          children: <Widget>[
                            Text(
                              'ERC20',
                              style: TextStyle(
                                fontFamily: _font,
                                fontSize: 14.5,
                                fontWeight: FontWeight.w700,
                                color: _text,
                              ),
                            ),
                            SizedBox(height: 2),
                            Text(
                              'Ethereum network',
                              style: TextStyle(
                                fontFamily: _font,
                                fontSize: 12,
                                color: _muted,
                              ),
                            ),
                          ],
                        ),
                      ),
                      const Icon(Icons.keyboard_arrow_right_rounded,
                          size: 22, color: _muted),
                    ],
                  ),
                ),
              ),
              const SizedBox(height: 18),
              Container(
                padding:
                    const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
                decoration: BoxDecoration(
                  color: _surface,
                  borderRadius: BorderRadius.circular(14),
                  border: Border.all(color: _hairline),
                ),
                child: Column(
                  children: <Widget>[
                    _kv('Network fee', '${_fee.toStringAsFixed(2)} USDC'),
                    _kv('You will receive',
                        '${_receive.toStringAsFixed(2)} USDC'),
                  ],
                ),
              ),
            ],
          ),
        ),
        bottomNavigationBar: Container(
          decoration: const BoxDecoration(
            color: _bg,
            border: Border(top: BorderSide(color: _hairline)),
          ),
          child: SafeArea(
            top: false,
            child: Padding(
              padding: const EdgeInsets.fromLTRB(20, 12, 20, 12),
              child: SizedBox(
                width: double.infinity,
                height: 52,
                child: FilledButton(
                  onPressed: _valid ? widget.onReview : null,
                  style: FilledButton.styleFrom(
                    backgroundColor: _brand,
                    disabledBackgroundColor: _surfaceAlt,
                    shape: RoundedRectangleBorder(
                      borderRadius: BorderRadius.circular(14),
                    ),
                  ),
                  child: Text(
                    'Review withdrawal',
                    style: TextStyle(
                      fontFamily: _font,
                      fontSize: 16,
                      fontWeight: FontWeight.w700,
                      color: _valid ? _bg : _muted,
                    ),
                  ),
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }

  Widget _label(String s) => Text(
        s,
        style: const TextStyle(
          fontFamily: _font,
          fontSize: 13,
          fontWeight: FontWeight.w700,
          color: _muted,
        ),
      );

  Widget _kv(String k, String v) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 8),
      child: Row(
        children: <Widget>[
          Text(
            k,
            style: const TextStyle(
              fontFamily: _font,
              fontSize: 13.5,
              color: _muted,
            ),
          ),
          const Spacer(),
          Text(
            v,
            style: const TextStyle(
              fontFamily: _font,
              fontSize: 13.5,
              fontWeight: FontWeight.w700,
              fontFeatures: <FontFeature>[FontFeature.tabularFigures()],
              color: _text,
            ),
          ),
        ],
      ),
    );
  }
}

Plus bundled 1 binary asset (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 web3-withdraw-form

2. AI agent (MCP)

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

FAQ

Is this crypto withdraw form free to use commercially?

Yes. Copy the code from this page, install it with the CLI, or pull it over MCP and ship it in a commercial wallet or exchange app. There is no licence key, no attribution requirement, and no paid tier hiding a fuller version.

Does the screen need any packages, or a QR scanner plugin?

No third-party packages at all. `Clipboard` and `FilteringTextInputFormatter` both come from `flutter/services`, which ships with the SDK, and the Inter font is bundled with the screen. The QR icon is an affordance only — wire a scanner plugin of your choice to it and write the result into the `_address` controller, the same way the Paste action does.

Which Flutter version does this need?

Flutter 3.22 or newer, because the Max pill's background uses `_brand.withValues(alpha: 0.14)` and the constructor uses super parameters. On an older SDK, swap `withValues(alpha: 0.14)` for `withOpacity(0.14)` and expand the constructor to the `{Key? key} : super(key: key)` form.

The fee is a constant — how do I make it real?

Replace the `_fee` const with a value fetched from your node or exchange API when the screen loads (fees vary by network and congestion), and hold it in state. Because 'You will receive' reads the `_receive` getter, the summary updates automatically once the fetched fee lands — no other wiring changes.

Why is the address only validated for length?

The 8-character minimum in `_valid` is a placeholder gate so the Review button doesn't enable on a stray character. In production, validate per network: an EVM address is 42 characters starting `0x` with an EIP-55 checksum, and other chains differ — which is also why real apps validate after the network is chosen, not before.

Related screens