How to Build a Crypto Discover Screen with Sparklines in Flutter (Full Code + Preview)
A coin browser has to do three things at once: search, filter, and show each asset's trend without a chart library. This tutorial builds all three in pure Flutter. Live text search and four category chips (All / Gainers / Losers / Trending) both funnel through a single computed `_visible` getter, and every row ends in a 50×26 sparkline drawn by a `CustomPainter` from seven normalised doubles. No charting package, no coin logos, no network calls — the ticker badge is painted text and the trend line is a `Path`.

Watch the Flutter UI walkthrough
A short screen recording of Crypto Discover running, if you'd rather see it before you read the code. The written tutorial below covers everything in it.
Can't see the video? Watch it on YouTube.
What you'll build
- ✓Live search over coin name *and* ticker, filtering as you type
- ✓Horizontal filter chips whose logic composes with search in one derived getter
- ✓A dependency-free sparkline `CustomPainter` that maps 0–1 values onto any box
- ✓Coin badges built from tinted circles and truncated tickers — no logo assets
- ✓A green/red trend colour shared by the sparkline and the percentage text
- ✓An empty state that quotes the user's own query back to them
Step-by-step build
Create the file
Add a new file at lib/fintech_crypto_discover/fintech_crypto_discover_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.
Coins as const data, with the sparkline baked in
class _FintechCryptoDiscoverScreenState
extends State<FintechCryptoDiscoverScreen> {
static const String _font = 'Inter';
static const Color _bg = Color(0xFF191C1F);
static const Color _surface = Color(0xFF242729);
static const Color _brand = Color(0xFF494FDF);
static const Color _teal = Color(0xFF00A87E);
static const Color _amber = Color(0xFFEC7E00);
static const Color _red = Color(0xFFE23B4A);
static const Color _muted = Color(0xFF8D969E);
static const List<String> _tabs = <String>['All', 'Gainers', 'Losers', 'Trending'];
int _tab = 0;
String _query = '';
static const List<_Coin> _all = <_Coin>[
_Coin('BTC', 'Bitcoin', _amber, r'$69,254.10', 2.4,
<double>[0.3, 0.4, 0.35, 0.5, 0.6, 0.66, 0.78]),
_Coin('ETH', 'Ethereum', _brand, r'$2,415.00', -1.2,
<double>[0.7, 0.66, 0.6, 0.55, 0.5, 0.46, 0.4]),
_Coin('SOL', 'Solana', _teal, r'$75.00', 5.8,
<double>[0.2, 0.3, 0.45, 0.55, 0.6, 0.7, 0.82]),
_Coin('ADA', 'Cardano', _red, r'$0.45', -0.4,
<double>[0.5, 0.52, 0.48, 0.5, 0.47, 0.5, 0.48]),
_Coin('XRP', 'Ripple', _brand, r'$0.62', 3.1,
<double>[0.35, 0.4, 0.38, 0.5, 0.55, 0.58, 0.64]),
_Coin('DOT', 'Polkadot', _amber, r'$7.20', -2.6,
<double>[0.7, 0.62, 0.6, 0.55, 0.48, 0.44, 0.4]),
_Coin('MATIC', 'Polygon', _brand, r'$0.88', 1.5,
<double>[0.45, 0.48, 0.5, 0.52, 0.55, 0.54, 0.58]),
_Coin('DOGE', 'Dogecoin', _amber, r'$0.16', 8.2,
<double>[0.2, 0.25, 0.4, 0.5, 0.62, 0.72, 0.88]),
];Alongside the colour tokens sit `_tabs` (the four chip labels), the two pieces of state — `int _tab = 0` and `String _query = ''` — and `_all`, a `const List<_Coin>` of eight coins. The last field of each `_Coin` is the interesting one: `<double>[0.3, 0.4, 0.35, …]`, seven *normalised* values between 0 and 1. Storing the trend pre-normalised rather than as prices is what lets the painter draw it into any size box without knowing the scale. Prices use raw strings (`r'$69,254.10'`) so the `$` isn't read as Dart interpolation.
One getter that composes search and filter
List<_Coin> get _visible {
Iterable<_Coin> list = _all;
if (_query.isNotEmpty) {
final String q = _query.toLowerCase();
list = list.where((_Coin c) =>
c.name.toLowerCase().contains(q) ||
c.ticker.toLowerCase().contains(q));
}
switch (_tab) {
case 1:
list = list.where((_Coin c) => c.change > 0);
case 2:
list = list.where((_Coin c) => c.change < 0);
case 3:
list = list.where((_Coin c) => c.change.abs() >= 3);
}
return list.toList();
}`_visible` is where all the filtering lives. It starts from `_all` as a lazy `Iterable`, applies the search `.where()` only when `_query` is non-empty (matching lowercase against both `name` and `ticker`), then runs a switch on `_tab` to layer the category filter on top — positive change for Gainers, negative for Losers, and `change.abs() >= 3` for Trending. Because each step returns another lazy `Iterable`, nothing is actually computed until the final `.toList()`. Deriving the list this way rather than keeping a separate `_filtered` field means search and chips can never disagree.
Search field and pill filter chips
Widget _buildSearch() {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 8),
child: Container(
height: 46,
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(
color: _surface,
borderRadius: BorderRadius.circular(14),
),
child: Row(
children: <Widget>[
const Icon(Icons.search_rounded, size: 20, color: _muted),
const SizedBox(width: 10),
Expanded(
child: TextField(
onChanged: (String v) => setState(() => _query = v),
cursorColor: _brand,
style: const TextStyle(
fontFamily: _font,
fontSize: 14,
letterSpacing: 0.24,
color: Colors.white,
),
decoration: const InputDecoration(
isDense: true,
border: InputBorder.none,
hintText: 'Search coins',
hintStyle: TextStyle(
fontFamily: _font,
fontSize: 14,
letterSpacing: 0.24,
color: _muted,
),
),
),
),
],
),
),
);
}
Widget _buildTabs() {
return SizedBox(
height: 40,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 20),
physics: const BouncingScrollPhysics(),
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),
child: Container(
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(horizontal: 18),
decoration: BoxDecoration(
color: active ? _brand : _surface,
borderRadius: BorderRadius.circular(9999),
),
child: Text(
_tabs[i],
style: TextStyle(
fontFamily: _font,
fontSize: 13.5,
fontWeight: FontWeight.w500,
letterSpacing: 0.24,
color: active ? Colors.white : _muted,
),
),
),
);
},
),
);
}The search box is a `Container` styled as a 46px rounded surface with a leading icon and an `Expanded` `TextField` whose `InputDecoration` sets `border: InputBorder.none` and `isDense: true` — that pair strips Material's default underline and vertical padding so the field sits flush inside your own container. `onChanged` writes straight into `setState`, which is why results update on every keystroke. The chips are a horizontal `ListView.separated` in a fixed 40px `SizedBox` (a horizontal list needs a bounded height), and each chip flips between `_brand`-on-white and `_surface`-on-muted based on `active`.
The coin row: badge, sparkline, price, delta
class _CoinRow extends StatelessWidget {
const _CoinRow({required this.coin, this.onTap});
final _Coin coin;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final bool up = coin.change >= 0;
final Color trend = up
? _FintechCryptoDiscoverScreenState._teal
: _FintechCryptoDiscoverScreenState._red;
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Row(
children: <Widget>[
Container(
width: 44,
height: 44,
alignment: Alignment.center,
decoration: BoxDecoration(
color: coin.color.withValues(alpha: 0.2),
shape: BoxShape.circle,
),
child: Text(
coin.ticker.length > 3
? coin.ticker.substring(0, 3)
: coin.ticker,
style: TextStyle(
fontFamily: _FintechCryptoDiscoverScreenState._font,
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 0.24,
color: coin.color,
),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
coin.name,
style: const TextStyle(
fontFamily: _FintechCryptoDiscoverScreenState._font,
fontSize: 14.5,
fontWeight: FontWeight.w500,
letterSpacing: 0.24,
color: Colors.white,
),
),
const SizedBox(height: 2),
Text(
coin.ticker,
style: const TextStyle(
fontFamily: _FintechCryptoDiscoverScreenState._font,
fontSize: 12.5,
letterSpacing: 0.24,
color: _FintechCryptoDiscoverScreenState._muted,
),
),
],
),
),
SizedBox(
width: 50,
height: 26,
child: CustomPaint(
painter: _SparkPainter(points: coin.spark, color: trend),
),
),
const SizedBox(width: 14),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
Text(
coin.price,
style: const TextStyle(
fontFamily: _FintechCryptoDiscoverScreenState._font,
fontSize: 14.5,
fontWeight: FontWeight.w600,
letterSpacing: 0.24,
color: Colors.white,
),
),
const SizedBox(height: 2),
Text(
'${up ? '+' : ''}${coin.change.toStringAsFixed(1)}%',
style: TextStyle(
fontFamily: _FintechCryptoDiscoverScreenState._font,
fontSize: 12,
fontWeight: FontWeight.w500,
letterSpacing: 0.24,
color: trend,
),
),
],
),
],
),
),
);
}
}`_CoinRow` computes `up` from the sign of `change` and derives a single `trend` colour (teal or red) from it — that one colour then paints both the sparkline and the percentage, so the row reads as up or down at a glance. The 44px badge shows `coin.ticker`, truncated with `substring(0, 3)` when longer than three characters, so 'MATIC' renders as 'MAT' and still fits the circle. The sparkline gets a hard `SizedBox(width: 50, height: 26)` because `CustomPaint` needs bounded constraints, and the delta is formatted with `toStringAsFixed(1)` plus a manual `+` prefix, since Dart only prints the minus sign for you.
Painting the sparkline by hand
/// Paints a simple sparkline from normalised 0–1 points.
class _SparkPainter extends CustomPainter {
_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;
final Paint line = Paint()
..color = color
..strokeWidth = 2
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round;
final double dx = size.width / (points.length - 1);
final Path path = Path();
for (int i = 0; i < points.length; i++) {
final double px = dx * i;
final double py = size.height - points[i].clamp(0, 1) * size.height;
i == 0 ? path.moveTo(px, py) : path.lineTo(px, py);
}
canvas.drawPath(path, line);
}
@override
bool shouldRepaint(covariant _SparkPainter oldDelegate) =>
oldDelegate.points != points || oldDelegate.color != color;
}The whole chart is 20 lines. `dx = size.width / (points.length - 1)` spreads the seven points evenly across the width, and `py = size.height - points[i].clamp(0, 1) * size.height` flips each value because canvas Y grows downward — without that subtraction the line would render upside down. The first point uses `moveTo` and the rest `lineTo`, building one `Path` that's stroked with `StrokeCap.round` and `StrokeJoin.round` so the ends and corners stay soft at 2px. `shouldRepaint` compares points and colour, so the line only redraws when the data actually changes.
Full code
The complete, ready-to-paste source. Free to use in your projects — one click copies it all.
import 'package:flutter/material.dart';
/// Crypto discover — explore & search all coins (Revolut-inspired design).
///
/// Self-contained per CONVENTIONS.md: pure Flutter only, the exact design font
/// (Inter) is bundled under `fonts/`, coin badges + sparklines are custom-painted
/// (no charting package, no network/emoji), and the screen forces its own dark
/// theme. Search filters live; category chips switch the visible set.
class FintechCryptoDiscoverScreen extends StatefulWidget {
const FintechCryptoDiscoverScreen({super.key, this.onBack, this.onAssetTap});
final VoidCallback? onBack;
final VoidCallback? onAssetTap;
@override
State<FintechCryptoDiscoverScreen> createState() =>
_FintechCryptoDiscoverScreenState();
}
class _FintechCryptoDiscoverScreenState
extends State<FintechCryptoDiscoverScreen> {
static const String _font = 'Inter';
static const Color _bg = Color(0xFF191C1F);
static const Color _surface = Color(0xFF242729);
static const Color _brand = Color(0xFF494FDF);
static const Color _teal = Color(0xFF00A87E);
static const Color _amber = Color(0xFFEC7E00);
static const Color _red = Color(0xFFE23B4A);
static const Color _muted = Color(0xFF8D969E);
static const List<String> _tabs = <String>['All', 'Gainers', 'Losers', 'Trending'];
int _tab = 0;
String _query = '';
static const List<_Coin> _all = <_Coin>[
_Coin('BTC', 'Bitcoin', _amber, r'$69,254.10', 2.4,
<double>[0.3, 0.4, 0.35, 0.5, 0.6, 0.66, 0.78]),
_Coin('ETH', 'Ethereum', _brand, r'$2,415.00', -1.2,
<double>[0.7, 0.66, 0.6, 0.55, 0.5, 0.46, 0.4]),
_Coin('SOL', 'Solana', _teal, r'$75.00', 5.8,
<double>[0.2, 0.3, 0.45, 0.55, 0.6, 0.7, 0.82]),
_Coin('ADA', 'Cardano', _red, r'$0.45', -0.4,
<double>[0.5, 0.52, 0.48, 0.5, 0.47, 0.5, 0.48]),
_Coin('XRP', 'Ripple', _brand, r'$0.62', 3.1,
<double>[0.35, 0.4, 0.38, 0.5, 0.55, 0.58, 0.64]),
_Coin('DOT', 'Polkadot', _amber, r'$7.20', -2.6,
<double>[0.7, 0.62, 0.6, 0.55, 0.48, 0.44, 0.4]),
_Coin('MATIC', 'Polygon', _brand, r'$0.88', 1.5,
<double>[0.45, 0.48, 0.5, 0.52, 0.55, 0.54, 0.58]),
_Coin('DOGE', 'Dogecoin', _amber, r'$0.16', 8.2,
<double>[0.2, 0.25, 0.4, 0.5, 0.62, 0.72, 0.88]),
];
List<_Coin> get _visible {
Iterable<_Coin> list = _all;
if (_query.isNotEmpty) {
final String q = _query.toLowerCase();
list = list.where((_Coin c) =>
c.name.toLowerCase().contains(q) ||
c.ticker.toLowerCase().contains(q));
}
switch (_tab) {
case 1:
list = list.where((_Coin c) => c.change > 0);
case 2:
list = list.where((_Coin c) => c.change < 0);
case 3:
list = list.where((_Coin c) => c.change.abs() >= 3);
}
return list.toList();
}
@override
Widget build(BuildContext context) {
final List<_Coin> list = _visible;
return Theme(
data: ThemeData.dark(useMaterial3: true),
child: Scaffold(
backgroundColor: _bg,
body: SafeArea(
child: Column(
children: <Widget>[
_buildAppBar(),
_buildSearch(),
_buildTabs(),
Expanded(
child: list.isEmpty
? _buildEmpty()
: ListView(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
children: <Widget>[
for (final _Coin c in list)
_CoinRow(coin: c, onTap: widget.onAssetTap),
],
),
),
],
),
),
),
);
}
Widget _buildAppBar() {
return Padding(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 4),
child: Row(
children: <Widget>[
IconButton(
onPressed: widget.onBack,
icon: const Icon(Icons.arrow_back_ios_new_rounded,
size: 20, color: Colors.white),
),
const Expanded(
child: Text(
'Discover',
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: _font,
fontSize: 18,
fontWeight: FontWeight.w500,
letterSpacing: 0.24,
color: Colors.white,
),
),
),
const SizedBox(width: 48),
],
),
);
}
Widget _buildSearch() {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 8),
child: Container(
height: 46,
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(
color: _surface,
borderRadius: BorderRadius.circular(14),
),
child: Row(
children: <Widget>[
const Icon(Icons.search_rounded, size: 20, color: _muted),
const SizedBox(width: 10),
Expanded(
child: TextField(
onChanged: (String v) => setState(() => _query = v),
cursorColor: _brand,
style: const TextStyle(
fontFamily: _font,
fontSize: 14,
letterSpacing: 0.24,
color: Colors.white,
),
decoration: const InputDecoration(
isDense: true,
border: InputBorder.none,
hintText: 'Search coins',
hintStyle: TextStyle(
fontFamily: _font,
fontSize: 14,
letterSpacing: 0.24,
color: _muted,
),
),
),
),
],
),
),
);
}
Widget _buildTabs() {
return SizedBox(
height: 40,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 20),
physics: const BouncingScrollPhysics(),
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),
child: Container(
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(horizontal: 18),
decoration: BoxDecoration(
color: active ? _brand : _surface,
borderRadius: BorderRadius.circular(9999),
),
child: Text(
_tabs[i],
style: TextStyle(
fontFamily: _font,
fontSize: 13.5,
fontWeight: FontWeight.w500,
letterSpacing: 0.24,
color: active ? Colors.white : _muted,
),
),
),
);
},
),
);
}
Widget _buildEmpty() {
return Center(
child: Text(
_query.isNotEmpty ? 'No coin matches "$_query"' : 'Nothing here',
style: const TextStyle(
fontFamily: _font,
fontSize: 14,
letterSpacing: 0.24,
color: _muted,
),
),
);
}
}
class _Coin {
const _Coin(this.ticker, this.name, this.color, this.price, this.change,
this.spark);
final String ticker;
final String name;
final Color color;
final String price;
final double change;
final List<double> spark;
}
class _CoinRow extends StatelessWidget {
const _CoinRow({required this.coin, this.onTap});
final _Coin coin;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final bool up = coin.change >= 0;
final Color trend = up
? _FintechCryptoDiscoverScreenState._teal
: _FintechCryptoDiscoverScreenState._red;
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Row(
children: <Widget>[
Container(
width: 44,
height: 44,
alignment: Alignment.center,
decoration: BoxDecoration(
color: coin.color.withValues(alpha: 0.2),
shape: BoxShape.circle,
),
child: Text(
coin.ticker.length > 3
? coin.ticker.substring(0, 3)
: coin.ticker,
style: TextStyle(
fontFamily: _FintechCryptoDiscoverScreenState._font,
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 0.24,
color: coin.color,
),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
coin.name,
style: const TextStyle(
fontFamily: _FintechCryptoDiscoverScreenState._font,
fontSize: 14.5,
fontWeight: FontWeight.w500,
letterSpacing: 0.24,
color: Colors.white,
),
),
const SizedBox(height: 2),
Text(
coin.ticker,
style: const TextStyle(
fontFamily: _FintechCryptoDiscoverScreenState._font,
fontSize: 12.5,
letterSpacing: 0.24,
color: _FintechCryptoDiscoverScreenState._muted,
),
),
],
),
),
SizedBox(
width: 50,
height: 26,
child: CustomPaint(
painter: _SparkPainter(points: coin.spark, color: trend),
),
),
const SizedBox(width: 14),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
Text(
coin.price,
style: const TextStyle(
fontFamily: _FintechCryptoDiscoverScreenState._font,
fontSize: 14.5,
fontWeight: FontWeight.w600,
letterSpacing: 0.24,
color: Colors.white,
),
),
const SizedBox(height: 2),
Text(
'${up ? '+' : ''}${coin.change.toStringAsFixed(1)}%',
style: TextStyle(
fontFamily: _FintechCryptoDiscoverScreenState._font,
fontSize: 12,
fontWeight: FontWeight.w500,
letterSpacing: 0.24,
color: trend,
),
),
],
),
],
),
),
);
}
}
/// Paints a simple sparkline from normalised 0–1 points.
class _SparkPainter extends CustomPainter {
_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;
final Paint line = Paint()
..color = color
..strokeWidth = 2
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round;
final double dx = size.width / (points.length - 1);
final Path path = Path();
for (int i = 0; i < points.length; i++) {
final double px = dx * i;
final double py = size.height - points[i].clamp(0, 1) * size.height;
i == 0 ? path.moveTo(px, py) : path.lineTo(px, py);
}
canvas.drawPath(path, line);
}
@override
bool shouldRepaint(covariant _SparkPainter oldDelegate) =>
oldDelegate.points != points || oldDelegate.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 fintech-crypto-discover2. AI agent (MCP)
Connect FlutterKit's MCP server in Claude or Cursor and just ask your agent to install fintech-crypto-discover — it fetches and writes the files for you.
FAQ
Is this crypto discover screen free to use?
Yes. The complete Dart source on this page is free for personal and commercial apps. Copy it from the code block, install it with the FlutterKit CLI (flutterkit add fintech-crypto-discover), or add it via an AI agent over MCP.
Does the sparkline need a charting package?
No. It's a 30-line CustomPainter that strokes a Path — no fl_chart, no syncfusion, no dependencies at all. The screen is pure Flutter; the only asset is the bundled Inter font.
How do I feed it real price data?
Replace the const _all list with coins from your API. The only conversion needed is the spark field: normalise your price history to 0–1 with (price - min) / (max - min) before handing it to _Coin, since the painter expects values in that range.
Why does the search feel instant with no debounce?
Because filtering runs over an in-memory list of eight items inside a lazy Iterable chain — there's no I/O to debounce. If you swap _visible for a network search, add a Timer-based debounce around the onChanged callback before calling your API.
Which Flutter version does it target?
It uses Color.withValues(alpha:), pattern-style switch statements without break, and Material 3, so it targets Flutter 3.27+ with Dart 3. On an older SDK, add break statements to the switch and swap withValues(alpha: x) for withOpacity(x).