E-commerce35 views

How to Build an Order Invoice Screen in Flutter (Full Code + Preview)

An invoice is the one screen a customer screenshots, forwards to an accountant, or opens six months later during a return, so it has to read like a document rather than an app view. This tutorial builds StyleCart's tax invoice in Flutter: a receipt card with brand header and invoice number, side-by-side Billed to / From blocks, an ITEM / QTY / AMOUNT table whose columns stay aligned without a Table widget, a totals split separated by painted tear-off perforations, a PAID badge, and a pinned Share / Download bar.

Order invoice — E-commerce Flutter UI screen
Live preview — Order invoice, built in pure Flutter.

Watch the Flutter UI walkthrough

A short screen recording of Order invoice 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 receipt-style card whose sections are divided by a CustomPainter perforation with punched edge notches
  • A three-column line table built from matching Expanded flex ratios instead of a Table widget
  • A totals ladder where discount, shipping and 8% GST are derived in build() from the line items
  • A green PAID · Visa •••• 4291 badge sitting directly under the bold total
  • A pinned Share / Download bar that ranks the two actions outline-then-filled

Step-by-step build

1

Create the file

Add a new file at lib/ecom_order_invoice/ecom_order_invoice_screen.dart in your Flutter project.

2

Register the bundled fonts

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

pubspec.yaml
flutter:
  fonts:
    - family: Manrope
      fonts:
        - asset: fonts/Manrope-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.

Tokens and the line items as data

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

/// StyleCart — Invoice.
///
/// A clean, printable tax invoice: brand header with invoice number and date,
/// bill-to block, an itemised line table, then the subtotal / discount /
/// shipping / tax / total split by painted perforated rules, a GST note, and a
/// pinned download / share action bar.
///
/// Self-contained per CONVENTIONS.md: pure Flutter, bundled Manrope, inline
/// Airbnb-style tokens, own light theme + SafeArea. The perforated rules are a
/// CustomPainter (no emoji glyph, no network). Exposes callbacks only.
class EcomOrderInvoiceScreen extends StatelessWidget {
  const EcomOrderInvoiceScreen({
    super.key,
    this.onBack,
    this.onDownload,
    this.onShare,
  });

  final VoidCallback? onBack;
  final VoidCallback? onDownload;
  final VoidCallback? onShare;

  static const String _font = 'Manrope';
  static const Color _canvas = Color(0xFFFFFFFF);
  static const Color _ink = Color(0xFF222222);
  static const Color _muted = Color(0xFF6A6A6A);
  static const Color _brand = Color(0xFFFF385C);
  static const Color _surface = Color(0xFFF2F2F2);
  static const Color _success = Color(0xFF2E9E5B);
  static const Color _hairline = Color(0xFFEBEBEB);

  static const List<_Line> _lines = <_Line>[
    _Line('Washed cotton overshirt', 'Sand · M', 1, 118),
    _Line('Wide-leg trouser', 'Black · 30', 1, 96),
    _Line('Court sneakers', 'White · 42', 2, 95),
  ];

`EcomOrderInvoiceScreen` is a `StatelessWidget` exposing only `onBack`, `onDownload` and `onShare` — an invoice displays a settled transaction, so there is nothing on the screen to mutate. The palette is deliberately paper-like: `_canvas` is pure white rather than an off-white, because this document gets screenshotted and printed, and `_hairline` `#EBEBEB` keeps the card border almost invisible. `_success` green is reserved exclusively for the discount figure and the PAID badge, so the two positive numbers on the page share one meaning. The three rows live in `static const List<_Line> _lines`, each carrying name, variant, qty and **unit** price — not the line total, which the code derives later.

Deriving the totals, then the page frame

ecom_order_invoice_screen.dart
  @override
  Widget build(BuildContext context) {
    final int subtotal = _lines.fold(0, (int s, _Line l) => s + l.unit * l.qty);
    const double discount = 40;
    const double shipping = 12;
    final double tax = (subtotal - discount) * 0.08;
    final double total = subtotal - discount + shipping + tax;

    return Theme(
      data: ThemeData.light(useMaterial3: true),
      child: Scaffold(
        backgroundColor: _canvas,
        body: SafeArea(
          child: Column(
            children: <Widget>[
              _header(),
              Expanded(
                child: ListView(
                  padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
                  children: <Widget>[
                    _invoiceCard(subtotal, discount, shipping, tax, total),
                    const SizedBox(height: 16),
                    _gstNote(),
                  ],
                ),
              ),
              _actions(),
            ],
          ),
        ),
      ),
    );
  }

