Web359 views

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

A crypto watchlist has to do two jobs at once: show live prices and trends at a glance, and let holders curate the list itself without a separate settings screen. This tutorial builds Aurum's dark exchange-style watchlist in Flutter — five saved coins with tinted ticker badges, painted sparklines, tabular-figure prices and signed percentage deltas — plus an Edit mode that swaps each row's sparkline for a drag handle so the list reorders in place. Everything is pure Flutter: one StatefulWidget, one boolean of state, and a thirty-line CustomPainter for the charts.

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

What you'll build

  • ✓A dark exchange-style coin list seeded with BTC, ETH, SOL, LINK and AVAX, each row carrying a circular ticker badge tinted in that coin's brand colour
  • ✓A 56×30 sparkline painted per row by a custom painter, stroked green for gains and red for losses to match the delta
  • ✓An Edit/Done toggle in the app bar that hides sparklines and reveals drag handles for in-place reordering via ReorderableListView
  • ✓Price cells set in tabular figures so a column of dollar values stays digit-aligned while it updates
  • ✓A tappable 'My Favourites' header card with a gold star and a Manage chevron, wired to an onManage callback

Step-by-step build

1

Create the file

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

One coin class, eight colours, one boolean

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

/// Web3 — Watchlist. A saved-coins list with ticker badges, a painted sparkline,
/// price + delta, and an edit mode with drag-reorder handles. Self-contained per
/// CONVENTIONS.md: pure Flutter, bundled Inter font, sparkline CustomPaint (no
/// glyphs / no network image), forced dark theme so it renders standalone as a
/// route.
class Web3WatchlistScreen extends StatefulWidget {
  const Web3WatchlistScreen({
    super.key,
    this.onBack,
    this.onManage,
    this.onCoinTap,
  });

  final VoidCallback? onBack;
  final VoidCallback? onManage;
  final VoidCallback? onCoinTap;

  @override
  State<Web3WatchlistScreen> createState() => _Web3WatchlistScreenState();
}

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

  final List<_Coin> _coins = <_Coin>[
    _Coin('BTC', 'Bitcoin', '67,342.18', 2.41, Color(0xFFF0B90B),
        <double>[3, 5, 4, 6, 7, 6, 8, 9, 8, 10]),
    _Coin('ETH', 'Ethereum', '2,748.10', 1.18, Color(0xFF627EEA),
        <double>[6, 5, 6, 5, 7, 6, 7, 8, 7, 8]),
    _Coin('SOL', 'Solana', '152.90', -3.42, Color(0xFF14F195),
        <double>[9, 8, 8, 6, 7, 5, 6, 4, 5, 4]),
    _Coin('LINK', 'Chainlink', '13.88', 0.62, Color(0xFF2A5ADA),
        <double>[4, 5, 5, 6, 5, 6, 6, 7, 6, 7]),
    _Coin('AVAX', 'Avalanche', '28.45', -1.07, Color(0xFFE84142),
        <double>[7, 7, 6, 7, 6, 5, 6, 5, 6, 5]),
  ];

  bool _editing = false;

`Web3WatchlistScreen` is a `StatefulWidget` with three optional callbacks — `onBack`, `onManage`, `onCoinTap` — so the screen stays backend-agnostic; navigation and data-fetching live in the host app. The palette is exchange-dark: near-black `#0B0E11` background, `#161A1E` card surface, gold `#F0B90B` brand accent, and a dedicated pair for market direction — `_up` green `#2EBD85` and `_down` red `#F6465D` — kept separate from the brand colour so gold never has to mean 'gaining'. Data is a `List<_Coin>` seeded with five coins, each carrying a ticker, full name, a preformatted price string, a signed delta, a per-coin tint (`#627EEA` for ETH, `#14F195` for SOL, and so on) and ten sparkline points. The only piece of UI state is `bool _editing` — the entire edit mode hangs off that one flag.

A forced dark theme and the Edit/Done toggle

