Fintech51 views

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

Every money-transfer app needs the screen you land on after tapping a contact: who is this person, how do I pay them, and what have we exchanged before. This tutorial builds a Revolut-style recipient detail screen in Flutter — a brand-ringed avatar over the payee's name and @tag, filled Send and outlined Request pills, and a transaction history where incoming amounts turn teal so direction reads at a glance. It is one self-contained dark-themed file with no packages beyond Flutter itself, wired to your navigation through three callbacks.

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

What you'll build

  • An 88px circular avatar with a 2px indigo ring separated from the photo by a 3px gap, with a solid-colour fallback if the asset is missing
  • Paired Send and Request pill buttons built from one widget that toggles between a brand fill and a surface fill
  • A transaction history where a directional arrow in a tinted disc and a teal-only incoming amount encode money in versus money out
  • A forced dark theme via a local Theme wrapper, so the screen looks identical inside any host app
  • Signed amounts rendered by hand from a plain value class, ready to swap for real API data

Step-by-step build

1

Create the file

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

Callbacks, a six-colour palette, and history as data

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

/// Recipient detail — beneficiary profile + transfer history (Revolut-inspired).
///
/// Self-contained per CONVENTIONS.md: pure Flutter only, the exact design font
/// (Inter) is bundled under `fonts/`, the contact photo is a bundled asset, and
/// the screen forces its own dark theme. Send / Request actions and a populated
/// payment history make it read like a real product.
class FintechRecipientDetailScreen extends StatelessWidget {
  const FintechRecipientDetailScreen({
    super.key,
    this.onBack,
    this.onSend,
    this.onRequest,
  });

  final VoidCallback? onBack;
  final VoidCallback? onSend;
  final VoidCallback? onRequest;

  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 _red = Color(0xFFE23B4A);
  static const Color _muted = Color(0xFF8D969E);

  static const List<_Txn> _history = <_Txn>[
    _Txn('Dinner — split', 'Yesterday', -42.50, false),
    _Txn('Sent', '12 Jun', -120.00, false),
    _Txn('Received', '4 Jun', 65.00, true),
    _Txn('Concert tickets', '28 May', -88.00, false),
    _Txn('Received', '19 May', 30.00, true),
  ];

The whole screen is a `StatelessWidget` — a beneficiary profile displays a person and offers exits, so there is nothing to mutate. Its three nullable callbacks (`onBack`, `onSend`, `onRequest`) are the only integration surface. The palette assigns each colour one job: `_brand` indigo (#494FDF) marks the avatar ring and the primary button, `_teal` is reserved for incoming money, `_red` for outgoing icons, and `_muted` grey handles secondary text on the near-black `_bg`. Finally, the five sample transfers live in a `static const List<_Txn> _history`, where each `_Txn` carries a label, a display date, a signed double and an `incoming` flag — keeping demo data out of the widget tree so it is one obvious swap for a real feed.

Forcing dark mode and composing the page

fintech_recipient_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(context),
              Expanded(
                child: ListView(
                  physics: const BouncingScrollPhysics(),
                  padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
                  children: <Widget>[
                    _buildHeader(),
                    const SizedBox(height: 24),
                    _buildActions(),
                    const SizedBox(height: 28),
                    _sectionLabel('History'),
                    const SizedBox(height: 6),
                    for (final _Txn t in _history) _TxnRow(txn: t),
                  ],
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }

`build` wraps everything in `Theme(data: ThemeData.dark(useMaterial3: true))`, so the screen keeps its dark look even inside a light-themed host app — the Scaffold then paints `_bg` explicitly on top of that. The layout is a `Column` with the app bar pinned outside an `Expanded` `ListView`, meaning the back button never scrolls away while the profile and history do, with `BouncingScrollPhysics` giving the list an iOS feel. The history rows are emitted by a collection-for — `for (final _Txn t in _history) _TxnRow(txn: t)` — directly inside the children list, which needs no `ListView.builder` for five rows and keeps the header, actions and rows in one linear reading order.

An app bar with no title

fintech_recipient_detail_screen.dart
  Widget _buildAppBar(BuildContext context) {
    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 Spacer(),
          IconButton(
            onPressed: () {},
            icon: const Icon(Icons.more_horiz_rounded,
                size: 22, color: Colors.white),
          ),
        ],
      ),
    );
  }

