── Navigation ── Pages arrived from the right and left to the right. Right is the direction the system back gesture drags, so a page leaving under a button press read as a swipe nobody made — and every push in this app is a layer over the one before it, which arrives from below. The axis is vertical now: the incoming page rises and covers, the page underneath stays put and dims, and back drops the top page off the bottom. Full travel rather than the old 14% nudge, which is what makes the direction nameable. test/page_transition_test.dart holds it. ── Home ── The pickup pill is back under the greeting. It was cut on the argument that the form below already carried the pickup; the form is four hundred points down the screen, and a location you can only see by scrolling to a form is a location you do not trust. It shows the locality while the form keeps the door, so the screen answers one question once, and it now has a resolving state — it used to print the last place you were, confidently, while the fix was still in flight. The sphere's caption was falling off the screen. Adding the pill pushed it out of the scroll viewport, silently, with no overflow warning — the fourth time Home growing has clipped something under the sphere. DmBookOrb.field is no longer a constant: the glow gives way to whatever is left, the sphere never does. ── 74 points of dead canvas, on every root ── Home, Orders and Account each cleared the floating tab bar by `MediaQuery.paddingOf(context).bottom + 74`, on the reasoning that the inset is the home indicator and 74 is the bar above it. Scaffold with extendBody replaces the body's bottom padding with the bar's whole laid-out height — measured, a 34pt inset and this bar hand the body 106. They were clearing the bar, then clearing it again. One helper now, dmTabBarClear(). ── One journey, one vocabulary ── Review said ORIGIN/DESTINATION; Home's form and tracking say PICKUP/DROP. Three screens a customer walks in order. The tracking card had already written down the argument; the booking screen hadn't followed. The rail's first milestone read "Booking created" directly above "Order created" — down the rail: created, created, in transit. It is "Pickup booked", which is what JourneyStage.milestoneLabel has called that stage all along on Orders and on Home's live row. ── The launcher icon ── tool/icons.py renders all twenty-eight files from one master, because the failure mode of exporting by hand is nineteen replaced and nine left on the old mark, at the density nobody checks. The master is a bare mark on a field, so it cannot be dropped into Android's adaptive canvas whole: crimson is the background, the white mark is the foreground, and it is sized by its reach from the canvas centre so a round mask cannot take the arrow tip. Measured on the built file: 63.7dp against a 66dp safe zone. A real monochrome layer replaces the colour foreground that was standing in for one — the system tints by alpha, so that themed as a blob. ic_launcher_round.png exists at last; minSdk is 24 and android:roundIcon had nothing to resolve to below API 26. The artwork is flattened (the source vignettes over ten values of red) and its horizontal offset is placed deliberately rather than inherited — geometric centring made the mark lean, because the arrow carries the bounding box right while contributing almost none of the ink. ── Bundle ── pubspec declared assets/images/ as a folder, which shipped the 974 KB icon master to every customer for a file no code opens. Named explicitly now.
364 lines
14 KiB
Dart
364 lines
14 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 '../widgets/buttons.dart';
|
|
import '../widgets/cards.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 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: [
|
|
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,
|
|
dmTabBarClear(context),
|
|
),
|
|
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,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|