How to Build a Crypto Portfolio Home Screen in Flutter (Full Code + Preview)
A crypto wallet's home tab has to show a number people check ten times a day — and let them hide it just as fast on a train. This tutorial walks through Aurum's portfolio home in Flutter: a hidable $21,822.61 balance with a daily-gain pill, a gradient area chart that swaps between six timeframes, a Buy/Send/Receive/Swap action card, and a holdings list where BTC, ETH, SOL and USDC each carry a painted ticker badge and inline sparkline. Everything is pure Flutter — every chart is a CustomPainter.

What you'll build
- ✓A hide-balance eye toggle where one _hidden bool masks the headline figure and every row's fiat value and coin amount in a single setState
- ✓An _AreaChartPainter equity curve with a gradient fill fading to transparent and a two-circle glowing last-price dot
- ✓A 1H–ALL timeframe pill row that swaps between six normalised data series, repainting the chart on tap
- ✓A quick-action card whose Buy button gets a gold gradient and a 16px-blur shadow while Send, Receive and Swap stay flat
- ✓Holdings rows with painted _TickerBadge coin avatars, 56×28 _SparklinePainter trend lines, and deltas coloured green #2EBD85 or red #F6465D
Step-by-step build
Create the file
Add a new file at lib/web3_home_portfolio/web3_home_portfolio_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.
Nine optional callbacks and the demo data
import 'package:flutter/material.dart';
/// Web3 home — Portfolio Home (Tab 1). Total balance with a hide toggle, a
/// painted equity area chart with timeframe pills, a quick-action row and a
/// holdings list with inline sparklines, over the 5-tab bottom nav. Self-
/// contained per CONVENTIONS.md: pure Flutter, bundled Inter font, all charts
/// custom-painted + painted ticker badges (no network image / no glyphs),
/// forced dark theme so it renders standalone as a route.
class Web3HomePortfolioScreen extends StatefulWidget {
const Web3HomePortfolioScreen({
super.key,
this.onTabSelected,
this.onSearch,
this.onNotifications,
this.onBuy,
this.onSend,
this.onReceive,
this.onSwap,
this.onSeeAllHoldings,
this.onAssetTap,
});
final ValueChanged<int>? onTabSelected;
final VoidCallback? onSearch;
final VoidCallback? onNotifications;
final VoidCallback? onBuy;
final VoidCallback? onSend;
final VoidCallback? onReceive;
final VoidCallback? onSwap;
final VoidCallback? onSeeAllHoldings;
final ValueChanged<String>? onAssetTap;
@override
State<Web3HomePortfolioScreen> createState() =>
_Web3HomePortfolioScreenState();
}
class _Web3HomePortfolioScreenState extends State<Web3HomePortfolioScreen> {
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> _frames = <String>['1H', '1D', '1W', '1M', '1Y', 'ALL'];
// Equity curves keyed by timeframe index (normalised 0..1, painted as area).
static const List<List<double>> _series = <List<double>>[
<double>[0.55, 0.5, 0.58, 0.52, 0.6, 0.57, 0.66, 0.62, 0.7],
<double>[0.4, 0.46, 0.42, 0.52, 0.48, 0.6, 0.55, 0.68, 0.72],
<double>[0.3, 0.42, 0.38, 0.5, 0.62, 0.55, 0.7, 0.66, 0.8],
<double>[0.2, 0.35, 0.3, 0.48, 0.4, 0.6, 0.72, 0.68, 0.85],
<double>[0.15, 0.28, 0.45, 0.4, 0.62, 0.58, 0.74, 0.8, 0.9],
<double>[0.1, 0.22, 0.34, 0.46, 0.5, 0.64, 0.7, 0.82, 0.95],
];
static const List<_Asset> _assets = <_Asset>[
_Asset('BTC', 'Bitcoin', r'$64,120', '+2.41%', true, '0.1842',
r'$11,810.90', Color(0xFFF7931A), <double>[0.4, 0.5, 0.45, 0.6, 0.55, 0.7, 0.8]),
_Asset('ETH', 'Ethereum', r'$2,748', '+1.07%', true, '1.842',
r'$5,063.21', Color(0xFF627EEA), <double>[0.5, 0.45, 0.55, 0.5, 0.6, 0.58, 0.66]),
_Asset('SOL', 'Solana', r'$152.90', '-3.18%', false, '24.5',
r'$3,748.50', Color(0xFF14F195), <double>[0.7, 0.65, 0.6, 0.5, 0.55, 0.4, 0.35]),
_Asset('USDC', 'USD Coin', r'$1.00', '+0.01%', true, '1,200.00',
r'$1,200.00', Color(0xFF2775CA), <double>[0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5]),
];
bool _hidden = false;
int _frame = 1;`Web3HomePortfolioScreen` is a `StatefulWidget` exposing nine nullable callbacks — `onTabSelected`, `onSearch`, `onNotifications`, the four action buttons, `onSeeAllHoldings` and `onAssetTap` (a `ValueChanged<String>` that hands back the ticker) — so the screen stays backend-agnostic: drop it in with no wiring and every tap is simply a no-op. The state class pins the palette as static consts: near-black `#0B0E11` background, `#161A1E` surface, gold `#F0B90B` brand, and the exchange-standard pair `#2EBD85` up-green / `#F6465D` down-red. `_series` holds six lists of nine doubles normalised 0..1, one per timeframe in `_frames`, and `_assets` is four `const _Asset` records (BTC, ETH, SOL, USDC) each carrying its brand tint — `#F7931A` orange for Bitcoin, `#627EEA` for Ethereum — plus a seven-point sparkline. Only two fields are mutable: `bool _hidden` and `int _frame`.
Forcing dark mode and assembling the scroll
@override
Widget build(BuildContext context) {
return Theme(
data: ThemeData.dark(useMaterial3: true),
child: Scaffold(
backgroundColor: _bg,
bottomNavigationBar: _BottomNav(
current: 0,
onTabSelected: widget.onTabSelected,
),
body: SafeArea(
bottom: false,
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
children: <Widget>[
_topBar(),
const SizedBox(height: 24),
_balanceBlock(),
const SizedBox(height: 18),
SizedBox(
height: 132,
child: CustomPaint(
painter: _AreaChartPainter(
points: _series[_frame],
up: true,
),
),
),
const SizedBox(height: 14),
_frameRow(),
const SizedBox(height: 22),
_quickActions(),
const SizedBox(height: 26),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
const Text(
'Your assets',
style: TextStyle(
fontFamily: _font,
fontSize: 17,
fontWeight: FontWeight.w700,
color: _text,
),
),
GestureDetector(
onTap: widget.onSeeAllHoldings,
behavior: HitTestBehavior.opaque,
child: const Text(
'See all',
style: TextStyle(
fontFamily: _font,
fontSize: 13.5,
fontWeight: FontWeight.w600,
color: _brand,
),
),
),
],
),
const SizedBox(height: 12),
for (final _Asset a in _assets) _AssetRow(
asset: a,
hidden: _hidden,
onTap: () => widget.onAssetTap?.call(a.ticker),
),
],
),
),
),
);
}`build` wraps everything in `Theme(data: ThemeData.dark(useMaterial3: true))` before the `Scaffold`, so the screen renders correctly as a standalone route even inside a light-themed app — a wallet home is dark by design, not by the host's preference. The body is a single `ListView` with `EdgeInsets.fromLTRB(20, 8, 20, 24)` stacking top bar, balance block, a `SizedBox(height: 132)` holding the `CustomPaint` chart, the pill row, quick actions and the holdings section. Note the chart is handed `_series[_frame]` — changing `_frame` swaps the whole curve. The 'Your assets' header pairs a 17px `w700` title with a gold 'See all' link in a `spaceBetween` Row, and the rows are emitted by a collection-for over `_assets`, each receiving the shared `_hidden` flag so masking propagates without any per-row state. `SafeArea` sets `bottom: false` because the bottom nav handles its own inset.
The top bar: initials avatar and a badged bell
Widget _topBar() {
return Row(
children: <Widget>[
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: _brand.withValues(alpha: 0.16),
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: const Text(
'RA',
style: TextStyle(
fontFamily: _font,
fontSize: 14,
fontWeight: FontWeight.w700,
color: _brand,
),
),
),
const SizedBox(width: 12),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Welcome back',
style: TextStyle(
fontFamily: _font,
fontSize: 12.5,
color: _muted,
),
),
SizedBox(height: 2),
Text(
'Account 1',
style: TextStyle(
fontFamily: _font,
fontSize: 15,
fontWeight: FontWeight.w700,
color: _text,
),
),
],
),
),
_IconButton(icon: Icons.search_rounded, onTap: widget.onSearch),
const SizedBox(width: 8),
_IconButton(
icon: Icons.notifications_none_rounded,
badge: true,
onTap: widget.onNotifications,
),
],
);
}
Widget _balanceBlock() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
const Text(
'Total balance',
style: TextStyle(
fontFamily: _font,
fontSize: 13,
color: _muted,
),
),
const SizedBox(width: 8),
GestureDetector(
onTap: () => setState(() => _hidden = !_hidden),
behavior: HitTestBehavior.opaque,
child: Icon(
_hidden
? Icons.visibility_off_outlined
: Icons.visibility_outlined,
size: 16,
color: _muted,
),
),
],
),
const SizedBox(height: 6),
Text(
_hidden ? '••••••••' : r'$21,822.61',
style: const TextStyle(
fontFamily: _font,
fontSize: 34,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
fontFeatures: <FontFeature>[FontFeature.tabularFigures()],
color: _text,
),
),
const SizedBox(height: 6),
Row(
children: <Widget>[
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: _up.withValues(alpha: 0.16),
borderRadius: BorderRadius.circular(7),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Icon(Icons.arrow_drop_up_rounded, size: 16, color: _up),
Text(
'+\$412.18 (1.92%)',
style: TextStyle(
fontFamily: _font,
fontSize: 12.5,
fontWeight: FontWeight.w600,
color: _up,
),
),
],
),
),
const SizedBox(width: 8),
const Text(
'Today',
style: TextStyle(
fontFamily: _font,
fontSize: 12.5,
color: _muted,
),
),
],
),
],
);
}Instead of a network image, the avatar is a 42px circle of `_brand.withValues(alpha: 0.16)` with gold 'RA' initials — one Container, no asset to load or fail. The greeting stacks muted 12.5px 'Welcome back' over bold 'Account 1', and two `_IconButton`s (search, and a bell with `badge: true`) sit on the right. Below, the balance block is where the screen earns its keep: a `GestureDetector` on the eye icon flips `_hidden` in `setState`, swapping the icon between `visibility_outlined` and `visibility_off_outlined` and the 34px headline between `$21,822.61` and eight bullets. The headline sets `FontFeature.tabularFigures()` so digits are fixed-width — a live-updating balance would otherwise jitter horizontally as digits change. The daily gain is a pill of `_up` at 16% alpha holding an `arrow_drop_up_rounded` and '+$412.18 (1.92%)' in full green, with `MainAxisSize.min` keeping it hugged to its text, and a plain muted 'Today' sits outside the pill so the coloured surface contains only the number.
Timeframe pills and the quick-action card
Widget _frameRow() {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
for (int i = 0; i < _frames.length; i++)
GestureDetector(
onTap: () => setState(() => _frame = i),
behavior: HitTestBehavior.opaque,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7),
decoration: BoxDecoration(
color: i == _frame ? _surfaceAlt : Colors.transparent,
borderRadius: BorderRadius.circular(9),
),
child: Text(
_frames[i],
style: TextStyle(
fontFamily: _font,
fontSize: 12.5,
fontWeight: FontWeight.w600,
color: i == _frame ? _brand : _muted,
),
),
),
),
],
);
}
Widget _quickActions() {
return Container(
padding: const EdgeInsets.symmetric(vertical: 18),
decoration: BoxDecoration(
color: _surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: _hairline),
),
child: Row(
children: <Widget>[
_QuickAction(
icon: Icons.add_rounded,
label: 'Buy',
primary: true,
onTap: widget.onBuy,
),
_QuickAction(
icon: Icons.arrow_upward_rounded,
label: 'Send',
onTap: widget.onSend),
_QuickAction(
icon: Icons.arrow_downward_rounded,
label: 'Receive',
onTap: widget.onReceive),
_QuickAction(
icon: Icons.swap_horiz_rounded,
label: 'Swap',
onTap: widget.onSwap),
],
),
);
}`_frameRow` loops over `_frames` with a collection-for inside a `spaceBetween` Row, so the six pills distribute evenly at any width with no gap arithmetic. Each pill is a `GestureDetector` (with `HitTestBehavior.opaque` so the padding is tappable) around a Container that is `_surfaceAlt` with gold 12.5px text when `i == _frame`, and transparent with muted text otherwise — selection is a background plus a colour swap, not a border, which keeps the row calm. Tapping calls `setState(() => _frame = i)` and the chart above repaints with the new series. `_quickActions` is a `_surface` card with a 20px radius and `_hairline` border holding four `_QuickAction`s; only Buy passes `primary: true`, which is the whole visual hierarchy of the row decided by a single flag.
Holdings rows: one bool hides every number
class _Asset {
const _Asset(this.ticker, this.name, this.price, this.delta, this.up,
this.amount, this.value, this.tint, this.spark);
final String ticker;
final String name;
final String price;
final String delta;
final bool up;
final String amount;
final String value;
final Color tint;
final List<double> spark;
}
class _AssetRow extends StatelessWidget {
const _AssetRow({required this.asset, required this.hidden, required this.onTap});
final _Asset asset;
final bool hidden;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final Color deltaColor = asset.up
? _Web3HomePortfolioScreenState._up
: _Web3HomePortfolioScreenState._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>[
_TickerBadge(ticker: asset.ticker, tint: asset.tint),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
asset.name,
style: const TextStyle(
fontFamily: _Web3HomePortfolioScreenState._font,
fontSize: 14.5,
fontWeight: FontWeight.w600,
color: _Web3HomePortfolioScreenState._text,
),
),
const SizedBox(height: 3),
Row(
children: <Widget>[
Text(
asset.price,
style: const TextStyle(
fontFamily: _Web3HomePortfolioScreenState._font,
fontSize: 12.5,
fontFeatures: <FontFeature>[
FontFeature.tabularFigures(),
],
color: _Web3HomePortfolioScreenState._muted,
),
),
const SizedBox(width: 6),
Text(
asset.delta,
style: TextStyle(
fontFamily: _Web3HomePortfolioScreenState._font,
fontSize: 12.5,
fontWeight: FontWeight.w600,
color: deltaColor,
),
),
],
),
],
),
),
SizedBox(
width: 56,
height: 28,
child: CustomPaint(
painter: _SparklinePainter(points: asset.spark, color: deltaColor),
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
Text(
hidden ? '••••' : asset.value,
style: const TextStyle(
fontFamily: _Web3HomePortfolioScreenState._font,
fontSize: 14,
fontWeight: FontWeight.w600,
fontFeatures: <FontFeature>[FontFeature.tabularFigures()],
color: _Web3HomePortfolioScreenState._text,
),
),
const SizedBox(height: 3),
Text(
hidden ? '••' : '${asset.amount} ${asset.ticker}',
style: const TextStyle(
fontFamily: _Web3HomePortfolioScreenState._font,
fontSize: 12,
fontFeatures: <FontFeature>[FontFeature.tabularFigures()],
color: _Web3HomePortfolioScreenState._muted,
),
),
],
),
],
),
),
),
);
}
}`_Asset` is a nine-field const model with a positional constructor, keeping the demo list at the top of the file compact. `_AssetRow` computes `deltaColor` once from `asset.up` and reuses it for both the percentage text and the sparkline stroke, so a coin's number and its trend line can never disagree. The row is `Material(color: Colors.transparent)` wrapping `InkWell` — the transparent Material is required for the ripple to render over the dark page background. Layout runs badge, then an `Expanded` name/price column, then a fixed `SizedBox(width: 56, height: 28)` for the `_SparklinePainter`, then a right-aligned value column. When `hidden` is true the fiat value becomes '••••' and the amount line '••' — the price and delta stay visible, because hiding what you own is not the same as hiding what the market is doing. All numeric text again uses `tabularFigures` so the columns align.
The gradient Buy button and circular icon buttons
class _QuickAction extends StatelessWidget {
const _QuickAction({
required this.icon,
required this.label,
required this.onTap,
this.primary = false,
});
final IconData icon;
final String label;
final VoidCallback? onTap;
final bool primary;
@override
Widget build(BuildContext context) {
return Expanded(
child: GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Column(
children: <Widget>[
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: primary
? const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: <Color>[
Color(0xFFF7D24C),
_Web3HomePortfolioScreenState._brand,
],
)
: null,
color: primary
? null
: _Web3HomePortfolioScreenState._surfaceAlt,
border: primary
? null
: Border.all(
color: _Web3HomePortfolioScreenState._hairline),
boxShadow: primary
? <BoxShadow>[
BoxShadow(
color: _Web3HomePortfolioScreenState._brand
.withValues(alpha: 0.30),
blurRadius: 16,
offset: const Offset(0, 6),
),
]
: null,
),
child: Icon(
icon,
size: 23,
color: primary
? _Web3HomePortfolioScreenState._bg
: _Web3HomePortfolioScreenState._text,
),
),
const SizedBox(height: 10),
Text(
label,
style: TextStyle(
fontFamily: _Web3HomePortfolioScreenState._font,
fontSize: 12.5,
fontWeight: primary ? FontWeight.w700 : FontWeight.w600,
color: primary
? _Web3HomePortfolioScreenState._text
: _Web3HomePortfolioScreenState._muted,
),
),
],
),
),
);
}
}
class _IconButton extends StatelessWidget {
const _IconButton({required this.icon, this.badge = false, required this.onTap});
final IconData icon;
final bool badge;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return Material(
color: _Web3HomePortfolioScreenState._surface,
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: onTap,
child: SizedBox(
width: 42,
height: 42,
child: Stack(
alignment: Alignment.center,
children: <Widget>[
Icon(icon, size: 20, color: _Web3HomePortfolioScreenState._text),
if (badge)
Positioned(
top: 11,
right: 12,
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: _Web3HomePortfolioScreenState._brand,
shape: BoxShape.circle,
border: Border.all(
color: _Web3HomePortfolioScreenState._surface,
width: 1.5),
),
),
),
],
),
),
),
);
}
}`_QuickAction` puts each action in an `Expanded` so four columns split the card evenly. The 52px circle branches entirely on `primary`: Buy gets a top-left-to-bottom-right `LinearGradient` from light gold `#F7D24C` into `_brand`, plus a `BoxShadow` of `_brand` at 30% alpha with `blurRadius: 16` and a 6px downward offset — a glow that makes the money-in action the brightest object on screen. The other three get flat `_surfaceAlt` with a hairline border. The icon colour flips too: near-black `_bg` on the gold circle, `_text` on the dark ones, and even the label weight steps from `w600` to `w700` for the primary. `_IconButton` uses `Material` with `CircleBorder` so the InkWell ripple clips to the circle, and the notification badge is an 8px gold dot `Positioned` at top 11 / right 12 with a 1.5px `_surface`-coloured border — the border punches a gap between dot and bell so it reads as sitting on top rather than touching.
Ticker badges and the five-tab bottom nav
/// Painted coin avatar — ticker text on a tinted circle (NO ₿/Ξ glyphs).
class _TickerBadge extends StatelessWidget {
const _TickerBadge({required this.ticker, required this.tint});
final String ticker;
final Color tint;
@override
Widget build(BuildContext context) {
return Container(
width: 40,
height: 40,
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: _Web3HomePortfolioScreenState._font,
fontSize: ticker.length >= 4 ? 9.5 : 11,
fontWeight: FontWeight.w700,
color: Color.lerp(tint, Colors.white, 0.35),
),
),
);
}
}
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: _Web3HomePortfolioScreenState._surface,
border: Border(
top: BorderSide(color: _Web3HomePortfolioScreenState._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
? _Web3HomePortfolioScreenState._brand
: _Web3HomePortfolioScreenState._muted,
),
const SizedBox(height: 4),
Text(
_labels[i],
style: TextStyle(
fontFamily: _Web3HomePortfolioScreenState._font,
fontSize: 10.5,
fontWeight: FontWeight.w600,
color: i == current
? _Web3HomePortfolioScreenState._brand
: _Web3HomePortfolioScreenState._muted,
),
),
],
),
),
),
],
),
),
),
);
}
}`_TickerBadge` solves coin logos with typography: a 40px circle tinted `tint.withValues(alpha: 0.16)` with a half-alpha border of the same tint, and the ticker text coloured `Color.lerp(tint, Colors.white, 0.35)` — lightening each coin's brand colour by 35% keeps it legible on the dark tint without flattening every badge to white. It also guards its own text: tickers longer than three characters are truncated with `substring(0, 3)` and four-plus-character tickers drop from 11 to 9.5px, so USDC still fits. `_BottomNav` keeps its icons and labels in parallel static const lists and loops them into five `Expanded` tabs inside a 62px row; the active tab (index 0, Home) paints both icon and 10.5px label in `_brand` gold. The nav's Container owns the `_surface` fill and top hairline, with `SafeArea(top: false)` inside it so the colour extends beneath the home indicator.
Two painters: gradient area chart and sparkline
/// Line + gradient-area equity chart with a last-price dot.
class _AreaChartPainter extends CustomPainter {
const _AreaChartPainter({required this.points, required this.up});
final List<double> points;
final bool up;
@override
void paint(Canvas canvas, Size size) {
if (points.isEmpty) return;
final Color line = up
? const Color(0xFF2EBD85)
: const Color(0xFFF6465D);
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]);
i == 0 ? path.moveTo(x, y) : path.lineTo(x, y);
}
final Path fill = Path.from(path)
..lineTo(size.width, size.height)
..lineTo(0, size.height)
..close();
canvas.drawPath(
fill,
Paint()
..shader = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: <Color>[line.withValues(alpha: 0.28), line.withValues(alpha: 0)],
).createShader(Offset.zero & size),
);
canvas.drawPath(
path,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2.5
..strokeJoin = StrokeJoin.round
..strokeCap = StrokeCap.round
..color = line,
);
final Offset last = Offset(
size.width, size.height * (1 - points.last));
canvas.drawCircle(last, 6, Paint()..color = line.withValues(alpha: 0.25));
canvas.drawCircle(last, 3.5, Paint()..color = line);
}
@override
bool shouldRepaint(_AreaChartPainter oldDelegate) =>
oldDelegate.points != points || oldDelegate.up != up;
}
/// Tiny inline trend line for market rows.
class _SparklinePainter extends CustomPainter {
const _SparklinePainter({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 = 2
..strokeJoin = StrokeJoin.round
..strokeCap = StrokeCap.round
..color = color,
);
}
@override
bool shouldRepaint(_SparklinePainter oldDelegate) =>
oldDelegate.points != points || oldDelegate.color != color;
}`_AreaChartPainter` builds one `Path` through the points — x spaced as `size.width * i / (points.length - 1)`, y inverted as `size.height * (1 - points[i])` since canvas y grows downward — then derives the fill with `Path.from(path)` extended down both bottom corners and closed. The fill is shaded by a vertical `LinearGradient` from the line colour at 28% alpha to fully transparent, painted before the 2.5px round-capped stroke so the line sits on top. The last price gets a two-circle glow: a 6px disc at 25% alpha under a solid 3.5px dot. `_SparklinePainter` differs in one important way — it normalises against its own min and max, so USDC's flat [0.5, …] series (range guarded against zero with `1e-6`) draws a centred line while SOL's decline uses the full 28px height. Both implement `shouldRepaint` by comparing points and colour, so switching timeframes repaints the big chart but never the row sparklines.
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 home — Portfolio Home (Tab 1). Total balance with a hide toggle, a
/// painted equity area chart with timeframe pills, a quick-action row and a
/// holdings list with inline sparklines, over the 5-tab bottom nav. Self-
/// contained per CONVENTIONS.md: pure Flutter, bundled Inter font, all charts
/// custom-painted + painted ticker badges (no network image / no glyphs),
/// forced dark theme so it renders standalone as a route.
class Web3HomePortfolioScreen extends StatefulWidget {
const Web3HomePortfolioScreen({
super.key,
this.onTabSelected,
this.onSearch,
this.onNotifications,
this.onBuy,
this.onSend,
this.onReceive,
this.onSwap,
this.onSeeAllHoldings,
this.onAssetTap,
});
final ValueChanged<int>? onTabSelected;
final VoidCallback? onSearch;
final VoidCallback? onNotifications;
final VoidCallback? onBuy;
final VoidCallback? onSend;
final VoidCallback? onReceive;
final VoidCallback? onSwap;
final VoidCallback? onSeeAllHoldings;
final ValueChanged<String>? onAssetTap;
@override
State<Web3HomePortfolioScreen> createState() =>
_Web3HomePortfolioScreenState();
}
class _Web3HomePortfolioScreenState extends State<Web3HomePortfolioScreen> {
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> _frames = <String>['1H', '1D', '1W', '1M', '1Y', 'ALL'];
// Equity curves keyed by timeframe index (normalised 0..1, painted as area).
static const List<List<double>> _series = <List<double>>[
<double>[0.55, 0.5, 0.58, 0.52, 0.6, 0.57, 0.66, 0.62, 0.7],
<double>[0.4, 0.46, 0.42, 0.52, 0.48, 0.6, 0.55, 0.68, 0.72],
<double>[0.3, 0.42, 0.38, 0.5, 0.62, 0.55, 0.7, 0.66, 0.8],
<double>[0.2, 0.35, 0.3, 0.48, 0.4, 0.6, 0.72, 0.68, 0.85],
<double>[0.15, 0.28, 0.45, 0.4, 0.62, 0.58, 0.74, 0.8, 0.9],
<double>[0.1, 0.22, 0.34, 0.46, 0.5, 0.64, 0.7, 0.82, 0.95],
];
static const List<_Asset> _assets = <_Asset>[
_Asset('BTC', 'Bitcoin', r'$64,120', '+2.41%', true, '0.1842',
r'$11,810.90', Color(0xFFF7931A), <double>[0.4, 0.5, 0.45, 0.6, 0.55, 0.7, 0.8]),
_Asset('ETH', 'Ethereum', r'$2,748', '+1.07%', true, '1.842',
r'$5,063.21', Color(0xFF627EEA), <double>[0.5, 0.45, 0.55, 0.5, 0.6, 0.58, 0.66]),
_Asset('SOL', 'Solana', r'$152.90', '-3.18%', false, '24.5',
r'$3,748.50', Color(0xFF14F195), <double>[0.7, 0.65, 0.6, 0.5, 0.55, 0.4, 0.35]),
_Asset('USDC', 'USD Coin', r'$1.00', '+0.01%', true, '1,200.00',
r'$1,200.00', Color(0xFF2775CA), <double>[0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5]),
];
bool _hidden = false;
int _frame = 1;
@override
Widget build(BuildContext context) {
return Theme(
data: ThemeData.dark(useMaterial3: true),
child: Scaffold(
backgroundColor: _bg,
bottomNavigationBar: _BottomNav(
current: 0,
onTabSelected: widget.onTabSelected,
),
body: SafeArea(
bottom: false,
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
children: <Widget>[
_topBar(),
const SizedBox(height: 24),
_balanceBlock(),
const SizedBox(height: 18),
SizedBox(
height: 132,
child: CustomPaint(
painter: _AreaChartPainter(
points: _series[_frame],
up: true,
),
),
),
const SizedBox(height: 14),
_frameRow(),
const SizedBox(height: 22),
_quickActions(),
const SizedBox(height: 26),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
const Text(
'Your assets',
style: TextStyle(
fontFamily: _font,
fontSize: 17,
fontWeight: FontWeight.w700,
color: _text,
),
),
GestureDetector(
onTap: widget.onSeeAllHoldings,
behavior: HitTestBehavior.opaque,
child: const Text(
'See all',
style: TextStyle(
fontFamily: _font,
fontSize: 13.5,
fontWeight: FontWeight.w600,
color: _brand,
),
),
),
],
),
const SizedBox(height: 12),
for (final _Asset a in _assets) _AssetRow(
asset: a,
hidden: _hidden,
onTap: () => widget.onAssetTap?.call(a.ticker),
),
],
),
),
),
);
}
Widget _topBar() {
return Row(
children: <Widget>[
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: _brand.withValues(alpha: 0.16),
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: const Text(
'RA',
style: TextStyle(
fontFamily: _font,
fontSize: 14,
fontWeight: FontWeight.w700,
color: _brand,
),
),
),
const SizedBox(width: 12),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Welcome back',
style: TextStyle(
fontFamily: _font,
fontSize: 12.5,
color: _muted,
),
),
SizedBox(height: 2),
Text(
'Account 1',
style: TextStyle(
fontFamily: _font,
fontSize: 15,
fontWeight: FontWeight.w700,
color: _text,
),
),
],
),
),
_IconButton(icon: Icons.search_rounded, onTap: widget.onSearch),
const SizedBox(width: 8),
_IconButton(
icon: Icons.notifications_none_rounded,
badge: true,
onTap: widget.onNotifications,
),
],
);
}
Widget _balanceBlock() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
const Text(
'Total balance',
style: TextStyle(
fontFamily: _font,
fontSize: 13,
color: _muted,
),
),
const SizedBox(width: 8),
GestureDetector(
onTap: () => setState(() => _hidden = !_hidden),
behavior: HitTestBehavior.opaque,
child: Icon(
_hidden
? Icons.visibility_off_outlined
: Icons.visibility_outlined,
size: 16,
color: _muted,
),
),
],
),
const SizedBox(height: 6),
Text(
_hidden ? '••••••••' : r'$21,822.61',
style: const TextStyle(
fontFamily: _font,
fontSize: 34,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
fontFeatures: <FontFeature>[FontFeature.tabularFigures()],
color: _text,
),
),
const SizedBox(height: 6),
Row(
children: <Widget>[
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: _up.withValues(alpha: 0.16),
borderRadius: BorderRadius.circular(7),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Icon(Icons.arrow_drop_up_rounded, size: 16, color: _up),
Text(
'+\$412.18 (1.92%)',
style: TextStyle(
fontFamily: _font,
fontSize: 12.5,
fontWeight: FontWeight.w600,
color: _up,
),
),
],
),
),
const SizedBox(width: 8),
const Text(
'Today',
style: TextStyle(
fontFamily: _font,
fontSize: 12.5,
color: _muted,
),
),
],
),
],
);
}
Widget _frameRow() {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
for (int i = 0; i < _frames.length; i++)
GestureDetector(
onTap: () => setState(() => _frame = i),
behavior: HitTestBehavior.opaque,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7),
decoration: BoxDecoration(
color: i == _frame ? _surfaceAlt : Colors.transparent,
borderRadius: BorderRadius.circular(9),
),
child: Text(
_frames[i],
style: TextStyle(
fontFamily: _font,
fontSize: 12.5,
fontWeight: FontWeight.w600,
color: i == _frame ? _brand : _muted,
),
),
),
),
],
);
}
Widget _quickActions() {
return Container(
padding: const EdgeInsets.symmetric(vertical: 18),
decoration: BoxDecoration(
color: _surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: _hairline),
),
child: Row(
children: <Widget>[
_QuickAction(
icon: Icons.add_rounded,
label: 'Buy',
primary: true,
onTap: widget.onBuy,
),
_QuickAction(
icon: Icons.arrow_upward_rounded,
label: 'Send',
onTap: widget.onSend),
_QuickAction(
icon: Icons.arrow_downward_rounded,
label: 'Receive',
onTap: widget.onReceive),
_QuickAction(
icon: Icons.swap_horiz_rounded,
label: 'Swap',
onTap: widget.onSwap),
],
),
);
}
}
class _Asset {
const _Asset(this.ticker, this.name, this.price, this.delta, this.up,
this.amount, this.value, this.tint, this.spark);
final String ticker;
final String name;
final String price;
final String delta;
final bool up;
final String amount;
final String value;
final Color tint;
final List<double> spark;
}
class _AssetRow extends StatelessWidget {
const _AssetRow({required this.asset, required this.hidden, required this.onTap});
final _Asset asset;
final bool hidden;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final Color deltaColor = asset.up
? _Web3HomePortfolioScreenState._up
: _Web3HomePortfolioScreenState._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>[
_TickerBadge(ticker: asset.ticker, tint: asset.tint),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
asset.name,
style: const TextStyle(
fontFamily: _Web3HomePortfolioScreenState._font,
fontSize: 14.5,
fontWeight: FontWeight.w600,
color: _Web3HomePortfolioScreenState._text,
),
),
const SizedBox(height: 3),
Row(
children: <Widget>[
Text(
asset.price,
style: const TextStyle(
fontFamily: _Web3HomePortfolioScreenState._font,
fontSize: 12.5,
fontFeatures: <FontFeature>[
FontFeature.tabularFigures(),
],
color: _Web3HomePortfolioScreenState._muted,
),
),
const SizedBox(width: 6),
Text(
asset.delta,
style: TextStyle(
fontFamily: _Web3HomePortfolioScreenState._font,
fontSize: 12.5,
fontWeight: FontWeight.w600,
color: deltaColor,
),
),
],
),
],
),
),
SizedBox(
width: 56,
height: 28,
child: CustomPaint(
painter: _SparklinePainter(points: asset.spark, color: deltaColor),
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
Text(
hidden ? '••••' : asset.value,
style: const TextStyle(
fontFamily: _Web3HomePortfolioScreenState._font,
fontSize: 14,
fontWeight: FontWeight.w600,
fontFeatures: <FontFeature>[FontFeature.tabularFigures()],
color: _Web3HomePortfolioScreenState._text,
),
),
const SizedBox(height: 3),
Text(
hidden ? '••' : '${asset.amount} ${asset.ticker}',
style: const TextStyle(
fontFamily: _Web3HomePortfolioScreenState._font,
fontSize: 12,
fontFeatures: <FontFeature>[FontFeature.tabularFigures()],
color: _Web3HomePortfolioScreenState._muted,
),
),
],
),
],
),
),
),
);
}
}
class _QuickAction extends StatelessWidget {
const _QuickAction({
required this.icon,
required this.label,
required this.onTap,
this.primary = false,
});
final IconData icon;
final String label;
final VoidCallback? onTap;
final bool primary;
@override
Widget build(BuildContext context) {
return Expanded(
child: GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Column(
children: <Widget>[
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: primary
? const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: <Color>[
Color(0xFFF7D24C),
_Web3HomePortfolioScreenState._brand,
],
)
: null,
color: primary
? null
: _Web3HomePortfolioScreenState._surfaceAlt,
border: primary
? null
: Border.all(
color: _Web3HomePortfolioScreenState._hairline),
boxShadow: primary
? <BoxShadow>[
BoxShadow(
color: _Web3HomePortfolioScreenState._brand
.withValues(alpha: 0.30),
blurRadius: 16,
offset: const Offset(0, 6),
),
]
: null,
),
child: Icon(
icon,
size: 23,
color: primary
? _Web3HomePortfolioScreenState._bg
: _Web3HomePortfolioScreenState._text,
),
),
const SizedBox(height: 10),
Text(
label,
style: TextStyle(
fontFamily: _Web3HomePortfolioScreenState._font,
fontSize: 12.5,
fontWeight: primary ? FontWeight.w700 : FontWeight.w600,
color: primary
? _Web3HomePortfolioScreenState._text
: _Web3HomePortfolioScreenState._muted,
),
),
],
),
),
);
}
}
class _IconButton extends StatelessWidget {
const _IconButton({required this.icon, this.badge = false, required this.onTap});
final IconData icon;
final bool badge;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return Material(
color: _Web3HomePortfolioScreenState._surface,
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: onTap,
child: SizedBox(
width: 42,
height: 42,
child: Stack(
alignment: Alignment.center,
children: <Widget>[
Icon(icon, size: 20, color: _Web3HomePortfolioScreenState._text),
if (badge)
Positioned(
top: 11,
right: 12,
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: _Web3HomePortfolioScreenState._brand,
shape: BoxShape.circle,
border: Border.all(
color: _Web3HomePortfolioScreenState._surface,
width: 1.5),
),
),
),
],
),
),
),
);
}
}
/// Painted coin avatar — ticker text on a tinted circle (NO ₿/Ξ glyphs).
class _TickerBadge extends StatelessWidget {
const _TickerBadge({required this.ticker, required this.tint});
final String ticker;
final Color tint;
@override
Widget build(BuildContext context) {
return Container(
width: 40,
height: 40,
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: _Web3HomePortfolioScreenState._font,
fontSize: ticker.length >= 4 ? 9.5 : 11,
fontWeight: FontWeight.w700,
color: Color.lerp(tint, Colors.white, 0.35),
),
),
);
}
}
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: _Web3HomePortfolioScreenState._surface,
border: Border(
top: BorderSide(color: _Web3HomePortfolioScreenState._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
? _Web3HomePortfolioScreenState._brand
: _Web3HomePortfolioScreenState._muted,
),
const SizedBox(height: 4),
Text(
_labels[i],
style: TextStyle(
fontFamily: _Web3HomePortfolioScreenState._font,
fontSize: 10.5,
fontWeight: FontWeight.w600,
color: i == current
? _Web3HomePortfolioScreenState._brand
: _Web3HomePortfolioScreenState._muted,
),
),
],
),
),
),
],
),
),
),
);
}
}
/// Line + gradient-area equity chart with a last-price dot.
class _AreaChartPainter extends CustomPainter {
const _AreaChartPainter({required this.points, required this.up});
final List<double> points;
final bool up;
@override
void paint(Canvas canvas, Size size) {
if (points.isEmpty) return;
final Color line = up
? const Color(0xFF2EBD85)
: const Color(0xFFF6465D);
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]);
i == 0 ? path.moveTo(x, y) : path.lineTo(x, y);
}
final Path fill = Path.from(path)
..lineTo(size.width, size.height)
..lineTo(0, size.height)
..close();
canvas.drawPath(
fill,
Paint()
..shader = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: <Color>[line.withValues(alpha: 0.28), line.withValues(alpha: 0)],
).createShader(Offset.zero & size),
);
canvas.drawPath(
path,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2.5
..strokeJoin = StrokeJoin.round
..strokeCap = StrokeCap.round
..color = line,
);
final Offset last = Offset(
size.width, size.height * (1 - points.last));
canvas.drawCircle(last, 6, Paint()..color = line.withValues(alpha: 0.25));
canvas.drawCircle(last, 3.5, Paint()..color = line);
}
@override
bool shouldRepaint(_AreaChartPainter oldDelegate) =>
oldDelegate.points != points || oldDelegate.up != up;
}
/// Tiny inline trend line for market rows.
class _SparklinePainter extends CustomPainter {
const _SparklinePainter({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 = 2
..strokeJoin = StrokeJoin.round
..strokeCap = StrokeCap.round
..color = color,
);
}
@override
bool shouldRepaint(_SparklinePainter 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 web3-home-portfolio2. AI agent (MCP)
Connect FlutterKit's MCP server in Claude or Cursor and just ask your agent to install web3-home-portfolio — it fetches and writes the files for you.
FAQ
Can I use this crypto portfolio screen in a commercial app?
Yes. The screen is free including commercial use — ship it in a wallet, exchange or tracker app you sell. No licence key, no sign-up, and no attribution required. Copy it from this page, install it with the CLI command, or pull it through MCP.
Does it depend on any packages or icon fonts?
No third-party packages at all — the pubspec needs nothing beyond Flutter itself. Both charts and the coin badges are drawn in code (there are deliberately no ₿/Ξ glyphs or network images), icons come from the built-in Material set, and the Inter font ships bundled with the screen at lib/web3_home_portfolio/fonts/Inter.ttf.
Which Flutter version do I need?
Flutter 3.22 or newer, because the code leans on `Color.withValues(alpha: …)` for the tinted pills, badge fills and chart gradient, plus `super.key` constructors. On an older SDK, replace each `withValues(alpha: x)` with `withOpacity(x)` and expand the constructors to the `{Key? key} : super(key: key)` form.
How do I wire real prices instead of the demo data?
Everything you see is hardcoded demo data: `_series` holds the six timeframe curves, `_assets` the four coins, and the $21,822.61 balance and +$412.18 pill are literals. Lift those into fields fed by your price feed (WebSocket or REST), normalise each series to 0..1 before handing it to `_AreaChartPainter`, and set each asset's `up` flag and delta string from the API. The painters already repaint whenever their points change.
Why does the big chart stay green even when I pick a losing timeframe?
The build passes `up: true` to `_AreaChartPainter` unconditionally, and all six demo series trend upward, so green is always correct here. The painter fully supports red — it picks #2EBD85 or #F6465D from its `up` flag — so with live data just pass `series.last >= series.first` (or your P&L sign) and the line, fill and dot all flip together.