Web320 views

How to Build a Crypto Receive QR Screen in Flutter (Full Code + Preview)

Every wallet needs a receive screen, and the failure mode is well known: someone sends tokens on the wrong network to a copied address and the funds are gone. This tutorial builds Aurum's Receive screen in Flutter — a QR drawn entirely by a CustomPainter on a white card, the full Ethereum address in a bordered panel with a copy chip that flips to 'Copied', Set amount and Share actions, and an amber warning that names the risk. No packages, no image assets: one Dart file you can drop into any wallet project.

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

What you'll build

  • ✓A 29×29 QR-style matrix painted by a CustomPainter — seeded LCG modules plus three hand-drawn finder squares, no image file and no package
  • ✓A wallet-address panel showing the full 0x address across two lines, with a copy chip that swaps its icon and label to a check + 'Copied' via Clipboard.setData
  • ✓An ETH token badge and header block above a white QR card on a forced-dark #0B0E11 theme
  • ✓A reusable _ActionButton tile used twice for Set amount and Share, driven by optional callbacks
  • ✓An amber wrong-network warning card that states the actual consequence — loss of funds

Step-by-step build

1

Create the file

Add a new file at lib/web3_receive_qr/web3_receive_qr_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.

Callbacks, a nine-colour dark palette, and one bit of state

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

/// Web3 receive — Receive. A large painted (deterministic, seeded) QR for the
/// wallet address, an address chip with copy, and share / set-amount actions.
/// Self-contained per CONVENTIONS.md: pure Flutter, bundled Inter font, QR
/// CustomPaint generated from a fixed seed (no glyphs / no network image),
/// forced dark theme so it renders standalone as a route.
class Web3ReceiveQrScreen extends StatefulWidget {
  const Web3ReceiveQrScreen({
    super.key,
    this.onBack,
    this.onShare,
    this.onSetAmount,
  });

  final VoidCallback? onBack;
  final VoidCallback? onShare;
  final VoidCallback? onSetAmount;

  @override
  State<Web3ReceiveQrScreen> createState() => _Web3ReceiveQrScreenState();
}

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

  static const String _address =
      '0x8f3aA2c41B9e7d6F0c2A9b4E1d5C7a8B3e6F9D02';

  bool _copied = false;

The second import, `flutter/services.dart`, is there for one call — `Clipboard.setData` later on. `Web3ReceiveQrScreen` is a `StatefulWidget` with three nullable `VoidCallback`s (`onBack`, `onShare`, `onSetAmount`), so it previews standalone and wires into a router when you're ready. The palette is Binance-flavoured: near-black `#0B0E11` background, `#161A1E` surface and `#1E2329` raised surface, brand amber `#F0B90B`, and a deliberately separate `_warn` orange `#F0A020` — the amber means tappable everywhere on this screen, so the alert gets its own hue rather than borrowing the action colour. The demo address is a `static const` 42-character 0x string at the top of the class, one edit away from your real one, and the only mutable state on the whole screen is `bool _copied`.

Forcing dark mode and framing the route

