Web323 views

How to Build an NFT Token Detail Screen in Flutter (Full Code + Preview)

Someone who taps a single NFT wants three answers before buying: is the collection genuine, is the asking price fair, and how rare is this piece? This tutorial builds Aurum's token page for Prism #218 in Flutter: a square seeded artwork painted on canvas, a verified collection link, a price card set against the last sale, a two-column trait grid that states rarity as a share of the collection, and a bottom bar where Buy is twice the width of Send.

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

What you'll build

  • ✓A square hero artwork from a CustomPainter, sized by AspectRatio inside a scrolling ListView
  • ✓A tappable gold Prisma row with a verified badge and a View collection chevron, wired to onCollection
  • ✓A split Price / Last sale card divided by a 1px by 46px hairline
  • ✓A non-scrolling two-column traits grid where each tile reads 'Nebula', then '12% have this'
  • ✓A pinned action bar where Buy for 1.24 ETH takes flex 2 against an outlined Send

Step-by-step build

1

Create the file

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

Four callbacks and traits stored as string triples

web3_nft_detail_screen.dart
import 'dart:math' as math;

import 'package:flutter/material.dart';

/// Web3 — NFT Detail. Large procedural art, name / collection, a traits grid,
/// price + last-sale, and a buy / list pinned bar. Self-contained per
/// CONVENTIONS.md: pure Flutter, bundled Inter font, art rendered by an
/// _NftArtPainter from a fixed seed (no glyphs / no network image), forced dark
/// theme so it renders standalone as a route.
class Web3NftDetailScreen extends StatelessWidget {
  const Web3NftDetailScreen({
    super.key,
    this.onBack,
    this.onBuy,
    this.onSendNft,
    this.onCollection,
  });

  final VoidCallback? onBack;
  final VoidCallback? onBuy;
  final VoidCallback? onSendNft;
  final VoidCallback? onCollection;

  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 _text = Color(0xFFEAECEF);
  static const Color _muted = Color(0xFF848E9C);
  static const Color _hairline = Color(0xFF2B3139);

  static const List<List<String>> _traits = <List<String>>[
    <String>['Background', 'Nebula', '12%'],
    <String>['Body', 'Chrome', '6%'],
    <String>['Eyes', 'Laser', '3%'],
    <String>['Aura', 'Gold', '1%'],
  ];

`Web3NftDetailScreen` is a `StatelessWidget` taking four nullable `VoidCallback`s: `onBack`, `onBuy`, `onSendNft` and `onCollection`. The screen never buys, transfers or navigates by itself; it reports the intent and lets the host app's wallet layer decide. The Aurum tokens are repeated here as `static const` so the file stands alone. The interesting declaration is `_traits`, a `List<List<String>>` where each inner list is a positional triple: trait type, value, rarity. Rarity is stored as the string '12%' rather than a number because this screen only displays it. That keeps the demo compact, but `t[0]`, `t[1]` and `t[2]` index blindly, so a typed record or small class is the first thing to add once the data comes from an API.

App bar, share action and the square hero

web3_nft_detail_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: onBack,
            icon: const Icon(Icons.arrow_back_ios_new_rounded,
                size: 18, color: _text),
          ),
          title: const Text(
            'Prism #218',
            style: TextStyle(
              fontFamily: _font,
              fontSize: 17,
              fontWeight: FontWeight.w700,
              color: _text,
            ),
          ),
          actions: <Widget>[
            IconButton(
              onPressed: onSendNft,
              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, 8, 20, 24),
            children: <Widget>[
              ClipRRect(
                borderRadius: BorderRadius.circular(20),
                child: AspectRatio(
                  aspectRatio: 1,
                  child: CustomPaint(painter: _NftArtPainter(seed: 7714)),
                ),
              ),

The `AppBar` centres the token name 'Prism #218' at 17px `w700` and adds one action, `Icons.ios_share_rounded` in `_muted`. Note that this icon calls `onSendNft`, the same callback as the Send button at the bottom, so share and transfer both lead to one place unless you give it its own callback. The body is a `ListView` with `fromLTRB(20, 8, 20, 24)` padding inside `SafeArea(top: false)`. The hero is `ClipRRect(20)` around `AspectRatio(aspectRatio: 1)` around a childless `CustomPaint`. The `AspectRatio` matters here. A ListView gives its children unbounded height, and a childless CustomPaint would collapse to zero, so AspectRatio takes the 20px-inset width and derives an equal height, making a full-width square on any phone.

Verified collection link above the title

web3_nft_detail_screen.dart
              const SizedBox(height: 18),
              GestureDetector(
                onTap: onCollection,
                behavior: HitTestBehavior.opaque,
                child: Row(
                  children: <Widget>[
                    const Text(
                      'Prisma',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 13.5,
                        fontWeight: FontWeight.w700,
                        color: _brand,
                      ),
                    ),
                    const SizedBox(width: 4),
                    const Icon(Icons.verified_rounded, size: 15, color: _brand),
                    const Spacer(),
                    Text(
                      'View collection',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 12.5,
                        fontWeight: FontWeight.w600,
                        color: _muted,
                      ),
                    ),
                    const Icon(Icons.keyboard_arrow_right_rounded,
                        size: 18, color: _muted),
                  ],
                ),
              ),
              const SizedBox(height: 6),
              const Text(
                'Prism #218',
                style: TextStyle(
                  fontFamily: _font,
                  fontSize: 22,
                  fontWeight: FontWeight.w800,
                  color: _text,
                ),
              ),

