E-commerce81 views

How to Build a Return Requested Confirmation Screen in Flutter (Full Code + Preview)

The moment after a shopper submits a return is where support tickets are born: they want to know the request went through, what to hand the courier, and when the money comes back. This tutorial builds StyleCart's return-requested screen in Flutter: an 88px `_CheckPainter` success disc, a return-label card with a deterministic 21×21 `_QrPainter` and the `RET-48213-2` ID, a four-step `IntrinsicHeight` timeline driven by a `_StepState` enum, a soft-green refund note, and a Save label / View status footer. Pure Flutter, no images, callbacks only.

Return Requested — E-commerce Flutter UI screen
Live preview — Return Requested, built in pure Flutter.

What you'll build

  • A success check painted by `_CheckPainter` as two green discs and a stroked tick, with every coordinate a fraction of the radius
  • A return-label card whose QR graphic is generated by `_QrPainter` from an integer formula and three finder patterns — no package, no asset
  • A 'What happens next' timeline where `_NodePainter` draws a done / active / todo node plus its connector from a `_StepState` enum
  • A refund-ETA note tinted with `_success.withValues(alpha: 0.07)` and a hairline-bordered footer that ranks View status above Save label

Step-by-step build

1

Create the file

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

Callbacks, tokens, and the timeline as data

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

/// StyleCart — Return Requested.
///
/// Confirms a return request: a painted success check, the return ID, a painted
/// QR return-label card (no network, no glyph), a short status timeline built
/// with the IntrinsicHeight stepper, and a refund-ETA note. CTAs save the label
/// and view the return status.
///
/// Self-contained per CONVENTIONS.md: pure Flutter, bundled Manrope, inline
/// Airbnb-style tokens, own light theme + SafeArea. Painter-only graphics.
/// Exposes callbacks only.
class EcomOrdersReturnConfirmedScreen extends StatelessWidget {
  const EcomOrdersReturnConfirmedScreen({
    super.key,
    this.onClose,
    this.onSaveLabel,
    this.onViewStatus,
  });

  final VoidCallback? onClose;
  final VoidCallback? onSaveLabel;
  final VoidCallback? onViewStatus;

  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 _faint = Color(0xFFC1C1C1);
  static const Color _brand = Color(0xFFFF385C);
  static const Color _success = Color(0xFF2E9E5B);
  static const Color _hairline = Color(0xFFEBEBEB);

  static const List<_Step> _steps = <_Step>[
    _Step('Return requested', 'Today', _StepState.active),
    _Step('Courier pickup', 'Thu, 18 Jun · 9–12', _StepState.todo),
    _Step('Quality check', 'Within 2 days of pickup', _StepState.todo),
    _Step('Refund issued', 'Est. 23–25 Jun', _StepState.todo),
  ];

`EcomOrdersReturnConfirmedScreen` is a `StatelessWidget` with three optional callbacks — `onClose`, `onSaveLabel`, `onViewStatus` — and no state at all, because a confirmation page only reports a result and offers exits. The palette is Airbnb-flavoured: `_brand` coral `#FF385C` for the primary action and the active step, `_success` green `#2E9E5B` reserved for the check and the refund note, and three greys (`_ink` `#222222`, `_muted` `#6A6A6A`, `_faint` `#C1C1C1`) that grade text by importance. The interesting bit is `_steps`: a `static const List<_Step>` of four label/time/state triples. Only the first is `_StepState.active`; the rest are `todo`. Keeping the timeline as data means swapping in server statuses later is a one-list change rather than a widget rewrite.

Forced light theme, close button, and the scrolling body

ecom_orders_return_confirmed_screen.dart
  @override
  Widget build(BuildContext context) {
    return Theme(
      data: ThemeData.light(useMaterial3: true),
      child: Scaffold(
        backgroundColor: _canvas,
        body: SafeArea(
          child: Column(
            children: <Widget>[
              Align(
                alignment: Alignment.centerRight,
                child: IconButton(
                  onPressed: onClose,
                  icon: const Icon(Icons.close_rounded, size: 22, color: _ink),
                ),
              ),
              Expanded(
                child: ListView(
                  padding: const EdgeInsets.fromLTRB(24, 4, 24, 24),
                  children: <Widget>[
                    Center(
                      child: SizedBox(
                        width: 88,
                        height: 88,
                        child: CustomPaint(painter: _CheckPainter()),
                      ),
                    ),
                    const SizedBox(height: 20),
                    const Text(
                      'Return requested',
                      textAlign: TextAlign.center,
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 23,
                        fontWeight: FontWeight.w800,
                        letterSpacing: -0.4,
                        color: _ink,
                      ),
                    ),
                    const SizedBox(height: 8),
                    const Text(
                      'We’ll collect 2 items from your address. Keep them in '
                      'the original packaging if you can.',
                      textAlign: TextAlign.center,
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 14.5,
                        fontWeight: FontWeight.w500,
                        height: 1.5,
                        color: _muted,
                      ),
                    ),
                    const SizedBox(height: 24),
                    _labelCard(),
                    const SizedBox(height: 20),
                    _timeline(),
                    const SizedBox(height: 16),
                    _refundNote(),
                  ],
                ),
              ),
              _footer(),
            ],
          ),
        ),
      ),
    );
  }