The bar is just a `Row`: a back `IconButton` using `arrow_back_ios_new_rounded`, a `Spacer`, and a `more_horiz_rounded` overflow button. There is deliberately no title text — the recipient's name renders at 22px a few lines below, and repeating it in the bar would say the same thing twice. Only the back button is wired to `onBack`; the overflow gets an empty closure so it stays tappable in a preview, and the 8px horizontal padding lets the icon buttons' built-in touch targets supply the rest of the spacing.

The ringed avatar and identity block

fintech_recipient_detail_screen.dart
  Widget _buildHeader() {
    return Column(
      children: <Widget>[
        Container(
          width: 88,
          height: 88,
          decoration: const BoxDecoration(
            shape: BoxShape.circle,
            border: Border.fromBorderSide(BorderSide(color: _brand, width: 2)),
          ),
          padding: const EdgeInsets.all(3),
          child: ClipOval(
            child: Image.asset(
              'lib/screens/fintech/fintech_recipient_detail/images/avatar_5.jpg',
              fit: BoxFit.cover,
              errorBuilder: (BuildContext c, Object e, StackTrace? s) =>
                  Container(color: _brand),
            ),
          ),
        ),
        const SizedBox(height: 14),
        const Text(
          'Priya Nair',
          style: TextStyle(
            fontFamily: _font,
            fontSize: 22,
            fontWeight: FontWeight.w500,
            letterSpacing: 0.24,
            color: Colors.white,
          ),
        ),
        const SizedBox(height: 4),
        const Text(
          '@priya  ·  Joined 2023',
          style: TextStyle(
            fontFamily: _font,
            fontSize: 13,
            letterSpacing: 0.24,
            color: _muted,
          ),
        ),
      ],
    );
  }

The avatar is an 88px `Container` whose `BoxDecoration` draws a 2px `_brand` circular border via `Border.fromBorderSide`, with `EdgeInsets.all(3)` of padding before the `ClipOval` photo — that 3px inset is what creates the floating-ring effect instead of a stroke touching the image. `Image.asset` carries an `errorBuilder` that falls back to a solid `_brand` container, so a missing bundled asset degrades to a coloured disc rather than a crash. Below it, 'Priya Nair' at 22px `w500` and the '@priya · Joined 2023' line at 13px `_muted` share the same `letterSpacing: 0.24`, giving the block a single typographic voice with hierarchy carried by size and colour alone.

Send and Request as equals in width, not in weight

fintech_recipient_detail_screen.dart
  Widget _buildActions() {
    return Row(
      children: <Widget>[
        Expanded(
          child: _ActionButton(
            label: 'Send',
            icon: Icons.arrow_upward_rounded,
            filled: true,
            onTap: onSend,
          ),
        ),
        const SizedBox(width: 12),
        Expanded(
          child: _ActionButton(
            label: 'Request',
            icon: Icons.arrow_downward_rounded,
            filled: false,
            onTap: onRequest,
          ),
        ),
      ],
    );
  }

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

Both actions sit in `Expanded` so they split the row evenly with a 12px gap, but only Send passes `filled: true` — it takes the `_brand` indigo fill because paying a saved recipient is the reason this screen exists, while Request sits on the quieter `_surface` grey. Same size, different emphasis: the layout says both are valid, the colour says which one the product expects. `_sectionLabel` then styles the 'History' heading by calling `text.toUpperCase()` at 11px with `letterSpacing: 1.0` in `_muted`, the classic small-caps treatment that separates sections without stealing attention from the rows below.

Transaction rows that encode direction three ways

fintech_recipient_detail_screen.dart
class _Txn {
  const _Txn(this.label, this.date, this.amount, this.incoming);

  final String label;
  final String date;
  final double amount;
  final bool incoming;
}

class _TxnRow extends StatelessWidget {
  const _TxnRow({required this.txn});

  final _Txn txn;