The provenance row comes before the token name because, on a marketplace, the collection is the trust signal: a lookalike 'Prism #218' from an unverified contract is the classic scam. 'Prisma' is set at 13.5px `w700` in gold with a 15px `Icons.verified_rounded` right next to it, so the badge reads as part of the name. A `Spacer` pushes the muted 12.5px 'View collection' and its `keyboard_arrow_right_rounded` chevron to the far edge. The whole `Row` sits in a `GestureDetector` with `HitTestBehavior.opaque`, which makes the empty space the Spacer creates tappable as well. Without it, only the glyphs would respond to `onCollection`. The 22px `w800` title then sits only 6px below, so the collection and token name read as one block.

Price set against the last sale

web3_nft_detail_screen.dart
              const SizedBox(height: 16),
              Container(
                padding: const EdgeInsets.all(16),
                decoration: BoxDecoration(
                  color: _surface,
                  borderRadius: BorderRadius.circular(16),
                  border: Border.all(color: _hairline),
                ),
                child: Row(
                  children: <Widget>[
                    Expanded(
                      child: Column(
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: const <Widget>[
                          Text(
                            'Price',
                            style: TextStyle(
                              fontFamily: _font,
                              fontSize: 12,
                              color: _muted,
                            ),
                          ),
                          SizedBox(height: 4),
                          Text(
                            '1.24 ETH',
                            style: TextStyle(
                              fontFamily: _font,
                              fontSize: 19,
                              fontWeight: FontWeight.w800,
                              color: _text,
                            ),
                          ),
                          SizedBox(height: 2),
                          Text(
                            '~ \$3,407.64',
                            style: TextStyle(
                              fontFamily: _font,
                              fontSize: 12,
                              color: _muted,
                            ),
                          ),
                        ],
                      ),
                    ),
                    Container(width: 1, height: 46, color: _hairline),
                    const SizedBox(width: 16),
                    Expanded(
                      child: Column(
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: const <Widget>[
                          Text(
                            'Last sale',
                            style: TextStyle(
                              fontFamily: _font,
                              fontSize: 12,
                              color: _muted,
                            ),
                          ),
                          SizedBox(height: 4),
                          Text(
                            '0.98 ETH',
                            style: TextStyle(
                              fontFamily: _font,
                              fontSize: 19,
                              fontWeight: FontWeight.w800,
                              color: _text,
                            ),
                          ),
                          SizedBox(height: 2),
                          Text(
                            '14 days ago',
                            style: TextStyle(
                              fontFamily: _font,
                              fontSize: 12,
                              color: _muted,
                            ),
                          ),
                        ],
                      ),
                    ),
                  ],
                ),
              ),