web3_watchlist_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(
            'Watchlist',
            style: TextStyle(
              fontFamily: _font,
              fontSize: 17,
              fontWeight: FontWeight.w700,
              color: _text,
            ),
          ),
          actions: <Widget>[
            TextButton(
              onPressed: () => setState(() => _editing = !_editing),
              child: Text(
                _editing ? 'Done' : 'Edit',
                style: const TextStyle(
                  fontFamily: _font,
                  fontSize: 14.5,
                  fontWeight: FontWeight.w700,
                  color: _brand,
                ),
              ),
            ),
          ],
        ),

`build` wraps everything in `Theme(data: ThemeData.dark(useMaterial3: true))`, which is what lets this screen drop into a light app and still render correctly as a standalone dark route — ripples, text defaults and icon colours all come out dark-appropriate without the host app changing its own theme. The `AppBar` matches the `_bg` background at zero elevation so it dissolves into the page, with a small 18px back chevron on the left. The interesting part is the single action: a `TextButton` whose `onPressed` runs `setState(() => _editing = !_editing)` and whose label reads `_editing ? 'Done' : 'Edit'`. One flag flip in one place re-renders every row into or out of edit mode — no mode controller, no second screen.

The My Favourites card

web3_watchlist_screen.dart
        body: SafeArea(
          top: false,
          child: Column(
            children: <Widget>[
              GestureDetector(
                onTap: widget.onManage,
                behavior: HitTestBehavior.opaque,
                child: Container(
                  margin: const EdgeInsets.fromLTRB(20, 8, 20, 4),
                  padding:
                      const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
                  decoration: BoxDecoration(
                    color: _surface,
                    borderRadius: BorderRadius.circular(12),
                    border: Border.all(color: _hairline),
                  ),
                  child: Row(
                    children: <Widget>[
                      const Icon(Icons.star_rounded, size: 18, color: _brand),
                      const SizedBox(width: 10),
                      const Expanded(
                        child: Text(
                          'My Favourites',
                          style: TextStyle(
                            fontFamily: _font,
                            fontSize: 14,
                            fontWeight: FontWeight.w700,
                            color: _text,
                          ),
                        ),
                      ),
                      const Text(
                        'Manage',
                        style: TextStyle(
                          fontFamily: _font,
                          fontSize: 13,
                          fontWeight: FontWeight.w700,
                          color: _muted,
                        ),
                      ),
                      const Icon(Icons.keyboard_arrow_right_rounded,
                          size: 20, color: _muted),
                    ],
                  ),
                ),
              ),

Between the app bar and the list sits a full-width tappable card: a `GestureDetector` with `behavior: HitTestBehavior.opaque`, so taps land anywhere on the card — including the padding — rather than only on its child widgets. Inside, a `_surface` Container with a 12px radius and a `_hairline` (`#2B3139`) border holds a Row: a gold `Icons.star_rounded` at 18px, the 'My Favourites' label in `Expanded` so it claims the middle, then a muted 'Manage' label and a right chevron pushed to the trailing edge. The whole card fires `widget.onManage` — it is the doorway to a separate manage screen, which is why this screen's own edit mode only handles reordering and leaves add/remove to that route.

ReorderableListView without the default handles

web3_watchlist_screen.dart
              Expanded(
                child: ReorderableListView.builder(
                  buildDefaultDragHandles: false,
                  padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
                  itemCount: _coins.length,
                  onReorder: (int oldIndex, int newIndex) {
                    setState(() {
                      if (newIndex > oldIndex) newIndex -= 1;
                      final _Coin c = _coins.removeAt(oldIndex);
                      _coins.insert(newIndex, c);
                    });
                  },
                  itemBuilder: (BuildContext context, int i) {
                    final _Coin c = _coins[i];
                    final bool pos = c.delta >= 0;
                    final Color d = pos ? _up : _down;

The list is a `ReorderableListView.builder` with `buildDefaultDragHandles: false` — crucial, because the default behaviour puts an always-visible drag icon on every row (or makes rows long-press-draggable on mobile), and this design only wants dragging during edit mode. `onReorder` does the standard index dance: when `newIndex > oldIndex` it decrements by one, because Flutter reports the target slot as if the moving row were still in its old position, then `removeAt`/`insert` mutates `_coins` inside `setState`. The builder also precomputes two values per row — `pos = c.delta >= 0` and `d = pos ? _up : _down` — so the same green-or-red colour feeds both the sparkline and the delta text from one decision.

Row shell: keys, tap gating and conditional drag handles

web3_watchlist_screen.dart
                    return Padding(
                      key: ValueKey<String>(c.ticker),
                      padding: const EdgeInsets.only(bottom: 10),
                      child: GestureDetector(
                        onTap: _editing ? null : widget.onCoinTap,
                        behavior: HitTestBehavior.opaque,
                        child: Container(
                          padding: const EdgeInsets.symmetric(
                              horizontal: 14, vertical: 12),
                          decoration: BoxDecoration(
                            color: _surface,
                            borderRadius: BorderRadius.circular(14),
                            border: Border.all(color: _hairline),
                          ),
                          child: Row(
                            children: <Widget>[
                              if (_editing) ...<Widget>[
                                ReorderableDragStartListener(
                                  index: i,
                                  child: const Icon(Icons.drag_handle_rounded,
                                      size: 22, color: _muted),
                                ),
                                const SizedBox(width: 10),
                              ],
                              Container(
                                width: 38,
                                height: 38,
                                decoration: BoxDecoration(
                                  color: c.tint.withValues(alpha: 0.16),
                                  shape: BoxShape.circle,
                                ),
                                alignment: Alignment.center,
                                child: Text(
                                  c.ticker,
                                  style: TextStyle(
                                    fontFamily: _font,
                                    fontSize: 10,
                                    fontWeight: FontWeight.w800,
                                    color: c.tint,
                                  ),
                                ),
                              ),

Every row's outermost widget carries `key: ValueKey<String>(c.ticker)` — `ReorderableListView` requires a key on each child to track rows across a drag, and the ticker is a natural stable ID. The row's `GestureDetector` sets `onTap: _editing ? null : widget.onCoinTap`, disabling navigation during edit mode so a tap meant for a drag handle can never accidentally open a coin detail page. The drag handle itself is added with a collection-if spread: `if (_editing) ...[ReorderableDragStartListener(...)]` inserts a 22px `drag_handle_rounded` icon plus its 10px gap only while editing. `ReorderableDragStartListener(index: i)` is the piece that makes a custom handle work — it tells the list which row starts dragging when that icon is grabbed. The ticker badge is a 38×38 circle filled with `c.tint.withValues(alpha: 0.16)` and the ticker at 10px `w800` in the full tint — a two-layer treatment that gives each coin identity without any logo assets.

Name column and the disappearing sparkline

web3_watchlist_screen.dart
                              const SizedBox(width: 12),
                              Expanded(
                                child: Column(
                                  crossAxisAlignment: CrossAxisAlignment.start,
                                  children: <Widget>[
                                    Text(
                                      c.ticker,
                                      style: const TextStyle(
                                        fontFamily: _font,
                                        fontSize: 14,
                                        fontWeight: FontWeight.w800,
                                        color: _text,
                                      ),
                                    ),
                                    const SizedBox(height: 2),
                                    Text(
                                      c.name,
                                      style: const TextStyle(
                                        fontFamily: _font,
                                        fontSize: 12,
                                        color: _muted,
                                      ),
                                    ),
                                  ],
                                ),
                              ),
                              if (!_editing) ...<Widget>[
                                SizedBox(
                                  width: 56,
                                  height: 30,
                                  child: CustomPaint(
                                    painter: _SparkPainter(
                                        points: c.spark, color: d),
                                  ),
                                ),
                                const SizedBox(width: 12),
                              ],

The middle of the row is an `Expanded` column — ticker at 14px `w800` over the full name at 12px muted — and because it's the only flexible child, it absorbs whatever width the mode change frees up or takes away. The sparkline is the mirror image of the drag handle: `if (!_editing) ...[SizedBox(width: 56, height: 30, child: CustomPaint(...))]`. When Edit is tapped, the handle appears on the left and the sparkline vanishes on the right, so the row trades a read-mode affordance for an edit-mode one instead of getting more crowded. The fixed 56×30 `SizedBox` matters — `CustomPaint` has no intrinsic size, and the painter scales its drawing to whatever box it's given. The painter receives `color: d`, so each chart is already tinted by that row's gain or loss.

Tabular figures for the price column

web3_watchlist_screen.dart
                              Column(
                                crossAxisAlignment: CrossAxisAlignment.end,
                                children: <Widget>[
                                  Text(
                                    '\$${c.price}',
                                    style: const TextStyle(
                                      fontFamily: _font,
                                      fontSize: 14,
                                      fontWeight: FontWeight.w700,
                                      fontFeatures: <FontFeature>[
                                        FontFeature.tabularFigures()
                                      ],
                                      color: _text,
                                    ),
                                  ),
                                  const SizedBox(height: 3),
                                  Text(
                                    '${pos ? '+' : ''}${c.delta.toStringAsFixed(2)}%',
                                    style: TextStyle(
                                      fontFamily: _font,
                                      fontSize: 12.5,
                                      fontWeight: FontWeight.w700,
                                      color: d,
                                    ),
                                  ),
                                ],
                              ),
                            ],
                          ),
                        ),
                      ),
                    );
                  },

The trailing column right-aligns the money: the dollar price at 14px `w700` and, below it, the delta at 12.5px in the row's green or red. The price style sets `fontFeatures: [FontFeature.tabularFigures()]`, which forces every digit of Inter to the same advance width — in a stacked list of prices like '67,342.18' over '2,748.10', proportional digits would make the columns shimmer and misalign, and any live update would cause the text to jiggle horizontally as digits change. The delta string is built as `'${pos ? '+' : ''}${c.delta.toStringAsFixed(2)}%'` — an explicit plus sign for gains, because `toStringAsFixed` already renders the minus and a bare '2.41%' would be ambiguous at a glance.

The sparkline painter: normalise, stroke, fill

web3_watchlist_screen.dart
class _Coin {
  _Coin(this.ticker, this.name, this.price, this.delta, this.tint, this.spark);

  final String ticker;
  final String name;
  final String price;
  final double delta;
  final Color tint;
  final List<double> spark;
}

class _SparkPainter extends CustomPainter {
  const _SparkPainter({required this.points, required this.color});

  final List<double> points;
  final Color color;

  @override
  void paint(Canvas canvas, Size size) {
    if (points.length < 2) return;
    double lo = points.reduce((double a, double b) => a < b ? a : b);
    double hi = points.reduce((double a, double b) => a > b ? a : b);
    if (hi - lo < 0.0001) hi = lo + 1;
    final double dx = size.width / (points.length - 1);
    final Path line = Path();
    for (int i = 0; i < points.length; i++) {
      final double x = dx * i;
      final double y =
          size.height - ((points[i] - lo) / (hi - lo)) * size.height;
      if (i == 0) {
        line.moveTo(x, y);
      } else {
        line.lineTo(x, y);
      }
    }
    final Path fill = Path.from(line)
      ..lineTo(size.width, size.height)
      ..lineTo(0, size.height)
      ..close();
    canvas.drawPath(
      fill,
      Paint()..color = color.withValues(alpha: 0.12),
    );
    canvas.drawPath(
      line,
      Paint()
        ..style = PaintingStyle.stroke
        ..strokeWidth = 1.6
        ..strokeJoin = StrokeJoin.round
        ..color = color,
    );
  }

  @override
  bool shouldRepaint(_SparkPainter old) =>
      old.points != points || old.color != color;
}

`_Coin` is a plain six-field value class; the real work is `_SparkPainter`. It first finds `lo` and `hi` with two `reduce` calls and normalises every point into the full height of the box — so each coin's chart uses all 30px regardless of whether its values span 3–10 or 4–7, which is the correct choice for a trend glyph where shape matters and absolute scale doesn't. The guard `if (hi - lo < 0.0001) hi = lo + 1` prevents a divide-by-zero on a flat series, and `points.length < 2` bails before there's anything to draw. The line path is then cloned with `Path.from(line)`, dropped to the bottom corners and closed, giving an area fill under the exact same curve; the fill paints first at `withValues(alpha: 0.12)` and the line strokes over it at 1.6px with `StrokeJoin.round` to soften the corners between segments. `shouldRepaint` compares `points` and `color`, so a delta flipping sign repaints the chart in the new colour.

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 — Watchlist. A saved-coins list with ticker badges, a painted sparkline,
/// price + delta, and an edit mode with drag-reorder handles. Self-contained per
/// CONVENTIONS.md: pure Flutter, bundled Inter font, sparkline CustomPaint (no
/// glyphs / no network image), forced dark theme so it renders standalone as a
/// route.
class Web3WatchlistScreen extends StatefulWidget {
  const Web3WatchlistScreen({
    super.key,
    this.onBack,
    this.onManage,
    this.onCoinTap,
  });

  final VoidCallback? onBack;
  final VoidCallback? onManage;
  final VoidCallback? onCoinTap;

  @override
  State<Web3WatchlistScreen> createState() => _Web3WatchlistScreenState();
}

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

  final List<_Coin> _coins = <_Coin>[
    _Coin('BTC', 'Bitcoin', '67,342.18', 2.41, Color(0xFFF0B90B),
        <double>[3, 5, 4, 6, 7, 6, 8, 9, 8, 10]),
    _Coin('ETH', 'Ethereum', '2,748.10', 1.18, Color(0xFF627EEA),
        <double>[6, 5, 6, 5, 7, 6, 7, 8, 7, 8]),
    _Coin('SOL', 'Solana', '152.90', -3.42, Color(0xFF14F195),
        <double>[9, 8, 8, 6, 7, 5, 6, 4, 5, 4]),
    _Coin('LINK', 'Chainlink', '13.88', 0.62, Color(0xFF2A5ADA),
        <double>[4, 5, 5, 6, 5, 6, 6, 7, 6, 7]),
    _Coin('AVAX', 'Avalanche', '28.45', -1.07, Color(0xFFE84142),
        <double>[7, 7, 6, 7, 6, 5, 6, 5, 6, 5]),
  ];

  bool _editing = false;

  @override
  Widget build(BuildContext context) {
    return Theme(
      data: ThemeData.dark(useMaterial3: true),
      child: Scaffold(
        backgroundColor: _bg,
        appBar: AppBar(
          backgroundColor: _bg,
          elevation: 0,
          centerTitle: true,
          leading: IconButton(
            onPressed: widget.onBack,
            icon: const Icon(Icons.arrow_back_ios_new_rounded,
                size: 18, color: _text),
          ),
          title: const Text(
            'Watchlist',
            style: TextStyle(
              fontFamily: _font,
              fontSize: 17,
              fontWeight: FontWeight.w700,
              color: _text,
            ),
          ),
          actions: <Widget>[
            TextButton(
              onPressed: () => setState(() => _editing = !_editing),
              child: Text(
                _editing ? 'Done' : 'Edit',
                style: const TextStyle(
                  fontFamily: _font,
                  fontSize: 14.5,
                  fontWeight: FontWeight.w700,
                  color: _brand,
                ),
              ),
            ),
          ],
        ),
        body: SafeArea(
          top: false,
          child: Column(
            children: <Widget>[
              GestureDetector(
                onTap: widget.onManage,
                behavior: HitTestBehavior.opaque,
                child: Container(
                  margin: const EdgeInsets.fromLTRB(20, 8, 20, 4),
                  padding:
                      const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
                  decoration: BoxDecoration(
                    color: _surface,
                    borderRadius: BorderRadius.circular(12),
                    border: Border.all(color: _hairline),
                  ),
                  child: Row(
                    children: <Widget>[
                      const Icon(Icons.star_rounded, size: 18, color: _brand),
                      const SizedBox(width: 10),
                      const Expanded(
                        child: Text(
                          'My Favourites',
                          style: TextStyle(
                            fontFamily: _font,
                            fontSize: 14,
                            fontWeight: FontWeight.w700,
                            color: _text,
                          ),
                        ),
                      ),
                      const Text(
                        'Manage',
                        style: TextStyle(
                          fontFamily: _font,
                          fontSize: 13,
                          fontWeight: FontWeight.w700,
                          color: _muted,
                        ),
                      ),
                      const Icon(Icons.keyboard_arrow_right_rounded,
                          size: 20, color: _muted),
                    ],
                  ),
                ),
              ),
              Expanded(
                child: ReorderableListView.builder(
                  buildDefaultDragHandles: false,
                  padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
                  itemCount: _coins.length,
                  onReorder: (int oldIndex, int newIndex) {
                    setState(() {
                      if (newIndex > oldIndex) newIndex -= 1;
                      final _Coin c = _coins.removeAt(oldIndex);
                      _coins.insert(newIndex, c);
                    });
                  },
                  itemBuilder: (BuildContext context, int i) {
                    final _Coin c = _coins[i];
                    final bool pos = c.delta >= 0;
                    final Color d = pos ? _up : _down;
                    return Padding(
                      key: ValueKey<String>(c.ticker),
                      padding: const EdgeInsets.only(bottom: 10),
                      child: GestureDetector(
                        onTap: _editing ? null : widget.onCoinTap,
                        behavior: HitTestBehavior.opaque,
                        child: Container(
                          padding: const EdgeInsets.symmetric(
                              horizontal: 14, vertical: 12),
                          decoration: BoxDecoration(
                            color: _surface,
                            borderRadius: BorderRadius.circular(14),
                            border: Border.all(color: _hairline),
                          ),
                          child: Row(
                            children: <Widget>[
                              if (_editing) ...<Widget>[
                                ReorderableDragStartListener(
                                  index: i,
                                  child: const Icon(Icons.drag_handle_rounded,
                                      size: 22, color: _muted),
                                ),
                                const SizedBox(width: 10),
                              ],
                              Container(
                                width: 38,
                                height: 38,
                                decoration: BoxDecoration(
                                  color: c.tint.withValues(alpha: 0.16),
                                  shape: BoxShape.circle,
                                ),
                                alignment: Alignment.center,
                                child: Text(
                                  c.ticker,
                                  style: TextStyle(
                                    fontFamily: _font,
                                    fontSize: 10,
                                    fontWeight: FontWeight.w800,
                                    color: c.tint,
                                  ),
                                ),
                              ),
                              const SizedBox(width: 12),
                              Expanded(
                                child: Column(
                                  crossAxisAlignment: CrossAxisAlignment.start,
                                  children: <Widget>[
                                    Text(
                                      c.ticker,
                                      style: const TextStyle(
                                        fontFamily: _font,
                                        fontSize: 14,
                                        fontWeight: FontWeight.w800,
                                        color: _text,
                                      ),
                                    ),
                                    const SizedBox(height: 2),
                                    Text(
                                      c.name,
                                      style: const TextStyle(
                                        fontFamily: _font,
                                        fontSize: 12,
                                        color: _muted,
                                      ),
                                    ),
                                  ],
                                ),
                              ),
                              if (!_editing) ...<Widget>[
                                SizedBox(
                                  width: 56,
                                  height: 30,
                                  child: CustomPaint(
                                    painter: _SparkPainter(
                                        points: c.spark, color: d),
                                  ),
                                ),
                                const SizedBox(width: 12),
                              ],
                              Column(
                                crossAxisAlignment: CrossAxisAlignment.end,
                                children: <Widget>[
                                  Text(
                                    '\$${c.price}',
                                    style: const TextStyle(
                                      fontFamily: _font,
                                      fontSize: 14,
                                      fontWeight: FontWeight.w700,
                                      fontFeatures: <FontFeature>[
                                        FontFeature.tabularFigures()
                                      ],
                                      color: _text,
                                    ),
                                  ),
                                  const SizedBox(height: 3),
                                  Text(
                                    '${pos ? '+' : ''}${c.delta.toStringAsFixed(2)}%',
                                    style: TextStyle(
                                      fontFamily: _font,
                                      fontSize: 12.5,
                                      fontWeight: FontWeight.w700,
                                      color: d,
                                    ),
                                  ),
                                ],
                              ),
                            ],
                          ),
                        ),
                      ),
                    );
                  },
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

class _Coin {
  _Coin(this.ticker, this.name, this.price, this.delta, this.tint, this.spark);

  final String ticker;
  final String name;
  final String price;
  final double delta;
  final Color tint;
  final List<double> spark;
}

class _SparkPainter extends CustomPainter {
  const _SparkPainter({required this.points, required this.color});

  final List<double> points;
  final Color color;

  @override
  void paint(Canvas canvas, Size size) {
    if (points.length < 2) return;
    double lo = points.reduce((double a, double b) => a < b ? a : b);
    double hi = points.reduce((double a, double b) => a > b ? a : b);
    if (hi - lo < 0.0001) hi = lo + 1;
    final double dx = size.width / (points.length - 1);
    final Path line = Path();
    for (int i = 0; i < points.length; i++) {
      final double x = dx * i;
      final double y =
          size.height - ((points[i] - lo) / (hi - lo)) * size.height;
      if (i == 0) {
        line.moveTo(x, y);
      } else {
        line.lineTo(x, y);
      }
    }
    final Path fill = Path.from(line)
      ..lineTo(size.width, size.height)
      ..lineTo(0, size.height)
      ..close();
    canvas.drawPath(
      fill,
      Paint()..color = color.withValues(alpha: 0.12),
    );
    canvas.drawPath(
      line,
      Paint()
        ..style = PaintingStyle.stroke
        ..strokeWidth = 1.6
        ..strokeJoin = StrokeJoin.round
        ..color = color,
    );
  }

  @override
  bool shouldRepaint(_SparkPainter old) =>
      old.points != points || old.color != color;
}

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-watchlist

2. AI agent (MCP)

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

FAQ

Is this crypto watchlist screen free to use commercially?

Yes. The full source on this page is free for personal and commercial projects alike — ship it in a wallet or exchange app, no licence key, no sign-up, no attribution required. Copy it from the page, install it via the CLI command, or pull it through MCP.

Does it need any packages for the sparklines or icons?

No — it's pure Flutter. The sparklines are a `CustomPainter` (`_SparkPainter`), the ticker badges are tinted circles with text rather than logo images, and every icon is from the built-in Material set. The only asset is the bundled Inter font, referenced as `fontFamily: 'Inter'` throughout.

How do I reorder rows without the default drag handles?

The list sets `buildDefaultDragHandles: false` and each row conditionally renders its own `ReorderableDragStartListener(index: i)` around a drag icon while `_editing` is true. That listener is what tells the `ReorderableListView` which row a drag starts from, so the handle only exists — and dragging only works — in edit mode.

How do I feed it live prices instead of the seeded coins?

Replace the hard-coded `_coins` list with data from your market feed — each `_Coin` needs a ticker, name, formatted price string, delta and a short list of sparkline points (the last 10 closes work well; the painter normalises whatever range you pass). Persist the order your `onReorder` produces, and wire `onCoinTap` to a detail route.

Which Flutter version does this require?

Flutter 3.22 or newer: the badge tint and the sparkline's area fill both use `Color.withValues(alpha: ...)`, and the constructor uses `super.key`. On an older SDK, swap `withValues(alpha: 0.16)` for `withOpacity(0.16)` (and 0.12 likewise) and expand the constructor to `{Key? key} : super(key: key)`.

Related screens