  Widget _header() {
    return Padding(
      padding: const EdgeInsets.fromLTRB(8, 4, 20, 8),
      child: Row(
        children: <Widget>[
          IconButton(
            onPressed: onBack,
            icon: const Icon(Icons.arrow_back_rounded, size: 22, color: _ink),
          ),
          const Text(
            'Invoice',
            style: TextStyle(
              fontFamily: _font,
              fontSize: 18,
              fontWeight: FontWeight.w800,
              color: _ink,
            ),
          ),
          const Spacer(),
          GestureDetector(
            onTap: onShare,
            child: const Icon(Icons.ios_share_rounded, size: 20, color: _ink),
          ),
        ],
      ),
    );
  }

`build` computes the money rather than hard-coding it: `_lines.fold` sums `l.unit * l.qty` for the subtotal (404), then `tax = (subtotal - discount) * 0.08` and `total = subtotal - discount + shipping + tax`. Note the tax base — the discount is subtracted *before* GST is applied and shipping is added *after*, which is the ordering a real tax invoice needs. The body is a Column of fixed `_header()`, an `Expanded` `ListView` and a fixed `_actions()`, so the action bar stays pinned while a long item list scrolls. The header pairs a back `IconButton` with a bare `ios_share_rounded` icon behind a `GestureDetector`, giving a share affordance up top as well as in the bar.

The invoice card header block

ecom_order_invoice_screen.dart
  Widget _invoiceCard(int subtotal, double discount, double shipping,
      double tax, double total) {
    return Container(
      decoration: BoxDecoration(
        color: _canvas,
        borderRadius: BorderRadius.circular(18),
        border: Border.all(color: _hairline),
      ),
      child: Column(
        children: <Widget>[
          Padding(
            padding: const EdgeInsets.all(20),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                Row(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: <Widget>[
                    Container(
                      width: 38,
                      height: 38,
                      decoration: BoxDecoration(
                        color: _brand,
                        borderRadius: BorderRadius.circular(10),
                      ),
                      child: const Center(
                        child: Text(
                          'S',
                          style: TextStyle(
                            fontFamily: _font,
                            fontSize: 20,
                            fontWeight: FontWeight.w800,
                            color: _canvas,
                          ),
                        ),
                      ),
                    ),
                    const SizedBox(width: 12),
                    const Expanded(
                      child: Column(
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: <Widget>[
                          Text(
                            'StyleCart',
                            style: TextStyle(
                              fontFamily: _font,
                              fontSize: 17,
                              fontWeight: FontWeight.w800,
                              color: _ink,
                            ),
                          ),
                          Text(
                            'Tax invoice',
                            style: TextStyle(
                              fontFamily: _font,
                              fontSize: 12.5,
                              fontWeight: FontWeight.w500,
                              color: _muted,
                            ),
                          ),
                        ],
                      ),
                    ),
                    const Column(
                      crossAxisAlignment: CrossAxisAlignment.end,
                      children: <Widget>[
                        Text(
                          'INV-48213',
                          style: TextStyle(
                            fontFamily: _font,
                            fontSize: 14,
                            fontWeight: FontWeight.w800,
                            color: _ink,
                          ),
                        ),
                        Text(
                          '16 Jun 2026',
                          style: TextStyle(
                            fontFamily: _font,
                            fontSize: 12,
                            fontWeight: FontWeight.w500,
                            color: _muted,
                          ),
                        ),
                      ],
                    ),
                  ],
                ),
                const SizedBox(height: 18),
                _billBlocks(),
              ],
            ),
          ),
          const _Perf(),

The card is a `Container` with an 18px radius and a `_hairline` border — no shadow, because a drop shadow would look wrong on something meant to read as paper. Its first row is a three-part masthead: a 38px `_brand` coral tile carrying the letter S, an `Expanded` column with 'StyleCart' over 'Tax invoice', and a right-aligned column with `INV-48213` over the date. The `Expanded` in the middle is what lets the invoice number hug the right edge regardless of brand-name length. `crossAxisAlignment: CrossAxisAlignment.start` on the row tops all three against the same baseline area, then 18px of space precedes `_billBlocks()` and the first `const _Perf()` divider.

Line table, totals ladder and the PAID badge

ecom_order_invoice_screen.dart
          Padding(
            padding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
            child: Column(
              children: <Widget>[
                _lineHeader(),
                const SizedBox(height: 8),
                for (final _Line l in _lines) _lineRow(l),
              ],
            ),
          ),
          const _Perf(),
          Padding(
            padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
            child: Column(
              children: <Widget>[
                _totalRow('Subtotal', '\$$subtotal'),
                _totalRow('Discount · STYLE10',
                    '−\$${discount.toStringAsFixed(0)}',
                    valueColor: _success),
                _totalRow('Shipping', '\$${shipping.toStringAsFixed(0)}'),
                _totalRow('Tax (GST 8%)', '\$${tax.toStringAsFixed(2)}'),
                const SizedBox(height: 8),
                Container(height: 1.4, color: _ink),
                const SizedBox(height: 10),
                _totalRow('Total', '\$${total.toStringAsFixed(2)}', bold: true),
                const SizedBox(height: 6),
                Container(
                  width: double.infinity,
                  padding: const EdgeInsets.symmetric(vertical: 9),
                  decoration: BoxDecoration(
                    color: _success.withValues(alpha: 0.1),
                    borderRadius: BorderRadius.circular(10),
                  ),
                  child: const Center(
                    child: Text(
                      'PAID · Visa •••• 4291',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 12.5,
                        fontWeight: FontWeight.w800,
                        letterSpacing: 0.4,
                        color: _success,
                      ),
                    ),
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }

Between the two `_Perf` rules sits the item section: `_lineHeader()` and a `for (final _Line l in _lines) _lineRow(l)` collection-for, so adding a product needs no widget edits. The totals block below is a stack of `_totalRow` calls where only the discount passes `valueColor: _success` — the single green number on the ledger. Before the total, `Container(height: 1.4, color: _ink)` draws a full-strength ink rule rather than a hairline, the visual convention that says 'everything above adds up to this'. The PAID pill is a full-width `_success.withValues(alpha: 0.1)` panel at 12.5px `w800` with `letterSpacing: 0.4`, naming the masked card so the reader knows which account was charged.

Billed to / From address blocks

ecom_order_invoice_screen.dart
  Widget _billBlocks() {
    return Row(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: <Widget>[
        Expanded(
          child: _addrBlock('Billed to', <String>[
            'Aria Patel',
            '24 Larkspur Lane, Apt 7B',
            'Brooklyn, NY 11217',
          ]),
        ),
        const SizedBox(width: 16),
        Expanded(
          child: _addrBlock('From', <String>[
            'StyleCart Inc.',
            '500 Market St, Suite 4',
            'San Francisco, CA',
          ]),
        ),
      ],
    );
  }

  Widget _addrBlock(String label, List<String> lines) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: <Widget>[
        Text(
          label.toUpperCase(),
          style: const TextStyle(
            fontFamily: _font,
            fontSize: 10.5,
            fontWeight: FontWeight.w800,
            letterSpacing: 0.6,
            color: _muted,
          ),
        ),
        const SizedBox(height: 6),
        for (int i = 0; i < lines.length; i++)
          Text(
            lines[i],
            style: TextStyle(
              fontFamily: _font,
              fontSize: 12.5,
              fontWeight: i == 0 ? FontWeight.w700 : FontWeight.w500,
              height: 1.4,
              color: i == 0 ? _ink : _muted,
            ),
          ),
      ],
    );
  }

  Widget _lineHeader() {
    return Row(
      children: const <Widget>[
        Expanded(
          flex: 5,
          child: Text(
            'ITEM',
            style: TextStyle(
              fontFamily: _font,
              fontSize: 10.5,
              fontWeight: FontWeight.w800,
              letterSpacing: 0.6,
              color: _muted,
            ),
          ),
        ),
        Expanded(
          flex: 1,
          child: Text(
            'QTY',
            textAlign: TextAlign.center,
            style: TextStyle(
              fontFamily: _font,
              fontSize: 10.5,
              fontWeight: FontWeight.w800,
              letterSpacing: 0.6,
              color: _muted,
            ),
          ),
        ),
        Expanded(
          flex: 2,
          child: Text(
            'AMOUNT',
            textAlign: TextAlign.end,
            style: TextStyle(
              fontFamily: _font,
              fontSize: 10.5,
              fontWeight: FontWeight.w800,
              letterSpacing: 0.6,
              color: _muted,
            ),
          ),
        ),
      ],
    );
  }

`_billBlocks` puts two `Expanded` columns side by side with 16px between them, so each address gets exactly half the card width and long street lines wrap instead of colliding. `_addrBlock` upper-cases its label at 10.5px `w800` with `letterSpacing: 0.6` — small-caps signalling a field name, not content. The address lines then use an indexed loop so `i == 0` gets `FontWeight.w700` and `_ink` while the rest get `w500` and `_muted`: the recipient's name reads as the answer, the street and city as supporting detail, with no extra widget nesting. `_lineHeader` reuses the same 10.5px caps style so the column titles and field labels feel like one typographic system.

Column alignment without a Table

ecom_order_invoice_screen.dart
  Widget _lineRow(_Line l) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 6),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          Expanded(
            flex: 5,
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                Text(
                  l.name,
                  style: const TextStyle(
                    fontFamily: _font,
                    fontSize: 13.5,
                    fontWeight: FontWeight.w700,
                    color: _ink,
                  ),
                ),
                const SizedBox(height: 1),
                Text(
                  l.variant,
                  style: const TextStyle(
                    fontFamily: _font,
                    fontSize: 11.5,
                    fontWeight: FontWeight.w500,
                    color: _muted,
                  ),
                ),
              ],
            ),
          ),
          Expanded(
            flex: 1,
            child: Text(
              '${l.qty}',
              textAlign: TextAlign.center,
              style: const TextStyle(
                fontFamily: _font,
                fontSize: 13,
                fontWeight: FontWeight.w600,
                color: _ink,
              ),
            ),
          ),
          Expanded(
            flex: 2,
            child: Text(
              '\$${l.unit * l.qty}',
              textAlign: TextAlign.end,
              style: const TextStyle(
                fontFamily: _font,
                fontSize: 13.5,
                fontWeight: FontWeight.w700,
                color: _ink,
              ),
            ),
          ),
        ],
      ),
    );
  }

  Widget _totalRow(String label, String value,
      {bool bold = false, Color? valueColor}) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 3),
      child: Row(
        children: <Widget>[
          Expanded(
            child: Text(
              label,
              style: TextStyle(
                fontFamily: _font,
                fontSize: bold ? 16 : 13,
                fontWeight: bold ? FontWeight.w800 : FontWeight.w500,
                color: bold ? _ink : _muted,
              ),
            ),
          ),
          Text(
            value,
            style: TextStyle(
              fontFamily: _font,
              fontSize: bold ? 18 : 13,
              fontWeight: bold ? FontWeight.w800 : FontWeight.w700,
              color: valueColor ?? _ink,
            ),
          ),
        ],
      ),
    );
  }