  @override
  Widget build(BuildContext context) {
    final bool inc = txn.incoming;
    final Color tint = inc
        ? FintechRecipientDetailScreen._teal
        : FintechRecipientDetailScreen._red;
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 10),
      child: Row(
        children: <Widget>[
          Container(
            width: 42,
            height: 42,
            decoration: BoxDecoration(
              color: tint.withValues(alpha: 0.15),
              shape: BoxShape.circle,
            ),
            child: Icon(
              inc ? Icons.south_west_rounded : Icons.north_east_rounded,
              size: 20,
              color: tint,
            ),
          ),
          const SizedBox(width: 14),
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                Text(
                  txn.label,
                  style: const TextStyle(
                    fontFamily: FintechRecipientDetailScreen._font,
                    fontSize: 14.5,
                    fontWeight: FontWeight.w500,
                    letterSpacing: 0.24,
                    color: Colors.white,
                  ),
                ),
                const SizedBox(height: 2),
                Text(
                  txn.date,
                  style: const TextStyle(
                    fontFamily: FintechRecipientDetailScreen._font,
                    fontSize: 12.5,
                    letterSpacing: 0.24,
                    color: FintechRecipientDetailScreen._muted,
                  ),
                ),
              ],
            ),
          ),
          Text(
            '${inc ? '+' : '-'}\$${txn.amount.abs().toStringAsFixed(2)}',
            style: TextStyle(
              fontFamily: FintechRecipientDetailScreen._font,
              fontSize: 14.5,
              fontWeight: FontWeight.w500,
              letterSpacing: 0.24,
              color: inc ? FintechRecipientDetailScreen._teal : Colors.white,
            ),
          ),
        ],
      ),
    );
  }
}

`_Txn` is a four-field const value class, and `_TxnRow` derives everything visual from its `incoming` flag: the `tint` resolves to `_teal` or `_red`, painting a 42px disc at `tint.withValues(alpha: 0.15)` behind a full-strength `south_west_rounded` (money arriving) or `north_east_rounded` (money leaving) icon. The amount string is assembled by hand — `'${inc ? '+' : '-'}\$${txn.amount.abs().toStringAsFixed(2)}'` — using `.abs()` because the sign character is chosen explicitly rather than inherited from the stored negative value. Note the asymmetry in the amount colour: incoming turns `_teal` but outgoing stays white, not red. Most rows in a payment history are outgoing, so highlighting only money in keeps the list calm instead of alarming.

One pill button widget for both variants

fintech_recipient_detail_screen.dart
class _ActionButton extends StatelessWidget {
  const _ActionButton({
    required this.label,
    required this.icon,
    required this.filled,
    this.onTap,
  });

