Web376 views

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

An NFT gallery lives or dies on its artwork, but a UI kit cannot ship six copyrighted JPEGs — so this screen paints its own. This tutorial builds Aurum's collectibles tab in Flutter: a dark, exchange-styled two-column grid of six NFT cards whose art is generated on-canvas by a seeded CustomPainter, with gold filter pills for All, Art, PFP and Gaming, collection labels, and tabular-figures floor prices in ETH. You finish with a self-contained gallery that runs offline with zero image assets, ready to swap in real marketplace data.

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

What you'll build

  • ✓A two-column GridView of six NFT cards at childAspectRatio 0.74, each clipped to a 16px-radius hairline-bordered tile
  • ✓Procedural NFT artwork painted by _NftArtPainter from an integer seed — an HSV gradient, five translucent circles and a rotated square, no image files
  • ✓Horizontal filter pills (All / Art / PFP / Gaming) where the active tab fills with #F0B90B gold and flips its label to the background colour
  • ✓A floor-price row per card that right-aligns an ETH figure in tabular numerals so prices line up across the grid
  • ✓A forced-dark Material 3 theme on a #0B0E11 canvas so the screen renders identically no matter what the host app's theme is

Step-by-step build

1

Create the file

Add a new file at lib/web3_nft_gallery/web3_nft_gallery_screen.dart in your Flutter project.

2

Register the bundled fonts

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

pubspec.yaml
flutter:
  fonts:
    - family: Inter
      fonts:
        - asset: fonts/Inter-Regular.ttf
3

Build it, piece by piece

Here's how the screen goes together. Each block below is a real slice of the code with a plain-English explanation — paste them in order, or grab the whole file from the next section.

Palette, tab labels and the NFT list as const data

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

import 'package:flutter/material.dart';

/// Web3 — NFT Gallery. A 2-column grid of NFTs with procedural gradient art,
/// collection label and floor chip, plus filter tabs. 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 Web3NftGalleryScreen extends StatefulWidget {
  const Web3NftGalleryScreen({super.key, this.onBack, this.onNftTap});

  final VoidCallback? onBack;
  final VoidCallback? onNftTap;

  @override
  State<Web3NftGalleryScreen> createState() => _Web3NftGalleryScreenState();
}

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

  static const List<String> _tabs = <String>['All', 'Art', 'PFP', 'Gaming'];

  static const List<_Nft> _nfts = <_Nft>[
    _Nft('Prism #218', 'Prisma', '1.24', 7714),
    _Nft('Vault Ape 04', 'VaultApes', '3.80', 1029),
    _Nft('Nebula 77', 'Nebula', '0.62', 5521),
    _Nft('Glyph Knight', 'Glyphs', '2.15', 3380),
    _Nft('Aether 12', 'Aether', '0.95', 8842),
    _Nft('Pixel Lord', 'PixelDAO', '1.50', 6207),
  ];

  int _tab = 0;

The widget takes two optional callbacks — `onBack` and `onNftTap` — so the gallery stays backend-agnostic: it renders the collection, the host app decides what tapping means. Everything visual is pinned as `static const`: an exchange-dark palette of `_bg` #0B0E11, `_surface` #161A1E, `_surfaceAlt` #1E2329, gold `_brand` #F0B90B, `_text` #EAECEF, `_muted` #848E9C and `_hairline` #2B3139. The six NFTs live in a `static const List<_Nft>` where each entry carries a name, a collection label, a floor price kept as a *string* ('1.24' — display data, not maths) and an integer seed like 7714. That seed is the whole art pipeline: it is the only input the painter gets, so 'Prism #218' draws the exact same artwork on every rebuild. The single piece of mutable state is `int _tab = 0`, the selected filter.

Forcing dark mode and a minimal app bar

web3_nft_gallery_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(
            'NFTs',
            style: TextStyle(
              fontFamily: _font,
              fontSize: 17,
              fontWeight: FontWeight.w700,
              color: _text,
            ),
          ),
        ),

