Web363 views

How to Build a Crypto Token Swap Screen in Flutter (Full Code + Preview)

A swap screen has one job: let someone type an amount and trust the number coming back. This tutorial builds Aurum's dark ETH-to-SOL convert screen in Flutter — two stacked token cards with a circular flip toggle straddling the gap, a custom 3x4 numpad that replaces the system keyboard, live conversion math with tabular figures, a rate and slippage row, and an amber Review button that stays disabled until the amount is above zero. Everything is pure Flutter, driven by two state variables.

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

What you'll build

  • ✓Stacked From/To token cards with a 40px circular flip toggle overlapping the seam between them
  • ✓A string-based amount model with digit-by-digit input guards: one decimal point, six decimals, nine digits total
  • ✓Live ETH-to-SOL conversion computed from USD prices, re-labelled and re-tinted when direction flips
  • ✓A custom 3x4 GridView numpad so the system keyboard never covers the cards
  • ✓A rate and slippage info card plus a Review button that only enables above zero

Step-by-step build

1

Create the file

Add a new file at lib/web3_swap_home/web3_swap_home_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, prices, and just two pieces of state

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

/// Web3 swap — Convert home. Stacked from/to token cards with balances, a
/// center swap-direction toggle, a painted numpad driving the "from" amount, a
/// rate + slippage row and a review bar. Self-contained per CONVENTIONS.md:
/// pure Flutter, bundled Inter font, custom numpad (no system keyboard),
/// painted ticker badges (no glyphs / no network image), forced dark theme so
/// it renders standalone as a route.
class Web3SwapHomeScreen extends StatefulWidget {
  const Web3SwapHomeScreen({
    super.key,
    this.onBack,
    this.onSettings,
    this.onSelectFrom,
    this.onSelectTo,
    this.onReview,
  });

  final VoidCallback? onBack;
  final VoidCallback? onSettings;
  final VoidCallback? onSelectFrom;
  final VoidCallback? onSelectTo;
  final VoidCallback? onReview;

  @override
  State<Web3SwapHomeScreen> createState() => _Web3SwapHomeScreenState();
}

class _Web3SwapHomeScreenState extends State<Web3SwapHomeScreen> {
  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 _text = Color(0xFFEAECEF);
  static const Color _muted = Color(0xFF848E9C);
  static const Color _hairline = Color(0xFF2B3139);

  // 1 ETH = 17.96 SOL (~ $2748.10 / $152.90).
  static const double _ethUsd = 2748.10;
  static const double _solUsd = 152.90;
  static const double _fromBalance = 1.842; // ETH

  String _amount = '0';
  bool _flipped = false; // false: ETH→SOL, true: SOL→ETH

The widget takes five optional callbacks — `onBack`, `onSettings`, `onSelectFrom`, `onSelectTo`, `onReview` — so the screen owns typing and flipping but delegates navigation, keeping it backend-agnostic. The palette is Binance-flavoured dark: near-black `#0B0E11` background, `#161A1E` card surface, `#1E2329` for raised elements, amber `#F0B90B` brand, `#848E9C` muted grey and a `#2B3139` hairline. Prices are constants (`_ethUsd = 2748.10`, `_solUsd = 152.90`) with a 1.842 ETH demo balance, and the entire UI derives from just two mutable fields: `_amount`, kept as a String rather than a double, and `_flipped`, a bool naming the swap direction. Everything else on screen is computed from those two.

The numpad handler and the conversion getters

web3_swap_home_screen.dart
  void _tap(String k) {
    setState(() {
      if (k == '<') {
        _amount = _amount.length <= 1
            ? '0'
            : _amount.substring(0, _amount.length - 1);
      } else if (k == '.') {
        if (!_amount.contains('.')) _amount = '$_amount.';
      } else {
        if (_amount == '0') {
          _amount = k;
        } else {
          final int dot = _amount.indexOf('.');
          if (dot != -1 && _amount.length - dot > 6) return; // 6 decimals max
          if (_amount.replaceAll('.', '').length >= 9) return;
          _amount = '$_amount$k';
        }
      }
    });
  }

  double get _value => double.tryParse(_amount) ?? 0;

