Redesign on the Stitch reference, in Plus Jakarta Sans

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
This commit is contained in:
2026-09-24 11:08:25 +05:30
parent 06fa6b797a
commit 86b6af48c2
73 changed files with 3398 additions and 1722 deletions

View File

@@ -3,14 +3,16 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../data/app_config.dart';
import '../../state/app_scope.dart';
import '../format.dart';
import '../../data/models.dart';
import '../tokens.dart';
import '../widgets/misc.dart';
import '../format.dart';
import 'shell_screen.dart';
import '../widgets/buttons.dart';
import '../widgets/cards.dart';
import '../widgets/chrome.dart';
import '../widgets/feedback.dart';
import '../widgets/inputs.dart';
import '../widgets/misc.dart';
import 'auth/login_screen.dart';
import 'sheets/place_search_sheet.dart';
@@ -35,49 +37,133 @@ class AccountScreen extends StatelessWidget {
final name = customer?.name ?? '';
final phone = customer?.phone ?? '';
// Counted off what is already loaded, so the figures move with the list
// rather than claiming a lifetime total the client cannot know.
final completed =
app.orders.where((o) => o.status == BookingStatus.completed);
final delivered = completed.fold<int>(0, (n, o) => n + o.totalPackages);
final cities = {
for (final o in completed)
for (final g in o.destinations)
?g.destination.districtCode,
}.length;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DmBrandHeader(
eyebrow: 'Account',
title: name.isEmpty ? 'Your account' : name,
leading: DmAvatar(initials: initialsOf(name), onBrand: true),
action: DmIconButton(
icon: LucideIcons.circleHelp,
tooltip: 'Help',
background: DmColors.onBrandRaise,
foreground: Colors.white,
border: Colors.transparent,
radius: DmRadius.lg,
size: 42,
onPressed: () => DmToast.show(context, 'Support is on the way'),
),
child: phone.isEmpty
? null
: DmHeaderTrack(
trailing: DmTextAction(
label: 'Edit',
color: Colors.white,
minHeight: 24,
onPressed: () => DmToast.show(context, 'Editing is coming soon'),
DmAppBar(
unread: app.activeBookings.isNotEmpty,
onSearch: () => ShellScreen.of(context)?.goToTab(1),
onNotifications: () =>
DmToast.show(context, 'Nothing new right now'),
),
// ── The profile, centred ──
//
// It was a 34pt name left-aligned with the number under it, which is
// the shape a *list* screen takes. The reference puts the person at
// the top of their own account: a large avatar with an edit badge, the
// name, the number, and the account's standing — and that is what
// makes Account read as somewhere you are rather than as a menu.
Padding(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 6, DmSpace.pad, 18),
child: Column(
children: [
Stack(
clipBehavior: Clip.none,
children: [
DmAvatar(initials: initialsOf(name), size: 86),
Positioned(
right: -2,
bottom: -2,
child: Container(
width: 30,
height: 30,
alignment: Alignment.center,
decoration: BoxDecoration(
color: DmColors.surface,
shape: BoxShape.circle,
boxShadow: DmShadow.card,
),
child: const Icon(
LucideIcons.pencil,
size: 14,
color: DmColors.ink2,
),
),
),
child: Text(
phone,
style: DmText.monoLg.copyWith(fontSize: 14.5, color: Colors.white),
],
),
const SizedBox(height: 14),
Text(
name.isEmpty ? 'Your account' : name,
style: DmText.headerTitle.copyWith(fontSize: 24),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (phone.isNotEmpty) ...[
const SizedBox(height: 7),
Text(
phone,
style: DmText.monoSm.copyWith(
fontSize: 13,
color: DmColors.ink3,
),
),
],
// ── Two figures, both counted here ──
//
// The reference puts "24 all-time parcels" and "100% carbon
// offset" side by side under the name. The first is real — it
// is the orders already in memory — so it is shown. The second
// is not: nothing in this backend measures an offset, and a
// sustainability claim is the last thing an app should invent.
// Delivered parcels and cities reached are what the data
// actually supports.
if (delivered > 0) ...[
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: _Stat(
icon: LucideIcons.package,
label: 'DELIVERED',
value: '$delivered',
note: delivered == 1 ? 'parcel' : 'parcels',
),
),
const SizedBox(width: 10),
Expanded(
child: _Stat(
icon: LucideIcons.mapPin,
label: 'CITIES',
value: '$cities',
note: cities == 1 ? 'destination' : 'destinations',
),
),
],
),
],
],
),
),
Expanded(
child: ListView(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 4, DmSpace.pad, 28),
// Clears the floating tab bar, which the shell stacks over the
// body rather than laying out beneath it.
padding: EdgeInsets.fromLTRB(DmSpace.pad, 4, DmSpace.pad, 74 + MediaQuery.paddingOf(context).bottom),
children: [
const DmMicroHead('Preferences', top: 16, brand: false),
const DmMicroHead('Preferences', top: 4, brand: false),
DmRowGroup(
children: [
DmRow(
icon: LucideIcons.mapPin,
label: 'Saved addresses',
value: app.pickup?.title ?? 'None',
note: 'Pickup and drop locations',
// No value. It was the pickup address, which is a street
// name and rendered as "38 Mettupal…" — a truncation
// nobody can read anything out of, in the slot meant for a
// word.
showChevron: true,
onTap: () async {
final place = await showPlaceSearchSheet(context);
@@ -90,19 +176,49 @@ class AccountScreen extends StatelessWidget {
const DmRow(
icon: LucideIcons.bell,
label: 'Notifications',
value: 'SMS and push',
note: 'Real-time SMS and push',
showChevron: true,
),
const DmRow(
icon: LucideIcons.wallet,
label: 'Payment methods',
value: 'UPI',
note: 'UPI, on delivery',
showChevron: true,
),
const DmRow(
],
),
// ── A second group, because the first was two things ──
//
// "Preferences" held three settings and one destination, and
// the destination — help — was the row a customer in trouble
// scans for. The two policy rows every app is expected to carry
// had nowhere to go at all, so they were simply missing.
//
// Split, each head says what its rows are: things you change,
// and things you read.
const DmMicroHead('About', brand: false),
DmRowGroup(
children: [
DmRow(
icon: LucideIcons.circleHelp,
label: 'Help and support',
note: 'Talk to the courier desk',
showChevron: true,
onTap: () => DmToast.show(context, 'Support is on the way'),
),
DmRow(
icon: LucideIcons.fileText,
label: 'Terms and policies',
note: 'What Doormile covers',
showChevron: true,
onTap: () => DmToast.show(context, 'Opening doormile.com…'),
),
DmRow(
icon: LucideIcons.info,
label: 'About Doormile',
note: 'Version and licences',
showChevron: true,
onTap: () => DmToast.show(context, 'Opening doormile.com…'),
),
],
),
@@ -111,36 +227,29 @@ class AccountScreen extends StatelessWidget {
// session came from a dev token rather than a sign-in.
if (!AppConfig.isProd || AppConfig.authBypassed) ...[
const DmMicroHead('Build', brand: false),
DmCard(
tone: DmCardTone.sunken,
children: [
DmCardCell(
// Not a DmRow: that lays a short label against a short
// value on one line, and this value is a URL plus every
// flag the build carries. It overflowed by 59px the
// moment a bypass was on — which is exactly when someone
// needs to read it.
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('API', style: DmText.eyebrow),
const SizedBox(height: 6),
Text(
AppConfig.describe,
style: DmText.mono.copyWith(
height: 1.5,
color: DmColors.ink2,
),
),
],
),
// Not a DmRow: that lays a short label against a short value
// on one line, and this value is a URL plus every flag the
// build carries. It overflowed by 59px the moment a bypass was
// on — which is exactly when someone needs to read it.
//
// Not a card either. It is a line of mono on the page, like
// every other thing on this screen.
Padding(
padding: const EdgeInsets.only(top: 2, bottom: 4),
child: Text(
AppConfig.describe,
style: DmText.mono.copyWith(
height: 1.5,
color: DmColors.ink3,
),
],
),
),
],
const SizedBox(height: 24),
DmButton(
label: 'Sign out',
label: 'Log Out',
icon: LucideIcons.logOut,
iconLeading: true,
kind: DmButtonKind.ghost,
danger: true,
onPressed: () {
@@ -156,9 +265,9 @@ class AccountScreen extends StatelessWidget {
TextSpan(
style: DmText.tiny.copyWith(color: DmColors.ink4),
children: [
const TextSpan(text: 'Doormile Customer '),
const TextSpan(text: 'DOORMILE CUSTOMER '),
TextSpan(
text: 'v1.0.0',
text: 'v${AppConfig.appVersion}',
style: DmText.monoSm.copyWith(color: DmColors.ink4),
),
],
@@ -172,3 +281,69 @@ class AccountScreen extends StatelessWidget {
);
}
}
/// One counted figure under the profile.
///
/// A glyph in the corner, a micro-label, the number at display size and the
/// unit under it — the shape a dashboard tile takes when it is one number
/// rather than a chart.
class _Stat extends StatelessWidget {
const _Stat({
required this.icon,
required this.label,
required this.value,
required this.note,
});
final IconData icon;
final String label;
final String value;
final String note;
@override
Widget build(BuildContext context) {
return DmCard(
children: [
DmCardCell(
padding: const EdgeInsets.fromLTRB(16, 14, 14, 15),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(
child: Text(
label,
style: DmText.eyebrow,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
Icon(icon, size: 15, color: DmColors.ink4),
],
),
const SizedBox(height: 10),
Text(
value,
style: DmText.headerTitle.copyWith(fontSize: 28),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
note,
style: DmText.small.copyWith(
fontSize: 12.5,
color: DmColors.ink3,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
);
}
}