Files
doormile_customer_app/lib/ui/screens/shell_screen.dart
Thiru-tenext 0d66627c3c Replace the customer app with Doormile CX
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>
2026-09-15 16:07:33 +05:30

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),
),
],
),
),
);
}
}