`build` wraps the whole `Scaffold` in `Theme(data: ThemeData.dark(useMaterial3: true))`, which is what the file's own doc comment means by 'forced dark theme': dropped into a light app, the gallery still renders on its intended near-black canvas instead of inheriting a white background behind dark cards. The `AppBar` matches `_bg` with `elevation: 0` so it dissolves into the page rather than sitting on it, and centres a 17px `w700` 'NFTs' title in the bundled Inter font. The leading `IconButton` uses `Icons.arrow_back_ios_new_rounded` at only 18px and wires straight to `widget.onBack` — the screen never assumes it owns a `Navigator`.

Gold filter pills in a 38px horizontal rail

web3_nft_gallery_screen.dart
        body: SafeArea(
          top: false,
          child: Column(
            children: <Widget>[
              SizedBox(
                height: 38,
                child: ListView.separated(
                  scrollDirection: Axis.horizontal,
                  padding: const EdgeInsets.symmetric(horizontal: 20),
                  itemCount: _tabs.length,
                  separatorBuilder: (BuildContext context, int i) =>
                      const SizedBox(width: 8),
                  itemBuilder: (BuildContext context, int i) {
                    final bool active = _tab == i;
                    return GestureDetector(
                      onTap: () => setState(() => _tab = i),
                      behavior: HitTestBehavior.opaque,
                      child: Container(
                        padding: const EdgeInsets.symmetric(horizontal: 16),
                        alignment: Alignment.center,
                        decoration: BoxDecoration(
                          color: active ? _brand : _surfaceAlt,
                          borderRadius: BorderRadius.circular(19),
                        ),
                        child: Text(
                          _tabs[i],
                          style: TextStyle(
                            fontFamily: _font,
                            fontSize: 13,
                            fontWeight: FontWeight.w700,
                            color: active ? _bg : _muted,
                          ),
                        ),
                      ),
                    );
                  },
                ),
              ),

`SafeArea(top: false)` skips the top inset because the `AppBar` already handles it; adding it twice would double the status-bar gap. The tabs sit in a `SizedBox` locked to 38px so the rail cannot push the grid around, holding a horizontal `ListView.separated` with 8px gaps and 20px edge padding — separated beats writing `SizedBox(width: 8)` by hand because the gaps only ever land *between* pills. Each pill is a `GestureDetector` with `HitTestBehavior.opaque` (the whole pill is tappable, not just its text) around a `Container` with `BorderRadius.circular(19)` — half the rail height, so the ends are perfect semicircles. Selection is a two-property swap driven by `_tab == i`: the active pill's fill goes `_surfaceAlt` → gold `_brand`, and its 13px `w700` label flips from `_muted` grey to `_bg` — dark-on-gold, because gold text on gold would vanish. `onTap` is just `setState(() => _tab = i)`.

The 2-column grid and the card shell

web3_nft_gallery_screen.dart
              Expanded(
                child: GridView.builder(
                  padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
                  gridDelegate:
                      const SliverGridDelegateWithFixedCrossAxisCount(
                    crossAxisCount: 2,
                    mainAxisSpacing: 14,
                    crossAxisSpacing: 14,
                    childAspectRatio: 0.74,
                  ),
                  itemCount: _nfts.length,
                  itemBuilder: (BuildContext context, int i) {
                    final _Nft n = _nfts[i];
                    return GestureDetector(
                      onTap: widget.onNftTap,
                      behavior: HitTestBehavior.opaque,
                      child: Container(
                        decoration: BoxDecoration(
                          color: _surface,
                          borderRadius: BorderRadius.circular(16),
                          border: Border.all(color: _hairline),
                        ),
                        clipBehavior: Clip.antiAlias,
                        child: Column(
                          crossAxisAlignment: CrossAxisAlignment.start,
                          children: <Widget>[
                            Expanded(
                              child: SizedBox.expand(
                                child: CustomPaint(
                                  painter: _NftArtPainter(seed: n.seed),
                                ),
                              ),
                            ),

The grid uses `SliverGridDelegateWithFixedCrossAxisCount` with `crossAxisCount: 2`, 14px spacing on both axes and `childAspectRatio: 0.74` — cards taller than wide, which is the proportion that leaves room for a square-ish artwork plus three lines of metadata underneath. Padding is `fromLTRB(20, 12, 20, 24)`: 12 on top to tuck under the tab rail, 24 at the bottom so the last row is not clipped by the home indicator. Each card is a `_surface` Container with a 1px `_hairline` border, 16px corners and — critically — `clipBehavior: Clip.antiAlias`, because the painter fills its rectangle edge to edge and would otherwise paint square corners over the rounding. The artwork slot is `Expanded` → `SizedBox.expand` → `CustomPaint(painter: _NftArtPainter(seed: n.seed))`: Expanded gives it all the height the metadata does not use, and `SizedBox.expand` forces a concrete size, since a `CustomPaint` with no child and no constraints would lay out at zero.

Name, collection and the floor-price row

web3_nft_gallery_screen.dart
                            Padding(
                              padding: const EdgeInsets.fromLTRB(10, 8, 10, 10),
                              child: Column(
                                crossAxisAlignment: CrossAxisAlignment.start,
                                children: <Widget>[
                                  Text(
                                    n.name,
                                    maxLines: 1,
                                    overflow: TextOverflow.ellipsis,
                                    style: const TextStyle(
                                      fontFamily: _font,
                                      fontSize: 13.5,
                                      fontWeight: FontWeight.w700,
                                      color: _text,
                                    ),
                                  ),
                                  const SizedBox(height: 2),
                                  Text(
                                    n.collection,
                                    maxLines: 1,
                                    overflow: TextOverflow.ellipsis,
                                    style: const TextStyle(
                                      fontFamily: _font,
                                      fontSize: 11.5,
                                      color: _muted,
                                    ),
                                  ),
                                  const SizedBox(height: 8),
                                  Row(
                                    children: <Widget>[
                                      const Text(
                                        'Floor',
                                        style: TextStyle(
                                          fontFamily: _font,
                                          fontSize: 11,
                                          color: _muted,
                                        ),
                                      ),
                                      const Spacer(),
                                      Text(
                                        '${n.floor} ETH',
                                        style: const TextStyle(
                                          fontFamily: _font,
                                          fontSize: 12.5,
                                          fontWeight: FontWeight.w800,
                                          fontFeatures: <FontFeature>[
                                            FontFeature.tabularFigures()
                                          ],
                                          color: _text,
                                        ),
                                      ),
                                    ],
                                  ),
                                ],
                              ),
                            ),
                          ],
                        ),
                      ),
                    );
                  },
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

The metadata block under the art uses asymmetric `fromLTRB(10, 8, 10, 10)` padding and three tiers of type: the piece name at 13.5px `w700` in `_text`, the collection label at 11.5px in `_muted` (2px below it), then after an 8px gap the floor row. Both text lines set `maxLines: 1` with `TextOverflow.ellipsis`, because at two columns a long name like 'Vault Ape 04' has maybe 140px to live in and wrapping would make card heights ragged. The floor row is a `Row` of a muted 11px 'Floor' label, a `Spacer`, and the price — `'${n.floor} ETH'` at 12.5px `w800` with `FontFeature.tabularFigures()`. Tabular figures give every digit the same advance width, so '1.24 ETH' and '3.80 ETH' occupy identical space and the right edge of every price in the grid aligns — the same trick order books use. The `Spacer` pushes label and value to opposite edges without any width calculation.

The _Nft model

web3_nft_gallery_screen.dart
class _Nft {
  const _Nft(this.name, this.collection, this.floor, this.seed);

  final String name;
  final String collection;
  final String floor;
  final int seed;
}

`_Nft` is a four-field const value class: `name`, `collection`, `floor` and `seed`. The positional const constructor is what lets the whole six-item list up top compile into the binary as one `static const` — zero allocation at runtime. The interesting field is `seed`: an `int` standing in for what would be an image URL in production. Keeping identity (name, collection, price) separate from rendering (seed → painter) means swapping to real marketplace art later is a one-line change at the `CustomPaint` call site, not a model rewrite.

A seeded LCG and the gradient backdrop

web3_nft_gallery_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),
    );

`_NftArtPainter.paint` opens with a hand-rolled linear congruential generator: `state = (state * 1103515245 + 12345) & 0x7fffffff` — the classic C `rand()` constants — with a `next()` closure returning 0–1 by taking `state % 10000 / 10000`. Why not `math.Random(seed)`? This makes determinism *visible in the code*: the same seed yields the same draw call sequence on every frame, every device, forever, with no dependence on any library's internals. A `hsv` helper wraps `HSVColor.fromAHSV`, taking hue as a 0–1 fraction and wrapping with `% 360` so hue arithmetic can overflow harmlessly. The first random draw becomes `baseHue`, and the backdrop is a full-bleed `drawRect` with a topLeft→bottomRight `LinearGradient` shader running from `hsv(baseHue, 0.65, 0.85)` to `hsv(baseHue + 0.18, 0.7, 0.55)` — the second stop rotated 18% around the wheel and darkened, which is why every card reads as a related two-tone wash rather than a flat fill.

Circles, a rotated square, and cheap repaints

web3_nft_gallery_screen.dart
    for (int i = 0; i < 5; 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),
      );
    }

    // A bold rotated rect motif.
    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;
}