  // Output amount given current direction.
  double get _outValue =>
      _flipped ? _value * _solUsd / _ethUsd : _value * _ethUsd / _solUsd;

  String get _fromTicker => _flipped ? 'SOL' : 'ETH';
  String get _toTicker => _flipped ? 'ETH' : 'SOL';
  String get _fromName => _flipped ? 'Solana' : 'Ethereum';
  String get _toName => _flipped ? 'Ethereum' : 'Solana';
  Color get _fromTint => _flipped ? const Color(0xFF14F195) : const Color(0xFF627EEA);
  Color get _toTint => _flipped ? const Color(0xFF627EEA) : const Color(0xFF14F195);

  String get _rateLabel => _flipped
      ? '1 SOL = 0.0557 ETH'
      : '1 ETH = 17.97 SOL';

`_tap` edits `_amount` as a string, which is why the field is a String in the first place — a double cannot represent the in-between state '1.' while someone is mid-entry. Backspace substrings the last character and collapses to '0' rather than empty; '.' is ignored if one already exists; a digit replaces a lone '0' instead of appending to it, so you never see '07'. Two guards return early: at most 6 characters after the dot (crypto-realistic precision) and at most 9 digits overall, which also protects the layout. Conversion goes through USD as the common unit — `_value * _ethUsd / _solUsd`, or the inverse when `_flipped` — and a family of getters (`_fromTicker`, `_fromTint`, `_rateLabel`...) swap ticker, name, Ethereum indigo `#627EEA`, Solana green `#14F195` and the rate string in one place, so the build method never branches on direction.

Forced dark theme and the fixed-bottom layout

web3_swap_home_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(
            'Swap',
            style: TextStyle(
              fontFamily: _font,
              fontSize: 17,
              fontWeight: FontWeight.w700,
              color: _text,
            ),
          ),
          actions: <Widget>[
            IconButton(
              onPressed: widget.onSettings,
              icon: const Icon(Icons.tune_rounded, size: 21, color: _muted),
            ),
            const SizedBox(width: 4),
          ],
        ),
        body: SafeArea(
          top: false,
          child: Column(
            children: <Widget>[
              const SizedBox(height: 8),
              Expanded(
                child: SingleChildScrollView(
                  padding: const EdgeInsets.symmetric(horizontal: 20),
                  child: Column(
                    children: <Widget>[
                      _buildCards(),
                      const SizedBox(height: 16),
                      _buildInfoRow(),
                    ],
                  ),
                ),
              ),
              _Numpad(onKey: _tap),
              Padding(
                padding: const EdgeInsets.fromLTRB(20, 4, 20, 12),
                child: SizedBox(
                  width: double.infinity,
                  height: 52,
                  child: FilledButton(
                    onPressed: _value > 0 ? widget.onReview : null,
                    style: FilledButton.styleFrom(
                      backgroundColor: _brand,
                      disabledBackgroundColor: _surfaceAlt,
                      shape: RoundedRectangleBorder(
                        borderRadius: BorderRadius.circular(14),
                      ),
                    ),
                    child: Text(
                      'Review swap',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 16,
                        fontWeight: FontWeight.w700,
                        color: _value > 0 ? _bg : _muted,
                      ),
                    ),
                  ),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }

The Scaffold is wrapped in `Theme(data: ThemeData.dark(useMaterial3: true))` so the screen renders dark even inside a light host app — it drops in as a standalone route without touching your global theme. The body Column pins what matters: cards and info row live in an `Expanded` `SingleChildScrollView`, while the `_Numpad` and the Review button sit below as plain Column children, so keys stay under the thumb on any screen height and only the content scrolls. The Review `FilledButton` passes `onPressed: _value > 0 ? widget.onReview : null` — Flutter's native disabled state, restyled with `disabledBackgroundColor: _surfaceAlt` and a label colour that flips between near-black `_bg` on amber and `_muted` grey, so an empty amount visibly cannot be submitted.

Two cards and a flip button straddling the seam

web3_swap_home_screen.dart
  Widget _buildCards() {
    return Stack(
      alignment: Alignment.center,
      children: <Widget>[
        Column(
          children: <Widget>[
            _TokenCard(
              label: 'From',
              ticker: _fromTicker,
              name: _fromName,
              tint: _fromTint,
              amount: _amount,
              sub: 'Balance $_fromBalance $_fromTicker',
              editable: true,
              onTap: widget.onSelectFrom,
            ),
            const SizedBox(height: 8),
            _TokenCard(
              label: 'To',
              ticker: _toTicker,
              name: _toName,
              tint: _toTint,
              amount: _value == 0 ? '0' : _outValue.toStringAsFixed(4),
              sub: 'You receive (estimated)',
              editable: false,
              onTap: widget.onSelectTo,
            ),
          ],
        ),
        // Center direction toggle straddling the two cards.
        Positioned(
          child: GestureDetector(
            onTap: () => setState(() => _flipped = !_flipped),
            behavior: HitTestBehavior.opaque,
            child: Container(
              width: 40,
              height: 40,
              decoration: BoxDecoration(
                color: _surfaceAlt,
                shape: BoxShape.circle,
                border: Border.all(color: _bg, width: 3),
              ),
              child: const Icon(Icons.swap_vert_rounded,
                  size: 22, color: _brand),
            ),
          ),
        ),
      ],
    );
  }

`_buildCards` is a `Stack` with `alignment: Alignment.center`: a Column holds the two `_TokenCard`s 8px apart, and a `Positioned` 40px circle centres itself exactly over that gap — the classic exchange layout where the direction toggle physically bridges the cards it swaps. The circle gets a 3px border in `_bg`, the page background colour, which fakes a punched-out gap around it without any clipping. Tapping it just toggles `_flipped`; the getters from earlier do the rest. Note what the From and To cards actually differ in: From shows the raw `_amount` string and 'Balance 1.842 ETH', while To shows `_outValue.toStringAsFixed(4)` labelled 'You receive (estimated)' with `editable: false` — honest wording, since a real quote moves before execution.

Rate and slippage in one bordered card

web3_swap_home_screen.dart
  Widget _buildInfoRow() {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
      decoration: BoxDecoration(
        color: _surface,
        borderRadius: BorderRadius.circular(14),
        border: Border.all(color: _hairline),
      ),
      child: Column(
        children: <Widget>[
          Row(
            children: <Widget>[
              const Text(
                'Rate',
                style: TextStyle(
                  fontFamily: _font,
                  fontSize: 13,
                  color: _muted,
                ),
              ),
              const Spacer(),
              Text(
                _rateLabel,
                style: const TextStyle(
                  fontFamily: _font,
                  fontSize: 13,
                  fontWeight: FontWeight.w600,
                  fontFeatures: <FontFeature>[FontFeature.tabularFigures()],
                  color: _text,
                ),
              ),
            ],
          ),
          const SizedBox(height: 11),
          Row(
            children: <Widget>[
              const Text(
                'Slippage tolerance',
                style: TextStyle(
                  fontFamily: _font,
                  fontSize: 13,
                  color: _muted,
                ),
              ),
              const Spacer(),
              GestureDetector(
                onTap: widget.onSettings,
                behavior: HitTestBehavior.opaque,
                child: Container(
                  padding:
                      const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
                  decoration: BoxDecoration(
                    color: _brand.withValues(alpha: 0.14),
                    borderRadius: BorderRadius.circular(8),
                  ),
                  child: const Text(
                    '0.5%',
                    style: TextStyle(
                      fontFamily: _font,
                      fontSize: 12.5,
                      fontWeight: FontWeight.w700,
                      color: _brand,
                    ),
                  ),
                ),
              ),
            ],
          ),
        ],
      ),
    );
  }

`_buildInfoRow` is a `_surface` container with a hairline border holding two label/value rows separated by 11px. The rate value renders with `FontFeature.tabularFigures()` so '1 ETH = 17.97 SOL' does not wobble sideways when the direction flip changes it to '1 SOL = 0.0557 ETH' — every digit occupies the same width. The slippage row puts '0.5%' in a chip tinted `_brand.withValues(alpha: 0.14)` with full-strength amber text, the screen's only accent outside the Review button, and wraps it in a `GestureDetector` firing the same `onSettings` callback as the app-bar tune icon — tapping the value you want to change is the natural gesture, not just the gear.

_TokenCard: a painted badge and a shrink-to-fit amount

web3_swap_home_screen.dart
class _TokenCard extends StatelessWidget {
  const _TokenCard({
    required this.label,
    required this.ticker,
    required this.name,
    required this.tint,
    required this.amount,
    required this.sub,
    required this.editable,
    required this.onTap,
  });

  final String label;
  final String ticker;
  final String name;
  final Color tint;
  final String amount;
  final String sub;
  final bool editable;
  final VoidCallback? onTap;

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
      decoration: BoxDecoration(
        color: _Web3SwapHomeScreenState._surface,
        borderRadius: BorderRadius.circular(16),
        border: Border.all(color: _Web3SwapHomeScreenState._hairline),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          Text(
            label,
            style: const TextStyle(
              fontFamily: _Web3SwapHomeScreenState._font,
              fontSize: 12.5,
              fontWeight: FontWeight.w600,
              color: _Web3SwapHomeScreenState._muted,
            ),
          ),
          const SizedBox(height: 8),
          Row(
            children: <Widget>[
              GestureDetector(
                onTap: onTap,
                behavior: HitTestBehavior.opaque,
                child: Container(
                  padding:
                      const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
                  decoration: BoxDecoration(
                    color: _Web3SwapHomeScreenState._surfaceAlt,
                    borderRadius: BorderRadius.circular(24),
                    border: Border.all(
                        color: _Web3SwapHomeScreenState._hairline),
                  ),
                  child: Row(
                    mainAxisSize: MainAxisSize.min,
                    children: <Widget>[
                      Container(
                        width: 26,
                        height: 26,
                        decoration: BoxDecoration(
                          color: tint.withValues(alpha: 0.16),
                          shape: BoxShape.circle,
                          border:
                              Border.all(color: tint.withValues(alpha: 0.5)),
                        ),
                        alignment: Alignment.center,
                        child: Text(
                          ticker,
                          style: TextStyle(
                            fontFamily: _Web3SwapHomeScreenState._font,
                            fontSize: 8,
                            fontWeight: FontWeight.w700,
                            color: Color.lerp(tint, Colors.white, 0.4),
                          ),
                        ),
                      ),
                      const SizedBox(width: 7),
                      Text(
                        ticker,
                        style: const TextStyle(
                          fontFamily: _Web3SwapHomeScreenState._font,
                          fontSize: 14,
                          fontWeight: FontWeight.w700,
                          color: _Web3SwapHomeScreenState._text,
                        ),
                      ),
                      const Icon(Icons.keyboard_arrow_down_rounded,
                          size: 20, color: _Web3SwapHomeScreenState._muted),
                    ],
                  ),
                ),
              ),
              const Spacer(),
              Flexible(
                child: FittedBox(
                  fit: BoxFit.scaleDown,
                  alignment: Alignment.centerRight,
                  child: Text(
                    amount,
                    style: TextStyle(
                      fontFamily: _Web3SwapHomeScreenState._font,
                      fontSize: 28,
                      fontWeight: FontWeight.w800,
                      fontFeatures: const <FontFeature>[
                        FontFeature.tabularFigures()
                      ],
                      color: editable || amount != '0'
                          ? _Web3SwapHomeScreenState._text
                          : _Web3SwapHomeScreenState._muted,
                    ),
                  ),
                ),
              ),
            ],
          ),
          const SizedBox(height: 8),
          Text(
            sub,
            style: const TextStyle(
              fontFamily: _Web3SwapHomeScreenState._font,
              fontSize: 12,
              color: _Web3SwapHomeScreenState._muted,
            ),
          ),
        ],
      ),
    );
  }
}

