Fintech87 views

How to Build a Fintech Budget Detail Screen in Flutter (Full Code + Preview)

Seeing '$412 spent' means nothing without a limit next to it — budgets only work when spend and headroom sit on the same screen. This tutorial builds a Revolut-style monthly budget detail in Flutter: a custom-painted 270° gauge showing $1,110 against a $1,500 limit, a stat row for money left, days remaining and a daily safe-to-spend figure, and four category sub-budgets whose progress bars flip to red when a category runs over. Everything is pure Flutter — one CustomPainter, a LinearProgressIndicator per row, no charting package, no network.

Fintech · Budget Detail — Fintech Flutter UI screen
Live preview — Fintech · Budget Detail, built in pure Flutter.

What you'll build

  • A 270° arc gauge painted with a rounded sweep-gradient stroke over a dark track
  • A centred spend readout ('$1,110 of $1,500 limit') stacked inside the gauge
  • A three-card stat row for 'Left to spend', 'Days left' and 'Daily safe'
  • Category sub-budget rows driven by a tiny const data class, each with its own tinted progress bar
  • Over-limit handling that recolours a category's bar and amount red the moment spend passes its cap

Step-by-step build

1

Create the file

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

Palette, callbacks, and budgets as const data

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

/// Budget detail — progress against a monthly budget (Revolut-inspired design).
///
/// Self-contained per CONVENTIONS.md: pure Flutter only, the exact design font
/// (Inter) is bundled under `fonts/`, the progress gauge is custom-painted (no
/// charting package, no network), and the screen forces its own dark theme. Per
/// category sub-budgets show their own progress bars.
class FintechBudgetDetailScreen extends StatelessWidget {
  const FintechBudgetDetailScreen({
    super.key,
    this.onBack,
    this.onEdit,
  });

  final VoidCallback? onBack;
  final VoidCallback? onEdit;

  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<_Sub> _subs = <_Sub>[
    _Sub('Restaurants', 412.80, 500, _amber),
    _Sub('Shopping', 318.40, 350, _brand),
    _Sub('Transport', 168.90, 150, _red),
    _Sub('Groceries', 210.00, 400, _teal),
  ];

`FintechBudgetDetailScreen` is a `StatelessWidget` taking only `onBack` and `onEdit` — the screen displays a computed month, so there is nothing to mutate locally. Seven static colour constants define the dark palette: `_bg` at `0xFF191C1F`, a slightly lighter `_surface` for cards and the gauge track, the indigo `_brand` `0xFF494FDF`, plus `_teal`, `_amber` and `_red` used as semantic tints. The four sub-budgets live in a `static const List<_Sub>`, each holding a name, spent amount, limit and tint — note `_Sub('Transport', 168.90, 150, _red)` is deliberately over its limit so the over-budget path is visible in the preview.

Forcing a dark theme and laying out the scroll

fintech_budget_detail_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>[
                    _buildGauge(),
                    const SizedBox(height: 24),
                    _buildStats(),
                    const SizedBox(height: 28),
                    _sectionLabel('Category budgets'),
                    const SizedBox(height: 12),
                    for (final _Sub s in _subs) _SubRow(sub: s),
                  ],
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }

`build` wraps the `Scaffold` in `Theme(data: ThemeData.dark(useMaterial3: true))`, so the screen stays dark no matter what the host app's theme is — important for a drop-in component. Inside `SafeArea`, a `Column` pins `_buildAppBar()` at the top while an `Expanded` `ListView` with `BouncingScrollPhysics` scrolls the gauge, stats and category section. The category rows are emitted with a collection-for — `for (final _Sub s in _subs) _SubRow(sub: s)` — so adding a fifth budget means adding one line to the `_subs` list, not new widget code.

A minimal app bar built from a Row

fintech_budget_detail_screen.dart
  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(
              'June budget',
              textAlign: TextAlign.center,
              style: TextStyle(
                fontFamily: _font,
                fontSize: 18,
                fontWeight: FontWeight.w500,
                letterSpacing: 0.24,
                color: Colors.white,
              ),
            ),
          ),
          IconButton(
            onPressed: onEdit,
            icon: const Icon(Icons.edit_outlined, size: 20, color: Colors.white),
          ),
        ],
      ),
    );
  }

The header skips `AppBar` entirely: a `Padding` around a `Row` holds a back `IconButton`, an `Expanded` centred 'June budget' title, and an edit `IconButton`. Because the title is `Expanded` with `textAlign: TextAlign.center` and the two icon buttons are the same size, the text lands optically centred without any `Stack` tricks. Both icons are 20px and pure white, and the title sits at 18px `w500` with the file's house `letterSpacing: 0.24` — every text style in this screen carries that same tracking for a consistent typographic voice.