The pricing card is a `_surface` Container with 16px padding, a 16px radius and a `_hairline` border. Inside it is a `Row` of two `Expanded` columns with the same structure: a 12px muted label, a 19px `w800` figure, then a 12px muted footnote. Pairing 'Price 1.24 ETH (~ $3,407.64)' with 'Last sale 0.98 ETH (14 days ago)' lets the buyer judge the markup at a glance, which a lone price can't do. The divider is a plain `Container(width: 1, height: 46)`. Inside a ListView the Row has no bounded height, so a `VerticalDivider` would collapse to nothing, and a fixed 46px line avoids that. The `SizedBox(width: 16)` is placed only after the line, because the left column's text is left-aligned and never reaches the divider. Both columns are `const` lists, so you'll need to remove that once the values become parameters.

Traits grid with rarity per tile

web3_nft_detail_screen.dart
              const SizedBox(height: 18),
              const Text(
                'Traits',
                style: TextStyle(
                  fontFamily: _font,
                  fontSize: 15,
                  fontWeight: FontWeight.w700,
                  color: _text,
                ),
              ),
              const SizedBox(height: 10),
              GridView.count(
                crossAxisCount: 2,
                shrinkWrap: true,
                physics: const NeverScrollableScrollPhysics(),
                mainAxisSpacing: 10,
                crossAxisSpacing: 10,
                childAspectRatio: 2.6,
                children: _traits.map((List<String> t) {
                  return Container(
                    padding: const EdgeInsets.symmetric(
                        horizontal: 12, vertical: 10),
                    decoration: BoxDecoration(
                      color: _surface,
                      borderRadius: BorderRadius.circular(12),
                      border: Border.all(color: _hairline),
                    ),
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      mainAxisAlignment: MainAxisAlignment.center,
                      children: <Widget>[
                        Text(
                          t[0].toUpperCase(),
                          style: const TextStyle(
                            fontFamily: _font,
                            fontSize: 10.5,
                            fontWeight: FontWeight.w700,
                            letterSpacing: 0.5,
                            color: _brand,
                          ),
                        ),
                        const SizedBox(height: 3),
                        Text(
                          t[1],
                          style: const TextStyle(
                            fontFamily: _font,
                            fontSize: 14,
                            fontWeight: FontWeight.w700,
                            color: _text,
                          ),
                        ),
                        const SizedBox(height: 2),
                        Text(
                          '${t[2]} have this',
                          style: const TextStyle(
                            fontFamily: _font,
                            fontSize: 11,
                            color: _muted,
                          ),
                        ),
                      ],
                    ),
                  );
                }).toList(),
              ),

Under a 15px 'Traits' heading, `GridView.count` lays the four triples out two per row with 10px spacing and `childAspectRatio: 2.6`, giving short, wide tiles. Because the grid sits inside the outer ListView, it sets `shrinkWrap: true` and `NeverScrollableScrollPhysics()`. The first gives it the height of its content instead of asking for infinite height, and the second stops it from scrolling separately inside the page. Each tile stacks three lines: `t[0].toUpperCase()` as a 10.5px gold eyebrow with 0.5 letter-spacing, the value at 14px `w700`, and `'${t[2]} have this'` in muted 11px. Writing it as a sentence makes '1% have this' mean something to a new collector. A bare '1%' doesn't. The data runs from 12% down to 1%, so the rarest trait, Gold aura, lands in the bottom-right tile.

Send versus Buy in the pinned bar

