How to Build a Crypto Markets List Screen in Flutter (Full Code + Preview)
Every exchange app lives or dies on its markets tab: dozens of coins, each needing a price, a trend and a one-tap action, all readable at a glance in the dark. This tutorial builds Aurum's Binance-style markets list in Flutter — five filter pills that re-sort and re-filter rows live, dense coin rows with tinted ticker badges and 46×24 painted sparklines, tabular-figure prices with thousands separators, signed 24h chips in green and red, and star-to-favourite pinning backed by a plain Set. Pure Flutter, no packages, no network images.

What you'll build
- ✓A Favorites/Spot/Futures/Gainers/Losers pill bar whose selection re-derives the visible rows through a single getter
- ✓Dense market rows: tappable star, tinted circular ticker badge, /USDT pair label, volume, sparkline, price and a signed 24h chip
- ✓A seven-point sparkline painted by a CustomPainter that normalises values and strokes a rounded polyline in the delta's colour
- ✓A price formatter that groups thousands with a regex and switches decimal places by magnitude, rendered in tabular figures
- ✓A five-tab exchange bottom nav and an empty state that teaches the star gesture when Favorites has nothing pinned
Step-by-step build
Create the file
Add a new file at lib/web3_markets_list/web3_markets_list_screen.dart in your Flutter project.
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:
flutter:
fonts:
- family: Inter
fonts:
- asset: fonts/Inter-Regular.ttfBuild it, piece by piece
Here's how the screen goes together. Each block below is a real slice of the code with a plain-English explanation — paste them in order, or grab the whole file from the next section.
Callbacks in, palette locked down
import 'package:flutter/material.dart';
/// Web3 markets — Markets list (bottom-nav Tab 2 shell). A category tab bar
/// (Favorites / Spot / Futures / Gainers / Losers) over data-dense market rows:
/// painted ticker badge, name + volume, inline sparkline, price and signed 24h
/// delta. Self-contained per CONVENTIONS.md: pure Flutter, bundled Inter font,
/// painted sparklines + ticker badges (no network image / no glyphs), forced
/// dark theme so it renders standalone as a route.
class Web3MarketsListScreen extends StatefulWidget {
const Web3MarketsListScreen({
super.key,
this.onTabSelected,
this.onCoinTap,
this.onFilters,
this.onSearch,
});
final ValueChanged<int>? onTabSelected;
final ValueChanged<String>? onCoinTap;
final VoidCallback? onFilters;
final VoidCallback? onSearch;
@override
State<Web3MarketsListScreen> createState() => _Web3MarketsListScreenState();
}
class _Web3MarketsListScreenState extends State<Web3MarketsListScreen> {
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 _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);
static const List<String> _tabs = <String>[
'Favorites', 'Spot', 'Futures', 'Gainers', 'Losers',
];`Web3MarketsListScreen` is a `StatefulWidget` taking four optional callbacks — `onTabSelected`, `onCoinTap`, `onFilters`, `onSearch` — so navigation and data drill-down stay the host app's business while the screen owns only its own filtering state. The state class pins the whole palette as `static const`: near-black `#0B0E11` background, `#161A1E` surface, amber `#F0B90B` brand, and the pair every trader already knows — `#2EBD85` up-green and `#F6465D` down-red. Keeping up/down as named constants matters because three separate widgets (the delta chip, the sparkline, and nothing else) must agree on them exactly. The five tab labels sit in a `static const List<String>` so the pill bar can be built by index.
Ten coins, one getter, three filters
static const List<_Market> _all = <_Market>[
_Market('BTC', 'Bitcoin', 64120.42, 2.41, '28.4B', true, Color(0xFFF7931A),
<double>[0.4, 0.5, 0.45, 0.6, 0.55, 0.7, 0.8]),
_Market('ETH', 'Ethereum', 2748.10, 1.07, '14.1B', true, Color(0xFF627EEA),
<double>[0.5, 0.45, 0.55, 0.5, 0.6, 0.58, 0.66]),
_Market('SOL', 'Solana', 152.90, -3.18, '4.8B', false, Color(0xFF14F195),
<double>[0.7, 0.65, 0.6, 0.5, 0.55, 0.4, 0.35]),
_Market('BNB', 'BNB', 596.30, 0.84, '1.9B', true, Color(0xFFF0B90B),
<double>[0.45, 0.5, 0.48, 0.52, 0.55, 0.6, 0.62]),
_Market('XRP', 'XRP', 0.6124, -1.42, '2.1B', false, Color(0xFF23292F),
<double>[0.6, 0.58, 0.55, 0.52, 0.5, 0.46, 0.44]),
_Market('LINK', 'Chainlink', 18.42, 4.62, '0.9B', true, Color(0xFF2A5ADA),
<double>[0.3, 0.4, 0.5, 0.45, 0.6, 0.7, 0.78]),
_Market('ADA', 'Cardano', 0.4521, 3.05, '0.7B', true, Color(0xFF0033AD),
<double>[0.4, 0.42, 0.5, 0.55, 0.58, 0.64, 0.7]),
_Market('AVAX', 'Avalanche', 27.18, -2.26, '0.6B', false, Color(0xFFE84142),
<double>[0.62, 0.58, 0.55, 0.5, 0.48, 0.44, 0.4]),
_Market('ARB', 'Arbitrum', 1.12, -1.84, '0.4B', false, Color(0xFF28A0F0),
<double>[0.6, 0.55, 0.5, 0.52, 0.45, 0.42, 0.4]),
_Market('DOGE', 'Dogecoin', 0.1284, 6.91, '1.2B', true, Color(0xFFC2A633),
<double>[0.3, 0.35, 0.45, 0.5, 0.62, 0.72, 0.84]),
];
int _tab = 1; // Spot by default.
final Set<String> _favorites = <String>{'BTC', 'ETH', 'BNB'};
List<_Market> get _rows {
switch (_tab) {
case 0:
return _all
.where((_Market m) => _favorites.contains(m.ticker))
.toList();
case 3:
return (List<_Market>.of(_all)
..sort((_Market a, _Market b) => b.delta.compareTo(a.delta)))
.where((_Market m) => m.delta >= 0)
.toList();
case 4:
return (List<_Market>.of(_all)
..sort((_Market a, _Market b) => a.delta.compareTo(b.delta)))
.where((_Market m) => m.delta < 0)
.toList();
default:
return _all;
}
}The dataset is a `static const List<_Market>` of ten coins, each carrying ticker, name, price, signed delta, volume string, a brand tint (Bitcoin's `#F7931A` orange, Solana's `#14F195` green) and seven normalised sparkline points. Mutable state is tiny: `int _tab = 1` (Spot by default, so the screen opens on the full list) and a `Set<String> _favorites` pre-seeded with BTC, ETH and BNB. The `_rows` getter is the screen's brain: case 0 filters `_all` by favourite membership, case 3 copies the list with `List<_Market>.of`, sorts descending by delta and keeps only `delta >= 0`, case 4 does the mirror for losers. Copying before sorting is not optional — `_all` is `const`, and sorting it in place would throw at runtime. Because rows are derived rather than stored, no filter result can ever go stale.
Forced dark theme and the pill tab bar
@override
Widget build(BuildContext context) {
final List<_Market> rows = _rows;
return Theme(
data: ThemeData.dark(useMaterial3: true),
child: Scaffold(
backgroundColor: _bg,
appBar: AppBar(
backgroundColor: _bg,
elevation: 0,
titleSpacing: 20,
title: const Text(
'Markets',
style: TextStyle(
fontFamily: _font,
fontSize: 20,
fontWeight: FontWeight.w700,
color: _text,
),
),
actions: <Widget>[
IconButton(
onPressed: widget.onSearch,
icon: const Icon(Icons.search_rounded, size: 22, color: _text),
),
IconButton(
onPressed: widget.onFilters,
icon: const Icon(Icons.tune_rounded, size: 21, color: _text),
),
const SizedBox(width: 6),
],
),
bottomNavigationBar: _BottomNav(
current: 1,
onTabSelected: widget.onTabSelected,
),
body: SafeArea(
top: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
SizedBox(
height: 40,
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 sel = i == _tab;
return GestureDetector(
onTap: () => setState(() => _tab = i),
behavior: HitTestBehavior.opaque,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14),
alignment: Alignment.center,
decoration: BoxDecoration(
color: sel ? _surfaceAlt : Colors.transparent,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: sel ? _brand : _hairline,
),
),
child: Text(
_tabs[i],
style: TextStyle(
fontFamily: _font,
fontSize: 13,
fontWeight: FontWeight.w600,
color: sel ? _brand : _muted,
),
),
),
);
},
),
),The build wraps everything in `Theme(data: ThemeData.dark(useMaterial3: true))`, so the screen renders dark even inside a light host app — dropped into any route, it looks like an exchange, not a settings page. The `AppBar` is just a 20px w700 'Markets' title with search and tune icons wired to the callbacks. The tab bar is a 40px-tall horizontal `ListView.separated` of `GestureDetector` pills: the selected pill gets a `#1E2329` fill, an amber border and amber 13px text, while the rest sit transparent with a `#2B3139` hairline border and muted text. `HitTestBehavior.opaque` makes the whole pill tappable, not just its label, and tapping simply does `setState(() => _tab = i)` — the `_rows` getter handles everything else.
Column headers, the list, and an empty state that teaches
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 6),
child: Row(
children: const <Widget>[
Text(
'Name / Vol',
style: TextStyle(
fontFamily: _font,
fontSize: 11.5,
color: _muted,
),
),
Spacer(),
Text(
'Last Price / 24h',
style: TextStyle(
fontFamily: _font,
fontSize: 11.5,
color: _muted,
),
),
],
),
),
const Divider(height: 1, thickness: 1, color: _hairline),
Expanded(
child: rows.isEmpty
? _empty()
: ListView.separated(
padding: const EdgeInsets.symmetric(
horizontal: 20, vertical: 4),
itemCount: rows.length,
separatorBuilder: (BuildContext context, int i) =>
const SizedBox(height: 2),
itemBuilder: (BuildContext context, int i) {
final _Market m = rows[i];
return _MarketRow(
market: m,
starred: _favorites.contains(m.ticker),
onStar: () => setState(() {
_favorites.contains(m.ticker)
? _favorites.remove(m.ticker)
: _favorites.add(m.ticker);
}),
onTap: () => widget.onCoinTap?.call(m.ticker),
);
},
),
),
],
),
),
),
);
}
Widget _empty() {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: const <Widget>[
Icon(Icons.star_border_rounded, size: 44, color: _muted),
SizedBox(height: 12),
Text(
'No favorites yet',
style: TextStyle(
fontFamily: _font,
fontSize: 14.5,
fontWeight: FontWeight.w600,
color: _text,
),
),
SizedBox(height: 4),
Text(
'Tap the star on any market to pin it here.',
style: TextStyle(fontFamily: _font, fontSize: 12.5, color: _muted),
),
],
),
);
}A muted 11.5px row — 'Name / Vol' on the left, `Spacer()`, 'Last Price / 24h' on the right — labels both ends of every row below it, then a 1px `#2B3139` `Divider` separates header from data. The list itself is a `ListView.separated` with only 2px between rows; the real breathing room comes from each row's own vertical padding, which keeps the whole row tappable edge to edge. Each `_MarketRow` receives `starred` from `_favorites.contains`, an `onStar` closure that toggles Set membership inside `setState`, and an `onTap` forwarding the ticker to `widget.onCoinTap`. The `rows.isEmpty ? _empty() : ...` branch exists for exactly one case — Favorites with everything unstarred — and its message, 'Tap the star on any market to pin it here.', teaches the gesture instead of apologising.
The market model and a magnitude-aware price formatter
class _Market {
const _Market(this.ticker, this.name, this.price, this.delta, this.vol,
this.up, this.tint, this.spark);
final String ticker;
final String name;
final double price;
final double delta;
final String vol;
final bool up;
final Color tint;
final List<double> spark;
}
class _MarketRow extends StatelessWidget {
const _MarketRow({
required this.market,
required this.starred,
required this.onStar,
required this.onTap,
});
final _Market market;
final bool starred;
final VoidCallback onStar;
final VoidCallback onTap;
String _fmtPrice(double v) {
if (v >= 1000) {
final String s = v.toStringAsFixed(2);
final List<String> parts = s.split('.');
final String intp = parts[0].replaceAllMapped(
RegExp(r'\B(?=(\d{3})+(?!\d))'),
(Match m) => ',',
);
return '$intp.${parts[1]}';
}
if (v >= 1) return v.toStringAsFixed(2);
return v.toStringAsFixed(4);
}`_Market` is a plain const class — eight final fields, positional constructor — cheap enough that the whole dataset lives in the const table. `_fmtPrice` is where a crypto list earns trust: above 1000 it fixes two decimals then injects commas with `RegExp(r'\B(?=(\d{3})+(?!\d))')`, a lookahead that matches every position followed by complete groups of three digits, turning 64120.42 into '64,120.42'. Between 1 and 1000 it keeps two decimals; below 1 it switches to four, because '$0.61' loses the information a $0.6124 XRP price actually carries. One formatter handles Bitcoin and Dogecoin without special cases.
The row: star, badge, pair, sparkline, price, chip
@override
Widget build(BuildContext context) {
final bool up = market.delta >= 0;
final Color deltaColor = up
? _Web3MarketsListScreenState._up
: _Web3MarketsListScreenState._down;
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Row(
children: <Widget>[
GestureDetector(
onTap: onStar,
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.only(right: 10),
child: Icon(
starred ? Icons.star_rounded : Icons.star_border_rounded,
size: 18,
color: starred
? _Web3MarketsListScreenState._brand
: _Web3MarketsListScreenState._muted,
),
),
),
_Badge(ticker: market.ticker, tint: market.tint),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Text(
market.ticker,
style: const TextStyle(
fontFamily: _Web3MarketsListScreenState._font,
fontSize: 14.5,
fontWeight: FontWeight.w700,
color: _Web3MarketsListScreenState._text,
),
),
const SizedBox(width: 5),
Text(
'/USDT',
style: const TextStyle(
fontFamily: _Web3MarketsListScreenState._font,
fontSize: 11,
color: _Web3MarketsListScreenState._muted,
),
),
],
),
const SizedBox(height: 3),
Text(
'Vol ${market.vol}',
style: const TextStyle(
fontFamily: _Web3MarketsListScreenState._font,
fontSize: 11.5,
color: _Web3MarketsListScreenState._muted,
),
),
],
),
),
SizedBox(
width: 46,
height: 24,
child: CustomPaint(
painter: _Spark(points: market.spark, color: deltaColor),
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
Text(
'\$${_fmtPrice(market.price)}',
style: const TextStyle(
fontFamily: _Web3MarketsListScreenState._font,
fontSize: 14,
fontWeight: FontWeight.w600,
fontFeatures: <FontFeature>[FontFeature.tabularFigures()],
color: _Web3MarketsListScreenState._text,
),
),
const SizedBox(height: 5),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 7, vertical: 2.5),
decoration: BoxDecoration(
color: deltaColor.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(6),
),
child: Text(
'${up ? '+' : ''}${market.delta.toStringAsFixed(2)}%',
style: TextStyle(
fontFamily: _Web3MarketsListScreenState._font,
fontSize: 12,
fontWeight: FontWeight.w700,
fontFeatures: const <FontFeature>[
FontFeature.tabularFigures(),
],
color: deltaColor,
),
),
),
],
),
],
),
),
),
);
}
}`_MarketRow` computes `deltaColor` once from `market.delta >= 0` and hands it to both the sparkline and the chip, so trend and number can never disagree. The layout is `Material` + `InkWell` for ripple, then a single `Row`: the star is its own `GestureDetector` with `HitTestBehavior.opaque` and right padding, so tapping it toggles the favourite without firing the row's `onTap`. The ticker sits at 14.5px w700 with '/USDT' beside it at 11px muted — pair context without shouting — and 'Vol 28.4B' underneath. The sparkline gets a fixed 46×24 `SizedBox`, keeping every row's chart column aligned. On the right, the price uses `FontFeature.tabularFigures()` so digits occupy equal widths and a live-updating column would not jitter, and the delta chip is a pill filled with `deltaColor.withValues(alpha: 0.14)` holding '+2.41%' in full colour — the sign is printed explicitly because green alone is not enough for colour-blind users or screenshots.
Painted ticker badges and the sparkline painter
class _Badge extends StatelessWidget {
const _Badge({required this.ticker, required this.tint});
final String ticker;
final Color tint;
@override
Widget build(BuildContext context) {
return Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: tint.withValues(alpha: 0.16),
shape: BoxShape.circle,
border: Border.all(color: tint.withValues(alpha: 0.5)),
),
alignment: Alignment.center,
child: Text(
ticker.length <= 3 ? ticker : ticker.substring(0, 3),
style: TextStyle(
fontFamily: _Web3MarketsListScreenState._font,
fontSize: ticker.length >= 4 ? 9 : 10.5,
fontWeight: FontWeight.w700,
color: Color.lerp(tint, Colors.white, 0.4),
),
),
);
}
}
class _Spark extends CustomPainter {
const _Spark({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;
final double minV = points.reduce((double a, double b) => a < b ? a : b);
final double maxV = points.reduce((double a, double b) => a > b ? a : b);
final double range = (maxV - minV).abs() < 1e-6 ? 1 : maxV - minV;
final Path path = Path();
for (int i = 0; i < points.length; i++) {
final double x = size.width * i / (points.length - 1);
final double y = size.height * (1 - (points[i] - minV) / range);
i == 0 ? path.moveTo(x, y) : path.lineTo(x, y);
}
canvas.drawPath(
path,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1.8
..strokeJoin = StrokeJoin.round
..strokeCap = StrokeCap.round
..color = color,
);
}
@override
bool shouldRepaint(_Spark oldDelegate) =>
oldDelegate.points != points || oldDelegate.color != color;
}`_Badge` fakes a coin logo with no image at all: a 38px circle filled with the coin's tint at 16% alpha, bordered at 50% alpha, and the ticker lerped 40% toward white with `Color.lerp` so it stays legible on any brand colour, dark XRP grey included. Tickers longer than three characters get truncated and dropped to 9px so AVAX fits the same circle as BTC. `_Spark` is a 30-line `CustomPainter`: it finds min and max, guards division by zero with `(maxV - minV).abs() < 1e-6 ? 1 : ...` for a flat series, maps each point to x by index and y by inverted normalised value, and strokes the path at 1.8px with round caps and joins. Normalising per row means every sparkline uses its full 24px of height — shape is what the eye reads at this size, not scale. `shouldRepaint` compares points and colour, so an unchanged row never repaints.
The exchange bottom nav
class _BottomNav extends StatelessWidget {
const _BottomNav({required this.current, required this.onTabSelected});
final int current;
final ValueChanged<int>? onTabSelected;
static const List<IconData> _icons = <IconData>[
Icons.account_balance_wallet_rounded,
Icons.candlestick_chart_rounded,
Icons.swap_horiz_rounded,
Icons.percent_rounded,
Icons.person_rounded,
];
static const List<String> _labels = <String>[
'Home', 'Markets', 'Trade', 'Earn', 'Profile',
];
@override
Widget build(BuildContext context) {
return Container(
decoration: const BoxDecoration(
color: _Web3MarketsListScreenState._surface,
border: Border(
top: BorderSide(color: _Web3MarketsListScreenState._hairline),
),
),
child: SafeArea(
top: false,
child: SizedBox(
height: 62,
child: Row(
children: <Widget>[
for (int i = 0; i < _icons.length; i++)
Expanded(
child: GestureDetector(
onTap: () => onTabSelected?.call(i),
behavior: HitTestBehavior.opaque,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Icon(
_icons[i],
size: 23,
color: i == current
? _Web3MarketsListScreenState._brand
: _Web3MarketsListScreenState._muted,
),
const SizedBox(height: 4),
Text(
_labels[i],
style: TextStyle(
fontFamily: _Web3MarketsListScreenState._font,
fontSize: 10.5,
fontWeight: FontWeight.w600,
color: i == current
? _Web3MarketsListScreenState._brand
: _Web3MarketsListScreenState._muted,
),
),
],
),
),
),
],
),
),
),
);
}
}`_BottomNav` hard-codes the five-tab exchange skeleton — Home, Markets, Trade, Earn, Profile — with `current` fixed to 1 by the caller, since this screen is the Markets tab. Each tab is an `Expanded` `GestureDetector` (equal widths, whole area tappable) stacking a 23px icon over a 10.5px w600 label, both flipping from muted grey to amber when active. The container paints the `#161A1E` surface with a top hairline, and `SafeArea(top: false)` sits inside it, so the surface colour extends under the home indicator while the 62px content row stays above it. Taps only forward the index to `onTabSelected` — the shell never navigates by itself, which is what lets you drop it into any router.
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 markets — Markets list (bottom-nav Tab 2 shell). A category tab bar
/// (Favorites / Spot / Futures / Gainers / Losers) over data-dense market rows:
/// painted ticker badge, name + volume, inline sparkline, price and signed 24h
/// delta. Self-contained per CONVENTIONS.md: pure Flutter, bundled Inter font,
/// painted sparklines + ticker badges (no network image / no glyphs), forced
/// dark theme so it renders standalone as a route.
class Web3MarketsListScreen extends StatefulWidget {
const Web3MarketsListScreen({
super.key,
this.onTabSelected,
this.onCoinTap,
this.onFilters,
this.onSearch,
});
final ValueChanged<int>? onTabSelected;
final ValueChanged<String>? onCoinTap;
final VoidCallback? onFilters;
final VoidCallback? onSearch;
@override
State<Web3MarketsListScreen> createState() => _Web3MarketsListScreenState();
}
class _Web3MarketsListScreenState extends State<Web3MarketsListScreen> {
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 _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);
static const List<String> _tabs = <String>[
'Favorites', 'Spot', 'Futures', 'Gainers', 'Losers',
];
static const List<_Market> _all = <_Market>[
_Market('BTC', 'Bitcoin', 64120.42, 2.41, '28.4B', true, Color(0xFFF7931A),
<double>[0.4, 0.5, 0.45, 0.6, 0.55, 0.7, 0.8]),
_Market('ETH', 'Ethereum', 2748.10, 1.07, '14.1B', true, Color(0xFF627EEA),
<double>[0.5, 0.45, 0.55, 0.5, 0.6, 0.58, 0.66]),
_Market('SOL', 'Solana', 152.90, -3.18, '4.8B', false, Color(0xFF14F195),
<double>[0.7, 0.65, 0.6, 0.5, 0.55, 0.4, 0.35]),
_Market('BNB', 'BNB', 596.30, 0.84, '1.9B', true, Color(0xFFF0B90B),
<double>[0.45, 0.5, 0.48, 0.52, 0.55, 0.6, 0.62]),
_Market('XRP', 'XRP', 0.6124, -1.42, '2.1B', false, Color(0xFF23292F),
<double>[0.6, 0.58, 0.55, 0.52, 0.5, 0.46, 0.44]),
_Market('LINK', 'Chainlink', 18.42, 4.62, '0.9B', true, Color(0xFF2A5ADA),
<double>[0.3, 0.4, 0.5, 0.45, 0.6, 0.7, 0.78]),
_Market('ADA', 'Cardano', 0.4521, 3.05, '0.7B', true, Color(0xFF0033AD),
<double>[0.4, 0.42, 0.5, 0.55, 0.58, 0.64, 0.7]),
_Market('AVAX', 'Avalanche', 27.18, -2.26, '0.6B', false, Color(0xFFE84142),
<double>[0.62, 0.58, 0.55, 0.5, 0.48, 0.44, 0.4]),
_Market('ARB', 'Arbitrum', 1.12, -1.84, '0.4B', false, Color(0xFF28A0F0),
<double>[0.6, 0.55, 0.5, 0.52, 0.45, 0.42, 0.4]),
_Market('DOGE', 'Dogecoin', 0.1284, 6.91, '1.2B', true, Color(0xFFC2A633),
<double>[0.3, 0.35, 0.45, 0.5, 0.62, 0.72, 0.84]),
];
int _tab = 1; // Spot by default.
final Set<String> _favorites = <String>{'BTC', 'ETH', 'BNB'};
List<_Market> get _rows {
switch (_tab) {
case 0:
return _all
.where((_Market m) => _favorites.contains(m.ticker))
.toList();
case 3:
return (List<_Market>.of(_all)
..sort((_Market a, _Market b) => b.delta.compareTo(a.delta)))
.where((_Market m) => m.delta >= 0)
.toList();
case 4:
return (List<_Market>.of(_all)
..sort((_Market a, _Market b) => a.delta.compareTo(b.delta)))
.where((_Market m) => m.delta < 0)
.toList();
default:
return _all;
}
}
@override
Widget build(BuildContext context) {
final List<_Market> rows = _rows;
return Theme(
data: ThemeData.dark(useMaterial3: true),
child: Scaffold(
backgroundColor: _bg,
appBar: AppBar(
backgroundColor: _bg,
elevation: 0,
titleSpacing: 20,
title: const Text(
'Markets',
style: TextStyle(
fontFamily: _font,
fontSize: 20,
fontWeight: FontWeight.w700,
color: _text,
),
),
actions: <Widget>[
IconButton(
onPressed: widget.onSearch,
icon: const Icon(Icons.search_rounded, size: 22, color: _text),
),
IconButton(
onPressed: widget.onFilters,
icon: const Icon(Icons.tune_rounded, size: 21, color: _text),
),
const SizedBox(width: 6),
],
),
bottomNavigationBar: _BottomNav(
current: 1,
onTabSelected: widget.onTabSelected,
),
body: SafeArea(
top: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
SizedBox(
height: 40,
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 sel = i == _tab;
return GestureDetector(
onTap: () => setState(() => _tab = i),
behavior: HitTestBehavior.opaque,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14),
alignment: Alignment.center,
decoration: BoxDecoration(
color: sel ? _surfaceAlt : Colors.transparent,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: sel ? _brand : _hairline,
),
),
child: Text(
_tabs[i],
style: TextStyle(
fontFamily: _font,
fontSize: 13,
fontWeight: FontWeight.w600,
color: sel ? _brand : _muted,
),
),
),
);
},
),
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 6),
child: Row(
children: const <Widget>[
Text(
'Name / Vol',
style: TextStyle(
fontFamily: _font,
fontSize: 11.5,
color: _muted,
),
),
Spacer(),
Text(
'Last Price / 24h',
style: TextStyle(
fontFamily: _font,
fontSize: 11.5,
color: _muted,
),
),
],
),
),
const Divider(height: 1, thickness: 1, color: _hairline),
Expanded(
child: rows.isEmpty
? _empty()
: ListView.separated(
padding: const EdgeInsets.symmetric(
horizontal: 20, vertical: 4),
itemCount: rows.length,
separatorBuilder: (BuildContext context, int i) =>
const SizedBox(height: 2),
itemBuilder: (BuildContext context, int i) {
final _Market m = rows[i];
return _MarketRow(
market: m,
starred: _favorites.contains(m.ticker),
onStar: () => setState(() {
_favorites.contains(m.ticker)
? _favorites.remove(m.ticker)
: _favorites.add(m.ticker);
}),
onTap: () => widget.onCoinTap?.call(m.ticker),
);
},
),
),
],
),
),
),
);
}
Widget _empty() {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: const <Widget>[
Icon(Icons.star_border_rounded, size: 44, color: _muted),
SizedBox(height: 12),
Text(
'No favorites yet',
style: TextStyle(
fontFamily: _font,
fontSize: 14.5,
fontWeight: FontWeight.w600,
color: _text,
),
),
SizedBox(height: 4),
Text(
'Tap the star on any market to pin it here.',
style: TextStyle(fontFamily: _font, fontSize: 12.5, color: _muted),
),
],
),
);
}
}
class _Market {
const _Market(this.ticker, this.name, this.price, this.delta, this.vol,
this.up, this.tint, this.spark);
final String ticker;
final String name;
final double price;
final double delta;
final String vol;
final bool up;
final Color tint;
final List<double> spark;
}
class _MarketRow extends StatelessWidget {
const _MarketRow({
required this.market,
required this.starred,
required this.onStar,
required this.onTap,
});
final _Market market;
final bool starred;
final VoidCallback onStar;
final VoidCallback onTap;
String _fmtPrice(double v) {
if (v >= 1000) {
final String s = v.toStringAsFixed(2);
final List<String> parts = s.split('.');
final String intp = parts[0].replaceAllMapped(
RegExp(r'\B(?=(\d{3})+(?!\d))'),
(Match m) => ',',
);
return '$intp.${parts[1]}';
}
if (v >= 1) return v.toStringAsFixed(2);
return v.toStringAsFixed(4);
}
@override
Widget build(BuildContext context) {
final bool up = market.delta >= 0;
final Color deltaColor = up
? _Web3MarketsListScreenState._up
: _Web3MarketsListScreenState._down;
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Row(
children: <Widget>[
GestureDetector(
onTap: onStar,
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.only(right: 10),
child: Icon(
starred ? Icons.star_rounded : Icons.star_border_rounded,
size: 18,
color: starred
? _Web3MarketsListScreenState._brand
: _Web3MarketsListScreenState._muted,
),
),
),
_Badge(ticker: market.ticker, tint: market.tint),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Text(
market.ticker,
style: const TextStyle(
fontFamily: _Web3MarketsListScreenState._font,
fontSize: 14.5,
fontWeight: FontWeight.w700,
color: _Web3MarketsListScreenState._text,
),
),
const SizedBox(width: 5),
Text(
'/USDT',
style: const TextStyle(
fontFamily: _Web3MarketsListScreenState._font,
fontSize: 11,
color: _Web3MarketsListScreenState._muted,
),
),
],
),
const SizedBox(height: 3),
Text(
'Vol ${market.vol}',
style: const TextStyle(
fontFamily: _Web3MarketsListScreenState._font,
fontSize: 11.5,
color: _Web3MarketsListScreenState._muted,
),
),
],
),
),
SizedBox(
width: 46,
height: 24,
child: CustomPaint(
painter: _Spark(points: market.spark, color: deltaColor),
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
Text(
'\$${_fmtPrice(market.price)}',
style: const TextStyle(
fontFamily: _Web3MarketsListScreenState._font,
fontSize: 14,
fontWeight: FontWeight.w600,
fontFeatures: <FontFeature>[FontFeature.tabularFigures()],
color: _Web3MarketsListScreenState._text,
),
),
const SizedBox(height: 5),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 7, vertical: 2.5),
decoration: BoxDecoration(
color: deltaColor.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(6),
),
child: Text(
'${up ? '+' : ''}${market.delta.toStringAsFixed(2)}%',
style: TextStyle(
fontFamily: _Web3MarketsListScreenState._font,
fontSize: 12,
fontWeight: FontWeight.w700,
fontFeatures: const <FontFeature>[
FontFeature.tabularFigures(),
],
color: deltaColor,
),
),
),
],
),
],
),
),
),
);
}
}
class _Badge extends StatelessWidget {
const _Badge({required this.ticker, required this.tint});
final String ticker;
final Color tint;
@override
Widget build(BuildContext context) {
return Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: tint.withValues(alpha: 0.16),
shape: BoxShape.circle,
border: Border.all(color: tint.withValues(alpha: 0.5)),
),
alignment: Alignment.center,
child: Text(
ticker.length <= 3 ? ticker : ticker.substring(0, 3),
style: TextStyle(
fontFamily: _Web3MarketsListScreenState._font,
fontSize: ticker.length >= 4 ? 9 : 10.5,
fontWeight: FontWeight.w700,
color: Color.lerp(tint, Colors.white, 0.4),
),
),
);
}
}
class _Spark extends CustomPainter {
const _Spark({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;
final double minV = points.reduce((double a, double b) => a < b ? a : b);
final double maxV = points.reduce((double a, double b) => a > b ? a : b);
final double range = (maxV - minV).abs() < 1e-6 ? 1 : maxV - minV;
final Path path = Path();
for (int i = 0; i < points.length; i++) {
final double x = size.width * i / (points.length - 1);
final double y = size.height * (1 - (points[i] - minV) / range);
i == 0 ? path.moveTo(x, y) : path.lineTo(x, y);
}
canvas.drawPath(
path,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1.8
..strokeJoin = StrokeJoin.round
..strokeCap = StrokeCap.round
..color = color,
);
}
@override
bool shouldRepaint(_Spark oldDelegate) =>
oldDelegate.points != points || oldDelegate.color != color;
}
class _BottomNav extends StatelessWidget {
const _BottomNav({required this.current, required this.onTabSelected});
final int current;
final ValueChanged<int>? onTabSelected;
static const List<IconData> _icons = <IconData>[
Icons.account_balance_wallet_rounded,
Icons.candlestick_chart_rounded,
Icons.swap_horiz_rounded,
Icons.percent_rounded,
Icons.person_rounded,
];
static const List<String> _labels = <String>[
'Home', 'Markets', 'Trade', 'Earn', 'Profile',
];
@override
Widget build(BuildContext context) {
return Container(
decoration: const BoxDecoration(
color: _Web3MarketsListScreenState._surface,
border: Border(
top: BorderSide(color: _Web3MarketsListScreenState._hairline),
),
),
child: SafeArea(
top: false,
child: SizedBox(
height: 62,
child: Row(
children: <Widget>[
for (int i = 0; i < _icons.length; i++)
Expanded(
child: GestureDetector(
onTap: () => onTabSelected?.call(i),
behavior: HitTestBehavior.opaque,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Icon(
_icons[i],
size: 23,
color: i == current
? _Web3MarketsListScreenState._brand
: _Web3MarketsListScreenState._muted,
),
const SizedBox(height: 4),
Text(
_labels[i],
style: TextStyle(
fontFamily: _Web3MarketsListScreenState._font,
fontSize: 10.5,
fontWeight: FontWeight.w600,
color: i == current
? _Web3MarketsListScreenState._brand
: _Web3MarketsListScreenState._muted,
),
),
],
),
),
),
],
),
),
),
);
}
}
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-markets-list2. AI agent (MCP)
Connect FlutterKit's MCP server in Claude or Cursor and just ask your agent to install web3-markets-list — it fetches and writes the files for you.
FAQ
Is this crypto markets list screen free to use commercially?
Yes — free for personal and commercial projects alike. Copy the code from this page, install it via the CLI command, or pull it through MCP; no licence key, no sign-up, no attribution required.
Does it need any packages or a chart library?
No. It is pure Flutter — the sparklines and ticker badges are drawn with CustomPainter, so there is no fl_chart, no cached_network_image, and no icon pack. The only asset is the bundled Inter font, referenced as fontFamily: 'Inter' throughout.
How do I feed it live market data?
Replace the static _all table with a list you build from your ticker feed — each _Market needs the ticker, name, price, signed delta, a volume string, a tint colour and a short list of normalised sparkline points. The _rows getter, sorting and favourites keep working unchanged, and onCoinTap hands you the tapped ticker for a detail route.
Why do Gainers and Losers not have their own lists?
Because they are views, not data. The _rows getter derives them from _all on every build — copy, sort by delta, filter by sign — so when a price update changes a coin's delta, it migrates between tabs automatically. Storing pre-sorted lists would mean re-sorting them by hand on every tick.
Which Flutter version does this require?
Flutter 3.22 or newer: the delta chips, badges and pill borders use Color.withValues(alpha: ...), and the constructors use super.key. On an older SDK, replace each withValues(alpha: x) with withOpacity(x) and expand the constructors to the {Key? key} : super(key: key) form.