first commit
This commit is contained in:
625
lib/views/Dashboard/profile/Profilepage.dart
Normal file
625
lib/views/Dashboard/profile/Profilepage.dart
Normal file
@@ -0,0 +1,625 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
387
lib/views/Dashboard/profile/informations/faq.dart
Normal file
387
lib/views/Dashboard/profile/informations/faq.dart
Normal file
@@ -0,0 +1,387 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import 'package:miler/views/Dashboard/profile/informations/help_center.dart';
|
||||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||
import 'package:miler/views/helpers/constants/design_constants.dart';
|
||||
import 'package:miler/views/helpers/constants/Font_constant.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';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// FAQ
|
||||
///
|
||||
/// ── What was here before ──
|
||||
///
|
||||
/// A page titled **"Help Center"** — which is a different page in this app,
|
||||
/// one row above it in the Account list — opening on a full-bleed red hero, a
|
||||
/// search field that searched nothing, and a grid of four category cards
|
||||
/// ("Rider Guidelines", "Safety Tips", "Payment Issues", "Technical Support")
|
||||
/// that were not tappable and led nowhere. Under all of that sat three actual
|
||||
/// answers, and under those a second SOS card duplicating the one on Account.
|
||||
///
|
||||
/// Three quarters of the screen was furniture. Worse, the two most prominent
|
||||
/// elements on it — the search box and the category grid — were inert. A
|
||||
/// control that does not respond teaches the rider that the app is broken, and
|
||||
/// he stops trying the ones that do work. Absent beats fake.
|
||||
///
|
||||
/// ── What it is now ──
|
||||
///
|
||||
/// The answers, first, in the section's card. A live search that actually
|
||||
/// filters them, shown only because it now does something. And one route out
|
||||
/// when the answer is not here: message support. The SOS lives on Account,
|
||||
/// where a rider in trouble already knows to find it, and is not repeated.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
class FaqPage extends StatefulWidget {
|
||||
const FaqPage({super.key});
|
||||
|
||||
@override
|
||||
State<FaqPage> createState() => _FaqPageState();
|
||||
}
|
||||
|
||||
class _FaqPageState extends State<FaqPage> {
|
||||
final TextEditingController _search = TextEditingController();
|
||||
String _query = '';
|
||||
|
||||
/// Written for this app, not for a generic delivery app: slot-based trips
|
||||
/// assigned by a hub, a round trip back to the hub, skip-don't-reorder, and
|
||||
/// cash the rider is carrying on someone else's behalf.
|
||||
static const List<_Faq> _faqs = [
|
||||
_Faq(
|
||||
'What is a trip, and why can I only work one stop at a time?',
|
||||
'Your hub assigns a whole slot as one trip: pick up from every customer '
|
||||
'on it, then return to the hub. The stops are ordered for you, so the '
|
||||
'app opens them one at a time — finish the active stop before the '
|
||||
'next one unlocks.',
|
||||
),
|
||||
_Faq(
|
||||
'A customer is not answering. Can I move them to the end?',
|
||||
'No — a trip keeps its order. Skip the stop with a reason instead. It '
|
||||
'comes out of your queue, your hub sees why, and the rest of the trip '
|
||||
'carries on unchanged.',
|
||||
),
|
||||
_Faq(
|
||||
'I am running late because of traffic. What should I do?',
|
||||
'Keep the app open so your location keeps reporting — the hub can see '
|
||||
'the delay without you calling. If a customer needs to know, use the '
|
||||
'call button on the stop.',
|
||||
),
|
||||
_Faq(
|
||||
'The app says I am too far from the pickup.',
|
||||
'Arrival is confirmed by GPS, within 500 m of the pickup point. If you '
|
||||
'are at the door and it still refuses, wait a few seconds for the fix '
|
||||
'to settle, and check that location permission is set to "Allow all '
|
||||
'the time".',
|
||||
),
|
||||
_Faq(
|
||||
'I collected cash. Whose money is it?',
|
||||
'It is the customer\'s payment to the business you are picking up from, '
|
||||
'held by you until you hand it in at the hub. Record it in the app at '
|
||||
'the stop so the amount you carry and the amount expected match.',
|
||||
),
|
||||
_Faq(
|
||||
'When do I get paid, and what are points?',
|
||||
'Earnings are settled by your hub on its regular cycle — Earnings shows '
|
||||
'what has been counted so far. Points are separate: they are earned '
|
||||
'on completed trips and shown under Rewards.',
|
||||
),
|
||||
_Faq(
|
||||
'I did not hear a new booking arrive.',
|
||||
'Check Account → Alert Sound, play the sound to be sure you can hear it '
|
||||
'over traffic, and make sure the phone is not on silent. Miler also '
|
||||
'needs battery optimisation turned off to alert you reliably.',
|
||||
),
|
||||
_Faq(
|
||||
'How do I change my name, photo or address?',
|
||||
'Account → Edit Profile for your name, photo and email; Account → Saved '
|
||||
'Address for your address. Your mobile number is fixed to the one '
|
||||
'your hub registered — contact support to change it.',
|
||||
),
|
||||
];
|
||||
|
||||
List<_Faq> get _results {
|
||||
final q = _query.trim().toLowerCase();
|
||||
if (q.isEmpty) return _faqs;
|
||||
return _faqs
|
||||
.where(
|
||||
(f) =>
|
||||
f.question.toLowerCase().contains(q) ||
|
||||
f.answer.toLowerCase().contains(q),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_search.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final results = _results;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: ColorConstants.surface,
|
||||
appBar: milerPageBar('FAQ', onBack: () => Get.back()),
|
||||
body: SettingsBody(
|
||||
children: [
|
||||
_searchField(),
|
||||
SizedBox(height: 18.h),
|
||||
if (results.isEmpty)
|
||||
_noResults()
|
||||
else
|
||||
SettingsCard(
|
||||
child: Column(
|
||||
children: [
|
||||
for (int i = 0; i < results.length; i++) ...[
|
||||
_FaqTile(faq: results[i]),
|
||||
if (i != results.length - 1)
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.w),
|
||||
child: Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
color: ColorConstants.dividerColor.withValues(
|
||||
alpha: 0.6,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 22.h),
|
||||
_stillStuck(),
|
||||
SizedBox(height: 12.h),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _searchField() {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.surfaceContainerLowest,
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
border: Border.all(color: ColorConstants.dividerColor),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _search,
|
||||
onChanged: (v) => setState(() => _query = v),
|
||||
style: TextStyle(
|
||||
fontSize: 14.5.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ColorConstants.onSurface,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search answers',
|
||||
hintStyle: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
prefixIcon: Icon(
|
||||
Icons.search_rounded,
|
||||
size: 20.sp,
|
||||
color: ColorConstants.onSurfaceVariant,
|
||||
),
|
||||
suffixIcon: _query.isEmpty
|
||||
? null
|
||||
: IconButton(
|
||||
tooltip: 'Close',
|
||||
icon: Icon(
|
||||
Icons.close_rounded,
|
||||
size: 18.sp,
|
||||
color: ColorConstants.onSurfaceVariant,
|
||||
),
|
||||
onPressed: () {
|
||||
_search.clear();
|
||||
setState(() => _query = '');
|
||||
},
|
||||
),
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 14.w,
|
||||
vertical: 14.h,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _noResults() {
|
||||
return SettingsCard(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 28.h),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.search_off_rounded,
|
||||
size: 34.sp,
|
||||
color: ColorConstants.onSurfaceVariant,
|
||||
),
|
||||
SizedBox(height: 12.h),
|
||||
Text(
|
||||
'No answer matches “${_query.trim()}”',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: ColorConstants.onSurface,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 6.h),
|
||||
Text(
|
||||
'Send us the question and we will answer it directly.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 12.5.sp,
|
||||
height: 1.45,
|
||||
color: ColorConstants.onSurfaceVariant,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The one way out of this page. A rider who did not find his answer needs a
|
||||
/// person, not another list.
|
||||
Widget _stillStuck() {
|
||||
return SettingsCard(
|
||||
padding: EdgeInsets.all(16.w),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Still stuck?',
|
||||
style: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: ColorConstants.onSurface,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 4.h),
|
||||
Text(
|
||||
'Message the support team — we usually reply within a day.',
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
height: 1.45,
|
||||
color: ColorConstants.onSurfaceVariant,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 14.h),
|
||||
MilerButton(
|
||||
label: 'Contact support',
|
||||
icon: Icons.mail_outline_rounded,
|
||||
variant: MilerButtonVariant.tonal,
|
||||
height: ButtonSizes.secondary,
|
||||
onPressed: () => openSheet(context, const HelpCenterMessage()),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One question, expanding to its answer.
|
||||
///
|
||||
/// Hand-rolled rather than an `ExpansionTile` so it matches [SettingsRow]'s
|
||||
/// metrics — the tile brought Material's own 56dp list geometry, its own
|
||||
/// chevron colour and a `Theme` override to suppress its dividers, none of
|
||||
/// which line up with the rest of the section.
|
||||
class _FaqTile extends StatefulWidget {
|
||||
final _Faq faq;
|
||||
const _FaqTile({required this.faq});
|
||||
|
||||
@override
|
||||
State<_FaqTile> createState() => _FaqTileState();
|
||||
}
|
||||
|
||||
class _FaqTileState extends State<_FaqTile> {
|
||||
bool _open = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PressScale(
|
||||
onTap: () => setState(() => _open = !_open),
|
||||
scale: 0.99,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 15.h),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.faq.question,
|
||||
style: TextStyle(
|
||||
fontSize: 14.5.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.35,
|
||||
color: ColorConstants.onSurface,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 12.w),
|
||||
AnimatedRotation(
|
||||
turns: _open ? 0.5 : 0,
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeOutCubic,
|
||||
child: Icon(
|
||||
Icons.expand_more_rounded,
|
||||
size: 22.sp,
|
||||
color: _open
|
||||
? ColorConstants.primary
|
||||
: ColorConstants.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// AnimatedSize rather than a hard show/hide: the card grows into
|
||||
// the answer, which is the same easing everything else in the app
|
||||
// moves on.
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 240),
|
||||
curve: Curves.easeOutCubic,
|
||||
alignment: Alignment.topCenter,
|
||||
child: _open
|
||||
? Padding(
|
||||
padding: EdgeInsets.only(top: 10.h, right: 24.w),
|
||||
child: Text(
|
||||
widget.faq.answer,
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
height: 1.55,
|
||||
color: ColorConstants.onSurfaceVariant,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox(width: double.infinity),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Faq {
|
||||
final String question;
|
||||
final String answer;
|
||||
const _Faq(this.question, this.answer);
|
||||
}
|
||||
340
lib/views/Dashboard/profile/informations/help_center.dart
Normal file
340
lib/views/Dashboard/profile/informations/help_center.dart
Normal file
@@ -0,0 +1,340 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import 'package:miler/data/miler_api.dart';
|
||||
import 'package:miler/views/Dashboard/profile/informations/faq.dart';
|
||||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||
import 'package:miler/views/helpers/constants/design_constants.dart';
|
||||
import 'package:miler/views/helpers/constants/Font_constant.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';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// HELP & SUPPORT
|
||||
///
|
||||
/// Contact-first. A rider opening this screen has already failed to solve
|
||||
/// something on his own, usually mid-shift with a customer waiting, so the
|
||||
/// fastest route to a human is the top of the page and the FAQ is the
|
||||
/// alternative underneath — not the other way round.
|
||||
///
|
||||
/// The page kept its own copies of the card and row builders; they are now the
|
||||
/// shared [SettingsCard] / [SettingsRow], so it cannot drift from Account
|
||||
/// again.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
class HelpCenter extends StatelessWidget {
|
||||
const HelpCenter({super.key});
|
||||
|
||||
/// The hub's support line. One constant, used by the call row.
|
||||
static const String _supportPhone = '+918925545000';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: ColorConstants.surface,
|
||||
appBar: milerPageBar('Help & Support', onBack: () => Get.back()),
|
||||
body: SettingsBody(
|
||||
children: [
|
||||
_intro(),
|
||||
SizedBox(height: 22.h),
|
||||
const SettingsLabel('Talk to us'),
|
||||
SettingsGroup(
|
||||
rows: [
|
||||
SettingsRow(
|
||||
icon: Icons.mail_outline_rounded,
|
||||
title: 'Send a message',
|
||||
subtitle: 'We usually reply within a day',
|
||||
onTap: () => openSheet(context, const HelpCenterMessage()),
|
||||
),
|
||||
SettingsRow(
|
||||
icon: Icons.call_outlined,
|
||||
title: 'Call the hub',
|
||||
subtitle: 'For anything that cannot wait',
|
||||
onTap: () => _dial(context, _supportPhone),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 22.h),
|
||||
const SettingsLabel('Answer it yourself'),
|
||||
SettingsGroup(
|
||||
rows: [
|
||||
SettingsRow(
|
||||
icon: Icons.quiz_outlined,
|
||||
title: 'Browse the FAQ',
|
||||
subtitle: 'Trips, skips, cash, alerts and payments',
|
||||
onTap: () => openSheet(context, const FaqPage()),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 22.h),
|
||||
_emergency(context),
|
||||
SizedBox(height: 12.h),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _intro() {
|
||||
return SettingsCard(
|
||||
padding: EdgeInsets.all(18.w),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'How can we help?',
|
||||
style: TextStyle(
|
||||
fontSize: 20.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.4,
|
||||
color: ColorConstants.onSurface,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 6.h),
|
||||
Text(
|
||||
'Tell us what happened — the stop, the customer, what the app did — '
|
||||
'and we will pick it up from your side of the trip.',
|
||||
style: TextStyle(
|
||||
fontSize: 13.5.sp,
|
||||
height: 1.5,
|
||||
color: ColorConstants.onSurfaceVariant,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Distinct from support: this is the one for an accident or a threat, and it
|
||||
/// is deliberately the only red-filled thing on the page.
|
||||
Widget _emergency(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: () => _dial(context, '112'),
|
||||
child: InfoBanner(
|
||||
text:
|
||||
'Emergency — tap to call for help if you are in immediate danger.',
|
||||
icon: Icons.warning_amber_rounded,
|
||||
color: ColorConstants.primary,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Says so when the dialer will not open, rather than absorbing the tap.
|
||||
static Future<void> _dial(BuildContext context, String number) async {
|
||||
final Uri url = Uri(scheme: 'tel', path: number);
|
||||
bool launched = false;
|
||||
try {
|
||||
if (await canLaunchUrl(url)) launched = await launchUrl(url);
|
||||
} catch (_) {
|
||||
launched = false;
|
||||
}
|
||||
if (!launched && context.mounted) {
|
||||
AppFeedback.error(context, 'Could not place the call. Dial $number.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// CONTACT SUPPORT
|
||||
///
|
||||
/// Opens as a task sheet from the bottom, because it is something the rider
|
||||
/// finishes or abandons rather than browses.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
class HelpCenterMessage extends StatefulWidget {
|
||||
const HelpCenterMessage({super.key});
|
||||
|
||||
@override
|
||||
State<HelpCenterMessage> createState() => _HelpCenterMessageState();
|
||||
}
|
||||
|
||||
class _HelpCenterMessageState extends State<HelpCenterMessage> {
|
||||
final _subjectController = TextEditingController();
|
||||
final _messageController = TextEditingController();
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
bool _sending = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_subjectController.dispose();
|
||||
_messageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// ── This used to be a 400ms lie ──
|
||||
///
|
||||
/// The old body was `await Future.delayed(400ms)` followed by "Our team will
|
||||
/// get back to you." Nothing was sent anywhere. A rider with a genuine
|
||||
/// problem — a customer who would not pay, a parcel that did not match —
|
||||
/// typed it out, was told it had been received, and it had not been.
|
||||
///
|
||||
/// It now posts a real ticket, and says so only when the server agrees.
|
||||
Future<void> _send() async {
|
||||
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||
FocusScope.of(context).unfocus();
|
||||
setState(() => _sending = true);
|
||||
|
||||
final res = await MilerApi.createSupportTicket(
|
||||
subject: _subjectController.text.trim(),
|
||||
description: _messageController.text.trim(),
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() => _sending = false);
|
||||
|
||||
if (!res.ok) {
|
||||
// Kept on screen with the text intact — a rider who has just typed out a
|
||||
// problem should not have to type it again.
|
||||
AppFeedback.error(
|
||||
context,
|
||||
res.message.isEmpty
|
||||
? "Couldn't send that — check your signal and try again"
|
||||
: res.message,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Get.back();
|
||||
AppFeedback.successGlobal('Our team will get back to you.');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: ColorConstants.surface,
|
||||
appBar: milerPageBar('Contact Support', onBack: () => Get.back()),
|
||||
body: SafeArea(
|
||||
bottom: false,
|
||||
child: SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: EdgeInsets.fromLTRB(16.w, 14.h, 16.w, 24.h),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Reveal(
|
||||
child: SettingsCard(
|
||||
padding: EdgeInsets.all(16.w),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'What has happened?',
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: ColorConstants.onSurface,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 6.h),
|
||||
Text(
|
||||
'Include the order ID if you have it — it gets your '
|
||||
'message to the right person faster.',
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
height: 1.45,
|
||||
color: ColorConstants.onSurfaceVariant,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 18.h),
|
||||
_label('Subject'),
|
||||
SizedBox(height: 8.h),
|
||||
_field(
|
||||
controller: _subjectController,
|
||||
hint: 'What is this about?',
|
||||
validator: (v) => (v == null || v.trim().isEmpty)
|
||||
? 'Please enter a subject'
|
||||
: null,
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
_label('Your message'),
|
||||
SizedBox(height: 8.h),
|
||||
_field(
|
||||
controller: _messageController,
|
||||
hint: 'Describe your issue…',
|
||||
minLines: 5,
|
||||
maxLines: 8,
|
||||
validator: (v) => (v == null || v.trim().isEmpty)
|
||||
? 'Please enter your message'
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: SafeArea(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(16.w, 8.h, 16.w, 12.h),
|
||||
child: PrimaryButton(
|
||||
label: 'Send message',
|
||||
loading: _sending,
|
||||
onPressed: _send,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _label(String text) => Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: ColorConstants.onSurface,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
);
|
||||
|
||||
Widget _field({
|
||||
required TextEditingController controller,
|
||||
required String hint,
|
||||
int minLines = 1,
|
||||
int maxLines = 1,
|
||||
String? Function(String?)? validator,
|
||||
}) {
|
||||
return TextFormField(
|
||||
controller: controller,
|
||||
minLines: minLines,
|
||||
maxLines: maxLines,
|
||||
validator: validator,
|
||||
style: TextStyle(
|
||||
fontSize: 14.5.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ColorConstants.onSurface,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: hint,
|
||||
hintStyle: TextStyle(
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
fontWeight: FontWeight.w400,
|
||||
fontSize: 14.sp,
|
||||
),
|
||||
filled: true,
|
||||
// A white field on a white card has no edge but its border.
|
||||
// The neutral fill is what separates them.
|
||||
fillColor: ColorConstants.neutralLight,
|
||||
alignLabelWithHint: true,
|
||||
contentPadding: EdgeInsets.symmetric(vertical: 14.h, horizontal: 14.w),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||
borderSide: BorderSide(color: ColorConstants.dividerColor),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||
borderSide: BorderSide(color: ColorConstants.dividerColor),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||
borderSide: BorderSide(color: ColorConstants.primary, width: 1.5),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
364
lib/views/Dashboard/profile/informations/notifications_page.dart
Normal file
364
lib/views/Dashboard/profile/informations/notifications_page.dart
Normal file
@@ -0,0 +1,364 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:miler/data/miler_api.dart';
|
||||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||
import 'package:miler/views/helpers/constants/Font_constant.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/views/helpers/constants/design_constants.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// NOTIFICATIONS
|
||||
///
|
||||
/// Reached from the bell on Account, and from the Account list. It is the
|
||||
/// screen most likely to be opened straight after Account, which is why the
|
||||
/// mismatch mattered: it was drawn on `grey.shade50` with `grey.shade100`
|
||||
/// cards, `Colors.black87` titles and `grey.shade600` body — none of them the
|
||||
/// app's tokens — so tapping the bell moved the rider to what looked like a
|
||||
/// different product.
|
||||
///
|
||||
/// Two other things were wrong, and both were worse than the styling:
|
||||
///
|
||||
/// 1. **It invented notifications.** When the log was empty the page filled
|
||||
/// it with three fabricated entries — "Pickup at 123 Main St. Estimated
|
||||
/// payout $12.40", a "$342.50" weekly payout, a maintenance window — shown
|
||||
/// to the rider as if they were real, in dollars, in an app that pays in
|
||||
/// rupees. A rider who reads that his payout has been transferred and finds
|
||||
/// nothing in his bank has been lied to by his own app. Placeholder data
|
||||
/// does not belong on a screen a real person reads; an empty inbox is a
|
||||
/// perfectly good thing to say, and now it says it.
|
||||
///
|
||||
/// 2. **It had filters that did not exist.** `_selectedFilter`, a four-entry
|
||||
/// `_filters` list and a whole `_buildFilterChips` widget were carried in
|
||||
/// the state and referenced by the list — but the chips were never built,
|
||||
/// so the filter could never be anything but "All". Dead controls are worse
|
||||
/// than missing ones; this is now genuinely one list.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
class NotificationsPage extends StatefulWidget {
|
||||
const NotificationsPage({super.key});
|
||||
|
||||
@override
|
||||
State<NotificationsPage> createState() => _NotificationsPageState();
|
||||
}
|
||||
|
||||
class _NotificationsPageState extends State<NotificationsPage> {
|
||||
List<Map<String, dynamic>> _items = const [];
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
/// Two sources, deliberately.
|
||||
///
|
||||
/// `GET /miler/notifications` is synthesized fresh from the rider's
|
||||
/// `BookingAssignment` rows on every call — so it is really "what the hub has
|
||||
/// assigned you", and it is authoritative for that. The local log is the
|
||||
/// push messages this device actually received, which the server list knows
|
||||
/// nothing about. Neither is a superset of the other, so both are shown.
|
||||
///
|
||||
/// **Read state is not modelled.** `PATCH /notifications/:id/read` returns
|
||||
/// success without persisting anything — there is no table behind it — and
|
||||
/// the server's `id` is just an array index, valid only until the next fetch.
|
||||
/// So nothing here marks anything as read, and nothing should be built on top
|
||||
/// of it until that table exists.
|
||||
Future<void> _load() async {
|
||||
final merged = <Map<String, dynamic>>[];
|
||||
|
||||
// Server first: assignments are the ones that matter operationally.
|
||||
final res = await MilerApi.notifications();
|
||||
if (res.ok) {
|
||||
for (final row in res.list.whereType<Map>()) {
|
||||
merged.add({
|
||||
'title': (row['title'] ?? 'New assignment').toString(),
|
||||
'body': (row['message'] ?? row['body'] ?? '').toString(),
|
||||
'time': (row['createdat'] ?? row['time'] ?? '').toString(),
|
||||
'type': (row['type'] ?? 'assignment').toString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Then whatever actually arrived on this handset.
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString('notifications_log');
|
||||
if (raw != null && raw.isNotEmpty) {
|
||||
try {
|
||||
final list = jsonDecode(raw) as List<dynamic>;
|
||||
merged.addAll(
|
||||
list.map((e) => (e as Map).map((k, v) => MapEntry(k.toString(), v))),
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items = merged;
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _clearAll() async {
|
||||
final ok = await showAppDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: ColorConstants.surfaceContainerLowest,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
),
|
||||
title: Text(
|
||||
'Clear notifications?',
|
||||
style: TextStyle(
|
||||
fontSize: 17.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: ColorConstants.onSurface,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
content: Text(
|
||||
'This removes them from this phone. It does not undo anything they '
|
||||
'told you about.',
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
height: 1.45,
|
||||
color: ColorConstants.onSurfaceVariant,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text(
|
||||
'Cancel',
|
||||
style: TextStyle(
|
||||
color: ColorConstants.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: Text(
|
||||
'Clear',
|
||||
style: TextStyle(
|
||||
color: ColorConstants.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok != true) return;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove('notifications_log');
|
||||
if (!mounted) return;
|
||||
setState(() => _items = const []);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: ColorConstants.surface,
|
||||
appBar: milerPageBar(
|
||||
'Notifications',
|
||||
onBack: () => Get.back(),
|
||||
actions: [
|
||||
if (!_loading && _items.isNotEmpty)
|
||||
TextButton(
|
||||
onPressed: _clearAll,
|
||||
child: Text(
|
||||
'Clear',
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: ColorConstants.primary,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SmoothSwap(
|
||||
child: _loading
|
||||
? const SkeletonList(count: 5)
|
||||
: _items.isEmpty
|
||||
? const MilerEmptyState(
|
||||
icon: Icons.notifications_none_rounded,
|
||||
title: 'No notifications yet',
|
||||
message:
|
||||
'Updates about your bookings, your shift and your '
|
||||
'earnings arrive here.',
|
||||
)
|
||||
: RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
color: ColorConstants.primary,
|
||||
backgroundColor: ColorConstants.surfaceContainerLowest,
|
||||
child: ListView.separated(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: EdgeInsets.fromLTRB(16.w, 14.h, 16.w, 28.h),
|
||||
itemCount: _items.length,
|
||||
separatorBuilder: (_, _) => SizedBox(height: 12.h),
|
||||
itemBuilder: (context, index) {
|
||||
final it = _items[index];
|
||||
return Reveal(
|
||||
key: ValueKey('notif_${it['id'] ?? index}'),
|
||||
delay: staggerDelay(index),
|
||||
child: _NotificationCard(item: it),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One notification, in the section's card.
|
||||
///
|
||||
/// The kind badge is the only colour on the card, and it comes from the token
|
||||
/// set: brand red for work, the money green for earnings, the info blue for
|
||||
/// everything else. It used to be `Colors.green.shade700` on `green.shade50`,
|
||||
/// `Colors.blue.shade700` on `blue.shade50` and a raw `0xFFFEE2E2` — three
|
||||
/// palettes that appear nowhere else in the app.
|
||||
class _NotificationCard extends StatelessWidget {
|
||||
final Map<String, dynamic> item;
|
||||
|
||||
const _NotificationCard({required this.item});
|
||||
|
||||
({IconData icon, Color accent}) _kind() {
|
||||
final type = (item['type'] ?? '').toString().toLowerCase();
|
||||
final title = (item['title'] ?? '').toString().toLowerCase();
|
||||
|
||||
if (type == 'earning' ||
|
||||
title.contains('payment') ||
|
||||
title.contains('payout') ||
|
||||
title.contains('earning')) {
|
||||
return (
|
||||
icon: Icons.account_balance_wallet_outlined,
|
||||
accent: ColorConstants.moneyGreen,
|
||||
);
|
||||
}
|
||||
if (type == 'task' ||
|
||||
title.contains('pickup') ||
|
||||
title.contains('booking') ||
|
||||
title.contains('trip')) {
|
||||
return (
|
||||
icon: Icons.local_shipping_outlined,
|
||||
accent: ColorConstants.primary,
|
||||
);
|
||||
}
|
||||
return (icon: Icons.info_outline_rounded, accent: ColorConstants.tertiary);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kind = _kind();
|
||||
final title = (item['title'] ?? 'Miler').toString();
|
||||
final body = (item['body'] ?? '').toString();
|
||||
final time = (item['time'] ?? 'Just now').toString();
|
||||
final imageUrl = (item['imageUrl'] ?? '').toString();
|
||||
final imagePath = (item['imagePath'] ?? '').toString();
|
||||
|
||||
return SettingsCard(
|
||||
padding: EdgeInsets.all(16.w),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 42.w,
|
||||
height: 42.w,
|
||||
decoration: BoxDecoration(
|
||||
color: kind.accent.withValues(alpha: 0.10),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(kind.icon, color: kind.accent, size: 21.sp),
|
||||
),
|
||||
SizedBox(width: 14.w),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.3,
|
||||
color: ColorConstants.onSurface,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 10.w),
|
||||
Text(
|
||||
time,
|
||||
style: TextStyle(
|
||||
fontSize: 11.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ColorConstants.onSurfaceVariant,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (body.trim().isNotEmpty) ...[
|
||||
SizedBox(height: 5.h),
|
||||
Text(
|
||||
body,
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
height: 1.45,
|
||||
color: ColorConstants.onSurfaceVariant,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (imagePath.isNotEmpty || imageUrl.isNotEmpty) ...[
|
||||
SizedBox(height: 12.h),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||
child: imagePath.isNotEmpty
|
||||
? Image.file(
|
||||
File(imagePath),
|
||||
height: 140.h,
|
||||
width: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => const SizedBox.shrink(),
|
||||
)
|
||||
: Image.network(
|
||||
imageUrl,
|
||||
height: 140.h,
|
||||
width: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
242
lib/views/Dashboard/profile/informations/order_alert_sound.dart
Normal file
242
lib/views/Dashboard/profile/informations/order_alert_sound.dart
Normal file
@@ -0,0 +1,242 @@
|
||||
import 'package:audioplayers/audioplayers.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||
import 'package:miler/views/helpers/constants/Font_constant.dart';
|
||||
import 'package:miler/views/helpers/widgets/app_widgets.dart';
|
||||
import 'package:miler/views/helpers/widgets/settings_ui.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// ALERT SOUND
|
||||
///
|
||||
/// The sound a new booking makes. For a rider on a bike this is not a cosmetic
|
||||
/// setting — it is the difference between hearing work arrive and missing it —
|
||||
/// so the screen is built around *hearing* the options, not reading a list of
|
||||
/// filenames.
|
||||
///
|
||||
/// What it replaced: a raw Material `ListTile` list with a leading `Radio`, a
|
||||
/// grey `dividerColor` slab as a header, `const EdgeInsets` and unscaled font
|
||||
/// sizes, and a snackbar on every tap. It shared no visual element with the
|
||||
/// page that opened it. Now: one card, one row per sound, a play button that is
|
||||
/// obviously a play button, and the current choice marked by a filled brand
|
||||
/// check rather than a system radio the app uses nowhere else.
|
||||
///
|
||||
/// Selection saves immediately — there is no Save button, because there is
|
||||
/// nothing to confirm and a rider on a shift should not have to.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
class OrderAlertSoundPage extends StatefulWidget {
|
||||
const OrderAlertSoundPage({super.key});
|
||||
|
||||
@override
|
||||
State<OrderAlertSoundPage> createState() => _OrderAlertSoundPageState();
|
||||
}
|
||||
|
||||
class _OrderAlertSoundPageState extends State<OrderAlertSoundPage> {
|
||||
static const String _prefsKey = 'order_alert_sound';
|
||||
static const String _defaultSound = 'assets/audio/alert-1.mp3';
|
||||
|
||||
final AudioPlayer _player = AudioPlayer();
|
||||
String _selected = _defaultSound;
|
||||
String? _playing;
|
||||
bool _loading = true;
|
||||
|
||||
static const List<_SoundOption> _options = [
|
||||
_SoundOption(label: 'Alert 1', assetPath: 'assets/audio/alert-1.mp3'),
|
||||
_SoundOption(label: 'Alert 2', assetPath: 'assets/audio/alert-2.mp3'),
|
||||
_SoundOption(label: 'Alert 3', assetPath: 'assets/audio/alert-3.mp3'),
|
||||
_SoundOption(label: 'Alert 4', assetPath: 'assets/audio/alert-4.mp3'),
|
||||
_SoundOption(label: 'Alert 5', assetPath: 'assets/audio/alert-5.mp3'),
|
||||
_SoundOption(label: 'Alert 6', assetPath: 'assets/audio/alert-6.mp3'),
|
||||
_SoundOption(label: 'Alert 7', assetPath: 'assets/audio/alert-7.mp3'),
|
||||
_SoundOption(label: 'Alert 8', assetPath: 'assets/audio/alert-8.mp3'),
|
||||
_SoundOption(label: 'Alert 9', assetPath: 'assets/audio/alert-9.mp3'),
|
||||
_SoundOption(label: 'Alert 10', assetPath: 'assets/audio/alert-10.mp3'),
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSelection();
|
||||
_player.onPlayerComplete.listen((_) {
|
||||
if (mounted) setState(() => _playing = null);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_player.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadSelection() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final saved = prefs.getString(_prefsKey);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_selected = (saved != null && saved.isNotEmpty) ? saved : _defaultSound;
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
/// Picking a sound plays it. Choosing an alert you cannot hear is not a
|
||||
/// choice, and the old screen made preview a separate, easily-missed button.
|
||||
Future<void> _select(_SoundOption opt) async {
|
||||
setState(() => _selected = opt.assetPath);
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_prefsKey, opt.assetPath);
|
||||
await _preview(opt);
|
||||
}
|
||||
|
||||
Future<void> _preview(_SoundOption opt) async {
|
||||
try {
|
||||
await _player.stop();
|
||||
if (!mounted) return;
|
||||
setState(() => _playing = opt.assetPath);
|
||||
// AssetSource resolves relative to the assets/ root.
|
||||
await _player.play(
|
||||
AssetSource(opt.assetPath.replaceFirst('assets/', '')),
|
||||
);
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() => _playing = null);
|
||||
}
|
||||
}
|
||||
|
||||
String get _currentLabel => _options
|
||||
.firstWhere((o) => o.assetPath == _selected, orElse: () => _options.first)
|
||||
.label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: ColorConstants.surface,
|
||||
appBar: milerPageBar('Alert Sound', onBack: () => Get.back()),
|
||||
body: SmoothSwap(
|
||||
child: _loading
|
||||
? const Center(child: AppLoader(label: 'Loading sounds…'))
|
||||
: SettingsBody(
|
||||
children: [
|
||||
_currentCard(),
|
||||
SizedBox(height: 22.h),
|
||||
const SettingsLabel('Choose a sound'),
|
||||
SettingsGroup(
|
||||
dividerIndent: 16,
|
||||
rows: [for (final opt in _options) _soundRow(opt)],
|
||||
),
|
||||
const SettingsHint(
|
||||
'Tap a sound to hear it and set it. Your phone must not be '
|
||||
'on silent for booking alerts to come through.',
|
||||
),
|
||||
SizedBox(height: 12.h),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The one thing a rider opening this screen wants to know first: which
|
||||
/// sound is live right now, and what does it sound like.
|
||||
Widget _currentCard() {
|
||||
return SettingsCard(
|
||||
padding: EdgeInsets.all(16.w),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 46.w,
|
||||
height: 46.w,
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.primary.withValues(alpha: 0.08),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.notifications_active_rounded,
|
||||
color: ColorConstants.primary,
|
||||
size: 23.sp,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 14.w),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_currentLabel,
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: ColorConstants.onSurface,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 2.h),
|
||||
Text(
|
||||
'Plays when a new booking arrives',
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
color: ColorConstants.onSurfaceVariant,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_playButton(
|
||||
_options.firstWhere(
|
||||
(o) => o.assetPath == _selected,
|
||||
orElse: () => _options.first,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _soundRow(_SoundOption opt) {
|
||||
final bool isSelected = _selected == opt.assetPath;
|
||||
return SettingsRow(
|
||||
icon: isSelected
|
||||
? Icons.check_circle_rounded
|
||||
: Icons.radio_button_unchecked_rounded,
|
||||
iconColor: isSelected
|
||||
? ColorConstants.primary
|
||||
: ColorConstants.onSurfaceVariant,
|
||||
title: opt.label,
|
||||
subtitle: isSelected ? 'Current alert' : null,
|
||||
onTap: () => _select(opt),
|
||||
trailing: _playButton(opt),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _playButton(_SoundOption opt) {
|
||||
final bool isPlaying = _playing == opt.assetPath;
|
||||
return PressScale(
|
||||
onTap: () => _preview(opt),
|
||||
child: Container(
|
||||
width: 38.w,
|
||||
height: 38.w,
|
||||
decoration: BoxDecoration(
|
||||
color: isPlaying
|
||||
? ColorConstants.primary
|
||||
: ColorConstants.neutralLight,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
isPlaying ? Icons.graphic_eq_rounded : Icons.play_arrow_rounded,
|
||||
size: 20.sp,
|
||||
color: isPlaying
|
||||
? ColorConstants.onPrimary
|
||||
: ColorConstants.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SoundOption {
|
||||
final String label;
|
||||
final String assetPath;
|
||||
const _SoundOption({required this.label, required this.assetPath});
|
||||
}
|
||||
409
lib/views/Dashboard/profile/informations/profile.dart
Normal file
409
lib/views/Dashboard/profile/informations/profile.dart
Normal file
@@ -0,0 +1,409 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:miler/controllers/profile_controller.dart';
|
||||
import 'package:miler/data/miler_api.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:miler/views/helpers/constants/Font_constant.dart';
|
||||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||
import 'package:miler/views/helpers/widgets/app_widgets.dart';
|
||||
import 'package:miler/views/helpers/constants/design_constants.dart';
|
||||
|
||||
class Profile extends StatefulWidget {
|
||||
const Profile({super.key});
|
||||
|
||||
@override
|
||||
State<Profile> createState() => _ProfileState();
|
||||
}
|
||||
|
||||
class _ProfileState extends State<Profile> {
|
||||
final _nameCtrl = TextEditingController();
|
||||
final _emailCtrl = TextEditingController();
|
||||
final _contactCtrl = TextEditingController();
|
||||
final _addressCtrl = TextEditingController();
|
||||
bool _saving = false;
|
||||
|
||||
/// The form is filled from SharedPreferences, which is a disk read. It is
|
||||
/// usually a few milliseconds, but "usually" is doing a lot of work on a
|
||||
/// budget phone mid-shift — and the failure mode was the worst kind: four
|
||||
/// empty boxes and a live Save button, i.e. a screen that looks like the
|
||||
/// rider's details were lost and invites him to overwrite them with blanks.
|
||||
bool _loading = true;
|
||||
|
||||
late final ProfileController _profileController =
|
||||
Get.isRegistered<ProfileController>()
|
||||
? Get.find<ProfileController>()
|
||||
: Get.put(ProfileController(), permanent: true);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadProfile();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameCtrl.dispose();
|
||||
_emailCtrl.dispose();
|
||||
_contactCtrl.dispose();
|
||||
_addressCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadProfile() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_nameCtrl.text = prefs.getString('user_name') ?? '';
|
||||
_emailCtrl.text = prefs.getString('user_email') ?? '';
|
||||
_contactCtrl.text = prefs.getString('contactno') ?? '';
|
||||
_addressCtrl.text = prefs.getString('user_address') ?? '';
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
|
||||
Future<void> _pickImage() async {
|
||||
try {
|
||||
final picker = ImagePicker();
|
||||
final picked = await picker.pickImage(
|
||||
source: ImageSource.gallery,
|
||||
imageQuality: 70,
|
||||
);
|
||||
if (picked == null) return;
|
||||
_profileController.setImagePath(picked.path);
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('profile_image_path', picked.path);
|
||||
if (mounted) setState(() {});
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// ── This used to save to the phone and nowhere else ──
|
||||
///
|
||||
/// The old body wrote three `SharedPreferences` keys, waited 300ms and said
|
||||
/// "Your details have been saved." The hub never saw any of it, so a rider
|
||||
/// who corrected his own name still appeared under the old one on every
|
||||
/// dispatch screen — and reinstalling the app silently reverted the change.
|
||||
///
|
||||
/// The server is now the record and prefs are the local cache of it, in that
|
||||
/// order. Note what is *not* sent: email and address have no field on
|
||||
/// `PUT /miler/profile`, so they stay device-local and are called out in the
|
||||
/// gap list rather than being quietly dropped.
|
||||
Future<void> _save() async {
|
||||
FocusScope.of(context).unfocus();
|
||||
setState(() => _saving = true);
|
||||
|
||||
final name = _nameCtrl.text.trim();
|
||||
final res = await MilerApi.updateProfile(displayName: name);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (!res.ok) {
|
||||
setState(() => _saving = false);
|
||||
AppFeedback.error(
|
||||
context,
|
||||
res.message.isEmpty
|
||||
? "Couldn't save your details — check your signal and try again"
|
||||
: res.message,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only cache locally once the server has taken it, so the two cannot
|
||||
// disagree about what the rider's name is.
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('user_name', name);
|
||||
await prefs.setString('user_email', _emailCtrl.text.trim());
|
||||
await prefs.setString('user_address', _addressCtrl.text.trim());
|
||||
_profileController.setProfile(
|
||||
name: _nameCtrl.text,
|
||||
email: _emailCtrl.text,
|
||||
address: _addressCtrl.text,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() => _saving = false);
|
||||
Get.back();
|
||||
AppFeedback.successGlobal('Your details have been saved.');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: ColorConstants.surface,
|
||||
appBar: milerPageBar('Edit Profile', onBack: () => Get.back()),
|
||||
body: SmoothSwap(
|
||||
child: _loading
|
||||
? _loadingForm()
|
||||
: SingleChildScrollView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
padding: EdgeInsets.fromLTRB(20.w, 8.h, 20.w, 24.h),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Center(child: _buildAvatar()),
|
||||
SizedBox(height: 8.h),
|
||||
Center(
|
||||
child: TextButton.icon(
|
||||
onPressed: _pickImage,
|
||||
icon: Icon(
|
||||
Icons.photo_camera_outlined,
|
||||
size: 16.sp,
|
||||
color: ColorConstants.primary,
|
||||
),
|
||||
label: Text(
|
||||
'Change Photo',
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ColorConstants.primary,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
_field(
|
||||
label: 'Full name',
|
||||
controller: _nameCtrl,
|
||||
icon: Icons.person_outline_rounded,
|
||||
hint: 'Enter your name',
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
_field(
|
||||
label: 'Mobile number',
|
||||
controller: _contactCtrl,
|
||||
icon: Icons.phone_outlined,
|
||||
hint: 'Mobile number',
|
||||
readOnly: true,
|
||||
prefix: '+91 ',
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
_field(
|
||||
label: 'Email address',
|
||||
controller: _emailCtrl,
|
||||
icon: Icons.mail_outline_rounded,
|
||||
hint: 'you@example.com',
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
_field(
|
||||
label: 'Address',
|
||||
controller: _addressCtrl,
|
||||
icon: Icons.location_on_outlined,
|
||||
hint: 'Your address',
|
||||
maxLines: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// No Save until there is something to save.
|
||||
bottomNavigationBar: _loading
|
||||
? null
|
||||
: SafeArea(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(20.w, 8.h, 20.w, 12.h),
|
||||
child: PrimaryButton(
|
||||
label: 'Save changes',
|
||||
loading: _saving,
|
||||
onPressed: _save,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The shape of the form, shimmering — so the page the rider slid up into is
|
||||
/// already the right layout when the real values land, rather than empty
|
||||
/// boxes that reflow under his thumb.
|
||||
Widget _loadingForm() {
|
||||
return MilerShimmer(
|
||||
child: SingleChildScrollView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
padding: EdgeInsets.fromLTRB(20.w, 8.h, 20.w, 24.h),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Center(
|
||||
child: SkeletonBone(width: 104.w, height: 104.w, radius: 104.w),
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
Center(
|
||||
child: SkeletonBone(width: 120.w, height: 14.h),
|
||||
),
|
||||
SizedBox(height: 24.h),
|
||||
for (int i = 0; i < 4; i++) ...[
|
||||
SkeletonBone(width: 110.w, height: 13.h),
|
||||
SizedBox(height: 8.h),
|
||||
SkeletonBone(height: 52.h, radius: 14.r),
|
||||
SizedBox(height: 16.h),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAvatar() {
|
||||
return Obx(() {
|
||||
final path = _profileController.imagePath.value;
|
||||
bool hasImage = false;
|
||||
try {
|
||||
hasImage = path.isNotEmpty && File(path).existsSync();
|
||||
} catch (_) {}
|
||||
return GestureDetector(
|
||||
onTap: _pickImage,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Container(
|
||||
width: 104.w,
|
||||
height: 104.w,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: ColorConstants.neutralLight,
|
||||
border: Border.all(
|
||||
color: ColorConstants.surfaceContainerLowest,
|
||||
width: 3,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: ColorConstants.onSurface.withValues(alpha: 0.06),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
image: hasImage
|
||||
? DecorationImage(
|
||||
image: FileImage(File(path)),
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: !hasImage
|
||||
? Icon(
|
||||
Icons.person_rounded,
|
||||
color: ColorConstants.onSurfaceVariant,
|
||||
size: 52.sp,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
width: 32.w,
|
||||
height: 32.w,
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.primary,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: ColorConstants.surfaceContainerLowest,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.photo_camera_rounded,
|
||||
color: ColorConstants.onPrimary,
|
||||
size: 15.sp,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Widget _field({
|
||||
required String label,
|
||||
required TextEditingController controller,
|
||||
required IconData icon,
|
||||
String? hint,
|
||||
String? prefix,
|
||||
bool readOnly = false,
|
||||
int maxLines = 1,
|
||||
TextInputType keyboardType = TextInputType.text,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ColorConstants.onSurface,
|
||||
letterSpacing: 0.2,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
TextField(
|
||||
controller: controller,
|
||||
readOnly: readOnly,
|
||||
maxLines: maxLines,
|
||||
keyboardType: keyboardType,
|
||||
style: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: readOnly
|
||||
? ColorConstants.onSurfaceVariant
|
||||
: ColorConstants.onSurface,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: hint,
|
||||
prefixText: prefix,
|
||||
prefixStyle: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ColorConstants.onSurface,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
hintStyle: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontWeight: FontWeight.w400,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
prefixIcon: Icon(
|
||||
icon,
|
||||
color: readOnly
|
||||
? ColorConstants.secondaryText
|
||||
: ColorConstants.onSurfaceVariant,
|
||||
size: 20.sp,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: readOnly
|
||||
? ColorConstants.neutralLight
|
||||
: ColorConstants.surfaceContainerLowest,
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 16.w,
|
||||
vertical: 14.h,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
borderSide: BorderSide(color: ColorConstants.dividerColor),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
borderSide: BorderSide(color: ColorConstants.dividerColor),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
borderSide: BorderSide(color: ColorConstants.primary, width: 2),
|
||||
),
|
||||
suffixIcon: readOnly
|
||||
? Icon(
|
||||
Icons.lock_outline_rounded,
|
||||
size: 16.sp,
|
||||
color: ColorConstants.secondaryText,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
826
lib/views/Dashboard/profile/informations/rider_rewards_page.dart
Normal file
826
lib/views/Dashboard/profile/informations/rider_rewards_page.dart
Normal file
@@ -0,0 +1,826 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
import 'package:miler/controllers/rewards_controller.dart';
|
||||
import 'package:miler/data/accepted_store.dart';
|
||||
import 'package:miler/data/rider_tier.dart';
|
||||
import 'package:miler/data/stop_compliance.dart';
|
||||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||
import 'package:miler/views/helpers/constants/design_constants.dart';
|
||||
import 'package:miler/views/helpers/constants/Font_constant.dart';
|
||||
import 'package:miler/views/helpers/widgets/app_widgets.dart';
|
||||
import 'package:miler/widget/Bottom_page.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// REWARDS — status, progress, compliance, rules. In that order.
|
||||
///
|
||||
/// ── What this replaced ──
|
||||
///
|
||||
/// A single gradient card with a number on it. It told the rider his balance
|
||||
/// and nothing else: not what the number was worth, not what earned it, not
|
||||
/// what he had to do next. Every loyalty-UX writeup names that as the failure
|
||||
/// case — a balance with no context is a score in a game whose rules are
|
||||
/// unpublished.
|
||||
///
|
||||
/// ── The shape, and where it comes from ──
|
||||
///
|
||||
/// Uber Pro is the version of this a miler has most likely already used, and
|
||||
/// its driver hub is built in four moves. This page makes the same four:
|
||||
///
|
||||
/// 1. **Where you stand** — the status, the balance, and the period they
|
||||
/// belong to, at the very top. Voucherify's checklist puts it bluntly:
|
||||
/// show what they've earned, what's next, and why it's worth it.
|
||||
/// 2. **How far the next step is** — a bar and a number of points, never a
|
||||
/// percentage. "180 points to Gold" is a thing a rider can act on today;
|
||||
/// "62% of the way" is not.
|
||||
/// 3. **The compliance score** — Uber gates its tiers on acceptance and
|
||||
/// cancellation rates whatever the volume. The equivalents here are the two
|
||||
/// things the hub already measures per stop: did he reach it inside the
|
||||
/// ETA, did he ride the route he was given. Both are named on the page as
|
||||
/// what the hub calls them — a compliance score, paying compliance points —
|
||||
/// so the screen and the depot conversation use one vocabulary.
|
||||
/// 4. **How points are earned**, stated in one line each. The single most
|
||||
/// repeated warning in the research is against opaque conversions.
|
||||
///
|
||||
/// ── What this page will not do ──
|
||||
///
|
||||
/// It does not invent perks. What a status is worth is a commercial decision
|
||||
/// belonging to the hub, and a rewards screen that promises benefits the
|
||||
/// business has not agreed to is worse than one that promises none. The tier
|
||||
/// thresholds are placeholders for the same reason and live in one named place
|
||||
/// — see [kTierThresholds].
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
class RiderRewardsPage extends StatefulWidget {
|
||||
const RiderRewardsPage({super.key});
|
||||
|
||||
@override
|
||||
State<RiderRewardsPage> createState() => _RiderRewardsPageState();
|
||||
}
|
||||
|
||||
class _RiderRewardsPageState extends State<RiderRewardsPage> {
|
||||
late final RewardsController _rewards = Get.isRegistered<RewardsController>()
|
||||
? Get.find<RewardsController>()
|
||||
: Get.put(RewardsController());
|
||||
|
||||
ComplianceSummary _summary = ComplianceSummary.empty;
|
||||
bool _loaded = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final done = await getCompletedBookings();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_summary = ComplianceSummary.from(done);
|
||||
_loaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final period = RewardPeriod.current();
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: ColorConstants.surface,
|
||||
appBar: milerPageBar('Rewards', onBack: () => Get.back()),
|
||||
body: SafeArea(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
color: ColorConstants.primary,
|
||||
child: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(
|
||||
parent: BouncingScrollPhysics(),
|
||||
),
|
||||
padding: EdgeInsets.fromLTRB(16.w, 12.h, 16.w, 28.h),
|
||||
children: [
|
||||
Obx(() {
|
||||
final standing = TierStanding.of(_rewards.totalPoints.value);
|
||||
return Column(
|
||||
children: [
|
||||
_statusCard(standing, period, _rewards.isLoading.value),
|
||||
SizedBox(height: 10.h),
|
||||
_ladder(standing),
|
||||
],
|
||||
);
|
||||
}),
|
||||
SizedBox(height: 22.h),
|
||||
_complianceScore(),
|
||||
SizedBox(height: 22.h),
|
||||
_howToEarn(),
|
||||
SizedBox(height: 18.h),
|
||||
_hubNote(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── 1 · Where you stand ────────────────────────────────────────────────
|
||||
//
|
||||
// Status, balance and period on one surface, because they are one fact. The
|
||||
// card stays brand maroon at every tier — only the badge changes — so the
|
||||
// screen does not become a different product as the rider moves up.
|
||||
Widget _statusCard(TierStanding s, RewardPeriod period, bool loading) {
|
||||
final daysLeft = period.daysLeft();
|
||||
final toNext = s.pointsToNext;
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: EdgeInsets.all(18.r),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
gradient: LinearGradient(
|
||||
colors: [ColorConstants.primaryContainer, ColorConstants.primary],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: ColorConstants.primary.withValues(alpha: 0.25),
|
||||
blurRadius: 18,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 46.w,
|
||||
height: 46.w,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.pureSurface.withValues(alpha: 0.16),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: s.tier.accent.withValues(alpha: 0.9),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: Icon(s.tier.icon, size: 24.sp, color: s.tier.accent),
|
||||
),
|
||||
SizedBox(width: 12.w),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${s.tier.label} status',
|
||||
style: TextStyle(
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.3,
|
||||
color: Colors.white,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 2.h),
|
||||
Text(
|
||||
'${period.label} · '
|
||||
'$daysLeft ${daysLeft == 1 ? 'day' : 'days'} left',
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white.withValues(alpha: 0.78),
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// The balance, as the biggest thing on the card.
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
loading ? '—' : '${s.points}',
|
||||
style: TextStyle(
|
||||
fontSize: 30.sp,
|
||||
fontWeight: FontWeight.w900,
|
||||
letterSpacing: -1,
|
||||
height: 1,
|
||||
color: Colors.white,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 2.h),
|
||||
Text(
|
||||
'points',
|
||||
style: TextStyle(
|
||||
fontSize: 11.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.4,
|
||||
color: Colors.white.withValues(alpha: 0.78),
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
SizedBox(height: 16.h),
|
||||
|
||||
// ── 2 · How far the next step is ──
|
||||
//
|
||||
// A count of points, not a percentage. "180 points to Gold" is
|
||||
// something a rider can decide to do something about this afternoon.
|
||||
Text(
|
||||
toNext == null
|
||||
? 'Top status — hold it to the end of the period'
|
||||
: toNext == 0
|
||||
? 'Enough points for ${s.nextTier!.label} — meet the '
|
||||
'standards below to move up'
|
||||
: '$toNext more ${toNext == 1 ? 'point' : 'points'} to '
|
||||
'${s.nextTier!.label}',
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
|
||||
child: LinearProgressIndicator(
|
||||
value: s.progressToNext,
|
||||
minHeight: 8.h,
|
||||
backgroundColor: Colors.white.withValues(alpha: 0.22),
|
||||
valueColor: AlwaysStoppedAnimation<Color>(s.tier.accent),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The four statuses on one rail, current one marked.
|
||||
///
|
||||
/// Uber's hub shows the whole ladder rather than just the next rung, and the
|
||||
/// reason holds here: a rider deciding whether the push is worth it needs to
|
||||
/// see how far the road goes, not only the next step on it.
|
||||
Widget _ladder(TierStanding s) {
|
||||
return Row(
|
||||
children: [
|
||||
for (final tier in RiderTier.values) ...[
|
||||
Expanded(
|
||||
child: Opacity(
|
||||
opacity: tier.index <= s.tier.index ? 1 : 0.45,
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
height: 4.h,
|
||||
decoration: BoxDecoration(
|
||||
color: tier.index <= s.tier.index
|
||||
? ColorConstants.primary
|
||||
: ColorConstants.borderStrong,
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 6.h),
|
||||
Text(
|
||||
tier.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 10.5.sp,
|
||||
fontWeight: tier == s.tier
|
||||
? FontWeight.w800
|
||||
: FontWeight.w600,
|
||||
color: tier == s.tier
|
||||
? ColorConstants.primary
|
||||
: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${tier.threshold}',
|
||||
style: TextStyle(
|
||||
fontSize: 9.5.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (tier != RiderTier.values.last) SizedBox(width: 6.w),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// ── 3 · What holds the status up: the compliance score ─────────────────
|
||||
//
|
||||
// Named, not implied. The hub judges a miler on two things it measures at
|
||||
// every stop — did he reach it inside the ETA, did he ride the route he was
|
||||
// given — and the rider's word for the pair is his *compliance*. Calling the
|
||||
// block "your standards" left him to work out that this was the same thing
|
||||
// the hub talks to him about, so the section says compliance, the figure is a
|
||||
// compliance score, and what it pays is compliance points.
|
||||
Widget _complianceScore() {
|
||||
final s = _summary;
|
||||
final nothingYet = _loaded && s.measured == 0;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_sectionLabel('COMPLIANCE SCORE'),
|
||||
SizedBox(height: 4.h),
|
||||
Text(
|
||||
'The share of today\'s stops that made both checks. It pays one '
|
||||
'compliance point per clean stop, and a status needs '
|
||||
'$kStandardTarget% on each check to hold.',
|
||||
style: _bodyStyle,
|
||||
),
|
||||
SizedBox(height: 12.h),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: EdgeInsets.all(16.r),
|
||||
decoration: _cardDecoration,
|
||||
child: !_loaded
|
||||
? SizedBox(
|
||||
height: 76.h,
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.4,
|
||||
color: ColorConstants.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: nothingYet
|
||||
? _nothingYet()
|
||||
: Column(
|
||||
children: [
|
||||
// The score itself, first and biggest: it is the one
|
||||
// number the hub quotes back at him, and the two meters
|
||||
// under it are the working that produced it.
|
||||
_scoreHeader(s),
|
||||
SizedBox(height: 16.h),
|
||||
Divider(height: 1, color: ColorConstants.dividerColor),
|
||||
SizedBox(height: 16.h),
|
||||
_meter(
|
||||
icon: Icons.schedule_rounded,
|
||||
label: 'Reached inside the ETA',
|
||||
hit: s.onTime,
|
||||
total: s.measured,
|
||||
percent: s.onTimePercent,
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
_meter(
|
||||
icon: Icons.alt_route_rounded,
|
||||
label: 'Rode the assigned route',
|
||||
hit: s.onRoute,
|
||||
total: s.measured,
|
||||
percent: s.onRoutePercent,
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
Divider(height: 1, color: ColorConstants.dividerColor),
|
||||
SizedBox(height: 12.h),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${s.clean} of ${s.measured} '
|
||||
'${s.measured == 1 ? 'stop' : 'stops'} today '
|
||||
'cleared both checks',
|
||||
style: TextStyle(
|
||||
fontSize: 12.5.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: ColorConstants.slateText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
MilerButton(
|
||||
label: 'See stops',
|
||||
icon: Icons.history_rounded,
|
||||
color: ColorConstants.primary,
|
||||
height: ButtonSizes.compact,
|
||||
expand: false,
|
||||
onPressed: () {
|
||||
Navigator.of(context).maybePop();
|
||||
BottomPage.goToTab(2);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
// Unmeasured stops are stated, never scored. A rider
|
||||
// must not lose status because the hub set no ETA or
|
||||
// the GPS never resolved.
|
||||
if (s.unmeasured > 0) ...[
|
||||
SizedBox(height: 10.h),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline_rounded,
|
||||
size: 13.sp,
|
||||
color: ColorConstants.secondaryText,
|
||||
),
|
||||
SizedBox(width: 6.w),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${s.unmeasured} other '
|
||||
'${s.unmeasured == 1 ? 'stop' : 'stops'} had '
|
||||
'no ETA or distance recorded. Those do not '
|
||||
'count against you.',
|
||||
style: TextStyle(
|
||||
fontSize: 11.5.sp,
|
||||
height: 1.4,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// ── 4 · How points are earned ──────────────────────────────────────────
|
||||
//
|
||||
// One line each, in the rider's units. The research is unanimous that opaque
|
||||
// conversions are what kill trust in a rewards screen, and these are the
|
||||
// actual rules the backend applies — not a simplified story about them.
|
||||
Widget _howToEarn() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_sectionLabel('HOW POINTS AND COMPLIANCE WORK'),
|
||||
SizedBox(height: 12.h),
|
||||
Container(
|
||||
decoration: _cardDecoration,
|
||||
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 6.h),
|
||||
child: Column(
|
||||
children: [
|
||||
_rule(
|
||||
icon: Icons.timer_rounded,
|
||||
title: 'Close a stop before its ETA',
|
||||
body:
|
||||
'Points scale with the distance you rode to reach it. '
|
||||
'Miss the ETA and that stop earns none.',
|
||||
),
|
||||
_ruleDivider(),
|
||||
_rule(
|
||||
icon: Icons.alt_route_rounded,
|
||||
title: 'Ride the route your hub assigned',
|
||||
body:
|
||||
'Wandering well past the planned distance breaks the '
|
||||
'route check for that stop.',
|
||||
),
|
||||
_ruleDivider(),
|
||||
_rule(
|
||||
icon: Icons.verified_rounded,
|
||||
title: 'Clear both checks for a compliance point',
|
||||
body:
|
||||
'A stop that makes its ETA and stays on route earns one '
|
||||
'compliance point. Your compliance score is the share of '
|
||||
'stops that did.',
|
||||
),
|
||||
_ruleDivider(),
|
||||
_rule(
|
||||
icon: Icons.event_repeat_rounded,
|
||||
title: 'Points reset each month',
|
||||
body:
|
||||
'Your status for the next period is set by the points '
|
||||
'you finish this one with, and your compliance score has '
|
||||
'to hold to keep it.',
|
||||
last: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// What a status is worth is the hub's call, and the app says so rather than
|
||||
/// inventing perks it cannot honour.
|
||||
Widget _hubNote() {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.storefront_rounded,
|
||||
size: 14.sp,
|
||||
color: ColorConstants.secondaryText,
|
||||
),
|
||||
SizedBox(width: 7.w),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Your hub sets what each status is worth and can change the '
|
||||
'thresholds. Ask your hub manager what your level earns you.',
|
||||
style: TextStyle(
|
||||
fontSize: 11.5.sp,
|
||||
height: 1.45,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// ── Pieces ─────────────────────────────────────────────────────────────
|
||||
|
||||
Widget _sectionLabel(String text) => Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 11.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 0.8,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
);
|
||||
|
||||
TextStyle get _bodyStyle => TextStyle(
|
||||
fontSize: 12.5.sp,
|
||||
height: 1.45,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
);
|
||||
|
||||
BoxDecoration get _cardDecoration => BoxDecoration(
|
||||
color: ColorConstants.surfaceContainerLowest,
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
border: Border.all(color: ColorConstants.dividerColor),
|
||||
);
|
||||
|
||||
Widget _nothingYet() {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.pending_actions_rounded,
|
||||
size: 22.sp,
|
||||
color: ColorConstants.secondaryText,
|
||||
),
|
||||
SizedBox(width: 10.w),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'No stops finished yet today. Your standards are counted as you '
|
||||
'complete them.',
|
||||
style: _bodyStyle,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// The score, and what it paid: `80%` beside `4 compliance points`.
|
||||
///
|
||||
/// Two figures rather than one because they answer different questions. The
|
||||
/// score is how well he worked — it is the number that decides whether a
|
||||
/// status holds. The points are what that work *earned*, and they are a count
|
||||
/// rather than a share: twelve clean stops must not read the same as four.
|
||||
Widget _scoreHeader(ComplianceSummary s) {
|
||||
final int score = s.cleanPercent;
|
||||
final bool meets = score >= kStandardTarget;
|
||||
final Color accent = meets
|
||||
? ColorConstants.acceptGreen
|
||||
: ColorConstants.warning;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 62.w,
|
||||
height: 62.w,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: accent.withValues(alpha: 0.10),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: accent.withValues(alpha: 0.45), width: 2),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'$score%',
|
||||
style: TextStyle(
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.w900,
|
||||
letterSpacing: -0.5,
|
||||
height: 1,
|
||||
color: accent,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 1.h),
|
||||
Text(
|
||||
'SCORE',
|
||||
style: TextStyle(
|
||||
fontSize: 8.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 0.6,
|
||||
height: 1,
|
||||
color: accent,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(width: 14.w),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.workspace_premium_rounded,
|
||||
size: 17.sp,
|
||||
color: ColorConstants.acceptGreen,
|
||||
),
|
||||
SizedBox(width: 6.w),
|
||||
Flexible(
|
||||
child: Text(
|
||||
'${s.points} compliance '
|
||||
'${s.points == 1 ? 'point' : 'points'} today',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.3,
|
||||
color: ColorConstants.slateText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 3.h),
|
||||
Text(
|
||||
meets
|
||||
? 'Above the $kStandardTarget% your status needs'
|
||||
: 'Your status needs $kStandardTarget% on each check',
|
||||
style: TextStyle(
|
||||
fontSize: 11.5.sp,
|
||||
height: 1.35,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// One standard: a count, a percentage, a bar, and whether it clears the bar.
|
||||
Widget _meter({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required int hit,
|
||||
required int total,
|
||||
required int percent,
|
||||
}) {
|
||||
final bool meets = percent >= kStandardTarget;
|
||||
// Amber below target, not red: this is a standing to lift, not a failure
|
||||
// to report. Red in this app is reserved for things that went wrong.
|
||||
final Color accent = meets
|
||||
? ColorConstants.acceptGreen
|
||||
: ColorConstants.warning;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, size: 15.sp, color: accent),
|
||||
SizedBox(width: 7.w),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: ColorConstants.slateText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
Text(
|
||||
'$hit/$total · $percent%',
|
||||
style: TextStyle(
|
||||
fontSize: 12.5.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.2,
|
||||
color: accent,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 7.h),
|
||||
// The target is drawn on the track, so "am I clearing it" is a glance
|
||||
// rather than a comparison against a number in the paragraph above.
|
||||
LayoutBuilder(
|
||||
builder: (context, c) => SizedBox(
|
||||
height: 8.h,
|
||||
child: Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
|
||||
child: LinearProgressIndicator(
|
||||
value: total == 0 ? 0 : hit / total,
|
||||
minHeight: 8.h,
|
||||
backgroundColor: ColorConstants.borderSubtle,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(accent),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: c.maxWidth * (kStandardTarget / 100),
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: Container(width: 2, color: ColorConstants.slateText),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _rule({
|
||||
required IconData icon,
|
||||
required String title,
|
||||
required String body,
|
||||
bool last = false,
|
||||
}) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12.h),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 32.w,
|
||||
height: 32.w,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.primary.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||||
),
|
||||
child: Icon(icon, size: 16.sp, color: ColorConstants.primary),
|
||||
),
|
||||
SizedBox(width: 12.w),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 13.5.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.2,
|
||||
color: ColorConstants.slateText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 3.h),
|
||||
Text(body, style: _bodyStyle),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _ruleDivider() =>
|
||||
Divider(height: 1, color: ColorConstants.dividerColor);
|
||||
}
|
||||
327
lib/views/Dashboard/profile/informations/saved_address.dart
Normal file
327
lib/views/Dashboard/profile/informations/saved_address.dart
Normal file
@@ -0,0 +1,327 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:miler/controllers/profile_controller.dart';
|
||||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||
import 'package:miler/views/helpers/constants/design_constants.dart';
|
||||
import 'package:miler/views/helpers/constants/Font_constant.dart';
|
||||
import 'package:miler/views/helpers/widgets/app_widgets.dart';
|
||||
import 'package:miler/views/helpers/widgets/settings_ui.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// SAVED ADDRESS
|
||||
///
|
||||
/// The rider's own address — where his shift starts and ends, and what the hub
|
||||
/// reads when it works out which slots begin near him.
|
||||
///
|
||||
/// ── What this page used to be ──
|
||||
///
|
||||
/// A read-only card showing the address, an info panel explaining what it was
|
||||
/// for, and a `Spacer()` holding the bottom half of the screen empty. It was
|
||||
/// titled "Saved Addresses", plural, and offered no way to add, edit or remove
|
||||
/// one. A rider whose address was wrong — or blank, which is the state every
|
||||
/// new rider is in — could read that it was wrong and do nothing about it. The
|
||||
/// only route to changing it was to guess that Edit Profile had a field for it.
|
||||
///
|
||||
/// A settings page that states a fact the rider cannot act on is not a settings
|
||||
/// page, it is a label. This one edits and saves, writing the same
|
||||
/// `user_address` key Edit Profile uses and pushing it through
|
||||
/// [ProfileController] so both screens agree the moment either changes.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
class SavedAddressPage extends StatefulWidget {
|
||||
const SavedAddressPage({super.key});
|
||||
|
||||
@override
|
||||
State<SavedAddressPage> createState() => _SavedAddressPageState();
|
||||
}
|
||||
|
||||
class _SavedAddressPageState extends State<SavedAddressPage> {
|
||||
final TextEditingController _controller = TextEditingController();
|
||||
final FocusNode _focus = FocusNode();
|
||||
|
||||
late final ProfileController _profileController =
|
||||
Get.isRegistered<ProfileController>()
|
||||
? Get.find<ProfileController>()
|
||||
: Get.put(ProfileController(), permanent: true);
|
||||
|
||||
/// Until the stored address is actually read back, this page cannot honestly
|
||||
/// say either "here is your address" or "nothing saved yet" — and it used to
|
||||
/// assert the second one for a frame, which is a claim a rider acts on.
|
||||
bool _loading = true;
|
||||
bool _editing = false;
|
||||
bool _saving = false;
|
||||
String _saved = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_focus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final address = (prefs.getString('user_address') ?? '').trim();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_saved = address;
|
||||
_controller.text = address;
|
||||
_loading = false;
|
||||
// A rider with nothing saved is here to add one. Open into the field
|
||||
// rather than making him find an Edit button first.
|
||||
_editing = address.isEmpty;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
FocusScope.of(context).unfocus();
|
||||
setState(() => _saving = true);
|
||||
final value = _controller.text.trim();
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('user_address', value);
|
||||
_profileController.setProfile(address: value);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_saved = value;
|
||||
_saving = false;
|
||||
_editing = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _startEditing() {
|
||||
setState(() => _editing = true);
|
||||
_focus.requestFocus();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: ColorConstants.surface,
|
||||
appBar: milerPageBar(
|
||||
'Saved Address',
|
||||
onBack: () => Get.back(),
|
||||
actions: [
|
||||
if (!_loading && !_editing && _saved.isNotEmpty)
|
||||
TextButton(
|
||||
onPressed: _startEditing,
|
||||
child: Text(
|
||||
'Edit',
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: ColorConstants.primary,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SmoothSwap(
|
||||
child: _loading
|
||||
? _loadingBody()
|
||||
: SettingsBody(
|
||||
children: [
|
||||
_editing ? _editor() : _addressCard(),
|
||||
const SettingsHint(
|
||||
'Your hub uses this to work out which slots start near you, '
|
||||
'and it is where your shift is assumed to end.',
|
||||
),
|
||||
SizedBox(height: 24.h),
|
||||
],
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: (_loading || !_editing)
|
||||
? null
|
||||
: SafeArea(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(16.w, 8.h, 16.w, 12.h),
|
||||
child: PrimaryButton(
|
||||
label: _saved.isEmpty ? 'Save address' : 'Update address',
|
||||
loading: _saving,
|
||||
onPressed: _controller.text.trim().isEmpty ? null : _save,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The saved address, at rest.
|
||||
Widget _addressCard() {
|
||||
final bool hasAddress = _saved.isNotEmpty;
|
||||
return SettingsCard(
|
||||
padding: EdgeInsets.all(16.w),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 44.w,
|
||||
height: 44.w,
|
||||
decoration: BoxDecoration(
|
||||
color: ColorConstants.primary.withValues(alpha: 0.08),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.home_outlined,
|
||||
color: ColorConstants.primary,
|
||||
size: 22.sp,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 14.w),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Home address',
|
||||
style: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: ColorConstants.onSurface,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 6.h),
|
||||
Text(
|
||||
hasAddress ? _saved : 'No address saved yet.',
|
||||
style: TextStyle(
|
||||
fontSize: 13.5.sp,
|
||||
height: 1.5,
|
||||
color: ColorConstants.onSurfaceVariant,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 14.h),
|
||||
MilerButton(
|
||||
label: hasAddress ? 'Change address' : 'Add your address',
|
||||
icon: hasAddress ? Icons.edit_outlined : Icons.add_rounded,
|
||||
variant: MilerButtonVariant.tonal,
|
||||
height: ButtonSizes.secondary,
|
||||
onPressed: _startEditing,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The editor. Deliberately one plain multi-line field: a rider types his
|
||||
/// address the way he would say it, and splitting it into house / street /
|
||||
/// pincode boxes buys nothing when the value is only ever read by a human.
|
||||
Widget _editor() {
|
||||
return SettingsCard(
|
||||
padding: EdgeInsets.all(16.w),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Home address',
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: ColorConstants.onSurface,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
TextField(
|
||||
controller: _controller,
|
||||
focusNode: _focus,
|
||||
minLines: 3,
|
||||
maxLines: 4,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
onChanged: (_) => setState(() {}),
|
||||
style: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
height: 1.45,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ColorConstants.onSurface,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'House / street, area, city, pincode',
|
||||
hintStyle: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
filled: true,
|
||||
// A white field on a white card has no edge but its border.
|
||||
// The neutral fill is what separates them.
|
||||
fillColor: ColorConstants.neutralLight,
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 14.w,
|
||||
vertical: 14.h,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
borderSide: BorderSide(color: ColorConstants.dividerColor),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
borderSide: BorderSide(color: ColorConstants.dividerColor),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
||||
borderSide: BorderSide(color: ColorConstants.primary, width: 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_saved.isNotEmpty)
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
onPressed: () {
|
||||
FocusScope.of(context).unfocus();
|
||||
setState(() {
|
||||
_controller.text = _saved;
|
||||
_editing = false;
|
||||
});
|
||||
},
|
||||
child: Text(
|
||||
'Cancel',
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ColorConstants.onSurfaceVariant,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _loadingBody() {
|
||||
return MilerShimmer(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(16.w, 14.h, 16.w, 28.h),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SkeletonBone(height: 150.h, radius: 18.r),
|
||||
SizedBox(height: 16.h),
|
||||
SkeletonBone(height: 34.h, radius: 10.r),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:miler/views/helpers/widgets/app_widgets.dart';
|
||||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
|
||||
// ===== Controller =====
|
||||
class TermsController extends GetxController {
|
||||
WebViewController? webViewController;
|
||||
var isLoading = true.obs;
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
initializeWebView();
|
||||
}
|
||||
|
||||
void initializeWebView() {
|
||||
webViewController = WebViewController()
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..setBackgroundColor(const Color(0x00000000))
|
||||
..setNavigationDelegate(
|
||||
NavigationDelegate(
|
||||
onPageStarted: (url) {
|
||||
isLoading.value = true;
|
||||
debugPrint('Started loading: $url');
|
||||
},
|
||||
onPageFinished: (url) {
|
||||
isLoading.value = false;
|
||||
debugPrint('Finished loading: $url');
|
||||
},
|
||||
onWebResourceError: (error) {
|
||||
isLoading.value = false;
|
||||
debugPrint('WebView error: ${error.description}');
|
||||
},
|
||||
),
|
||||
);
|
||||
loadTermsUrl();
|
||||
}
|
||||
|
||||
Future<void> loadTermsUrl() async {
|
||||
if (webViewController != null) {
|
||||
try {
|
||||
await webViewController!.loadRequest(
|
||||
Uri.parse('https://doormile.in/terms'),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('Error loading URL: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Page =====
|
||||
class TermsCondition extends StatelessWidget {
|
||||
const TermsCondition({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final controller = Get.put(TermsController());
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: ColorConstants.surface,
|
||||
appBar: milerPageBar('Terms & Conditions', onBack: () => Get.back()),
|
||||
body: SafeArea(
|
||||
child: Obx(() {
|
||||
final wvc = controller.webViewController;
|
||||
final loading = controller.isLoading.value;
|
||||
return Stack(
|
||||
children: [
|
||||
if (wvc != null) WebViewWidget(controller: wvc),
|
||||
// A hairline progress bar over a blank white page told the rider
|
||||
// nothing was happening. While the document is on its way, say so
|
||||
// in the app's own voice — and cover the blank page while doing
|
||||
// it, so the text does not appear to arrive from behind a bar.
|
||||
if (loading)
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
color: ColorConstants.surface,
|
||||
child: const AppLoader(label: 'Loading terms…'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user