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.

Watch the Flutter UI walkthrough
A short screen recording of Pickup or Drop-off 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
- ✓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
Create the file
Add a new file at lib/ecom_orders_return_method/ecom_orders_return_method_screen.dart in your Flutter project.
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:
flutter:
fonts:
- family: Manrope
fonts:
- asset: fonts/Manrope-Regular.ttfBuild 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
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
@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
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
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
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
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
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
/// 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-method2. 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.