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
This commit is contained in:
@@ -7,10 +7,8 @@ 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 'auth/login_screen.dart';
|
||||
@@ -39,245 +37,261 @@ class AccountScreen extends StatelessWidget {
|
||||
|
||||
// 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 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,
|
||||
for (final g in o.destinations) ?g.destination.districtCode,
|
||||
}.length;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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(
|
||||
return SafeArea(
|
||||
bottom: false,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// No wordmark bar, and no search or bell on it. Home and Orders lost
|
||||
// the same one: a root that opens on the customer's own face does not
|
||||
// need to be told which app it is, and the two glyphs beside it went
|
||||
// to screens that have their own way in.
|
||||
// ── 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,
|
||||
26,
|
||||
DmSpace.pad,
|
||||
18,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Stack(
|
||||
clipBehavior: Clip.none,
|
||||
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',
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
// 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: 4, brand: false),
|
||||
DmRowGroup(
|
||||
children: [
|
||||
DmRow(
|
||||
icon: LucideIcons.mapPin,
|
||||
label: 'Saved addresses',
|
||||
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);
|
||||
if (place != null && context.mounted) {
|
||||
app.setPickup(place);
|
||||
DmToast.show(context, 'Pickup location updated');
|
||||
}
|
||||
},
|
||||
),
|
||||
const DmRow(
|
||||
icon: LucideIcons.bell,
|
||||
label: 'Notifications',
|
||||
note: 'Real-time SMS and push',
|
||||
showChevron: true,
|
||||
),
|
||||
const DmRow(
|
||||
icon: LucideIcons.wallet,
|
||||
label: 'Payment methods',
|
||||
note: 'UPI, on delivery',
|
||||
showChevron: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
// ── 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…'),
|
||||
),
|
||||
],
|
||||
),
|
||||
// The environment line is not a control — it is what a bug
|
||||
// report needs in order to be actionable. Always shown when the
|
||||
// session came from a dev token rather than a sign-in.
|
||||
if (!AppConfig.isProd || AppConfig.authBypassed) ...[
|
||||
const DmMicroHead('Build', brand: false),
|
||||
// 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,
|
||||
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',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
DmButton(
|
||||
label: 'Log Out',
|
||||
icon: LucideIcons.logOut,
|
||||
iconLeading: true,
|
||||
kind: DmButtonKind.ghost,
|
||||
danger: true,
|
||||
onPressed: () {
|
||||
app.signOut();
|
||||
Navigator.of(context).pushAndRemoveUntil(
|
||||
MaterialPageRoute<void>(builder: (_) => const LoginScreen()),
|
||||
(route) => false,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
// 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,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
style: DmText.tiny.copyWith(color: DmColors.ink4),
|
||||
children: [
|
||||
const DmMicroHead('Preferences', top: 4, brand: false),
|
||||
DmRowGroup(
|
||||
children: [
|
||||
const TextSpan(text: 'DOORMILE CUSTOMER '),
|
||||
TextSpan(
|
||||
text: 'v${AppConfig.appVersion}',
|
||||
style: DmText.monoSm.copyWith(color: DmColors.ink4),
|
||||
DmRow(
|
||||
icon: LucideIcons.mapPin,
|
||||
label: 'Saved addresses',
|
||||
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);
|
||||
if (place != null && context.mounted) {
|
||||
app.setPickup(place);
|
||||
DmToast.show(context, 'Pickup location updated');
|
||||
}
|
||||
},
|
||||
),
|
||||
const DmRow(
|
||||
icon: LucideIcons.bell,
|
||||
label: 'Notifications',
|
||||
note: 'Real-time SMS and push',
|
||||
showChevron: true,
|
||||
),
|
||||
const DmRow(
|
||||
icon: LucideIcons.wallet,
|
||||
label: 'Payment methods',
|
||||
note: 'UPI, on delivery',
|
||||
showChevron: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
// ── 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…'),
|
||||
),
|
||||
],
|
||||
),
|
||||
// The environment line is not a control — it is what a bug
|
||||
// report needs in order to be actionable. Always shown when the
|
||||
// session came from a dev token rather than a sign-in.
|
||||
if (!AppConfig.isProd || AppConfig.authBypassed) ...[
|
||||
const DmMicroHead('Build', brand: false),
|
||||
// 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: 'Log Out',
|
||||
icon: LucideIcons.logOut,
|
||||
iconLeading: true,
|
||||
kind: DmButtonKind.ghost,
|
||||
danger: true,
|
||||
onPressed: () {
|
||||
app.signOut();
|
||||
Navigator.of(context).pushAndRemoveUntil(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => const LoginScreen(),
|
||||
),
|
||||
(route) => false,
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
style: DmText.tiny.copyWith(color: DmColors.ink4),
|
||||
children: [
|
||||
const TextSpan(text: 'DOORMILE CUSTOMER '),
|
||||
TextSpan(
|
||||
text: 'v${AppConfig.appVersion}',
|
||||
style: DmText.monoSm.copyWith(color: DmColors.ink4),
|
||||
),
|
||||
],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user