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 'settings/about_screen.dart'; import 'settings/notifications_screen.dart'; import 'settings/payment_screen.dart'; import 'settings/settings_kit.dart'; import 'settings/support_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(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'); } }, ), // ── Both of these were chevrons pointing at nothing ── // // No `onTap` at all: the rows rendered the affordance for // opening a page and then swallowed the tap. A customer // cannot tell that from a page that is slow, so they press // it again. DmRow( icon: LucideIcons.bell, label: 'Notifications', // Was "Real-time SMS and push". Push is not wired in — // `registerPushToken` says so itself — so the note was // promising a channel the app does not have. note: 'What you get told, and when', showChevron: true, onTap: () => pushSettings(context, const NotificationsScreen()), ), DmRow( icon: LucideIcons.wallet, label: 'Payment methods', note: 'Pay after the doorstep weigh', showChevron: true, onTap: () => pushSettings(context, const PaymentScreen()), ), ], ), // ── 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: () => pushSettings(context, const SupportScreen()), ), // ── One screen, two doors ── // // Both rows land on [AboutScreen] under their own title. // The policies are three links and the build is three // facts; splitting them gives two screens that each look // unfinished, and a customer looking for "the legal bit" // finds it either way. DmRow( icon: LucideIcons.fileText, label: 'Terms and policies', note: 'What Doormile covers', showChevron: true, onTap: () => pushSettings( context, const AboutScreen(title: 'Terms and policies'), ), ), DmRow( icon: LucideIcons.info, label: 'About Doormile', note: 'Version and licences', showChevron: true, onTap: () => pushSettings(context, const AboutScreen()), ), ], ), // 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( 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, ), ], ), ), ], ); } }