Files
2026-08-11 13:16:33 +05:30

626 lines
23 KiB
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/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/Font_constant.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:shared_preferences/shared_preferences.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';
_email = prefs.getString('user_email') ?? 'rider@miler.com';
_contact = prefs.getString('contactno') ?? 'Not added';
_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();
}
});
}
@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.
backgroundColor: ColorConstants.pureSurface,
// 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: _circleIconButton(
Icons.notifications_none_rounded,
() => openSheet(context, const NotificationsPage()),
semanticLabel: 'Notifications',
),
),
// ── 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.
body: SettingsBody(
onRefresh: _refresh,
children: [
// ── Who you are ──
_profileCard(),
SizedBox(height: 16.h),
// ── The three numbers worth a tap ──
_quickActions(),
SizedBox(height: 22.h),
// ── 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.
const SettingsLabel('Money'),
SettingsGroup(
rows: [
SettingsRow(
icon: Icons.account_balance_wallet_outlined,
title: 'Earnings',
subtitle: 'Today, this week and this month',
onTap: () => openSheet(context, const Summary()),
),
],
),
SizedBox(height: 18.h),
const SettingsLabel('Account'),
SettingsGroup(
rows: [
SettingsRow(
icon: Icons.person_outline_rounded,
title: 'Edit Profile',
subtitle: _email.isNotEmpty ? _email : null,
onTap: () => openSheet(context, const Profile()),
),
SettingsRow(
icon: Icons.location_on_outlined,
title: 'Saved Address',
subtitle: 'Where your shift starts and ends',
onTap: () => openSheet(context, const SavedAddressPage()),
),
SettingsRow(
icon: Icons.notifications_active_outlined,
title: 'Alert Sound',
subtitle: 'What a new booking sounds like',
onTap: () => openSheet(context, const OrderAlertSoundPage()),
),
SettingsRow(
icon: Icons.notifications_none_rounded,
title: 'Notifications',
onTap: () => openSheet(context, const NotificationsPage()),
),
],
),
SizedBox(height: 22.h),
const SettingsLabel('Support'),
SettingsGroup(
rows: [
SettingsRow(
icon: Icons.headset_mic_outlined,
title: 'Help & Support',
subtitle: 'Message the team — we reply within a day',
onTap: () => openSheet(context, const HelpCenter()),
),
SettingsRow(
icon: Icons.quiz_outlined,
title: 'FAQ',
subtitle: 'Answers to the usual questions',
onTap: () => openSheet(context, const FaqPage()),
),
SettingsRow(
icon: Icons.description_outlined,
title: 'Terms & Conditions',
onTap: () => openSheet(context, const TermsCondition()),
),
],
),
SizedBox(height: 16.h),
// ── SOS ──
_buildSOSBanner(),
SizedBox(height: 18.h),
SettingsGroup(
rows: [
SettingsRow(
icon: Icons.logout_rounded,
title: 'Logout',
danger: true,
onTap: () => _showLogoutDialog(context),
),
],
),
SizedBox(height: 22.h),
Center(
child: Text(
"Miler ${_appVersion.isNotEmpty ? _appVersion : ''}\n© 2026 Doormile Logistics"
.trim(),
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 11.sp,
color: ColorConstants.onSurfaceVariant,
fontFamily: FontConstants.fontFamily,
height: 1.6,
),
),
),
SizedBox(height: 20.h),
],
),
);
}
/// 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();
}
// ─── Section label ───────────────────────────────────────────────────────────
// ─── Header icon button ──────────────────────────────────────────────────────
Widget _circleIconButton(
IconData icon,
VoidCallback onTap, {
required String semanticLabel,
}) {
return PressScale(
onTap: onTap,
semanticLabel: semanticLabel,
minTarget: true,
child: Container(
width: 42.w,
height: 42.w,
decoration: BoxDecoration(
color: ColorConstants.surfaceContainerLowest,
shape: BoxShape.circle,
border: Border.all(color: ColorConstants.dividerColor),
boxShadow: [
BoxShadow(
color: ColorConstants.onSurface.withValues(alpha: 0.05),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Icon(icon, color: ColorConstants.onSurface, size: 22.sp),
),
);
}
// ─── Profile card ────────────────────────────────────────────────────────────
Widget _profileCard() {
return PressScale(
onTap: () => openSheet(context, const Profile()),
child: Container(
padding: EdgeInsets.all(16.w),
decoration: SettingsCard.decoration(),
child: Row(
children: [
Obx(() {
final path = _profileController.imagePath.value;
bool hasImage = false;
try {
hasImage = path.isNotEmpty && File(path).existsSync();
} catch (_) {}
return Container(
width: 64.w,
height: 64.w,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: ColorConstants.neutralLight,
image: hasImage
? DecorationImage(
image: FileImage(File(path)),
fit: BoxFit.cover,
)
: null,
),
child: !hasImage
? Icon(
Icons.person_rounded,
color: ColorConstants.onSurfaceVariant,
size: 34.sp,
)
: null,
);
}),
SizedBox(width: 14.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!_prefsLoaded) ...[
MilerShimmer(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SkeletonBone(width: 150.w, height: 18.h),
SizedBox(height: 7.h),
SkeletonBone(width: 110.w, height: 13.h),
],
),
),
] else ...[
Text(
_name.isNotEmpty ? _name : "Rider Name",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.w700,
color: ColorConstants.onSurface,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 3.h),
Text(
_contact.isNotEmpty ? _contact : "Not added",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13.sp,
color: ColorConstants.onSurfaceVariant,
fontFamily: FontConstants.fontFamily,
),
),
],
SizedBox(height: 8.h),
Container(
padding: EdgeInsets.symmetric(
horizontal: 10.w,
vertical: 4.h,
),
decoration: BoxDecoration(
color: ColorConstants.primary.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
),
child: Text(
"View profile",
style: TextStyle(
fontSize: 11.5.sp,
fontWeight: FontWeight.w700,
color: ColorConstants.primary,
fontFamily: FontConstants.fontFamily,
),
),
),
],
),
),
Icon(
Icons.chevron_right_rounded,
color: ColorConstants.onSurfaceVariant,
size: 22.sp,
),
],
),
),
);
}
// ─── Quick actions ───────────────────────────────────────────────────────────
Widget _quickActions() {
return Obx(
() => Row(
children: [
_quickCard(
Icons.card_giftcard_rounded,
"Rewards",
value: _formatCount(_rewardsController.totalPoints.value),
loading: _rewardsController.isLoading.value,
onTap: () => openSheet(context, const RiderRewardsPage()),
),
SizedBox(width: 12.w),
_quickCard(
Icons.local_shipping_rounded,
"Pickups",
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),
),
SizedBox(width: 12.w),
_quickCard(
Icons.headset_mic_rounded,
"Help",
value: "24/7",
onTap: () => openSheet(context, const HelpCenter()),
),
],
),
);
}
Widget _quickCard(
IconData icon,
String label, {
String? value,
VoidCallback? onTap,
bool loading = false,
}) {
return Expanded(
child: PressScale(
onTap: onTap,
child: Container(
padding: EdgeInsets.symmetric(vertical: 16.h, horizontal: 6.w),
decoration: SettingsCard.decoration(),
child: Column(
children: [
Icon(icon, color: ColorConstants.primary, size: 24.sp),
SizedBox(height: 8.h),
if (value != null) ...[
// 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: 34.w, height: 15.h),
),
)
else
Text(
value,
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w800,
color: ColorConstants.onSurface,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 1.h),
],
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12.sp,
fontWeight: value != null ? FontWeight.w500 : FontWeight.w600,
color: value != null
? ColorConstants.onSurfaceVariant
: ColorConstants.onSurface,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
),
);
}
/// 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();
}
/// 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),
);
}
}
// ─── SOS banner ─────────────────────────────────────────────────────────────
Widget _buildSOSBanner() {
return GestureDetector(
onTap: _callEmergency,
child: InfoBanner(
text:
"Emergency Response — Tap to call SOS if you are in immediate danger.",
icon: Icons.warning_amber_rounded,
color: ColorConstants.primary,
),
);
}
}
// ─── 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(
"Logout",
style: TextStyle(
fontSize: 17.sp,
fontWeight: FontWeight.w700,
fontFamily: FontConstants.fontFamily,
color: ColorConstants.onSurface,
),
),
content: Text(
"Are you sure you want to logout?",
style: TextStyle(
fontSize: 14.sp,
color: ColorConstants.onSurfaceVariant,
fontFamily: FontConstants.fontFamily,
),
),
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: "Logout",
onPressed: () async {
final prefs = await SharedPreferences.getInstance();
prefs.setBool('logged_out', true);
Get.offAll(() => const SignIn());
},
height: ButtonSizes.secondary,
),
),
],
),
],
);
},
);
}