Files
doormile_customer_app/lib/ui/screens/shell_screen.dart
Thiru-tenext 8956e818a4 One Touch, and a pickup map worth the name
Two ways to book the same parcel, and the difference said where the
choice is made rather than in a paragraph about it.

ONE TOUCH is the sphere: a state, a district, a window, and nothing
else. The door, the recipient and the weight go out omitted, which the
contract already treats as "not added yet" - the Miler writes them down
standing at the address with the parcel in hand, which is the only
moment anybody knows them.

The pick/drop form under it is the other way in. PICKUP already carries
the detected address; DROP is the only row with a question on it, and
it asks for the full door - once per destination - before the window.
Captions rather than an explainer: "Just a city and a time" under the
sphere, "Enter the full address yourself" on the row.

The pickup screen is a map screen now instead of a screen with a map on
it. Full-bleed basemap, pin nailed to the centre, floating search and
back, and a fixed sheet carrying what the pin resolved to. The
collect-from sheet can hand off to it and carry the pin back.

Also:

* Home reads what-is-running, then the one gesture, then the form. The
  live card lost its courier row and half its height.
* The sphere turns inside out on press - white floods from the centre
  and the word inverts through the clip, not by tween - and bounces
  back on release.
* Order IDs copy, on Orders and on tracking.
* Tracking says PICKUP and DROP with an arrow between them, carries the
  reference at the foot of the card it identifies, and shows every
  timing exactly once.
* The wordmark bar is gone from all three roots; Orders and Account had
  no SafeArea under it, so their titles were sitting under the notch.

flutter analyze: clean. flutter test: 90 passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
2026-09-24 15:59:06 +05:30

198 lines
6.5 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../tokens.dart';
import 'account_screen.dart';
import 'home_screen.dart';
import 'orders_screen.dart';
/// Bottom-navigation shell: Home · Orders · Account.
///
/// Booking and tracking screens are pushed on top, so the tab bar only ever
/// shows on the three destinations that need it.
class ShellScreen extends StatefulWidget {
const ShellScreen({super.key, this.initialTab = 0});
final int initialTab;
static ShellScreenState? of(BuildContext context) =>
context.findAncestorStateOfType<ShellScreenState>();
@override
State<ShellScreen> createState() => ShellScreenState();
}
class ShellScreenState extends State<ShellScreen> {
late int _index = widget.initialTab;
void goToTab(int index) {
if (index == _index) return;
HapticFeedback.selectionClick();
setState(() => _index = index);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: DmColors.canvas,
// The bar floats, so it is stacked over the content rather than laid
// out beneath it — and each root pads its own scroll view past it.
extendBody: true,
body: IndexedStack(
index: _index,
children: const [HomeScreen(), OrdersScreen(), AccountScreen()],
),
bottomNavigationBar: _TabBar(index: _index, onChanged: goToTab),
);
}
}
/// The tab bar, fixed to the bottom edge.
///
/// ── A floating pill, after all ──
///
/// This bar has been three things. It hovered as a pill, then sat on the edge
/// with a hairline over it and a crimson indicator travelling along it, then
/// lost the indicator's edge. The reference the app is now drawn against puts
/// it back in the air — and the reason is better than the first time round.
///
/// The pill works *because* the screens above it stopped having edges. When
/// every card was outlined, a floating bar was a fourth rounded surface in a
/// stack of them. Now the content is white shapes lifted off a near-white
/// page, and the bar is one more of those: same radius language, same shadow,
/// read as the same kind of object.
///
/// The labels went with the edge. Three glyphs a customer has seen on every
/// app they own do not need naming, and without them the bar is 62pt instead
/// of 86 — which is the space the pill is floating in.
class _TabBar extends StatelessWidget {
const _TabBar({required this.index, required this.onChanged});
final int index;
final ValueChanged<int> onChanged;
/// Lucide, not Material. One 1.5px stroke family at one weight, so the three
/// glyphs read as a set instead of three different drawing styles — and the
/// same set the pushed screens use.
static const _items = [
(label: 'Home', icon: LucideIcons.house),
(label: 'Orders', icon: LucideIcons.package),
(label: 'Account', icon: LucideIcons.userRound),
];
static const double _slot = 74;
static const double _height = 62;
@override
Widget build(BuildContext context) {
return SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 10),
// A Row, not a Center: `bottomNavigationBar` hands its child an
// unbounded height, and a Center in that slot expands to fill the
// whole screen with the pill floating in the middle of it.
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
height: _height,
padding: const EdgeInsets.symmetric(horizontal: 6),
decoration: BoxDecoration(
color: DmColors.surface,
borderRadius: DmRadius.all(DmRadius.pill),
boxShadow: DmShadow.cardRaised,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (var i = 0; i < _items.length; i++)
SizedBox(
width: _slot,
child: _TabButton(
label: _items[i].label,
icon: _items[i].icon,
selected: index == i,
onTap: () => onChanged(i),
),
),
],
),
),
],
),
),
);
}
}
class _TabButton extends StatefulWidget {
const _TabButton({
required this.label,
required this.icon,
required this.selected,
required this.onTap,
});
final String label;
final IconData icon;
final bool selected;
final VoidCallback onTap;
@override
State<_TabButton> createState() => _TabButtonState();
}
class _TabButtonState extends State<_TabButton> {
bool _down = false;
@override
Widget build(BuildContext context) {
final selected = widget.selected;
return Semantics(
button: true,
selected: selected,
label: widget.label,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (_) => setState(() => _down = true),
onTapUp: (_) => setState(() => _down = false),
onTapCancel: () => setState(() => _down = false),
onTap: widget.onTap,
child: AnimatedScale(
scale: _down ? 0.88 : 1,
duration: DmMotion.fast,
curve: DmMotion.ease,
child: Center(
// ── Colour alone ──
//
// The glyph sat in a `brandSoft` rounded square, which made the
// selected tab the only filled object in a bar whose whole job is
// to recede. The reference marks it with the brand and nothing
// else, and with three glyphs in a white pill that is unambiguous
// — there is only one coloured thing on the bar.
//
// It arrives with a shade of overshoot, so the tab reacts to the
// press rather than recolouring after it.
child: AnimatedScale(
scale: selected ? 1.08 : 1,
duration: DmMotion.slow,
curve: DmMotion.glide,
child: AnimatedContainer(
duration: DmMotion.base,
curve: DmMotion.ease,
child: Icon(
widget.icon,
size: 23,
color: selected ? DmColors.brand : DmColors.ink4,
),
),
),
),
),
),
);
}
}