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
350 lines
13 KiB
Dart
350 lines
13 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
|
|
|
import '../../data/app_config.dart';
|
|
import '../../state/app_scope.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 'auth/login_screen.dart';
|
|
import 'sheets/place_search_sheet.dart';
|
|
|
|
/// Account — profile, preferences, and the build's environment line.
|
|
///
|
|
/// The profile folds into the brand header: the avatar takes the slot the other
|
|
/// tab roots give an action, the customer's name takes the slot the title holds
|
|
/// elsewhere, and the phone number sits in the recessed track. That removes the
|
|
/// profile *card* the screen used to open with — a card whose entire content
|
|
/// was three facts about the person already looking at the screen.
|
|
class AccountScreen extends StatelessWidget {
|
|
const AccountScreen({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final app = AppScope.of(context);
|
|
final customer = app.customer;
|
|
|
|
// Empty rather than a stand-in name: this screen is only ever built for a
|
|
// signed-in customer, and a placeholder here would read as somebody's
|
|
// actual account.
|
|
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: [
|
|
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(
|
|
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(
|
|
// 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,
|
|
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,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|