One stateless `_TokenCard` renders both rows; eight constructor fields (`label`, `ticker`, `tint`, `amount`, `sub`, `editable`, `onTap`...) cover every difference between From and To. The token selector is a pill — `_surfaceAlt` fill, radius 24 — whose badge is painted, not fetched: a 26px circle filled with `tint.withValues(alpha: 0.16)`, ringed at alpha 0.5, containing the ticker at 8px in `Color.lerp(tint, Colors.white, 0.4)`. No network image, no icon pack, and any token gets a matching badge from its brand colour alone. The amount is the subtle part: 28px w800 tabular-figure text inside `Flexible` plus `FittedBox(fit: BoxFit.scaleDown, alignment: Alignment.centerRight)`, so a nine-digit entry scales down to fit beside the pill instead of overflowing. Its colour test `editable || amount != '0'` greys out only the To card's placeholder zero — the From zero stays bright because that is where the cursor conceptually lives.

The 3x4 numpad grid

web3_swap_home_screen.dart
class _Numpad extends StatelessWidget {
  const _Numpad({required this.onKey});

  final ValueChanged<String> onKey;

  static const List<String> _keys = <String>[
    '1', '2', '3', '4', '5', '6', '7', '8', '9', '.', '0', '<',
  ];

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 12),
      child: GridView.builder(
        shrinkWrap: true,
        physics: const NeverScrollableScrollPhysics(),
        padding: EdgeInsets.zero,
        itemCount: _keys.length,
        gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
          crossAxisCount: 3,
          childAspectRatio: 2.2,
        ),
        itemBuilder: (BuildContext context, int i) {
          final String k = _keys[i];
          return InkWell(
            borderRadius: BorderRadius.circular(12),
            onTap: () => onKey(k),
            child: Center(
              child: k == '<'
                  ? const Icon(Icons.backspace_outlined,
                      size: 22, color: _Web3SwapHomeScreenState._text)
                  : Text(
                      k,
                      style: const TextStyle(
                        fontFamily: _Web3SwapHomeScreenState._font,
                        fontSize: 24,
                        fontWeight: FontWeight.w600,
                        color: _Web3SwapHomeScreenState._text,
                      ),
                    ),
            ),
          );
        },
      ),
    );
  }
}

