Design system - MilerSurface ladder (canvas → working → raised → floating) with MilerPanel as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1. - Visible vocabulary applied across Home, Deliveries, Activity, Account and the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x), canvas wells for anything that opens, small filled tags for shelf labels, demoted placeholders. Recorded in DESIGN_SYSTEM.md §6. - One icon family: 222 Material glyphs migrated to Lucide; none left outside lib/xpress. - Colour semantics corrected: amber only for what is genuinely owed, brand red reserved for the live stop, disabled primaries go neutral rather than pale. Data and lifecycle - lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart makes admin sequence the single ordering authority; service_day.dart, and stop_area.dart rewritten against live Coimbatore addresses (digit-token stripping, city stoplist, street suffixes, stammer collapse). - countLabel states the load once, in bags. Testing - 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity, sheets and verify, with test/failures/ now gitignored (diff debris). - New pins: home_gutter_test, stop_area_test, plus updated structural bounds. Note: this commit also carries pre-existing working-tree deletions that were present before this work (API_SPEC.md, README.md, demo test fixtures). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
869 lines
35 KiB
Dart
869 lines
35 KiB
Dart
// ── One icon family ──
|
||
//
|
||
// Account was the last screen still drawing Material glyphs while Home,
|
||
// Deliveries and Activity had moved to Lucide. Two icon languages on one
|
||
// bottom-nav is visible the moment a rider switches tabs: Material's filled,
|
||
// optically heavier shapes beside Lucide's even 2px stroke read as two apps.
|
||
//
|
||
// Straight swaps — same meanings, same sizes, same tap targets.
|
||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||
import 'dart:io';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:get/get.dart';
|
||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||
import 'package:miler/views/Dashboard/profile/informations/profile.dart';
|
||
import 'package:miler/views/helpers/constants/miler_surface.dart';
|
||
import 'package:miler/views/Dashboard/profile/informations/notifications_page.dart';
|
||
import 'package:miler/views/Dashboard/profile/informations/help_center.dart';
|
||
import 'package:miler/views/Dashboard/profile/informations/faq.dart';
|
||
import 'package:miler/views/Dashboard/profile/informations/rider_rewards_page.dart';
|
||
import 'package:miler/views/Dashboard/profile/informations/terms_condition.dart';
|
||
import 'package:miler/views/Dashboard/profile/informations/saved_address.dart';
|
||
import 'package:miler/views/Dashboard/profile/informations/order_alert_sound.dart';
|
||
import 'package:miler/views/Dashboard/summary/summary.dart';
|
||
import 'package:miler/views/helpers/constants/miler_type.dart';
|
||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||
import 'package:miler/views/helpers/constants/design_constants.dart';
|
||
import 'package:miler/views/helpers/widgets/miler_app_bar.dart';
|
||
import 'package:miler/views/helpers/widgets/app_widgets.dart';
|
||
import 'package:miler/views/helpers/widgets/page_transitions.dart';
|
||
import 'package:miler/views/helpers/widgets/settings_ui.dart';
|
||
import 'package:miler/widget/Bottom_page.dart';
|
||
import 'package:miler/views/onboardscreens/Sign_in.dart';
|
||
import 'package:miler/controllers/profile_controller.dart';
|
||
import 'package:miler/data/service_profile.dart';
|
||
import 'package:shared_preferences/shared_preferences.dart';
|
||
import 'package:miler/data/accepted_store.dart';
|
||
import 'package:miler/data/proof_store.dart';
|
||
import 'package:miler/controllers/rewards_controller.dart';
|
||
import 'package:miler/controllers/summary_controller.dart';
|
||
import 'package:package_info_plus/package_info_plus.dart';
|
||
import 'package:url_launcher/url_launcher.dart';
|
||
|
||
class ProfilePage extends StatefulWidget {
|
||
const ProfilePage({super.key});
|
||
|
||
@override
|
||
State<ProfilePage> createState() => _ProfilePageState();
|
||
}
|
||
|
||
class _ProfilePageState extends State<ProfilePage> {
|
||
late final ProfileController _profileController =
|
||
Get.isRegistered<ProfileController>()
|
||
? Get.find<ProfileController>()
|
||
: Get.put(ProfileController(), permanent: true);
|
||
|
||
final RewardsController _rewardsController =
|
||
Get.isRegistered<RewardsController>()
|
||
? Get.find<RewardsController>()
|
||
: Get.put(RewardsController());
|
||
|
||
final SummaryController _summaryController =
|
||
Get.isRegistered<SummaryController>()
|
||
? Get.find<SummaryController>()
|
||
: Get.put(SummaryController());
|
||
|
||
String _name = '';
|
||
String _email = '';
|
||
String _contact = '';
|
||
String _appVersion = '';
|
||
|
||
/// Name and phone come off disk. Until they land, the card used to state
|
||
/// "Rider Name / Not added" as if that were the answer — so the rider's first
|
||
/// glimpse of his own account said his number was missing.
|
||
bool _prefsLoaded = false;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_loadProfilePrefs();
|
||
_loadAppVersion();
|
||
ever(_profileController.userName, (_) => _assignFromController());
|
||
ever(_profileController.userEmail, (_) => _assignFromController());
|
||
ever(_profileController.userContact, (_) => _assignFromController());
|
||
_profileController.loadFromPrefs();
|
||
}
|
||
|
||
Future<void> _loadAppVersion() async {
|
||
try {
|
||
final info = await PackageInfo.fromPlatform();
|
||
if (!mounted) return;
|
||
setState(
|
||
() => _appVersion = 'v${info.version} (Build ${info.buildNumber})',
|
||
);
|
||
} catch (_) {}
|
||
}
|
||
|
||
Future<void> _loadProfilePrefs() async {
|
||
final prefs = await SharedPreferences.getInstance();
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_name = prefs.getString('user_name') ?? 'Rider Name';
|
||
// ── Empty, not invented ──
|
||
//
|
||
// These defaulted to `rider@miler.com` and `Not added`, and both were
|
||
// rendered as facts: a rider who had never entered an email read his own
|
||
// account page saying his address was `rider@miler.com`. A field with
|
||
// nothing behind it is a field the page does not draw.
|
||
_email = prefs.getString('user_email') ?? '';
|
||
_contact = prefs.getString('contactno') ?? '';
|
||
_prefsLoaded = true;
|
||
});
|
||
final userId = prefs.getInt('userid') ?? 0;
|
||
if (userId > 0) {
|
||
_rewardsController.fetchBonusSummary(userId);
|
||
_summaryController.fetchSummaryStats(userId);
|
||
}
|
||
}
|
||
|
||
void _assignFromController() {
|
||
setState(() {
|
||
if (_profileController.userName.value.trim().isNotEmpty) {
|
||
_name = _profileController.userName.value.trim();
|
||
}
|
||
if (_profileController.userEmail.value.trim().isNotEmpty) {
|
||
_email = _profileController.userEmail.value.trim();
|
||
}
|
||
if (_profileController.userContact.value.trim().isNotEmpty) {
|
||
_contact = _profileController.userContact.value.trim();
|
||
}
|
||
});
|
||
}
|
||
|
||
// ── The rider's control centre ────────────────────────────────────────────
|
||
//
|
||
// Read top to bottom the page is: who you are → what you have earned → what
|
||
// you can change → who you can ask → the way out. Identity leads because the
|
||
// header is also the doorway to the one thing on this page a rider is
|
||
// likeliest to have got wrong about himself.
|
||
//
|
||
// ── Why it stopped being cards ──
|
||
//
|
||
// Everything above the section labels used to be a `SettingsCard`: a white,
|
||
// outlined, shadowed panel for the profile, and three more side by side for
|
||
// the figures. On a white page that is four boxes drawn round the first
|
||
// screenful, each with a border that has to be told apart from the paper
|
||
// behind it — and the three figure cards carried a saturated brand glyph
|
||
// apiece, so the loudest things on the rider's own account page were three
|
||
// decorative icons.
|
||
//
|
||
// The rows below them had already lost their boxes (see [SettingsGroup]).
|
||
// This is the top of the page catching up: the identity block sits on the
|
||
// paper, the figures share one quiet neutral surface, and the only fill left
|
||
// above the fold is the one the eye is meant to find.
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Scaffold(
|
||
// ── The same white as the other tabs ──
|
||
//
|
||
// This was [ColorConstants.surface] — #FCF9F8, a warm off-white — while
|
||
// Home and Activity are on [ColorConstants.pureSurface]. On Bookings the
|
||
// difference never shows, because cards cover the page; Account is bare
|
||
// rows on the bare background, so the whole tab read a shade dirtier than
|
||
// the three either side of it. Four tabs of one app should not disagree
|
||
// about what colour the paper is.
|
||
// The brand shows through the sheet's two top corners — see [MilerSheet].
|
||
backgroundColor: ColorConstants.primary,
|
||
// Account was the odd tab out: no top bar at all, just a 28sp inline
|
||
// heading scrolling away with the content, while the other three sat
|
||
// under a fixed branded bar. Four tabs of one app should not disagree
|
||
// about where the page title lives.
|
||
appBar: MilerAppBar(title: 'Account', trailing: _bell()),
|
||
// ── Pull to refresh ──
|
||
//
|
||
// A pull here used to drag the page off the bottom of the screen and
|
||
// spring back having done nothing: `BouncingScrollPhysics` forces the iOS
|
||
// rubber-band on every platform, and rubber-band overscroll is unbounded
|
||
// unless a RefreshIndicator catches the gesture. The gesture responded,
|
||
// so it read as a refresh that had failed. Home was fixed this way; this
|
||
// is the same fix, and now the pull actually re-reads the profile, the
|
||
// points balance and the pickup count.
|
||
// ── Account joins the ladder ──
|
||
//
|
||
// The tab was borderless rows on bare white — Uber's account idiom on a
|
||
// page that had no layers. The rest of the app now stands white panels
|
||
// on the canvas, and a rider swiping across the four tabs watched three
|
||
// layered pages and one flat one. Same recipe here: canvas ground, one
|
||
// panel per section, the labels on the canvas between them.
|
||
body: MilerSheet(
|
||
color: MilerSurface.canvas,
|
||
child: SettingsBody(
|
||
onRefresh: _refresh,
|
||
// The page's single left edge — the same gutter Activity uses, so a
|
||
// rider moving between tabs does not watch the content step inwards.
|
||
// ── The floating nav bar is 90-odd points tall ──
|
||
//
|
||
// This reserved 28, so the foot of the list could never be scrolled
|
||
// clear of it: Help & Support's second line and the rows under it sat
|
||
// behind the bar permanently, which reads as text truncated
|
||
// mid-sentence rather than as content hidden. [BottomPage.bottomInset]
|
||
// is the one place that height is known — the same call Activity and
|
||
// Deliveries make.
|
||
// Horizontal 0: each panel carries `MilerSurface.panelGutter`
|
||
// itself, exactly as Home's sections do.
|
||
padding: EdgeInsets.fromLTRB(
|
||
0,
|
||
10.h,
|
||
0,
|
||
BottomPage.bottomInset(context) + 20.h,
|
||
),
|
||
children: [
|
||
// ── Who you are, and the day's three figures: one card ──
|
||
MilerPanel(
|
||
gap: 24,
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
_identity(),
|
||
SizedBox(height: 12.h),
|
||
_quickStrip(),
|
||
_figuresNotice(),
|
||
],
|
||
),
|
||
),
|
||
|
||
// ── Earnings ──
|
||
//
|
||
// It used to be a tab of its own. A pay figure is a once-or-twice-a-
|
||
// day read, and a permanent quarter of the nav bar is the most
|
||
// expensive place in the app to keep one — so it lives here, at the
|
||
// top of Account, where the rest of "things about me rather than
|
||
// about this shift" already lives. The tab it vacated now shows what
|
||
// he has finished today, which is the glance he actually makes
|
||
// between stops.
|
||
Padding(
|
||
padding: EdgeInsetsDirectional.only(
|
||
start: MilerSurface.panelGutter + MilerSurface.panelPad,
|
||
),
|
||
child: const SettingsLabel('Money'),
|
||
),
|
||
MilerPanel(
|
||
padded: false,
|
||
child: Padding(
|
||
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 6.h),
|
||
child: SettingsGroup(
|
||
rows: [
|
||
SettingsRow(
|
||
icon: LucideIcons.wallet,
|
||
title: 'Earnings',
|
||
subtitle: 'Today, this week and this month',
|
||
onTap: () => openSheet(context, const Summary()),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
SizedBox(height: 20.h),
|
||
|
||
Padding(
|
||
padding: EdgeInsetsDirectional.only(
|
||
start: MilerSurface.panelGutter + MilerSurface.panelPad,
|
||
),
|
||
child: const SettingsLabel('Account'),
|
||
),
|
||
MilerPanel(
|
||
padded: false,
|
||
child: Padding(
|
||
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 6.h),
|
||
child: SettingsGroup(
|
||
rows: [
|
||
SettingsRow(
|
||
icon: LucideIcons.user,
|
||
title: 'Edit Profile',
|
||
subtitle: _email.isNotEmpty ? _email : null,
|
||
onTap: () => openSheet(context, const Profile()),
|
||
),
|
||
SettingsRow(
|
||
icon: LucideIcons.mapPin,
|
||
title: 'Saved Address',
|
||
subtitle: 'Where your shift starts and ends',
|
||
onTap: () => openSheet(context, const SavedAddressPage()),
|
||
),
|
||
SettingsRow(
|
||
icon: LucideIcons.bellRing,
|
||
title: 'Alert Sound',
|
||
// The line's own noun, never a literal. It reads "booking" on
|
||
// a parcel route and "delivery" on a milk run, which is the
|
||
// same word the nav bar, the accepted pill and the empty
|
||
// states are already using — see [ServiceProfile.jobNoun].
|
||
subtitle:
|
||
'What a new ${ServiceProfile.active.jobNoun} sounds like',
|
||
onTap: () =>
|
||
openSheet(context, const OrderAlertSoundPage()),
|
||
),
|
||
SettingsRow(
|
||
icon: LucideIcons.bell,
|
||
title: 'Notifications',
|
||
onTap: () =>
|
||
openSheet(context, const NotificationsPage()),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
SizedBox(height: 20.h),
|
||
|
||
Padding(
|
||
padding: EdgeInsetsDirectional.only(
|
||
start: MilerSurface.panelGutter + MilerSurface.panelPad,
|
||
),
|
||
child: const SettingsLabel('Support'),
|
||
),
|
||
MilerPanel(
|
||
padded: false,
|
||
child: Padding(
|
||
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 6.h),
|
||
child: SettingsGroup(
|
||
rows: [
|
||
SettingsRow(
|
||
icon: LucideIcons.headphones,
|
||
title: 'Help & Support',
|
||
subtitle: 'Message the team — we reply within a day',
|
||
onTap: () => openSheet(context, const HelpCenter()),
|
||
),
|
||
SettingsRow(
|
||
icon: LucideIcons.circleHelp,
|
||
title: 'FAQ',
|
||
subtitle: 'Answers to the usual questions',
|
||
onTap: () => openSheet(context, const FaqPage()),
|
||
),
|
||
SettingsRow(
|
||
icon: LucideIcons.fileText,
|
||
title: 'Terms & Conditions',
|
||
onTap: () => openSheet(context, const TermsCondition()),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
SizedBox(height: 26.h),
|
||
|
||
// ── SOS ──
|
||
_emergency(),
|
||
|
||
// ── Ending the session is not a preference ──
|
||
//
|
||
// Logout sat 18pt under the emergency banner in the same group shape
|
||
// as Alert Sound, which put "sign me out" and "which noise a booking
|
||
// makes" on one visual footing. It is below a rule now, with a
|
||
// screenful of air above it, in the destructive red rather than the
|
||
// brand's.
|
||
SizedBox(height: 30.h),
|
||
Container(height: 1, color: ColorConstants.borderSubtle),
|
||
SizedBox(height: 4.h),
|
||
MilerPanel(
|
||
padded: false,
|
||
child: Padding(
|
||
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 6.h),
|
||
child: SettingsGroup(
|
||
rows: [
|
||
SettingsRow(
|
||
icon: LucideIcons.logOut,
|
||
title: 'Logout',
|
||
danger: true,
|
||
onTap: () => _showLogoutDialog(context),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
|
||
SizedBox(height: 26.h),
|
||
_footer(),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// The page's single left edge. Matches Activity's, so the four tabs share
|
||
/// one margin.
|
||
static double get _gutter => 20.w;
|
||
|
||
/// Re-reads everything the page shows. Deliberately awaits, so the spinner
|
||
/// stays down until the numbers are actually new.
|
||
Future<void> _refresh() async {
|
||
await _profileController.loadFromPrefs();
|
||
await _loadProfilePrefs();
|
||
await _loadAppVersion();
|
||
}
|
||
|
||
// ─── The bell ────────────────────────────────────────────────────────────
|
||
|
||
/// Unfilled, unoutlined, unshadowed.
|
||
///
|
||
/// It was a 42pt white disc with a hairline border and a drop shadow, sitting
|
||
/// on the app bar's frosted wash — a container, drawn round a glyph, on a
|
||
/// surface that is already a surface. What a rider needs from it is a target
|
||
/// he can hit without looking, which is the [PressScale] and the 44pt box,
|
||
/// not the ring.
|
||
Widget _bell() {
|
||
return PressScale(
|
||
onTap: () => openSheet(context, const NotificationsPage()),
|
||
semanticLabel: 'Notifications',
|
||
minTarget: true,
|
||
child: SizedBox(
|
||
width: ButtonSizes.minTapTarget,
|
||
height: ButtonSizes.minTapTarget,
|
||
child: Icon(
|
||
LucideIcons.bell,
|
||
// On the brand ground — see [MilerAppBar].
|
||
color: Colors.white,
|
||
size: 23.sp,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ─── Identity ────────────────────────────────────────────────────────────
|
||
|
||
/// Avatar, name, number, and the line of work he is on — on the page, not in
|
||
/// a box.
|
||
///
|
||
/// The whole block opens Edit Profile, which is why the "View profile" pill
|
||
/// that used to sit under the number is gone: a tappable row with a chevron
|
||
/// on the end already says where it goes, and the pill was a second control
|
||
/// for the same destination taking a third line to say so.
|
||
Widget _identity() {
|
||
return PressScale(
|
||
onTap: () => openSheet(context, const Profile()),
|
||
semanticLabel: 'Your profile',
|
||
child: Padding(
|
||
padding: EdgeInsets.symmetric(vertical: 8.h),
|
||
child: Row(
|
||
children: [
|
||
_avatar(),
|
||
SizedBox(width: 15.w),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
// ── The card waits before it speaks ──
|
||
//
|
||
// Name and number come off disk. Until they land this said
|
||
// "Rider Name / Not added" as if that were the answer, so the
|
||
// rider's first glimpse of his own account told him his
|
||
// number was missing.
|
||
if (!_prefsLoaded)
|
||
MilerShimmer(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
SkeletonBone(width: 156.w, height: 19.h, radius: 6),
|
||
SizedBox(height: 9.h),
|
||
SkeletonBone(width: 208.w, height: 13.h, radius: 6),
|
||
],
|
||
),
|
||
)
|
||
else ...[
|
||
Text(
|
||
_name.isNotEmpty ? _name : 'Rider Name',
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: MilerType.cardTitle.copyWith(fontSize: 20.sp),
|
||
),
|
||
SizedBox(height: 4.h),
|
||
_identityLine(),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
SizedBox(width: 8.w),
|
||
Icon(
|
||
LucideIcons.chevronRight,
|
||
color: ColorConstants.borderStrong,
|
||
size: 22.sp,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// `+91 98765 43210 · Doormile Milk Man`
|
||
///
|
||
/// ── Why the line of work is here ──
|
||
///
|
||
/// It is the second thing about a rider that is true all day and the first
|
||
/// one he cannot check anywhere else in the app: every screen adapts to it —
|
||
/// the tab is called Deliveries or Bookings, money is collected or it is not
|
||
/// — and nothing until now actually *named* it. It reads
|
||
/// [ServiceProfile.active], never a tenant id or a line name, so a third kind
|
||
/// of client is a new profile rather than a new branch here.
|
||
///
|
||
/// A [Wrap], because at 2× text scale a number and a service name are longer
|
||
/// than the phone and the line has to fold rather than clip.
|
||
Widget _identityLine() {
|
||
final String contact = _contact.trim();
|
||
final String line = ServiceProfile.active.label.trim();
|
||
|
||
final parts = <Widget>[
|
||
if (contact.isNotEmpty)
|
||
Text(contact, maxLines: 1, style: MilerType.label),
|
||
if (line.isNotEmpty)
|
||
Text(line, maxLines: 1, style: MilerType.label.semibold),
|
||
];
|
||
|
||
if (parts.isEmpty) {
|
||
return Text('Add your number in Edit Profile', style: MilerType.label);
|
||
}
|
||
|
||
return Wrap(
|
||
crossAxisAlignment: WrapCrossAlignment.center,
|
||
spacing: 7.w,
|
||
runSpacing: 2.h,
|
||
children: [
|
||
for (final (i, p) in parts.indexed) ...[
|
||
if (i > 0)
|
||
Text(
|
||
'·',
|
||
style: MilerType.label.copyWith(
|
||
fontWeight: FontWeight.w700,
|
||
color: ColorConstants.borderStrong,
|
||
),
|
||
),
|
||
p,
|
||
],
|
||
],
|
||
);
|
||
}
|
||
|
||
/// The rider's photo, or the glyph that stands in for one.
|
||
///
|
||
/// Guarded by an `existsSync`: an image-picker path points into a cache the
|
||
/// OS is free to clear, so a photo that is no longer on disk falls back to
|
||
/// the glyph rather than throwing behind a scroll view.
|
||
Widget _avatar() {
|
||
return Obx(() {
|
||
final path = _profileController.imagePath.value;
|
||
bool hasImage = false;
|
||
try {
|
||
hasImage = path.isNotEmpty && File(path).existsSync();
|
||
} catch (_) {}
|
||
return Container(
|
||
width: 60.w,
|
||
height: 60.w,
|
||
decoration: BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
// A tint of the brand rather than a neutral disc — this is identity,
|
||
// which is the one thing on this screen the accent is for.
|
||
color: hasImage
|
||
? ColorConstants.neutralLight
|
||
: ColorConstants.primary.withValues(alpha: 0.10),
|
||
image: hasImage
|
||
? DecorationImage(image: FileImage(File(path)), fit: BoxFit.cover)
|
||
: null,
|
||
),
|
||
child: !hasImage
|
||
? Icon(
|
||
LucideIcons.user,
|
||
color: ColorConstants.borderStrong,
|
||
size: 32.sp,
|
||
)
|
||
: null,
|
||
);
|
||
});
|
||
}
|
||
|
||
// ─── The three figures ───────────────────────────────────────────────────
|
||
|
||
/// One surface, three cells, two hairlines.
|
||
///
|
||
/// These were three separate outlined cards with a saturated brand glyph on
|
||
/// each. Three boxes to hold three numbers is three times the container for
|
||
/// the same content — and the glyphs were decoration: nothing about a gift
|
||
/// icon tells a rider what a points balance is that the word "Rewards"
|
||
/// underneath it did not already say.
|
||
///
|
||
/// One quiet neutral fill now, divided rather than repeated, with the figures
|
||
/// leading and the labels under them. It is the calmest shape that still
|
||
/// reads as three things you can press.
|
||
Widget _quickStrip() {
|
||
return Obx(
|
||
() => Container(
|
||
decoration: BoxDecoration(
|
||
// The canvas well, not `neutralLight`: inside the account card the
|
||
// old fill measured 1.02:1 against the white and the strip's three
|
||
// figures floated on nothing.
|
||
color: MilerSurface.canvas,
|
||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||
),
|
||
child: IntrinsicHeight(
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
_figureCell(
|
||
label: 'Rewards',
|
||
// A points balance the server could not be asked for is a gap,
|
||
// not a zero. A stale zero on a screen about somebody's pay is
|
||
// the one mistake worth writing extra code to avoid — see the
|
||
// note on [RewardsController].
|
||
value: _rewardsController.error.value.isNotEmpty
|
||
? null
|
||
: _formatCount(_rewardsController.totalPoints.value),
|
||
loading: _rewardsController.isLoading.value,
|
||
onTap: () => openSheet(context, const RiderRewardsPage()),
|
||
),
|
||
_cellRule(),
|
||
_figureCell(
|
||
// Named for the line, and for where the card goes. "Pickups"
|
||
// was a literal, and on a milk run — where every stop at a
|
||
// customer's door is a drop — it labelled the rider's whole
|
||
// year of work with the one thing he does at a kitchen.
|
||
label: ServiceProfile.active.workTabLabel,
|
||
value: _formatCount(_summaryController.total.value),
|
||
loading: _summaryController.isLoading.value,
|
||
// Was `onTap: () {}` — a card that looks tappable, sits next to
|
||
// two that are, and does nothing. It shows the rider's lifetime
|
||
// pickup count, so it goes where that count comes from.
|
||
onTap: () => BottomPage.goToTab(1),
|
||
),
|
||
_cellRule(),
|
||
_figureCell(
|
||
label: 'Help',
|
||
value: '24/7',
|
||
onTap: () => openSheet(context, const HelpCenter()),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _cellRule() => Padding(
|
||
padding: EdgeInsets.symmetric(vertical: 14.h),
|
||
child: Container(width: 1, color: ColorConstants.borderSubtle),
|
||
);
|
||
|
||
Widget _figureCell({
|
||
required String label,
|
||
required String? value,
|
||
required VoidCallback onTap,
|
||
bool loading = false,
|
||
}) {
|
||
return Expanded(
|
||
child: PressScale(
|
||
onTap: onTap,
|
||
semanticLabel: '$label, ${value ?? 'not available'}',
|
||
child: Padding(
|
||
padding: EdgeInsets.symmetric(vertical: 15.h, horizontal: 6.w),
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
// A stale zero reads as a real number. While the count is in
|
||
// flight, show that it is in flight.
|
||
if (loading)
|
||
Padding(
|
||
padding: EdgeInsets.symmetric(vertical: 3.h),
|
||
child: MilerShimmer(
|
||
child: SkeletonBone(width: 38.w, height: 16.h, radius: 6),
|
||
),
|
||
)
|
||
else
|
||
// Scaled down rather than truncated. At 2× text scale a
|
||
// four-digit balance ellipsised to "1,2…", which is a number
|
||
// nobody can read — and a figure that cannot be read is worse
|
||
// than a smaller one.
|
||
FittedBox(
|
||
fit: BoxFit.scaleDown,
|
||
child: Text(
|
||
value ?? '—',
|
||
maxLines: 1,
|
||
style: MilerType.figure(
|
||
19,
|
||
color: value == null
|
||
? ColorConstants.secondaryText
|
||
: ColorConstants.slateText,
|
||
),
|
||
),
|
||
),
|
||
SizedBox(height: 4.h),
|
||
Text(
|
||
label,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: MilerType.micro,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// One line, only when the figures above it could not be fetched.
|
||
///
|
||
/// Not a card and not a full-screen error: the rest of this page is read off
|
||
/// the phone and is perfectly good without a network, so a failed figure is
|
||
/// worth a sentence and a retry — not a state the whole tab has to sit in.
|
||
Widget _figuresNotice() {
|
||
return Obx(() {
|
||
if (_rewardsController.error.value.isEmpty) {
|
||
return const SizedBox.shrink();
|
||
}
|
||
return Padding(
|
||
padding: EdgeInsets.only(top: 10.h),
|
||
child: Row(
|
||
children: [
|
||
Icon(
|
||
LucideIcons.cloudOff,
|
||
size: 14.sp,
|
||
color: ColorConstants.warning,
|
||
),
|
||
SizedBox(width: 8.w),
|
||
Expanded(
|
||
child: Text(
|
||
'Could not refresh these figures',
|
||
maxLines: 2,
|
||
style: MilerType.micro.on(ColorConstants.warning),
|
||
),
|
||
),
|
||
SizedBox(width: 8.w),
|
||
PressScale(
|
||
onTap: _refresh,
|
||
semanticLabel: 'Retry',
|
||
minTarget: true,
|
||
child: Padding(
|
||
padding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 4.h),
|
||
child: Text(
|
||
'Retry',
|
||
style: MilerType.micro
|
||
.on(ColorConstants.primary)
|
||
.copyWith(fontWeight: FontWeight.w700),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
});
|
||
}
|
||
|
||
/// Formats an integer with thousands separators (e.g. 12450 -> "12,450").
|
||
String _formatCount(int value) {
|
||
final s = value.toString();
|
||
final buf = StringBuffer();
|
||
for (int i = 0; i < s.length; i++) {
|
||
if (i > 0 && (s.length - i) % 3 == 0) buf.write(',');
|
||
buf.write(s[i]);
|
||
}
|
||
return buf.toString();
|
||
}
|
||
|
||
// ─── SOS ─────────────────────────────────────────────────────────────────
|
||
|
||
/// India's emergency number. This was **911**, which does nothing on an
|
||
/// Indian network — the one control in the app that only matters when a rider
|
||
/// has been hit or threatened, wired to a number that cannot answer.
|
||
static const String _emergencyNumber = '112';
|
||
|
||
/// Fails loudly. `canLaunchUrl` returning false used to end the story: the
|
||
/// rider taps SOS in an emergency and the app does nothing at all.
|
||
Future<void> _callEmergency() async {
|
||
final Uri url = Uri(scheme: 'tel', path: _emergencyNumber);
|
||
bool launched = false;
|
||
try {
|
||
if (await canLaunchUrl(url)) {
|
||
launched = await launchUrl(url);
|
||
}
|
||
} catch (_) {
|
||
launched = false;
|
||
}
|
||
if (!launched && mounted) {
|
||
AppFeedback.error(
|
||
context,
|
||
'Could not place the call. Dial $_emergencyNumber from your phone.',
|
||
duration: const Duration(seconds: 8),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// See [EmergencyRow] — one widget, shared with Help & Support, so the
|
||
/// sentence promising the rider what a press will do cannot exist in two
|
||
/// versions.
|
||
Widget _emergency() =>
|
||
EmergencyRow(number: _emergencyNumber, onTap: _callEmergency);
|
||
|
||
// ─── Footer ──────────────────────────────────────────────────────────────
|
||
|
||
Widget _footer() {
|
||
return Center(
|
||
child: Text(
|
||
'Miler ${_appVersion.isNotEmpty ? _appVersion : ''}\n'
|
||
'© 2026 Doormile Logistics'
|
||
.trim(),
|
||
textAlign: TextAlign.center,
|
||
style: MilerType.micro.copyWith(height: 1.7),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
// ─── Logout dialog ────────────────────────────────────────────────────────────
|
||
|
||
void _showLogoutDialog(BuildContext context) {
|
||
showAppDialog(
|
||
context: context,
|
||
builder: (BuildContext context) {
|
||
return AlertDialog(
|
||
backgroundColor: ColorConstants.surfaceContainerLowest,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||
),
|
||
title: Text('Log out?', style: MilerType.cardTitle),
|
||
content: Text(
|
||
'Your work for today stays on this phone. You will sign back in with '
|
||
'your PIN.',
|
||
style: MilerType.caption,
|
||
),
|
||
actionsPadding: EdgeInsets.fromLTRB(20.w, 4.h, 20.w, 18.h),
|
||
actions: [
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: MilerButton(
|
||
label: "Cancel",
|
||
onPressed: () => Navigator.pop(context),
|
||
variant: MilerButtonVariant.outlined,
|
||
color: ColorConstants.onSurfaceVariant,
|
||
height: ButtonSizes.secondary,
|
||
),
|
||
),
|
||
SizedBox(width: 12.w),
|
||
Expanded(
|
||
child: MilerButton(
|
||
label: 'Log out',
|
||
// Destructive, so it is drawn destructive. The brand red on
|
||
// this button made the action that ends the session look
|
||
// like every other primary action in the app.
|
||
color: ColorConstants.errorRed,
|
||
onPressed: () async {
|
||
// ── The next rider on this handset starts empty ──
|
||
//
|
||
// Finished work, skipped stops, carried bags and bag
|
||
// labels are local, and they used to sit under global
|
||
// keys — so signing out and back in as somebody else
|
||
// showed them the previous rider's history. They are
|
||
// scoped per rider/tenant/line now ([WorkScope]), and
|
||
// this drops *this* scope's drawer on the way out so
|
||
// nothing survives the session that created it.
|
||
await clearScopedStores();
|
||
// Doorstep photos are exactly the kind of record that must
|
||
// not outlive the session that took them.
|
||
await ProofStore.clearScope();
|
||
final prefs = await SharedPreferences.getInstance();
|
||
await prefs.setBool('logged_out', true);
|
||
Get.offAll(() => const SignIn());
|
||
},
|
||
height: ButtonSizes.secondary,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
);
|
||
},
|
||
);
|
||
}
|