  final String label;
  final IconData icon;
  final bool filled;
  final VoidCallback? onTap;

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      height: 52,
      child: Material(
        color: filled
            ? FintechRecipientDetailScreen._brand
            : FintechRecipientDetailScreen._surface,
        borderRadius: BorderRadius.circular(9999),
        child: InkWell(
          borderRadius: BorderRadius.circular(9999),
          onTap: onTap,
          child: Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              Icon(icon, size: 18, color: Colors.white),
              const SizedBox(width: 8),
              Text(
                label,
                style: const TextStyle(
                  fontFamily: FintechRecipientDetailScreen._font,
                  fontSize: 15,
                  fontWeight: FontWeight.w500,
                  letterSpacing: 0.24,
                  color: Colors.white,
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

`_ActionButton` skips `FilledButton`/`OutlinedButton` in favour of a raw `Material` + `InkWell` pair inside a 52px `SizedBox`, because one widget with a `filled` flag is simpler than styling two Material button classes to match. `BorderRadius.circular(9999)` on both layers gives a true pill and — crucially — keeps the ink ripple clipped to it, which is why the radius appears on the `InkWell` as well as the `Material`. The `filled` flag only switches the background between `_brand` and `_surface`; the 18px icon, 8px gap and 15px `w500` label stay white in both variants, so the pair reads as siblings rather than two unrelated controls.

Full code

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

import 'package:flutter/material.dart';

/// Recipient detail — beneficiary profile + transfer history (Revolut-inspired).
///
/// Self-contained per CONVENTIONS.md: pure Flutter only, the exact design font
/// (Inter) is bundled under `fonts/`, the contact photo is a bundled asset, and
/// the screen forces its own dark theme. Send / Request actions and a populated
/// payment history make it read like a real product.
class FintechRecipientDetailScreen extends StatelessWidget {
  const FintechRecipientDetailScreen({
    super.key,
    this.onBack,
    this.onSend,
    this.onRequest,
  });

  final VoidCallback? onBack;
  final VoidCallback? onSend;
  final VoidCallback? onRequest;

  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 _red = Color(0xFFE23B4A);
  static const Color _muted = Color(0xFF8D969E);

  static const List<_Txn> _history = <_Txn>[
    _Txn('Dinner — split', 'Yesterday', -42.50, false),
    _Txn('Sent', '12 Jun', -120.00, false),
    _Txn('Received', '4 Jun', 65.00, true),
    _Txn('Concert tickets', '28 May', -88.00, false),
    _Txn('Received', '19 May', 30.00, true),
  ];

  @override
  Widget build(BuildContext context) {
    return Theme(
      data: ThemeData.dark(useMaterial3: true),
      child: Scaffold(
        backgroundColor: _bg,
        body: SafeArea(
          child: Column(
            children: <Widget>[
              _buildAppBar(context),
              Expanded(
                child: ListView(
                  physics: const BouncingScrollPhysics(),
                  padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
                  children: <Widget>[
                    _buildHeader(),
                    const SizedBox(height: 24),
                    _buildActions(),
                    const SizedBox(height: 28),
                    _sectionLabel('History'),
                    const SizedBox(height: 6),
                    for (final _Txn t in _history) _TxnRow(txn: t),
                  ],
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }

  Widget _buildAppBar(BuildContext context) {
    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 Spacer(),
          IconButton(
            onPressed: () {},
            icon: const Icon(Icons.more_horiz_rounded,
                size: 22, color: Colors.white),
          ),
        ],
      ),
    );
  }

  Widget _buildHeader() {
    return Column(
      children: <Widget>[
        Container(
          width: 88,
          height: 88,
          decoration: const BoxDecoration(
            shape: BoxShape.circle,
            border: Border.fromBorderSide(BorderSide(color: _brand, width: 2)),
          ),
          padding: const EdgeInsets.all(3),
          child: ClipOval(
            child: Image.asset(
              'lib/screens/fintech/fintech_recipient_detail/images/avatar_5.jpg',
              fit: BoxFit.cover,
              errorBuilder: (BuildContext c, Object e, StackTrace? s) =>
                  Container(color: _brand),
            ),
          ),
        ),
        const SizedBox(height: 14),
        const Text(
          'Priya Nair',
          style: TextStyle(
            fontFamily: _font,
            fontSize: 22,
            fontWeight: FontWeight.w500,
            letterSpacing: 0.24,
            color: Colors.white,
          ),
        ),
        const SizedBox(height: 4),
        const Text(
          '@priya  ·  Joined 2023',
          style: TextStyle(
            fontFamily: _font,
            fontSize: 13,
            letterSpacing: 0.24,
            color: _muted,
          ),
        ),
      ],
    );
  }

  Widget _buildActions() {
    return Row(
      children: <Widget>[
        Expanded(
          child: _ActionButton(
            label: 'Send',
            icon: Icons.arrow_upward_rounded,
            filled: true,
            onTap: onSend,
          ),
        ),
        const SizedBox(width: 12),
        Expanded(
          child: _ActionButton(
            label: 'Request',
            icon: Icons.arrow_downward_rounded,
            filled: false,
            onTap: onRequest,
          ),
        ),
      ],
    );
  }

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

class _Txn {
  const _Txn(this.label, this.date, this.amount, this.incoming);

  final String label;
  final String date;
  final double amount;
  final bool incoming;
}

class _TxnRow extends StatelessWidget {
  const _TxnRow({required this.txn});

  final _Txn txn;

  @override
  Widget build(BuildContext context) {
    final bool inc = txn.incoming;
    final Color tint = inc
        ? FintechRecipientDetailScreen._teal
        : FintechRecipientDetailScreen._red;
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 10),
      child: Row(
        children: <Widget>[
          Container(
            width: 42,
            height: 42,
            decoration: BoxDecoration(
              color: tint.withValues(alpha: 0.15),
              shape: BoxShape.circle,
            ),
            child: Icon(
              inc ? Icons.south_west_rounded : Icons.north_east_rounded,
              size: 20,
              color: tint,
            ),
          ),
          const SizedBox(width: 14),
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                Text(
                  txn.label,
                  style: const TextStyle(
                    fontFamily: FintechRecipientDetailScreen._font,
                    fontSize: 14.5,
                    fontWeight: FontWeight.w500,
                    letterSpacing: 0.24,
                    color: Colors.white,
                  ),
                ),
                const SizedBox(height: 2),
                Text(
                  txn.date,
                  style: const TextStyle(
                    fontFamily: FintechRecipientDetailScreen._font,
                    fontSize: 12.5,
                    letterSpacing: 0.24,
                    color: FintechRecipientDetailScreen._muted,
                  ),
                ),
              ],
            ),
          ),
          Text(
            '${inc ? '+' : '-'}\$${txn.amount.abs().toStringAsFixed(2)}',
            style: TextStyle(
              fontFamily: FintechRecipientDetailScreen._font,
              fontSize: 14.5,
              fontWeight: FontWeight.w500,
              letterSpacing: 0.24,
              color: inc ? FintechRecipientDetailScreen._teal : Colors.white,
            ),
          ),
        ],
      ),
    );
  }
}

class _ActionButton extends StatelessWidget {
  const _ActionButton({
    required this.label,
    required this.icon,
    required this.filled,
    this.onTap,
  });

  final String label;
  final IconData icon;
  final bool filled;
  final VoidCallback? onTap;

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      height: 52,
      child: Material(
        color: filled
            ? FintechRecipientDetailScreen._brand
            : FintechRecipientDetailScreen._surface,
        borderRadius: BorderRadius.circular(9999),
        child: InkWell(
          borderRadius: BorderRadius.circular(9999),
          onTap: onTap,
          child: Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              Icon(icon, size: 18, color: Colors.white),
              const SizedBox(width: 8),
              Text(
                label,
                style: const TextStyle(
                  fontFamily: FintechRecipientDetailScreen._font,
                  fontSize: 15,
                  fontWeight: FontWeight.w500,
                  letterSpacing: 0.24,
                  color: Colors.white,
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

Plus bundled 2 binary assets (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-recipient-detail

2. AI agent (MCP)

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

FAQ

Is this recipient detail screen free to use in a commercial app?

Yes. FlutterKit screens are free to use, including commercially — you can drop this beneficiary profile into a production banking or wallet app, modify it, and ship it with no attribution or sign-up required.

Does this screen need any packages or fonts?

No packages — it imports only `package:flutter/material.dart`. It expects the Inter font family (referenced as `fontFamily: 'Inter'`) bundled in your app's `fonts/` folder via pubspec, and one bundled avatar image; the `errorBuilder` means the screen still renders with a solid indigo disc if that asset is absent.

Which Flutter version does this require?

Flutter 3.27 or newer, because the transaction-row disc uses `tint.withValues(alpha: 0.15)`. On an older SDK, replace that call with `tint.withOpacity(0.15)`; the constructor also uses super parameters (`super.key`), which need at least Flutter 3.0 / Dart 2.17.

How do I replace the hardcoded history with real transfers?

Promote `_Txn` to a public model (or map your API type onto it), add a `List<_Txn> history` constructor parameter, and pass it where the static `_history` is read in the collection-for inside `build`. Because `_TxnRow` derives its icon, tint and sign entirely from each item's `incoming` flag and amount, no other code changes — for long histories, move the rows into a `ListView.builder`.

Can I load the avatar from the network instead of an asset?

Yes — swap `Image.asset` for `Image.network` inside the `ClipOval` and keep the existing `errorBuilder` as the offline fallback; adding a matching `loadingBuilder` returning the same solid `_brand` container avoids a flash while the photo loads. The 2px ring and 3px inset live on the outer `Container`, so they are unaffected by where the image comes from.

Related screens