Web364 views

How to Build an Enable Biometrics Screen in Flutter (Full Code + Preview)

Biometric opt-in screens fail when they read like a system permission dialog — users tap skip because nobody told them what they gain. This tutorial builds Aurum's enable-biometrics screen for a Flutter crypto wallet: a Face ID glyph drawn entirely with a CustomPainter inside a gold-tinted circle, a headline that sells speed rather than security jargon, a three-benefit card with green icon tiles, and a gold enable button ranked above a quiet 'Maybe later' escape. The UI is auth-agnostic — two callbacks wire it to local_auth or any biometric service.

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

What you'll build

  • ✓A Face ID glyph painted from scratch — bracketed scan corners, eyes, an L-shaped nose and a bezier smile — with no icon pack or asset file
  • ✓A benefit card that reuses one small _Benefit widget three times, each with a green-tinted rounded icon tile
  • ✓A gold Material + InkWell enable button ranked above a muted 'Maybe later' TextButton
  • ✓A Spacer-driven vertical layout that centres the pitch and pins the actions to the bottom on any screen height
  • ✓An onEnable / onSkip callback surface that leaves the actual biometric wiring to your auth layer

Step-by-step build

1

Create the file

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

Two callbacks and a Binance-style palette

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

/// Web3 security — Enable Biometrics. A prompt with a painted Face/Touch ID
/// glyph, a benefit list and enable/skip actions. Self-contained per
/// CONVENTIONS.md: pure Flutter, bundled Inter font, painted icon (no network
/// image), forced dark theme so it renders standalone as a route.
class Web3SecurityEnableBiometricScreen extends StatelessWidget {
  const Web3SecurityEnableBiometricScreen({
    super.key,
    this.onEnable,
    this.onSkip,
  });

  final VoidCallback? onEnable;
  final VoidCallback? onSkip;