web3_nft_detail_screen.dart
        bottomNavigationBar: Container(
          decoration: const BoxDecoration(
            color: _bg,
            border: Border(top: BorderSide(color: _hairline)),
          ),
          child: SafeArea(
            top: false,
            child: Padding(
              padding: const EdgeInsets.fromLTRB(20, 12, 20, 12),
              child: Row(
                children: <Widget>[
                  Expanded(
                    child: SizedBox(
                      height: 52,
                      child: OutlinedButton(
                        onPressed: onSendNft,
                        style: OutlinedButton.styleFrom(
                          side: const BorderSide(color: _hairline),
                          shape: RoundedRectangleBorder(
                            borderRadius: BorderRadius.circular(14),
                          ),
                        ),
                        child: const Text(
                          'Send',
                          style: TextStyle(
                            fontFamily: _font,
                            fontSize: 16,
                            fontWeight: FontWeight.w700,
                            color: _text,
                          ),
                        ),
                      ),
                    ),
                  ),
                  const SizedBox(width: 12),
                  Expanded(
                    flex: 2,
                    child: SizedBox(
                      height: 52,
                      child: FilledButton(
                        onPressed: onBuy,
                        style: FilledButton.styleFrom(
                          backgroundColor: _brand,
                          shape: RoundedRectangleBorder(
                            borderRadius: BorderRadius.circular(14),
                          ),
                        ),
                        child: const Text(
                          'Buy for 1.24 ETH',
                          style: TextStyle(
                            fontFamily: _font,
                            fontSize: 16,
                            fontWeight: FontWeight.w700,
                            color: _bg,
                          ),
                        ),
                      ),
                    ),
                  ),
                ],
              ),
            ),
          ),
        ),
      ),
    );
  }
}

The actions sit in `bottomNavigationBar`, so they stay pinned while the art and traits scroll underneath. The wrapper is a `_bg` Container with a top hairline, around `SafeArea(top: false)` and 20/12 padding. Both buttons are 52px tall with 14px corners, but they aren't equal. Send is an `OutlinedButton` in a plain `Expanded`. Buy is a gold `FilledButton` in `Expanded(flex: 2)`, so it takes two thirds of the row. On a listing, purchase is the action the page exists for, and transferring a token you don't own yet is the unusual case. The Buy label is drawn in `_bg` near-black, because white on #F0B90B has too little contrast. The label also hard-codes 'Buy for 1.24 ETH', which repeats the price card, so keep the two in sync once prices are real.

The gallery's painter, with one extra circle

web3_nft_detail_screen.dart
/// Deterministic procedural NFT art: a seeded gradient backdrop with layered
/// translucent shapes (LCG, no Math.random).
class _NftArtPainter extends CustomPainter {
  const _NftArtPainter({required this.seed});

  final int seed;

  @override
  void paint(Canvas canvas, Size size) {
    int state = seed & 0x7fffffff;
    double next() {
      state = (state * 1103515245 + 12345) & 0x7fffffff;
      return (state % 10000) / 10000.0;
    }

    Color hsv(double h, double s, double v) =>
        HSVColor.fromAHSV(1, (h * 360) % 360, s, v).toColor();

    final double baseHue = next();
    canvas.drawRect(
      Offset.zero & size,
      Paint()
        ..shader = LinearGradient(
          begin: Alignment.topLeft,
          end: Alignment.bottomRight,
          colors: <Color>[
            hsv(baseHue, 0.65, 0.85),
            hsv(baseHue + 0.18, 0.7, 0.55),
          ],
        ).createShader(Offset.zero & size),
    );

    for (int i = 0; i < 6; i++) {
      final double r = (0.18 + next() * 0.34) * size.width;
      final Offset c = Offset(next() * size.width, next() * size.height);
      canvas.drawCircle(
        c,
        r,
        Paint()
          ..color = hsv(baseHue + 0.1 * i, 0.6, 0.95).withValues(alpha: 0.22),
      );
    }

    canvas.save();
    canvas.translate(size.width * 0.5, size.height * 0.5);
    canvas.rotate(next() * math.pi);
    final double s = size.width * (0.3 + next() * 0.25);
    canvas.drawRect(
      Rect.fromCenter(center: Offset.zero, width: s, height: s),
      Paint()..color = hsv(baseHue + 0.5, 0.5, 1).withValues(alpha: 0.30),
    );
    canvas.restore();
  }

  @override
  bool shouldRepaint(_NftArtPainter old) => old.seed != seed;
}

`_NftArtPainter` is the same seeded LCG artist used in the NFT gallery screen: a gradient wash from `baseHue`, translucent circles stepping 0.1 in hue, then a complementary square rotated about the centre. The only difference is the loop bound, `i < 6` here versus 5 in the gallery. That one change has a consequence. Every circle uses three `next()` draws (radius, x, y), so the sixth circle consumes draws 17 to 19, and the square's rotation and size shift to later values in the sequence. As a result, seed 7714 produces the same backdrop as the gallery's Prism #218 tile but a different square. Because the canvas is larger, the extra circle helps fill it. `shouldRepaint` compares seeds, so the new painter created on each build doesn't trigger a repaint.