`_lineRow` mirrors `_lineHeader` exactly — `Expanded(flex: 5)`, `flex: 1`, `flex: 2` — which is what keeps ITEM, QTY and AMOUNT aligned down the page without a `Table` or `DataTable`. QTY is centred and AMOUNT uses `TextAlign.end`, the two alignments a receipt expects. The amount cell prints `l.unit * l.qty`, so the sneakers row shows $190 for two pairs at 95 while the stored data stays a unit price. `_totalRow` takes `bold` and `valueColor` flags and switches size and weight from one expression each — 13px `w500` muted labels for the components, 16/18px `w800` ink for the total — so a single helper renders the whole ladder.

GST note and the pinned action bar

ecom_order_invoice_screen.dart
  Widget _gstNote() {
    return Container(
      padding: const EdgeInsets.all(14),
      decoration: BoxDecoration(
        color: _surface,
        borderRadius: BorderRadius.circular(14),
      ),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: const <Widget>[
          Icon(Icons.info_outline_rounded, size: 16, color: _muted),
          SizedBox(width: 10),
          Expanded(
            child: Text(
              'This is a computer-generated tax invoice. GSTIN '
              '29ABCDE1234F1Z5 · No signature required.',
              style: TextStyle(
                fontFamily: _font,
                fontSize: 12,
                fontWeight: FontWeight.w500,
                height: 1.4,
                color: _muted,
              ),
            ),
          ),
        ],
      ),
    );
  }

  Widget _actions() {
    return Container(
      decoration: const BoxDecoration(
        color: _canvas,
        border: Border(top: BorderSide(color: _hairline)),
      ),
      child: SafeArea(
        top: false,
        child: Padding(
          padding: const EdgeInsets.fromLTRB(20, 12, 20, 12),
          child: Row(
            children: <Widget>[
              Expanded(
                child: SizedBox(
                  height: 52,
                  child: OutlinedButton.icon(
                    onPressed: onShare,
                    icon: const Icon(Icons.ios_share_rounded, size: 18),
                    label: const Text(
                      'Share',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 15,
                        fontWeight: FontWeight.w700,
                      ),
                    ),
                    style: OutlinedButton.styleFrom(
                      foregroundColor: _ink,
                      side: const BorderSide(color: _ink, width: 1.3),
                      shape: RoundedRectangleBorder(
                        borderRadius: BorderRadius.circular(14),
                      ),
                    ),
                  ),
                ),
              ),
              const SizedBox(width: 12),
              Expanded(
                child: SizedBox(
                  height: 52,
                  child: FilledButton.icon(
                    onPressed: onDownload,
                    icon: const Icon(Icons.download_rounded, size: 18),
                    label: const Text(
                      'Download',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 15,
                        fontWeight: FontWeight.w700,
                      ),
                    ),
                    style: FilledButton.styleFrom(
                      backgroundColor: _brand,
                      foregroundColor: _canvas,
                      shape: RoundedRectangleBorder(
                        borderRadius: BorderRadius.circular(14),
                      ),
                    ),
                  ),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

`_gstNote` is a `_surface` grey panel with an info icon, a 10px gap and an `Expanded` text carrying the GSTIN and 'No signature required' — `CrossAxisAlignment.start` keeps the icon on the first line as the sentence wraps to two. The bar in `_actions` wraps its `Row` in `SafeArea(top: false)` *inside* the Container, so the white background and its top hairline run under the home indicator while the buttons stay clear of it. Both buttons are `Expanded` at 52px, but they are ranked: Share is an `OutlinedButton.icon` with a 1.3px ink border, Download a coral `FilledButton.icon`, because saving the invoice is the action most people came for.

Painting the tear-off perforation

ecom_order_invoice_screen.dart
class _Line {
  const _Line(this.name, this.variant, this.qty, this.unit);
  final String name;
  final String variant;
  final int qty;
  final int unit;
}

/// A full-width perforated rule (dashes + side notches) separating invoice
/// sections, like a tear-off receipt.
class _Perf extends StatelessWidget {
  const _Perf();

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      height: 18,
      width: double.infinity,
      child: CustomPaint(painter: _PerfPainter()),
    );
  }
}

class _PerfPainter extends CustomPainter {
  static const Color _hairline = Color(0xFFD8D8D8);
  static const Color _canvas = Color(0xFFFFFFFF);

  @override
  void paint(Canvas canvas, Size size) {
    final double cy = size.height / 2;

    // Side notches (punch the card edges).
    final Paint notch = Paint()..color = _canvas;
    canvas.drawCircle(Offset(0, cy), 7, notch);
    canvas.drawCircle(Offset(size.width, cy), 7, notch);

    // Dashed rule.
    final Paint dash = Paint()
      ..color = _hairline
      ..strokeWidth = 1.4
      ..strokeCap = StrokeCap.round;
    const double dashW = 6;
    const double gap = 5;
    double x = 10;
    while (x < size.width - 10) {
      canvas.drawLine(Offset(x, cy), Offset(x + dashW, cy), dash);
      x += dashW + gap;
    }
  }

  @override
  bool shouldRepaint(_PerfPainter oldDelegate) => false;
}

`_Line` is a plain const data class, and `_Perf` is an 18px-tall full-width `CustomPaint`. `_PerfPainter` first draws two white circles of radius 7 centred at `Offset(0, cy)` and `Offset(size.width, cy)` — half of each falls outside the box, so they punch through the card's own hairline border and read as ticket notches. The dashes are then walked manually: `x` starts at 10 and advances by `dashW + gap` (6 + 5) while `x < size.width - 10`, leaving clearance so the line never runs into a notch. `StrokeCap.round` at 1.4px softens each dash, and `shouldRepaint` returns `false` because the rule depends on nothing but its size.

Full code

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

import 'package:flutter/material.dart';

/// StyleCart — Invoice.
///
/// A clean, printable tax invoice: brand header with invoice number and date,
/// bill-to block, an itemised line table, then the subtotal / discount /
/// shipping / tax / total split by painted perforated rules, a GST note, and a
/// pinned download / share action bar.
///
/// Self-contained per CONVENTIONS.md: pure Flutter, bundled Manrope, inline
/// Airbnb-style tokens, own light theme + SafeArea. The perforated rules are a
/// CustomPainter (no emoji glyph, no network). Exposes callbacks only.
class EcomOrderInvoiceScreen extends StatelessWidget {
  const EcomOrderInvoiceScreen({
    super.key,
    this.onBack,
    this.onDownload,
    this.onShare,
  });

  final VoidCallback? onBack;
  final VoidCallback? onDownload;
  final VoidCallback? onShare;

  static const String _font = 'Manrope';
  static const Color _canvas = Color(0xFFFFFFFF);
  static const Color _ink = Color(0xFF222222);
  static const Color _muted = Color(0xFF6A6A6A);
  static const Color _brand = Color(0xFFFF385C);
  static const Color _surface = Color(0xFFF2F2F2);
  static const Color _success = Color(0xFF2E9E5B);
  static const Color _hairline = Color(0xFFEBEBEB);

  static const List<_Line> _lines = <_Line>[
    _Line('Washed cotton overshirt', 'Sand · M', 1, 118),
    _Line('Wide-leg trouser', 'Black · 30', 1, 96),
    _Line('Court sneakers', 'White · 42', 2, 95),
  ];

  @override
  Widget build(BuildContext context) {
    final int subtotal = _lines.fold(0, (int s, _Line l) => s + l.unit * l.qty);
    const double discount = 40;
    const double shipping = 12;
    final double tax = (subtotal - discount) * 0.08;
    final double total = subtotal - discount + shipping + tax;

    return Theme(
      data: ThemeData.light(useMaterial3: true),
      child: Scaffold(
        backgroundColor: _canvas,
        body: SafeArea(
          child: Column(
            children: <Widget>[
              _header(),
              Expanded(
                child: ListView(
                  padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
                  children: <Widget>[
                    _invoiceCard(subtotal, discount, shipping, tax, total),
                    const SizedBox(height: 16),
                    _gstNote(),
                  ],
                ),
              ),
              _actions(),
            ],
          ),
        ),
      ),
    );
  }

  Widget _header() {
    return Padding(
      padding: const EdgeInsets.fromLTRB(8, 4, 20, 8),
      child: Row(
        children: <Widget>[
          IconButton(
            onPressed: onBack,
            icon: const Icon(Icons.arrow_back_rounded, size: 22, color: _ink),
          ),
          const Text(
            'Invoice',
            style: TextStyle(
              fontFamily: _font,
              fontSize: 18,
              fontWeight: FontWeight.w800,
              color: _ink,
            ),
          ),
          const Spacer(),
          GestureDetector(
            onTap: onShare,
            child: const Icon(Icons.ios_share_rounded, size: 20, color: _ink),
          ),
        ],
      ),
    );
  }

  Widget _invoiceCard(int subtotal, double discount, double shipping,
      double tax, double total) {
    return Container(
      decoration: BoxDecoration(
        color: _canvas,
        borderRadius: BorderRadius.circular(18),
        border: Border.all(color: _hairline),
      ),
      child: Column(
        children: <Widget>[
          Padding(
            padding: const EdgeInsets.all(20),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                Row(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: <Widget>[
                    Container(
                      width: 38,
                      height: 38,
                      decoration: BoxDecoration(
                        color: _brand,
                        borderRadius: BorderRadius.circular(10),
                      ),
                      child: const Center(
                        child: Text(
                          'S',
                          style: TextStyle(
                            fontFamily: _font,
                            fontSize: 20,
                            fontWeight: FontWeight.w800,
                            color: _canvas,
                          ),
                        ),
                      ),
                    ),
                    const SizedBox(width: 12),
                    const Expanded(
                      child: Column(
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: <Widget>[
                          Text(
                            'StyleCart',
                            style: TextStyle(
                              fontFamily: _font,
                              fontSize: 17,
                              fontWeight: FontWeight.w800,
                              color: _ink,
                            ),
                          ),
                          Text(
                            'Tax invoice',
                            style: TextStyle(
                              fontFamily: _font,
                              fontSize: 12.5,
                              fontWeight: FontWeight.w500,
                              color: _muted,
                            ),
                          ),
                        ],
                      ),
                    ),
                    const Column(
                      crossAxisAlignment: CrossAxisAlignment.end,
                      children: <Widget>[
                        Text(
                          'INV-48213',
                          style: TextStyle(
                            fontFamily: _font,
                            fontSize: 14,
                            fontWeight: FontWeight.w800,
                            color: _ink,
                          ),
                        ),
                        Text(
                          '16 Jun 2026',
                          style: TextStyle(
                            fontFamily: _font,
                            fontSize: 12,
                            fontWeight: FontWeight.w500,
                            color: _muted,
                          ),
                        ),
                      ],
                    ),
                  ],
                ),
                const SizedBox(height: 18),
                _billBlocks(),
              ],
            ),
          ),
          const _Perf(),
          Padding(
            padding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
            child: Column(
              children: <Widget>[
                _lineHeader(),
                const SizedBox(height: 8),
                for (final _Line l in _lines) _lineRow(l),
              ],
            ),
          ),
          const _Perf(),
          Padding(
            padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
            child: Column(
              children: <Widget>[
                _totalRow('Subtotal', '\$$subtotal'),
                _totalRow('Discount · STYLE10',
                    '−\$${discount.toStringAsFixed(0)}',
                    valueColor: _success),
                _totalRow('Shipping', '\$${shipping.toStringAsFixed(0)}'),
                _totalRow('Tax (GST 8%)', '\$${tax.toStringAsFixed(2)}'),
                const SizedBox(height: 8),
                Container(height: 1.4, color: _ink),
                const SizedBox(height: 10),
                _totalRow('Total', '\$${total.toStringAsFixed(2)}', bold: true),
                const SizedBox(height: 6),
                Container(
                  width: double.infinity,
                  padding: const EdgeInsets.symmetric(vertical: 9),
                  decoration: BoxDecoration(
                    color: _success.withValues(alpha: 0.1),
                    borderRadius: BorderRadius.circular(10),
                  ),
                  child: const Center(
                    child: Text(
                      'PAID · Visa •••• 4291',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 12.5,
                        fontWeight: FontWeight.w800,
                        letterSpacing: 0.4,
                        color: _success,
                      ),
                    ),
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }

  Widget _billBlocks() {
    return Row(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: <Widget>[
        Expanded(
          child: _addrBlock('Billed to', <String>[
            'Aria Patel',
            '24 Larkspur Lane, Apt 7B',
            'Brooklyn, NY 11217',
          ]),
        ),
        const SizedBox(width: 16),
        Expanded(
          child: _addrBlock('From', <String>[
            'StyleCart Inc.',
            '500 Market St, Suite 4',
            'San Francisco, CA',
          ]),
        ),
      ],
    );
  }

  Widget _addrBlock(String label, List<String> lines) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: <Widget>[
        Text(
          label.toUpperCase(),
          style: const TextStyle(
            fontFamily: _font,
            fontSize: 10.5,
            fontWeight: FontWeight.w800,
            letterSpacing: 0.6,
            color: _muted,
          ),
        ),
        const SizedBox(height: 6),
        for (int i = 0; i < lines.length; i++)
          Text(
            lines[i],
            style: TextStyle(
              fontFamily: _font,
              fontSize: 12.5,
              fontWeight: i == 0 ? FontWeight.w700 : FontWeight.w500,
              height: 1.4,
              color: i == 0 ? _ink : _muted,
            ),
          ),
      ],
    );
  }

  Widget _lineHeader() {
    return Row(
      children: const <Widget>[
        Expanded(
          flex: 5,
          child: Text(
            'ITEM',
            style: TextStyle(
              fontFamily: _font,
              fontSize: 10.5,
              fontWeight: FontWeight.w800,
              letterSpacing: 0.6,
              color: _muted,
            ),
          ),
        ),
        Expanded(
          flex: 1,
          child: Text(
            'QTY',
            textAlign: TextAlign.center,
            style: TextStyle(
              fontFamily: _font,
              fontSize: 10.5,
              fontWeight: FontWeight.w800,
              letterSpacing: 0.6,
              color: _muted,
            ),
          ),
        ),
        Expanded(
          flex: 2,
          child: Text(
            'AMOUNT',
            textAlign: TextAlign.end,
            style: TextStyle(
              fontFamily: _font,
              fontSize: 10.5,
              fontWeight: FontWeight.w800,
              letterSpacing: 0.6,
              color: _muted,
            ),
          ),
        ),
      ],
    );
  }

  Widget _lineRow(_Line l) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 6),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          Expanded(
            flex: 5,
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                Text(
                  l.name,
                  style: const TextStyle(
                    fontFamily: _font,
                    fontSize: 13.5,
                    fontWeight: FontWeight.w700,
                    color: _ink,
                  ),
                ),
                const SizedBox(height: 1),
                Text(
                  l.variant,
                  style: const TextStyle(
                    fontFamily: _font,
                    fontSize: 11.5,
                    fontWeight: FontWeight.w500,
                    color: _muted,
                  ),
                ),
              ],
            ),
          ),
          Expanded(
            flex: 1,
            child: Text(
              '${l.qty}',
              textAlign: TextAlign.center,
              style: const TextStyle(
                fontFamily: _font,
                fontSize: 13,
                fontWeight: FontWeight.w600,
                color: _ink,
              ),
            ),
          ),
          Expanded(
            flex: 2,
            child: Text(
              '\$${l.unit * l.qty}',
              textAlign: TextAlign.end,
              style: const TextStyle(
                fontFamily: _font,
                fontSize: 13.5,
                fontWeight: FontWeight.w700,
                color: _ink,
              ),
            ),
          ),
        ],
      ),
    );
  }

  Widget _totalRow(String label, String value,
      {bool bold = false, Color? valueColor}) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 3),
      child: Row(
        children: <Widget>[
          Expanded(
            child: Text(
              label,
              style: TextStyle(
                fontFamily: _font,
                fontSize: bold ? 16 : 13,
                fontWeight: bold ? FontWeight.w800 : FontWeight.w500,
                color: bold ? _ink : _muted,
              ),
            ),
          ),
          Text(
            value,
            style: TextStyle(
              fontFamily: _font,
              fontSize: bold ? 18 : 13,
              fontWeight: bold ? FontWeight.w800 : FontWeight.w700,
              color: valueColor ?? _ink,
            ),
          ),
        ],
      ),
    );
  }

  Widget _gstNote() {
    return Container(
      padding: const EdgeInsets.all(14),
      decoration: BoxDecoration(
        color: _surface,
        borderRadius: BorderRadius.circular(14),
      ),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: const <Widget>[
          Icon(Icons.info_outline_rounded, size: 16, color: _muted),
          SizedBox(width: 10),
          Expanded(
            child: Text(
              'This is a computer-generated tax invoice. GSTIN '
              '29ABCDE1234F1Z5 · No signature required.',
              style: TextStyle(
                fontFamily: _font,
                fontSize: 12,
                fontWeight: FontWeight.w500,
                height: 1.4,
                color: _muted,
              ),
            ),
          ),
        ],
      ),
    );
  }

  Widget _actions() {
    return Container(
      decoration: const BoxDecoration(
        color: _canvas,
        border: Border(top: BorderSide(color: _hairline)),
      ),
      child: SafeArea(
        top: false,
        child: Padding(
          padding: const EdgeInsets.fromLTRB(20, 12, 20, 12),
          child: Row(
            children: <Widget>[
              Expanded(
                child: SizedBox(
                  height: 52,
                  child: OutlinedButton.icon(
                    onPressed: onShare,
                    icon: const Icon(Icons.ios_share_rounded, size: 18),
                    label: const Text(
                      'Share',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 15,
                        fontWeight: FontWeight.w700,
                      ),
                    ),
                    style: OutlinedButton.styleFrom(
                      foregroundColor: _ink,
                      side: const BorderSide(color: _ink, width: 1.3),
                      shape: RoundedRectangleBorder(
                        borderRadius: BorderRadius.circular(14),
                      ),
                    ),
                  ),
                ),
              ),
              const SizedBox(width: 12),
              Expanded(
                child: SizedBox(
                  height: 52,
                  child: FilledButton.icon(
                    onPressed: onDownload,
                    icon: const Icon(Icons.download_rounded, size: 18),
                    label: const Text(
                      'Download',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 15,
                        fontWeight: FontWeight.w700,
                      ),
                    ),
                    style: FilledButton.styleFrom(
                      backgroundColor: _brand,
                      foregroundColor: _canvas,
                      shape: RoundedRectangleBorder(
                        borderRadius: BorderRadius.circular(14),
                      ),
                    ),
                  ),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

class _Line {
  const _Line(this.name, this.variant, this.qty, this.unit);
  final String name;
  final String variant;
  final int qty;
  final int unit;
}

/// A full-width perforated rule (dashes + side notches) separating invoice
/// sections, like a tear-off receipt.
class _Perf extends StatelessWidget {
  const _Perf();

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      height: 18,
      width: double.infinity,
      child: CustomPaint(painter: _PerfPainter()),
    );
  }
}

class _PerfPainter extends CustomPainter {
  static const Color _hairline = Color(0xFFD8D8D8);
  static const Color _canvas = Color(0xFFFFFFFF);

  @override
  void paint(Canvas canvas, Size size) {
    final double cy = size.height / 2;

    // Side notches (punch the card edges).
    final Paint notch = Paint()..color = _canvas;
    canvas.drawCircle(Offset(0, cy), 7, notch);
    canvas.drawCircle(Offset(size.width, cy), 7, notch);

    // Dashed rule.
    final Paint dash = Paint()
      ..color = _hairline
      ..strokeWidth = 1.4
      ..strokeCap = StrokeCap.round;
    const double dashW = 6;
    const double gap = 5;
    double x = 10;
    while (x < size.width - 10) {
      canvas.drawLine(Offset(x, cy), Offset(x + dashW, cy), dash);
      x += dashW + gap;
    }
  }

  @override
  bool shouldRepaint(_PerfPainter oldDelegate) => false;
}

Plus bundled 5 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 ecom-order-invoice

2. AI agent (MCP)

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

FAQ

Can I use this invoice screen in a commercial app?

Yes. FlutterKit is free — there is no paid tier, no licence key and no sign-up. Copy the Dart from this page, add it with the CLI, or pull it through MCP, and ship it in a store app. Attribution is not required.

The Download button only fires a callback — how do I actually produce a PDF?

That is deliberate: the screen is backend-agnostic and hands you `onDownload`. Either fetch a PDF your server already generated for the order, or render one client-side — a package like `pdf` plus `printing` lets you rebuild this same layout as a document and hand it to the OS share sheet. Wire the same source to `onShare`.

Do I need any packages or a font file?

No packages at all — it is pure `flutter/material`. The perforated rules are a `CustomPainter`, so there is no SVG or image asset either. The one thing to add is Manrope: declare it in `pubspec.yaml` under the family name `Manrope`, or change the `_font` constant to whatever you already bundle.

How do I change the tax rate or the order the totals are calculated in?

Everything is in `build`: `tax = (subtotal - discount) * 0.08` applies GST to the discounted subtotal and leaves shipping untaxed, then `total` adds shipping back. Swap 0.08 for your rate, and if your jurisdiction taxes shipping, move it inside the tax base. Also update the `'Tax (GST 8%)'` label so the row still matches the maths.

Which Flutter version does this need?

Flutter 3.22 or newer, because the PAID badge uses `Color.withValues(alpha: 0.1)` and the constructor uses `super.key`. On an older SDK replace it with `withOpacity(0.1)` and expand the constructor to `{Key? key, ...}) : super(key: key)`.

Related screens