`_Numpad` is a stateless `GridView.builder` over a 12-entry list — digits 1–9, then '.', '0', '<' — with `crossAxisCount: 3` and `childAspectRatio: 2.2` for wide, short keys. `shrinkWrap: true` with `NeverScrollableScrollPhysics` is what lets a GridView sit inside the body Column as a fixed block: it sizes to its children and gives up scrolling instead of demanding infinite height. Each key is a borderless `InkWell` with a 12px ripple radius — Material feedback with none of the chrome — and the builder swaps in a `backspace_outlined` icon when the key is '<'. All interpretation lives in the parent's `_tap`; the numpad only reports which label was pressed through `ValueChanged<String>`, so you could reuse it for a PIN screen unchanged.

Full code

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

import 'package:flutter/material.dart';

/// Web3 swap — Convert home. Stacked from/to token cards with balances, a
/// center swap-direction toggle, a painted numpad driving the "from" amount, a
/// rate + slippage row and a review bar. Self-contained per CONVENTIONS.md:
/// pure Flutter, bundled Inter font, custom numpad (no system keyboard),
/// painted ticker badges (no glyphs / no network image), forced dark theme so
/// it renders standalone as a route.
class Web3SwapHomeScreen extends StatefulWidget {
  const Web3SwapHomeScreen({
    super.key,
    this.onBack,
    this.onSettings,
    this.onSelectFrom,
    this.onSelectTo,
    this.onReview,
  });