web3_receive_qr_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(
            'Receive',
            style: TextStyle(
              fontFamily: _font,
              fontSize: 17,
              fontWeight: FontWeight.w700,
              color: _text,
            ),
          ),
          actions: <Widget>[
            IconButton(
              onPressed: widget.onShare,
              icon: const Icon(Icons.ios_share_rounded, size: 20, color: _muted),
            ),
            const SizedBox(width: 4),
          ],
        ),
        body: SafeArea(
          top: false,
          child: ListView(
            padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),

`build` wraps everything in `Theme(data: ThemeData.dark(useMaterial3: true))`, so the screen stays dark even inside a light-themed host app — it is designed to render correctly as a standalone route. The `AppBar` paints itself in the same `_bg` as the scaffold with `elevation: 0`, which makes it read as the top of the page rather than a bar sitting on it: an 18px back chevron in `_text`, a centred 17px `w700` 'Receive' title, and an `ios_share` action in `_muted` — muted because the primary Share button lives in the body and the bar copy of it is a convenience. The body is `SafeArea(top: false)` (the AppBar already owns the notch) around a `ListView` padded `fromLTRB(20, 12, 20, 24)`, so nothing clips on a short phone.

The ETH badge and the white QR card

web3_receive_qr_screen.dart
            children: <Widget>[
              Center(
                child: Column(
                  children: <Widget>[
                    Container(
                      width: 44,
                      height: 44,
                      decoration: BoxDecoration(
                        color: _brand.withValues(alpha: 0.14),
                        shape: BoxShape.circle,
                      ),
                      alignment: Alignment.center,
                      child: const Text(
                        'ETH',
                        style: TextStyle(
                          fontFamily: _font,
                          fontSize: 12,
                          fontWeight: FontWeight.w800,
                          color: _brand,
                        ),
                      ),
                    ),
                    const SizedBox(height: 10),
                    const Text(
                      'Ethereum',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 16,
                        fontWeight: FontWeight.w700,
                        color: _text,
                      ),
                    ),
                    const SizedBox(height: 2),
                    const Text(
                      'Scan to receive ETH and ERC20 tokens',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 12.5,
                        color: _muted,
                      ),
                    ),
                  ],
                ),
              ),
              const SizedBox(height: 20),
              Center(
                child: Container(
                  padding: const EdgeInsets.all(18),
                  decoration: BoxDecoration(
                    color: Colors.white,
                    borderRadius: BorderRadius.circular(20),
                  ),
                  child: SizedBox(
                    width: 220,
                    height: 220,
                    child: CustomPaint(painter: _QrPainter(seed: 20260628)),
                  ),
                ),
              ),

The header identifies the chain before the reader scans anything: a 44px circle filled with `_brand.withValues(alpha: 0.14)` holding 'ETH' at 12px `w800` in full amber — a token badge with zero logo assets — then 'Ethereum' at 16px `w700` and the 12.5px muted line 'Scan to receive ETH and ERC20 tokens'. The QR sits in a white `Container` with `borderRadius: 20` and 18px padding around a fixed 220×220 `SizedBox` hosting `CustomPaint(painter: _QrPainter(seed: 20260628))`. Two details matter here: the white card doubles as the quiet zone a scanner-style code needs against a dark app, and the fixed 220px width divides into the painter's 29 modules at ~7.6px per cell, so edges land crisply. The seed is a date-like constant — the pattern never changes between builds.

The full address and the Copy → Copied chip

web3_receive_qr_screen.dart
              const SizedBox(height: 20),
              const Text(
                'Wallet address',
                style: TextStyle(
                  fontFamily: _font,
                  fontSize: 12.5,
                  fontWeight: FontWeight.w700,
                  color: _muted,
                ),
              ),
              const SizedBox(height: 8),
              Container(
                padding: const EdgeInsets.all(14),
                decoration: BoxDecoration(
                  color: _surface,
                  borderRadius: BorderRadius.circular(14),
                  border: Border.all(color: _hairline),
                ),
                child: Row(
                  children: <Widget>[
                    const Expanded(
                      child: Text(
                        _address,
                        style: TextStyle(
                          fontFamily: _font,
                          fontSize: 13.5,
                          fontWeight: FontWeight.w600,
                          height: 1.4,
                          color: _text,
                        ),
                      ),
                    ),
                    const SizedBox(width: 12),
                    GestureDetector(
                      onTap: () {
                        Clipboard.setData(
                            const ClipboardData(text: _address));
                        setState(() => _copied = true);
                      },
                      behavior: HitTestBehavior.opaque,
                      child: Container(
                        padding: const EdgeInsets.symmetric(
                            horizontal: 12, vertical: 8),
                        decoration: BoxDecoration(
                          color: _brand.withValues(alpha: 0.14),
                          borderRadius: BorderRadius.circular(9),
                        ),
                        child: Row(
                          children: <Widget>[
                            Icon(
                              _copied
                                  ? Icons.check_rounded
                                  : Icons.copy_rounded,
                              size: 15,
                              color: _brand,
                            ),
                            const SizedBox(width: 5),
                            Text(
                              _copied ? 'Copied' : 'Copy',
                              style: const TextStyle(
                                fontFamily: _font,
                                fontSize: 12.5,
                                fontWeight: FontWeight.w700,
                                color: _brand,
                              ),
                            ),
                          ],
                        ),
                      ),
                    ),
                  ],
                ),
              ),