`build` wraps everything in `Theme(data: ThemeData.light(useMaterial3: true))` so the screen looks identical inside a dark-themed host app — the white `_canvas` and dark `_ink` are hard-coded, so inheriting a dark theme would break contrast. Inside `SafeArea`, a `Column` holds three parts: an `Align(centerRight)` close `IconButton` (a close glyph, not a back arrow, because this screen ends the return flow), an `Expanded` `ListView` for the content, and the `_footer()` pinned beneath. The `ListView` starts with an 88×88 `CustomPaint(painter: _CheckPainter())` centred, then the 23px `w800` headline with `letterSpacing: -0.4`, then body copy at 14.5px with `height: 1.5` telling the shopper two items will be collected. Using a `ListView` rather than a `Column` means the label card, timeline and refund note still fit on a small phone by scrolling.

The return-label card with a painted QR

ecom_orders_return_confirmed_screen.dart
  Widget _labelCard() {
    return Container(
      padding: const EdgeInsets.all(18),
      decoration: BoxDecoration(
        color: _canvas,
        borderRadius: BorderRadius.circular(18),
        border: Border.all(color: _hairline),
        boxShadow: const <BoxShadow>[
          BoxShadow(
            color: Color(0x0F000000),
            blurRadius: 18,
            offset: Offset(0, 8),
          ),
        ],
      ),
      child: Row(
        children: <Widget>[
          Container(
            width: 92,
            height: 92,
            padding: const EdgeInsets.all(8),
            decoration: BoxDecoration(
              color: _canvas,
              borderRadius: BorderRadius.circular(12),
              border: Border.all(color: _hairline),
            ),
            child: CustomPaint(painter: _QrPainter()),
          ),
          const SizedBox(width: 16),
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                const Text(
                  'Return label',
                  style: TextStyle(
                    fontFamily: _font,
                    fontSize: 12.5,
                    fontWeight: FontWeight.w600,
                    color: _muted,
                  ),
                ),
                const SizedBox(height: 4),
                const Text(
                  'RET-48213-2',
                  style: TextStyle(
                    fontFamily: _font,
                    fontSize: 18,
                    fontWeight: FontWeight.w800,
                    letterSpacing: 0.5,
                    color: _ink,
                  ),
                ),
                const SizedBox(height: 8),
                Text(
                  'Show this to the courier or attach it to the parcel.',
                  style: const TextStyle(
                    fontFamily: _font,
                    fontSize: 12.5,
                    fontWeight: FontWeight.w500,
                    height: 1.35,
                    color: _muted,
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }

`_labelCard` is a white `Container` with an 18px radius, a `_hairline` `#EBEBEB` border and a single soft shadow (`Color(0x0F000000)`, `blurRadius: 18`, offset 8px down) — the only elevated surface on the page, which is what makes it read as the thing you would show a courier. Inside, a `Row` puts a 92×92 bordered box on the left with 8px padding and `CustomPaint(painter: _QrPainter())`; the padding is what stops the QR's finder squares from touching the border. The `Expanded` column on the right stacks a 12.5px muted 'Return label' eyebrow, the ID `RET-48213-2` at 18px `w800` with `letterSpacing: 0.5` (wider tracking makes an alphanumeric code easier to read aloud), and a one-line instruction at `height: 1.35`. Nothing here is tappable; the footer's Save label button owns that action.

The IntrinsicHeight step timeline

ecom_orders_return_confirmed_screen.dart
  Widget _timeline() {
    return Container(
      padding: const EdgeInsets.fromLTRB(16, 18, 16, 6),
      decoration: BoxDecoration(
        color: _canvas,
        borderRadius: BorderRadius.circular(16),
        border: Border.all(color: _hairline),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          const Padding(
            padding: EdgeInsets.only(left: 2, bottom: 12),
            child: Text(
              'What happens next',
              style: TextStyle(
                fontFamily: _font,
                fontSize: 13,
                fontWeight: FontWeight.w800,
                letterSpacing: 0.3,
                color: _ink,
              ),
            ),
          ),
          for (int i = 0; i < _steps.length; i++)
            _stepTile(_steps[i], i == _steps.length - 1),
        ],
      ),
    );
  }

  Widget _stepTile(_Step s, bool last) {
    return IntrinsicHeight(
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: <Widget>[
          SizedBox(
            width: 26,
            child: CustomPaint(painter: _NodePainter(s.state, last)),
          ),
          Expanded(
            child: Padding(
              padding: EdgeInsets.only(left: 12, bottom: last ? 6 : 22),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: <Widget>[
                  Text(
                    s.label,
                    style: TextStyle(
                      fontFamily: _font,
                      fontSize: 14,
                      fontWeight: s.state == _StepState.todo
                          ? FontWeight.w600
                          : FontWeight.w700,
                      color: s.state == _StepState.todo ? _muted : _ink,
                    ),
                  ),
                  const SizedBox(height: 2),
                  Text(
                    s.time,
                    style: TextStyle(
                      fontFamily: _font,
                      fontSize: 12.5,
                      fontWeight: FontWeight.w500,
                      color: s.state == _StepState.active ? _brand : _faint,
                    ),
                  ),
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }

`_timeline` is a bordered panel headed 'What happens next' and then a collection-for that calls `_stepTile(_steps[i], i == _steps.length - 1)` so the last tile knows to skip its connector. Each tile is wrapped in `IntrinsicHeight` with `crossAxisAlignment: CrossAxisAlignment.stretch`: that forces the 26px-wide `CustomPaint` column on the left to be exactly as tall as the text column on the right, which is how `_NodePainter` can draw a connector to `size.height` and have it meet the next node regardless of how many lines the label wraps to. The text column's bottom padding is `last ? 6 : 22`, so the spacing lives inside the painted area and the connector spans it. Typography follows state: a `todo` step's label drops from `w700` `_ink` to `w600` `_muted`, and the time line is `_brand` coral only for the `active` step, `_faint` otherwise — so 'Today' is the single coloured word in the panel.

Refund note and the two-button footer

ecom_orders_return_confirmed_screen.dart
  Widget _refundNote() {
    return Container(
      padding: const EdgeInsets.all(14),
      decoration: BoxDecoration(
        color: _success.withValues(alpha: 0.07),
        borderRadius: BorderRadius.circular(14),
        border: Border.all(color: _success.withValues(alpha: 0.2)),
      ),
      child: Row(
        children: const <Widget>[
          Icon(Icons.savings_outlined, size: 20, color: _success),
          SizedBox(width: 12),
          Expanded(
            child: Text(
              'Your refund of \$214 starts once the items pass the quality '
              'check — usually 2–3 days after pickup.',
              style: TextStyle(
                fontFamily: _font,
                fontSize: 12.5,
                fontWeight: FontWeight.w600,
                height: 1.4,
                color: _ink,
              ),
            ),
          ),
        ],
      ),
    );
  }

  Widget _footer() {
    return Container(
      decoration: const BoxDecoration(
        color: _canvas,
        border: Border(top: BorderSide(color: _hairline)),
      ),
      child: SafeArea(
        top: false,
        child: Padding(
          padding: const EdgeInsets.fromLTRB(24, 12, 24, 10),
          child: Row(
            children: <Widget>[
              Expanded(
                child: SizedBox(
                  height: 52,
                  child: OutlinedButton(
                    onPressed: onSaveLabel,
                    style: OutlinedButton.styleFrom(
                      foregroundColor: _ink,
                      side: const BorderSide(color: _hairline),
                      shape: RoundedRectangleBorder(
                        borderRadius: BorderRadius.circular(14),
                      ),
                    ),
                    child: const Text(
                      'Save label',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 15,
                        fontWeight: FontWeight.w700,
                      ),
                    ),
                  ),
                ),
              ),
              const SizedBox(width: 12),
              Expanded(
                child: SizedBox(
                  height: 52,
                  child: FilledButton(
                    onPressed: onViewStatus,
                    style: FilledButton.styleFrom(
                      backgroundColor: _brand,
                      foregroundColor: _canvas,
                      shape: RoundedRectangleBorder(
                        borderRadius: BorderRadius.circular(14),
                      ),
                    ),
                    child: const Text(
                      'View status',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 15,
                        fontWeight: FontWeight.w700,
                      ),
                    ),
                  ),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

`_refundNote` is the one green surface: `_success.withValues(alpha: 0.07)` fill, a `0.2`-alpha green border, a `savings_outlined` icon, and 12.5px `w600` copy stating the `$214` refund starts after the quality check. The whole `Row` is `const`, so it costs nothing to rebuild. `_footer` is a `Container` with a top `_hairline` border wrapping `SafeArea(top: false)` — the border sits outside the safe area, so the white background runs under the home indicator while the buttons stay above it. Two `Expanded` 52px buttons share the row: an `OutlinedButton` 'Save label' with `_ink` foreground and a hairline side, and a `FilledButton` 'View status' in `_brand` coral. They are deliberately unequal — viewing status is the thing most people do next, and saving the label is a one-off. Both use a 14px `RoundedRectangleBorder` to match the cards above.

The step model and _NodePainter

ecom_orders_return_confirmed_screen.dart
enum _StepState { done, active, todo }

class _Step {
  const _Step(this.label, this.time, this.state);
  final String label;
  final String time;
  final _StepState state;
}

/// Timeline node + connector (same family as the other order steppers).
class _NodePainter extends CustomPainter {
  _NodePainter(this.state, this.last);
  final _StepState state;
  final bool last;

  static const Color _brand = Color(0xFFFF385C);
  static const Color _success = Color(0xFF2E9E5B);
  static const Color _faint = Color(0xFFC1C1C1);
  static const Color _hairline = Color(0xFFEBEBEB);

  @override
  void paint(Canvas canvas, Size size) {
    final double cx = size.width / 2;
    const double cy = 11;
    const double r = 11;

    if (!last) {
      canvas.drawLine(
        Offset(cx, cy + r),
        Offset(cx, size.height),
        Paint()
          ..color = state == _StepState.done ? _success : _hairline
          ..strokeWidth = 2,
      );
    }

    switch (state) {
      case _StepState.done:
        canvas.drawCircle(Offset(cx, cy), r, Paint()..color = _success);
        final Path check = Path()
          ..moveTo(cx - 4.6, cy + 0.3)
          ..lineTo(cx - 1.4, cy + 3.4)
          ..lineTo(cx + 4.8, cy - 3.6);
        canvas.drawPath(
          check,
          Paint()
            ..style = PaintingStyle.stroke
            ..strokeWidth = 2.2
            ..strokeCap = StrokeCap.round
            ..strokeJoin = StrokeJoin.round
            ..color = const Color(0xFFFFFFFF),
        );
      case _StepState.active:
        canvas.drawCircle(
          Offset(cx, cy),
          r,
          Paint()..color = _brand.withValues(alpha: 0.16),
        );
        canvas.drawCircle(Offset(cx, cy), 4.5, Paint()..color = _brand);
      case _StepState.todo:
        canvas.drawCircle(
          Offset(cx, cy),
          r - 1,
          Paint()
            ..style = PaintingStyle.stroke
            ..strokeWidth = 2
            ..color = _faint,
        );
    }
  }

  @override
  bool shouldRepaint(_NodePainter old) =>
      old.state != state || old.last != last;
}

`_StepState { done, active, todo }` and the tiny `_Step` value class are the entire data model. `_NodePainter` takes a state and a `last` flag. It first draws the connector — a 2px line from `cy + r` (just below the node) to `size.height`, green if the step is `done`, hairline grey otherwise — and skips it when `last` is true. Then a `switch` on state with Dart 3's no-`break` syntax: `done` fills an 11px-radius `_success` circle and strokes a three-point white tick with `StrokeCap.round`; `active` paints a 16%-alpha coral halo at full radius with a solid 4.5px coral dot inside; `todo` strokes an `r - 1` ring in `_faint` so the 2px stroke stays inside the same 22px footprint. `cy` is a constant 11, which lines the node up with the first line of the 14px label. `shouldRepaint` compares `state` and `last`, so a status update repaints only the tiles that changed.

Painting the success check

ecom_orders_return_confirmed_screen.dart
/// A clean success check on a soft green disc — no emoji glyph.
class _CheckPainter extends CustomPainter {
  const _CheckPainter();

  static const Color _success = Color(0xFF2E9E5B);

  @override
  void paint(Canvas canvas, Size size) {
    final Offset c = Offset(size.width / 2, size.height / 2);
    final double r = size.width / 2;
    canvas.drawCircle(
        c, r, Paint()..color = _success.withValues(alpha: 0.12));
    canvas.drawCircle(c, r * 0.66, Paint()..color = _success);
    final Path check = Path()
      ..moveTo(c.dx - r * 0.26, c.dy + r * 0.02)
      ..lineTo(c.dx - r * 0.07, c.dy + r * 0.22)
      ..lineTo(c.dx + r * 0.30, c.dy - r * 0.22);
    canvas.drawPath(
      check,
      Paint()
        ..style = PaintingStyle.stroke
        ..strokeWidth = r * 0.11
        ..strokeCap = StrokeCap.round
        ..strokeJoin = StrokeJoin.round
        ..color = const Color(0xFFFFFFFF),
    );
  }

  @override
  bool shouldRepaint(_CheckPainter old) => false;
}

`_CheckPainter` is a `const` painter with no inputs, so `shouldRepaint` simply returns `false`. It draws three things from a centre `c` and radius `r = size.width / 2`: a full-size disc at `_success.withValues(alpha: 0.12)` for a soft halo, a solid `_success` disc at `r * 0.66`, and a tick `Path` whose three points are offsets like `c.dx - r * 0.26` and `c.dy + r * 0.22`. Because every coordinate and the `strokeWidth` (`r * 0.11`) are fractions of `r`, the same painter renders crisply in the 88px box used here or in a 40px list badge. `StrokeCap.round` and `StrokeJoin.round` give the tick soft ends and a soft elbow, which is what keeps it from looking like a font glyph — the file comment specifically calls out 'no emoji glyph'.

A deterministic QR-style label painter

ecom_orders_return_confirmed_screen.dart
/// A deterministic painted QR-style label (21×21 modules, three finder
/// patterns, brand centre) — recognisable as a scan label, no network/asset.
class _QrPainter extends CustomPainter {
  const _QrPainter();

  static const Color _ink = Color(0xFF222222);
  static const Color _brand = Color(0xFFFF385C);

  bool _inFinder(int x, int y) =>
      _finder(x, y) || _finder(x - 14, y) || _finder(x, y - 14);

  bool _finder(int x, int y) => x >= 0 && x < 7 && y >= 0 && y < 7;

  @override
  void paint(Canvas canvas, Size size) {
    const int n = 21;
    final double cell = size.width / n;
    final Paint p = Paint()..color = _ink;

    for (int y = 0; y < n; y++) {
      for (int x = 0; x < n; x++) {
        if (_inFinder(x, y)) continue;
        final bool on = ((x * 7 + y * 13 + x * y * 3) % 5) == 0;
        if (on) {
          canvas.drawRect(
            Rect.fromLTWH(x * cell, y * cell, cell, cell),
            p,
          );
        }
      }
    }

    // Finder patterns.
    void finderAt(double gx, double gy) {
      final Rect outer = Rect.fromLTWH(gx, gy, cell * 7, cell * 7);
      canvas.drawRect(outer, p);
      canvas.drawRect(
        Rect.fromLTWH(gx + cell, gy + cell, cell * 5, cell * 5),
        Paint()..color = const Color(0xFFFFFFFF),
      );
      canvas.drawRect(
        Rect.fromLTWH(gx + cell * 2, gy + cell * 2, cell * 3, cell * 3),
        p,
      );
    }

    finderAt(0, 0);
    finderAt(cell * 14, 0);
    finderAt(0, cell * 14);

    // Brand centre module.
    canvas.drawRect(
      Rect.fromLTWH(cell * 9, cell * 9, cell * 3, cell * 3),
      Paint()..color = _brand,
    );
  }

  @override
  bool shouldRepaint(_QrPainter old) => false;
}

`_QrPainter` produces something that scans as 'a QR code' to the eye without any encoding library. It divides the square into `n = 21` cells, then loops every cell and fills it when `((x * 7 + y * 13 + x * y * 3) % 5) == 0` — a fixed arithmetic hash, so the pattern is identical on every frame and every device, and the painter can stay `const` with `shouldRepaint => false`. `_inFinder` masks out the three 7×7 corners (`x, y`, `x - 14`, `y - 14`) so the random modules never collide with them; the local `finderAt` closure then draws each finder as ink 7×7, white 5×5, ink 3×3 — the real QR finder structure. Finally a 3×3 `_brand` coral block at cell 9 stamps the centre, echoing the brand mark that real branded QR codes carry. Swap this painter for a `qr_flutter` widget when you have a real label payload; the 92px box and 8px quiet zone already fit it.

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 — Return Requested.
///
/// Confirms a return request: a painted success check, the return ID, a painted
/// QR return-label card (no network, no glyph), a short status timeline built
/// with the IntrinsicHeight stepper, and a refund-ETA note. CTAs save the label
/// and view the return status.
///
/// Self-contained per CONVENTIONS.md: pure Flutter, bundled Manrope, inline
/// Airbnb-style tokens, own light theme + SafeArea. Painter-only graphics.
/// Exposes callbacks only.
class EcomOrdersReturnConfirmedScreen extends StatelessWidget {
  const EcomOrdersReturnConfirmedScreen({
    super.key,
    this.onClose,
    this.onSaveLabel,
    this.onViewStatus,
  });

  final VoidCallback? onClose;
  final VoidCallback? onSaveLabel;
  final VoidCallback? onViewStatus;

  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 _faint = Color(0xFFC1C1C1);
  static const Color _brand = Color(0xFFFF385C);
  static const Color _success = Color(0xFF2E9E5B);
  static const Color _hairline = Color(0xFFEBEBEB);

  static const List<_Step> _steps = <_Step>[
    _Step('Return requested', 'Today', _StepState.active),
    _Step('Courier pickup', 'Thu, 18 Jun · 9–12', _StepState.todo),
    _Step('Quality check', 'Within 2 days of pickup', _StepState.todo),
    _Step('Refund issued', 'Est. 23–25 Jun', _StepState.todo),
  ];

  @override
  Widget build(BuildContext context) {
    return Theme(
      data: ThemeData.light(useMaterial3: true),
      child: Scaffold(
        backgroundColor: _canvas,
        body: SafeArea(
          child: Column(
            children: <Widget>[
              Align(
                alignment: Alignment.centerRight,
                child: IconButton(
                  onPressed: onClose,
                  icon: const Icon(Icons.close_rounded, size: 22, color: _ink),
                ),
              ),
              Expanded(
                child: ListView(
                  padding: const EdgeInsets.fromLTRB(24, 4, 24, 24),
                  children: <Widget>[
                    Center(
                      child: SizedBox(
                        width: 88,
                        height: 88,
                        child: CustomPaint(painter: _CheckPainter()),
                      ),
                    ),
                    const SizedBox(height: 20),
                    const Text(
                      'Return requested',
                      textAlign: TextAlign.center,
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 23,
                        fontWeight: FontWeight.w800,
                        letterSpacing: -0.4,
                        color: _ink,
                      ),
                    ),
                    const SizedBox(height: 8),
                    const Text(
                      'We’ll collect 2 items from your address. Keep them in '
                      'the original packaging if you can.',
                      textAlign: TextAlign.center,
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 14.5,
                        fontWeight: FontWeight.w500,
                        height: 1.5,
                        color: _muted,
                      ),
                    ),
                    const SizedBox(height: 24),
                    _labelCard(),
                    const SizedBox(height: 20),
                    _timeline(),
                    const SizedBox(height: 16),
                    _refundNote(),
                  ],
                ),
              ),
              _footer(),
            ],
          ),
        ),
      ),
    );
  }

  Widget _labelCard() {
    return Container(
      padding: const EdgeInsets.all(18),
      decoration: BoxDecoration(
        color: _canvas,
        borderRadius: BorderRadius.circular(18),
        border: Border.all(color: _hairline),
        boxShadow: const <BoxShadow>[
          BoxShadow(
            color: Color(0x0F000000),
            blurRadius: 18,
            offset: Offset(0, 8),
          ),
        ],
      ),
      child: Row(
        children: <Widget>[
          Container(
            width: 92,
            height: 92,
            padding: const EdgeInsets.all(8),
            decoration: BoxDecoration(
              color: _canvas,
              borderRadius: BorderRadius.circular(12),
              border: Border.all(color: _hairline),
            ),
            child: CustomPaint(painter: _QrPainter()),
          ),
          const SizedBox(width: 16),
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                const Text(
                  'Return label',
                  style: TextStyle(
                    fontFamily: _font,
                    fontSize: 12.5,
                    fontWeight: FontWeight.w600,
                    color: _muted,
                  ),
                ),
                const SizedBox(height: 4),
                const Text(
                  'RET-48213-2',
                  style: TextStyle(
                    fontFamily: _font,
                    fontSize: 18,
                    fontWeight: FontWeight.w800,
                    letterSpacing: 0.5,
                    color: _ink,
                  ),
                ),
                const SizedBox(height: 8),
                Text(
                  'Show this to the courier or attach it to the parcel.',
                  style: const TextStyle(
                    fontFamily: _font,
                    fontSize: 12.5,
                    fontWeight: FontWeight.w500,
                    height: 1.35,
                    color: _muted,
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }

  Widget _timeline() {
    return Container(
      padding: const EdgeInsets.fromLTRB(16, 18, 16, 6),
      decoration: BoxDecoration(
        color: _canvas,
        borderRadius: BorderRadius.circular(16),
        border: Border.all(color: _hairline),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          const Padding(
            padding: EdgeInsets.only(left: 2, bottom: 12),
            child: Text(
              'What happens next',
              style: TextStyle(
                fontFamily: _font,
                fontSize: 13,
                fontWeight: FontWeight.w800,
                letterSpacing: 0.3,
                color: _ink,
              ),
            ),
          ),
          for (int i = 0; i < _steps.length; i++)
            _stepTile(_steps[i], i == _steps.length - 1),
        ],
      ),
    );
  }

  Widget _stepTile(_Step s, bool last) {
    return IntrinsicHeight(
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: <Widget>[
          SizedBox(
            width: 26,
            child: CustomPaint(painter: _NodePainter(s.state, last)),
          ),
          Expanded(
            child: Padding(
              padding: EdgeInsets.only(left: 12, bottom: last ? 6 : 22),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: <Widget>[
                  Text(
                    s.label,
                    style: TextStyle(
                      fontFamily: _font,
                      fontSize: 14,
                      fontWeight: s.state == _StepState.todo
                          ? FontWeight.w600
                          : FontWeight.w700,
                      color: s.state == _StepState.todo ? _muted : _ink,
                    ),
                  ),
                  const SizedBox(height: 2),
                  Text(
                    s.time,
                    style: TextStyle(
                      fontFamily: _font,
                      fontSize: 12.5,
                      fontWeight: FontWeight.w500,
                      color: s.state == _StepState.active ? _brand : _faint,
                    ),
                  ),
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }

  Widget _refundNote() {
    return Container(
      padding: const EdgeInsets.all(14),
      decoration: BoxDecoration(
        color: _success.withValues(alpha: 0.07),
        borderRadius: BorderRadius.circular(14),
        border: Border.all(color: _success.withValues(alpha: 0.2)),
      ),
      child: Row(
        children: const <Widget>[
          Icon(Icons.savings_outlined, size: 20, color: _success),
          SizedBox(width: 12),
          Expanded(
            child: Text(
              'Your refund of \$214 starts once the items pass the quality '
              'check — usually 2–3 days after pickup.',
              style: TextStyle(
                fontFamily: _font,
                fontSize: 12.5,
                fontWeight: FontWeight.w600,
                height: 1.4,
                color: _ink,
              ),
            ),
          ),
        ],
      ),
    );
  }

  Widget _footer() {
    return Container(
      decoration: const BoxDecoration(
        color: _canvas,
        border: Border(top: BorderSide(color: _hairline)),
      ),
      child: SafeArea(
        top: false,
        child: Padding(
          padding: const EdgeInsets.fromLTRB(24, 12, 24, 10),
          child: Row(
            children: <Widget>[
              Expanded(
                child: SizedBox(
                  height: 52,
                  child: OutlinedButton(
                    onPressed: onSaveLabel,
                    style: OutlinedButton.styleFrom(
                      foregroundColor: _ink,
                      side: const BorderSide(color: _hairline),
                      shape: RoundedRectangleBorder(
                        borderRadius: BorderRadius.circular(14),
                      ),
                    ),
                    child: const Text(
                      'Save label',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 15,
                        fontWeight: FontWeight.w700,
                      ),
                    ),
                  ),
                ),
              ),
              const SizedBox(width: 12),
              Expanded(
                child: SizedBox(
                  height: 52,
                  child: FilledButton(
                    onPressed: onViewStatus,
                    style: FilledButton.styleFrom(
                      backgroundColor: _brand,
                      foregroundColor: _canvas,
                      shape: RoundedRectangleBorder(
                        borderRadius: BorderRadius.circular(14),
                      ),
                    ),
                    child: const Text(
                      'View status',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 15,
                        fontWeight: FontWeight.w700,
                      ),
                    ),
                  ),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

enum _StepState { done, active, todo }

class _Step {
  const _Step(this.label, this.time, this.state);
  final String label;
  final String time;
  final _StepState state;
}

/// Timeline node + connector (same family as the other order steppers).
class _NodePainter extends CustomPainter {
  _NodePainter(this.state, this.last);
  final _StepState state;
  final bool last;

  static const Color _brand = Color(0xFFFF385C);
  static const Color _success = Color(0xFF2E9E5B);
  static const Color _faint = Color(0xFFC1C1C1);
  static const Color _hairline = Color(0xFFEBEBEB);

  @override
  void paint(Canvas canvas, Size size) {
    final double cx = size.width / 2;
    const double cy = 11;
    const double r = 11;

    if (!last) {
      canvas.drawLine(
        Offset(cx, cy + r),
        Offset(cx, size.height),
        Paint()
          ..color = state == _StepState.done ? _success : _hairline
          ..strokeWidth = 2,
      );
    }

    switch (state) {
      case _StepState.done:
        canvas.drawCircle(Offset(cx, cy), r, Paint()..color = _success);
        final Path check = Path()
          ..moveTo(cx - 4.6, cy + 0.3)
          ..lineTo(cx - 1.4, cy + 3.4)
          ..lineTo(cx + 4.8, cy - 3.6);
        canvas.drawPath(
          check,
          Paint()
            ..style = PaintingStyle.stroke
            ..strokeWidth = 2.2
            ..strokeCap = StrokeCap.round
            ..strokeJoin = StrokeJoin.round
            ..color = const Color(0xFFFFFFFF),
        );
      case _StepState.active:
        canvas.drawCircle(
          Offset(cx, cy),
          r,
          Paint()..color = _brand.withValues(alpha: 0.16),
        );
        canvas.drawCircle(Offset(cx, cy), 4.5, Paint()..color = _brand);
      case _StepState.todo:
        canvas.drawCircle(
          Offset(cx, cy),
          r - 1,
          Paint()
            ..style = PaintingStyle.stroke
            ..strokeWidth = 2
            ..color = _faint,
        );
    }
  }

  @override
  bool shouldRepaint(_NodePainter old) =>
      old.state != state || old.last != last;
}

/// A clean success check on a soft green disc — no emoji glyph.
class _CheckPainter extends CustomPainter {
  const _CheckPainter();

  static const Color _success = Color(0xFF2E9E5B);

  @override
  void paint(Canvas canvas, Size size) {
    final Offset c = Offset(size.width / 2, size.height / 2);
    final double r = size.width / 2;
    canvas.drawCircle(
        c, r, Paint()..color = _success.withValues(alpha: 0.12));
    canvas.drawCircle(c, r * 0.66, Paint()..color = _success);
    final Path check = Path()
      ..moveTo(c.dx - r * 0.26, c.dy + r * 0.02)
      ..lineTo(c.dx - r * 0.07, c.dy + r * 0.22)
      ..lineTo(c.dx + r * 0.30, c.dy - r * 0.22);
    canvas.drawPath(
      check,
      Paint()
        ..style = PaintingStyle.stroke
        ..strokeWidth = r * 0.11
        ..strokeCap = StrokeCap.round
        ..strokeJoin = StrokeJoin.round
        ..color = const Color(0xFFFFFFFF),
    );
  }

  @override
  bool shouldRepaint(_CheckPainter old) => false;
}

/// A deterministic painted QR-style label (21×21 modules, three finder
/// patterns, brand centre) — recognisable as a scan label, no network/asset.
class _QrPainter extends CustomPainter {
  const _QrPainter();

  static const Color _ink = Color(0xFF222222);
  static const Color _brand = Color(0xFFFF385C);

  bool _inFinder(int x, int y) =>
      _finder(x, y) || _finder(x - 14, y) || _finder(x, y - 14);

  bool _finder(int x, int y) => x >= 0 && x < 7 && y >= 0 && y < 7;

  @override
  void paint(Canvas canvas, Size size) {
    const int n = 21;
    final double cell = size.width / n;
    final Paint p = Paint()..color = _ink;

    for (int y = 0; y < n; y++) {
      for (int x = 0; x < n; x++) {
        if (_inFinder(x, y)) continue;
        final bool on = ((x * 7 + y * 13 + x * y * 3) % 5) == 0;
        if (on) {
          canvas.drawRect(
            Rect.fromLTWH(x * cell, y * cell, cell, cell),
            p,
          );
        }
      }
    }

    // Finder patterns.
    void finderAt(double gx, double gy) {
      final Rect outer = Rect.fromLTWH(gx, gy, cell * 7, cell * 7);
      canvas.drawRect(outer, p);
      canvas.drawRect(
        Rect.fromLTWH(gx + cell, gy + cell, cell * 5, cell * 5),
        Paint()..color = const Color(0xFFFFFFFF),
      );
      canvas.drawRect(
        Rect.fromLTWH(gx + cell * 2, gy + cell * 2, cell * 3, cell * 3),
        p,
      );
    }

    finderAt(0, 0);
    finderAt(cell * 14, 0);
    finderAt(0, cell * 14);

    // Brand centre module.
    canvas.drawRect(
      Rect.fromLTWH(cell * 9, cell * 9, cell * 3, cell * 3),
      Paint()..color = _brand,
    );
  }

  @override
  bool shouldRepaint(_QrPainter old) => 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-orders-return-confirmed

2. AI agent (MCP)

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

FAQ

Can I use this return-confirmation screen in a commercial app?

Yes. FlutterKit screens are free for personal and commercial projects under an MIT-style licence — no key, no attribution, no sign-up. Copy the code from this page or run `flutterkit add ecom-orders-return-confirmed` and ship it in your store app.

Does the screen need any pub packages or fonts?

No packages — it is pure `package:flutter/material.dart`. The QR, the success check and the timeline nodes are all `CustomPainter`s. The only external asset is the Manrope font, which `flutterkit add ecom-orders-return-confirmed` bundles and registers in `pubspec.yaml` for you.

How do I feed real return statuses into the timeline?

Replace the `static const List<_Step> _steps` with a constructor parameter (say `List<_Step> steps`) and build it from your API — each item needs a label, a time string and a `_StepState`. Mark completed stages `done`, the current one `active`, the rest `todo`; `_NodePainter` already draws the green connector for `done` steps and `shouldRepaint` only redraws tiles whose state changed.

Is the QR code real? Will a courier scan it?

No — `_QrPainter` is a deterministic placeholder: a modulo formula decides which of the 21×21 cells are filled, plus three hand-drawn finder patterns. It looks right in a design and needs no dependency. For a scannable label, drop a `qr_flutter` `QrImageView` (with your return ID as data) into the same 92×92 box in `_labelCard`.

Which Flutter version does this need?

Flutter 3.22 or newer: the refund note, the active node halo and the check disc use `Color.withValues(alpha: x)`, and the constructor uses `super.key`. On an older SDK change each `withValues(alpha: x)` to `withOpacity(x)` and write the constructor as `{Key? key, ...} : super(key: key)`.

Related screens