E-commerce81 views

How to Build a Pickup or Drop-off Return Screen in Flutter (Full Code + Preview)

Returns are where shopping apps quietly lose trust: shoppers want to know how the item gets back, and forcing a single courier flow ignores anyone who walks past a store daily. This tutorial builds StyleCart's return-method screen in Flutter — two radio-style cards toggle between courier pickup and store drop-off, the body swaps between an editable address plus a horizontal slot picker and a store card topped by a fully painted mini-map, and a pinned button relabels itself with the chosen slot before firing a single boolean callback.

Pickup or Drop-off — E-commerce Flutter UI screen
Live preview — Pickup or Drop-off, built in pure Flutter.

What you'll build

  • Two selectable method cards whose radio dot is drawn with nothing but a border-width jump
  • A conditional body that spreads pickup or drop-off widget lists straight into one ListView
  • A horizontal date/time slot picker whose selected chip inverts to a coral fill
  • A store card with a CustomPaint mini-map — block grid, roads, park and a teardrop pin, zero assets
  • A pinned schedule bar that interpolates the chosen slot into its own label

Step-by-step build

1

Create the file

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

Two booleans of state and a slot table

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

/// StyleCart — Pickup or Drop-off.
///
/// Choose how the return travels back: a courier pickup (reveals a pickup
/// address + a horizontal slot picker) or a drop-off at a partner store
/// (reveals a painted mini-map store card). A pinned bar schedules the return.
///
/// Self-contained per CONVENTIONS.md: pure Flutter, bundled Manrope, inline
/// Airbnb-style tokens, own light theme + SafeArea. Painter-only graphics.
/// Exposes callbacks only.
class EcomOrdersReturnMethodScreen extends StatefulWidget {
  const EcomOrdersReturnMethodScreen({
    super.key,
    this.onBack,
    this.onChangeAddress,
    this.onSchedule,
  });

  final VoidCallback? onBack;
  final VoidCallback? onChangeAddress;

  /// Fires with `true` for courier pickup, `false` for store drop-off.
  final ValueChanged<bool>? onSchedule;

  @override
  State<EcomOrdersReturnMethodScreen> createState() =>
      _EcomOrdersReturnMethodScreenState();
}

class _EcomOrdersReturnMethodScreenState
    extends State<EcomOrdersReturnMethodScreen> {
  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 _surface = Color(0xFFF2F2F2);
  static const Color _success = Color(0xFF2E9E5B);
  static const Color _hairline = Color(0xFFEBEBEB);

  bool _pickup = true;
  int _slot = 0;

  static const List<_Slot> _slots = <_Slot>[
    _Slot('Thu', '18 Jun', '9–12'),
    _Slot('Thu', '18 Jun', '12–3'),
    _Slot('Fri', '19 Jun', '9–12'),
    _Slot('Fri', '19 Jun', '3–6'),
    _Slot('Sat', '20 Jun', '10–1'),
  ];

The widget is stateful but its entire state is `bool _pickup` and `int _slot` — which method is chosen and which slot chip is lit. Three callbacks go out: `onBack`, `onChangeAddress`, and `onSchedule`, which is typed `ValueChanged<bool>` so one handler serves both CTAs — `true` means courier pickup, `false` means drop-off. The palette is Airbnb-flavoured: `_brand` coral `0xFFFF385C` for selection and the CTA, with `_success` green kept aside solely for the store's Open-now badge. The five pickup windows live in `static const List<_Slot> _slots` as `(day, date, window)` value objects, so the chip copy is data, not widget code.

One ListView, two bodies

ecom_orders_return_method_screen.dart
  @override
  Widget build(BuildContext context) {
    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>[
                    _methodCard(
                      pickup: true,
                      icon: Icons.local_shipping_outlined,
                      title: 'Courier pickup',
                      subtitle: 'Free · we collect from your address',
                    ),
                    const SizedBox(height: 12),
                    _methodCard(
                      pickup: false,
                      icon: Icons.storefront_outlined,
                      title: 'Drop at a store',
                      subtitle: 'Free · hand it in at a partner store',
                    ),
                    const SizedBox(height: 22),
                    if (_pickup) ..._pickupDetails() else ..._dropDetails(),
                  ],
                ),
              ),
              _scheduleBar(),
            ],
          ),
        ),
      ),
    );
  }

  Widget _header() {
    return Padding(
      padding: const EdgeInsets.fromLTRB(8, 4, 20, 6),
      child: Row(
        children: <Widget>[
          IconButton(
            onPressed: widget.onBack,
            icon: const Icon(Icons.arrow_back_rounded, size: 22, color: _ink),
          ),
          const Text(
            'How should we collect it?',
            style: TextStyle(
              fontFamily: _font,
              fontSize: 18,
              fontWeight: FontWeight.w800,
              letterSpacing: -0.3,
              color: _ink,
            ),
          ),
        ],
      ),
    );
  }