  final VoidCallback? onBack;
  final VoidCallback? onSettings;
  final VoidCallback? onSelectFrom;
  final VoidCallback? onSelectTo;
  final VoidCallback? onReview;

  @override
  State<Web3SwapHomeScreen> createState() => _Web3SwapHomeScreenState();
}

class _Web3SwapHomeScreenState extends State<Web3SwapHomeScreen> {
  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 _text = Color(0xFFEAECEF);
  static const Color _muted = Color(0xFF848E9C);
  static const Color _hairline = Color(0xFF2B3139);

  // 1 ETH = 17.96 SOL (~ $2748.10 / $152.90).
  static const double _ethUsd = 2748.10;
  static const double _solUsd = 152.90;
  static const double _fromBalance = 1.842; // ETH

  String _amount = '0';
  bool _flipped = false; // false: ETH→SOL, true: SOL→ETH

  void _tap(String k) {
    setState(() {
      if (k == '<') {
        _amount = _amount.length <= 1
            ? '0'
            : _amount.substring(0, _amount.length - 1);
      } else if (k == '.') {
        if (!_amount.contains('.')) _amount = '$_amount.';
      } else {
        if (_amount == '0') {
          _amount = k;
        } else {
          final int dot = _amount.indexOf('.');
          if (dot != -1 && _amount.length - dot > 6) return; // 6 decimals max
          if (_amount.replaceAll('.', '').length >= 9) return;
          _amount = '$_amount$k';
        }
      }
    });
  }

