Four passes, and the shape they landed on. The type face is Plus Jakarta Sans (variable, wght 200-800), which brings a fix with it: it carries the rupee glyph and Switzer does not, so prices stop being set in Geist Mono to work around a missing character. Mono stays where it is earned - references and phone numbers, read digit by digit. Surfaces lift rather than outline. Cards carry two very soft shadow layers instead of a hairline, because eight outlined boxes down a screen read as a wireframe. The tab bar floats as a pill again for the same reason it was right to: it is now the same kind of object as everything above it. Screens: * Home is the greeting, the address, the sphere and one card. The card lost its progress bar - a filling line says "wait", and a parcel two days into a journey is not something anyone is waiting through - and gained the size that buys. * Orders cards are four bands: identity, destination, route, and whatever is happening right now. Plus a search field, because the list is the archive. * Tracking leads with the state at display size, then TRIP MILESTONES with a step counter, then the courier. * Review is a route thread over two particular cards. * Account opens on the person: avatar, name, and two counted figures. Three real bugs the redesign surfaced: * Quick dispatch handed `loadCities()` straight to a FutureBuilder, so the catalogue was refetched on every rebuild and Home never settled. * Order cards showed the whole visit's weight on one destination's row - somebody else's parcel. Per group now, and only once actually weighed. * The pickup window was printed beside "In transit", where it reads as a delivery time nobody promised. Nothing invented. The reference shows EXPRESS PRIORITY, CARBON OFFSET, CONCIERGE ELITE and hub-to-hub routing; this backend sends none of them, so they are absent rather than mocked up. flutter analyze: clean. flutter test: 88 passing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
198 lines
6.4 KiB
Dart
198 lines
6.4 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 = 62;
|
|
static const double _height = 58;
|
|
|
|
@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.9 : 1,
|
|
duration: DmMotion.fast,
|
|
curve: DmMotion.ease,
|
|
child: Center(
|
|
child: AnimatedContainer(
|
|
duration: DmMotion.base,
|
|
curve: DmMotion.ease,
|
|
width: 42,
|
|
height: 42,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: selected ? DmColors.brandSoft : Colors.transparent,
|
|
borderRadius: DmRadius.all(DmRadius.sm),
|
|
),
|
|
child: AnimatedScale(
|
|
// A shade of overshoot as the tint arrives under it, so the
|
|
// two read as one gesture rather than as a glyph that
|
|
// recolours when a rectangle happens to appear behind it.
|
|
scale: selected ? 1.06 : 1,
|
|
duration: DmMotion.slow,
|
|
curve: DmMotion.glide,
|
|
child: Icon(
|
|
widget.icon,
|
|
size: 21,
|
|
color: selected ? DmColors.brand : DmColors.ink4,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|