`build` wraps everything in `Theme(data: ThemeData.light(useMaterial3: true))` so the screen keeps its own light look regardless of the host app's theme. The Column stacks the header, an `Expanded` ListView, and `_scheduleBar` as a sibling — outside the scroll view, which is what pins it. The interesting line is 82: `if (_pickup) ..._pickupDetails() else ..._dropDetails()` — both builders return `List<Widget>` and the collection-if spreads whichever applies directly into the ListView's children, so switching method is just a `setState` rebuild, no navigation and no nested scroll view. The header's `fromLTRB(8, 4, 20, 6)` padding is asymmetric because the back `IconButton` brings its own touch padding on the left.

Method cards with a border-trick radio

ecom_orders_return_method_screen.dart
  Widget _methodCard({
    required bool pickup,
    required IconData icon,
    required String title,
    required String subtitle,
  }) {
    final bool on = _pickup == pickup;
    return GestureDetector(
      onTap: () => setState(() => _pickup = pickup),
      child: Container(
        padding: const EdgeInsets.all(16),
        decoration: BoxDecoration(
          color: on ? _brand.withValues(alpha: 0.04) : _canvas,
          borderRadius: BorderRadius.circular(16),
          border: Border.all(
            color: on ? _brand : _hairline,
            width: on ? 1.6 : 1,
          ),
        ),
        child: Row(
          children: <Widget>[
            Container(
              width: 44,
              height: 44,
              alignment: Alignment.center,
              decoration: BoxDecoration(
                color: on ? _brand.withValues(alpha: 0.12) : _surface,
                borderRadius: BorderRadius.circular(12),
              ),
              child: Icon(icon, size: 22, color: on ? _brand : _muted),
            ),
            const SizedBox(width: 14),
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: <Widget>[
                  Text(
                    title,
                    style: const TextStyle(
                      fontFamily: _font,
                      fontSize: 15,
                      fontWeight: FontWeight.w800,
                      color: _ink,
                    ),
                  ),
                  const SizedBox(height: 3),
                  Text(
                    subtitle,
                    style: const TextStyle(
                      fontFamily: _font,
                      fontSize: 12.5,
                      fontWeight: FontWeight.w600,
                      color: _muted,
                    ),
                  ),
                ],
              ),
            ),
            const SizedBox(width: 10),
            Container(
              width: 22,
              height: 22,
              decoration: BoxDecoration(
                shape: BoxShape.circle,
                border: Border.all(
                  color: on ? _brand : _faint,
                  width: on ? 6.5 : 2,
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }

Each `_methodCard` carries its own identity as a `pickup` bool and computes `on = _pickup == pickup`, so tapping either card is one `setState(() => _pickup = pickup)` and the pair can never both be selected. Selection is shown three ways at once: the card fills with `_brand.withValues(alpha: 0.04)`, the border swaps from a 1px `_hairline` to a 1.6px coral, and the leading icon tile re-tints from `_surface` grey to 12% coral. The radio itself is the neat part — a bare 22px circle `Container` whose border width jumps from 2 to 6.5 when active; at that diameter the thick coral ring closes in around a small white hole, reading as a filled radio without a `Radio` widget or any inner child.

Pickup details: address row and slot rail

ecom_orders_return_method_screen.dart
  List<Widget> _pickupDetails() {
    return <Widget>[
      _sectionTitle('Pickup address'),
      const SizedBox(height: 10),
      GestureDetector(
        onTap: widget.onChangeAddress,
        child: Container(
          padding: const EdgeInsets.all(16),
          decoration: BoxDecoration(
            color: _canvas,
            borderRadius: BorderRadius.circular(16),
            border: Border.all(color: _hairline),
          ),
          child: Row(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: <Widget>[
              const Icon(Icons.location_on_outlined, size: 20, color: _faint),
              const SizedBox(width: 12),
              const Expanded(
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: <Widget>[
                    Text(
                      'Home',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 14.5,
                        fontWeight: FontWeight.w800,
                        color: _ink,
                      ),
                    ),
                    SizedBox(height: 3),
                    Text(
                      '24 Larkspur Lane, Apt 7B, Brooklyn, NY 11217',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 13.5,
                        fontWeight: FontWeight.w500,
                        height: 1.35,
                        color: _muted,
                      ),
                    ),
                  ],
                ),
              ),
              SizedBox(width: 8),
              Text(
                'Change',
                style: TextStyle(
                  fontFamily: _font,
                  fontSize: 13,
                  fontWeight: FontWeight.w700,
                  color: _brand,
                ),
              ),
            ],
          ),
        ),
      ),
      const SizedBox(height: 22),
      _sectionTitle('Pickup slot'),
      const SizedBox(height: 10),
      SizedBox(
        height: 96,
        child: ListView.separated(
          scrollDirection: Axis.horizontal,
          itemCount: _slots.length,
          separatorBuilder: (BuildContext c, int idx) =>
              const SizedBox(width: 10),
          itemBuilder: (BuildContext c, int i) => _slotCard(i),
        ),
      ),
    ];
  }

`_pickupDetails` returns a plain widget list: a section title, the address card, another title, and the slot rail. The entire address card sits inside a `GestureDetector` wired to `widget.onChangeAddress`, so the tap target is the full card while the coral 'Change' text is only the visual affordance — far easier to hit than a link-sized target. `crossAxisAlignment: CrossAxisAlignment.start` keeps the location pin aligned with the 'Home' line when the two-line Brooklyn address wraps. The slots render inside a fixed `SizedBox(height: 96)` holding a horizontal `ListView.separated` with 10px separators, so five chips scroll off-screen gracefully on narrow phones instead of shrinking.

Slot chips that invert when chosen

ecom_orders_return_method_screen.dart
  Widget _slotCard(int i) {
    final _Slot s = _slots[i];
    final bool on = _slot == i;
    return GestureDetector(
      onTap: () => setState(() => _slot = i),
      child: Container(
        width: 82,
        padding: const EdgeInsets.symmetric(vertical: 12),
        decoration: BoxDecoration(
          color: on ? _brand : _canvas,
          borderRadius: BorderRadius.circular(14),
          border: Border.all(color: on ? _brand : _hairline),
        ),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              s.day,
              style: TextStyle(
                fontFamily: _font,
                fontSize: 12.5,
                fontWeight: FontWeight.w700,
                color: on ? _canvas : _muted,
              ),
            ),
            const SizedBox(height: 4),
            Text(
              s.date,
              style: TextStyle(
                fontFamily: _font,
                fontSize: 14,
                fontWeight: FontWeight.w800,
                color: on ? _canvas : _ink,
              ),
            ),
            const SizedBox(height: 6),
            Text(
              s.window,
              style: TextStyle(
                fontFamily: _font,
                fontSize: 12,
                fontWeight: FontWeight.w600,
                color: on ? _canvas.withValues(alpha: 0.85) : _faint,
              ),
            ),
          ],
        ),
      ),
    );
  }