  double get _value => double.tryParse(_amount) ?? 0;

  // Output amount given current direction.
  double get _outValue =>
      _flipped ? _value * _solUsd / _ethUsd : _value * _ethUsd / _solUsd;

  String get _fromTicker => _flipped ? 'SOL' : 'ETH';
  String get _toTicker => _flipped ? 'ETH' : 'SOL';
  String get _fromName => _flipped ? 'Solana' : 'Ethereum';
  String get _toName => _flipped ? 'Ethereum' : 'Solana';
  Color get _fromTint => _flipped ? const Color(0xFF14F195) : const Color(0xFF627EEA);
  Color get _toTint => _flipped ? const Color(0xFF627EEA) : const Color(0xFF14F195);

  String get _rateLabel => _flipped
      ? '1 SOL = 0.0557 ETH'
      : '1 ETH = 17.97 SOL';

  @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(
            'Swap',
            style: TextStyle(
              fontFamily: _font,
              fontSize: 17,
              fontWeight: FontWeight.w700,
              color: _text,
            ),
          ),
          actions: <Widget>[
            IconButton(
              onPressed: widget.onSettings,
              icon: const Icon(Icons.tune_rounded, size: 21, color: _muted),
            ),
            const SizedBox(width: 4),
          ],
        ),
        body: SafeArea(
          top: false,
          child: Column(
            children: <Widget>[
              const SizedBox(height: 8),
              Expanded(
                child: SingleChildScrollView(
                  padding: const EdgeInsets.symmetric(horizontal: 20),
                  child: Column(
                    children: <Widget>[
                      _buildCards(),
                      const SizedBox(height: 16),
                      _buildInfoRow(),
                    ],
                  ),
                ),
              ),
              _Numpad(onKey: _tap),
              Padding(
                padding: const EdgeInsets.fromLTRB(20, 4, 20, 12),
                child: SizedBox(
                  width: double.infinity,
                  height: 52,
                  child: FilledButton(
                    onPressed: _value > 0 ? widget.onReview : null,
                    style: FilledButton.styleFrom(
                      backgroundColor: _brand,
                      disabledBackgroundColor: _surfaceAlt,
                      shape: RoundedRectangleBorder(
                        borderRadius: BorderRadius.circular(14),
                      ),
                    ),
                    child: Text(
                      'Review swap',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 16,
                        fontWeight: FontWeight.w700,
                        color: _value > 0 ? _bg : _muted,
                      ),
                    ),
                  ),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }

  Widget _buildCards() {
    return Stack(
      alignment: Alignment.center,
      children: <Widget>[
        Column(
          children: <Widget>[
            _TokenCard(
              label: 'From',
              ticker: _fromTicker,
              name: _fromName,
              tint: _fromTint,
              amount: _amount,
              sub: 'Balance $_fromBalance $_fromTicker',
              editable: true,
              onTap: widget.onSelectFrom,
            ),
            const SizedBox(height: 8),
            _TokenCard(
              label: 'To',
              ticker: _toTicker,
              name: _toName,
              tint: _toTint,
              amount: _value == 0 ? '0' : _outValue.toStringAsFixed(4),
              sub: 'You receive (estimated)',
              editable: false,
              onTap: widget.onSelectTo,
            ),
          ],
        ),
        // Center direction toggle straddling the two cards.
        Positioned(
          child: GestureDetector(
            onTap: () => setState(() => _flipped = !_flipped),
            behavior: HitTestBehavior.opaque,
            child: Container(
              width: 40,
              height: 40,
              decoration: BoxDecoration(
                color: _surfaceAlt,
                shape: BoxShape.circle,
                border: Border.all(color: _bg, width: 3),
              ),
              child: const Icon(Icons.swap_vert_rounded,
                  size: 22, color: _brand),
            ),
          ),
        ),
      ],
    );
  }

  Widget _buildInfoRow() {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
      decoration: BoxDecoration(
        color: _surface,
        borderRadius: BorderRadius.circular(14),
        border: Border.all(color: _hairline),
      ),
      child: Column(
        children: <Widget>[
          Row(
            children: <Widget>[
              const Text(
                'Rate',
                style: TextStyle(
                  fontFamily: _font,
                  fontSize: 13,
                  color: _muted,
                ),
              ),
              const Spacer(),
              Text(
                _rateLabel,
                style: const TextStyle(
                  fontFamily: _font,
                  fontSize: 13,
                  fontWeight: FontWeight.w600,
                  fontFeatures: <FontFeature>[FontFeature.tabularFigures()],
                  color: _text,
                ),
              ),
            ],
          ),
          const SizedBox(height: 11),
          Row(
            children: <Widget>[
              const Text(
                'Slippage tolerance',
                style: TextStyle(
                  fontFamily: _font,
                  fontSize: 13,
                  color: _muted,
                ),
              ),
              const Spacer(),
              GestureDetector(
                onTap: widget.onSettings,
                behavior: HitTestBehavior.opaque,
                child: Container(
                  padding:
                      const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
                  decoration: BoxDecoration(
                    color: _brand.withValues(alpha: 0.14),
                    borderRadius: BorderRadius.circular(8),
                  ),
                  child: const Text(
                    '0.5%',
                    style: TextStyle(
                      fontFamily: _font,
                      fontSize: 12.5,
                      fontWeight: FontWeight.w700,
                      color: _brand,
                    ),
                  ),
                ),
              ),
            ],
          ),
        ],
      ),
    );
  }
}

class _TokenCard extends StatelessWidget {
  const _TokenCard({
    required this.label,
    required this.ticker,
    required this.name,
    required this.tint,
    required this.amount,
    required this.sub,
    required this.editable,
    required this.onTap,
  });

  final String label;
  final String ticker;
  final String name;
  final Color tint;
  final String amount;
  final String sub;
  final bool editable;
  final VoidCallback? onTap;

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
      decoration: BoxDecoration(
        color: _Web3SwapHomeScreenState._surface,
        borderRadius: BorderRadius.circular(16),
        border: Border.all(color: _Web3SwapHomeScreenState._hairline),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          Text(
            label,
            style: const TextStyle(
              fontFamily: _Web3SwapHomeScreenState._font,
              fontSize: 12.5,
              fontWeight: FontWeight.w600,
              color: _Web3SwapHomeScreenState._muted,
            ),
          ),
          const SizedBox(height: 8),
          Row(
            children: <Widget>[
              GestureDetector(
                onTap: onTap,
                behavior: HitTestBehavior.opaque,
                child: Container(
                  padding:
                      const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
                  decoration: BoxDecoration(
                    color: _Web3SwapHomeScreenState._surfaceAlt,
                    borderRadius: BorderRadius.circular(24),
                    border: Border.all(
                        color: _Web3SwapHomeScreenState._hairline),
                  ),
                  child: Row(
                    mainAxisSize: MainAxisSize.min,
                    children: <Widget>[
                      Container(
                        width: 26,
                        height: 26,
                        decoration: BoxDecoration(
                          color: tint.withValues(alpha: 0.16),
                          shape: BoxShape.circle,
                          border:
                              Border.all(color: tint.withValues(alpha: 0.5)),
                        ),
                        alignment: Alignment.center,
                        child: Text(
                          ticker,
                          style: TextStyle(
                            fontFamily: _Web3SwapHomeScreenState._font,
                            fontSize: 8,
                            fontWeight: FontWeight.w700,
                            color: Color.lerp(tint, Colors.white, 0.4),
                          ),
                        ),
                      ),
                      const SizedBox(width: 7),
                      Text(
                        ticker,
                        style: const TextStyle(
                          fontFamily: _Web3SwapHomeScreenState._font,
                          fontSize: 14,
                          fontWeight: FontWeight.w700,
                          color: _Web3SwapHomeScreenState._text,
                        ),
                      ),
                      const Icon(Icons.keyboard_arrow_down_rounded,
                          size: 20, color: _Web3SwapHomeScreenState._muted),
                    ],
                  ),
                ),
              ),
              const Spacer(),
              Flexible(
                child: FittedBox(
                  fit: BoxFit.scaleDown,
                  alignment: Alignment.centerRight,
                  child: Text(
                    amount,
                    style: TextStyle(
                      fontFamily: _Web3SwapHomeScreenState._font,
                      fontSize: 28,
                      fontWeight: FontWeight.w800,
                      fontFeatures: const <FontFeature>[
                        FontFeature.tabularFigures()
                      ],
                      color: editable || amount != '0'
                          ? _Web3SwapHomeScreenState._text
                          : _Web3SwapHomeScreenState._muted,
                    ),
                  ),
                ),
              ),
            ],
          ),
          const SizedBox(height: 8),
          Text(
            sub,
            style: const TextStyle(
              fontFamily: _Web3SwapHomeScreenState._font,
              fontSize: 12,
              color: _Web3SwapHomeScreenState._muted,
            ),
          ),
        ],
      ),
    );
  }
}