Under a muted 'Wallet address' caption, a `_surface` panel with a `_hairline` border holds the address in an `Expanded` `Text` at 13.5px `w600`, `height: 1.4` — the full 42 characters, wrapping onto a second line rather than truncating to 0x8f3a…9D02. On a receive screen that is the right call: this is where a user verifies what actually landed on their clipboard. The chip beside it is a `GestureDetector` with `behavior: HitTestBehavior.opaque`, so the whole pill is tappable, not just its glyphs. Its `onTap` does two things in order: `Clipboard.setData(const ClipboardData(text: _address))`, then `setState(() => _copied = true)`. Both the icon and the label are ternaries on `_copied` — `copy_rounded` becomes `check_rounded`, 'Copy' becomes 'Copied' — and the pill reuses the exact tint recipe of the ETH badge, `_brand.withValues(alpha: 0.14)` behind full-amber content.

Two equal actions and the wrong-network warning

web3_receive_qr_screen.dart
              const SizedBox(height: 16),
              Row(
                children: <Widget>[
                  Expanded(
                    child: _ActionButton(
                      icon: Icons.tune_rounded,
                      label: 'Set amount',
                      onTap: widget.onSetAmount,
                    ),
                  ),
                  const SizedBox(width: 12),
                  Expanded(
                    child: _ActionButton(
                      icon: Icons.ios_share_rounded,
                      label: 'Share',
                      onTap: widget.onShare,
                    ),
                  ),
                ],
              ),
              const SizedBox(height: 16),
              Container(
                padding: const EdgeInsets.all(14),
                decoration: BoxDecoration(
                  color: _warn.withValues(alpha: 0.10),
                  borderRadius: BorderRadius.circular(14),
                  border: Border.all(color: _warn.withValues(alpha: 0.4)),
                ),
                child: const Row(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: <Widget>[
                    Icon(Icons.warning_amber_rounded, size: 19, color: _warn),
                    SizedBox(width: 12),
                    Expanded(
                      child: Text(
                        'Send only Ethereum-network assets to this address. '
                        'Sending other networks may result in loss of funds.',
                        style: TextStyle(
                          fontFamily: _font,
                          fontSize: 12.5,
                          height: 1.45,
                          fontWeight: FontWeight.w500,
                          color: _text,
                        ),
                      ),
                    ),
                  ],
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }

'Set amount' and 'Share' are two `Expanded` `_ActionButton`s split by a 12px gap — equals, unlike a checkout's ranked buttons, because neither is the obvious next step on a receive screen. Share is wired to the same `widget.onShare` as the AppBar icon, so both entry points reach one handler. The warning card below is one colour at two alphas: `_warn.withValues(alpha: 0.10)` fill inside a `withValues(alpha: 0.4)` border of the same orange, which keeps fill and edge cohesive without a second hue. `crossAxisAlignment: CrossAxisAlignment.start` pins the 19px `warning_amber_rounded` icon to the first line of the wrapping text, and the copy says the quiet part out loud — 'may result in loss of funds' — instead of a vague 'be careful'. The whole Row is `const`; nothing in it ever changes.

_ActionButton — one tile, defined once

web3_receive_qr_screen.dart
class _ActionButton extends StatelessWidget {
  const _ActionButton({required this.icon, required this.label, this.onTap});

  final IconData icon;
  final String label;
  final VoidCallback? onTap;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onTap,
      behavior: HitTestBehavior.opaque,
      child: Container(
        padding: const EdgeInsets.symmetric(vertical: 14),
        decoration: BoxDecoration(
          color: _Web3ReceiveQrScreenState._surfaceAlt,
          borderRadius: BorderRadius.circular(12),
        ),
        child: Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Icon(icon, size: 18, color: _Web3ReceiveQrScreenState._brand),
            const SizedBox(width: 8),
            Text(
              label,
              style: const TextStyle(
                fontFamily: _Web3ReceiveQrScreenState._font,
                fontSize: 14,
                fontWeight: FontWeight.w700,
                color: _Web3ReceiveQrScreenState._text,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

`_ActionButton` is a private `StatelessWidget` taking an `IconData`, a `label`, and a nullable `onTap`, so the two body actions can never drift apart in padding or type style. It is deliberately not a `FilledButton`: the design wants a flat `#1E2329` tile with a 12px radius and no Material splash or elevation, so it's a `GestureDetector` (again `HitTestBehavior.opaque`) around a `Container` with 14px vertical padding. The centred Row pairs an 18px icon in brand amber with a 14px `w700` label in `_text`. Note how it reads its colours — `_Web3ReceiveQrScreenState._surfaceAlt`, `._brand`, `._font` — reaching into the state class's static consts, which keeps the entire screen's design tokens in one place while staying a single self-contained file.

Painting the module grid with a seeded LCG

web3_receive_qr_screen.dart
/// Deterministic QR-style matrix from a fixed seed (LCG, no Math.random).
class _QrPainter extends CustomPainter {
  const _QrPainter({required this.seed});

  final int seed;

  static const int _n = 29; // module grid size

  @override
  void paint(Canvas canvas, Size size) {
    final double cell = size.width / _n;
    final Paint dark = Paint()..color = const Color(0xFF0B0E11);

    int state = seed & 0x7fffffff;
    int next() {
      state = (state * 1103515245 + 12345) & 0x7fffffff;
      return state;
    }

    bool inFinder(int r, int c) {
      bool block(int r0, int c0) =>
          r >= r0 && r < r0 + 7 && c >= c0 && c < c0 + 7;
      return block(0, 0) || block(0, _n - 7) || block(_n - 7, 0);
    }

    for (int r = 0; r < _n; r++) {
      for (int c = 0; c < _n; c++) {
        if (inFinder(r, c)) continue;
        if (next() % 100 < 48) {
          canvas.drawRect(
            Rect.fromLTWH(c * cell, r * cell, cell, cell),
            dark,
          );
        }
      }
    }

`_QrPainter` builds a 29×29 grid (`_n = 29`), with `cell = size.width / _n` so the painter scales to whatever box it's given. Instead of importing `dart:math`, it defines a local `next()` closure implementing a linear congruential generator with the classic glibc constants — `state * 1103515245 + 12345`, masked to 31 bits with `& 0x7fffffff`. Seeded with the same number every time, it emits the same sequence every time: the pattern is deterministic, not random, which is why the code renders identically offline, in tests, and in screenshots. `inFinder` masks out three 7×7 corner blocks before drawing. The double loop then fills each remaining cell with 48% probability (`next() % 100 < 48`) — close to the roughly half-dark density of a real QR — using `#0B0E11` ink, the same near-black as the app background.

Finder squares and the repaint contract

web3_receive_qr_screen.dart
    void finder(int r0, int c0) {
      canvas.drawRect(
        Rect.fromLTWH(c0 * cell, r0 * cell, 7 * cell, 7 * cell),
        dark,
      );
      canvas.drawRect(
        Rect.fromLTWH((c0 + 1) * cell, (r0 + 1) * cell, 5 * cell, 5 * cell),
        Paint()..color = Colors.white,
      );
      canvas.drawRect(
        Rect.fromLTWH((c0 + 2) * cell, (r0 + 2) * cell, 3 * cell, 3 * cell),
        dark,
      );
    }

    finder(0, 0);
    finder(0, _n - 7);
    finder(_n - 7, 0);
  }

  @override
  bool shouldRepaint(_QrPainter oldDelegate) => oldDelegate.seed != seed;
}

The `finder` closure draws the concentric target every real QR carries in its corners: a 7×7 dark square, a 5×5 white square inset one cell, and a 3×3 dark core — three plain `drawRect` calls, no strokes to reason about. It's called for top-left `(0, 0)`, top-right `(0, _n - 7)` and bottom-left `(_n - 7, 0)`, and pointedly not bottom-right, matching the actual QR spec — that asymmetry is what makes the pattern read as a genuine code at a glance. Because `inFinder` already skipped these cells in the data loop, the squares land on clean white. `shouldRepaint` compares seeds, so the canvas only redraws if you hand it a different seed — effectively never for a const painter.

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 receive — Receive. A large painted (deterministic, seeded) QR for the
/// wallet address, an address chip with copy, and share / set-amount actions.
/// Self-contained per CONVENTIONS.md: pure Flutter, bundled Inter font, QR
/// CustomPaint generated from a fixed seed (no glyphs / no network image),
/// forced dark theme so it renders standalone as a route.
class Web3ReceiveQrScreen extends StatefulWidget {
  const Web3ReceiveQrScreen({
    super.key,
    this.onBack,
    this.onShare,
    this.onSetAmount,
  });

  final VoidCallback? onBack;
  final VoidCallback? onShare;
  final VoidCallback? onSetAmount;

  @override
  State<Web3ReceiveQrScreen> createState() => _Web3ReceiveQrScreenState();
}

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

  static const String _address =
      '0x8f3aA2c41B9e7d6F0c2A9b4E1d5C7a8B3e6F9D02';

  bool _copied = false;

  @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(
            'Receive',
            style: TextStyle(
              fontFamily: _font,
              fontSize: 17,
              fontWeight: FontWeight.w700,
              color: _text,
            ),
          ),
          actions: <Widget>[
            IconButton(
              onPressed: widget.onShare,
              icon: const Icon(Icons.ios_share_rounded, size: 20, color: _muted),
            ),
            const SizedBox(width: 4),
          ],
        ),
        body: SafeArea(
          top: false,
          child: ListView(
            padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
            children: <Widget>[
              Center(
                child: Column(
                  children: <Widget>[
                    Container(
                      width: 44,
                      height: 44,
                      decoration: BoxDecoration(
                        color: _brand.withValues(alpha: 0.14),
                        shape: BoxShape.circle,
                      ),
                      alignment: Alignment.center,
                      child: const Text(
                        'ETH',
                        style: TextStyle(
                          fontFamily: _font,
                          fontSize: 12,
                          fontWeight: FontWeight.w800,
                          color: _brand,
                        ),
                      ),
                    ),
                    const SizedBox(height: 10),
                    const Text(
                      'Ethereum',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 16,
                        fontWeight: FontWeight.w700,
                        color: _text,
                      ),
                    ),
                    const SizedBox(height: 2),
                    const Text(
                      'Scan to receive ETH and ERC20 tokens',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 12.5,
                        color: _muted,
                      ),
                    ),
                  ],
                ),
              ),
              const SizedBox(height: 20),
              Center(
                child: Container(
                  padding: const EdgeInsets.all(18),
                  decoration: BoxDecoration(
                    color: Colors.white,
                    borderRadius: BorderRadius.circular(20),
                  ),
                  child: SizedBox(
                    width: 220,
                    height: 220,
                    child: CustomPaint(painter: _QrPainter(seed: 20260628)),
                  ),
                ),
              ),
              const SizedBox(height: 20),
              const Text(
                'Wallet address',
                style: TextStyle(
                  fontFamily: _font,
                  fontSize: 12.5,
                  fontWeight: FontWeight.w700,
                  color: _muted,
                ),
              ),
              const SizedBox(height: 8),
              Container(
                padding: const EdgeInsets.all(14),
                decoration: BoxDecoration(
                  color: _surface,
                  borderRadius: BorderRadius.circular(14),
                  border: Border.all(color: _hairline),
                ),
                child: Row(
                  children: <Widget>[
                    const Expanded(
                      child: Text(
                        _address,
                        style: TextStyle(
                          fontFamily: _font,
                          fontSize: 13.5,
                          fontWeight: FontWeight.w600,
                          height: 1.4,
                          color: _text,
                        ),
                      ),
                    ),
                    const SizedBox(width: 12),
                    GestureDetector(
                      onTap: () {
                        Clipboard.setData(
                            const ClipboardData(text: _address));
                        setState(() => _copied = true);
                      },
                      behavior: HitTestBehavior.opaque,
                      child: Container(
                        padding: const EdgeInsets.symmetric(
                            horizontal: 12, vertical: 8),
                        decoration: BoxDecoration(
                          color: _brand.withValues(alpha: 0.14),
                          borderRadius: BorderRadius.circular(9),
                        ),
                        child: Row(
                          children: <Widget>[
                            Icon(
                              _copied
                                  ? Icons.check_rounded
                                  : Icons.copy_rounded,
                              size: 15,
                              color: _brand,
                            ),
                            const SizedBox(width: 5),
                            Text(
                              _copied ? 'Copied' : 'Copy',
                              style: const TextStyle(
                                fontFamily: _font,
                                fontSize: 12.5,
                                fontWeight: FontWeight.w700,
                                color: _brand,
                              ),
                            ),
                          ],
                        ),
                      ),
                    ),
                  ],
                ),
              ),
              const SizedBox(height: 16),
              Row(
                children: <Widget>[
                  Expanded(
                    child: _ActionButton(
                      icon: Icons.tune_rounded,
                      label: 'Set amount',
                      onTap: widget.onSetAmount,
                    ),
                  ),
                  const SizedBox(width: 12),
                  Expanded(
                    child: _ActionButton(
                      icon: Icons.ios_share_rounded,
                      label: 'Share',
                      onTap: widget.onShare,
                    ),
                  ),
                ],
              ),
              const SizedBox(height: 16),
              Container(
                padding: const EdgeInsets.all(14),
                decoration: BoxDecoration(
                  color: _warn.withValues(alpha: 0.10),
                  borderRadius: BorderRadius.circular(14),
                  border: Border.all(color: _warn.withValues(alpha: 0.4)),
                ),
                child: const Row(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: <Widget>[
                    Icon(Icons.warning_amber_rounded, size: 19, color: _warn),
                    SizedBox(width: 12),
                    Expanded(
                      child: Text(
                        'Send only Ethereum-network assets to this address. '
                        'Sending other networks may result in loss of funds.',
                        style: TextStyle(
                          fontFamily: _font,
                          fontSize: 12.5,
                          height: 1.45,
                          fontWeight: FontWeight.w500,
                          color: _text,
                        ),
                      ),
                    ),
                  ],
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

class _ActionButton extends StatelessWidget {
  const _ActionButton({required this.icon, required this.label, this.onTap});

  final IconData icon;
  final String label;
  final VoidCallback? onTap;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onTap,
      behavior: HitTestBehavior.opaque,
      child: Container(
        padding: const EdgeInsets.symmetric(vertical: 14),
        decoration: BoxDecoration(
          color: _Web3ReceiveQrScreenState._surfaceAlt,
          borderRadius: BorderRadius.circular(12),
        ),
        child: Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Icon(icon, size: 18, color: _Web3ReceiveQrScreenState._brand),
            const SizedBox(width: 8),
            Text(
              label,
              style: const TextStyle(
                fontFamily: _Web3ReceiveQrScreenState._font,
                fontSize: 14,
                fontWeight: FontWeight.w700,
                color: _Web3ReceiveQrScreenState._text,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

/// Deterministic QR-style matrix from a fixed seed (LCG, no Math.random).
class _QrPainter extends CustomPainter {
  const _QrPainter({required this.seed});

  final int seed;

  static const int _n = 29; // module grid size

  @override
  void paint(Canvas canvas, Size size) {
    final double cell = size.width / _n;
    final Paint dark = Paint()..color = const Color(0xFF0B0E11);

    int state = seed & 0x7fffffff;
    int next() {
      state = (state * 1103515245 + 12345) & 0x7fffffff;
      return state;
    }

    bool inFinder(int r, int c) {
      bool block(int r0, int c0) =>
          r >= r0 && r < r0 + 7 && c >= c0 && c < c0 + 7;
      return block(0, 0) || block(0, _n - 7) || block(_n - 7, 0);
    }

    for (int r = 0; r < _n; r++) {
      for (int c = 0; c < _n; c++) {
        if (inFinder(r, c)) continue;
        if (next() % 100 < 48) {
          canvas.drawRect(
            Rect.fromLTWH(c * cell, r * cell, cell, cell),
            dark,
          );
        }
      }
    }

    void finder(int r0, int c0) {
      canvas.drawRect(
        Rect.fromLTWH(c0 * cell, r0 * cell, 7 * cell, 7 * cell),
        dark,
      );
      canvas.drawRect(
        Rect.fromLTWH((c0 + 1) * cell, (r0 + 1) * cell, 5 * cell, 5 * cell),
        Paint()..color = Colors.white,
      );
      canvas.drawRect(
        Rect.fromLTWH((c0 + 2) * cell, (r0 + 2) * cell, 3 * cell, 3 * cell),
        dark,
      );
    }

    finder(0, 0);
    finder(0, _n - 7);
    finder(_n - 7, 0);
  }

  @override
  bool shouldRepaint(_QrPainter oldDelegate) => oldDelegate.seed != seed;
}

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-receive-qr

2. AI agent (MCP)

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

FAQ

Is this crypto receive screen free to use commercially?

Yes — free, including commercial use. Copy the code from this page, install it via the CLI, or pull it through MCP and ship it in a production wallet. There is no licence key and no attribution requirement.

Is the painted QR code actually scannable?

No, and that's by design. `_QrPainter` draws a deterministic 29×29 pattern from a seeded LCG with real-looking finder squares, but it encodes nothing — it's a stand-in for previews and design work. To ship a scannable code, add `qr_flutter` and replace the `CustomPaint` inside the white card with `QrImageView(data: _address, size: 220)`; the card, padding and everything else stays as-is.

How do I make the 'Copied' chip reset back to 'Copy'?

As written, `_copied` flips to true on tap and stays there, which is fine for a single visit. To reset it, start a timer in the same `onTap`: `Timer(const Duration(seconds: 2), () { if (mounted) setState(() => _copied = false); })`, importing `dart:async`. The `mounted` guard matters because the user can pop the route before the timer fires.

Does this screen need any packages or font setup?

No third-party packages — the only imports are `flutter/material.dart` and `flutter/services.dart`, both from the SDK. The Inter font is bundled with the screen at `lib/web3_receive_qr/fonts/Inter.ttf` and referenced by `fontFamily: 'Inter'`, so there's no `google_fonts` dependency and no network fetch at runtime.

Which Flutter version does this code require?

Flutter 3.22 or newer: the tinted badge, copy chip and warning card all use `Color.withValues(alpha: ...)`, and the constructor uses `super.key`. On an older SDK, swap each `withValues(alpha: x)` for `withOpacity(x)` and expand the constructor to the `{Key? key} : super(key: key)` form.

Related screens