Full code

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

import 'dart:math' as math;

import 'package:flutter/material.dart';

/// Web3 — NFT Detail. Large procedural art, name / collection, a traits grid,
/// price + last-sale, and a buy / list pinned bar. Self-contained per
/// CONVENTIONS.md: pure Flutter, bundled Inter font, art rendered by an
/// _NftArtPainter from a fixed seed (no glyphs / no network image), forced dark
/// theme so it renders standalone as a route.
class Web3NftDetailScreen extends StatelessWidget {
  const Web3NftDetailScreen({
    super.key,
    this.onBack,
    this.onBuy,
    this.onSendNft,
    this.onCollection,
  });

  final VoidCallback? onBack;
  final VoidCallback? onBuy;
  final VoidCallback? onSendNft;
  final VoidCallback? onCollection;

  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 _text = Color(0xFFEAECEF);
  static const Color _muted = Color(0xFF848E9C);
  static const Color _hairline = Color(0xFF2B3139);

  static const List<List<String>> _traits = <List<String>>[
    <String>['Background', 'Nebula', '12%'],
    <String>['Body', 'Chrome', '6%'],
    <String>['Eyes', 'Laser', '3%'],
    <String>['Aura', 'Gold', '1%'],
  ];

  @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: onBack,
            icon: const Icon(Icons.arrow_back_ios_new_rounded,
                size: 18, color: _text),
          ),
          title: const Text(
            'Prism #218',
            style: TextStyle(
              fontFamily: _font,
              fontSize: 17,
              fontWeight: FontWeight.w700,
              color: _text,
            ),
          ),
          actions: <Widget>[
            IconButton(
              onPressed: onSendNft,
              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, 8, 20, 24),
            children: <Widget>[
              ClipRRect(
                borderRadius: BorderRadius.circular(20),
                child: AspectRatio(
                  aspectRatio: 1,
                  child: CustomPaint(painter: _NftArtPainter(seed: 7714)),
                ),
              ),
              const SizedBox(height: 18),
              GestureDetector(
                onTap: onCollection,
                behavior: HitTestBehavior.opaque,
                child: Row(
                  children: <Widget>[
                    const Text(
                      'Prisma',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 13.5,
                        fontWeight: FontWeight.w700,
                        color: _brand,
                      ),
                    ),
                    const SizedBox(width: 4),
                    const Icon(Icons.verified_rounded, size: 15, color: _brand),
                    const Spacer(),
                    Text(
                      'View collection',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 12.5,
                        fontWeight: FontWeight.w600,
                        color: _muted,
                      ),
                    ),
                    const Icon(Icons.keyboard_arrow_right_rounded,
                        size: 18, color: _muted),
                  ],
                ),
              ),
              const SizedBox(height: 6),
              const Text(
                'Prism #218',
                style: TextStyle(
                  fontFamily: _font,
                  fontSize: 22,
                  fontWeight: FontWeight.w800,
                  color: _text,
                ),
              ),
              const SizedBox(height: 16),
              Container(
                padding: const EdgeInsets.all(16),
                decoration: BoxDecoration(
                  color: _surface,
                  borderRadius: BorderRadius.circular(16),
                  border: Border.all(color: _hairline),
                ),
                child: Row(
                  children: <Widget>[
                    Expanded(
                      child: Column(
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: const <Widget>[
                          Text(
                            'Price',
                            style: TextStyle(
                              fontFamily: _font,
                              fontSize: 12,
                              color: _muted,
                            ),
                          ),
                          SizedBox(height: 4),
                          Text(
                            '1.24 ETH',
                            style: TextStyle(
                              fontFamily: _font,
                              fontSize: 19,
                              fontWeight: FontWeight.w800,
                              color: _text,
                            ),
                          ),
                          SizedBox(height: 2),
                          Text(
                            '~ \$3,407.64',
                            style: TextStyle(
                              fontFamily: _font,
                              fontSize: 12,
                              color: _muted,
                            ),
                          ),
                        ],
                      ),
                    ),
                    Container(width: 1, height: 46, color: _hairline),
                    const SizedBox(width: 16),
                    Expanded(
                      child: Column(
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: const <Widget>[
                          Text(
                            'Last sale',
                            style: TextStyle(
                              fontFamily: _font,
                              fontSize: 12,
                              color: _muted,
                            ),
                          ),
                          SizedBox(height: 4),
                          Text(
                            '0.98 ETH',
                            style: TextStyle(
                              fontFamily: _font,
                              fontSize: 19,
                              fontWeight: FontWeight.w800,
                              color: _text,
                            ),
                          ),
                          SizedBox(height: 2),
                          Text(
                            '14 days ago',
                            style: TextStyle(
                              fontFamily: _font,
                              fontSize: 12,
                              color: _muted,
                            ),
                          ),
                        ],
                      ),
                    ),
                  ],
                ),
              ),
              const SizedBox(height: 18),
              const Text(
                'Traits',
                style: TextStyle(
                  fontFamily: _font,
                  fontSize: 15,
                  fontWeight: FontWeight.w700,
                  color: _text,
                ),
              ),
              const SizedBox(height: 10),
              GridView.count(
                crossAxisCount: 2,
                shrinkWrap: true,
                physics: const NeverScrollableScrollPhysics(),
                mainAxisSpacing: 10,
                crossAxisSpacing: 10,
                childAspectRatio: 2.6,
                children: _traits.map((List<String> t) {
                  return Container(
                    padding: const EdgeInsets.symmetric(
                        horizontal: 12, vertical: 10),
                    decoration: BoxDecoration(
                      color: _surface,
                      borderRadius: BorderRadius.circular(12),
                      border: Border.all(color: _hairline),
                    ),
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      mainAxisAlignment: MainAxisAlignment.center,
                      children: <Widget>[
                        Text(
                          t[0].toUpperCase(),
                          style: const TextStyle(
                            fontFamily: _font,
                            fontSize: 10.5,
                            fontWeight: FontWeight.w700,
                            letterSpacing: 0.5,
                            color: _brand,
                          ),
                        ),
                        const SizedBox(height: 3),
                        Text(
                          t[1],
                          style: const TextStyle(
                            fontFamily: _font,
                            fontSize: 14,
                            fontWeight: FontWeight.w700,
                            color: _text,
                          ),
                        ),
                        const SizedBox(height: 2),
                        Text(
                          '${t[2]} have this',
                          style: const TextStyle(
                            fontFamily: _font,
                            fontSize: 11,
                            color: _muted,
                          ),
                        ),
                      ],
                    ),
                  );
                }).toList(),
              ),
            ],
          ),
        ),
        bottomNavigationBar: Container(
          decoration: const BoxDecoration(
            color: _bg,
            border: Border(top: BorderSide(color: _hairline)),
          ),
          child: SafeArea(
            top: false,
            child: Padding(
              padding: const EdgeInsets.fromLTRB(20, 12, 20, 12),
              child: Row(
                children: <Widget>[
                  Expanded(
                    child: SizedBox(
                      height: 52,
                      child: OutlinedButton(
                        onPressed: onSendNft,
                        style: OutlinedButton.styleFrom(
                          side: const BorderSide(color: _hairline),
                          shape: RoundedRectangleBorder(
                            borderRadius: BorderRadius.circular(14),
                          ),
                        ),
                        child: const Text(
                          'Send',
                          style: TextStyle(
                            fontFamily: _font,
                            fontSize: 16,
                            fontWeight: FontWeight.w700,
                            color: _text,
                          ),
                        ),
                      ),
                    ),
                  ),
                  const SizedBox(width: 12),
                  Expanded(
                    flex: 2,
                    child: SizedBox(
                      height: 52,
                      child: FilledButton(
                        onPressed: onBuy,
                        style: FilledButton.styleFrom(
                          backgroundColor: _brand,
                          shape: RoundedRectangleBorder(
                            borderRadius: BorderRadius.circular(14),
                          ),
                        ),
                        child: const Text(
                          'Buy for 1.24 ETH',
                          style: TextStyle(
                            fontFamily: _font,
                            fontSize: 16,
                            fontWeight: FontWeight.w700,
                            color: _bg,
                          ),
                        ),
                      ),
                    ),
                  ),
                ],
              ),
            ),
          ),
        ),
      ),
    );
  }
}

