Fintech54 views

How to Build a Stock Portfolio Screen in Flutter (Full Code + Preview)

An investing home screen has to answer three questions before the user scrolls: what am I worth, is the market open, and which of my positions moved. This build answers each with its own component — a 38px total with a day-change line, a market-status card counting down to the close, and holding rows carrying a two-letter ticker badge, a 50×26 sparkline, and a signed percentage. Both charts are `CustomPainter`s, so there's no charting dependency and nothing to license.

Stocks Portfolio — Fintech Flutter UI screen
Live preview — Stocks Portfolio, built in pure Flutter.

Watch the Flutter UI walkthrough

A short screen recording of Stocks Portfolio 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

  • A market-status card with a live dot, an exchange name, and a time-to-close line
  • Holding rows leading with the ticker and demoting the company name into a '· 8 sh' subtitle
  • Circular badges showing the first two letters of a ticker, tinted from one colour per position
  • A gradient area chart drawn from twelve normalised points with reserved headroom so peaks never clip
  • Direction-aware colouring where one `up` boolean drives the sparkline, the percentage, and its sign

Step-by-step build

1

Create the file

Add a new file at lib/fintech_stocks_home/fintech_stocks_home_screen.dart in your Flutter project.

2

Register the bundled fonts

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

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

Build it, piece by piece

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

Positions as constant data

fintech_stocks_home_screen.dart
class FintechStocksHomeScreen extends StatelessWidget {
  const FintechStocksHomeScreen({
    super.key,
    this.onBack,
    this.onStockTap,
    this.onDiscover,
  });