Stacking the readout inside the gauge

fintech_budget_detail_screen.dart
  Widget _buildGauge() {
    return SizedBox(
      height: 210,
      child: Center(
        child: SizedBox(
          width: 200,
          height: 200,
          child: Stack(
            alignment: Alignment.center,
            children: <Widget>[
              CustomPaint(
                size: const Size(200, 200),
                painter: _GaugePainter(progress: 0.74),
              ),
              Column(
                mainAxisSize: MainAxisSize.min,
                children: const <Widget>[
                  Text(
                    r'$1,110',
                    style: TextStyle(
                      fontFamily: _font,
                      fontSize: 34,
                      fontWeight: FontWeight.w600,
                      letterSpacing: 0.24,
                      color: Colors.white,
                    ),
                  ),
                  SizedBox(height: 2),
                  Text(
                    r'of $1,500 limit',
                    style: TextStyle(
                      fontFamily: _font,
                      fontSize: 13,
                      letterSpacing: 0.24,
                      color: _muted,
                    ),
                  ),
                ],
              ),
            ],
          ),
        ),
      ),
    );
  }

`_buildGauge` centres a fixed 200×200 `Stack` inside a 210px-tall band: a `CustomPaint` running `_GaugePainter(progress: 0.74)` behind, and a `Column` with the numbers in front. Layering text over the painter with `Alignment.center` is much simpler than making the painter draw its own text. The `$1,110` figure is 34px `w600` white while `of $1,500 limit` drops to 13px `_muted`, so the eye reads the amount first and the context second. Both strings are raw literals (`r'$1,110'`) — the `r` prefix stops Dart treating `$` as interpolation.

The three-stat summary row

fintech_budget_detail_screen.dart
  Widget _buildStats() {
    return Row(
      children: <Widget>[
        Expanded(child: _stat(r'$390', 'Left to spend', _teal)),
        const SizedBox(width: 12),
        Expanded(child: _stat('17', 'Days left', Colors.white)),
        const SizedBox(width: 12),
        Expanded(child: _stat(r'$23', 'Daily safe', _brand)),
      ],
    );
  }

  Widget _stat(String value, String label, Color color) {
    return Container(
      padding: const EdgeInsets.symmetric(vertical: 16),
      decoration: BoxDecoration(
        color: _surface,
        borderRadius: BorderRadius.circular(16),
      ),
      child: Column(
        children: <Widget>[
          Text(
            value,
            style: TextStyle(
              fontFamily: _font,
              fontSize: 18,
              fontWeight: FontWeight.w600,
              letterSpacing: 0.24,
              color: color,
            ),
          ),
          const SizedBox(height: 4),
          Text(
            label,
            style: const TextStyle(
              fontFamily: _font,
              fontSize: 11.5,
              letterSpacing: 0.24,
              color: _muted,
            ),
          ),
        ],
      ),
    );
  }

`_buildStats` puts three `Expanded` cards in a `Row` with 12px gaps, all built by one `_stat(value, label, color)` helper so the cards can never drift apart in style. The value colour is semantic: '$390' left to spend is `_teal` (good news), '17' days is plain white (neutral fact), and the '$23' daily safe figure is `_brand` indigo, tying it to the gauge's fill colour since both express the same budget. Each card is just a `_surface` `Container` with a 16px radius and vertical padding — no border, letting the background contrast do the separation.

Section label and the _Sub value class

fintech_budget_detail_screen.dart
  Widget _sectionLabel(String text) {
    return Text(
      text.toUpperCase(),
      style: const TextStyle(
        fontFamily: _font,
        fontSize: 11,
        fontWeight: FontWeight.w500,
        letterSpacing: 1.0,
        color: _muted,
      ),
    );
  }
}

class _Sub {
  const _Sub(this.name, this.spent, this.limit, this.tint);
  final String name;
  final double spent;
  final double limit;
  final Color tint;
}

`_sectionLabel` uppercases its text and sets 11px `w500` with `letterSpacing: 1.0` in `_muted` — the wide tracking is what makes a tiny all-caps label read as a section heading rather than shouting. `_Sub` is a four-field const class (`name`, `spent`, `limit`, `tint`) with no methods; keeping the maths out of the model and in the row widget means the same class could later be filled from an API response unchanged.