/// Deterministic procedural NFT art: a seeded gradient backdrop with layered
/// translucent shapes (LCG, no Math.random).
class _NftArtPainter extends CustomPainter {
  const _NftArtPainter({required this.seed});

  final int seed;

  @override
  void paint(Canvas canvas, Size size) {
    int state = seed & 0x7fffffff;
    double next() {
      state = (state * 1103515245 + 12345) & 0x7fffffff;
      return (state % 10000) / 10000.0;
    }

    Color hsv(double h, double s, double v) =>
        HSVColor.fromAHSV(1, (h * 360) % 360, s, v).toColor();

    final double baseHue = next();
    canvas.drawRect(
      Offset.zero & size,
      Paint()
        ..shader = LinearGradient(
          begin: Alignment.topLeft,
          end: Alignment.bottomRight,
          colors: <Color>[
            hsv(baseHue, 0.65, 0.85),
            hsv(baseHue + 0.18, 0.7, 0.55),
          ],
        ).createShader(Offset.zero & size),
    );

    for (int i = 0; i < 6; i++) {
      final double r = (0.18 + next() * 0.34) * size.width;
      final Offset c = Offset(next() * size.width, next() * size.height);
      canvas.drawCircle(
        c,
        r,
        Paint()
          ..color = hsv(baseHue + 0.1 * i, 0.6, 0.95).withValues(alpha: 0.22),
      );
    }

    canvas.save();
    canvas.translate(size.width * 0.5, size.height * 0.5);
    canvas.rotate(next() * math.pi);
    final double s = size.width * (0.3 + next() * 0.25);
    canvas.drawRect(
      Rect.fromCenter(center: Offset.zero, width: s, height: s),
      Paint()..color = hsv(baseHue + 0.5, 0.5, 1).withValues(alpha: 0.30),
    );
    canvas.restore();
  }

