first commit

This commit is contained in:
2026-08-11 13:16:33 +05:30
parent 18531bf46b
commit c8350563d9
388 changed files with 69656 additions and 0 deletions

View 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);
}

View 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),
),
),
);
}
}

View 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(),
),
),
],
],
),
);
}
}

View 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});
}

View 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,
),
),
],
);
}
}

View 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);
}

View 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),
],
),
),
);
}
}

View File

@@ -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…'),
),
),
],
);
}),
),
);
}
}