Sub-budget rows that flag overspend

fintech_budget_detail_screen.dart
class _SubRow extends StatelessWidget {
  const _SubRow({required this.sub});

  final _Sub sub;

  @override
  Widget build(BuildContext context) {
    final double frac = (sub.spent / sub.limit).clamp(0, 1);
    final bool over = sub.spent > sub.limit;
    final Color barColor =
        over ? FintechBudgetDetailScreen._red : sub.tint;
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 9),
      child: Column(
        children: <Widget>[
          Row(
            children: <Widget>[
              Text(
                sub.name,
                style: const TextStyle(
                  fontFamily: FintechBudgetDetailScreen._font,
                  fontSize: 14,
                  fontWeight: FontWeight.w500,
                  letterSpacing: 0.24,
                  color: Colors.white,
                ),
              ),
              const Spacer(),
              Text(
                '\$${sub.spent.toStringAsFixed(0)} / \$${sub.limit.toStringAsFixed(0)}',
                style: TextStyle(
                  fontFamily: FintechBudgetDetailScreen._font,
                  fontSize: 12.5,
                  fontWeight: FontWeight.w500,
                  letterSpacing: 0.24,
                  color: over
                      ? FintechBudgetDetailScreen._red
                      : FintechBudgetDetailScreen._muted,
                ),
              ),
            ],
          ),
          const SizedBox(height: 8),
          ClipRRect(
            borderRadius: BorderRadius.circular(9999),
            child: LinearProgressIndicator(
              value: frac,
              minHeight: 6,
              backgroundColor: FintechBudgetDetailScreen._surface,
              valueColor: AlwaysStoppedAnimation<Color>(barColor),
            ),
          ),
        ],
      ),
    );
  }
}

`_SubRow` derives everything from its `_Sub`: `frac` is `(sub.spent / sub.limit).clamp(0, 1)` so an overspent bar fills completely instead of overflowing, and `over` compares spend to limit separately so the state survives the clamp. When `over` is true both the bar and the '$169 / $150' amount switch to `_red`, overriding the category's own tint — Transport demonstrates this. The bar itself is a `LinearProgressIndicator` at `minHeight: 6` with the track set to `_surface`, wrapped in a `ClipRRect` with radius 9999 because the indicator has no rounding of its own. Amounts are formatted with `toStringAsFixed(0)` — whole dollars only, matching the compact 12.5px trailing text.

Painting the 270° gauge

fintech_budget_detail_screen.dart
/// Paints a 270° arc gauge with a rounded progress sweep over a track.
class _GaugePainter extends CustomPainter {
  _GaugePainter({required this.progress});

  final double progress;

  static const double _start = 2.3562; // 135°
  static const double _full = 4.7124; // 270°

  @override
  void paint(Canvas canvas, Size size) {
    final Offset center = size.center(Offset.zero);
    final double radius = size.width / 2 - 12;
    final Rect rect = Rect.fromCircle(center: center, radius: radius);

    final Paint track = Paint()
      ..color = const Color(0xFF242729)
      ..style = PaintingStyle.stroke
      ..strokeWidth = 16
      ..strokeCap = StrokeCap.round;
    canvas.drawArc(rect, _start, _full, false, track);

    final Paint fill = Paint()
      ..shader = const SweepGradient(
        colors: <Color>[Color(0xFF494FDF), Color(0xFF7C80F0)],
      ).createShader(rect)
      ..style = PaintingStyle.stroke
      ..strokeWidth = 16
      ..strokeCap = StrokeCap.round;
    canvas.drawArc(rect, _start, _full * progress.clamp(0, 1), false, fill);
  }

  @override
  bool shouldRepaint(covariant _GaugePainter oldDelegate) =>
      oldDelegate.progress != progress;
}

`_GaugePainter` draws two arcs on the same `Rect.fromCircle`, whose radius is `size.width / 2 - 12` — the 12px inset keeps the 16px round-capped stroke from clipping at the edges. The constants `_start = 2.3562` (135°) and `_full = 4.7124` (270°) open the arc downward, leaving a symmetric gap at the bottom where a full circle would feel closed. The track paints in flat `0xFF242729`; the fill reuses the same geometry but swaps in a `SweepGradient` shader from `0xFF494FDF` to a lighter `0xFF7C80F0`, sweeping only `_full * progress.clamp(0, 1)` radians. `shouldRepaint` compares `progress`, so the painter only redraws when the value 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';