`_slotCard(i)` compares `_slot == i` and inverts the whole chip on selection: the background flips from `_canvas` white to solid `_brand`, and every text colour flips to white so contrast survives the fill. The three lines keep their hierarchy even on the coral chip — day at `w700` muted-or-white, date at 14px `w800` as the anchor, and the time window dimmed to `_canvas.withValues(alpha: 0.85)` so it stays subordinate on both states. A fixed `width: 82` with vertical-only padding gives every chip the same footprint, which is what makes the rail scan like a calendar strip.

Drop-off details: painted map card and QR note

ecom_orders_return_method_screen.dart
  List<Widget> _dropDetails() {
    return <Widget>[
      _sectionTitle('Nearest drop-off store'),
      const SizedBox(height: 10),
      Container(
        decoration: BoxDecoration(
          color: _canvas,
          borderRadius: BorderRadius.circular(16),
          border: Border.all(color: _hairline),
        ),
        child: Column(
          children: <Widget>[
            ClipRRect(
              borderRadius: const BorderRadius.vertical(
                  top: Radius.circular(16)),
              child: SizedBox(
                height: 120,
                width: double.infinity,
                child: CustomPaint(painter: _MiniMapPainter()),
              ),
            ),
            Padding(
              padding: const EdgeInsets.all(16),
              child: Row(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: <Widget>[
                  const Expanded(
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: <Widget>[
                        Text(
                          'StyleCart · SoHo',
                          style: TextStyle(
                            fontFamily: _font,
                            fontSize: 15,
                            fontWeight: FontWeight.w800,
                            color: _ink,
                          ),
                        ),
                        SizedBox(height: 3),
                        Text(
                          '112 Spring St · 0.6 mi away',
                          style: TextStyle(
                            fontFamily: _font,
                            fontSize: 13,
                            fontWeight: FontWeight.w600,
                            color: _muted,
                          ),
                        ),
                      ],
                    ),
                  ),
                  Container(
                    padding: const EdgeInsets.symmetric(
                        horizontal: 10, vertical: 6),
                    decoration: BoxDecoration(
                      color: _success.withValues(alpha: 0.12),
                      borderRadius: BorderRadius.circular(8),
                    ),
                    child: const Text(
                      'Open now',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 12,
                        fontWeight: FontWeight.w700,
                        color: _success,
                      ),
                    ),
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
      const SizedBox(height: 14),
      Container(
        padding: const EdgeInsets.all(14),
        decoration: BoxDecoration(
          color: _surface,
          borderRadius: BorderRadius.circular(14),
        ),
        child: Row(
          children: const <Widget>[
            Icon(Icons.info_outline_rounded, size: 18, color: _muted),
            SizedBox(width: 10),
            Expanded(
              child: Text(
                'Drop off any time within 7 days. Bring the QR label we’ll '
                'send to your email.',
                style: TextStyle(
                  fontFamily: _font,
                  fontSize: 12.5,
                  fontWeight: FontWeight.w500,
                  height: 1.4,
                  color: _muted,
                ),
              ),
            ),
          ],
        ),
      ),
    ];
  }

The store card is a bordered Column whose top half is a `SizedBox(height: 120)` filled by `CustomPaint(painter: _MiniMapPainter())`; a `ClipRRect` rounds only the top two corners so the map fuses with the card instead of floating inside it. Below, the store row pairs 'StyleCart · SoHo' and its '0.6 mi away' distance with an 'Open now' pill in `_success` green over a 12% tint — the one place the green token appears, so open status pops against an otherwise coral screen. A separate `_surface` note card with an info icon carries the operational fine print: seven days to drop off, bring the emailed QR label.

A schedule bar that says what it will do

ecom_orders_return_method_screen.dart
  Widget _sectionTitle(String text) {
    return Text(
      text,
      style: const TextStyle(
        fontFamily: _font,
        fontSize: 13,
        fontWeight: FontWeight.w800,
        letterSpacing: 0.3,
        color: _ink,
      ),
    );
  }

  Widget _scheduleBar() {
    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: SizedBox(
            height: 56,
            child: FilledButton(
              onPressed: () => widget.onSchedule?.call(_pickup),
              style: FilledButton.styleFrom(
                backgroundColor: _brand,
                foregroundColor: _canvas,
                minimumSize: const Size.fromHeight(56),
                shape: RoundedRectangleBorder(
                  borderRadius: BorderRadius.circular(16),
                ),
              ),
              child: Text(
                _pickup
                    ? 'Schedule pickup · ${_slots[_slot].day} ${_slots[_slot].date}'
                    : 'Confirm drop-off',
                style: const TextStyle(
                  fontFamily: _font,
                  fontSize: 16,
                  fontWeight: FontWeight.w700,
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

class _Slot {
  const _Slot(this.day, this.date, this.window);
  final String day;
  final String date;
  final String window;
}

`_scheduleBar` is a top-hairlined Container wrapping `SafeArea(top: false)`, so the white background extends beneath the home indicator while the 56px `FilledButton` stays above it. The label is the screen's payoff: in pickup mode it interpolates the live selection — `'Schedule pickup · ${_slots[_slot].day} ${_slots[_slot].date}'` renders as 'Schedule pickup · Thu 18 Jun' — and in drop-off mode it reads 'Confirm drop-off', so the button restates the decision before committing it. `onPressed` calls `widget.onSchedule?.call(_pickup)`, handing the backend the single bool it needs. The tiny `_Slot` class at the end is just three final Strings with a const constructor.

Painting the mini-map

ecom_orders_return_method_screen.dart
/// A lightweight painted map preview: block grid, two roads, a park, and a
/// brand store pin — no tiles, no network.
class _MiniMapPainter extends CustomPainter {
  const _MiniMapPainter();

  @override
  void paint(Canvas canvas, Size size) {
    final Rect r = Offset.zero & size;
    canvas.drawRect(r, Paint()..color = const Color(0xFFEDF0F2));

    final Paint block = Paint()..color = const Color(0xFFE2E7EA);
    const double cell = 34;
    for (double y = -8; y < size.height; y += cell) {
      for (double x = -8; x < size.width; x += cell) {
        canvas.drawRRect(
          RRect.fromRectAndRadius(
            Rect.fromLTWH(x + 4, y + 4, cell - 10, cell - 10),
            const Radius.circular(3),
          ),
          block,
        );
      }
    }

    // Park patch.
    canvas.drawRRect(
      RRect.fromRectAndRadius(
        Rect.fromLTWH(size.width * 0.06, size.height * 0.5,
            size.width * 0.26, size.height * 0.4),
        const Radius.circular(6),
      ),
      Paint()..color = const Color(0xFFCFE6D2),
    );

    // Roads.
    final Paint road = Paint()
      ..color = const Color(0xFFFFFFFF)
      ..strokeWidth = 9
      ..strokeCap = StrokeCap.round;
    canvas.drawLine(
        Offset(0, size.height * 0.62), Offset(size.width, size.height * 0.42),
        road);
    canvas.drawLine(
        Offset(size.width * 0.66, 0), Offset(size.width * 0.78, size.height),
        road);

    // Store pin (teardrop) at the road junction.
    final Offset pin = Offset(size.width * 0.7, size.height * 0.5);
    const double pr = 11;
    final Path tear = Path()
      ..moveTo(pin.dx, pin.dy + pr * 1.7)
      ..quadraticBezierTo(pin.dx - pr, pin.dy + pr * 0.4, pin.dx - pr,
          pin.dy - pr * 0.2)
      ..arcToPoint(Offset(pin.dx + pr, pin.dy - pr * 0.2),
          radius: const Radius.circular(pr))
      ..quadraticBezierTo(
          pin.dx + pr, pin.dy + pr * 0.4, pin.dx, pin.dy + pr * 1.7)
      ..close();
    canvas.drawShadow(tear, const Color(0x55000000), 3, true);
    canvas.drawPath(tear, Paint()..color = const Color(0xFFFF385C));
    canvas.drawCircle(
        Offset(pin.dx, pin.dy - pr * 0.15), 4, Paint()..color = Colors.white);
  }

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

`_MiniMapPainter` fakes a map tile in four layers with no network and no image. It floods the canvas `0xFFEDF0F2`, then loops a 34px grid of small rounded rects starting at `-8` so partial blocks bleed off every edge like real buildings at a viewport boundary. A green `0xFFCFE6D2` RRect sized in fractions of `size` becomes the park, and two 9px white round-cap `drawLine` roads cross the canvas at slight diagonals. The teardrop pin sits at `(0.7w, 0.5h)` — deliberately on the road junction — built as a `Path` of two quadratic béziers meeting an `arcToPoint` for the round head, given depth by `drawShadow`, filled with the same `0xFFFF385C` brand coral, and finished with a 4px white dot. `shouldRepaint` returns `false` since nothing here reads state.

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 — Pickup or Drop-off.
///
/// Choose how the return travels back: a courier pickup (reveals a pickup
/// address + a horizontal slot picker) or a drop-off at a partner store
/// (reveals a painted mini-map store card). A pinned bar schedules the return.
///
/// Self-contained per CONVENTIONS.md: pure Flutter, bundled Manrope, inline
/// Airbnb-style tokens, own light theme + SafeArea. Painter-only graphics.
/// Exposes callbacks only.
class EcomOrdersReturnMethodScreen extends StatefulWidget {
  const EcomOrdersReturnMethodScreen({
    super.key,
    this.onBack,
    this.onChangeAddress,
    this.onSchedule,
  });

  final VoidCallback? onBack;
  final VoidCallback? onChangeAddress;

  /// Fires with `true` for courier pickup, `false` for store drop-off.
  final ValueChanged<bool>? onSchedule;

  @override
  State<EcomOrdersReturnMethodScreen> createState() =>
      _EcomOrdersReturnMethodScreenState();
}

class _EcomOrdersReturnMethodScreenState
    extends State<EcomOrdersReturnMethodScreen> {
  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 _surface = Color(0xFFF2F2F2);
  static const Color _success = Color(0xFF2E9E5B);
  static const Color _hairline = Color(0xFFEBEBEB);

  bool _pickup = true;
  int _slot = 0;

  static const List<_Slot> _slots = <_Slot>[
    _Slot('Thu', '18 Jun', '9–12'),
    _Slot('Thu', '18 Jun', '12–3'),
    _Slot('Fri', '19 Jun', '9–12'),
    _Slot('Fri', '19 Jun', '3–6'),
    _Slot('Sat', '20 Jun', '10–1'),
  ];

  @override
  Widget build(BuildContext context) {
    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>[
                    _methodCard(
                      pickup: true,
                      icon: Icons.local_shipping_outlined,
                      title: 'Courier pickup',
                      subtitle: 'Free · we collect from your address',
                    ),
                    const SizedBox(height: 12),
                    _methodCard(
                      pickup: false,
                      icon: Icons.storefront_outlined,
                      title: 'Drop at a store',
                      subtitle: 'Free · hand it in at a partner store',
                    ),
                    const SizedBox(height: 22),
                    if (_pickup) ..._pickupDetails() else ..._dropDetails(),
                  ],
                ),
              ),
              _scheduleBar(),
            ],
          ),
        ),
      ),
    );
  }

  Widget _header() {
    return Padding(
      padding: const EdgeInsets.fromLTRB(8, 4, 20, 6),
      child: Row(
        children: <Widget>[
          IconButton(
            onPressed: widget.onBack,
            icon: const Icon(Icons.arrow_back_rounded, size: 22, color: _ink),
          ),
          const Text(
            'How should we collect it?',
            style: TextStyle(
              fontFamily: _font,
              fontSize: 18,
              fontWeight: FontWeight.w800,
              letterSpacing: -0.3,
              color: _ink,
            ),
          ),
        ],
      ),
    );
  }

  Widget _methodCard({
    required bool pickup,
    required IconData icon,
    required String title,
    required String subtitle,
  }) {
    final bool on = _pickup == pickup;
    return GestureDetector(
      onTap: () => setState(() => _pickup = pickup),
      child: Container(
        padding: const EdgeInsets.all(16),
        decoration: BoxDecoration(
          color: on ? _brand.withValues(alpha: 0.04) : _canvas,
          borderRadius: BorderRadius.circular(16),
          border: Border.all(
            color: on ? _brand : _hairline,
            width: on ? 1.6 : 1,
          ),
        ),
        child: Row(
          children: <Widget>[
            Container(
              width: 44,
              height: 44,
              alignment: Alignment.center,
              decoration: BoxDecoration(
                color: on ? _brand.withValues(alpha: 0.12) : _surface,
                borderRadius: BorderRadius.circular(12),
              ),
              child: Icon(icon, size: 22, color: on ? _brand : _muted),
            ),
            const SizedBox(width: 14),
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: <Widget>[
                  Text(
                    title,
                    style: const TextStyle(
                      fontFamily: _font,
                      fontSize: 15,
                      fontWeight: FontWeight.w800,
                      color: _ink,
                    ),
                  ),
                  const SizedBox(height: 3),
                  Text(
                    subtitle,
                    style: const TextStyle(
                      fontFamily: _font,
                      fontSize: 12.5,
                      fontWeight: FontWeight.w600,
                      color: _muted,
                    ),
                  ),
                ],
              ),
            ),
            const SizedBox(width: 10),
            Container(
              width: 22,
              height: 22,
              decoration: BoxDecoration(
                shape: BoxShape.circle,
                border: Border.all(
                  color: on ? _brand : _faint,
                  width: on ? 6.5 : 2,
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }

  List<Widget> _pickupDetails() {
    return <Widget>[
      _sectionTitle('Pickup address'),
      const SizedBox(height: 10),
      GestureDetector(
        onTap: widget.onChangeAddress,
        child: Container(
          padding: const EdgeInsets.all(16),
          decoration: BoxDecoration(
            color: _canvas,
            borderRadius: BorderRadius.circular(16),
            border: Border.all(color: _hairline),
          ),
          child: Row(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: <Widget>[
              const Icon(Icons.location_on_outlined, size: 20, color: _faint),
              const SizedBox(width: 12),
              const Expanded(
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: <Widget>[
                    Text(
                      'Home',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 14.5,
                        fontWeight: FontWeight.w800,
                        color: _ink,
                      ),
                    ),
                    SizedBox(height: 3),
                    Text(
                      '24 Larkspur Lane, Apt 7B, Brooklyn, NY 11217',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 13.5,
                        fontWeight: FontWeight.w500,
                        height: 1.35,
                        color: _muted,
                      ),
                    ),
                  ],
                ),
              ),
              SizedBox(width: 8),
              Text(
                'Change',
                style: TextStyle(
                  fontFamily: _font,
                  fontSize: 13,
                  fontWeight: FontWeight.w700,
                  color: _brand,
                ),
              ),
            ],
          ),
        ),
      ),
      const SizedBox(height: 22),
      _sectionTitle('Pickup slot'),
      const SizedBox(height: 10),
      SizedBox(
        height: 96,
        child: ListView.separated(
          scrollDirection: Axis.horizontal,
          itemCount: _slots.length,
          separatorBuilder: (BuildContext c, int idx) =>
              const SizedBox(width: 10),
          itemBuilder: (BuildContext c, int i) => _slotCard(i),
        ),
      ),
    ];
  }

  Widget _slotCard(int i) {
    final _Slot s = _slots[i];
    final bool on = _slot == i;
    return GestureDetector(
      onTap: () => setState(() => _slot = i),
      child: Container(
        width: 82,
        padding: const EdgeInsets.symmetric(vertical: 12),
        decoration: BoxDecoration(
          color: on ? _brand : _canvas,
          borderRadius: BorderRadius.circular(14),
          border: Border.all(color: on ? _brand : _hairline),
        ),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              s.day,
              style: TextStyle(
                fontFamily: _font,
                fontSize: 12.5,
                fontWeight: FontWeight.w700,
                color: on ? _canvas : _muted,
              ),
            ),
            const SizedBox(height: 4),
            Text(
              s.date,
              style: TextStyle(
                fontFamily: _font,
                fontSize: 14,
                fontWeight: FontWeight.w800,
                color: on ? _canvas : _ink,
              ),
            ),
            const SizedBox(height: 6),
            Text(
              s.window,
              style: TextStyle(
                fontFamily: _font,
                fontSize: 12,
                fontWeight: FontWeight.w600,
                color: on ? _canvas.withValues(alpha: 0.85) : _faint,
              ),
            ),
          ],
        ),
      ),
    );
  }

  List<Widget> _dropDetails() {
    return <Widget>[
      _sectionTitle('Nearest drop-off store'),
      const SizedBox(height: 10),
      Container(
        decoration: BoxDecoration(
          color: _canvas,
          borderRadius: BorderRadius.circular(16),
          border: Border.all(color: _hairline),
        ),
        child: Column(
          children: <Widget>[
            ClipRRect(
              borderRadius: const BorderRadius.vertical(
                  top: Radius.circular(16)),
              child: SizedBox(
                height: 120,
                width: double.infinity,
                child: CustomPaint(painter: _MiniMapPainter()),
              ),
            ),
            Padding(
              padding: const EdgeInsets.all(16),
              child: Row(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: <Widget>[
                  const Expanded(
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: <Widget>[
                        Text(
                          'StyleCart · SoHo',
                          style: TextStyle(
                            fontFamily: _font,
                            fontSize: 15,
                            fontWeight: FontWeight.w800,
                            color: _ink,
                          ),
                        ),
                        SizedBox(height: 3),
                        Text(
                          '112 Spring St · 0.6 mi away',
                          style: TextStyle(
                            fontFamily: _font,
                            fontSize: 13,
                            fontWeight: FontWeight.w600,
                            color: _muted,
                          ),
                        ),
                      ],
                    ),
                  ),
                  Container(
                    padding: const EdgeInsets.symmetric(
                        horizontal: 10, vertical: 6),
                    decoration: BoxDecoration(
                      color: _success.withValues(alpha: 0.12),
                      borderRadius: BorderRadius.circular(8),
                    ),
                    child: const Text(
                      'Open now',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 12,
                        fontWeight: FontWeight.w700,
                        color: _success,
                      ),
                    ),
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
      const SizedBox(height: 14),
      Container(
        padding: const EdgeInsets.all(14),
        decoration: BoxDecoration(
          color: _surface,
          borderRadius: BorderRadius.circular(14),
        ),
        child: Row(
          children: const <Widget>[
            Icon(Icons.info_outline_rounded, size: 18, color: _muted),
            SizedBox(width: 10),
            Expanded(
              child: Text(
                'Drop off any time within 7 days. Bring the QR label we’ll '
                'send to your email.',
                style: TextStyle(
                  fontFamily: _font,
                  fontSize: 12.5,
                  fontWeight: FontWeight.w500,
                  height: 1.4,
                  color: _muted,
                ),
              ),
            ),
          ],
        ),
      ),
    ];
  }

  Widget _sectionTitle(String text) {
    return Text(
      text,
      style: const TextStyle(
        fontFamily: _font,
        fontSize: 13,
        fontWeight: FontWeight.w800,
        letterSpacing: 0.3,
        color: _ink,
      ),
    );
  }

  Widget _scheduleBar() {
    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: SizedBox(
            height: 56,
            child: FilledButton(
              onPressed: () => widget.onSchedule?.call(_pickup),
              style: FilledButton.styleFrom(
                backgroundColor: _brand,
                foregroundColor: _canvas,
                minimumSize: const Size.fromHeight(56),
                shape: RoundedRectangleBorder(
                  borderRadius: BorderRadius.circular(16),
                ),
              ),
              child: Text(
                _pickup
                    ? 'Schedule pickup · ${_slots[_slot].day} ${_slots[_slot].date}'
                    : 'Confirm drop-off',
                style: const TextStyle(
                  fontFamily: _font,
                  fontSize: 16,
                  fontWeight: FontWeight.w700,
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

class _Slot {
  const _Slot(this.day, this.date, this.window);
  final String day;
  final String date;
  final String window;
}

/// A lightweight painted map preview: block grid, two roads, a park, and a
/// brand store pin — no tiles, no network.
class _MiniMapPainter extends CustomPainter {
  const _MiniMapPainter();

  @override
  void paint(Canvas canvas, Size size) {
    final Rect r = Offset.zero & size;
    canvas.drawRect(r, Paint()..color = const Color(0xFFEDF0F2));

    final Paint block = Paint()..color = const Color(0xFFE2E7EA);
    const double cell = 34;
    for (double y = -8; y < size.height; y += cell) {
      for (double x = -8; x < size.width; x += cell) {
        canvas.drawRRect(
          RRect.fromRectAndRadius(
            Rect.fromLTWH(x + 4, y + 4, cell - 10, cell - 10),
            const Radius.circular(3),
          ),
          block,
        );
      }
    }

    // Park patch.
    canvas.drawRRect(
      RRect.fromRectAndRadius(
        Rect.fromLTWH(size.width * 0.06, size.height * 0.5,
            size.width * 0.26, size.height * 0.4),
        const Radius.circular(6),
      ),
      Paint()..color = const Color(0xFFCFE6D2),
    );

    // Roads.
    final Paint road = Paint()
      ..color = const Color(0xFFFFFFFF)
      ..strokeWidth = 9
      ..strokeCap = StrokeCap.round;
    canvas.drawLine(
        Offset(0, size.height * 0.62), Offset(size.width, size.height * 0.42),
        road);
    canvas.drawLine(
        Offset(size.width * 0.66, 0), Offset(size.width * 0.78, size.height),
        road);

    // Store pin (teardrop) at the road junction.
    final Offset pin = Offset(size.width * 0.7, size.height * 0.5);
    const double pr = 11;
    final Path tear = Path()
      ..moveTo(pin.dx, pin.dy + pr * 1.7)
      ..quadraticBezierTo(pin.dx - pr, pin.dy + pr * 0.4, pin.dx - pr,
          pin.dy - pr * 0.2)
      ..arcToPoint(Offset(pin.dx + pr, pin.dy - pr * 0.2),
          radius: const Radius.circular(pr))
      ..quadraticBezierTo(
          pin.dx + pr, pin.dy + pr * 0.4, pin.dx, pin.dy + pr * 1.7)
      ..close();
    canvas.drawShadow(tear, const Color(0x55000000), 3, true);
    canvas.drawPath(tear, Paint()..color = const Color(0xFFFF385C));
    canvas.drawCircle(
        Offset(pin.dx, pin.dy - pr * 0.15), 4, Paint()..color = Colors.white);
  }

  @override
  bool shouldRepaint(_MiniMapPainter 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-method

2. AI agent (MCP)

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

FAQ

Is this return-method screen free to use in a commercial app?

Yes. FlutterKit screens are free to use, including commercially — drop this pickup/drop-off screen into a client project or a store release as-is or restyled, with no licence fee and no attribution required.

Does it need any packages, plugins, or downloaded fonts?

No packages at all — the code.json lists an empty dependency set. Even the mini-map is a `CustomPainter`, so there is no maps SDK to configure. The only asset is the bundled Manrope font family, referenced through the `_font` constant; register Manrope in your `pubspec.yaml` fonts section (or swap `_font` for a family you already ship) and the screen runs on plain Flutter.

Which Flutter version does this code require?

Flutter 3.27 or newer, because the tints use `Color.withValues(alpha: ...)` in the method cards, the slot chips, and the Open-now badge. On an older SDK, replace each `withValues(alpha: x)` with `withOpacity(x)`; the constructor also uses `super.key`, which needs Dart 2.17 / Flutter 3.0 at minimum.

How do I replace the painted mini-map with a real map?

Swap the `CustomPaint(painter: _MiniMapPainter())` inside the 120px `SizedBox` for a `GoogleMap` or `FlutterMap` widget centred on the store's coordinates — the `ClipRRect` above it already handles the rounded top corners. Keeping the painter as a loading placeholder also works well, since it costs nothing and matches the brand colour of a real pin marker.

How do I load real pickup slots instead of the hard-coded five?

Replace the `static const _slots` list with slots fetched from your API — keep the `_Slot(day, date, window)` shape, or map `DateTime` ranges into it with `intl` formatting. Nothing else changes: the rail's `itemCount`, the chip builder, and even the CTA label all read from `_slots[_slot]`, so the button keeps announcing the newly selected real slot automatically. Just reset `_slot = 0` when a fresh list arrives so the index stays valid.

Related screens