  static const String _font = 'Inter';
  static const Color _bg = Color(0xFF0B0E11);
  static const Color _surface = Color(0xFF161A1E);
  static const Color _brand = Color(0xFFF0B90B);
  static const Color _up = Color(0xFF2EBD85);
  static const Color _text = Color(0xFFEAECEF);
  static const Color _muted = Color(0xFF848E9C);
  static const Color _hairline = Color(0xFF2B3139);

The screen is a `StatelessWidget` taking only `onEnable` and `onSkip` — both nullable `VoidCallback`s, because an opt-in prompt holds no state of its own; the decision belongs to whoever presented it. The palette is Binance-flavoured dark: `#0B0E11` background, `#161A1E` surface, `#2B3139` hairline, and two accents doing different jobs. Gold `#F0B90B` (`_brand`) is the wallet's identity colour and marks the glyph and the enable button; green `#2EBD85` (`_up`) — the colour crypto UIs use for gains — tints the benefit icons, quietly framing each benefit as a win. `_font` pins everything to the bundled Inter face.

Forced dark theme and the glyph badge

web3_security_enable_biometric_screen.dart
  @override
  Widget build(BuildContext context) {
    return Theme(
      data: ThemeData.dark(useMaterial3: true),
      child: Scaffold(
        backgroundColor: _bg,
        body: SafeArea(
          child: Padding(
            padding: const EdgeInsets.fromLTRB(24, 24, 24, 24),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.stretch,
              children: <Widget>[
                const Spacer(),
                Center(
                  child: Container(
                    width: 116,
                    height: 116,
                    decoration: BoxDecoration(
                      color: _brand.withValues(alpha: 0.12),
                      shape: BoxShape.circle,
                    ),
                    child: const Padding(
                      padding: EdgeInsets.all(28),
                      child: CustomPaint(painter: _FaceIdPainter()),
                    ),
                  ),
                ),

`build` wraps the `Scaffold` in `Theme(data: ThemeData.dark(useMaterial3: true))` so the screen renders correctly as a standalone route even inside a light app — ripples, text defaults and the `TextButton` all inherit dark styling without per-widget overrides. Inside `SafeArea`, a stretched `Column` opens with `const Spacer()`, the first half of the layout trick that centres the content block. The badge is a 116px circle filled with `_brand.withValues(alpha: 0.12)` — a 12% gold wash rather than solid gold, so the glyph's full-strength strokes stay the loudest element. `EdgeInsets.all(28)` insets the `CustomPaint` to a 60px drawing box, and because `_FaceIdPainter` is `const`, the whole badge subtree is compile-time constant.

A headline that sells speed, not security

web3_security_enable_biometric_screen.dart
                const SizedBox(height: 32),
                const Text(
                  'Unlock faster with Face ID',
                  textAlign: TextAlign.center,
                  style: TextStyle(
                    fontFamily: _font,
                    fontSize: 25,
                    fontWeight: FontWeight.w700,
                    letterSpacing: -0.3,
                    color: _text,
                  ),
                ),
                const SizedBox(height: 10),
                const Text(
                  'Use biometrics to open Aurum and approve transactions '
                  'without typing your passcode.',
                  textAlign: TextAlign.center,
                  style: TextStyle(
                    fontFamily: _font,
                    fontSize: 14.5,
                    height: 1.45,
                    color: _muted,
                  ),
                ),

The headline is 'Unlock faster with Face ID' — 25px `w700` with `letterSpacing: -0.3` — and the word choice is the interesting decision. Security prompts that lead with 'Secure your wallet' imply the user is currently unsafe; leading with speed frames enabling biometrics as a convenience upgrade, which converts better for an optional step. The subtitle names the two concrete moments biometrics will replace the passcode — opening Aurum and approving transactions — in muted `#848E9C` at 14.5px with `height: 1.45`. It is written as two adjacent string literals that Dart concatenates at compile time, keeping the source under line length without a runtime `+`.

The three-benefit card

web3_security_enable_biometric_screen.dart
                const SizedBox(height: 28),
                Container(
                  padding: const EdgeInsets.all(16),
                  decoration: BoxDecoration(
                    color: _surface,
                    borderRadius: BorderRadius.circular(16),
                    border: Border.all(color: _hairline),
                  ),
                  child: const Column(
                    children: <Widget>[
                      _Benefit(
                          icon: Icons.flash_on_rounded,
                          text: 'Open the app in an instant'),
                      SizedBox(height: 14),
                      _Benefit(
                          icon: Icons.verified_user_outlined,
                          text: 'Approve sends with a glance'),
                      SizedBox(height: 14),
                      _Benefit(
                          icon: Icons.phonelink_lock_outlined,
                          text: 'Biometrics stay on this device'),
                    ],
                  ),
                ),

The card is a `_surface` container with a 16px radius and a `_hairline` border — the border matters on a near-black background, where an elevation shadow would be invisible. Because `_Benefit` has a const constructor, the entire `Column` of three rows and two 14px gaps is a single `const` expression. The rows are ordered as an objection-handling sequence: `Icons.flash_on_rounded` for 'Open the app in an instant' (the hook), `Icons.verified_user_outlined` for 'Approve sends with a glance' (the daily payoff), and `Icons.phonelink_lock_outlined` for 'Biometrics stay on this device' — placed last because privacy is the objection a crypto user is quietly holding, and answering it is what earns the tap.

Ranked actions: gold fill versus quiet text

web3_security_enable_biometric_screen.dart
                const Spacer(),
                SizedBox(
                  height: 54,
                  child: Material(
                    color: _brand,
                    borderRadius: BorderRadius.circular(14),
                    child: InkWell(
                      borderRadius: BorderRadius.circular(14),
                      onTap: onEnable,
                      child: const Center(
                        child: Text(
                          'Enable Face ID',
                          style: TextStyle(
                            fontFamily: _font,
                            fontSize: 16,
                            fontWeight: FontWeight.w600,
                            color: _bg,
                          ),
                        ),
                      ),
                    ),
                  ),
                ),
                const SizedBox(height: 8),
                SizedBox(
                  height: 48,
                  child: TextButton(
                    onPressed: onSkip,
                    child: const Text(
                      'Maybe later',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 15,
                        fontWeight: FontWeight.w600,
                        color: _muted,
                      ),
                    ),
                  ),
                ),

A second `Spacer` pushes both actions to the bottom, so the pitch stays centred while the buttons pin to the thumb zone on any device height. The enable button is deliberately not a `FilledButton`: a 54px `SizedBox` wraps `Material(color: _brand)` with an `InkWell` whose `borderRadius` matches the Material's 14px, giving exact control of the fill and a correctly clipped ripple. Its label is `color: _bg` — near-black on gold, the highest-contrast pairing on the screen. The skip path is a 48px `TextButton` reading 'Maybe later' in `_muted` grey: present enough to be honest, quiet enough that the eye lands on gold first. Only 8px separates them — the ranking is carried by colour, not distance.

The reusable _Benefit row

web3_security_enable_biometric_screen.dart
class _Benefit extends StatelessWidget {
  const _Benefit({required this.icon, required this.text});

  final IconData icon;
  final String text;

  @override
  Widget build(BuildContext context) {
    return Row(
      children: <Widget>[
        Container(
          width: 34,
          height: 34,
          decoration: BoxDecoration(
            color: Web3SecurityEnableBiometricScreen._up.withValues(alpha: 0.14),
            borderRadius: BorderRadius.circular(10),
          ),
          child: Icon(icon,
              size: 18, color: Web3SecurityEnableBiometricScreen._up),
        ),
        const SizedBox(width: 12),
        Expanded(
          child: Text(
            text,
            style: const TextStyle(
              fontFamily: Web3SecurityEnableBiometricScreen._font,
              fontSize: 14,
              fontWeight: FontWeight.w500,
              color: Web3SecurityEnableBiometricScreen._text,
            ),
          ),
        ),
      ],
    );
  }
}

`_Benefit` takes an `IconData` and a `String` and renders one row: a 34px square with a 10px radius, filled with `_up.withValues(alpha: 0.14)` and holding an 18px icon in full `_up` green — the tint-behind-solid pattern that gives each icon a seat without a heavy block of colour. It reaches into the parent's private constants (`Web3SecurityEnableBiometricScreen._up`, `._font`, `._text`), which works because both classes share a library file and keeps the palette in exactly one place. The text sits in `Expanded` after a 12px gap, so a longer benefit or a large accessibility font wraps within the row instead of overflowing the card.

Painting the Face ID glyph

web3_security_enable_biometric_screen.dart
class _FaceIdPainter extends CustomPainter {
  const _FaceIdPainter();

  @override
  void paint(Canvas canvas, Size size) {
    final double w = size.width, h = size.height;
    final Paint stroke = Paint()
      ..style = PaintingStyle.stroke
      ..strokeWidth = 3
      ..strokeCap = StrokeCap.round
      ..strokeJoin = StrokeJoin.round
      ..color = const Color(0xFFF0B90B);

    // Frame corners.
    const double r = 6;
    void corner(double x, double y, double dx, double dy) {
      final Path p = Path()
        ..moveTo(x, y + dy * r * 2)
        ..lineTo(x, y + dy * r)
        ..arcToPoint(Offset(x + dx * r, y),
            radius: const Radius.circular(r), clockwise: dx * dy < 0)
        ..lineTo(x + dx * r * 2, y);
      canvas.drawPath(p, stroke);
    }

    corner(0, 0, 1, 1);
    corner(w, 0, -1, 1);
    corner(0, h, 1, -1);
    corner(w, h, -1, -1);

    // Eyes.
    canvas.drawLine(
        Offset(w * 0.34, h * 0.36), Offset(w * 0.34, h * 0.46), stroke);
    canvas.drawLine(
        Offset(w * 0.66, h * 0.36), Offset(w * 0.66, h * 0.46), stroke);
    // Nose.
    final Path nose = Path()
      ..moveTo(w * 0.5, h * 0.42)
      ..lineTo(w * 0.5, h * 0.56)
      ..lineTo(w * 0.44, h * 0.56);
    canvas.drawPath(nose, stroke);
    // Smile.
    final Path smile = Path()
      ..moveTo(w * 0.34, h * 0.66)
      ..quadraticBezierTo(w * 0.5, h * 0.78, w * 0.66, h * 0.66);
    canvas.drawPath(smile, stroke);
  }

  @override
  bool shouldRepaint(_FaceIdPainter oldDelegate) => false;
}

`_FaceIdPainter` uses a single gold stroke `Paint` — 3px, `StrokeCap.round`, `StrokeJoin.round` — so every element shares the same soft line quality. The clever part is the local `corner` closure: it draws one scan bracket as a line, a 6px `arcToPoint`, and a second line, with `dx`/`dy` direction signs mirroring it into place. The four calls at (0,0), (w,0), (0,h) and (w,h) produce all four brackets from one path definition, and `clockwise: dx * dy < 0` is what flips the arc's sweep per quadrant so every corner curves outward. The face inside is minimal: two vertical eye strokes at 34% and 66% width, an L-shaped nose path, and a smile as a single `quadraticBezierTo` dipping to 78% height. Every coordinate is a fraction of `size`, so the glyph scales with its box, and `shouldRepaint` returns `false` because nothing it draws ever changes.

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 security — Enable Biometrics. A prompt with a painted Face/Touch ID
/// glyph, a benefit list and enable/skip actions. Self-contained per
/// CONVENTIONS.md: pure Flutter, bundled Inter font, painted icon (no network
/// image), forced dark theme so it renders standalone as a route.
class Web3SecurityEnableBiometricScreen extends StatelessWidget {
  const Web3SecurityEnableBiometricScreen({
    super.key,
    this.onEnable,
    this.onSkip,
  });

  final VoidCallback? onEnable;
  final VoidCallback? onSkip;

  static const String _font = 'Inter';
  static const Color _bg = Color(0xFF0B0E11);
  static const Color _surface = Color(0xFF161A1E);
  static const Color _brand = Color(0xFFF0B90B);
  static const Color _up = Color(0xFF2EBD85);
  static const Color _text = Color(0xFFEAECEF);
  static const Color _muted = Color(0xFF848E9C);
  static const Color _hairline = Color(0xFF2B3139);

  @override
  Widget build(BuildContext context) {
    return Theme(
      data: ThemeData.dark(useMaterial3: true),
      child: Scaffold(
        backgroundColor: _bg,
        body: SafeArea(
          child: Padding(
            padding: const EdgeInsets.fromLTRB(24, 24, 24, 24),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.stretch,
              children: <Widget>[
                const Spacer(),
                Center(
                  child: Container(
                    width: 116,
                    height: 116,
                    decoration: BoxDecoration(
                      color: _brand.withValues(alpha: 0.12),
                      shape: BoxShape.circle,
                    ),
                    child: const Padding(
                      padding: EdgeInsets.all(28),
                      child: CustomPaint(painter: _FaceIdPainter()),
                    ),
                  ),
                ),
                const SizedBox(height: 32),
                const Text(
                  'Unlock faster with Face ID',
                  textAlign: TextAlign.center,
                  style: TextStyle(
                    fontFamily: _font,
                    fontSize: 25,
                    fontWeight: FontWeight.w700,
                    letterSpacing: -0.3,
                    color: _text,
                  ),
                ),
                const SizedBox(height: 10),
                const Text(
                  'Use biometrics to open Aurum and approve transactions '
                  'without typing your passcode.',
                  textAlign: TextAlign.center,
                  style: TextStyle(
                    fontFamily: _font,
                    fontSize: 14.5,
                    height: 1.45,
                    color: _muted,
                  ),
                ),
                const SizedBox(height: 28),
                Container(
                  padding: const EdgeInsets.all(16),
                  decoration: BoxDecoration(
                    color: _surface,
                    borderRadius: BorderRadius.circular(16),
                    border: Border.all(color: _hairline),
                  ),
                  child: const Column(
                    children: <Widget>[
                      _Benefit(
                          icon: Icons.flash_on_rounded,
                          text: 'Open the app in an instant'),
                      SizedBox(height: 14),
                      _Benefit(
                          icon: Icons.verified_user_outlined,
                          text: 'Approve sends with a glance'),
                      SizedBox(height: 14),
                      _Benefit(
                          icon: Icons.phonelink_lock_outlined,
                          text: 'Biometrics stay on this device'),
                    ],
                  ),
                ),
                const Spacer(),
                SizedBox(
                  height: 54,
                  child: Material(
                    color: _brand,
                    borderRadius: BorderRadius.circular(14),
                    child: InkWell(
                      borderRadius: BorderRadius.circular(14),
                      onTap: onEnable,
                      child: const Center(
                        child: Text(
                          'Enable Face ID',
                          style: TextStyle(
                            fontFamily: _font,
                            fontSize: 16,
                            fontWeight: FontWeight.w600,
                            color: _bg,
                          ),
                        ),
                      ),
                    ),
                  ),
                ),
                const SizedBox(height: 8),
                SizedBox(
                  height: 48,
                  child: TextButton(
                    onPressed: onSkip,
                    child: const Text(
                      'Maybe later',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 15,
                        fontWeight: FontWeight.w600,
                        color: _muted,
                      ),
                    ),
                  ),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

class _Benefit extends StatelessWidget {
  const _Benefit({required this.icon, required this.text});

  final IconData icon;
  final String text;

  @override
  Widget build(BuildContext context) {
    return Row(
      children: <Widget>[
        Container(
          width: 34,
          height: 34,
          decoration: BoxDecoration(
            color: Web3SecurityEnableBiometricScreen._up.withValues(alpha: 0.14),
            borderRadius: BorderRadius.circular(10),
          ),
          child: Icon(icon,
              size: 18, color: Web3SecurityEnableBiometricScreen._up),
        ),
        const SizedBox(width: 12),
        Expanded(
          child: Text(
            text,
            style: const TextStyle(
              fontFamily: Web3SecurityEnableBiometricScreen._font,
              fontSize: 14,
              fontWeight: FontWeight.w500,
              color: Web3SecurityEnableBiometricScreen._text,
            ),
          ),
        ),
      ],
    );
  }
}

class _FaceIdPainter extends CustomPainter {
  const _FaceIdPainter();

  @override
  void paint(Canvas canvas, Size size) {
    final double w = size.width, h = size.height;
    final Paint stroke = Paint()
      ..style = PaintingStyle.stroke
      ..strokeWidth = 3
      ..strokeCap = StrokeCap.round
      ..strokeJoin = StrokeJoin.round
      ..color = const Color(0xFFF0B90B);

    // Frame corners.
    const double r = 6;
    void corner(double x, double y, double dx, double dy) {
      final Path p = Path()
        ..moveTo(x, y + dy * r * 2)
        ..lineTo(x, y + dy * r)
        ..arcToPoint(Offset(x + dx * r, y),
            radius: const Radius.circular(r), clockwise: dx * dy < 0)
        ..lineTo(x + dx * r * 2, y);
      canvas.drawPath(p, stroke);
    }

    corner(0, 0, 1, 1);
    corner(w, 0, -1, 1);
    corner(0, h, 1, -1);
    corner(w, h, -1, -1);

    // Eyes.
    canvas.drawLine(
        Offset(w * 0.34, h * 0.36), Offset(w * 0.34, h * 0.46), stroke);
    canvas.drawLine(
        Offset(w * 0.66, h * 0.36), Offset(w * 0.66, h * 0.46), stroke);
    // Nose.
    final Path nose = Path()
      ..moveTo(w * 0.5, h * 0.42)
      ..lineTo(w * 0.5, h * 0.56)
      ..lineTo(w * 0.44, h * 0.56);
    canvas.drawPath(nose, stroke);
    // Smile.
    final Path smile = Path()
      ..moveTo(w * 0.34, h * 0.66)
      ..quadraticBezierTo(w * 0.5, h * 0.78, w * 0.66, h * 0.66);
    canvas.drawPath(smile, stroke);
  }

  @override
  bool shouldRepaint(_FaceIdPainter oldDelegate) => false;
}

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-security-enable-biometric

2. AI agent (MCP)

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

FAQ

Can I use this biometrics screen in a commercial app?

Yes. The screen is free for personal and commercial projects alike — copy the code from this page, install it with the CLI, or pull it through MCP. There is no licence key to enter and no attribution required.

How do I connect this to real Face ID or fingerprint auth?

The UI is deliberately auth-agnostic — it draws the prompt and exposes `onEnable` and `onSkip`, nothing more. In `onEnable`, call your biometric layer (the `local_auth` package's `authenticate()` is the usual choice), persist the user's preference on success, and navigate on. Because no auth code lives in the widget, swapping providers later touches nothing here.

Does this screen need any packages or font setup?

No third-party packages — it is pure Flutter, and the Face ID glyph is a `CustomPainter`, not an icon-pack asset. The Inter font ships bundled with the screen and is registered under the family name the code already references, so typography works out of the box.

Which Flutter version is required?

Flutter 3.22 or newer: the gold badge and the benefit tiles use `Color.withValues(alpha: ...)`, and the constructor uses `super.key`. On an older SDK, change `withValues(alpha: 0.12)` to `withOpacity(0.12)` (and 0.14 likewise) and expand the constructor to the `{Key? key} : super(key: key)` form.

My app targets Android — can I make this say Touch ID or fingerprint instead?

Yes, in two small edits: change the two 'Face ID' strings (headline and button label), and swap the glyph — replace the `CustomPaint` with `Icons.fingerprint` in gold, or keep `_FaceIdPainter`'s corner brackets and draw a fingerprint inside them. On real devices you can branch on what `local_auth` reports as the available biometric type.

Related screens