Book a pickup, track it to delivery — the rebuilt customer app. - Design language from doormile-screens.html: brand #8F0F06, Manrope + Geist Mono (variable fonts), bordered cards instead of shadows, crimson brand headers, sliding tab indicator, mono for anything read digit by digit. - lib/data (one live API implementation, plus a debug-only offline fake), lib/state, lib/ui (tokens, widgets, screens). - 84 tests, plus a design snapshot harness that renders every screen with the real fonts: flutter test test/design_snapshot_test.dart --run-skipped --update-goldens This replaces the previous app (pubspec 'doormile', app id com.doormile.customer). That tree remains in history at 6c7d656; note its android/app/google-services.json is not carried over, and the application id here is in.doormile.customer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
202 lines
6.6 KiB
Dart
202 lines
6.6 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,
|
|
body: IndexedStack(
|
|
index: _index,
|
|
children: const [HomeScreen(), OrdersScreen(), AccountScreen()],
|
|
),
|
|
bottomNavigationBar: _TabBar(index: _index, onChanged: goToTab),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The tab bar, fixed to the bottom edge.
|
|
///
|
|
/// ── Why it is no longer a floating pill-shaped bar ──
|
|
///
|
|
/// It used to hover 10pt above the bottom with a 28pt radius and a shadow
|
|
/// under it, which put a second rounded surface between the content and the
|
|
/// gesture bar and made every screen end in two stacked edges. It now sits on
|
|
/// the edge with a single hairline above it, like the footers in the rest of
|
|
/// the app.
|
|
///
|
|
/// The indicator does the work instead: a crimson pill that travels to the tab
|
|
/// you pick, with the glyph turning white as it arrives. Selection reads as one
|
|
/// object moving rather than three tabs changing colour in place — and the
|
|
/// label under it stays crimson, so the tab is still identifiable in the
|
|
/// half-second the pill is in flight.
|
|
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),
|
|
];
|
|
|
|
/// The travelling pill's size. Narrower than its slot so it reads as an
|
|
/// object sitting in the row rather than as a selected cell.
|
|
static const double _pillWidth = 62;
|
|
static const double _pillHeight = 34;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return DecoratedBox(
|
|
decoration: const BoxDecoration(
|
|
color: DmColors.surface,
|
|
border: Border(top: BorderSide(color: DmColors.border)),
|
|
),
|
|
child: SafeArea(
|
|
top: false,
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(6, 9, 6, 7),
|
|
child: SizedBox(
|
|
// The row is the pill plus the label, and the label is type: at
|
|
// 1.8x accessibility text a fixed 58 clipped it by two points. The
|
|
// bar gets taller instead, which is what a larger-text user asked
|
|
// for.
|
|
height: _pillHeight +
|
|
5 +
|
|
MediaQuery.textScalerOf(context).scale(11.5) * 1.3,
|
|
child: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final slot = constraints.maxWidth / _items.length;
|
|
return Stack(
|
|
children: [
|
|
AnimatedPositioned(
|
|
duration: DmMotion.slow,
|
|
curve: DmMotion.glide,
|
|
left: slot * index + (slot - _pillWidth) / 2,
|
|
width: _pillWidth,
|
|
top: 0,
|
|
height: _pillHeight,
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
color: DmColors.brand,
|
|
borderRadius: DmRadius.all(DmRadius.pill),
|
|
boxShadow: DmShadow.liftSm,
|
|
),
|
|
),
|
|
),
|
|
Row(
|
|
children: [
|
|
for (var i = 0; i < _items.length; i++)
|
|
Expanded(
|
|
child: _TabButton(
|
|
label: _items[i].label,
|
|
icon: _items[i].icon,
|
|
selected: index == i,
|
|
onTap: () => onChanged(i),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TabButton extends StatelessWidget {
|
|
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
|
|
Widget build(BuildContext context) {
|
|
return Semantics(
|
|
button: true,
|
|
selected: selected,
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: onTap,
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.start,
|
|
children: [
|
|
SizedBox(
|
|
height: _TabBar._pillHeight,
|
|
child: Center(
|
|
child: AnimatedSwitcher(
|
|
duration: DmMotion.base,
|
|
child: Icon(
|
|
icon,
|
|
key: ValueKey(selected),
|
|
size: 22,
|
|
color: selected ? Colors.white : DmColors.ink3,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 5),
|
|
AnimatedDefaultTextStyle(
|
|
duration: DmMotion.base,
|
|
curve: DmMotion.ease,
|
|
style: DmText.label.copyWith(
|
|
fontSize: 11.5,
|
|
height: 1.0,
|
|
color: selected ? DmColors.brand : DmColors.ink3,
|
|
fontWeight: selected ? FontWeight.w700 : FontWeight.w600,
|
|
),
|
|
child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|