class _Numpad extends StatelessWidget {
  const _Numpad({required this.onKey});

  final ValueChanged<String> onKey;

  static const List<String> _keys = <String>[
    '1', '2', '3', '4', '5', '6', '7', '8', '9', '.', '0', '<',
  ];

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 12),
      child: GridView.builder(
        shrinkWrap: true,
        physics: const NeverScrollableScrollPhysics(),
        padding: EdgeInsets.zero,
        itemCount: _keys.length,
        gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
          crossAxisCount: 3,
          childAspectRatio: 2.2,
        ),
        itemBuilder: (BuildContext context, int i) {
          final String k = _keys[i];
          return InkWell(
            borderRadius: BorderRadius.circular(12),
            onTap: () => onKey(k),
            child: Center(
              child: k == '<'
                  ? const Icon(Icons.backspace_outlined,
                      size: 22, color: _Web3SwapHomeScreenState._text)
                  : Text(
                      k,
                      style: const TextStyle(
                        fontFamily: _Web3SwapHomeScreenState._font,
                        fontSize: 24,
                        fontWeight: FontWeight.w600,
                        color: _Web3SwapHomeScreenState._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-swap-home

2. AI agent (MCP)

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

FAQ

Is this swap screen free to use in a commercial app?

Yes. Copy the code from this page, install it via the CLI command, or pull it through MCP, and ship it in a commercial wallet or exchange app. There is no licence key, no sign-up requirement and no attribution line to include.

Does it need any packages, or a special font?

No third-party packages — the whole screen is Flutter SDK widgets, and the token badges are drawn with tinted containers rather than images. The only asset is the bundled Inter font (referenced as `fontFamily: 'Inter'`), which ships alongside the Dart file; swap the constant if your app already carries a different face.

How do I replace the hardcoded ETH and SOL prices with live rates?

The maths funnels through two constants, `_ethUsd` and `_solUsd`, plus the `_rateLabel` string. Promote those to fields fed by your price API, format `_rateLabel` from them instead of hardcoding it, and everything downstream — `_outValue`, the To card, the rate row — updates on the next `setState`. Extending beyond two tokens means turning the direction getters into a token model, but the card and numpad code need no changes.

Why a custom numpad instead of a TextField with the system keyboard?

The system keyboard slides over the bottom half of the screen, hiding the To card and the Review button at exactly the moment the user wants to check the output. An in-layout numpad keeps every number visible while typing, and the string-based `_tap` handler enforces rules a keyboard cannot — one decimal point, six decimals, nine digits — without any error states.

Which Flutter version does this code require?

Flutter 3.22 or newer: the badge tints and the slippage chip use `Color.withValues(alpha: ...)`, and the constructors use super parameters. On an older SDK, replace each `withValues(alpha: x)` with `withOpacity(x)` and expand `super.key` to the `{Key? key} : super(key: key)` form.

Related screens