/// Budget detail — progress against a monthly budget (Revolut-inspired design).
///
/// Self-contained per CONVENTIONS.md: pure Flutter only, the exact design font
/// (Inter) is bundled under `fonts/`, the progress gauge is custom-painted (no
/// charting package, no network), and the screen forces its own dark theme. Per
/// category sub-budgets show their own progress bars.
class FintechBudgetDetailScreen extends StatelessWidget {
  const FintechBudgetDetailScreen({
    super.key,
    this.onBack,
    this.onEdit,
  });

  final VoidCallback? onBack;
  final VoidCallback? onEdit;

  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<_Sub> _subs = <_Sub>[
    _Sub('Restaurants', 412.80, 500, _amber),
    _Sub('Shopping', 318.40, 350, _brand),
    _Sub('Transport', 168.90, 150, _red),
    _Sub('Groceries', 210.00, 400, _teal),
  ];

  @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>[
                    _buildGauge(),
                    const SizedBox(height: 24),
                    _buildStats(),
                    const SizedBox(height: 28),
                    _sectionLabel('Category budgets'),
                    const SizedBox(height: 12),
                    for (final _Sub s in _subs) _SubRow(sub: s),
                  ],
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }

  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(
              'June budget',
              textAlign: TextAlign.center,
              style: TextStyle(
                fontFamily: _font,
                fontSize: 18,
                fontWeight: FontWeight.w500,
                letterSpacing: 0.24,
                color: Colors.white,
              ),
            ),
          ),
          IconButton(
            onPressed: onEdit,
            icon: const Icon(Icons.edit_outlined, size: 20, color: Colors.white),
          ),
        ],
      ),
    );
  }

  Widget _buildGauge() {
    return SizedBox(
      height: 210,
      child: Center(
        child: SizedBox(
          width: 200,
          height: 200,
          child: Stack(
            alignment: Alignment.center,
            children: <Widget>[
              CustomPaint(
                size: const Size(200, 200),
                painter: _GaugePainter(progress: 0.74),
              ),
              Column(
                mainAxisSize: MainAxisSize.min,
                children: const <Widget>[
                  Text(
                    r'$1,110',
                    style: TextStyle(
                      fontFamily: _font,
                      fontSize: 34,
                      fontWeight: FontWeight.w600,
                      letterSpacing: 0.24,
                      color: Colors.white,
                    ),
                  ),
                  SizedBox(height: 2),
                  Text(
                    r'of $1,500 limit',
                    style: TextStyle(
                      fontFamily: _font,
                      fontSize: 13,
                      letterSpacing: 0.24,
                      color: _muted,
                    ),
                  ),
                ],
              ),
            ],
          ),
        ),
      ),
    );
  }

  Widget _buildStats() {
    return Row(
      children: <Widget>[
        Expanded(child: _stat(r'$390', 'Left to spend', _teal)),
        const SizedBox(width: 12),
        Expanded(child: _stat('17', 'Days left', Colors.white)),
        const SizedBox(width: 12),
        Expanded(child: _stat(r'$23', 'Daily safe', _brand)),
      ],
    );
  }

  Widget _stat(String value, String label, Color color) {
    return Container(
      padding: const EdgeInsets.symmetric(vertical: 16),
      decoration: BoxDecoration(
        color: _surface,
        borderRadius: BorderRadius.circular(16),
      ),
      child: Column(
        children: <Widget>[
          Text(
            value,
            style: TextStyle(
              fontFamily: _font,
              fontSize: 18,
              fontWeight: FontWeight.w600,
              letterSpacing: 0.24,
              color: color,
            ),
          ),
          const SizedBox(height: 4),
          Text(
            label,
            style: const TextStyle(
              fontFamily: _font,
              fontSize: 11.5,
              letterSpacing: 0.24,
              color: _muted,
            ),
          ),
        ],
      ),
    );
  }

  Widget _sectionLabel(String text) {
    return Text(
      text.toUpperCase(),
      style: const TextStyle(
        fontFamily: _font,
        fontSize: 11,
        fontWeight: FontWeight.w500,
        letterSpacing: 1.0,
        color: _muted,
      ),
    );
  }
}

class _Sub {
  const _Sub(this.name, this.spent, this.limit, this.tint);
  final String name;
  final double spent;
  final double limit;
  final Color tint;
}

class _SubRow extends StatelessWidget {
  const _SubRow({required this.sub});

  final _Sub sub;