  @override
  bool shouldRepaint(_NftArtPainter old) => old.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-nft-detail

2. AI agent (MCP)

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

FAQ

Can I ship this NFT detail screen in a commercial app?

Yes. Copy it from this page, install it with the CLI, or fetch it over MCP, then use it in a paid wallet or marketplace. There's no licence key to set up and no attribution to add.

Does it need any packages or a font download?

No packages. The file imports only `dart:math`, which the painter uses for `math.pi`, and `material.dart`. The artwork is drawn on canvas, so there are no image assets either. Every `TextStyle` uses `fontFamily: 'Inter'`, which comes bundled with the screen. If you don't register it in pubspec.yaml, Flutter falls back silently to the platform font.

What Flutter version is required?

Flutter 3.22 or later. The painter tints its circles and square with `Color.withValues(alpha: 0.22)` and `withValues(alpha: 0.30)`, and the constructor uses `super.key`. On an older SDK, change these to `withOpacity(0.22)` and `withOpacity(0.30)` and write the constructor as `{Key? key, this.onBack, ...} : super(key: key)`.

Why doesn't the hero match the Prism #218 thumbnail in the gallery when both use seed 7714?

The detail painter draws six circles and the gallery's draws five. Each circle takes three numbers from the same seeded sequence, so the backdrop gradient and the first five circles match, but the rotated square here uses later numbers and comes out at a different angle and size. If you want a continuous gallery-to-detail transition (for example with a `Hero`), share one painter class between both screens.

How do I fill this with a real token's data?

Add constructor fields for the name, collection, verified flag, price, last sale and a list of traits. Replace the string triples with a small `Trait` class and calculate rarity as the number of tokens with that value divided by the collection supply. Most NFT indexer APIs return those counts. Use the price field in both the card and the Buy label, and give the share icon its own callback instead of reusing `onSendNft`.

Related screens