  final VoidCallback? onBack;
  final VoidCallback? onStockTap;
  final VoidCallback? onDiscover;

  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<_Holding> _holdings = <_Holding>[
    _Holding('AAPL', 'Apple Inc.', _brand, '12 sh', r'$2,610.00', 1.8,
        <double>[0.3, 0.4, 0.38, 0.5, 0.55, 0.62, 0.7]),
    _Holding('TSLA', 'Tesla Inc.', _red, '6 sh', r'$1,488.00', -2.3,
        <double>[0.7, 0.66, 0.6, 0.55, 0.48, 0.44, 0.4]),
    _Holding('NVDA', 'NVIDIA Corp.', _teal, '4 sh', r'$4,920.00', 4.6,
        <double>[0.25, 0.35, 0.45, 0.5, 0.62, 0.72, 0.85]),
    _Holding('AMZN', 'Amazon.com', _amber, '8 sh', r'$1,520.00', 0.6,
        <double>[0.48, 0.5, 0.49, 0.52, 0.5, 0.53, 0.55]),
  ];

The screen is stateless — quotes come from upstream, so there's nothing to hold locally. Each `_Holding` pairs display strings (`ticker`, `name`, `shares`, and a pre-formatted `value`) with two machine-readable fields: `change`, a signed double, and `spark`, seven points already normalised to 0–1. That split matters. Formatting money is a locale problem better solved before the widget layer, but `change` has to stay numeric because its *sign* decides colours further down. The `r'$2,610.00'` raw-string prefix is what allows a literal `$` without Dart treating it as interpolation.

Shell, section header, and holdings loop

fintech_stocks_home_screen.dart
  @override
  Widget build(BuildContext context) {
    return Theme(
      data: ThemeData.dark(useMaterial3: true),
      child: Scaffold(
        backgroundColor: _bg,
        body: SafeArea(
          child: Column(
            children: <Widget>[
              _buildAppBar(),
              Expanded(
                child: ListView(
                  physics: const BouncingScrollPhysics(),
                  padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
                  children: <Widget>[
                    _buildHeader(),
                    const SizedBox(height: 20),
                    _buildChart(),
                    const SizedBox(height: 12),
                    _buildMarketState(),
                    const SizedBox(height: 22),
                    Row(
                      mainAxisAlignment: MainAxisAlignment.spaceBetween,
                      children: <Widget>[
                        const Text(
                          'YOUR STOCKS',
                          style: TextStyle(
                            fontFamily: _font,
                            fontSize: 11,
                            fontWeight: FontWeight.w500,
                            letterSpacing: 1.0,
                            color: _muted,
                          ),
                        ),
                        GestureDetector(
                          onTap: onDiscover,
                          child: const Text(
                            'Discover',
                            style: TextStyle(
                              fontFamily: _font,
                              fontSize: 12.5,
                              fontWeight: FontWeight.w500,
                              letterSpacing: 0.24,
                              color: _brand,
                            ),
                          ),
                        ),
                      ],
                    ),
                    const SizedBox(height: 6),
                    for (final _Holding h in _holdings)
                      _HoldingRow(holding: h, onTap: onStockTap),
                  ],
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }

  Widget _buildAppBar() {
    return Padding(
      padding: const EdgeInsets.fromLTRB(8, 4, 8, 4),
      child: Row(
        children: <Widget>[
          IconButton(
            onPressed: onBack,
            icon: const Icon(Icons.arrow_back_ios_new_rounded,
                size: 20, color: Colors.white),
          ),
          const Expanded(
            child: Text(
              'Stocks',
              textAlign: TextAlign.center,
              style: TextStyle(
                fontFamily: _font,
                fontSize: 18,
                fontWeight: FontWeight.w500,
                letterSpacing: 0.24,
                color: Colors.white,
              ),
            ),
          ),
          IconButton(
            onPressed: onDiscover,
            icon: const Icon(Icons.search_rounded, size: 22, color: Colors.white),
          ),
        ],
      ),
    );
  }

`ThemeData.dark(useMaterial3: true)` pins the dark rendering regardless of the host app's theme, and `BouncingScrollPhysics` gives the iOS rubber-band scroll expected in a finance app. 'YOUR STOCKS' uses the small-caps section convention — 11px with `letterSpacing: 1.0` in `_muted` — balanced against a brand-indigo 'Discover' link by `MainAxisAlignment.spaceBetween`. The rows are emitted with a collection-for straight into the `ListView` children, which is appropriate for a fixed handful of positions; a long watchlist would want `ListView.builder` so rows build lazily.

Total value and the market-status card

fintech_stocks_home_screen.dart
  Widget _buildHeader() {
    return Column(
      children: const <Widget>[
        Text(
          'Total value',
          style: TextStyle(
            fontFamily: _font,
            fontSize: 13,
            letterSpacing: 0.24,
            color: _muted,
          ),
        ),
        SizedBox(height: 6),
        Text(
          r'$10,538.00',
          style: TextStyle(
            fontFamily: _font,
            fontSize: 38,
            fontWeight: FontWeight.w600,
            letterSpacing: 0.24,
            color: Colors.white,
          ),
        ),
        SizedBox(height: 4),
        Text(
          r'+$184.20 (1.78%) today',
          style: TextStyle(
            fontFamily: _font,
            fontSize: 13,
            fontWeight: FontWeight.w500,
            letterSpacing: 0.24,
            color: _teal,
          ),
        ),
      ],
    );
  }

  Widget _buildChart() {
    return SizedBox(
      height: 120,
      child: CustomPaint(
        size: Size.infinite,
        painter: _AreaChartPainter(
          points: const <double>[
            0.35, 0.3, 0.42, 0.4, 0.5, 0.48, 0.58, 0.55, 0.66, 0.62, 0.72, 0.7
          ],
          color: _teal,
        ),
      ),
    );
  }

  Widget _buildMarketState() {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
      decoration: BoxDecoration(
        color: _surface,
        borderRadius: BorderRadius.circular(14),
      ),
      child: Row(
        children: const <Widget>[
          Icon(Icons.circle, size: 9, color: _teal),
          SizedBox(width: 8),
          Text(
            'Market open',
            style: TextStyle(
              fontFamily: _font,
              fontSize: 13,
              fontWeight: FontWeight.w500,
              letterSpacing: 0.24,
              color: Colors.white,
            ),
          ),
          Spacer(),
          Text(
            'Closes in 3h 12m · NYSE',
            style: TextStyle(
              fontFamily: _font,
              fontSize: 12.5,
              letterSpacing: 0.24,
              color: _muted,
            ),
          ),
        ],
      ),
    );
  }

The header stacks a muted 'Total value' label, the balance at 38px `w600`, and the day's change in teal — the size jump from 13 to 38 is the whole hierarchy, no decoration needed. Below the chart, the market card is the piece worth stealing: a 9px `Icons.circle` in teal reading as a live indicator, 'Market open' in white, then a `Spacer()` pushing 'Closes in 3h 12m · NYSE' to the trailing edge in muted grey. That one row tells the user whether their orders will execute now or queue — a detail most portfolio mockups leave out entirely.

The holding row's information hierarchy

fintech_stocks_home_screen.dart
class _HoldingRow extends StatelessWidget {
  const _HoldingRow({required this.holding, this.onTap});

  final _Holding holding;
  final VoidCallback? onTap;

  @override
  Widget build(BuildContext context) {
    final bool up = holding.change >= 0;
    final Color trend = up
        ? FintechStocksHomeScreen._teal
        : FintechStocksHomeScreen._red;
    return InkWell(
      onTap: onTap,
      child: Padding(
        padding: const EdgeInsets.symmetric(vertical: 10),
        child: Row(
          children: <Widget>[
            _TickerBadge(ticker: holding.ticker, color: holding.color),
            const SizedBox(width: 14),
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: <Widget>[
                  Text(
                    holding.ticker,
                    style: const TextStyle(
                      fontFamily: FintechStocksHomeScreen._font,
                      fontSize: 14.5,
                      fontWeight: FontWeight.w600,
                      letterSpacing: 0.24,
                      color: Colors.white,
                    ),
                  ),
                  const SizedBox(height: 2),
                  Text(
                    '${holding.name} · ${holding.shares}',
                    maxLines: 1,
                    overflow: TextOverflow.ellipsis,
                    style: const TextStyle(
                      fontFamily: FintechStocksHomeScreen._font,
                      fontSize: 12.5,
                      letterSpacing: 0.24,
                      color: FintechStocksHomeScreen._muted,
                    ),
                  ),
                ],
              ),
            ),
            SizedBox(
              width: 50,
              height: 26,
              child: CustomPaint(
                painter: _SparkPainter(points: holding.spark, color: trend),
              ),
            ),
            const SizedBox(width: 14),
            Column(
              crossAxisAlignment: CrossAxisAlignment.end,
              children: <Widget>[
                Text(
                  holding.value,
                  style: const TextStyle(
                    fontFamily: FintechStocksHomeScreen._font,
                    fontSize: 14.5,
                    fontWeight: FontWeight.w600,
                    letterSpacing: 0.24,
                    color: Colors.white,
                  ),
                ),
                const SizedBox(height: 2),
                Text(
                  '${up ? '+' : ''}${holding.change.toStringAsFixed(1)}%',
                  style: TextStyle(
                    fontFamily: FintechStocksHomeScreen._font,
                    fontSize: 12,
                    fontWeight: FontWeight.w500,
                    letterSpacing: 0.24,
                    color: trend,
                  ),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

Note the deliberate inversion versus a crypto list: the *ticker* is the headline at 14.5px `w600`, and the company name is demoted into a combined subtitle, `'${holding.name} · ${holding.shares}'`, with `maxLines: 1` and `TextOverflow.ellipsis` guarding against long names. Traders scan by symbol. `up = holding.change >= 0` is computed once and `trend` resolves to teal or red from it — that single colour then feeds both the `_SparkPainter` and the percentage text, so the mini-chart's direction and the number can never contradict each other. The four zones (44px badge, `Expanded` text, fixed 50×26 chart, right-aligned `crossAxisAlignment: CrossAxisAlignment.end` values) keep every sparkline in the list on the same vertical line.

Two-letter ticker badges

fintech_stocks_home_screen.dart
class _TickerBadge extends StatelessWidget {
  const _TickerBadge({required this.ticker, required this.color});

  final String ticker;
  final Color color;

  @override
  Widget build(BuildContext context) {
    return Container(
      width: 44,
      height: 44,
      alignment: Alignment.center,
      decoration: BoxDecoration(
        color: color.withValues(alpha: 0.2),
        shape: BoxShape.circle,
      ),
      child: Text(
        ticker.substring(0, 2),
        style: TextStyle(
          fontFamily: FintechStocksHomeScreen._font,
          fontSize: 13,
          fontWeight: FontWeight.w700,
          letterSpacing: 0.24,
          color: color,
        ),
      ),
    );
  }
}

`_TickerBadge` renders `ticker.substring(0, 2)` rather than the full symbol — 'AA', 'TE', 'NV', 'AM' — because four characters at a legible weight won't fit a 44px circle without shrinking the type past readability. The tint recipe does the rest: the circle is `color.withValues(alpha: 0.2)` and the text is the same colour at full strength, so one value per position produces a matched pair and four differently-coloured badges still read as one system. Be aware `substring(0, 2)` will throw on a one-character ticker, so guard it if your symbols can be that short.

The area chart and its headroom

fintech_stocks_home_screen.dart
class _AreaChartPainter extends CustomPainter {
  _AreaChartPainter({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 dx = size.width / (points.length - 1);
    double x(int i) => dx * i;
    double y(int i) => size.height - points[i].clamp(0, 1) * size.height * 0.9 - 6;

    final Path line = Path()..moveTo(x(0), y(0));
    for (int i = 1; i < points.length; i++) {
      line.lineTo(x(i), y(i));
    }
    final Path area = Path.from(line)
      ..lineTo(size.width, size.height)
      ..lineTo(0, size.height)
      ..close();
    canvas.drawPath(
      area,
      Paint()
        ..shader = LinearGradient(
          begin: Alignment.topCenter,
          end: Alignment.bottomCenter,
          colors: <Color>[color.withValues(alpha: 0.28), color.withValues(alpha: 0)],
        ).createShader(Offset.zero & size),
    );
    canvas.drawPath(
      line,
      Paint()
        ..color = color
        ..strokeWidth = 2.5
        ..style = PaintingStyle.stroke
        ..strokeCap = StrokeCap.round
        ..strokeJoin = StrokeJoin.round,
    );
  }

  @override
  bool shouldRepaint(covariant _AreaChartPainter oldDelegate) =>
      oldDelegate.points != points || oldDelegate.color != color;
}

Because the points arrive pre-normalised, the painter needs no min/max scan. The `y(i)` helper does three things in one expression: `size.height - ...` inverts for canvas coordinates where Y grows downward, `* 0.9` compresses the plot into 90% of the box, and `- 6` lifts it further — together reserving headroom so a 1.0 point doesn't touch the top edge and get visually clipped by the 2.5px stroke. The fill path is cloned from the stroke with `Path.from(line)` then closed to the bottom corners, so the two can't drift apart. Painting it with a `shader` instead of a colour is what produces the fade: a `LinearGradient` from 28% alpha down to fully transparent, materialised by `createShader(Offset.zero & size)`.

The sparkline and repaint control

fintech_stocks_home_screen.dart
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;
}

`_SparkPainter` strips the chart down to its line — no gradient, no headroom, full height used — with a 2px stroke and rounded caps and joins so a seven-point zigzag still looks smooth at 50px wide. It chooses `moveTo` versus `lineTo` inline with `i == 0 ? ... : ...`. Both painters declare `shouldRepaint` with a `covariant` parameter, which is what lets them take the concrete painter type instead of the base `CustomPainter`, and both compare their actual inputs so Flutter can skip repainting rows whose data hasn't changed. One caveat worth knowing: `oldDelegate.points != points` is an identity comparison on lists, so a new list with identical values still counts as changed — fine here, since the data is `const`.

Full code

The complete, ready-to-paste source. Free to use in your projects — one click copies it all.

import 'package:flutter/material.dart';

/// Stocks home — investing portfolio overview (Revolut-inspired design system).
///
/// Self-contained per CONVENTIONS.md: pure Flutter only, the exact design font
/// (Inter) is bundled under `fonts/`, the portfolio area chart and ticker badges
/// are custom-painted (no charting package, no network/emoji), and the screen
/// forces its own dark theme. Holdings carry per-stock sparklines + day change.
class FintechStocksHomeScreen extends StatelessWidget {
  const FintechStocksHomeScreen({
    super.key,
    this.onBack,
    this.onStockTap,
    this.onDiscover,
  });

  final VoidCallback? onBack;
  final VoidCallback? onStockTap;
  final VoidCallback? onDiscover;

  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<_Holding> _holdings = <_Holding>[
    _Holding('AAPL', 'Apple Inc.', _brand, '12 sh', r'$2,610.00', 1.8,
        <double>[0.3, 0.4, 0.38, 0.5, 0.55, 0.62, 0.7]),
    _Holding('TSLA', 'Tesla Inc.', _red, '6 sh', r'$1,488.00', -2.3,
        <double>[0.7, 0.66, 0.6, 0.55, 0.48, 0.44, 0.4]),
    _Holding('NVDA', 'NVIDIA Corp.', _teal, '4 sh', r'$4,920.00', 4.6,
        <double>[0.25, 0.35, 0.45, 0.5, 0.62, 0.72, 0.85]),
    _Holding('AMZN', 'Amazon.com', _amber, '8 sh', r'$1,520.00', 0.6,
        <double>[0.48, 0.5, 0.49, 0.52, 0.5, 0.53, 0.55]),
  ];

  @override
  Widget build(BuildContext context) {
    return Theme(
      data: ThemeData.dark(useMaterial3: true),
      child: Scaffold(
        backgroundColor: _bg,
        body: SafeArea(
          child: Column(
            children: <Widget>[
              _buildAppBar(),
              Expanded(
                child: ListView(
                  physics: const BouncingScrollPhysics(),
                  padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
                  children: <Widget>[
                    _buildHeader(),
                    const SizedBox(height: 20),
                    _buildChart(),
                    const SizedBox(height: 12),
                    _buildMarketState(),
                    const SizedBox(height: 22),
                    Row(
                      mainAxisAlignment: MainAxisAlignment.spaceBetween,
                      children: <Widget>[
                        const Text(
                          'YOUR STOCKS',
                          style: TextStyle(
                            fontFamily: _font,
                            fontSize: 11,
                            fontWeight: FontWeight.w500,
                            letterSpacing: 1.0,
                            color: _muted,
                          ),
                        ),
                        GestureDetector(
                          onTap: onDiscover,
                          child: const Text(
                            'Discover',
                            style: TextStyle(
                              fontFamily: _font,
                              fontSize: 12.5,
                              fontWeight: FontWeight.w500,
                              letterSpacing: 0.24,
                              color: _brand,
                            ),
                          ),
                        ),
                      ],
                    ),
                    const SizedBox(height: 6),
                    for (final _Holding h in _holdings)
                      _HoldingRow(holding: h, onTap: onStockTap),
                  ],
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }

  Widget _buildAppBar() {
    return Padding(
      padding: const EdgeInsets.fromLTRB(8, 4, 8, 4),
      child: Row(
        children: <Widget>[
          IconButton(
            onPressed: onBack,
            icon: const Icon(Icons.arrow_back_ios_new_rounded,
                size: 20, color: Colors.white),
          ),
          const Expanded(
            child: Text(
              'Stocks',
              textAlign: TextAlign.center,
              style: TextStyle(
                fontFamily: _font,
                fontSize: 18,
                fontWeight: FontWeight.w500,
                letterSpacing: 0.24,
                color: Colors.white,
              ),
            ),
          ),
          IconButton(
            onPressed: onDiscover,
            icon: const Icon(Icons.search_rounded, size: 22, color: Colors.white),
          ),
        ],
      ),
    );
  }

  Widget _buildHeader() {
    return Column(
      children: const <Widget>[
        Text(
          'Total value',
          style: TextStyle(
            fontFamily: _font,
            fontSize: 13,
            letterSpacing: 0.24,
            color: _muted,
          ),
        ),
        SizedBox(height: 6),
        Text(
          r'$10,538.00',
          style: TextStyle(
            fontFamily: _font,
            fontSize: 38,
            fontWeight: FontWeight.w600,
            letterSpacing: 0.24,
            color: Colors.white,
          ),
        ),
        SizedBox(height: 4),
        Text(
          r'+$184.20 (1.78%) today',
          style: TextStyle(
            fontFamily: _font,
            fontSize: 13,
            fontWeight: FontWeight.w500,
            letterSpacing: 0.24,
            color: _teal,
          ),
        ),
      ],
    );
  }

  Widget _buildChart() {
    return SizedBox(
      height: 120,
      child: CustomPaint(
        size: Size.infinite,
        painter: _AreaChartPainter(
          points: const <double>[
            0.35, 0.3, 0.42, 0.4, 0.5, 0.48, 0.58, 0.55, 0.66, 0.62, 0.72, 0.7
          ],
          color: _teal,
        ),
      ),
    );
  }

  Widget _buildMarketState() {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
      decoration: BoxDecoration(
        color: _surface,
        borderRadius: BorderRadius.circular(14),
      ),
      child: Row(
        children: const <Widget>[
          Icon(Icons.circle, size: 9, color: _teal),
          SizedBox(width: 8),
          Text(
            'Market open',
            style: TextStyle(
              fontFamily: _font,
              fontSize: 13,
              fontWeight: FontWeight.w500,
              letterSpacing: 0.24,
              color: Colors.white,
            ),
          ),
          Spacer(),
          Text(
            'Closes in 3h 12m · NYSE',
            style: TextStyle(
              fontFamily: _font,
              fontSize: 12.5,
              letterSpacing: 0.24,
              color: _muted,
            ),
          ),
        ],
      ),
    );
  }
}

class _Holding {
  const _Holding(this.ticker, this.name, this.color, this.shares, this.value,
      this.change, this.spark);
  final String ticker;
  final String name;
  final Color color;
  final String shares;
  final String value;
  final double change;
  final List<double> spark;
}

class _HoldingRow extends StatelessWidget {
  const _HoldingRow({required this.holding, this.onTap});

  final _Holding holding;
  final VoidCallback? onTap;

  @override
  Widget build(BuildContext context) {
    final bool up = holding.change >= 0;
    final Color trend = up
        ? FintechStocksHomeScreen._teal
        : FintechStocksHomeScreen._red;
    return InkWell(
      onTap: onTap,
      child: Padding(
        padding: const EdgeInsets.symmetric(vertical: 10),
        child: Row(
          children: <Widget>[
            _TickerBadge(ticker: holding.ticker, color: holding.color),
            const SizedBox(width: 14),
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: <Widget>[
                  Text(
                    holding.ticker,
                    style: const TextStyle(
                      fontFamily: FintechStocksHomeScreen._font,
                      fontSize: 14.5,
                      fontWeight: FontWeight.w600,
                      letterSpacing: 0.24,
                      color: Colors.white,
                    ),
                  ),
                  const SizedBox(height: 2),
                  Text(
                    '${holding.name} · ${holding.shares}',
                    maxLines: 1,
                    overflow: TextOverflow.ellipsis,
                    style: const TextStyle(
                      fontFamily: FintechStocksHomeScreen._font,
                      fontSize: 12.5,
                      letterSpacing: 0.24,
                      color: FintechStocksHomeScreen._muted,
                    ),
                  ),
                ],
              ),
            ),
            SizedBox(
              width: 50,
              height: 26,
              child: CustomPaint(
                painter: _SparkPainter(points: holding.spark, color: trend),
              ),
            ),
            const SizedBox(width: 14),
            Column(
              crossAxisAlignment: CrossAxisAlignment.end,
              children: <Widget>[
                Text(
                  holding.value,
                  style: const TextStyle(
                    fontFamily: FintechStocksHomeScreen._font,
                    fontSize: 14.5,
                    fontWeight: FontWeight.w600,
                    letterSpacing: 0.24,
                    color: Colors.white,
                  ),
                ),
                const SizedBox(height: 2),
                Text(
                  '${up ? '+' : ''}${holding.change.toStringAsFixed(1)}%',
                  style: TextStyle(
                    fontFamily: FintechStocksHomeScreen._font,
                    fontSize: 12,
                    fontWeight: FontWeight.w500,
                    letterSpacing: 0.24,
                    color: trend,
                  ),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

class _TickerBadge extends StatelessWidget {
  const _TickerBadge({required this.ticker, required this.color});

  final String ticker;
  final Color color;

  @override
  Widget build(BuildContext context) {
    return Container(
      width: 44,
      height: 44,
      alignment: Alignment.center,
      decoration: BoxDecoration(
        color: color.withValues(alpha: 0.2),
        shape: BoxShape.circle,
      ),
      child: Text(
        ticker.substring(0, 2),
        style: TextStyle(
          fontFamily: FintechStocksHomeScreen._font,
          fontSize: 13,
          fontWeight: FontWeight.w700,
          letterSpacing: 0.24,
          color: color,
        ),
      ),
    );
  }
}

/// Paints a filled area chart from normalised 0–1 points.
class _AreaChartPainter extends CustomPainter {
  _AreaChartPainter({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 dx = size.width / (points.length - 1);
    double x(int i) => dx * i;
    double y(int i) => size.height - points[i].clamp(0, 1) * size.height * 0.9 - 6;

    final Path line = Path()..moveTo(x(0), y(0));
    for (int i = 1; i < points.length; i++) {
      line.lineTo(x(i), y(i));
    }
    final Path area = Path.from(line)
      ..lineTo(size.width, size.height)
      ..lineTo(0, size.height)
      ..close();
    canvas.drawPath(
      area,
      Paint()
        ..shader = LinearGradient(
          begin: Alignment.topCenter,
          end: Alignment.bottomCenter,
          colors: <Color>[color.withValues(alpha: 0.28), color.withValues(alpha: 0)],
        ).createShader(Offset.zero & size),
    );
    canvas.drawPath(
      line,
      Paint()
        ..color = color
        ..strokeWidth = 2.5
        ..style = PaintingStyle.stroke
        ..strokeCap = StrokeCap.round
        ..strokeJoin = StrokeJoin.round,
    );
  }

  @override
  bool shouldRepaint(covariant _AreaChartPainter oldDelegate) =>
      oldDelegate.points != points || oldDelegate.color != color;
}

/// 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-stocks-home

2. AI agent (MCP)

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

FAQ

Is this stock portfolio screen free to use?

Yes. The full 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-stocks-home), or add it via an AI agent over MCP.

Does it need a charting package?

No. The portfolio area chart and every row sparkline are CustomPainters in this file, built from Canvas, Path, and Paint — no fl_chart, no syncfusion, no network. The only asset to register is the bundled Inter font family.

How do I plug in live quotes?

Build the _holdings list from your quote feed, keeping change as a signed double and normalising each price series to 0–1 with (p - min) / (max - min) before assigning it to spark. The badge tint, sparkline colour, percentage colour, and its + sign are all derived from those two fields, so nothing else needs touching.

Can I show real company logos instead of the letter badges?

Yes — swap _TickerBadge's Text for an Image.network or Image.asset inside the same 44px circle, keeping the tinted background as the placeholder colour so the row still looks right while the logo loads.

Which Flutter version does it target?

It uses Color.withValues(alpha:) and Material 3, so it targets Flutter 3.27+. On an older SDK, replace every withValues(alpha: x) call — including the two inside the chart's gradient — with withOpacity(x), and it compiles back to Flutter 3.10.

Related screens