  @override
  Widget build(BuildContext context) {
    final double frac = (sub.spent / sub.limit).clamp(0, 1);
    final bool over = sub.spent > sub.limit;
    final Color barColor =
        over ? FintechBudgetDetailScreen._red : sub.tint;
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 9),
      child: Column(
        children: <Widget>[
          Row(
            children: <Widget>[
              Text(
                sub.name,
                style: const TextStyle(
                  fontFamily: FintechBudgetDetailScreen._font,
                  fontSize: 14,
                  fontWeight: FontWeight.w500,
                  letterSpacing: 0.24,
                  color: Colors.white,
                ),
              ),
              const Spacer(),
              Text(
                '\$${sub.spent.toStringAsFixed(0)} / \$${sub.limit.toStringAsFixed(0)}',
                style: TextStyle(
                  fontFamily: FintechBudgetDetailScreen._font,
                  fontSize: 12.5,
                  fontWeight: FontWeight.w500,
                  letterSpacing: 0.24,
                  color: over
                      ? FintechBudgetDetailScreen._red
                      : FintechBudgetDetailScreen._muted,
                ),
              ),
            ],
          ),
          const SizedBox(height: 8),
          ClipRRect(
            borderRadius: BorderRadius.circular(9999),
            child: LinearProgressIndicator(
              value: frac,
              minHeight: 6,
              backgroundColor: FintechBudgetDetailScreen._surface,
              valueColor: AlwaysStoppedAnimation<Color>(barColor),
            ),
          ),
        ],
      ),
    );
  }
}

/// Paints a 270° arc gauge with a rounded progress sweep over a track.
class _GaugePainter extends CustomPainter {
  _GaugePainter({required this.progress});

  final double progress;

  static const double _start = 2.3562; // 135°
  static const double _full = 4.7124; // 270°

  @override
  void paint(Canvas canvas, Size size) {
    final Offset center = size.center(Offset.zero);
    final double radius = size.width / 2 - 12;
    final Rect rect = Rect.fromCircle(center: center, radius: radius);

    final Paint track = Paint()
      ..color = const Color(0xFF242729)
      ..style = PaintingStyle.stroke
      ..strokeWidth = 16
      ..strokeCap = StrokeCap.round;
    canvas.drawArc(rect, _start, _full, false, track);

    final Paint fill = Paint()
      ..shader = const SweepGradient(
        colors: <Color>[Color(0xFF494FDF), Color(0xFF7C80F0)],
      ).createShader(rect)
      ..style = PaintingStyle.stroke
      ..strokeWidth = 16
      ..strokeCap = StrokeCap.round;
    canvas.drawArc(rect, _start, _full * progress.clamp(0, 1), false, fill);
  }

  @override
  bool shouldRepaint(covariant _GaugePainter oldDelegate) =>
      oldDelegate.progress != progress;
}

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-budget-detail

2. AI agent (MCP)

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

FAQ

Is this budget detail screen free to use commercially?

Yes. FlutterKit screens are free to use, including in commercial apps — copy the code from this page or install it with `flutterkit add fintech-budget-detail`, ship it in a paid fintech product, and no attribution or sign-up is required.

Do I need any packages or fonts for this screen?

No pub packages at all — the gauge is a hand-written `CustomPainter` and the bars are stock `LinearProgressIndicator`s. The only asset is the Inter font, which ships bundled with the screen under `fonts/` and is referenced via the `_font` constant, so there is no google_fonts dependency and no network fetch.

Which Flutter version does this need?

Flutter 3.0 or newer. The constructor uses super parameters (`super.key`), which need Dart 2.17, and the theme is built with `ThemeData.dark(useMaterial3: true)`. There is no `Color.withValues` in this file, so nothing here requires the 3.22+ colour API.

How do I drive the gauge and stats from real budget data?

Add `spent`, `limit` and a `DateTime` for the period end to the constructor, then compute `progress` as `spent / limit` for `_GaugePainter`, 'Left to spend' as `limit - spent`, 'Days left' from the date, and 'Daily safe' as the remainder divided by days left. Replace the raw `r'$1,110'` literals with interpolated strings — once you interpolate, drop the `r` prefix. Feed `_subs` from your category totals; `_SubRow` already handles the over-limit case.

Can I change the gauge's sweep, thickness or colours?

Yes — `_start` and `_full` are radians, so a half-circle gauge is `_start: 3.1416, _full: 3.1416`; keep `_start` equal to π plus half of the bottom gap for symmetry. Stroke weight is the two `strokeWidth: 16` values (change both, and revisit the `- 12` radius inset if you go much thicker), and the fill colours live in the `SweepGradient` list.

Related screens