Files
doormile_milderapp/lib/views/Dashboard/profile/Profilepage.dart
2026-08-28 11:13:15 +05:30

883 lines
35 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ── 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/narrative.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 corner has to have something behind it ──
//
// Account had the sheet but not the shape: its two top corners clipped
// onto a white ground, so the curve revealed white and read as a
// rounding artefact rather than as the brand coming through. The brand
// goes behind the sheet, the same as the other three tabs; the page's
// own colour is the sheet's [MilerSurface.canvas].
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(),
// The bar's curve opens onto whatever is directly under it, and under
// this bar is the sheet's canvas rather than the app's paper. See
// [milerBrandSurface].
pageColor: Narrative.canvas,
),
// ── 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: Narrative.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: const EdgeInsetsDirectional.only(
start: MilerSurface.panelGutter,
),
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: const EdgeInsetsDirectional.only(
start: MilerSurface.panelGutter,
),
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: const EdgeInsetsDirectional.only(
start: MilerSurface.panelGutter,
),
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,
),
),
],
),
],
);
},
);
}