626 lines
23 KiB
Dart
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|