On top of the gradient, a loop stamps five translucent circles: radius 18–52% of the card width, centres anywhere on the canvas, each coloured `hsv(baseHue + 0.1 * i, 0.6, 0.95)` at `withValues(alpha: 0.22)` — the hue stepping 10% per circle so the overlaps blend into new tints instead of just darkening. Then the signature mark: `canvas.save()`, translate to the centre, `rotate(next() * math.pi)`, and a square 30–55% of the width drawn at the *complementary* hue (`baseHue + 0.5`, i.e. 180° across the wheel) at alpha 0.30, before `canvas.restore()` unwinds the transform. That one complementary shape is what makes each card look composed rather than random. `shouldRepaint` returns `old.seed != seed` — since the seed fully determines the pixels, Flutter can skip repainting all six cards on every setState from the filter tabs.

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 Gallery. A 2-column grid of NFTs with procedural gradient art,
/// collection label and floor chip, plus filter tabs. 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 Web3NftGalleryScreen extends StatefulWidget {
  const Web3NftGalleryScreen({super.key, this.onBack, this.onNftTap});

  final VoidCallback? onBack;
  final VoidCallback? onNftTap;

  @override
  State<Web3NftGalleryScreen> createState() => _Web3NftGalleryScreenState();
}

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

  static const List<String> _tabs = <String>['All', 'Art', 'PFP', 'Gaming'];

  static const List<_Nft> _nfts = <_Nft>[
    _Nft('Prism #218', 'Prisma', '1.24', 7714),
    _Nft('Vault Ape 04', 'VaultApes', '3.80', 1029),
    _Nft('Nebula 77', 'Nebula', '0.62', 5521),
    _Nft('Glyph Knight', 'Glyphs', '2.15', 3380),
    _Nft('Aether 12', 'Aether', '0.95', 8842),
    _Nft('Pixel Lord', 'PixelDAO', '1.50', 6207),
  ];

  int _tab = 0;

  @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(
            'NFTs',
            style: TextStyle(
              fontFamily: _font,
              fontSize: 17,
              fontWeight: FontWeight.w700,
              color: _text,
            ),
          ),
        ),
        body: SafeArea(
          top: false,
          child: Column(
            children: <Widget>[
              SizedBox(
                height: 38,
                child: ListView.separated(
                  scrollDirection: Axis.horizontal,
                  padding: const EdgeInsets.symmetric(horizontal: 20),
                  itemCount: _tabs.length,
                  separatorBuilder: (BuildContext context, int i) =>
                      const SizedBox(width: 8),
                  itemBuilder: (BuildContext context, int i) {
                    final bool active = _tab == i;
                    return GestureDetector(
                      onTap: () => setState(() => _tab = i),
                      behavior: HitTestBehavior.opaque,
                      child: Container(
                        padding: const EdgeInsets.symmetric(horizontal: 16),
                        alignment: Alignment.center,
                        decoration: BoxDecoration(
                          color: active ? _brand : _surfaceAlt,
                          borderRadius: BorderRadius.circular(19),
                        ),
                        child: Text(
                          _tabs[i],
                          style: TextStyle(
                            fontFamily: _font,
                            fontSize: 13,
                            fontWeight: FontWeight.w700,
                            color: active ? _bg : _muted,
                          ),
                        ),
                      ),
                    );
                  },
                ),
              ),
              Expanded(
                child: GridView.builder(
                  padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
                  gridDelegate:
                      const SliverGridDelegateWithFixedCrossAxisCount(
                    crossAxisCount: 2,
                    mainAxisSpacing: 14,
                    crossAxisSpacing: 14,
                    childAspectRatio: 0.74,
                  ),
                  itemCount: _nfts.length,
                  itemBuilder: (BuildContext context, int i) {
                    final _Nft n = _nfts[i];
                    return GestureDetector(
                      onTap: widget.onNftTap,
                      behavior: HitTestBehavior.opaque,
                      child: Container(
                        decoration: BoxDecoration(
                          color: _surface,
                          borderRadius: BorderRadius.circular(16),
                          border: Border.all(color: _hairline),
                        ),
                        clipBehavior: Clip.antiAlias,
                        child: Column(
                          crossAxisAlignment: CrossAxisAlignment.start,
                          children: <Widget>[
                            Expanded(
                              child: SizedBox.expand(
                                child: CustomPaint(
                                  painter: _NftArtPainter(seed: n.seed),
                                ),
                              ),
                            ),
                            Padding(
                              padding: const EdgeInsets.fromLTRB(10, 8, 10, 10),
                              child: Column(
                                crossAxisAlignment: CrossAxisAlignment.start,
                                children: <Widget>[
                                  Text(
                                    n.name,
                                    maxLines: 1,
                                    overflow: TextOverflow.ellipsis,
                                    style: const TextStyle(
                                      fontFamily: _font,
                                      fontSize: 13.5,
                                      fontWeight: FontWeight.w700,
                                      color: _text,
                                    ),
                                  ),
                                  const SizedBox(height: 2),
                                  Text(
                                    n.collection,
                                    maxLines: 1,
                                    overflow: TextOverflow.ellipsis,
                                    style: const TextStyle(
                                      fontFamily: _font,
                                      fontSize: 11.5,
                                      color: _muted,
                                    ),
                                  ),
                                  const SizedBox(height: 8),
                                  Row(
                                    children: <Widget>[
                                      const Text(
                                        'Floor',
                                        style: TextStyle(
                                          fontFamily: _font,
                                          fontSize: 11,
                                          color: _muted,
                                        ),
                                      ),
                                      const Spacer(),
                                      Text(
                                        '${n.floor} ETH',
                                        style: const TextStyle(
                                          fontFamily: _font,
                                          fontSize: 12.5,
                                          fontWeight: FontWeight.w800,
                                          fontFeatures: <FontFeature>[
                                            FontFeature.tabularFigures()
                                          ],
                                          color: _text,
                                        ),
                                      ),
                                    ],
                                  ),
                                ],
                              ),
                            ),
                          ],
                        ),
                      ),
                    );
                  },
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

class _Nft {
  const _Nft(this.name, this.collection, this.floor, this.seed);

  final String name;
  final String collection;
  final String floor;
  final int seed;
}

/// 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 < 5; 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),
      );
    }

    // A bold rotated rect motif.
    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-gallery

2. AI agent (MCP)

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

FAQ

Is this NFT gallery screen free to use commercially?

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

How do I show real NFT images from a marketplace API instead of the painted art?

Replace the `CustomPaint(painter: _NftArtPainter(...))` inside the card's `SizedBox.expand` with `Image.network(url, fit: BoxFit.cover)`, and swap the `seed` field on `_Nft` for an image URL from OpenSea, Reservoir or Alchemy's NFT API. Keep the painter as the `loadingBuilder`/`errorBuilder` fallback — a seeded placeholder is nicer than a grey box while metadata resolves.

Does this screen need any packages or font setup?

No packages — it is pure Flutter, importing only `dart:math` and `material.dart`. The artwork is drawn by a CustomPainter, so there is no image asset and no SVG dependency either. The Inter font it references ships bundled with the screen at lib/web3_nft_gallery/fonts/Inter.ttf.

Why does every NFT card look the same on each rebuild if the art is random?

Because it is seeded, not random. Each `_Nft` carries a fixed integer seed, and the painter derives every colour, circle and rotation from that seed through a deterministic LCG — no `math.Random`, no wall clock. Same seed, same picture, which also lets `shouldRepaint` skip work whenever the seed has not changed.

Which Flutter version does this need?

Flutter 3.22 or newer: the painter's circles and square use `Color.withValues(alpha: ...)`, and the constructor uses `super.key`. On an older SDK, change `withValues(alpha: 0.22)` to `withOpacity(0.22)` (and 0.30 likewise) and expand the constructor to `{Key? key} : super(key: key)`.

Related screens