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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,139 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.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/widget/Bottom_page.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// ACCEPTED PILL — how many stops you have taken, and the way to them
///
/// Home is where the rider *decides*; Bookings is where he *works*. The bridge
/// between the two used to be a "Continue trip" button sitting under the last
/// hub row of a trip — which meant it only existed once that particular trip
/// had nothing left to decide, it moved as the route grew, and it was below the
/// fold on any route longer than about four stops. A rider who had accepted
/// three stops and scrolled back to the top had nothing on screen telling him
/// so, and no way through.
///
/// This says both things in one control, in one fixed place: **how many
/// bookings he is holding**, and one tap to go work them. It is the counterpart
/// to [SelectionBar] — that one is about the stops he is choosing, this one
/// about the stops he has already taken — so they share the floating slot at
/// the foot of the page and never appear together. A selection in progress is
/// the more urgent of the two and wins the slot.
///
/// The count comes from the accepted store, which drops a booking once it is
/// picked, so the number is "waiting for you on Bookings" rather than "accepted
/// at some point today".
/// ─────────────────────────────────────────────────────────────────────────
class AcceptedPill extends StatelessWidget {
/// Accepted bookings still waiting to be worked. Zero renders nothing.
final int count;
/// Jumps to the Bookings tab.
final VoidCallback onTap;
const AcceptedPill({super.key, required this.count, required this.onTap});
@override
Widget build(BuildContext context) {
if (count <= 0) return const SizedBox.shrink();
final radius = BorderRadius.circular(DesignConstants.radiusFull);
// Rides the floating nav bar: it is `Positioned` outside the Scaffold's
// body, so `SafeArea` knows nothing about it and anything pinned to the
// bottom lands underneath. See [BottomPage.bottomInset].
//
// A fixed inset now. This used to follow a `navVisible` notifier up and
// down, because the bar left the screen entirely on scroll. It compacts
// instead of leaving, so there is no longer a state in which the room is
// free — and chasing the 20pt difference re-laid this out every time the
// rider changed scroll direction, for a gap he would never notice.
return Padding(
padding: EdgeInsets.only(bottom: BottomPage.bottomInset(context) + 10.h),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 16.w),
child: Align(
// Right-aligned and sized to its content: this is an ambient way
// through, not the action the screen is for. A full-width green bar
// here would out-rank the accept the rider is actually on Home to
// make.
alignment: Alignment.centerRight,
child: Semantics(
button: true,
label: count == 1
? '1 accepted booking. Go to Bookings.'
: '$count accepted bookings. Go to Bookings.',
child: Material(
color: ColorConstants.primary,
borderRadius: radius,
elevation: 0,
child: InkWell(
onTap: onTap,
borderRadius: radius,
child: Container(
// 48 clears the Material touch-target floor without the pill
// reading as a primary CTA.
height: 48.h,
padding: EdgeInsets.fromLTRB(8.w, 0, 14.w, 0),
decoration: BoxDecoration(
borderRadius: radius,
boxShadow: DesignConstants.shadowLg,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// The count as a badge, not as a word in a sentence: it
// is the thing the rider is checking from across the
// handlebars, and it changes while the words do not.
Container(
width: 32.w,
height: 32.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color: ColorConstants.pureSurface,
shape: BoxShape.circle,
),
child: Text(
'$count',
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w800,
height: 1,
letterSpacing: -0.3,
color: ColorConstants.primary,
fontFamily: FontConstants.fontFamily,
),
),
),
SizedBox(width: 9.w),
Text(
count == 1 ? 'booking accepted' : 'bookings accepted',
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.1,
color: Colors.white,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(width: 2.w),
Icon(
Icons.chevron_right_rounded,
size: 20.sp,
color: Colors.white,
),
],
),
),
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,481 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:slide_to_submit_button/slide_to_submit_button.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/helpers/constants/Font_constant.dart';
import 'package:miler/views/helpers/constants/design_constants.dart';
import 'package:miler/views/helpers/widgets/miler_app_bar.dart' show milerGlassSheet;
import 'package:miler/views/helpers/widgets/page_transitions.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// GOING ON / OFF DUTY
///
/// The confirmation behind the duty switch in the app bar. It used to be a
/// white slab with a 32pt red ✕ floating above a 88pt red circle holding a
/// **wifi** glyph, under the heading "Go Online?" and one grey line — "You
/// will stop receiving bookings".
///
/// Three things were wrong with that, and none of them was the styling:
///
/// • **Wifi is the wrong idea.** Duty is not connectivity. A rider reading a
/// wifi symbol on a screen that stops his work is being told the app has a
/// network problem.
/// • **It did not say what changes.** "You will stop receiving bookings" is
/// one consequence of three. Live tracking stops, and — the one that
/// actually catches people out — the app will refuse to take him off duty
/// while a booking is still open. He found that out *after* sliding.
/// • **It was the only sheet in the app that looked like this.** Every other
/// sheet is frosted glass with a grab handle and a small round close
/// button; see [milerGlassSheet].
///
/// So: the app's own sheet chrome, the duty glyph the rest of the app uses,
/// a strip showing the switch moving from one state to the other, and the
/// consequences named one per line instead of summarised in a sentence.
///
/// The slide-to-confirm survives both directions. Going off duty mid-shift is
/// worth a deliberate gesture, and a rider whose thumb is wet from the rain
/// should not be able to end his day with a stray tap.
/// ─────────────────────────────────────────────────────────────────────────
class DutySheet extends StatelessWidget {
/// True when this sheet is asking to go **on** duty.
final bool goingOnline;
/// Runs on a completed slide, and owns the work: the pending-booking check,
/// the API call, the polling restart. Returns true when duty actually
/// changed.
///
/// The knob belongs to the sheet, not the caller — it springs back on every
/// outcome, and the sheet closes itself only on a true. That is why this is a
/// `Future<bool>` and not the package's controller: the controller type is
/// not exported from `slide_to_submit_button`, so naming it here would mean
/// reaching into the package's `src/`.
final Future<bool> Function() onConfirm;
const DutySheet({
super.key,
required this.goingOnline,
required this.onConfirm,
});
/// Opens the sheet. The caller keeps the toggle logic; this only presents it.
static Future<void> show(
BuildContext context, {
required bool goingOnline,
required Future<bool> Function() onConfirm,
}) {
return showModalBottomSheet<void>(
context: context,
sheetAnimationStyle: kMilerSheetStyle,
isScrollControlled: true,
// The sheet paints its own frosted shape — a white sheet behind it would
// show as a square corner behind the rounded one.
backgroundColor: Colors.transparent,
builder: (_) =>
DutySheet(goingOnline: goingOnline, onConfirm: onConfirm),
);
}
/// Green to start, brand red to stop.
///
/// Green matches the duty switch's own "on" fill, so the sheet is visibly
/// about the control that opened it. Stopping is not grey: grey is what a
/// disabled control looks like, and this is the most consequential button on
/// the screen.
Color get _accent =>
goingOnline ? ColorConstants.success : ColorConstants.primary;
@override
Widget build(BuildContext context) {
return SafeArea(
top: false,
left: false,
right: false,
bottom: true,
child: milerGlassSheet(
child: Padding(
padding: EdgeInsets.fromLTRB(20.w, 12.h, 20.w, 20.h),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 40.w,
height: 4.h,
decoration: BoxDecoration(
color: ColorConstants.borderSubtle,
borderRadius: BorderRadius.circular(
DesignConstants.radiusLg,
),
),
),
),
SizedBox(height: 16.h),
_header(context),
SizedBox(height: 18.h),
// What the switch is about to do, drawn rather than described.
_TransitionStrip(goingOnline: goingOnline, accent: _accent),
SizedBox(height: 16.h),
..._consequences(),
SizedBox(height: 20.h),
_slider(context),
],
),
),
),
);
}
Widget _header(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// The duty glyph, not a wifi bar. Same icon the "Go on duty" button on
// an empty trip carries, so one idea keeps one symbol.
Container(
padding: EdgeInsets.all(10.r),
decoration: BoxDecoration(
color: _accent.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
child: Icon(
Icons.power_settings_new_rounded,
color: _accent,
size: 20.sp,
),
),
SizedBox(width: 12.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
goingOnline ? 'Go on duty?' : 'Go off duty?',
style: TextStyle(
fontSize: 19.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.3,
fontFamily: FontConstants.fontFamily,
color: ColorConstants.slateText,
),
),
SizedBox(height: 2.h),
Text(
goingOnline
? 'Your hub can start assigning you trips again.'
: 'Your hub stops assigning you trips until you are back.',
style: TextStyle(
fontSize: 12.5.sp,
height: 1.35,
fontWeight: FontWeight.w600,
fontFamily: FontConstants.fontFamily,
color: ColorConstants.secondaryText,
),
),
],
),
),
SizedBox(width: 10.w),
// The same small round dismiss every other sheet uses, in place of the
// 32pt red ✕ that used to sit alone on its own row above everything.
InkWell(
onTap: () => Navigator.of(context).maybePop(),
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
child: Container(
padding: EdgeInsets.all(8.r),
decoration: BoxDecoration(
color: ColorConstants.neutralLight,
shape: BoxShape.circle,
),
child: Icon(
Icons.close_rounded,
color: ColorConstants.slateText,
size: 18.sp,
),
),
),
],
);
}
/// One line per thing that actually changes.
///
/// The third off-duty line is the one the old sheet withheld: the app blocks
/// the switch while a booking is still open, and it used to say so only
/// after the rider had completed the slide and watched it bounce back.
List<Widget> _consequences() {
final rows = goingOnline
? const [
(Icons.alt_route_rounded, 'Your hub can add you to the next slot'),
(
Icons.my_location_rounded,
'Live tracking runs while you are on duty',
),
]
: const [
(
Icons.notifications_paused_rounded,
'No new trips until you are back on',
),
(Icons.location_off_rounded, 'Live tracking pauses'),
(
Icons.inventory_2_rounded,
'Any booking you are holding must be finished first',
),
];
return [
for (var i = 0; i < rows.length; i++) ...[
if (i > 0) SizedBox(height: 10.h),
_ConsequenceRow(
icon: rows[i].$1,
label: rows[i].$2,
accent: _accent,
),
],
];
}
Widget _slider(BuildContext context) {
final label = goingOnline ? 'Slide to go on duty' : 'Slide to go off duty';
return SlideToSubmit.custom(
height: 58,
sliderWidth: 46,
padding: const EdgeInsets.all(6),
// The track is the accent at low alpha, so the control is the same colour
// as the decision. It was a flat grey slab that gave no clue which of the
// two things it was about to do.
backgroundDecoration: BoxDecoration(
color: _accent.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
),
foregroundDecoration: const BoxDecoration(color: Colors.transparent),
slider: Center(
child: Container(
height: 46,
width: 46,
alignment: Alignment.center,
decoration: BoxDecoration(
// A filled knob in the accent, not a white disc with a grey
// chevron: on a tinted track the white one read as the empty part.
color: _accent,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: _accent.withValues(alpha: 0.30),
blurRadius: 10,
offset: const Offset(0, 3),
),
],
),
child: Icon(
Icons.arrow_forward_rounded,
size: 20.sp,
color: ColorConstants.onAccent,
),
),
),
// Words, not just a drifting arrow. The old hint was an arrow asset and
// nothing else, which says "something slides" without saying what
// happens when it lands.
hint: IgnorePointer(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(width: 46.0 + 8.w),
Expanded(
child: Text(
label,
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.2,
fontFamily: FontConstants.fontFamily,
color: _accent,
),
),
),
Padding(
padding: EdgeInsets.only(right: 14.w),
// Boxed and clipped. The arrow animates in from `Offset(-2, 0)` —
// two of its own widths to the left — so left to itself it drifts
// straight across the label and prints an arrow through the
// middle of the words. Its own box turns that into what it was
// meant to be: an arrow sliding in at the end of the track.
child: SizedBox(
width: 26.w,
child: ClipRect(
// The asset is a fixed dark glyph, so it is tinted rather
// than dropped: the track is coloured now, and a black arrow
// on a green track was the one thing on the control that did
// not belong to it.
child: ColorFiltered(
colorFilter: ColorFilter.mode(
_accent.withValues(alpha: 0.55),
BlendMode.srcIn,
),
child: const AnimatedSlideArrow(
arrowImage: AssetImage(
'assets/arrow_right.png',
package: 'slide_to_submit_button',
),
),
),
),
),
),
],
),
),
// The knob springs back whatever happens, and the sheet closes only when
// duty actually changed — a failed API call leaves the rider looking at
// the decision he was trying to make, not at a screen that has silently
// dropped it.
onSubmit: (controller) async {
final ok = await onConfirm();
try {
controller.reset();
} catch (_) {}
if (ok && context.mounted) Navigator.of(context).maybePop();
},
);
}
}
/// `Off duty ──→ On duty`, as the two states of the switch that opened this.
///
/// A confirmation should show the change, not only name it. The state he is
/// leaving stays grey and the one he is heading for takes the accent, so which
/// direction this sheet runs in is legible before a word is read.
class _TransitionStrip extends StatelessWidget {
final bool goingOnline;
final Color accent;
const _TransitionStrip({required this.goingOnline, required this.accent});
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: _StateChip(
label: goingOnline ? 'Off duty' : 'On duty',
color: ColorConstants.secondaryText,
filled: false,
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 10.w),
child: Icon(
Icons.arrow_forward_rounded,
size: 16.sp,
color: ColorConstants.borderStrong,
),
),
Expanded(
child: _StateChip(
label: goingOnline ? 'On duty' : 'Off duty',
color: accent,
filled: true,
),
),
],
);
}
}
class _StateChip extends StatelessWidget {
final String label;
final Color color;
/// The destination state. The one being left is drawn hollow.
final bool filled;
const _StateChip({
required this.label,
required this.color,
required this.filled,
});
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.symmetric(vertical: 10.h, horizontal: 12.w),
decoration: BoxDecoration(
color: filled ? color.withValues(alpha: 0.12) : Colors.transparent,
border: Border.all(
color: filled ? Colors.transparent : ColorConstants.borderStrong,
),
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 8.w,
height: 8.w,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
SizedBox(width: 8.w),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.2,
fontFamily: FontConstants.fontFamily,
color: filled ? color : ColorConstants.secondaryText,
),
),
),
],
),
);
}
}
class _ConsequenceRow extends StatelessWidget {
final IconData icon;
final String label;
final Color accent;
const _ConsequenceRow({
required this.icon,
required this.label,
required this.accent,
});
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: EdgeInsets.only(top: 1.h),
child: Icon(icon, size: 17.sp, color: accent.withValues(alpha: 0.75)),
),
SizedBox(width: 10.w),
Expanded(
child: Text(
label,
style: TextStyle(
fontSize: 13.sp,
height: 1.35,
fontWeight: FontWeight.w600,
fontFamily: FontConstants.fontFamily,
color: ColorConstants.slateText,
),
),
),
],
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,328 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/helpers/constants/Font_constant.dart';
import 'package:miler/widget/Bottom_page.dart';
import 'package:miler/Models/stop_status.dart';
import 'package:miler/views/helpers/constants/design_constants.dart';
/// The live strip that sits at the foot of Home and Bookings while a pickup is
/// under way — the rider's way back into the stop he is already on.
///
/// ── One banner, not two ──
///
/// Home and Bookings each had their own copy. They had drifted into different
/// widgets: one a gradient card with a double drop shadow, an order-id badge and
/// a status pill; the other a flat red box with a LIVE chip and a hard 40-
/// character address truncation. Same job, same data, two designs — which is how
/// the same screen came to look older on one tab than the other.
///
/// ── It has to clear the nav bar ──
///
/// Both copies pinned themselves to `bottom: 0`. The nav bar *floats*: it is a
/// `Positioned` pill in the shell's `Stack`, not a `bottomNavigationBar`, so the
/// Scaffold reserves nothing for it and `SafeArea` cannot see it. The banner
/// therefore sat underneath it, with the bar covering the tap target of the one
/// control telling the rider he has a job open.
///
/// So the banner lifts itself clear by [BottomPage.bottomInset]. Both call sites
/// just pin it to `bottom: 0` and let it place itself.
class ActivePickupBanner extends StatefulWidget {
final List<Map<String, dynamic>> activePickups;
final void Function(Map<String, dynamic>) onTap;
const ActivePickupBanner({
super.key,
required this.activePickups,
required this.onTap,
});
@override
State<ActivePickupBanner> createState() => _ActivePickupBannerState();
}
class _ActivePickupBannerState extends State<ActivePickupBanner>
with SingleTickerProviderStateMixin {
/// The live dot actually pulses.
///
/// A static dot beside the word LIVE is a claim the interface does not back
/// up; movement is what makes "there is a job running right now" read without
/// being read. Initialised lazily on the declaration rather than in
/// `initState` so hot reload cannot leave it unset — see the note in
/// `Bottom_page.dart`.
/// Created on first *use*, not on first touch of any kind.
///
/// This was a `late final` initialised on the declaration, which meant
/// `dispose()` calling `_pulse.dispose()` *constructed* the controller during
/// teardown whenever it had never been used — and `build` returns early with
/// no active pickups, so that is the normal path when a rider's last stop
/// finishes and the banner unmounts. `createTicker` then looked up
/// `TickerMode` on a deactivated element and threw. Two of the app's own
/// tests caught it; it needs the nullable so dispose can ask "was there
/// ever one?" instead of answering "there is now".
AnimationController? _pulseController;
AnimationController get _pulse => _pulseController ??= AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1100),
)..repeat(reverse: true);
@override
void dispose() {
_pulseController?.dispose();
super.dispose();
}
String _address(Map<String, dynamic> pickup) =>
(pickup['pickupaddress'] ??
pickup['PickupAddress'] ??
pickup['address'] ??
'')
.toString()
.trim();
/// Where the rider has got to on this stop — from the one place the app
/// names its statuses, so the banner cannot say "At pickup" while the card
/// behind it says something else about the same stop. See [StopStatusX.label].
String _status(Map<String, dynamic> pickup) => stopStatusOf(pickup).label;
@override
Widget build(BuildContext context) {
if (widget.activePickups.isEmpty) return const SizedBox.shrink();
final pickup = widget.activePickups.first;
final count = widget.activePickups.length;
final address = _address(pickup);
// Rides the floating nav bar: it is `Positioned` outside the Scaffold's
// body, so `SafeArea` knows nothing about it and anything pinned to the
// bottom lands underneath. See [BottomPage.bottomInset].
//
// A fixed inset now. This used to follow a `navVisible` notifier up and
// down, because the bar left the screen entirely on scroll. It compacts
// instead of leaving, so there is no longer a state in which the room is
// free — and chasing the 20pt difference re-laid this out every time the
// rider changed scroll direction, for a gap he would never notice.
return Padding(
padding: EdgeInsets.only(bottom: BottomPage.bottomInset(context) + 10.h),
child: Padding(
// 16 — the same gutter the nav bar above it uses, so the two stack as
// one column rather than as two floating objects of different widths.
padding: EdgeInsets.symmetric(horizontal: 16.w),
child: _Card(
pulse: _pulse,
count: count,
address: address,
status: _status(pickup),
onTap: () => widget.onTap(pickup),
),
),
);
}
}
class _Card extends StatelessWidget {
final Animation<double> pulse;
final int count;
final String address;
final String status;
final VoidCallback onTap;
const _Card({
required this.pulse,
required this.count,
required this.address,
required this.status,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Material(
color: ColorConstants.primary,
// 20 — between the stop cards' 16 and the nav bar's 32, which is where it
// sits in the visual stack.
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
clipBehavior: Clip.antiAlias,
// Tinted rather than black: a neutral shadow under a saturated red surface
// reads as grime. The gradient and second shadow the Home copy carried are
// gone — one flat fill and one shadow is the language the rest of the app
// now speaks.
shadowColor: ColorConstants.primary.withValues(alpha: 0.45),
elevation: 10,
child: InkWell(
onTap: onTap,
splashColor: Colors.white.withValues(alpha: 0.10),
highlightColor: Colors.white.withValues(alpha: 0.06),
child: Padding(
padding: EdgeInsets.fromLTRB(12.w, 11.h, 12.w, 11.h),
child: Row(
children: [
Container(
width: 42.w,
height: 42.w,
decoration: BoxDecoration(
color: ColorConstants.pureSurface.withValues(alpha: 0.16),
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
),
child: Icon(
Icons.two_wheeler_rounded,
color: Colors.white,
size: 22.sp,
),
),
SizedBox(width: 11.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
_LiveChip(pulse: pulse),
SizedBox(width: 7.w),
// Flexible, not bare: this row has a fixed-width chip on
// one side and a title that grows with the pickup count
// and the text scale on the other. Every unflexed row of
// that shape in this app has overflowed eventually.
Flexible(
child: Text(
count > 1
? '$count Active Pickups'
: 'Active Pickup',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 15.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.3,
fontFamily: FontConstants.fontFamily,
),
),
),
],
),
SizedBox(height: 3.h),
Row(
children: [
Icon(
Icons.place_rounded,
color: Colors.white.withValues(alpha: 0.72),
size: 13.sp,
),
SizedBox(width: 3.w),
Expanded(
child: Text(
// Ellipsised by the layout, not chopped at 40
// characters — the old truncation cut mid-word and
// then appended its own "…", which on a narrow phone
// ellipsised a second time.
address.isEmpty ? status : '$status · $address',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.82),
fontSize: 12.5.sp,
fontWeight: FontWeight.w500,
fontFamily: FontConstants.fontFamily,
),
),
),
],
),
],
),
),
SizedBox(width: 8.w),
Container(
width: 30.w,
height: 30.w,
decoration: BoxDecoration(
color: ColorConstants.pureSurface.withValues(alpha: 0.16),
shape: BoxShape.circle,
),
child: Icon(
Icons.arrow_forward_rounded,
color: Colors.white,
size: 16.sp,
),
),
],
),
),
),
);
}
}
/// The word LIVE, with a dot that breathes.
class _LiveChip extends StatelessWidget {
final Animation<double> pulse;
const _LiveChip({required this.pulse});
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.fromLTRB(6.w, 2.5.h, 7.w, 2.5.h),
decoration: BoxDecoration(
color: ColorConstants.pureSurface.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedBuilder(
animation: pulse,
builder: (context, _) {
// A halo that grows and fades around a dot that stays put. Scaling
// the dot itself would shift the text beside it every frame.
return SizedBox(
width: 7.w,
height: 7.w,
child: Stack(
alignment: Alignment.center,
children: [
Transform.scale(
scale: 1.0 + 1.1 * pulse.value,
child: DecoratedBox(
decoration: BoxDecoration(
color: ColorConstants.pureSurface.withValues(
alpha: 0.55 * (1 - pulse.value),
),
shape: BoxShape.circle,
),
child: SizedBox(width: 7.w, height: 7.w),
),
),
Container(
width: 6.w,
height: 6.w,
decoration: BoxDecoration(
color: ColorConstants.pureSurface,
shape: BoxShape.circle,
),
),
],
),
);
},
),
SizedBox(width: 5.w),
Text(
'LIVE',
style: TextStyle(
color: Colors.white,
fontSize: 9.sp,
fontWeight: FontWeight.w800,
letterSpacing: 0.7,
fontFamily: FontConstants.fontFamily,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,392 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/helpers/constants/Font_constant.dart';
import 'package:miler/views/helpers/constants/design_constants.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// Route brief — the VRP facts a miler needs before touching a single stop.
///
/// A miler's day is one solved VRP instance: the hub admin has already fixed
/// the sequence, so the rider never re-optimises anything. What he *does* need
/// is to see the shape of the solution he's been handed, because every
/// classic VRP constraint has a real consequence on the bike:
///
/// • **Capacity (CVRP)** — parcels + weight decide whether the load fits the
/// box. Finding out at stop 9 is too late.
/// • **Time windows (VRPTW)** — each booking has a slot; arriving after it
/// closes is a failed stop. The rider needs to know how many are at risk.
/// • **Route duration limit** — the shift is the hard cap on the route. Time
/// left ÷ stops left is the per-stop budget he's actually racing against.
/// • **Round trip to the depot** — the route ends where it started, so the
/// return leg is part of the distance, not an afterthought.
/// • **Cash on the route** — not a textbook VRP term, but the rider carries
/// it, so it belongs in the same glance.
///
/// Everything here is derived from data the app already has (stop coordinates,
/// quantities, expected pickup times, the shift window in prefs) — no new
/// endpoint required.
/// ─────────────────────────────────────────────────────────────────────────
class RouteBrief {
/// Stops still to be worked on this route.
final int stops;
/// Total parcels to be collected — the load against box capacity.
final int parcels;
/// Total declared weight in kg (0 when the backend doesn't send weights).
final double weightKg;
/// Cash/UPI still to be collected across the route.
final double collectAmount;
/// Approximate round-trip length: rider → stop 1 → … → stop N → back to
/// start. Straight-line (haversine), so it under-reads real road distance —
/// always shown with a `≈`.
final double routeKm;
/// Shift window as stored by the profile sync, e.g. `09:00:00`.
final String shiftStart;
final String shiftEnd;
/// True only when both ends of the window are present and parseable.
final bool shiftSet;
/// Time remaining until the shift closes. Null when the shift isn't set;
/// `Duration.zero` when the window has already passed.
final Duration? timeLeft;
/// Stops whose booked slot has already passed — these are failing their
/// time window right now.
final int overdueStops;
/// Stops whose slot closes within the next 30 minutes.
final int dueSoonStops;
const RouteBrief({
required this.stops,
required this.parcels,
required this.weightKg,
required this.collectAmount,
required this.routeKm,
required this.shiftStart,
required this.shiftEnd,
required this.shiftSet,
required this.timeLeft,
required this.overdueStops,
required this.dueSoonStops,
});
/// A brief that knows nothing — no shift, no load, no pace.
///
/// For callers that render a trip without the page-level context, chiefly
/// [TripCard] used as a whole widget in tests. `shiftSet: false` is the
/// meaningful part: every consumer already treats that as "say nothing about
/// time", so an absent brief degrades to the same state as an unset shift
/// rather than needing a null check at each use.
static const RouteBrief empty = RouteBrief(
stops: 0,
parcels: 0,
weightKg: 0,
collectAmount: 0,
routeKm: 0,
shiftStart: '',
shiftEnd: '',
shiftSet: false,
timeLeft: null,
overdueStops: 0,
dueSoonStops: 0,
);
/// Average time available per remaining stop — the pace the rider has to
/// hold to finish inside the shift. Null when either half is unknown.
Duration? get perStopBudget {
final left = timeLeft;
if (left == null || stops <= 0 || left <= Duration.zero) return null;
return Duration(seconds: left.inSeconds ~/ stops);
}
/// True when the remaining stops can't realistically fit in the time left
/// (under 6 minutes a stop, which is faster than a pickup can be done).
bool get isPaceAtRisk {
final budget = perStopBudget;
return budget != null && budget.inMinutes < 6;
}
String get shiftWindowLabel {
if (!shiftSet) return 'Shift not set';
return '${_prettyTime(shiftStart)} – ${_prettyTime(shiftEnd)}';
}
/// Builds the brief from the pending-stop map the Home page already holds.
///
/// [extraStops] are stops that have left the pending list but are still on
/// the route (accepted, awaiting pickup) — they count towards the load,
/// distance and time-window totals just like pending ones.
///
/// [riderLat]/[riderLng] are optional: without them the route length falls
/// back to chaining the stops themselves (and to the per-stop `kms` field
/// the backend sends, when coordinates are missing altogether).
factory RouteBrief.from({
required List<String> orderIds,
required Map<String, Map<String, dynamic>> ordersMap,
required String shiftStart,
required String shiftEnd,
List<Map<String, dynamic>> extraStops = const [],
double? riderLat,
double? riderLng,
DateTime? now,
}) {
final clock = now ?? DateTime.now();
int parcels = 0;
double weight = 0;
double collect = 0;
int overdue = 0;
int dueSoon = 0;
double fallbackKm = 0;
final points = <({double lat, double lng})>[];
final allStops = <Map<String, dynamic>>[
for (final id in orderIds)
if (ordersMap[id] != null) ordersMap[id]!,
...extraStops,
];
for (final stop in allStops) {
parcels += _toInt(stop['Quantity'] ?? stop['quantity']);
weight += _toDouble(stop['weight'] ?? stop['Weight']);
collect += _toDouble(stop['collectionamt'] ?? stop['pickupamt']);
fallbackKm += _toDouble(stop['kms'] ?? stop['km']);
final lat = _toDouble(stop['pickuplat'] ?? stop['PickupLat']);
final lng = _toDouble(stop['pickuplon'] ?? stop['PickupLon']);
if (lat != 0 && lng != 0) points.add((lat: lat, lng: lng));
final due = _parseTimestamp(
stop['expected_pickup_time'] ??
stop['expectedpickuptime'] ??
stop['slotendtime'],
);
if (due != null) {
if (due.isBefore(clock)) {
overdue++;
} else if (due.difference(clock) <= const Duration(minutes: 30)) {
dueSoon++;
}
}
}
// Round trip: start at the rider (his current position stands in for the
// hub he left), walk the fixed sequence, then close the loop back to it.
double routeKm = 0;
final ({double lat, double lng})? origin =
(riderLat != null && riderLng != null && riderLat != 0 && riderLng != 0)
? (lat: riderLat, lng: riderLng)
: null;
if (points.isNotEmpty) {
final chain = <({double lat, double lng})>[
if (origin != null) origin,
...points,
if (origin != null) origin,
];
for (var i = 0; i < chain.length - 1; i++) {
routeKm += _haversineKm(chain[i], chain[i + 1]);
}
}
if (routeKm == 0) routeKm = fallbackKm;
final start = _parseClock(shiftStart, clock);
final end = _parseClock(shiftEnd, clock);
final shiftSet = start != null && end != null;
Duration? left;
if (shiftSet) {
// Overnight shifts (22:00 → 06:00) roll the end into tomorrow.
final adjustedEnd = end.isBefore(start)
? end.add(const Duration(days: 1))
: end;
final diff = adjustedEnd.difference(clock);
left = diff.isNegative ? Duration.zero : diff;
}
return RouteBrief(
stops: allStops.length,
parcels: parcels,
weightKg: weight,
collectAmount: collect,
routeKm: routeKm,
shiftStart: shiftStart,
shiftEnd: shiftEnd,
shiftSet: shiftSet,
timeLeft: left,
overdueStops: overdue,
dueSoonStops: dueSoon,
);
}
// ── parsing helpers ────────────────────────────────────────────────────
static int _toInt(dynamic v) =>
v == null ? 0 : int.tryParse(v.toString().trim()) ?? 0;
static double _toDouble(dynamic v) {
if (v == null) return 0;
if (v is num) return v.toDouble();
// Strip units/currency the backend sometimes bakes into the string.
final cleaned = v.toString().replaceAll(RegExp(r'[^0-9.\-]'), '');
return double.tryParse(cleaned) ?? 0;
}
static DateTime? _parseTimestamp(dynamic v) {
if (v == null) return null;
return DateTime.tryParse(v.toString().trim());
}
/// Parses `HH:mm[:ss]` or `h:mm AM/PM` onto [onDay].
static DateTime? _parseClock(String raw, DateTime onDay) {
final value = raw.trim();
if (value.isEmpty) return null;
final match = RegExp(
r'^(\d{1,2}):(\d{2})(?::(\d{2}))?\s*([AaPp][Mm])?$',
).firstMatch(value);
if (match == null) return null;
var hour = int.tryParse(match.group(1) ?? '') ?? -1;
final minute = int.tryParse(match.group(2) ?? '') ?? -1;
final second = int.tryParse(match.group(3) ?? '0') ?? 0;
if (hour < 0 || minute < 0 || minute > 59) return null;
final meridiem = match.group(4)?.toUpperCase();
if (meridiem == 'PM' && hour < 12) hour += 12;
if (meridiem == 'AM' && hour == 12) hour = 0;
if (hour > 23) return null;
return DateTime(onDay.year, onDay.month, onDay.day, hour, minute, second);
}
static String _prettyTime(String raw) {
final parsed = _parseClock(raw, DateTime.now());
if (parsed == null) return raw.trim();
final hour12 = parsed.hour % 12 == 0 ? 12 : parsed.hour % 12;
final minute = parsed.minute.toString().padLeft(2, '0');
return '$hour12:$minute ${parsed.hour < 12 ? 'AM' : 'PM'}';
}
static double _haversineKm(
({double lat, double lng}) a,
({double lat, double lng}) b,
) {
const earthRadiusKm = 6371.0;
final dLat = _rad(b.lat - a.lat);
final dLng = _rad(b.lng - a.lng);
final h =
math.sin(dLat / 2) * math.sin(dLat / 2) +
math.cos(_rad(a.lat)) *
math.cos(_rad(b.lat)) *
math.sin(dLng / 2) *
math.sin(dLng / 2);
return earthRadiusKm * 2 * math.atan2(math.sqrt(h), math.sqrt(1 - h));
}
static double _rad(double deg) => deg * math.pi / 180.0;
}
/// Formats a duration the way a rider reads a clock: `2h 15m`, `45m`.
String formatDuration(Duration d) {
if (d.inMinutes < 1) return 'under a minute';
if (d.inHours < 1) return '${d.inMinutes}m';
final minutes = d.inMinutes.remainder(60);
return minutes == 0 ? '${d.inHours}h' : '${d.inHours}h ${minutes}m';
}
/// ─────────────────────────────────────────────────────────────────────────
/// Shift banner — the assigned shift window, and nothing else.
///
/// One state. When the hub has set a shift this is a quiet informational strip:
/// confirmation, not news, so it stays visually silent and never competes with
/// the stop list. When no shift is set the widget renders **nothing**.
///
/// ── Why the "No shift set for today" box is gone ──
///
/// It had already been de-escalated once, from a pulsing hazard-triangle alert
/// down to a calm amber row, and the reasoning that drove that step applies all
/// the way to zero: the rider cannot fix a missing shift, did not cause it, and
/// is not blocked by it — the trips below still load and he can still ride
/// them. The row's own subtitle admitted as much ("your hub manager sets it ·
/// trips still work").
///
/// So it was a permanent amber box, at the top of the first screen, telling the
/// rider about someone else's unfinished admin. Its Retry button could only
/// re-read the same local prefs, because there is no endpoint to re-pull the
/// window — so the one affordance it offered mostly did nothing. Removing it
/// costs the rider no information he can act on and gives the route back the
/// top of the screen.
///
/// The window still governs duty close and the shift-end alarm; it is simply
/// not this widget's job to editorialise when it is absent.
/// ─────────────────────────────────────────────────────────────────────────
class ShiftBanner extends StatelessWidget {
final RouteBrief brief;
const ShiftBanner({super.key, required this.brief});
@override
Widget build(BuildContext context) {
if (!brief.shiftSet) return const SizedBox.shrink();
return _buildSetState();
}
// ── Shift assigned: quiet, informational ───────────────────────────────
Widget _buildSetState() {
final left = brief.timeLeft;
final ended = left != null && left == Duration.zero;
return Container(
padding: EdgeInsets.symmetric(vertical: 9.h, horizontal: 12.w),
decoration: BoxDecoration(
color: ColorConstants.surfaceContainerLow,
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
border: Border.all(color: ColorConstants.borderStrong, width: 1.5),
),
child: Row(
children: [
Icon(
Icons.access_time_rounded,
color: ColorConstants.primary,
size: 15.sp,
),
SizedBox(width: 7.w),
Text(
brief.shiftWindowLabel,
style: TextStyle(
color: ColorConstants.slateText,
fontSize: 12.5.sp,
fontWeight: FontWeight.w700,
fontFamily: FontConstants.fontFamily,
),
),
const Spacer(),
if (left != null)
Text(
ended ? 'Shift ended' : '${formatDuration(left)} left',
style: TextStyle(
color: ended
? ColorConstants.error
: (brief.isPaceAtRisk
? ColorConstants.warning
: ColorConstants.secondaryText),
fontSize: 11.5.sp,
fontWeight: FontWeight.w700,
fontFamily: FontConstants.fontFamily,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,202 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.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';
/// ─────────────────────────────────────────────────────────────────────────
/// SELECTION BAR — the contextual action for a multi-stop pick
///
/// Appears the moment the rider ticks a stop and leaves when he unticks the
/// last one. While it is up it owns the bulk action: the inline "Accept all N
/// stops" button in the trip's footer stands down, so there is never a choice
/// of two bulk buttons pointing at different sets of stops.
///
/// ── Why this floats instead of sitting in the list ──
///
/// It used to be an inline button in the footer, below the stop rows. That put
/// a *response to a direct action* at the bottom of a scroll: tick a stop near
/// the top of a six-stop route and the button confirming what you had just done
/// was off-screen. The rider had no feedback that the tick had registered
/// beyond the tick itself, and no way to act on it without scrolling past every
/// remaining stop.
///
/// A selection is transient and it is *modal in spirit* — the screen is in a
/// different mode while it is non-empty. Every app that does multi-select the
/// way riders already know it, Gmail and Google Photos included, answers that
/// with a contextual bar pinned to the edge of the viewport: it appears with
/// the selection, it is always reachable, and it leaves without a trace. The
/// count lives on it, so the rider never has to go looking for how many he has
/// picked.
///
/// ── Clearing ──
///
/// The ✕ is on the left, where a dismiss belongs and where it cannot be hit by
/// a thumb going for Accept. Without it the only way out of a selection was to
/// untick each stop by hand, which on a six-stop route is six taps to undo one
/// mistake.
/// ─────────────────────────────────────────────────────────────────────────
class SelectionBar extends StatelessWidget {
/// How many stops are ticked. Zero renders nothing.
final int count;
/// Accepts exactly the ticked stops.
final VoidCallback? onAccept;
/// Declines exactly the ticked stops, after asking why. Null hides the
/// action.
final VoidCallback? onReject;
/// Drops the whole selection.
final VoidCallback onClear;
/// True while the accept is in flight.
final bool busy;
const SelectionBar({
super.key,
required this.count,
required this.onAccept,
required this.onClear,
this.onReject,
this.busy = false,
});
@override
Widget build(BuildContext context) {
// Rides the floating nav bar: it is `Positioned` outside the Scaffold's
// body, so `SafeArea` knows nothing about it and anything pinned to the
// bottom lands underneath. See [BottomPage.bottomInset].
//
// A fixed inset now. This used to follow a `navVisible` notifier up and
// down, because the bar left the screen entirely on scroll. It compacts
// instead of leaving, so there is no longer a state in which the room is
// free — and chasing the 20pt difference re-laid this out every time the
// rider changed scroll direction, for a gap he would never notice.
return Padding(
padding: EdgeInsets.only(bottom: BottomPage.bottomInset(context) + 10.h),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 16.w),
child: Material(
color: Colors.white,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
// A real lift, not the page's usual whisper: this is the one surface
// on Home that is temporarily above everything else, and it has to
// read that way against a list of white cards.
elevation: 0,
child: Container(
padding: EdgeInsets.fromLTRB(6.w, 8.h, 10.w, 8.h),
decoration: BoxDecoration(
color: ColorConstants.pureSurface,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
border: Border.all(color: ColorConstants.borderSubtle),
boxShadow: DesignConstants.shadowLg,
),
child: Row(
children: [
_ClearButton(onTap: busy ? null : onClear),
SizedBox(width: 2.w),
// The running count, in words — but only when there is room for
// it. With two decisions on the bar there is not: at 320pt the
// three fixed controls plus this line overflowed by 94pt, and
// "Accept 3" already carries the number. So the sentence is a
// luxury for the one-button case and the space goes to the
// buttons otherwise.
if (onReject == null)
Expanded(
child: Text(
'$count ${count == 1 ? 'stop' : 'stops'} selected',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13.5.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.2,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
)
else
const Spacer(),
SizedBox(width: 6.w),
// ── Both decisions, on the bar ──
//
// Reject used to live on every stop row and went when the rows
// became tick-only. That left the rider able to say yes to a
// selection and nothing else: declining a stop he could not do
// — shop shut, address wrong — had no route at all, and the hub
// learned about it by the stop simply never being accepted.
//
// So it comes back where Accept is, on the set he has picked.
// Outlined and unlabelled-by-count against Accept's filled
// green: they are not equal choices, and the one that ends work
// should not look like the one that starts it.
if (onReject != null) ...[
Flexible(
child: MilerButton(
// No icon, and flexible: the width an icon costs is width
// the two labels need side by side on a 320pt phone, and
// under real pressure the pair should give ground evenly
// rather than run off the edge of the bar.
label: 'Reject',
variant: MilerButtonVariant.outlined,
color: ColorConstants.errorRed,
height: ButtonSizes.secondary,
expand: false,
onPressed: busy ? null : onReject,
),
),
SizedBox(width: 8.w),
],
// Content-hugging, so the count on the left keeps its room on a
// narrow phone and the label never ellipsises — a bulk action
// that reads "Accept 3 sto…" is not one the rider will trust.
Flexible(
child: MilerButton(
label: 'Accept $count',
icon: Icons.done_all_rounded,
color: ColorConstants.acceptGreen,
height: ButtonSizes.secondary,
expand: false,
loading: busy,
onPressed: onAccept,
),
),
],
),
),
),
),
);
}
}
class _ClearButton extends StatelessWidget {
final VoidCallback? onTap;
const _ClearButton({this.onTap});
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
label: 'Clear selection',
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
child: SizedBox(
width: ButtonSizes.minTapTarget,
height: ButtonSizes.minTapTarget,
child: Icon(
Icons.close_rounded,
size: 20.sp,
color: ColorConstants.secondaryText,
),
),
),
);
}
}

View File

@@ -0,0 +1,596 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart' show LatLng;
import 'package:miler/views/helpers/widgets/miler_map.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:miler/views/Dashboard/pickups/route_metrics.dart';
import 'package:miler/views/Dashboard/pickups/stop_type.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/constants/design_constants.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// STOP DETAIL SHEET
///
/// Opens when the rider taps a stop on the trip card. It answers the two
/// questions the list deliberately does not have room for:
///
/// "Where actually is this?" → a map with the hub, the stop, and the leg
/// between them
/// "What exactly is here?" → the full address, contact, parcel counts,
/// cash, notes, OTP
///
/// This is why the card above can afford to truncate. The list is for
/// scanning — one line of identity, two of address — and everything it drops
/// lives here, one tap away. A list that tries to show everything shows
/// nothing well.
///
/// The map is deliberately small and non-interactive: it is an orientation
/// aid, not a navigation surface. Turn-by-turn is Google Maps' job, and the
/// Navigate button hands off to it.
/// ─────────────────────────────────────────────────────────────────────────
class StopDetailSheet extends StatelessWidget {
final Map<String, dynamic> stop;
/// Stop's position in the route, 1-based.
final int stopNumber;
/// Where the rider is now — draws the "you are here" leg on the map.
final double? riderLat;
final double? riderLng;
const StopDetailSheet({
super.key,
required this.stop,
required this.stopNumber,
this.riderLat,
this.riderLng,
});
/// Opens the sheet. Kept here so callers do not each re-declare the shape.
static Future<void> show(
BuildContext context, {
required Map<String, dynamic> stop,
required int stopNumber,
double? riderLat,
double? riderLng,
}) {
return showModalBottomSheet<void>(
context: context,
// The tall variant: this sheet opens at 78% of the screen, so it has a
// long way to travel and gets the time to do it.
sheetAnimationStyle: kMilerLargeSheetStyle,
isScrollControlled: true,
backgroundColor: ColorConstants.pureSurface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (_) => StopDetailSheet(
stop: stop,
stopNumber: stopNumber,
riderLat: riderLat,
riderLng: riderLng,
),
);
}
double _d(dynamic v) {
if (v == null) return 0;
if (v is num) return v.toDouble();
return double.tryParse(v.toString().trim()) ?? 0;
}
String _s(List<String> keys) {
for (final k in keys) {
final v = stop[k];
if (v != null && v.toString().trim().isNotEmpty && v.toString() != '0') {
return v.toString().trim();
}
}
return '';
}
@override
Widget build(BuildContext context) {
final kind = stopKindOf(stop);
final lat = _d(stop['pickuplat'] ?? stop['PickupLat']);
final lng = _d(stop['pickuplon'] ?? stop['PickupLon']);
final hasLocation = lat != 0 && lng != 0;
final name = _s(['pickupcustomer', 'tenantname']);
final address = _s(['pickupaddress', 'PickupAddress']);
final phone = _s(['pickupcontactno']);
final notes = _s(['notes', 'Notes']);
final otp = stopOtp(stop);
final deliver = deliveryParcelCount(stop);
final collect = pickupParcelCount(stop);
final cash = stopCollectionAmount(stop);
final meters = RouteMetricsHelper.metersToStop(
stop,
riderLat: riderLat,
riderLng: riderLng,
);
return DraggableScrollableSheet(
initialChildSize: 0.78,
minChildSize: 0.5,
maxChildSize: 0.95,
expand: false,
builder: (context, scrollController) => Column(
children: [
SizedBox(height: 10.h),
Container(
width: 40.w,
height: 4.h,
decoration: BoxDecoration(
color: ColorConstants.borderStrong,
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
),
),
Expanded(
child: ListView(
controller: scrollController,
padding: EdgeInsets.fromLTRB(20.w, 14.h, 20.w, 20.h),
children: [
_header(kind, name),
SizedBox(height: 14.h),
if (hasLocation) ...[
_map(lat, lng, kind),
SizedBox(height: 10.h),
_distanceLine(meters),
SizedBox(height: 16.h),
],
if (address.isNotEmpty) ...[
_sectionLabel(kind.locationLabel),
SizedBox(height: 5.h),
// The full address, unabridged. The card above compresses it
// to two lines; this is where the rest lives.
SelectableText(
address,
style: TextStyle(
fontSize: 14.5.sp,
height: 1.45,
fontWeight: FontWeight.w600,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 16.h),
],
_sectionLabel('WHAT TO DO HERE'),
SizedBox(height: 8.h),
if (kind.hasDelivery)
_actionRow(
icon: Icons.local_shipping_rounded,
color: ColorConstants.deliveryAccent,
title:
'Deliver ${deliver > 0 ? deliver : 1} parcel${deliver == 1 ? '' : 's'}',
detail: otp.isNotEmpty ? 'OTP $otp' : 'OTP + photo proof',
),
if (kind.hasPickup)
_actionRow(
icon: Icons.inventory_2_rounded,
color: ColorConstants.pickupAccent,
title:
'Collect ${collect > 0 ? collect : 1} parcel${collect == 1 ? '' : 's'}',
detail: cash > 0
? 'Collect ₹${cash.round()}'
: 'Photo + weight',
),
SizedBox(height: 6.h),
_detailGrid(),
if (notes.isNotEmpty) ...[
SizedBox(height: 16.h),
_noteBox(notes),
],
SizedBox(height: 20.h),
_actions(context, phone, lat, lng, hasLocation),
],
),
),
],
),
);
}
Widget _header(StopKind kind, String name) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 34.w,
height: 34.w,
alignment: Alignment.center,
decoration: BoxDecoration(color: kind.accent, shape: BoxShape.circle),
child: Text(
'$stopNumber',
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w800,
color: Colors.white,
fontFamily: FontConstants.fontFamily,
),
),
),
SizedBox(width: 11.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 4.h),
decoration: BoxDecoration(
color: kind.accent,
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
child: Text(
kind.badgeLabel,
style: TextStyle(
fontSize: 10.sp,
fontWeight: FontWeight.w800,
letterSpacing: 0.3,
color: Colors.white,
fontFamily: FontConstants.fontFamily,
),
),
),
SizedBox(height: 6.h),
Text(
name.isEmpty ? 'Stop $stopNumber' : name,
style: TextStyle(
fontSize: 20.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.5,
height: 1.15,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
],
);
}
/// A small, non-interactive map. Orientation only — it answers "which side of
/// town is this?" in one glance and then gets out of the way.
///
/// ── Why it is not lite mode any more ──
///
/// It was. Lite mode renders the map once, into a bitmap, at whatever size the
/// view happens to have when the snapshot is taken — and it does not re-render
/// when that size changes. Everything about where this map lives conspires
/// against that: it is built by [AfterEntrance] a beat *after* the sheet has
/// begun to settle, inside a [DraggableScrollableSheet] whose height is still
/// moving, in a list the rider can drag. The snapshot was therefore taken
/// against a box that no longer existed, and what he saw was half a map with
/// dead space under it.
///
/// A normal map lays out against its box and repaints when the box changes, so
/// the failure is structural rather than a timing race to be tuned. The cost
/// lite mode was buying — not building a platform view during the entrance —
/// is already paid for by [AfterEntrance] holding it back, so nothing is lost
/// by dropping it.
Widget _map(double lat, double lng, StopKind kind) {
final stopPos = LatLng(lat, lng);
final hasRider =
riderLat != null && riderLng != null && riderLat != 0 && riderLng != 0;
final riderPos = hasRider ? LatLng(riderLat!, riderLng!) : null;
return ClipRRect(
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
child: SizedBox(
height: 170.h,
// ── Why the map waits ──
//
// This sheet used to arrive with a jolt, and the curve was never the
// problem: a GoogleMap is an Android platform view, and building one
// costs the UI thread a couple of hundred milliseconds. Constructed in
// the same frame the sheet begins to rise, it swallowed most of the
// entrance — the animation ran, but the frames were never drawn, so the
// sheet appeared to snap into place.
//
// [AfterEntrance] holds it until the sheet has finished rising. The
// motion is now spent on cheap widgets, and the map fades up a beat
// later, which is what Uber's own sheets do.
child: AfterEntrance(
placeholder: MapPlaceholder(radius: 14.r),
builder: (context) => IgnorePointer(
// Not interactive: a pannable map inside a draggable sheet fights
// the sheet's own gestures, and this is not where routing happens.
// [MilerMap] carries its own gesture set; the IgnorePointer above it
// is what actually settles the argument.
child: MilerMap(
initialCenter: stopPos,
initialZoom: 14.5,
polylines: [
if (riderPos != null)
Polyline(
points: [riderPos, stopPos],
color: kind.accent,
strokeWidth: 4,
strokeCap: StrokeCap.round,
),
],
markers: [
milerMarker(
point: stopPos,
width: MilerPin.size + 8,
height: MilerPin.size + MilerPin.tail,
// The stop's own accent, not one of nine stock hues — the same
// colour as the chip on the card this sheet opened from.
child: MilerPin(
color: kind.accent,
icon: kind.icon,
emphasised: true,
),
),
if (riderPos != null)
milerMarker(
point: riderPos,
width: MilerRiderDot.size,
height: MilerRiderDot.size,
child: const MilerRiderDot(),
),
],
),
),
),
),
);
}
Widget _distanceLine(double? meters) {
final distance = RouteMetricsHelper.formatDistance(meters);
final travel = RouteMetricsHelper.travelTime(meters);
if (distance == '—') return const SizedBox.shrink();
return Row(
children: [
Icon(
Icons.near_me_rounded,
size: 16.sp,
color: ColorConstants.secondaryText,
),
SizedBox(width: 6.w),
Text(
'$distance away',
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w800,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
if (travel > Duration.zero) ...[
Text(
' · ${RouteMetricsHelper.formatDuration(travel)} ride',
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w700,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
],
],
);
}
Widget _sectionLabel(String text) => Text(
text,
style: TextStyle(
fontSize: 10.sp,
fontWeight: FontWeight.w800,
letterSpacing: 0.8,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
);
Widget _actionRow({
required IconData icon,
required Color color,
required String title,
required String detail,
}) {
return Padding(
padding: EdgeInsets.only(bottom: 8.h),
child: Row(
children: [
Container(
width: 32.w,
height: 32.w,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
child: Icon(icon, size: 17.sp, color: color),
),
SizedBox(width: 10.w),
Expanded(
child: Text(
title,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w800,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
),
Text(
detail,
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w800,
color: color,
fontFamily: FontConstants.fontFamily,
),
),
],
),
);
}
Widget _detailGrid() {
final rows = <MapEntry<String, String>>[
MapEntry('Order', _s(['orderid', 'OrderId'])),
MapEntry('Product', _s(['description', 'productname', 'product'])),
MapEntry('Weight', () {
final w = _s(['weight', 'Weight']);
return w.isEmpty ? '' : (w.contains('kg') ? w : '$w kg');
}()),
MapEntry('Booked slot', () {
final raw = _s(['expected_pickup_time']);
final t = DateTime.tryParse(raw);
return t == null ? '' : RouteMetricsHelper.formatClock(t);
}()),
MapEntry('Contact', _s(['pickupcontactno'])),
].where((e) => e.value.isNotEmpty).toList();
if (rows.isEmpty) return const SizedBox.shrink();
return Column(
children: [
Divider(height: 20.h, color: ColorConstants.borderSubtle),
for (final r in rows)
Padding(
padding: EdgeInsets.only(bottom: 8.h),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 110.w,
child: Text(
r.key,
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w600,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
),
Expanded(
child: Text(
r.value,
style: TextStyle(
fontSize: 13.5.sp,
fontWeight: FontWeight.w700,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
),
],
),
),
],
);
}
Widget _noteBox(String notes) {
return Container(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 11.h),
decoration: BoxDecoration(
color: ColorConstants.warningContainer,
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
border: Border.all(
color: ColorConstants.warning.withValues(alpha: 0.35),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.sticky_note_2_rounded,
size: 17.sp,
color: ColorConstants.warning,
),
SizedBox(width: 9.w),
Expanded(
child: Text(
notes,
style: TextStyle(
fontSize: 13.sp,
height: 1.4,
fontWeight: FontWeight.w700,
color: ColorConstants.onErrorContainer,
fontFamily: FontConstants.fontFamily,
),
),
),
],
),
);
}
Widget _actions(
BuildContext context,
String phone,
double lat,
double lng,
bool hasLocation,
) {
return Row(
children: [
if (phone.isNotEmpty) ...[
Expanded(
child: MilerButton(
label: 'Call',
icon: Icons.call_rounded,
variant: MilerButtonVariant.outlined,
color: ColorConstants.acceptGreen,
onPressed: () => _dial(phone),
),
),
SizedBox(width: 10.w),
],
Expanded(
flex: 2,
child: MilerButton(
label: 'Navigate',
icon: Icons.navigation_rounded,
color: ColorConstants.primary,
onPressed: hasLocation ? () => _navigate(lat, lng) : null,
),
),
],
);
}
Future<void> _dial(String phone) async {
final clean = phone.replaceAll(RegExp(r'[^\d+]'), '');
if (clean.isEmpty) return;
try {
await launchUrl(
Uri(scheme: 'tel', path: clean),
mode: LaunchMode.platformDefault,
);
} catch (_) {}
}
Future<void> _navigate(double lat, double lng) async {
// Native intent first, web as the fallback — same order as the stop screen.
try {
final native = Uri.parse('google.navigation:q=$lat,$lng&mode=d');
if (await launchUrl(native, mode: LaunchMode.externalApplication)) return;
} catch (_) {}
try {
await launchUrl(
Uri.parse(
'https://www.google.com/maps/dir/?api=1'
'&destination=$lat,$lng&travelmode=driving',
),
mode: LaunchMode.externalApplication,
);
} catch (_) {}
}
}

View File

@@ -0,0 +1,752 @@
import 'dart:math' as math;
import 'package:miler/views/Dashboard/pickups/stop_type.dart';
import 'package:miler/views/Dashboard/pickups/route_metrics.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// A TRIP — one slot's worth of work, as a single unit.
///
/// The hub manager does not assign bookings one at a time. He takes every
/// customer booking that falls inside a slot (say 10:00–13:00), orders them
/// into a route, and hands the whole thing to one miler. So the rider's
/// decision is never "do I want this one parcel?" — it is "do I take this
/// trip?". Home used to show loose bookings, which framed the job as a
/// marketplace it isn't, and hid the two facts that actually matter: how long
/// the whole run takes, and where it starts and ends.
///
/// A trip is therefore always shaped:
///
/// HUB → Stop 1 → Stop 2 → … → Stop N → HUB
///
/// with the same hub at both ends. Every duration and distance below includes
/// the return leg, because the rider is not finished until he is back.
///
/// This class is pure — no Flutter, no I/O — so the arithmetic is unit-tested
/// and safe to call from `build`.
/// ─────────────────────────────────────────────────────────────────────────
class Trip {
/// Stable identity for this trip (backend slot/trip id, or a derived key).
final String id;
/// The customer-facing slot window this trip serves. Null when the backend
/// sends no usable times — the trip is then simply "today's route".
final DateTime? slotStart;
final DateTime? slotEnd;
/// Stops in the admin's fixed order. Never re-sorted by the app.
final List<Map<String, dynamic>> stops;
/// Straight-line round-trip length in metres, hub → stops → hub.
final double routeMeters;
/// Sum of the per-stop service estimates (time off the bike).
final Duration serviceDuration;
/// Time on the bike, at [RouteMetricsHelper.kDefaultSpeedMps].
final Duration travelDuration;
final int totalParcels;
final int deliverParcels;
final int collectParcels;
final double totalWeightKg;
final double cashToCollect;
const Trip({
required this.id,
required this.slotStart,
required this.slotEnd,
required this.stops,
required this.routeMeters,
required this.serviceDuration,
required this.travelDuration,
required this.totalParcels,
required this.deliverParcels,
required this.collectParcels,
required this.totalWeightKg,
required this.cashToCollect,
});
int get stopCount => stops.length;
/// The number the rider is actually planning around: ride time plus every
/// minute spent standing at a door.
Duration get totalDuration => travelDuration + serviceDuration;
/// Average time per stop across the whole trip, service only.
Duration get averageStopDuration => stopCount == 0
? Duration.zero
: Duration(seconds: serviceDuration.inSeconds ~/ stopCount);
/// `10:00 AM – 1:00 PM`, or a plain label when the slot is unknown.
String get slotLabel {
final s = slotStart;
final e = slotEnd;
if (s == null || e == null) return "Today's route";
return '${RouteMetricsHelper.formatClock(s)} – '
'${RouteMetricsHelper.formatClock(e)}';
}
/// True when the slot window has already closed.
bool isExpired([DateTime? now]) {
final e = slotEnd;
if (e == null) return false;
return (now ?? DateTime.now()).isAfter(e);
}
/// Order ids in this trip.
List<String> get orderIds => [
for (final s in stops)
if ((s['orderid'] ?? '').toString().isNotEmpty) (s['orderid']).toString(),
];
// ── Per-stop state & completion ────────────────────────────────────────
/// State of every stop, in route order.
List<StopState> states({
required Set<String> acceptedIds,
required Set<String> rejectedIds,
}) => [
for (final s in stops)
stopStateOf(s, acceptedIds: acceptedIds, rejectedIds: rejectedIds),
];
/// Ids an "accept whole trip" action should operate on — only the stops
/// still awaiting a decision. Re-accepting one already accepted would be a
/// wasted call, and re-accepting a rejected one would undo the rider.
List<String> pendingOrderIds({
required Set<String> acceptedIds,
required Set<String> rejectedIds,
}) => [
for (final s in stops)
if (stopStateOf(s, acceptedIds: acceptedIds, rejectedIds: rejectedIds) ==
StopState.pending)
(s['orderid'] ?? '').toString(),
].where((id) => id.isNotEmpty).toList();
/// Fraction of the trip that is finished, 0.0 → 1.0.
///
/// Counts **resolved** stops — done or rejected — not just completed ones.
/// A rejected stop is off the rider's plate; leaving it out of the numerator
/// would strand the trip below 100% with nothing he could do about it.
double completion({
required Set<String> acceptedIds,
required Set<String> rejectedIds,
}) {
if (stops.isEmpty) return 0;
final resolved = states(
acceptedIds: acceptedIds,
rejectedIds: rejectedIds,
).where((s) => s.isResolved).length;
return resolved / stops.length;
}
/// Completion as a whole percent, for display.
int completionPercent({
required Set<String> acceptedIds,
required Set<String> rejectedIds,
}) => (completion(acceptedIds: acceptedIds, rejectedIds: rejectedIds) * 100)
.round();
/// True once every stop has been resolved — the rider can head back.
bool isFinished({
required Set<String> acceptedIds,
required Set<String> rejectedIds,
}) =>
stops.isNotEmpty &&
states(
acceptedIds: acceptedIds,
rejectedIds: rejectedIds,
).every((s) => s.isResolved);
/// True while any stop still needs an accept/reject decision.
bool hasPendingStops({
required Set<String> acceptedIds,
required Set<String> rejectedIds,
}) => states(
acceptedIds: acceptedIds,
rejectedIds: rejectedIds,
).any((s) => s.needsDecision);
/// Short tab label — `Trip 1`.
static String tabLabel(int index) => 'Trip ${index + 1}';
/// Address components every stop on this trip shares — city, state,
/// pincode and so on. Cached per build by the caller.
List<String> get sharedAddressTail => commonAddressTail([
for (final s in stops) (s['pickupaddress'] ?? '').toString(),
]);
/// The distinguishing part of a stop's address, with the shared tail removed.
String shortAddress(Map<String, dynamic> stop, List<String> tail) =>
stripAddressTail((stop['pickupaddress'] ?? '').toString(), tail);
// ── Per-stop estimates ─────────────────────────────────────────────────
/// Estimated time OFF the bike at [index].
///
/// Service time is not a constant. Handing a parcel over and reading back an
/// OTP is quick; weighing and photographing a collection is slower; doing
/// both at one door is slower still; and counting cash adds real minutes.
/// These are the numbers the pace warning on Home is measured against, so
/// they are deliberately not optimistic.
static Duration serviceTimeFor(Map<String, dynamic> stop) {
final kind = stopKindOf(stop);
var seconds = switch (kind) {
StopKind.delivery => 150, // 2m30 — hand over, OTP, photo
StopKind.pickup => 240, // 4m00 — collect, weigh, photo
StopKind.combined => 330, // 5m30 — both, but one walk-up
};
// Cash handling is the single biggest variable at a door.
if (stopCollectionAmount(stop) > 0) seconds += 90;
// Multi-parcel stops take longer to count and load. First parcel is in
// the base estimate; each extra adds handling time.
final parcels = deliveryParcelCount(stop) + pickupParcelCount(stop);
if (parcels > 1) seconds += (parcels - 1) * 25;
return Duration(seconds: seconds);
}
/// Estimated ride time from the previous point to stop [index]. For index 0
/// that is the hub → stop 1 leg.
Duration travelTimeToStop(int index, {double? hubLat, double? hubLng}) {
if (index < 0 || index >= stops.length) return Duration.zero;
final ({double lat, double lng})? from = index == 0
? _origin(hubLat, hubLng)
: _coordsOf(stops[index - 1]);
final to = _coordsOf(stops[index]);
if (from == null || to == null) return Duration.zero;
final meters = RouteMetricsHelper.distanceMeters(
from.lat,
from.lng,
to.lat,
to.lng,
);
return RouteMetricsHelper.travelTime(meters);
}
/// Running clock estimate of when the rider reaches stop [index], assuming
/// he leaves the hub at [departure].
DateTime etaForStop(
int index, {
required DateTime departure,
double? hubLat,
double? hubLng,
}) {
var clock = departure;
for (var i = 0; i <= index && i < stops.length; i++) {
clock = clock.add(travelTimeToStop(i, hubLat: hubLat, hubLng: hubLng));
if (i < index) clock = clock.add(serviceTimeFor(stops[i]));
}
return clock;
}
({double lat, double lng})? _origin(double? lat, double? lng) =>
(lat != null && lng != null && lat != 0 && lng != 0)
? (lat: lat, lng: lng)
: null;
// ── Construction ───────────────────────────────────────────────────────
/// Builds one trip from an ordered list of stops.
///
/// [hubLat]/[hubLng] anchor both ends of the round trip. The backend does
/// not yet send hub coordinates, so callers pass the rider's current
/// position — he is at (or near) the hub when he picks up a trip, which
/// makes it a workable stand-in. When it is missing, the route length is the
/// stop-to-stop chain only and the return leg is simply absent rather than
/// guessed.
factory Trip.fromStops({
required String id,
required List<Map<String, dynamic>> stops,
DateTime? slotStart,
DateTime? slotEnd,
double? hubLat,
double? hubLng,
}) {
final ordered = sortStops(stops);
int parcels = 0;
int deliver = 0;
int collect = 0;
double weight = 0;
double cash = 0;
var service = Duration.zero;
for (final s in ordered) {
final d = deliveryParcelCount(s);
final c = pickupParcelCount(s);
deliver += d;
collect += c;
parcels += (d + c) > 0 ? (d + c) : 1;
weight += _toDouble(s['weight'] ?? s['Weight']);
cash += stopCollectionAmount(s);
service += serviceTimeFor(s);
}
// Round trip: hub → every stop in order → back to the hub.
final chain = <({double lat, double lng})>[];
final origin =
(hubLat != null && hubLng != null && hubLat != 0 && hubLng != 0)
? (lat: hubLat, lng: hubLng)
: null;
if (origin != null) chain.add(origin);
for (final s in ordered) {
final c = _coordsOf(s);
if (c != null) chain.add(c);
}
if (origin != null && chain.length > 1) chain.add(origin);
double meters = 0;
for (var i = 0; i < chain.length - 1; i++) {
meters += RouteMetricsHelper.distanceMeters(
chain[i].lat,
chain[i].lng,
chain[i + 1].lat,
chain[i + 1].lng,
);
}
if (meters == 0) {
// Fall back to whatever per-stop kilometres the backend sent.
for (final s in ordered) {
meters += _toDouble(s['kms'] ?? s['km']) * 1000;
}
}
return Trip(
id: id,
slotStart: slotStart,
slotEnd: slotEnd,
stops: ordered,
routeMeters: meters,
serviceDuration: service,
travelDuration: RouteMetricsHelper.travelTime(meters),
totalParcels: parcels,
deliverParcels: deliver,
collectParcels: collect,
totalWeightKg: weight,
cashToCollect: cash,
);
}
/// Groups loose stops into trips.
///
/// Grouping key, in order of trust:
/// 1. An explicit backend `tripid` / `slotid` / `routeid`.
/// 2. An explicit slot window (`slotstarttime` + `slotendtime`).
/// 3. The `expected_pickup_time` bucketed into a [windowHours] block
/// aligned to the clock — 10:00–13:00, 13:00–16:00, and so on. This is
/// how the hub builds slots, so bucketing reproduces them closely enough
/// to be useful while the backend has no trip field.
/// 4. Everything else lands in one "today's route" trip rather than being
/// dropped.
///
/// Returned trips are ordered by slot start, earliest first.
/// The most trips a rider's day can contain.
///
/// Three is the operational fact, not a display limit: the hub assigns morning,
/// afternoon and evening slots. [TripTabs] renders three slots for the same
/// reason, and [_capToMaxTrips] folds any extra buckets into the last so the
/// two never disagree.
static const int maxTripsPerDay = 3;
static List<Trip> groupIntoTrips(
List<Map<String, dynamic>> stops, {
int windowHours = 3,
double? hubLat,
double? hubLng,
DateTime? now,
}) {
if (stops.isEmpty) return const [];
final clock = now ?? DateTime.now();
final buckets = <String, List<Map<String, dynamic>>>{};
final windows = <String, (DateTime?, DateTime?)>{};
for (final stop in stops) {
final explicitId = _firstNonEmpty(stop, [
'tripid',
'tripId',
'slotid',
'slotId',
'routeid',
'routeId',
]);
final slotFrom = _parseTimestamp(
stop['slotstarttime'] ?? stop['slotStartTime'] ?? stop['slotfrom'],
);
final slotTo = _parseTimestamp(
stop['slotendtime'] ?? stop['slotEndTime'] ?? stop['slotto'],
);
String key;
DateTime? from;
DateTime? to;
if (explicitId != null) {
key = 'id:$explicitId';
from = slotFrom;
to = slotTo;
} else if (slotFrom != null && slotTo != null) {
key = 'slot:${slotFrom.toIso8601String()}';
from = slotFrom;
to = slotTo;
} else {
final due = _parseTimestamp(
stop['expected_pickup_time'] ??
stop['expectedpickuptime'] ??
stop['eta'],
);
if (due != null) {
final blockHour = (due.hour ~/ windowHours) * windowHours;
from = DateTime(due.year, due.month, due.day, blockHour);
to = from.add(Duration(hours: windowHours));
key = 'win:${from.toIso8601String()}';
} else {
from = null;
to = null;
key = 'day:${clock.year}-${clock.month}-${clock.day}';
}
}
buckets.putIfAbsent(key, () => []).add(stop);
windows.putIfAbsent(key, () => (from, to));
}
final trips = <Trip>[
for (final entry in buckets.entries)
Trip.fromStops(
id: entry.key,
stops: entry.value,
slotStart: windows[entry.key]?.$1,
slotEnd: windows[entry.key]?.$2,
hubLat: hubLat,
hubLng: hubLng,
),
];
trips.sort((a, b) {
final as = a.slotStart;
final bs = b.slotStart;
if (as == null && bs == null) return 0;
if (as == null) return 1; // undated trips last
if (bs == null) return -1;
return as.compareTo(bs);
});
return _capToMaxTrips(trips, hubLat: hubLat, hubLng: hubLng);
}
/// A rider's day is three trips. Anything past that is folded into the last.
///
/// The bucketing above is time-based — a stop with no trip id lands in a
/// three-hour window — so a long day, or one stop booked well outside the
/// others, can produce a fourth or fifth bucket. That surfaced as a "Trip 4"
/// tab, which is not a thing the hub assigns.
///
/// **Merged, never dropped.** Truncating the list would be the one-line fix
/// and it would silently hide real work: those stops are accepted bookings the
/// rider is expected to complete, and a stop he cannot see is a stop he cannot
/// deliver. The tail is therefore folded into the third trip, whose slot then
/// spans from its own start to the last stop's end, so the label stays honest
/// about what it now contains.
static List<Trip> _capToMaxTrips(
List<Trip> trips, {
double? hubLat,
double? hubLng,
}) {
if (trips.length <= maxTripsPerDay) return trips;
final kept = trips.take(maxTripsPerDay - 1).toList();
final tail = trips.skip(maxTripsPerDay - 1).toList();
final mergedStops = [for (final t in tail) ...t.stops];
final starts = tail.map((t) => t.slotStart).whereType<DateTime>();
final ends = tail.map((t) => t.slotEnd).whereType<DateTime>();
kept.add(
Trip.fromStops(
id: tail.first.id,
stops: mergedStops,
slotStart: starts.isEmpty
? null
: starts.reduce((a, b) => a.isBefore(b) ? a : b),
slotEnd: ends.isEmpty
? null
: ends.reduce((a, b) => a.isAfter(b) ? a : b),
hubLat: hubLat,
hubLng: hubLng,
),
);
return kept;
}
/// Puts stops in the admin's intended order.
///
/// `step` is authoritative — it IS the admin's solved sequence, and the app
/// must never second-guess it. Only when `step` is absent do we fall back to
/// the booked time, then to the order the backend sent. Sorting by distance
/// would be re-optimising the route, which the rider is not allowed to do.
static List<Map<String, dynamic>> sortStops(
List<Map<String, dynamic>> stops,
) {
final indexed = <(int, Map<String, dynamic>)>[
for (var i = 0; i < stops.length; i++) (i, stops[i]),
];
indexed.sort((a, b) {
final stepA = _toInt(a.$2['step'] ?? a.$2['Step']);
final stepB = _toInt(b.$2['step'] ?? b.$2['Step']);
if (stepA > 0 && stepB > 0 && stepA != stepB) return stepA - stepB;
if (stepA > 0 && stepB <= 0) return -1;
if (stepB > 0 && stepA <= 0) return 1;
final dueA = _parseTimestamp(a.$2['expected_pickup_time']);
final dueB = _parseTimestamp(b.$2['expected_pickup_time']);
if (dueA != null && dueB != null && dueA != dueB) {
return dueA.compareTo(dueB);
}
return a.$1 - b.$1; // stable: keep backend order
});
return [for (final e in indexed) e.$2];
}
// ── helpers ────────────────────────────────────────────────────────────
static ({double lat, double lng})? _coordsOf(Map<String, dynamic> stop) {
final lat = _toDouble(stop['pickuplat'] ?? stop['PickupLat']);
final lng = _toDouble(stop['pickuplon'] ?? stop['PickupLon']);
if (lat == 0 || lng == 0) return null;
return (lat: lat, lng: lng);
}
static String? _firstNonEmpty(Map<String, dynamic> m, List<String> keys) {
for (final k in keys) {
final v = m[k];
final s = v?.toString().trim() ?? '';
if (s.isNotEmpty && s != '0' && s != 'null') return s;
}
return null;
}
static DateTime? _parseTimestamp(dynamic v) {
if (v == null) return null;
return DateTime.tryParse(v.toString().trim());
}
static int _toInt(dynamic v) {
if (v == null) return 0;
if (v is num) return v.toInt();
return int.tryParse(v.toString().trim()) ?? 0;
}
static double _toDouble(dynamic v) {
if (v == null) return 0;
if (v is num) return v.toDouble();
final cleaned = v.toString().replaceAll(RegExp(r'[^0-9.\-]'), '');
return double.tryParse(cleaned) ?? 0;
}
}
/// ─────────────────────────────────────────────────────────────────────────
/// ADDRESS COMPRESSION
///
/// A raw stop address is written for a postal system, not a rider:
///
/// "5/2, North Avenue, Vadavalli, Coimbatore, Tamil Nadu, 641041"
///
/// On a six-stop trip, "Coimbatore, Tamil Nadu, 641041" is repeated six times
/// and distinguishes nothing — every stop is in the same city, or the hub
/// would not have put them on one route. It is pure noise, and it is the noise
/// that pushes the useful part ("5/2, North Avenue") onto a second line and
/// then off the end of an ellipsis.
///
/// So: find the trailing components EVERY stop on the trip shares, and drop
/// them. What survives is exactly the part that tells one stop from another.
/// The full address is never lost — it goes to the detail sheet and to the
/// navigation hand-off untouched.
///
/// This is data-driven rather than a hardcoded list of cities and states: it
/// works for any city, any country, and degrades to a no-op on a one-stop trip
/// (where nothing is shared, so nothing is dropped).
/// ─────────────────────────────────────────────────────────────────────────
/// Splits an address into trimmed, non-empty components.
List<String> addressParts(String address) =>
address.split(',').map((p) => p.trim()).where((p) => p.isNotEmpty).toList();
/// The trailing components shared by every address in [addresses].
///
/// Returns an empty list when fewer than two addresses are given, or when the
/// tails differ — there is then nothing redundant to remove.
List<String> commonAddressTail(List<String> addresses) {
final parts = [
for (final a in addresses)
if (addressParts(a).isNotEmpty) addressParts(a),
];
if (parts.length < 2) return const [];
final shortest = parts.map((p) => p.length).reduce((a, b) => a < b ? a : b);
final tail = <String>[];
for (var back = 1; back < shortest; back++) {
final candidate = parts.first[parts.first.length - back].toLowerCase();
final shared = parts.every(
(p) => p[p.length - back].toLowerCase() == candidate,
);
if (!shared) break;
tail.insert(0, parts.first[parts.first.length - back]);
}
return tail;
}
/// [address] with [tail] removed. Always leaves at least one component, so a
/// stop can never render a blank address.
String stripAddressTail(String address, List<String> tail) {
if (tail.isEmpty) return address.trim();
final parts = addressParts(address);
var end = parts.length;
for (var i = tail.length - 1; i >= 0; i--) {
if (end <= 1) break;
if (parts[end - 1].toLowerCase() != tail[i].toLowerCase()) break;
end--;
}
return parts.sublist(0, end).join(', ');
}
/// Where a single stop sits in the accept → work → done lifecycle.
///
/// This is what lets one trip card serve both Home and Bookings. A trip does
/// not leave Home the moment it is accepted — the rider still wants to watch
/// it fill up — so every stop carries its own state and the card renders the
/// right control for it: Accept/Reject while pending, a progress tick once
/// it's moving, nothing at all once it's done.
enum StopState {
/// Awaiting the rider's decision. Shows Accept + Reject.
pending,
/// Accepted, not yet started. Lives on the Bookings tab.
accepted,
/// The rider is physically on this stop right now.
active,
/// Picked up / delivered.
done,
/// Skipped, awaiting a return visit. Not a failure.
skipped,
/// The rider declined this stop. It stays visible, struck through, so he
/// can see he handled it rather than wondering where it went.
rejected,
}
extension StopStateX on StopState {
/// Counts toward "trip complete". A rejected stop is resolved — the rider
/// has nothing left to do with it — so it must not hold the trip at 90%
/// forever.
bool get isResolved => this == StopState.done || this == StopState.rejected;
/// Still needs a decision from the rider.
bool get needsDecision => this == StopState.pending;
/// Committed to: he has taken it and owes the customer a visit.
bool get isCommitted =>
this == StopState.accepted ||
this == StopState.active ||
this == StopState.skipped;
String get label => switch (this) {
StopState.pending => 'Awaiting your decision',
StopState.accepted => 'Accepted',
StopState.active => 'In progress',
StopState.done => 'Completed',
StopState.skipped => 'Skipped',
StopState.rejected => 'Rejected',
};
}
/// Derives a stop's state from its backend status plus the local
/// accepted/rejected stores.
///
/// Local stores win for accept/reject because those writes are optimistic —
/// the rider's tap is recorded instantly and the network catches up. If the
/// server view won, a slow response would bounce a stop back to "pending"
/// under his thumb.
StopState stopStateOf(
Map<String, dynamic> stop, {
required Set<String> acceptedIds,
required Set<String> rejectedIds,
}) {
final id = (stop['orderid'] ?? '').toString();
final raw = (stop['orderstatus'] ?? '').toString().trim().toLowerCase();
// 1. Irreversible server states win over everything. A parcel that has been
// collected or handed over cannot be un-decided by a local tap.
if (raw == 'picked' ||
raw == 'picked up' ||
raw == 'pickedup' ||
raw == 'pickuped' ||
raw == 'delivered' ||
raw == 'cancelled' ||
raw == 'canceled') {
return StopState.done;
}
// 2. The rider is physically on the stop — also not a local decision.
if (raw == 'active' || raw == 'arrived') return StopState.active;
if (raw == 'skipped') return StopState.skipped;
// 3. Local decisions next, and they beat the server's accept/reject.
// They are strictly newer: the rider just tapped, and the server view is
// at best one poll behind. Without this, un-rejecting a stop would be
// undone by the next refetch still reporting `rejected`.
if (rejectedIds.contains(id)) return StopState.rejected;
if (acceptedIds.contains(id)) return StopState.accepted;
// 4. Finally the server's own view.
if (raw == 'rejected') return StopState.rejected;
if (raw == 'accepted') return StopState.accepted;
return StopState.pending;
}
/// How a stop is progressing through the trip, for the progress rail.
enum StopProgress {
/// Finished — picked up / delivered. Rail turns green here.
done,
/// The stop the rider is on right now.
current,
/// Not started.
pending,
/// Skipped and awaiting a return visit.
skipped,
}
/// Formats a duration the way a rider plans: `2h 15m`, `45 min`.
String formatTripDuration(Duration d) {
if (d.inMinutes < 1) return '—';
if (d.inMinutes < 60) return '${d.inMinutes} min';
final minutes = d.inMinutes.remainder(60);
return minutes == 0 ? '${d.inHours}h' : '${d.inHours}h ${minutes}m';
}
/// Rounds a duration up to the nearest 5 minutes.
///
/// An estimate printed as "37 min" claims a precision the arithmetic does not
/// have and invites the rider to treat it as a promise. "40 min" reads as
/// what it is.
Duration roundTripDuration(Duration d) {
if (d.inSeconds <= 0) return Duration.zero;
final minutes = (d.inMinutes / 5).ceil() * 5;
return Duration(minutes: math.max(5, minutes));
}

View File

@@ -0,0 +1,590 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:miler/views/Dashboard/home/route_brief.dart';
import 'package:miler/views/Dashboard/home/trip.dart';
import 'package:miler/views/Dashboard/pickups/route_metrics.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';
/// ─────────────────────────────────────────────────────────────────────────
/// THE TRIP BRIEF — one strip, opened only when the rider asks for it
///
/// ── What this replaced, and what it cost ──
///
/// The top of Home carried four separate surfaces before the first stop: a
/// `ShiftBanner` strip, a large white "CURRENT SHIFT" card holding a 25sp slot
/// window, a 66dp completion ring, a parcel chip and VIEW ROUTE, and then a row
/// of three shadowed stat cards for Duration, Distance and Payment.
///
/// Measured at the 390×844 design size, that stack was **~351dp**: roughly
/// 49 for the banner, 168 for the shift card, 114 for the stat cards, plus the
/// gaps. On the same device the scrollable region under the app bar is about
/// 650dp, and the pinned trip tabs take ~56 of it. So **over half of the
/// scrollable screen was spent before the rider saw a single stop**, and on a
/// six-stop route he could see two of them.
///
/// ── Why that was the wrong trade, not just a big one ──
///
/// Every one of those figures is **start-of-shift briefing material**. The slot
/// window, the round-trip duration, the route distance, the parcel count and
/// the cash total are all fixed the moment the hub assigns the trip; none of
/// them changes while the rider works. They are read once, at the start, and
/// then they sit there for the rest of the shift charging full price.
///
/// Only two things on that whole stack move during a shift: **how far through
/// he is**, and **how much of the shift is left**. Those are the two the rider
/// glances at repeatedly, and they were the two buried among six that he wasn't
/// looking at. So they stay on the face of the strip and everything else goes
/// one tap away — the same trade Uber Driver makes with its earnings header and
/// Google Maps makes with the collapsed navigation sheet: one live line, the
/// full breakdown on demand.
///
/// ── Two things this also fixes ──
///
/// • **The old card was mislabelled.** It printed the caption "CURRENT SHIFT"
/// above `trip.slotLabel`, which is the *trip's* window (09:00–13:00), not
/// the rider's shift (09:00–18:00). The real shift window was in the
/// `ShiftBanner` directly above it. Two different time ranges, stacked, one
/// of them wearing the other's name. Expanded, they are now adjacent rows
/// with their own labels, so the difference is finally legible.
///
/// • **Progress was drawn twice.** A 66dp animated ring here, and "50% done"
/// on the pinned tab ~56dp below it. The ring is now 34dp and carries the
/// fraction as text beside it, because the tab already says the percentage.
///
/// ── What is deliberately NOT hidden ──
///
/// Collapsing must never hide a problem. Two things therefore stay on the
/// collapsed face:
///
/// • **Time left**, coloured by [RouteBrief.isPaceAtRisk] and by whether the
/// window has closed. If the rider cannot finish in time, the strip says so
/// without being opened.
/// • **Cash to collect**, whenever there is any. Miler is the carrier, so that
/// money is the rider's personal liability until he reconciles it at the
/// hub — how much he is carrying is not reference material.
/// ─────────────────────────────────────────────────────────────────────────
class TripBriefStrip extends StatefulWidget {
final Trip trip;
final RouteBrief brief;
/// Completion across the trip, 0–100. Supplied by the page because it is
/// computed from the accepted/rejected sets the page owns.
final int percent;
/// Opens the whole route on one map. Hidden when the trip has no stops.
final VoidCallback? onViewRoute;
const TripBriefStrip({
super.key,
required this.trip,
required this.brief,
required this.percent,
this.onViewRoute,
});
@override
State<TripBriefStrip> createState() => _TripBriefStripState();
}
class _TripBriefStripState extends State<TripBriefStrip> {
/// Starts closed. The brief is a once-a-shift read, and defaulting it open
/// would give back exactly the height this widget exists to reclaim.
bool _open = false;
Trip get _trip => widget.trip;
RouteBrief get _brief => widget.brief;
/// Stops the rider has finished, derived from the percentage the page already
/// computed rather than recounting the sets here.
int get _done => (_trip.stopCount * widget.percent / 100).round();
@override
Widget build(BuildContext context) {
return Padding(
// 12, which is the page gutter — the same value `_footerBar` uses and the
// one the header this replaced used. It reads as 28 when [TripCard] is
// composed whole (its own 16 plus this), and as 12 on Home, which calls
// the three sections directly so that the trip tabs can be a pinned
// sliver between them. The stop rows deliberately sit 4 further in; see
// the gutter group in `card_density_test.dart`.
padding: EdgeInsets.fromLTRB(12.w, 10.h, 12.w, 4.h),
child: Container(
decoration: BoxDecoration(
color: ColorConstants.pureSurface,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
// One surface, one shadow. The stack this replaced nested a shadowed
// card inside padding inside three more shadowed cards — four
// elevations describing one object.
boxShadow: const [
BoxShadow(
color: Color(0x14000000),
blurRadius: 16,
offset: Offset(0, 5),
),
],
),
// Grows and shrinks in place rather than the body appearing in one
// frame, so the stop list below is pushed down visibly and the rider
// can see where the extra content came from.
child: AnimatedSize(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
alignment: Alignment.topCenter,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [_face(), if (_open) _body()],
),
),
),
);
}
// ── The collapsed face ───────────────────────────────────────────────────
//
// Ring, the two live figures, the cash chip, the chevron. Nothing else ever
// appears here.
Widget _face() {
final radius = BorderRadius.circular(DesignConstants.radiusXl);
final cash = _trip.cashToCollect;
return Semantics(
button: true,
expanded: _open,
label: _open ? 'Hide trip details' : 'Show trip details',
child: Material(
color: Colors.transparent,
borderRadius: radius,
child: InkWell(
onTap: () => setState(() => _open = !_open),
borderRadius: radius,
child: Padding(
padding: EdgeInsets.fromLTRB(14.w, 12.h, 10.w, 12.h),
child: Row(
children: [
_MiniRing(percent: widget.percent),
SizedBox(width: 12.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'$_done of ${_trip.stopCount} '
'${_trip.stopCount == 1 ? 'stop' : 'stops'} done',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.3,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 2.h),
_timeLeftLine(),
],
),
),
if (cash > 0) ...[SizedBox(width: 8.w), _cashChip(cash)],
SizedBox(width: 2.w),
AnimatedRotation(
turns: _open ? 0.5 : 0,
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
child: Icon(
Icons.keyboard_arrow_down_rounded,
size: 24.sp,
color: ColorConstants.secondaryText,
),
),
],
),
),
),
),
);
}
/// The one line on this widget that can carry bad news, so it is the one line
/// that is allowed to change colour.
Widget _timeLeftLine() {
final left = _brief.timeLeft;
final String text;
final Color color;
if (!_brief.shiftSet || left == null) {
// The hub has not set a window. Say nothing about time rather than
// inventing a state — see the note on [ShiftBanner] for why an absent
// shift is not the rider's problem to solve.
text = _trip.slotLabel;
color = ColorConstants.secondaryText;
} else if (left == Duration.zero) {
text = 'Shift ended';
color = ColorConstants.error;
} else if (_brief.isPaceAtRisk) {
// Under six minutes a stop — faster than a pickup can physically be
// done. He needs to see this without opening anything.
text = '${formatDuration(left)} left · running tight';
color = ColorConstants.warning;
} else {
text = '${formatDuration(left)} left in shift';
color = ColorConstants.secondaryText;
}
return Text(
text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12.sp,
fontWeight: FontWeight.w700,
color: color,
fontFamily: FontConstants.fontFamily,
),
);
}
Widget _cashChip(double cash) {
return Container(
padding: EdgeInsets.symmetric(horizontal: 9.w, vertical: 5.h),
decoration: BoxDecoration(
color: ColorConstants.acceptGreen.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
),
child: Text(
'₹${_money(cash)}',
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.3,
color: ColorConstants.moneyGreen,
fontFamily: FontConstants.fontFamily,
),
),
);
}
// ── The expanded brief ───────────────────────────────────────────────────
//
// ── Why there are no cards in here ──
//
// Each figure used to sit in its own bordered, tinted box. That is four more
// surfaces inside a surface that is already inside a page — and it repeated
// the exact mistake the old permanent header made, just one tap further in:
// a box around a number adds no information, the number IS the information.
// Worse, a boxed figure has to be *aligned* somewhere inside its box, and
// left-aligning it parked every value in the top-left corner of an otherwise
// empty rectangle, so the band read as four mostly-blank cards rather than
// four facts.
//
// So: no boxes, and the figures are centred on their own column. One white
// surface, two bands of content, and the whitespace between them doing the
// grouping that borders were doing badly.
//
// 1. **The shift window**, as a full-width label→value row, and only when
// the hub has actually set one. The trip slot was a second row here until
// it turned out to be the same range the collapsed face already shows —
// see the note at that band.
//
// 2. **The four journey figures**, centred, evenly across one row.
//
// VIEW ROUTE is not here any more either; it is an action, and it now sits on
// the START · HUB row where the route actually begins. See `_hubRow`.
Widget _body() {
final cash = _trip.cashToCollect;
final duration = roundTripDuration(_trip.totalDuration);
return Padding(
padding: EdgeInsets.fromLTRB(12.w, 0, 12.w, 14.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Divider(height: 1, color: ColorConstants.borderSubtle),
SizedBox(height: 10.h),
// ── Band 1: the shift window ──
//
// The trip slot used to sit here as a second row. It was printing the
// same range the face prints two lines above it — [_timeLeftLine]
// falls back to `slotLabel` whenever the hub has not set a shift — so
// opening the strip showed the rider the identical window twice, once
// under "2 of 6 stops done" and once under "Trip slot". The slot is
// also on the trip tab right above the strip, which is where it
// belongs: it is what distinguishes one trip tab from another.
//
// So the row is gone, and this band now exists only when there is a
// shift window to state — a window the face reports as time *left*
// rather than as a range.
if (_brief.shiftSet) ...[
_windowRow(
icon: Icons.event_available_rounded,
label: 'Shift',
value: _brief.shiftWindowLabel,
tint: ColorConstants.slateText,
),
SizedBox(height: 14.h),
Divider(height: 1, color: ColorConstants.borderSubtle),
SizedBox(height: 14.h),
],
// ── Band 2: the journey, four centred figures on one row ──
//
// `IntrinsicHeight` so the four columns share a height and their
// captions land on one baseline even when a value scales down.
IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _figure(
icon: Icons.schedule_rounded,
value: '≈${formatTripDuration(duration)}',
label: 'DURATION',
),
),
Expanded(
child: _figure(
icon: Icons.route_rounded,
value: RouteMetricsHelper.formatDistance(_trip.routeMeters),
label: 'DISTANCE',
),
),
Expanded(
child: _figure(
icon: Icons.inventory_2_rounded,
value: '${_trip.totalParcels}',
label: _trip.totalParcels == 1 ? 'PARCEL' : 'PARCELS',
),
),
Expanded(
child: cash > 0
? _figure(
icon: Icons.payments_rounded,
value: '₹${_money(cash)}',
label: 'PAYMENT',
money: true,
)
// Holds its column so a cash-free trip keeps the row on
// four even columns instead of redistributing the space
// and shifting the other three figures sideways.
: _figure(
icon: Icons.money_off_rounded,
value: 'None',
label: 'PAYMENT',
muted: true,
),
),
],
),
),
],
),
);
}
/// One time window: icon disc, label, and the range itself on the right.
Widget _windowRow({
required IconData icon,
required String label,
required String value,
required Color tint,
}) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 5.h),
child: Row(
children: [
Container(
width: 30.w,
height: 30.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color: tint.withValues(alpha: 0.09),
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
child: Icon(icon, size: 16.sp, color: tint),
),
SizedBox(width: 10.w),
Text(
label,
style: TextStyle(
fontSize: 12.5.sp,
fontWeight: FontWeight.w700,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
const Spacer(),
Flexible(
child: Text(
value,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
style: TextStyle(
fontSize: 13.5.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.2,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
),
],
),
);
}
/// One journey figure, centred on its column and standing on the page rather
/// than inside a box: icon, the number, a small-caps caption.
///
/// The icon is bare, with no tinted disc behind it. At four across, a disc per
/// figure put four filled circles in a row that read as the loudest thing in
/// the brief while carrying none of its information — and the caption under
/// each value already says what the icon says.
Widget _figure({
required IconData icon,
required String value,
required String label,
bool money = false,
bool muted = false,
}) {
// Only the money is coloured. The rest are facts about the journey, and
// tinting every category is the habit this screen has spent a long time
// unlearning.
final Color accent = money
? ColorConstants.moneyGreen
: muted
? ColorConstants.secondaryText
: ColorConstants.slateText;
return Padding(
padding: EdgeInsets.symmetric(horizontal: 3.w),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(
icon,
size: 17.sp,
color: money
? ColorConstants.moneyGreen
: ColorConstants.secondaryText,
),
SizedBox(height: 7.h),
// Fixed height with a scale-down inside it, so a long value shrinks
// rather than making one of the four columns taller than the rest.
SizedBox(
height: 21.h,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.center,
child: Text(
value,
style: TextStyle(
fontSize: 16.5.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.5,
height: 1.05,
color: accent,
fontFamily: FontConstants.fontFamily,
),
),
),
),
SizedBox(height: 4.h),
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
label,
maxLines: 1,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 9.sp,
fontWeight: FontWeight.w800,
letterSpacing: 0.5,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
),
],
),
);
}
/// `1234.0` → `1,234`, `1234.5` → `1,234.5`. Mirrors the trim the stat card
/// used so the figure does not change shape as it moves into the strip.
static String _money(double v) {
final whole = v == v.roundToDouble();
final s = whole ? v.toStringAsFixed(0) : v.toStringAsFixed(1);
final parts = s.split('.');
final digits = parts.first;
final buf = StringBuffer();
for (int i = 0; i < digits.length; i++) {
if (i > 0 && (digits.length - i) % 3 == 0) buf.write(',');
buf.write(digits[i]);
}
return parts.length > 1 ? '${buf.toString()}.${parts[1]}' : buf.toString();
}
}
/// The completion ring, at a third of the area it used to occupy.
///
/// It was 66dp and sat beside a 25sp headline; it is 34 here and sits beside a
/// 15sp line, so it holds the same relative weight in a strip that is a fifth
/// of the height. The percentage itself is not printed inside it — the pinned
/// trip tab below already prints "50% done", and the fraction next to the ring
/// says the same thing in stops, which is the unit the rider works in.
class _MiniRing extends StatelessWidget {
final int percent;
const _MiniRing({required this.percent});
@override
Widget build(BuildContext context) {
final complete = percent >= 100;
final color = complete
? ColorConstants.acceptGreen
: ColorConstants.primary;
return SizedBox(
width: 34.w,
height: 34.w,
child: Stack(
alignment: Alignment.center,
children: [
SizedBox.expand(
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: percent / 100),
duration: const Duration(milliseconds: 550),
curve: Curves.easeOutCubic,
builder: (_, value, __) => CircularProgressIndicator(
value: value,
strokeWidth: 3.5,
backgroundColor: ColorConstants.borderSubtle,
valueColor: AlwaysStoppedAnimation<Color>(color),
),
),
),
if (complete)
Icon(Icons.check_rounded, size: 17.sp, color: color)
else
Text(
'$percent',
style: TextStyle(
fontSize: 11.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.4,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
],
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,264 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/helpers/constants/design_constants.dart';
import 'package:miler/views/helpers/constants/miler_type.dart';
import 'package:miler/views/helpers/widgets/app_widgets.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// TRIP NOT ASSIGNED YET
///
/// The state a trip tab sits in before the hub fills it. Deliberately not the
/// generic "no bookings" screen: the rider's question on Trip 3 at 10am is
/// "will I get a third trip?", and the honest answer is "not yet" — not "you
/// have no work", which is false while Trip 1 is running.
///
/// **No card.** This used to be a bordered white box floating on the page,
/// which read as a piece of *content* the hub had sent — a card that had
/// arrived with nothing in it. An empty slot is the absence of content, so it
/// is drawn as the page itself: no border, no fill, no shadow, the same
/// boxless treatment every other empty screen in the app uses through
/// [MilerEmptyState].
///
/// **It breathes.** Waiting is the whole subject of the screen, so the halo
/// swells and settles on a slow four-second cycle and the chip's dot pulses
/// with it. A still screen saying "waiting" is a claim the interface does not
/// back up; a rider glancing down needs to see that the app is still watching
/// for his trip and has not simply stopped. Off duty is not waiting — nothing
/// is coming until he goes on — so there the motion stops and the colour
/// drains to grey.
/// ─────────────────────────────────────────────────────────────────────────
class TripEmptyState extends StatefulWidget {
/// `Trip 2` — the tab this slot belongs to, named the same way the tabs,
/// the hub and his supervisor name it.
final String tripLabel;
/// Off duty. A different sentence, a different colour, and the one action
/// that changes it.
final bool offline;
/// Opens the duty confirmation. Only used when [offline].
final VoidCallback? onGoOnDuty;
const TripEmptyState({
super.key,
required this.tripLabel,
required this.offline,
this.onGoOnDuty,
});
@override
State<TripEmptyState> createState() => _TripEmptyStateState();
}
class _TripEmptyStateState extends State<TripEmptyState>
with SingleTickerProviderStateMixin {
/// Slow enough to read as breathing rather than blinking.
///
/// 2s out and 2s back. The pulse on the live-pickup banner runs at 1.1s
/// because it marks a job happening *now*; this one marks a wait, and a wait
/// that flashes at you is an alarm.
///
/// Initialised lazily on the declaration rather than in `initState` so hot
/// reload cannot leave it unset — see the note in `Bottom_page.dart`.
late final AnimationController _breath = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 2000),
)..repeat(reverse: true);
@override
void dispose() {
_breath.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final offline = widget.offline;
// Grey off duty, brand red waiting. The halo and the chip move together on
// this one colour, so the screen carries a single decision rather than
// three.
final Color accent = offline
? ColorConstants.secondaryText
: ColorConstants.primary;
// The button is the exception, and deliberately: a grey "Go on duty" on a
// grey screen reads as a disabled control, which is the opposite of what it
// is — the one live thing on an off-duty screen. Green is what going on
// duty is coloured everywhere else in the app.
final Color cta = ColorConstants.acceptGreen;
return Padding(
// Generous top air. With the card gone there is no edge to separate this
// from the pinned tab bar, so the space does that job — and an empty
// screen is the one place in the app that can afford it.
//
// Vertical only: [MilerEmptyState] already insets its copy by 40 a side,
// and adding to that squeezed the chip below on a narrow phone.
padding: EdgeInsets.fromLTRB(0, 36.h, 0, 40.h),
child: MilerEmptyState(
// The component draws its own static halo from `icon`; this one is
// supplied as the illustration instead so it can breathe. The icon is
// still passed for the fallback path.
icon: offline ? Icons.wifi_off_rounded : Icons.alt_route_rounded,
illustration: _BreathingHalo(
breath: _breath,
accent: accent,
icon: offline ? Icons.wifi_off_rounded : Icons.alt_route_rounded,
// Off duty is a settled state, not a wait — so it holds still.
animate: !offline,
),
title: offline
? 'You are off duty'
: '${widget.tripLabel} not assigned yet',
message: offline
? 'Go on duty to start receiving trips from your hub.'
: 'Your hub assigns trips as customer slots fill up. This one '
'appears here the moment it is ready.',
accent: offline ? cta : accent,
actionLabel: offline ? 'Go on duty' : null,
onAction: offline ? widget.onGoOnDuty : null,
// The chip goes under the copy, where a CTA would be if there were one
// to offer. On duty there is nothing for the rider to do but wait, and
// the honest screen says so rather than inventing a button.
footer: offline
? null
: _WaitingChip(breath: _breath, accent: accent),
),
);
}
}
/// The icon on two soft rings that swell and settle together.
class _BreathingHalo extends StatelessWidget {
final Animation<double> breath;
final Color accent;
final IconData icon;
final bool animate;
const _BreathingHalo({
required this.breath,
required this.accent,
required this.icon,
required this.animate,
});
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: breath,
builder: (context, _) {
// 0 → 1 with the ends eased, so the halo pauses at full breath instead
// of bouncing off it.
final t = animate ? Curves.easeInOut.transform(breath.value) : 0.0;
return Center(
child: Stack(
alignment: Alignment.center,
children: [
// Outer ring: the widest travel and the faintest tint, so what
// the eye catches is movement rather than a pulsing disc.
Container(
width: 132.w + 16.w * t,
height: 132.w + 16.w * t,
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.05 * (1 - 0.35 * t)),
shape: BoxShape.circle,
),
),
Container(
width: 96.w + 8.w * t,
height: 96.w + 8.w * t,
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.09),
shape: BoxShape.circle,
),
),
// The disc holding the glyph stays put. Something has to be
// still or the whole group reads as drifting.
//
// Its shadow is a soft even lift rather than a drop: offset
// downward it cast a dark crescent across the ring underneath and
// the three circles read as one muddy shape.
Container(
width: 64.w,
height: 64.w,
decoration: BoxDecoration(
color: ColorConstants.pureSurface,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: accent.withValues(alpha: 0.06),
blurRadius: 14,
spreadRadius: 1,
),
],
),
child: Icon(icon, size: 28.sp, color: accent),
),
],
),
);
},
);
}
}
/// `● Waiting for your hub` — the status line, as a pill.
///
/// It is what turns the screen from a dead end into a state: the rider is not
/// being told he has nothing, he is being told the app is still listening.
class _WaitingChip extends StatelessWidget {
final Animation<double> breath;
final Color accent;
const _WaitingChip({required this.breath, required this.accent});
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 8.h),
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.07),
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedBuilder(
animation: breath,
builder: (context, _) {
final t = Curves.easeInOut.transform(breath.value);
return Container(
width: 7.w,
height: 7.w,
decoration: BoxDecoration(
// Never fully out: a dot that disappears reads as a fault,
// not a heartbeat.
color: accent.withValues(alpha: 0.45 + 0.55 * t),
shape: BoxShape.circle,
),
);
},
),
SizedBox(width: 8.w),
// Flexible, so a rider on the largest system font size gets a
// narrower chip rather than a clipped one.
Flexible(
child: Text(
'Waiting for your hub',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: MilerType.micro.copyWith(
color: accent,
fontWeight: FontWeight.w700,
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,491 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:miler/views/Dashboard/home/trip.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/helpers/constants/Font_constant.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// TRIP PROGRESS — how far through the route the rider is.
///
/// Shown at the top of Bookings, above the stop list:
///
/// 10:00 AM – 1:00 PM 4 of 18 done
/// ████████▓▓░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
/// On stop 5 of 18 · 2 skipped · 2h 10m left, then hub
///
/// **No box.** This was a white card — 1.5px border, 16px radius, a drop
/// shadow, 12px of padding on all four sides and a 12px top margin — sitting
/// directly above a list of white bordered stop cards. It cost roughly 40px of
/// height for chrome alone, on the first screen of the page, and the border it
/// spent that height on was the same border the cards below already use, so it
/// read as a stop card that could not be tapped.
///
/// A progress bar does not need a container to be legible: it is already a
/// shape with a hard edge. So the box, its shadow, its padding and the icon on
/// the title row are gone, and what is left sits straight on the page in the
/// same 16px gutter as the cards. Same three facts, about half the height, and
/// the first real stop is now visible without scrolling.
///
/// ── Numbered nodes, not a proportional bar ──
///
/// A five-stop route draws 1 2 3 4 5, and two stops done means two of those
/// numbers are filled green. The bar that was here before was honest about the
/// *fraction* — 40% of its width green — but a fraction is not the unit the
/// rider works in. He is on stop 3; the list below numbers its cards 1…5; the
/// map pins are numbered. The summary now speaks the same language as
/// everything it summarises, and tapping a number is a way back to that stop.
///
/// The scooter rides the rail: a `two_wheeler` glyph sits above the node the
/// rider is working, so where-am-I is one glance rather than a count — and when
/// a stop is completed it **drives forward to the next one** while the node it
/// left turns green. Progress on this page is something the rider watches
/// happen, not something he re-reads. Colour does the rest:
///
/// • **Green** for completed. Green is the app's only "done" signal.
/// • **Amber** for skipped: a skipped stop is neither done nor waiting, and it
/// has to stay visible as unfinished business.
/// • **Maroon** for the stop he is on, under the scooter.
/// • **Grey** for everything still ahead.
///
/// ── The reason numbers were dropped once before, and what changed ──
///
/// This widget used to draw a node per stop inside a fixed-width row, so route
/// length — set by the hub, bounded by nothing here — squeezed the nodes into
/// each other until the digits were unreadable and the hub markers collided. It
/// got worse exactly as the rider's day got harder, which is why it became a
/// bar.
///
/// The nodes are back because that failure was a layout bug, not a problem with
/// numbering: the row was made to fit an unbounded count into a fixed width.
/// Now the nodes have a **fixed size and the row scrolls** when they no longer
/// fit, and it auto-scrolls to keep the scooter in view. A 5-stop route lays
/// out evenly across the full width; a 40-stop route draws the same 34pt nodes
/// and lets the rider push the rest into view. The widget's height is constant
/// either way — see `trip_progress_test.dart`, which is what stops this from
/// regressing into a crush again.
/// ─────────────────────────────────────────────────────────────────────────
class TripProgressRail extends StatefulWidget {
/// Progress state per stop, in the admin's fixed order.
final List<StopProgress> progress;
/// Slot window label, e.g. `10:00 AM – 1:00 PM`.
final String? slotLabel;
/// Remaining time estimate for the unfinished part of the trip.
final Duration? remaining;
/// Tapping a node scrolls to / opens that stop.
final void Function(int index)? onStopTap;
const TripProgressRail({
super.key,
required this.progress,
this.slotLabel,
this.remaining,
this.onStopTap,
});
@override
State<TripProgressRail> createState() => _TripProgressRailState();
}
class _TripProgressRailState extends State<TripProgressRail> {
/// Only attached when the rail actually overflows; a short route lays out
/// with no scroll view at all.
final ScrollController _scroll = ScrollController();
/// Node footprint, and the tightest the gap between two nodes is allowed to
/// get. 34 rather than the 26 this started at: the numbers are the rail's
/// content, and at 26 a two-digit stop filled its circle wall to wall.
static const double _node = 34;
static const double _minGap = 22;
/// The scooter's box, and the air between it and the nodes it rides over.
static const double _scooter = 24;
static const double _lift = 3;
/// Constant, by construction — nodes never shrink and the scooter always
/// rides above them, so route length changes the rail's *width* only.
double get _railHeight => _scooter.w + _lift.h + _node.w;
/// How long the scooter takes to travel one stop. Long enough to read as
/// movement — the point is that the rider *sees* his progress advance when a
/// stop is completed, rather than finding the marker already somewhere else.
static const Duration _ride = Duration(milliseconds: 480);
List<StopProgress> get progress => widget.progress;
String? get slotLabel => widget.slotLabel;
Duration? get remaining => widget.remaining;
@override
void initState() {
super.initState();
_scheduleReveal();
}
@override
void didUpdateWidget(covariant TripProgressRail old) {
super.didUpdateWidget(old);
// The rider finishing a stop moves the scooter, and on a long route the
// node it moves to may be off the right edge.
if (old.progress.length != progress.length ||
_currentPosition != _positionIn(old.progress)) {
_scheduleReveal();
}
}
@override
void dispose() {
_scroll.dispose();
super.dispose();
}
/// Centres the scooter's node after layout, when there is anything to scroll.
void _scheduleReveal() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_scroll.hasClients) return;
final at = _currentPosition;
if (at == null) return;
final double viewport = _scroll.position.viewportDimension;
final double target =
((at - 1) * (_node.w + _minGap.w)) - (viewport / 2) + (_node.w / 2);
final double clamped = target.clamp(
0.0,
_scroll.position.maxScrollExtent,
);
if ((clamped - _scroll.offset).abs() < 1) return;
_scroll.animateTo(
clamped,
duration: const Duration(milliseconds: 320),
curve: Curves.easeOutCubic,
);
});
}
int get _doneCount => progress.where((p) => p == StopProgress.done).length;
int get _skippedCount =>
progress.where((p) => p == StopProgress.skipped).length;
/// 1-based position of the stop the rider is on, or the first one still
/// waiting. Null once nothing is left.
int? get _currentPosition => _positionIn(progress);
int? _positionIn(List<StopProgress> states) {
final active = states.indexOf(StopProgress.current);
if (active >= 0) return active + 1;
final next = states.indexOf(StopProgress.pending);
return next >= 0 ? next + 1 : null;
}
/// Spacing between two node centres.
///
/// A short route spreads its nodes evenly across the whole width rather than
/// huddling on the left; once even spacing would fall below [_minGap] the gap
/// locks at that floor and the rail starts scrolling instead. Nodes never
/// shrink — that was the failure that killed the first version of this rail.
double _gapFor(double available, int total) {
if (total < 2) return 0;
final double even = (available - (total * _node.w)) / (total - 1);
return even >= _minGap.w ? even : _minGap.w;
}
/// The rail: one numbered node per stop, joined by connectors, with the
/// scooter riding above the stop the rider is working.
///
/// ── Why the scooter is an overlay and not just the current node's glyph ──
///
/// It used to *be* the node — the current circle drew a scooter instead of
/// its digit. That cost the stop its number, and worse, finishing a stop
/// simply repainted two circles: the marker was already at the next stop
/// before the rider's eye got there, so nothing appeared to happen. As a
/// separate layer it can be *positioned*, which means it can be animated, so
/// completing a stop visibly drives the scooter forward one node ([_ride])
/// and every stop keeps its number underneath.
Widget _rail() {
final total = progress.length;
return LayoutBuilder(
builder: (context, constraints) {
final double available = constraints.maxWidth;
final double gap = _gapFor(available, total);
final double content = (total * _node.w) + ((total - 1) * gap);
// +0.5 so a route that lands exactly on the width is not tipped into a
// scroll view by float error.
final bool fits = content <= available + 0.5;
final children = <Widget>[];
for (var i = 0; i < total; i++) {
if (i > 0) {
// A connector is coloured by the stop *behind* it, so the green
// runs continuously from the start of the route up to the scooter
// rather than restarting at each node.
children.add(_connector(progress[i - 1], width: gap));
}
children.add(_dot(i));
}
final int? at = _currentPosition;
final Widget layers = SizedBox(
width: fits ? available : content,
height: _railHeight,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned(
left: 0,
bottom: 0,
child: Row(mainAxisSize: MainAxisSize.min, children: children),
),
if (at != null)
AnimatedPositioned(
duration: _ride,
curve: Curves.easeOutCubic,
top: 0,
// Node centre, less half the glyph, so the scooter sits over
// the node rather than beside it.
left:
((at - 1) * (_node.w + gap)) +
(_node.w / 2) -
(_scooter.w / 2),
child: _scooterGlyph(),
),
],
),
);
if (fits) return layers;
return SizedBox(
height: _railHeight,
child: SingleChildScrollView(
controller: _scroll,
scrollDirection: Axis.horizontal,
physics: const BouncingScrollPhysics(),
child: layers,
),
);
},
);
}
/// The rider, in the app's own maroon. No disc behind it: the rail sits bare
/// on the page and a filled badge here would outweigh the node it points at.
Widget _scooterGlyph() {
return SizedBox(
width: _scooter.w,
height: _scooter.w,
child: Icon(
Icons.two_wheeler_rounded,
size: 20.sp,
color: ColorConstants.primary,
),
);
}
/// One stop, as the number the rest of the page calls it by.
///
/// Every node keeps its digit, including the one the rider is on — the
/// scooter rides above it rather than replacing it. The fill animates on
/// [_ride] so a stop turning green is a change the rider can see happen,
/// in step with the scooter leaving it.
Widget _dot(int index) {
final state = progress[index];
final Color fill;
final Color ink;
switch (state) {
case StopProgress.done:
fill = ColorConstants.acceptGreen;
ink = Colors.white;
case StopProgress.skipped:
fill = ColorConstants.warning;
ink = Colors.white;
case StopProgress.current:
fill = ColorConstants.primary;
ink = Colors.white;
case StopProgress.pending:
// Filled grey rather than an outlined circle: the rail sits bare on the
// page above bordered stop cards, and a ring per stop would put a row
// of small competing edges directly above them.
fill = ColorConstants.neutralLight;
ink = ColorConstants.secondaryText;
}
final Widget circle = AnimatedContainer(
duration: _ride,
curve: Curves.easeOutCubic,
width: _node.w,
height: _node.w,
alignment: Alignment.center,
decoration: BoxDecoration(color: fill, shape: BoxShape.circle),
child: Text(
'${index + 1}',
style: TextStyle(
// Fixed against the box: the circle does not grow with the system
// font scale, so the digits inside it cannot either.
fontSize: 14.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.3,
height: 1,
color: ink,
fontFamily: FontConstants.fontFamily,
),
),
);
return Semantics(
button: widget.onStopTap != null,
label: 'Stop ${index + 1}, ${_stateWord(state)}',
child: GestureDetector(
onTap: widget.onStopTap == null ? null : () => widget.onStopTap!(index),
behavior: HitTestBehavior.opaque,
child: circle,
),
);
}
static String _stateWord(StopProgress state) => switch (state) {
StopProgress.done => 'done',
StopProgress.skipped => 'skipped',
StopProgress.current => 'you are here',
StopProgress.pending => 'not started',
};
/// The line between two nodes. Green behind a finished stop, amber behind a
/// skipped one, grey ahead — so the rail reads as a route travelled, not as a
/// set of unrelated badges.
Widget _connector(StopProgress behind, {required double width}) {
final Color color = switch (behind) {
StopProgress.done => ColorConstants.acceptGreen,
StopProgress.skipped => ColorConstants.warning,
_ => ColorConstants.borderStrong,
};
return AnimatedContainer(
duration: _ride,
curve: Curves.easeOutCubic,
width: width,
height: 4.h,
color: color,
);
}
@override
Widget build(BuildContext context) {
if (progress.isEmpty) return const SizedBox.shrink();
final total = progress.length;
final done = _doneCount;
final allDone = done == total;
return Padding(
// Same 16px gutter as the stop cards below, so the bar lines up with
// their edges instead of being inset by a border's worth again.
padding: EdgeInsets.fromLTRB(16.w, 12.h, 16.w, 10.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Headline: slot + progress count ──
//
// The count lost its pill. A bordered chip around two words, directly
// above a bordered bar, was two edges saying one thing; colour and
// weight carry "done" here exactly as they do everywhere else in the
// app, and green already means finished.
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Expanded(
child: Text(
slotLabel ?? 'Your route',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.2,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
),
SizedBox(width: 8.w),
Text(
allDone ? 'All stops done' : '$done of $total done',
style: TextStyle(
fontSize: 12.5.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.2,
color: allDone
? ColorConstants.acceptGreen
: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
],
),
SizedBox(height: 9.h),
// ── The rail ──
//
// One numbered node per stop, the finished ones green, the scooter on
// the one he is working. Fixed node size with a horizontal scroll
// behind it, so a 40-stop route is longer, never smaller.
_rail(),
SizedBox(height: 8.h),
// ── Foot: where he is, and what is left, on one line ──
//
// The rail used to spend this line on START·Hub / RETURN·Hub labels
// at either end. Both are constant on every route, every day — the
// trip always leaves the hub and always comes back — so they were
// paying for width with no information. What changes, and what he
// actually wants, is which stop he is on and how long is left.
//
// Both halves used to sit at opposite ends of a Row, which left a gap
// in the middle wide enough to read as two separate facts. They are
// one sentence about one trip, so they are now one string, and the
// whole line ellipsises as a unit if the route label is long.
Text(
allDone
? 'Head back to the hub'
: _footStatus(total: total, done: done),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11.5.sp,
fontWeight: FontWeight.w700,
color: allDone
? ColorConstants.acceptGreen
: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
],
),
);
}
String _footStatus({required int total, required int done}) {
final at = _currentPosition;
final skipped = _skippedCount;
final left = remaining;
final parts = <String>[
if (at != null) 'On stop $at of $total' else '${total - done} to go',
if (skipped > 0) '$skipped skipped',
if (left != null && left > Duration.zero)
'${formatTripDuration(roundTripDuration(left))} left, then hub',
];
return parts.join(' · ');
}
}
/// Hub accent, shared with the Home trip card.
final Color kRailHubColor = ColorConstants.tertiary;

View File

@@ -0,0 +1,707 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:miler/views/Dashboard/home/trip.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/helpers/constants/Font_constant.dart';
import 'package:miler/views/helpers/constants/design_constants.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// TRIP TABS — Trip 1 · Trip 2 · Trip 3
///
/// A rider is assigned at most three trips a day, so three tabs fit across a
/// phone and every trip is one tap away. Tabs beat a dropdown here for a
/// reason: with a dropdown the rider cannot see how many trips he has or how
/// far along each one is without opening it, and "how much is left today" is
/// the question he asks most.
///
/// Each tab carries its own completion percentage, so the whole day reads at a
/// glance: `Trip 1 · 100% Trip 2 · 40% Trip 3 · 0%`
///
/// **Overflow.** Three is the norm, not a guarantee — a hub can assign more.
/// Past three the bar keeps the first two tabs and folds the rest into a
/// "More" menu, rather than shrinking every tab until the labels are
/// unreadable. The dropdown is the exception path, exactly as asked.
/// ─────────────────────────────────────────────────────────────────────────
class TripTabs extends StatefulWidget {
final List<Trip> trips;
final int selectedIndex;
final ValueChanged<int> onSelect;
/// Optimistic accept/reject sets, for the per-tab completion figure.
final Set<String> acceptedIds;
final Set<String> rejectedIds;
/// Tabs shown inline before the rest collapse into the overflow menu.
///
/// Also the number of tabs ALWAYS rendered: a rider's day is three trips, so
/// Trip 2 and Trip 3 are shown even before the hub has assigned them. A tab
/// that appears and disappears through the morning makes the screen feel
/// unstable; an empty Trip 3 that says "nothing assigned yet" answers the
/// question he actually has.
static const int maxInlineTabs = 3;
/// The travelling selection marker at the foot of the pill.
static const Key markerKey = ValueKey('trip_tab_marker');
const TripTabs({
super.key,
required this.trips,
required this.selectedIndex,
required this.onSelect,
this.acceptedIds = const {},
this.rejectedIds = const {},
});
@override
State<TripTabs> createState() => _TripTabsState();
}
class _TripTabsState extends State<TripTabs>
with SingleTickerProviderStateMixin {
/// Carries the pill from the tab it was on to the tab just chosen.
///
/// Same mechanism as the bottom nav bar, and for the same reason. The pill
/// used to belong to each tab — an `AnimatedContainer` per segment whose fill
/// went from transparent to white — so moving from Trip 3 to Trip 1 faded one
/// pill out on the right and a *different* one in on the left. Nothing ever
/// occupied the space between, so there was no object to travel across Trip 2
/// with, and the marker appeared to teleport.
///
/// There is one pill now, owned by the control, and this is its position in
/// *slot* units — 1.5 is genuinely halfway between the second and third
/// segment. It also drives the tint on every label, so a tab warms up as the
/// pill passes over it instead of switching the instant it is tapped while the
/// pill is still two segments away.
///
/// Initialised on the declaration rather than in `initState`: a `late final`
/// assigned in `initState` is never filled in on a `State` that already
/// exists, and hot reload reuses the existing `State`. See the identical note
/// in `Bottom_page.dart`.
late final AnimationController _pillAnim = AnimationController(
vsync: this,
// Matches the nav bar. A pill crossing two segments needs long enough for
// the eye to follow it; the 200ms the cross-fade used reads as a jump.
duration: const Duration(milliseconds: 380),
value: 1.0,
);
late final Animation<double> _pillCurve = CurvedAnimation(
parent: _pillAnim,
curve: Curves.easeInOutCubic,
);
late Tween<double> _pillTween = Tween<double>(
begin: _slotOf(_inlineTabs()),
end: _slotOf(_inlineTabs()),
);
double get _pillPos => _pillTween.evaluate(_pillCurve);
/// Trip indices rendered as inline segments, left to right.
///
/// Three is the norm, not a guarantee — a hub can assign more. Past three the
/// bar keeps the first two segments and folds the rest into a "More" menu
/// rather than shrinking every tab until the labels are unreadable. When the
/// selection lives in the overflow it is pulled into the last inline slot, so
/// the rider can always see which trip he is looking at.
List<int> _inlineTabs() {
final tabCount = widget.trips.length > TripTabs.maxInlineTabs
? widget.trips.length
: TripTabs.maxInlineTabs;
final overflows = tabCount > TripTabs.maxInlineTabs;
final inlineCount = overflows ? TripTabs.maxInlineTabs - 1 : tabCount;
return <int>[
for (var i = 0; i < inlineCount; i++) i,
if (overflows && widget.selectedIndex >= inlineCount)
widget.selectedIndex,
];
}
List<int> _hiddenTabs(List<int> inline) {
final tabCount = widget.trips.length > TripTabs.maxInlineTabs
? widget.trips.length
: TripTabs.maxInlineTabs;
final overflows = tabCount > TripTabs.maxInlineTabs;
final inlineCount = overflows ? TripTabs.maxInlineTabs - 1 : tabCount;
return <int>[
for (var i = inlineCount; i < tabCount; i++)
if (!inline.contains(i)) i,
];
}
/// Which *slot* the selection occupies. Not the same as the trip index once
/// an overflow menu is in play.
double _slotOf(List<int> inline) {
final i = inline.indexOf(widget.selectedIndex);
return (i < 0 ? 0 : i).toDouble();
}
@override
void didUpdateWidget(TripTabs old) {
super.didUpdateWidget(old);
// Selection is owned by the page, not by this control, so the slide starts
// from here rather than from a tap handler.
final target = _slotOf(_inlineTabs());
if ((target - _pillTween.end!).abs() < 0.001) return;
// Begin from where the pill actually *is*. Starting from the previous
// selection instead would snap it back and re-run the whole journey when the
// rider changes his mind mid-slide.
_pillTween = Tween<double>(begin: _pillPos, end: target);
_pillAnim.forward(from: 0);
}
@override
void dispose() {
_pillAnim.dispose();
super.dispose();
}
/// The colour a segment settles on once the pill is over it.
Color _litColourOf(int tripIndex) {
if (tripIndex >= widget.trips.length) return ColorConstants.secondaryText;
return _percentOf(tripIndex) >= 100
? ColorConstants.acceptGreen
: ColorConstants.primary;
}
/// The colour a segment sits at with the pill elsewhere.
///
/// A finished trip stays green whether or not you are looking at it — "Trip 1,
/// Done" is worth knowing from Trip 2 — so for those the two colours are the
/// same and nothing changes as the pill goes by.
Color _restColourOf(int tripIndex) {
if (tripIndex >= widget.trips.length) return ColorConstants.secondaryText;
return _percentOf(tripIndex) >= 100
? ColorConstants.acceptGreen
: ColorConstants.secondaryText;
}
int _percentOf(int tripIndex) => widget.trips[tripIndex].completionPercent(
acceptedIds: widget.acceptedIds,
rejectedIds: widget.rejectedIds,
);
/// True when this trip has a stop in progress — the rider is on it now.
///
/// Derived rather than stored: a trip is running when any of its stops is, and
/// [stopStateOf] already resolves that from the server status plus the local
/// accept/reject sets.
bool _isRunning(Trip trip) => trip.stops.any(
(s) =>
stopStateOf(
s,
acceptedIds: widget.acceptedIds,
rejectedIds: widget.rejectedIds,
) ==
StopState.active,
);
@override
Widget build(BuildContext context) {
final inline = _inlineTabs();
final hidden = _hiddenTabs(inline);
// ── A segmented control on a track ──
//
// Three bordered chips became a tab bar became this: one light track holding
// three segments, with the tab you are looking at marked by a pill that
// slides between them. It is the iOS/Material segmented pattern, and it is
// the right one here because the choice is *exclusive and complete* — a
// rider is always looking at exactly one of his three trips, and a track
// makes that set visible as a set.
return Container(
// 16 — the app's base gutter, and the narrowest inset on this screen.
//
// The cards above sit on 28 and the route below on 32, so the track is
// deliberately the widest element in the column. Two reasons it reads
// right rather than misaligned:
//
// • Every card here carries a drop shadow with an 18–20pt blur, which
// bleeds past the card's box on each side. A card's *visible* edge is
// softly wider than its layout edge, while the track is a flat fill
// with a hard edge and no bleed — so matching the numbers exactly made
// the track look inset further than everything around it. It was
// measured at an identical 28 and still read as narrower.
//
// • A segmented control is a *container* for the three trips, not another
// card in the stack. Running it wider than the content it governs is
// the usual way that reads, and it gives each of the three segments
// more room for its label and status line.
margin: EdgeInsets.fromLTRB(16.w, 2.h, 16.w, 4.h),
padding: EdgeInsets.all(3.w),
decoration: BoxDecoration(
color: ColorConstants.neutralLight,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
),
// LayoutBuilder OUTSIDE IntrinsicHeight, not inside it. LayoutBuilder
// refuses to answer intrinsic-dimension queries, so nesting it under
// IntrinsicHeight throws rather than laying out.
child: LayoutBuilder(
builder: (context, box) {
// The overflow button is a fixed 48 plus a 6 gap, so the segments
// divide what is left. Without this the pill would sit off its tab
// whenever a hub assigned an unusual day.
final reserved = hidden.isEmpty ? 0.0 : 48.w + 6.w;
final slot = (box.maxWidth - reserved) / inline.length;
return AnimatedBuilder(
animation: _pillAnim,
builder: (context, _) {
final pos = _pillPos;
// The trailing underline travels *with* the pill as one object.
// Left in the segments it would have had to blink out under the
// old tab and in under the new one, which is the behaviour being
// removed. Its colour lerps between the two segments it is
// between, so crossing a finished trip shades red → green → red
// rather than snapping at the midpoint.
final lo = pos.floor().clamp(0, inline.length - 1);
final hi = pos.ceil().clamp(0, inline.length - 1);
final markColour = Color.lerp(
_litColourOf(inline[lo]),
_litColourOf(inline[hi]),
pos - pos.floor(),
)!;
// No fixed height: the tab content is text, and a user with large
// font settings overflowed a hard 54.h by a few pixels.
// IntrinsicHeight sizes the row to its tallest tab instead, so
// every tab matches and nothing can overflow.
return IntrinsicHeight(
child: Stack(
children: [
PositionedDirectional(
start: slot * pos,
top: 0,
bottom: 0,
width: slot,
child: Container(
margin: EdgeInsets.symmetric(horizontal: 2.w),
decoration: BoxDecoration(
// The same light glass red the nav bar uses for its
// selection. A white pill was doing the job with a
// drop shadow — fine on its own, but it made this the
// one selected-state in the app that was not the brand
// tint, so the two controls a rider uses most looked
// like they came from different apps.
color: ColorConstants.primary.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
child: Align(
alignment: Alignment.bottomCenter,
child: Container(
// Keyed so tests can follow it across the strip.
// Identifying it by colour instead breaks the moment
// it travels over an unassigned trip, where it is
// deliberately grey rather than the brand red.
key: TripTabs.markerKey,
height: 3.h,
margin: EdgeInsets.only(
left: 12.w,
right: 12.w,
bottom: 4.h,
),
decoration: BoxDecoration(
color: markColour,
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
),
),
),
),
),
Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (
var slotIndex = 0;
slotIndex < inline.length;
slotIndex++
) ...[
Builder(
builder: (context) {
final i = inline[slotIndex];
// How lit this segment is: 1 with the pill on it,
// 0 once the pill is a whole slot away. Cubed, so
// the glow stays tight around the pill — a linear
// ramp leaves every segment it crosses looking
// half-selected, and the control then reads as
// having no clear selection mid-slide.
final d = (pos - slotIndex).abs();
final t = (1.0 - d).clamp(0.0, 1.0);
final lit = t * t * t;
return Expanded(
child: _Tab(
label: Trip.tabLabel(i),
percent: i < widget.trips.length
? _percentOf(i)
: 0,
assigned: i < widget.trips.length,
selected: i == widget.selectedIndex,
active:
i < widget.trips.length &&
_isRunning(widget.trips[i]),
lit: lit,
restColour: _restColourOf(i),
litColour: _litColourOf(i),
onTap: () => widget.onSelect(i),
),
);
},
),
if (hidden.isNotEmpty &&
slotIndex == inline.length - 1)
SizedBox(width: 6.w),
],
if (hidden.isNotEmpty)
_OverflowMenu(
trips: widget.trips,
hidden: hidden,
onSelect: widget.onSelect,
acceptedIds: widget.acceptedIds,
rejectedIds: widget.rejectedIds,
),
],
),
],
),
);
},
);
},
),
);
}
}
class TripTabsHeader extends SliverPersistentHeaderDelegate {
final List<Trip> trips;
final int selectedIndex;
final ValueChanged<int> onSelect;
final Set<String> acceptedIds;
final Set<String> rejectedIds;
/// Reserved height. Pass [extentFor] unless you have measured otherwise.
final double extent;
const TripTabsHeader({
required this.trips,
required this.selectedIndex,
required this.onSelect,
required this.extent,
this.acceptedIds = const {},
this.rejectedIds = const {},
});
/// Height to reserve for the bar at this context's text scale.
///
/// Sums what a tab is actually made of — outer margin, border, padding, one
/// line of label, the gap, the progress underline — then adds a deliberate
/// safety margin. Over-reserving is the safe direction; see the class note.
static double extentFor(BuildContext context) {
final scaler = MediaQuery.textScalerOf(context);
final labelLine = scaler.scale(12.5.sp) * 1.4;
final statusLine = scaler.scale(10.5.sp) * 1.4;
return 6
.h // Container margin, 2 top + 4 bottom
+
6
.w // track padding, 3 top + 3 bottom
+
5
.h // lead-in above the label
+
labelLine +
1
.h // gap between label and status
+
statusLine +
4
.h // gap above the running underline
+
3
.h // the underline
+
4
.h // tail below it
+
6; // safety, so a rounding error adds air rather than a clip
}
@override
double get minExtent => extent;
@override
double get maxExtent => extent;
@override
Widget build(
BuildContext context,
double shrinkOffset,
bool overlapsContent,
) {
return _PinnedSurface(
// True exactly when stops have scrolled underneath the bar. That is the
// moment it stops being part of the page and starts floating over it, so
// it is the moment it needs an edge — without one the tabs and the top of
// a stop card blur into each other.
floating: overlapsContent || shrinkOffset > 0,
child: TripTabs(
trips: trips,
selectedIndex: selectedIndex,
onSelect: onSelect,
acceptedIds: acceptedIds,
rejectedIds: rejectedIds,
),
);
}
@override
bool shouldRebuild(TripTabsHeader old) =>
old.extent != extent ||
old.selectedIndex != selectedIndex ||
old.trips != trips ||
old.acceptedIds != acceptedIds ||
old.rejectedIds != rejectedIds;
}
/// Opaque backing for the pinned bar, with an edge that appears only once
/// content is passing underneath.
class _PinnedSurface extends StatelessWidget {
final bool floating;
final Widget child;
const _PinnedSurface({required this.floating, required this.child});
@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: const Duration(milliseconds: 160),
decoration: BoxDecoration(
// Opaque, or the stop cards show through the bar as they pass under it.
color: ColorConstants.surface,
border: Border(
bottom: BorderSide(
color: floating ? ColorConstants.borderStrong : Colors.transparent,
width: 1,
),
),
),
// Centred rather than stretched: the reserved extent over-estimates by
// design, so the spare pixels become air above and below instead of
// stretching the tabs into a shape they were not designed for.
child: Center(child: child),
);
}
}
class _Tab extends StatelessWidget {
final String label;
final int percent;
/// False when the hub has not assigned this trip yet.
final bool assigned;
/// The tab the rider is looking at. Used for semantics only — everything
/// painted follows [lit] instead, so the label tracks the pill rather than
/// flipping the instant it is tapped.
final bool selected;
/// The trip actually under way.
final bool active;
/// How close the travelling pill is, 0 → 1.
final double lit;
/// Colour with the pill elsewhere, and colour with the pill on this tab.
final Color restColour;
final Color litColour;
final VoidCallback onTap;
const _Tab({
required this.label,
required this.percent,
required this.selected,
required this.lit,
required this.restColour,
required this.litColour,
required this.onTap,
this.assigned = true,
this.active = false,
});
@override
Widget build(BuildContext context) {
final complete = assigned && percent >= 100;
// ── One thing is marked, and it is the tab you are on ──
//
// Colour follows the pill. It briefly followed a separate "this trip is
// running" signal, and the result on a real screen was that Trip 1 sat
// permanently red and underlined while the rider stood on Trip 2 — two tabs
// claiming to be the important one, and the tap he had just made appearing
// to do nothing. A navigation control has to answer "where am I" before it
// answers anything else.
//
// Running is not lost, it is demoted: the tab still says "Active" in its
// status line, in whatever colour that tab's state calls for. That is the
// right weight for it — useful to know, not something to fight the selection
// for the eye.
final Color accent = Color.lerp(restColour, litColour, lit)!;
final String status = !assigned
? 'Not set'
: complete
? 'Done'
: active
? 'Active'
: percent > 0
? '$percent% done'
: '$percent%';
return Semantics(
button: true,
selected: selected,
label: assigned
? '$label, $status${active ? ', in progress' : ''}'
: '$label, not assigned yet',
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(height: 5.h),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12.5.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.3,
color: accent,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 1.h),
// Progress as words, not a bar. The underline is reserved for "this
// is the one you are on", and one mark cannot mean two things — but
// "how far through is each trip" is why tabs beat a dropdown here,
// so the figure has to survive somewhere legible.
Text(
status,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 10.5.sp,
fontWeight: FontWeight.w700,
color: accent,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 4.h),
// Where the underline used to be drawn. It now travels with the
// pill, but the space it occupied is still reserved here so every
// segment keeps the height `TripTabsHeader.extentFor` reserves for
// it and nothing shifts.
SizedBox(height: 3.h),
SizedBox(height: 4.h),
],
),
),
);
}
}
/// "More" button holding trips 4+ when a hub assigns an unusual day.
class _OverflowMenu extends StatelessWidget {
final List<Trip> trips;
final List<int> hidden;
final ValueChanged<int> onSelect;
final Set<String> acceptedIds;
final Set<String> rejectedIds;
const _OverflowMenu({
required this.trips,
required this.hidden,
required this.onSelect,
required this.acceptedIds,
required this.rejectedIds,
});
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
label: '${hidden.length} more trips',
child: Material(
color: Colors.white,
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
child: PopupMenuButton<int>(
onSelected: onSelect,
tooltip: 'More trips',
position: PopupMenuPosition.under,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
),
itemBuilder: (_) => [
for (final i in hidden)
PopupMenuItem<int>(
value: i,
child: Row(
children: [
Text(
Trip.tabLabel(i),
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w800,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(width: 8.w),
Text(
i < trips.length ? trips[i].slotLabel : 'Not assigned',
style: TextStyle(
fontSize: 11.5.sp,
fontWeight: FontWeight.w600,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
],
child: Container(
width: 48.w,
alignment: Alignment.center,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.expand_more_rounded,
size: 22.sp,
color: ColorConstants.slateText,
),
Text(
'+${hidden.length}',
style: TextStyle(
fontSize: 11.sp,
fontWeight: FontWeight.w800,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,741 @@
import 'package:flutter/material.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'dart:async';
import 'package:miler/views/helpers/constants/Font_constant.dart';
import 'package:slide_to_submit_button/slide_to_submit_button.dart';
import 'package:image_picker/image_picker.dart';
import 'package:miler/views/helpers/constants/design_constants.dart';
import 'package:miler/views/helpers/widgets/app_widgets.dart';
import 'package:miler/views/helpers/widgets/page_transitions.dart';
/// ------------------------------
/// MAIN WIDGET WITH TWO BUTTONS
/// ------------------------------
class OrderStatusRow extends StatefulWidget {
final String currentStatus;
final Future<bool> Function(
String newStatus, {
String? notes,
String? proofImagePath,
})
onStatusChange;
final bool enabled;
/// Secondary per-booking actions rendered as compact icon buttons at the
/// start of the action bar, so the card shows a single row of controls
/// (Call · Details · Reject · Slide-to-Accept) instead of two stacked rows.
/// Null hides that button. They stay tappable even while the primary
/// slide/accept is disabled (e.g. multi-select mode) — a rider still needs to
/// call the customer or read the parcel details.
final VoidCallback? onCall;
final VoidCallback? onDetails;
const OrderStatusRow({
super.key,
required this.currentStatus,
required this.onStatusChange,
this.enabled = true,
this.onCall,
this.onDetails,
});
@override
State<OrderStatusRow> createState() => _OrderStatusRowState();
}
class _OrderStatusRowState extends State<OrderStatusRow> {
bool _isProcessing = false;
String? _overrideStatus;
@override
void didUpdateWidget(covariant OrderStatusRow oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.currentStatus != widget.currentStatus &&
mounted &&
_overrideStatus != null &&
widget.currentStatus == _overrideStatus) {
setState(() {
_overrideStatus = null;
});
} else if (oldWidget.currentStatus != widget.currentStatus &&
_overrideStatus != null) {
_overrideStatus = null;
}
}
String get _effectiveStatus => _overrideStatus ?? widget.currentStatus;
Future<bool> _onStatusChanged(
String newStatus, {
String? notes,
String? proofImagePath,
}) async {
debugPrint(
'[OSR] _onStatusChanged: $newStatus, proofImagePath: $proofImagePath',
);
if (_isProcessing) return false;
// Optimistic UI: flip status immediately to avoid visible lag.
setState(() {
_isProcessing = true;
_overrideStatus = newStatus;
});
bool success = false;
bool timedOut = false;
try {
// Hard cap wait time to keep UI from spinning indefinitely.
success = await widget
.onStatusChange(
newStatus,
notes: notes,
proofImagePath: proofImagePath,
)
.timeout(
const Duration(seconds: 3),
onTimeout: () {
timedOut = true;
// Assume success on timeout to avoid UI rollback; data will
// refresh from server on next fetch.
return true;
},
);
} catch (_) {
success = false;
}
if (!mounted) return success;
setState(() {
_isProcessing = false;
// If API failed, revert the optimistic status.
// If timed out, keep the optimistic status (server likely completed).
_overrideStatus = (success || timedOut) ? newStatus : null;
});
return success;
}
// ------------------------------
// REJECT SHEET
// ------------------------------
void _showRejectSheet(BuildContext context) {
showModalBottomSheet(
context: context,
sheetAnimationStyle: kMilerSheetStyle,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) {
String selectedReason = "";
bool isLoading = false;
final List<String> reasons = [
"Customer not reachable",
"Wrong address",
"Out of service area",
"Other reason",
];
return SafeArea(
top: false,
left: false,
right: false,
bottom: true,
child: StatefulBuilder(
builder: (context, setModalState) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
"Reject booking",
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
fontFamily: FontConstants.fontFamily,
),
),
IconButton(
tooltip: 'Close',
icon: Icon(
Icons.close_rounded,
color: ColorConstants.onSurfaceVariant,
),
onPressed: () => Navigator.pop(context),
),
],
),
const SizedBox(height: 10),
for (String reason in reasons)
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: GestureDetector(
onTap: () => setModalState(() {
selectedReason = reason;
}),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
height: ButtonSizes.primary,
width: double.infinity,
decoration: BoxDecoration(
color: selectedReason == reason
? ColorConstants.primary
: ColorConstants.neutralLight,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
border: Border.all(
color: selectedReason == reason
? ColorConstants.primary
: ColorConstants.dividerColor,
),
),
alignment: Alignment.center,
child: Text(
reason,
style: TextStyle(
color: selectedReason == reason
? Colors.white
: ColorConstants.onSurface,
fontWeight: FontWeight.w600,
fontFamily: FontConstants.fontFamily,
fontSize: 15,
),
),
),
),
),
const SizedBox(height: 20),
MilerButton(
label: "Reject booking",
color: ColorConstants.error,
loading: isLoading,
onPressed: selectedReason.isEmpty
? null
: () async {
setModalState(() => isLoading = true);
final success = await _onStatusChanged(
"REJECTED",
notes: selectedReason,
);
if (context.mounted) {
Navigator.pop(context, success);
}
},
),
],
),
);
},
),
);
},
);
}
// ------------------------------
// CANCEL SHEET
// ------------------------------
void _showCancelSheet(BuildContext context) {
showModalBottomSheet(
context: context,
sheetAnimationStyle: kMilerSheetStyle,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) {
bool confirmCancel = false;
bool isLoading = false;
return SafeArea(
top: false,
left: false,
right: false,
bottom: true,
child: StatefulBuilder(
builder: (context, setModalState) {
return Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
"Cancel this Booking?",
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
fontFamily: FontConstants.fontFamily,
),
),
const SizedBox(height: 10),
const Text(
"Once cancelled, this Booking will return to pending state.",
textAlign: TextAlign.center,
),
const SizedBox(height: 25),
CheckboxListTile(
value: confirmCancel,
onChanged: (value) =>
setModalState(() => confirmCancel = value ?? false),
title: const Text("I confirm to cancel this Booking"),
controlAffinity: ListTileControlAffinity.leading,
),
const SizedBox(height: 20),
MilerButton(
label: "Confirm cancel",
color: ColorConstants.warning,
loading: isLoading,
onPressed: !confirmCancel
? null
: () async {
setModalState(() => isLoading = true);
// Capture the messenger BEFORE popping — using the
// sheet's context after pop looks up a deactivated
// widget and can crash.
final messenger = ScaffoldMessenger.of(context);
// Actually cancel via the real status-change path
// instead of just showing a fake success snackbar.
final success = await _onStatusChanged(
"CANCELLED",
notes: "Cancelled by rider",
);
if (context.mounted) {
Navigator.pop(context, success);
}
messenger.showSnackBar(
SnackBar(
content: Text(
success
? "Booking cancelled"
: "Couldn't cancel. Please try again.",
),
),
);
},
),
],
),
);
},
),
);
},
);
}
@override
Widget build(BuildContext context) {
final String currentStatus = _effectiveStatus;
final bool showCancel =
currentStatus == "ARRIVED" || currentStatus == "PICKED";
return Padding(
padding: const EdgeInsets.fromLTRB(16, 2, 16, 12),
child: Stack(
children: [
// Single action bar: [Call] [Details] [Reject/Cancel] [Slide → …].
// The three leading actions are compact icon buttons; the slider keeps
// the remaining width because it's the primary action and needs room
// to be draggable.
Row(
children: [
if (widget.onCall != null) ...[
_IconAction(
icon: Icons.phone_rounded,
color: ColorConstants.acceptGreen,
tooltip: 'Call customer',
onTap: widget.onCall,
),
const SizedBox(width: 8),
],
if (widget.onDetails != null) ...[
_IconAction(
icon: Icons.receipt_long_rounded,
color: ColorConstants.onSurfaceVariant,
tooltip: 'Booking details',
onTap: widget.onDetails,
),
const SizedBox(width: 8),
],
// LEFT PRIMARY (Reject / Cancel) — compact tonal destructive
_IconAction(
icon: showCancel ? Icons.undo_rounded : Icons.close_rounded,
color: showCancel
? ColorConstants.warning
: ColorConstants.error,
tooltip: showCancel ? 'Cancel pickup' : 'Reject booking',
onTap: () {
if (showCancel) {
_showCancelSheet(context);
} else {
_showRejectSheet(context);
}
},
),
const SizedBox(width: 10),
// RIGHT BUTTON (ACCEPT / ARRIVED / PICKED) — takes remaining width
OrderStatusButton(
key: ValueKey(currentStatus),
currentStatus: currentStatus,
onStatusChange: (status, {proofImagePath}) =>
_onStatusChanged(status, proofImagePath: proofImagePath),
enabled: widget.enabled && !_isProcessing,
),
],
),
if (_isProcessing)
Positioned.fill(
child: IgnorePointer(
child: Container(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
),
child: Center(
child: Container(
height: 46,
width: 46,
decoration: BoxDecoration(
color: ColorConstants.pureSurface,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
padding: const EdgeInsets.all(10),
child: CircularProgressIndicator(
strokeWidth: 3,
valueColor: AlwaysStoppedAnimation<Color>(
ColorConstants.primary,
),
),
),
),
),
),
),
],
),
);
}
}
/// Reusable rounded pill action button used by the booking status row.
/// [filled] gives a solid brand-colored button (Accept side); otherwise it
/// renders as a soft tonal button with an outline (Reject / Cancel side).
class StatusPill extends StatelessWidget {
final String label;
final IconData icon;
final Color color;
final bool filled;
final bool enabled;
final VoidCallback? onTap;
const StatusPill({
super.key,
required this.label,
required this.icon,
required this.color,
this.filled = true,
this.enabled = true,
this.onTap,
});
@override
Widget build(BuildContext context) {
final Color fg = !enabled
? ColorConstants.disabledFill
: (filled ? Colors.white : color);
final Color bg = !enabled
? ColorConstants.neutralLight
: (filled ? color : color.withValues(alpha: 0.10));
return Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
child: InkWell(
onTap: enabled ? onTap : null,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
child: Ink(
height: ButtonSizes.secondary,
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
border: filled
? null
: Border.all(
color: color.withValues(alpha: enabled ? 0.22 : 0.0),
),
boxShadow: (filled && enabled)
? [
BoxShadow(
color: color.withValues(alpha: 0.28),
blurRadius: 12,
offset: const Offset(0, 5),
),
]
: null,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: fg, size: 18),
const SizedBox(width: 8),
Text(
label,
style: TextStyle(
fontSize: 14.5,
color: fg,
fontWeight: FontWeight.w700,
letterSpacing: 0.3,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
),
);
}
}
/// Compact square icon button (50×50) used for the secondary actions in the
/// booking action bar — Call, Details, Reject/Cancel. Tonal fill + the action's
/// colour, so the destructive Reject reads red without a full-width label
/// eating horizontal space the slider needs.
class _IconAction extends StatelessWidget {
final IconData icon;
final Color color;
final VoidCallback? onTap;
final String? tooltip;
const _IconAction({
required this.icon,
required this.color,
this.onTap,
this.tooltip,
});
@override
Widget build(BuildContext context) {
final Widget button = Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
child: Ink(
height: ButtonSizes.secondary,
width: ButtonSizes.secondary,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
border: Border.all(color: color.withValues(alpha: 0.20)),
),
child: Icon(icon, color: color, size: 20),
),
),
);
if (tooltip == null) return button;
return Tooltip(message: tooltip!, child: button);
}
}
/// ------------------------------
/// STATUS BUTTON LOGIC (NO PICKUP)
/// ------------------------------
class OrderStatusButton extends StatefulWidget {
final String currentStatus;
final Future<bool> Function(String newStatus, {String? proofImagePath})
onStatusChange;
final bool enabled;
const OrderStatusButton({
super.key,
required this.currentStatus,
required this.onStatusChange,
this.enabled = true,
});
@override
State<OrderStatusButton> createState() => _OrderStatusButtonState();
}
class _OrderStatusButtonState extends State<OrderStatusButton> {
String get _buttonText => widget.currentStatus;
Color _getButtonColor() {
switch (_buttonText) {
case "ARRIVED":
return ColorConstants.warning;
case "PICKED":
return ColorConstants.primary;
default:
return ColorConstants.acceptGreen;
}
}
IconData _iconForStatus() {
switch (_buttonText) {
case "ARRIVED":
return Icons.inventory_2_rounded;
case "PICKED":
return Icons.task_alt_rounded;
case "ACCEPTED":
return Icons.directions_bike_rounded;
default:
return Icons.check_rounded;
}
}
// Single-step next status. Null = terminal (PICKED) or unknown — nothing to
// advance to.
String? get _nextStatus {
switch (_buttonText) {
case "ACCEPT":
return "ACCEPTED";
case "ACCEPTED":
return "ARRIVED";
case "ARRIVED":
return "PICKED";
default:
return null;
}
}
// Friendly action verb shown on the slider, e.g. "Slide → Accept" instead of
// the raw backend status token ("ACCEPTED").
String get _actionLabel {
switch (_nextStatus) {
case "ACCEPTED":
return "Accept";
case "ARRIVED":
return "Arrived";
case "PICKED":
return "Pick Up";
default:
return _nextStatus ?? "";
}
}
bool _advancing = false;
/// Advances one step. PICKED requires a proof photo. Runs the same logic the
/// old bottom-sheet slider did — just triggered inline, in one gesture.
Future<void> _performAdvance(dynamic sliderController) async {
final next = _nextStatus;
if (next == null || _advancing) return;
_advancing = true;
try {
if (next == "PICKED") {
final XFile? photo = await ImagePicker().pickImage(
source: ImageSource.camera,
imageQuality: 50,
);
if (photo != null) {
await widget.onStatusChange(next, proofImagePath: photo.path);
}
} else {
await widget.onStatusChange(next);
}
} finally {
_advancing = false;
try {
sliderController?.reset();
} catch (_) {}
}
}
@override
Widget build(BuildContext context) {
final next = _nextStatus;
final color = _getButtonColor();
// Terminal / unknown status → a non-interactive pill (nothing to swipe).
if (next == null) {
return Expanded(
child: StatusPill(
label: _buttonText,
icon: _iconForStatus(),
color: color,
filled: true,
enabled: false,
),
);
}
// Inline slide-to-advance — one gesture on the card, no bottom sheet.
return Expanded(
child: IgnorePointer(
ignoring: !widget.enabled,
child: Opacity(
opacity: widget.enabled ? 1 : 0.5,
child: SizedBox(
height: ButtonSizes.secondary,
child: SlideToSubmit.custom(
height: ButtonSizes.secondary,
sliderWidth: 38,
padding: const EdgeInsets.all(5),
backgroundDecoration: BoxDecoration(
color: color.withValues(alpha: 0.16),
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
),
foregroundDecoration: BoxDecoration(
color: Colors.transparent,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
),
slider: Center(
child: Container(
height: 40,
width: 40,
alignment: Alignment.center,
decoration: BoxDecoration(
color: ColorConstants.pureSurface,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.12),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: Icon(
Icons.arrow_forward_rounded,
size: 18,
color: color,
),
),
),
hint: Align(
alignment: Alignment.center,
child: Text(
'Slide → $_actionLabel',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
fontFamily: FontConstants.fontFamily,
letterSpacing: 0.2,
color: color,
),
),
),
onSubmit: (controller) async {
await _performAdvance(controller);
},
),
),
),
),
);
}
}

View File

@@ -0,0 +1,802 @@
part of 'pickups.dart';
// ─────────────────────────────────────────────────────────────────────────────
// UNIFIED STOP CARD
//
// One card renders all three operational modes — PICKUP, DELIVERY, and
// PICKUP & DELIVERY (combined) — because a rider scanning a route list must
// not have to learn three different layouts. The mode changes the accent, the
// badge, and the action breakdown; the *shape* never moves.
//
// Reading order, top to bottom, matches the order the rider asks the questions:
// 1. WHAT kind of stop, which number, and how far? → meta line
// 2. WHO and WHERE? → name + address
// 3. WHAT exactly do I do here? → action line
// 4. GO. → primary CTA
//
// Each of those is a ROW OF TEXT, not a panel. See the note inside build().
//
// Thumb zone: the CTA and the call button live in the bottom band of the card.
// Everything above is read-only — nothing the rider needs to *press* sits in
// the top 60%, which is where his hand covers the screen while reading.
// ─────────────────────────────────────────────────────────────────────────────
class PickupCard extends StatelessWidget {
final Map<String, dynamic> item;
final int displayStep;
/// Live straight-line distance from the rider to this stop, in metres.
/// Null when no usable fix exists — the strip then says so instead of
/// inventing a zero.
final double? distanceMeters;
/// The rider's current ground speed in m/s, if known. Drives the ETA;
/// [RouteMetricsHelper] falls back to an urban average when it's unusable.
final double? riderSpeedMps;
final bool enabled;
final bool isSkipped;
const PickupCard({
super.key,
required this.item,
required this.displayStep,
this.distanceMeters,
this.riderSpeedMps,
this.enabled = true,
this.isSkipped = false,
});
String _val(List<String> keys, [String fallback = '']) {
for (final k in keys) {
final v = item[k];
if (v != null && v.toString().trim().isNotEmpty) return v.toString();
}
return fallback;
}
/// Minimum 48dp tap targets, enforced against the raw logical pixel value so
/// a small-screen `.h` scale-down can never shrink below the floor.
///
/// Was 52dp with a 56dp CTA. The card's text carries the reading now — the
/// name, the address and the action line all went up a step — and a 56dp
/// button under bigger type made the bottom band the loudest thing on a card
/// whose job is to be scanned, not pressed. 48dp is still Material's floor
/// for a thumb, so nothing is harder to hit; the button just stops competing
/// with the words above it.
static double _tapTarget(double scaled) => math.max(48.0, scaled);
@override
Widget build(BuildContext context) {
final StopKind kind = stopKindOf(item);
final String customer = _val([
'pickupcustomer',
'tenantname',
], kind.hasDelivery ? 'Delivery point' : 'Pickup point');
final String address = _val([
'pickupaddress',
'PickupAddress',
], 'Address not available');
final String orderId = _val(['orderid']);
final String notes = _val(['notes', 'Notes']);
final String phone = _val(['pickupcontactno']);
final int deliverQty = deliveryParcelCount(item);
final int collectQty = pickupParcelCount(item);
final double cash = stopCollectionAmount(item);
// ── The card's one accent, driven by STATE not type ──
//
// This was `kind.accent`, which meant the stop *type* coloured the left
// stripe, the badge and — worst — the primary CTA. So "Start Pickup" was
// maroon, "Start Delivery" royal blue and "Start Combined Stop" indigo: the
// single most important control on the screen changed colour according to a
// category, and a rider scanning for "the button" had no constant to look
// for. Uber's rule is one accent moment per screen precisely because the
// accent is the only thing that says *this* matters.
//
// So type is now carried in words (see the meta line) and the accent is
// spent on state, which is what actually varies and matters: amber when this
// is a resume of a skipped stop, the brand accent otherwise.
final Color accent = isSkipped
? ColorConstants.warning
: ColorConstants.primary;
return Slidable(
key: ValueKey('slidable_$orderId'),
startActionPane: enabled
? ActionPane(
motion: const ScrollMotion(),
extentRatio: 0.35,
children: [
SlidableAction(
borderRadius: BorderRadius.circular(
DesignConstants.radiusXl,
),
onPressed: (_) => _startStop(context, gated: true),
// Same green as the Start button it is a shortcut for.
backgroundColor: ColorConstants.acceptGreen,
foregroundColor: Colors.white,
icon: isSkipped
? Icons.refresh_rounded
: Icons.navigation_rounded,
label: isSkipped ? 'Resume' : 'Start',
),
],
)
: null,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: enabled ? 1 : 0.7,
// ── The card itself opens the stop ──
//
// Tapping a card did nothing here. Everything it knows — the map, the
// full address, the parcel breakdown, the customer's number, the notes —
// was reachable only through the ⋯ menu or by starting the stop, which
// is a commitment rather than a look. Home has opened a detail sheet on
// tap since it was built; a rider moving between the two tabs learned
// one gesture and found it dead on the other.
//
// `HitTestBehavior.opaque` so the whole card responds, including its
// padding. The buttons inside keep their own taps — a child's gesture
// wins over an ancestor's — so Start, Call and ⋯ are unaffected.
// PressScale, not a bare GestureDetector: this card opens a sheet that
// takes ~340ms to arrive, and with no press state the rider got no
// acknowledgement at all in that gap — the classic "did it register?"
// second tap. The dip under the thumb is the receipt.
child: PressScale(
onTap: () => _openDetail(context),
semanticLabel: 'Stop $displayStep, $customer, $address',
semanticHint: 'Opens the full stop details',
child: Container(
margin: EdgeInsets.symmetric(horizontal: 16.w, vertical: 7.h),
// ── Fill, not an edge ──
//
// Border and shadow both gone; the fill separates the card from the
// page on its own, matching the Home stop card and the way most large
// apps draw a list card. The border, the shadow and the fill were
// three devices doing one job.
//
// See [ColorConstants.cardSurface] for why the fill sits 9.4% off the
// page rather than the ~5% a "subtle" grey gives: with no edge, it is
// the only thing left doing the separating, and it has to hold up in
// sunlight.
decoration: BoxDecoration(
color: ColorConstants.cardSurface,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
),
// The card's one piece of chrome: a 4px stripe down the left edge. It
// now carries STATE — amber when this is a skipped stop being resumed,
// the brand accent otherwise — and costs no vertical space at all,
// which the badges and panels it replaced all did. Stop type moved to
// the chip on the meta line, so the stripe no longer doubles as a
// colour-coded category marker.
//
// IntrinsicHeight is load-bearing, not decoration. The stripe is a
// Container with a width and no height, so it only fills the card
// when the Row stretches it — and `CrossAxisAlignment.stretch` sizes
// children to the Row's own cross-axis extent. Inside a ListView
// that extent is UNBOUNDED, so the stripe was asked to be infinitely
// tall and the whole card failed to lay out: every stop rendered as
// nothing while the progress rail above (a separate widget reading
// the same list) kept showing the right count. IntrinsicHeight gives
// the Row a real height first, so stretch has something finite to
// stretch to.
child: ClipRRect(
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
width: 4.w,
color: enabled ? accent : accent.withValues(alpha: 0.35),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── One card, one box ──
//
// This stop card used to nest up to ten containers inside its
// own border: a filled type badge, a bordered "Stop #N" chip, an
// amber full-width ETA panel, a 30px tinted icon tile beside the
// address, a bordered "ACTION REQUIRED" panel holding two rows
// that each carried their own 24px tinted icon tile, and a
// bordered note panel — before reaching the button. Around 390px
// of height, so barely two stops fitted on a phone, and the
// rider scrolled to answer "where am I going next".
//
// Uber's Base system states the rule this broke: *the UI
// recedes, content leads* — containers stay subordinate to what
// they hold — and it prefers whitespace and contrast to layered
// elevation. A box does not make a thing important; being the
// biggest, darkest text on the card does.
//
// So every inner box is gone. What they contained is now type
// and colour on plain rows, and the accent survives in exactly
// two places: the card's left edge and the CTA. Uber calls that
// "a single accent moment"; four tinted panels competing was the
// opposite of one.
//
// Result is roughly half the height, so three to four stops fit
// where two did.
// ── Meta line: what kind of stop, which one, how far ──
Padding(
padding: EdgeInsets.fromLTRB(14.w, 11.h, 12.w, 0),
child: Row(
children: [
// Darkened filled chip — a mark, not a surface. The card
// stays neutral, so the type colour never becomes a pane.
//
// Flexible with an inner ellipsis, because the label is the
// only elastic thing on this line: "PICKUP & DELIVERY" is
// nearly three times the width of "PICKUP", and it shares
// the row with a stop number and the distance chip, both of
// which have a fixed claim. The label is what gives way — a
// truncated type word still reads, an overflowing row does
// not.
Flexible(
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 8.w,
vertical: 4.h,
),
decoration: BoxDecoration(
color: enabled
? kind.chipColor
: kind.chipColor.withValues(
alpha: 0.45,
),
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
kind.icon,
size: 12.sp,
color: Colors.white,
),
SizedBox(width: 5.w),
Flexible(
child: Text(
kind.chipLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 10.5.sp,
fontWeight: FontWeight.w800,
letterSpacing: 0.5,
color: Colors.white,
fontFamily:
FontConstants.fontFamily,
),
),
),
],
),
),
),
if (displayStep > 0) ...[
SizedBox(width: 7.w),
Text(
'Stop $displayStep',
maxLines: 1,
style: TextStyle(
fontSize: 11.5.sp,
fontWeight: FontWeight.w800,
letterSpacing: 0.2,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
],
SizedBox(width: 8.w),
const Spacer(),
// Distance and ETA lost their amber panel but not their
// prominence — this is still the pair he checks at every red
// light, so it is the one bordered thing on the line.
_DistanceEta(
meters: distanceMeters,
speedMps: riderSpeedMps,
muted: !enabled,
),
],
),
),
// ── Identity: the name is the thing he shouts at the gate ──
Padding(
padding: EdgeInsets.fromLTRB(14.w, 7.h, 12.w, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
customer,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 19.sp,
fontWeight: FontWeight.w800,
height: 1.15,
letterSpacing: -0.3,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 3.h),
// Two lines, not three. A third line of street
// address never changes which gate he walks to, and
// it cost every card in the list 18px.
//
// See [kFullAddressTrial] for the design switch that
// lifts this on both stop-card screens at once.
Text(
address,
maxLines: kFullAddressTrial
? kFullAddressMaxLines
: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13.5.sp,
height: 1.35,
fontWeight: FontWeight.w600,
// Slate, not the brown M3 leftover, and dark
// enough to stay legible on the card's fill.
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
if (phone.isNotEmpty) ...[
SizedBox(width: 8.w),
_CallButton(phone: phone),
],
],
),
),
// ── What to do here, as one line of text ──
_ActionLine(
kind: kind,
deliverQty: deliverQty,
collectQty: collectQty,
cash: cash,
skipped: isSkipped,
),
// ── Customer note: a line with an icon, not a panel ──
if (notes.isNotEmpty)
Padding(
padding: EdgeInsets.fromLTRB(14.w, 6.h, 12.w, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.sticky_note_2_rounded,
size: 13.sp,
color: ColorConstants.warning,
),
SizedBox(width: 6.w),
Expanded(
child: Text(
notes,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13.sp,
height: 1.3,
fontWeight: FontWeight.w700,
color: ColorConstants.warning,
fontFamily: FontConstants.fontFamily,
),
),
),
],
),
),
// ── 5. Primary CTA — the only thing to press ──
Padding(
padding: EdgeInsets.fromLTRB(
14.w,
12.h,
14.w,
12.h,
),
child: Row(
children: [
Expanded(
child: SizedBox(
height: _tapTarget(48.h),
// ── Green, filled, and the loudest thing
// on the card ──
//
// This was the stop-kind accent — brand
// maroon on a pickup — which put the
// forward action in the same hue the app
// uses for Reject and for destructive
// confirmations. One rule now holds across
// the whole run: green advances the job,
// maroon is brand and navigation, red
// destroys. See [MilerButton].
//
// Hand-rolled `ElevatedButton` replaced by
// the shared button so the radius, the
// label size and the press state match
// every other CTA the rider presses today.
child: enabled
? MilerButton(
label: isSkipped
? 'Resume stop'
: kind.startCtaLabel,
icon: isSkipped
? Icons.refresh_rounded
: Icons.arrow_forward_rounded,
color: ColorConstants.acceptGreen,
height: _tapTarget(48.h),
onPressed: () =>
_startStop(context),
)
: Container(
alignment: Alignment.center,
decoration: BoxDecoration(
color: ColorConstants.pureSurface,
borderRadius:
BorderRadius.circular(DesignConstants.radiusXl),
border: Border.all(
color:
ColorConstants.borderStrong,
),
),
child: Text(
'Finish the current stop first',
style: TextStyle(
fontWeight: FontWeight.w700,
fontSize: 13.5.sp,
color: ColorConstants
.secondaryText,
fontFamily:
FontConstants.fontFamily,
),
),
),
),
),
if (!isSkipped) ...[
SizedBox(width: 10.w),
_SquareIconButton(
icon: Icons.more_horiz_rounded,
size: _tapTarget(48.h),
background: ColorConstants.pureSurface,
border: ColorConstants.borderStrong,
iconColor: ColorConstants.slateText,
semanticLabel: 'More options',
onTap: () {
final parentState = context
.findAncestorStateOfType<
_MyPickupsState
>();
if (parentState != null) {
_showMyOptionsSheet(
context,
item,
parentState,
);
}
},
),
],
],
),
),
],
),
),
],
),
),
),
),
),
),
);
}
/// Everything the app knows about this stop, in the same sheet Home uses.
///
/// Deliberately the same widget rather than a Bookings-specific one: the two
/// screens show the same stop, and two sheets would drift.
void _openDetail(BuildContext context) {
final parentState = context.findAncestorStateOfType<_MyPickupsState>();
StopDetailSheet.show(
context,
stop: item,
stopNumber: displayStep,
riderLat: parentState?._currentLocation?.latitude,
riderLng: parentState?._currentLocation?.longitude,
);
}
/// Opens the stop's map/navigation screen.
///
/// [gated] applies the on-duty check — the swipe action can fire from a
/// resting thumb, so it verifies duty first; the explicit CTA does not
/// re-ask because the card is only enabled for an on-duty rider anyway.
Future<void> _startStop(BuildContext context, {bool gated = false}) async {
final parentState = context.findAncestorStateOfType<_MyPickupsState>();
if (parentState == null) return;
if (gated) {
if (!await parentState.ensureOnlineForAction()) return;
if (!context.mounted) return;
}
await openScreen(
context,
_PickupMapScreen(pickup: item, parentState: parentState),
swipeToGoBack: false,
);
}
}
// ── Distance + ETA, as one chip ──────────────────────────────────────────────
//
// Was a full-width amber panel with its own border, on the argument that this
// is the one number that changes while the rider is moving. The argument was
// right; the panel was not the way to honour it. A tinted band 40px tall
// pushed the address and the button down on every card in the list, and in
// sunlight an amber fill on white reads as a grey smudge anyway — the very
// thing the card's own comments warned about for badges.
//
// Bare text on the meta line was the correction, and it went one step too far:
// the two live figures ended up sharing a line with the type badge and the stop
// number in a similar weight, so nothing marked where the static half of the
// line ended and the moving half began. A single pill fixes that at no cost in
// height — it wraps BOTH figures, so distance and ETA read as one fact about
// one journey rather than two numbers that happen to be adjacent, and the
// chip's edge does the separating that four different font weights were trying
// to do. One chip, not two: they are never useful apart.
class _DistanceEta extends StatelessWidget {
final double? meters;
final double? speedMps;
final bool muted;
const _DistanceEta({
required this.meters,
required this.speedMps,
this.muted = false,
});
@override
Widget build(BuildContext context) {
final distance = RouteMetricsHelper.formatDistance(meters);
final travel = RouteMetricsHelper.formatDuration(
RouteMetricsHelper.travelTime(meters, liveSpeedMps: speedMps),
);
if (distance == '—') return const SizedBox.shrink();
final Color fg = muted
? ColorConstants.secondaryText
: ColorConstants.slateText;
return Container(
padding: EdgeInsets.symmetric(horizontal: 9.w, vertical: 4.h),
// White, not the app's neutral grey: the card underneath is now grey, and
// a grey chip on a grey card has no edge at all. Inner surfaces go lighter
// than their container.
decoration: BoxDecoration(
color: ColorConstants.pureSurface,
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
border: Border.all(
color: muted
? ColorConstants.borderSubtle
: ColorConstants.borderStrong,
width: 1.2,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.near_me_rounded, size: 13.sp, color: fg),
SizedBox(width: 5.w),
Text(
travel == '—' ? distance : '$distance · $travel',
style: TextStyle(
fontSize: 12.5.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.2,
color: fg,
fontFamily: FontConstants.fontFamily,
),
),
],
),
);
}
}
// ── What to do here ──────────────────────────────────────────────────────────
//
// Replaces the bordered "ACTION REQUIRED" panel, its all-caps heading, and the
// two rows inside it that each carried a 24px tinted icon tile — four boxes to
// say "Collect 3 parcels, cash ₹340".
//
// One line of text says the same thing. A combined stop still gets both jobs,
// separated by a bullet and each in its own leg colour, so the "TWO jobs here"
// signal the panel existed for survives: it is now carried by colour and by
// the word "and", which a rider reads faster than he parses a stacked panel.
class _ActionLine extends StatelessWidget {
final StopKind kind;
final int deliverQty;
final int collectQty;
final double cash;
final bool skipped;
const _ActionLine({
required this.kind,
required this.deliverQty,
required this.collectQty,
required this.cash,
required this.skipped,
});
static String _money(double v) {
if (v >= 100) return '₹${v.round()}';
final rounded = (v * 100).round() / 100;
return rounded == rounded.roundToDouble()
? '₹${rounded.round()}'
: '₹${rounded.toStringAsFixed(2)}';
}
@override
Widget build(BuildContext context) {
final spans = <InlineSpan>[];
TextStyle style(Color c) => TextStyle(
fontSize: 14.5.sp,
fontWeight: FontWeight.w800,
color: c,
fontFamily: FontConstants.fontFamily,
);
// Parcel legs are neutral; only the cash is coloured. These used to be
// royal blue and pink respectively, which is colour spent on naming a leg
// that the word beside it already names — and it made the line read as three
// unrelated fragments. Matches [_MetaLine] on the Home stop row.
if (kind.hasDelivery) {
final n = deliverQty > 0 ? deliverQty : 1;
spans.add(
TextSpan(text: 'Deliver $n', style: style(ColorConstants.slateText)),
);
}
if (kind.hasPickup) {
if (spans.isNotEmpty) {
spans.add(
TextSpan(text: ' · ', style: style(ColorConstants.borderStrong)),
);
}
final n = collectQty > 0 ? collectQty : 1;
spans.add(
TextSpan(text: 'Collect $n', style: style(ColorConstants.slateText)),
);
}
if (spans.isEmpty) return const SizedBox.shrink();
if (cash > 0) {
spans.add(
TextSpan(text: ' · ', style: style(ColorConstants.secondaryText)),
);
spans.add(
TextSpan(
// `acceptGreen` is a fill colour; as text on the card fill it measures
// 2.28:1. See [ColorConstants.moneyGreen].
text: 'Collect ${_money(cash)}',
style: style(ColorConstants.moneyGreen),
),
);
}
if (skipped) {
spans.add(
TextSpan(
text: ' · Skipped earlier',
style: style(ColorConstants.warning),
),
);
}
return Padding(
padding: EdgeInsets.fromLTRB(14.w, 7.h, 12.w, 0),
child: Text.rich(
TextSpan(children: spans),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
);
}
}
class _CallButton extends StatelessWidget {
final String phone;
const _CallButton({required this.phone});
@override
Widget build(BuildContext context) {
return _SquareIconButton(
icon: Icons.call_rounded,
size: math.max(52.0, 52.w),
// Neutral, like its twin on the Home stop row: a green-tinted tile put a
// second green control beside a green-adjacent CTA, and green already
// means "done" elsewhere in the app. Calling is an affordance, not a state.
background: ColorConstants.pureSurface,
border: ColorConstants.borderStrong,
iconColor: ColorConstants.slateText,
semanticLabel: 'Call customer',
onTap: () async {
// A dialer that fails to open is silent by default: the rider is at
// the door, taps Call, and nothing happens. Say so, and show him the
// number so he can dial it himself.
final ok = await launchPhoneDialer(phone);
if (!ok && context.mounted) {
AppFeedback.error(context, "Couldn't open the dialer — $phone");
}
},
);
}
}
// ── Square icon button with a guaranteed tap target ──────────────────────────
class _SquareIconButton extends StatelessWidget {
final IconData icon;
final double size;
final Color background;
final Color border;
final Color iconColor;
final String semanticLabel;
final VoidCallback onTap;
const _SquareIconButton({
required this.icon,
required this.size,
required this.background,
required this.border,
required this.iconColor,
required this.semanticLabel,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
label: semanticLabel,
child: Material(
color: background,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
child: Container(
width: size,
height: size,
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
border: Border.all(color: border, width: 1.5),
),
child: Icon(icon, color: iconColor, size: 22.sp),
),
),
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,979 @@
part of 'pickups.dart';
// ─────────────────────────────────────────────────────────────────────────────
// PICKUP DONE SCREEN — PREMIUM REDESIGN (Scratch Card + Standard)
// ─────────────────────────────────────────────────────────────────────────────
class PickupsDone extends StatefulWidget {
final bool isCancelled;
final bool isDelivery;
final int bonusPoints;
final List<Map<String, dynamic>>? remainingOrders;
final String? paymentMethod;
final _MyPickupsState? parentState;
const PickupsDone({
super.key,
this.isCancelled = false,
this.isDelivery = false,
this.bonusPoints = 0,
this.remainingOrders,
this.paymentMethod,
this.parentState,
});
@override
State<PickupsDone> createState() => _PickupsDoneState();
}
class _PickupsDoneState extends State<PickupsDone>
with TickerProviderStateMixin {
late ConfettiController _confettiController;
final GlobalKey<ScratcherState> _scratcherKey = GlobalKey<ScratcherState>();
double _opacity = 0.0;
bool _isScratched = false;
late AnimationController _entranceController;
late Animation<double> _circleScaleAnim;
late Animation<double> _fadeAnim;
late Animation<Offset> _slideAnim;
/// The next few stops, capped for display.
List<Map<String, dynamic>> _nextOrders = [];
/// How many stops are actually left, before the display cap. The screen
/// tells the rider where he is in the day, and "3 left" when 11 remain is
/// worse than saying nothing.
int _remainingCount = 0;
bool _loadingNextOrders = true;
@override
void initState() {
super.initState();
_confettiController = ConfettiController(
duration: const Duration(seconds: 4),
);
// 800 → 300. This screen sits between two stops and the rider crosses it
// 40 times a shift; an eight-hundred-millisecond entrance is half a second
// of standing still, 40 times. The celebration branch keeps its own longer
// beat — see [DesignConstants.motionCelebrate] — because that fires once
// per completed route, not once per stop.
_entranceController = AnimationController(
duration: DesignConstants.motionPage,
vsync: this,
);
_circleScaleAnim = CurvedAnimation(
parent: _entranceController,
curve: const Interval(0.0, 0.6, curve: Curves.elasticOut),
);
_fadeAnim = CurvedAnimation(
parent: _entranceController,
curve: const Interval(0.3, 1.0, curve: Curves.easeOut),
);
_slideAnim = Tween<Offset>(begin: const Offset(0, 0.3), end: Offset.zero)
.animate(
CurvedAnimation(
parent: _entranceController,
curve: const Interval(0.3, 1.0, curve: Curves.easeOut),
),
);
Future.delayed(const Duration(milliseconds: 200), () {
if (mounted) {
_entranceController.forward();
// Celebrate only when it's actually special: a real bonus reveal.
// Route-completion confetti fires from _loadNextOrders once we know no
// stops remain. A plain mid-route stop gets a calm screen (no confetti)
// so the rider isn't slowed by fireworks 15 times a shift.
if (!widget.isCancelled && widget.bonusPoints > 0) {
_confettiController.play();
}
}
});
_loadNextOrders();
}
/// Fires the confetti only when the route is finished (no stops left). A
/// bonus reveal already celebrates in initState, and cancelled stops never do.
void _maybeCelebrateRouteComplete() {
if (!mounted || widget.isCancelled || widget.bonusPoints > 0) return;
if (_nextOrders.isEmpty) _confettiController.play();
}
Future<void> _loadNextOrders() async {
try {
// If parent passed remaining bookings, use those
if (widget.remainingOrders != null) {
final live = widget.remainingOrders!
.where((o) => !stopStatusOf(o).isFinishedPickup)
.toList();
setState(() {
_remainingCount = live.length;
_nextOrders = live.take(3).toList();
_loadingNextOrders = false;
});
_maybeCelebrateRouteComplete();
return;
}
// Otherwise fetch from SharedPreferences / API
final prefs = await SharedPreferences.getInstance();
final userId = prefs.getInt('userId') ?? prefs.getInt('userid') ?? 0;
if (userId == 0) {
if (mounted) setState(() => _loadingNextOrders = false);
return;
}
final provider = PickupProvider();
final items = await provider.getPickupQueuesPicked();
List<Map<String, dynamic>> next = items
.whereType<Map<String, dynamic>>()
.toList();
// Fallback when the server returns nothing. On the live backend the
// accepted store is the load-bearing record of bookings the rider has
// taken on (the backend keeps them as 'miler_assigned'), so use it to
// surface the remaining stops — completed/cancelled ones are removed from
// the store on finish, so they won't show here. The mock list is only
// added on the old demo path.
if (next.isEmpty) {
final accepted = await getAcceptedBookings();
next = [...accepted];
}
// Nothing left is a real answer here — it is what "Return to hub" is for.
// The debug demo-route fallback that used to sit here is gone with the
// rest of the mock layer.
// Keep only pickups that aren't already completed/cancelled.
next = next.where((o) => !stopStatusOf(o).isFinishedPickup).toList();
if (mounted) {
setState(() {
_remainingCount = next.length;
_nextOrders = next.take(3).toList();
_loadingNextOrders = false;
});
_maybeCelebrateRouteComplete();
}
} catch (e) {
debugPrint('[DONE] Error loading next bookings: $e');
if (mounted) setState(() => _loadingNextOrders = false);
}
}
@override
void dispose() {
_confettiController.dispose();
_entranceController.dispose();
super.dispose();
}
/// Opens the next pickup stop's navigation screen, replacing this completion
/// screen so the rider can't tap back into it. When no stops remain, returns
/// to the hub (Bookings tab).
void _goToNextStop({Map<String, dynamic>? order}) {
final Map<String, dynamic>? next =
order ?? (_nextOrders.isNotEmpty ? _nextOrders.first : null);
if (next != null) {
// Use the same screen as "Start pickup" so every stop has an identical
// flow: preview → navigate → arrive → verify → confirm → next stop.
// Thread the real pickups state through so each stop's completion updates
// the one live list (removes it, unblocks the following stop).
Get.off(
() => _PickupMapScreen(pickup: next, parentState: widget.parentState),
);
} else {
Get.offAll(() => const BottomPage(initialIndex: 1));
}
}
/// The screen's single surface. With nothing on the page boxed, there is
/// only one background, and everything — including the pinned CTA bar — is
/// drawn straight onto it.
static Color get _pageBg => ColorConstants.daylightSurface;
@override
Widget build(BuildContext context) {
final bool showScratchCard = !widget.isCancelled && widget.bonusPoints > 0;
return Scaffold(
backgroundColor: _pageBg,
body: Stack(
children: [
SafeArea(
child: showScratchCard
? _buildScratchCardContent()
: _buildStandardContent(),
),
// Confetti overlay
Align(
alignment: Alignment.topCenter,
child: ConfettiWidget(
confettiController: _confettiController,
blastDirectionality: BlastDirectionality.explosive,
shouldLoop: false,
numberOfParticles: 30,
gravity: 0.3,
emissionFrequency: 0.05,
colors: [
ColorConstants.primary,
ColorConstants.acceptGreen,
Color(0xFF007AFF),
Color(0xFFFFB800),
Color(0xFFAF52DE),
],
createParticlePath: drawStar,
),
),
],
),
);
}
// ── Standard Picked up / Cancelled ─────────────────────────────────────────
//
// A handoff, not a trophy — and one surface, not five.
//
// ── What was wrong with the previous version ──
//
// Every group on this screen was a bordered, filled, rounded rectangle: a
// green banner, a shadowed hero card, a bordered list card with a pill inside
// it and a filled purple badge inside that. Four containers, three border
// weights and a shadow, stacked down a page holding about forty words.
//
// The cost is not ugliness, it is *ranking*. A border says "this is a thing,
// separate from its neighbours". Give that emphasis to all four groups and
// none of them is emphasised: the rider's eye lands on whichever box is
// biggest rather than on whichever fact matters, and the thing he opened the
// screen for — where am I going next — has to be found rather than seen.
//
// ── What replaces them ──
//
// Nothing is boxed. Rank is carried by the things that carry rank for free:
//
// • **Type size.** The stop name is 28sp against a 12.5sp status line. That
// ratio alone answers "what is this screen about" before anything is read.
// • **Colour weight.** Finished work is grey with a small green tick; the
// live destination is near-black. Completed information should be legible
// and ignorable, which is exactly what grey-on-neutral is.
// • **Whitespace.** A 26pt gap groups more cleanly than a 1px border does,
// and it costs no visual noise to do it.
// • **One hairline**, between the hero and the list — the single place where
// a genuine change of subject happens.
//
// The order is the rider's own sentence: you finished that → this is next →
// then these → press this. Roughly one second, top to bottom, without a
// single edge to parse.
Widget _buildStandardContent() {
return Column(
children: [
Expanded(
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
// ── The top margin is not decoration ──
//
// This screen has no app bar — there is nowhere to go back to, so
// there is nothing for one to hold — and `SafeArea` only clears the
// notch. With 10pt after it the status line began about 34pt down
// an 844pt page, hard under the clock, and every page in the app
// that *does* have a bar starts its content around 90pt. So the
// handoff read as the one screen missing its header rather than as
// the one screen that does not need one.
//
// 34pt puts the first line clear of the system chrome and gives the
// page a top edge of its own. It is the same trick Uber's post-trip
// screens use: no bar, but a margin deep enough that the absence
// reads as deliberate.
padding: EdgeInsets.fromLTRB(20.w, 34.h, 20.w, 32.h),
child: FadeTransition(
opacity: _fadeAnim,
child: SlideTransition(
position: _slideAnim,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_statusLine(),
if (_loadingNextOrders)
_findingNextStop()
else if (_nextOrders.isEmpty)
_routeComplete()
else ...[
// Deliberately wider than the 34pt margin above the
// status line. Equal gaps left that line belonging to
// neither the top edge nor the hero — it just hung there.
// Tighter above than below groups it with the top of the
// page, which is what it is: a footnote on the screen the
// rider just left, not an introduction to this one.
SizedBox(height: 42.h),
_nextStopHero(_nextOrders.first),
if (_nextOrders.length > 1) _upNext(),
],
],
),
),
),
),
),
_handoffBar(),
],
);
}
/// What just happened, and how much is left — on one grey line.
///
/// This was a filled, bordered green banner with a bordered white pill inside
/// it, about 60pt tall, and it was the loudest object on a screen whose
/// subject is somewhere else entirely. It is *completed* information: the
/// rider performed the act himself thirty seconds ago and is not deciding
/// anything with it. He needs to be able to confirm it, not to be told it.
///
/// So the green survives only in the tick — enough to answer "did that
/// register?" at a glance — and the words drop to the same grey as every
/// other secondary caption in the app. The count rides the same line, dot
/// separated, because "delivered" and "9 left" are one thought about the
/// shift, not two facts needing two containers.
Widget _statusLine() {
final isCancelled = widget.isCancelled;
final method = widget.paymentMethod?.trim() ?? '';
final done = widget.isDelivery ? 'Delivered' : 'Picked up';
final parts = <String>[
isCancelled ? 'Not completed' : done,
if (!isCancelled && method.isNotEmpty) method,
if (_remainingCount > 0)
'$_remainingCount ${_remainingCount == 1 ? 'stop' : 'stops'} left',
];
return Row(
children: [
Icon(
isCancelled ? Icons.cancel_rounded : Icons.check_circle_rounded,
size: 16.sp,
color: isCancelled
? ColorConstants.errorRed
: ColorConstants.acceptGreen,
),
SizedBox(width: 7.w),
Expanded(
child: Text(
parts.join(' · '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12.5.sp,
fontWeight: FontWeight.w700,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
),
],
);
}
/// The next stop, at the size of the decision it is.
///
/// No card, no border, no shadow. A hero that needs a box drawn round it to
/// read as the hero is not yet a hero — and this one does not: at 28sp
/// against a 12.5sp status line above it and 15sp rows below, it is the only
/// thing on the screen the eye can land on first.
///
/// The stop type moves from a filled purple badge floated to the right into a
/// dotted label under the address. Same information, ranked correctly: the
/// rider decides *whether to go* from the name and the address, and only then
/// asks what he will be doing when he arrives. A saturated chip at the top
/// asked that question first and in the loudest voice on the screen.
Widget _nextStopHero(Map<String, dynamic> stop) {
final kind = stopKindOf(stop);
final name = (stop['pickupcustomer'] ?? 'Stop').toString();
final address = (stop['pickupaddress'] ?? 'Address not available')
.toString();
final kms = (stop['kms'] ?? '').toString();
final skipped =
(stop['orderstatus']?.toString().toLowerCase() ?? '') == 'skipped';
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('NEXT STOP', style: _eyebrow),
SizedBox(height: 10.h),
Text(
name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 28.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.9,
height: 1.08,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 10.h),
Text(
address,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14.5.sp,
height: 1.45,
fontWeight: FontWeight.w500,
color: ColorConstants.onSurfaceVariant,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 16.h),
Row(
children: [
// The one place the type colour appears, and it appears as a 6pt
// dot. Enough to separate a delivery from a pickup at a glance
// across a day of these; not enough to compete with the name.
Container(
width: 6.w,
height: 6.w,
decoration: BoxDecoration(
color: kind.accent,
shape: BoxShape.circle,
),
),
SizedBox(width: 8.w),
Flexible(
child: Text(
kind.titleLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12.5.sp,
fontWeight: FontWeight.w700,
color: kind.accent,
fontFamily: FontConstants.fontFamily,
),
),
),
if (kms.isNotEmpty && kms != '0') ...[
Text(' · ', style: _metaStyle),
Text('$kms km away', style: _metaStyle),
],
],
),
if (skipped) ...[SizedBox(height: 14.h), _retryNote()],
],
);
}
/// The one place on this screen that still earns a fill.
///
/// It is not a group of related content — it is an exception, and an
/// exception that contradicts what the rider remembers: he passed this stop
/// earlier and is being sent back. A tinted strip is how the app marks that
/// everywhere else, and dropping it to a plain line here would hide the one
/// fact on the screen he cannot infer from anything around it.
Widget _retryNote() {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.replay_rounded,
size: 15.sp,
color: ColorConstants.warning,
),
SizedBox(width: 8.w),
Expanded(
child: Text(
'You skipped this one earlier — this is the retry',
style: TextStyle(
fontSize: 12.5.sp,
height: 1.35,
fontWeight: FontWeight.w700,
color: ColorConstants.warning,
fontFamily: FontConstants.fontFamily,
),
),
),
],
);
}
/// Stops after the next one: one list, not a stack of cards.
///
/// These are context, never a decision — the rider only ever acts on the stop
/// in front of him. So they get the quietest treatment that is still tappable:
/// a numbered circle, two lines of text, a chevron, and a hairline between
/// rows inset to the text so the column reads as one list rather than as
/// separate objects.
///
/// The single full-width rule above the section is the only border on the
/// screen. It is there because this is the one point where the subject
/// genuinely changes — from the stop he is going to, to the ones after it.
Widget _upNext() {
final later = _nextOrders.skip(1).toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: 32.h),
Container(height: 1, color: ColorConstants.borderSubtle),
SizedBox(height: 22.h),
Text(
'UP NEXT · ${later.length} ${later.length == 1 ? 'STOP' : 'STOPS'}',
style: _eyebrow,
),
SizedBox(height: 6.h),
for (var i = 0; i < later.length; i++) ...[
if (i > 0)
Padding(
// Inset to the text column, not to the numbers. A rule that runs
// under the circles cuts the list into slabs; one that starts
// where the names start reads as a separator inside a single list.
padding: EdgeInsets.only(left: 39.w),
child: Container(height: 1, color: ColorConstants.borderSubtle),
),
_upNextRow(later[i], i + 2),
],
],
);
}
Widget _upNextRow(Map<String, dynamic> stop, int position) {
final kind = stopKindOf(stop);
final name = (stop['pickupcustomer'] ?? 'Stop').toString();
final address = (stop['pickupaddress'] ?? '').toString();
return InkWell(
onTap: () => _goToNextStop(order: stop),
child: Padding(
padding: EdgeInsets.symmetric(vertical: 15.h),
child: Row(
children: [
Container(
width: 26.w,
height: 26.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color: kind.accent.withValues(alpha: 0.10),
shape: BoxShape.circle,
),
child: Text(
'$position',
style: TextStyle(
fontSize: 11.5.sp,
fontWeight: FontWeight.w800,
color: kind.accent,
fontFamily: FontConstants.fontFamily,
),
),
),
SizedBox(width: 13.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w700,
letterSpacing: -0.2,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
if (address.isNotEmpty) ...[
SizedBox(height: 2.h),
Text(
address,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12.5.sp,
fontWeight: FontWeight.w500,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
],
],
),
),
SizedBox(width: 8.w),
Icon(
Icons.chevron_right_rounded,
size: 20.sp,
color: ColorConstants.secondaryText,
),
],
),
),
);
}
Widget _findingNextStop() {
return Padding(
padding: EdgeInsets.symmetric(vertical: 78.h),
child: Center(
child: Column(
children: [
SizedBox(
width: 22.w,
height: 22.w,
child: CircularProgressIndicator(
strokeWidth: 2.5,
valueColor: AlwaysStoppedAnimation<Color>(
ColorConstants.primary,
),
),
),
SizedBox(height: 14.h),
Text(
'Finding your next stop…',
style: TextStyle(
fontSize: 13.5.sp,
fontWeight: FontWeight.w600,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
);
}
/// The one moment on this screen that has earned a celebration: the route is
/// finished. Confetti already fires from [_maybeCelebrateRouteComplete].
///
/// Still no card. The green disc is a *mark* — it is the subject of the
/// message, not a container holding it — which is the line this screen draws
/// between a shape that is allowed and one that is not.
Widget _routeComplete() {
return Padding(
padding: EdgeInsets.fromLTRB(8.w, 58.h, 8.w, 24.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
padding: EdgeInsets.all(16.r),
decoration: BoxDecoration(
color: ColorConstants.acceptGreen.withValues(alpha: 0.10),
shape: BoxShape.circle,
),
child: Icon(
Icons.warehouse_rounded,
size: 30.sp,
color: ColorConstants.acceptGreen,
),
),
SizedBox(height: 18.h),
Text(
'Every stop done',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 24.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.6,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 8.h),
Text(
'Head back to the hub to drop the parcels and hand in any cash '
'you collected.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14.sp,
height: 1.5,
fontWeight: FontWeight.w500,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
],
),
);
}
/// Pinned CTA.
///
/// Same background as the page and a single hairline above it, rather than a
/// white slab with a shadow. The bar is not a separate surface floating over
/// the content — it is the bottom of the page with the button held in it, and
/// on a screen that has just given up every other border it would be the
/// heaviest edge left if it kept its own.
Widget _handoffBar() {
final hasNext = _nextOrders.isNotEmpty;
return Container(
padding: EdgeInsets.fromLTRB(20.w, 12.h, 20.w, 12.h),
decoration: BoxDecoration(
color: _pageBg,
border: Border(
top: BorderSide(color: ColorConstants.borderSubtle, width: 1),
),
),
child: SafeArea(
top: false,
child: MilerButton(
label: _loadingNextOrders
? 'Loading…'
: hasNext
? 'Move to next stop'
: 'Return to hub',
icon: hasNext ? Icons.arrow_forward_rounded : Icons.warehouse_rounded,
color: hasNext ? ColorConstants.acceptGreen : ColorConstants.primary,
loading: _loadingNextOrders,
onPressed: _loadingNextOrders ? null : () => _goToNextStop(),
),
),
);
}
/// Section labels. Small, letterspaced, grey — the quietest way to name a
/// group, and the reason none of the groups needs an outline.
TextStyle get _eyebrow => TextStyle(
fontSize: 11.sp,
fontWeight: FontWeight.w800,
letterSpacing: 1.3,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
);
TextStyle get _metaStyle => TextStyle(
fontSize: 12.5.sp,
fontWeight: FontWeight.w700,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
);
// ── Scratch Card Content ───────────────────────────────────────────────────
Widget _buildScratchCardContent() {
return Padding(
padding: EdgeInsets.symmetric(horizontal: 24.w),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Spacer(flex: 1),
ScaleTransition(
scale: _circleScaleAnim,
child: Container(
width: 68.w,
height: 68.w,
// ── Celebration, in the app's own voice ──
//
// This was an amber gradient over a 20px coloured glow, in two
// hex values that appear nowhere else in the product — so the one
// moment a rider is being congratulated looked like it came from
// a different app. It stays visually special (it is the only
// filled disc on any handoff screen, and it still scales in with
// an elastic curve) but it is now the app's own reward colour,
// flat, the way every other surface here is.
decoration: BoxDecoration(
color: ColorConstants.rewardAmber,
shape: BoxShape.circle,
),
child: Icon(
Icons.card_giftcard_rounded,
size: 30.sp,
color: Colors.white,
),
),
),
SizedBox(height: 16.h),
FadeTransition(
opacity: _fadeAnim,
child: Column(
children: [
Text(
'You Won a Bonus!',
style: TextStyle(
fontSize: 24.sp,
fontWeight: FontWeight.w900,
color: ColorConstants.onSurface,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 6.h),
Text(
'Scratch the card to reveal your bonus points',
style: TextStyle(
fontSize: 14.sp,
color: ColorConstants.onSurfaceVariant,
fontFamily: FontConstants.fontFamily,
),
textAlign: TextAlign.center,
),
],
),
),
SizedBox(height: 24.h),
// Scratch Card
FadeTransition(
opacity: _fadeAnim,
child: Center(
child: Container(
width: 250.w,
height: 250.w,
decoration: BoxDecoration(
color: ColorConstants.pureSurface,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.08),
blurRadius: 20,
offset: const Offset(0, 8),
),
BoxShadow(
color: const Color(0xFFFFB800).withValues(alpha: 0.15),
blurRadius: 30,
offset: const Offset(0, 12),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
child: Scratcher(
key: _scratcherKey,
brushSize: 50,
threshold: 50,
color: ColorConstants.primary,
onChange: (value) {},
onThreshold: () {
_confettiController.play();
setState(() {
_opacity = 1.0;
_isScratched = true;
});
},
image: Image.asset(
'assets/images/doormile_icon.png',
fit: BoxFit.scaleDown,
width: 100,
height: 100,
),
child: Container(
width: 250,
height: 250,
color: ColorConstants.pureSurface,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.monetization_on_rounded,
size: 64.sp,
color: const Color(0xFFFFB800),
),
SizedBox(height: 10.h),
Text(
'${widget.bonusPoints}',
style: TextStyle(
fontSize: 52.sp,
fontWeight: FontWeight.w900,
color: ColorConstants.onSurface,
fontFamily: FontConstants.fontFamily,
letterSpacing: -2,
),
),
Text(
'Bonus Points',
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
color: ColorConstants.onSurfaceVariant,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
),
),
),
),
),
if (!_isScratched)
Padding(
padding: EdgeInsets.only(top: 16.h),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.touch_app_rounded,
color: ColorConstants.onSurfaceVariant,
size: 16.sp,
),
SizedBox(width: 6.w),
Text(
'Scratch the card to reveal',
style: TextStyle(
fontSize: 13.sp,
color: ColorConstants.onSurfaceVariant,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
const Spacer(flex: 1),
_buildDoneButton(isEnabled: _isScratched),
SizedBox(height: 24.h),
],
),
);
}
/// ── One button, not a second implementation of one ──
///
/// This was a hand-rolled `AnimatedContainer` with a gradient fill, a drop
/// shadow, its own radius, its own label size and `disabledFill` used as the
/// *text* colour when disabled. It is the same action as the button on the
/// no-bonus branch fifty lines up — "move to the next stop" — so it is the
/// same button now, and the celebration is carried by the screen around it
/// rather than by giving the CTA its own costume.
Widget _buildDoneButton({required bool isEnabled}) {
final bool hasNext = _nextOrders.isNotEmpty;
return MilerButton(
label: hasNext ? 'Move to next stop' : 'Return to hub',
icon: hasNext ? Icons.arrow_forward_rounded : Icons.warehouse_rounded,
color: hasNext ? ColorConstants.acceptGreen : ColorConstants.primary,
onPressed: isEnabled ? () => _goToNextStop() : null,
);
}
Path drawStar(Size size) {
double degToRad(double deg) => deg * (math.pi / 180.0);
const numberOfPoints = 5;
final halfWidth = size.width / 2;
final externalRadius = halfWidth;
final internalRadius = halfWidth / 2.5;
final degreesPerStep = degToRad(360 / numberOfPoints);
final halfDegreesPerStep = degreesPerStep / 2;
final path = Path();
final fullAngle = degToRad(360);
path.moveTo(size.width, halfWidth);
for (double step = 0; step < fullAngle; step += degreesPerStep) {
path.lineTo(
halfWidth + externalRadius * math.cos(step),
halfWidth + externalRadius * math.sin(step),
);
path.lineTo(
halfWidth + internalRadius * math.cos(step + halfDegreesPerStep),
halfWidth + internalRadius * math.sin(step + halfDegreesPerStep),
);
}
path.close();
return path;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
part of 'pickups.dart';
// -------------------------------------------------------------------------
// MAP VIEW BUTTON (Customer locations)
// -------------------------------------------------------------------------
class MapViewRow extends StatelessWidget {
final List<Map<String, dynamic>> pickup;
final Map<String, int>? preservedStepNumbers;
const MapViewRow({
super.key,
required this.pickup,
this.preservedStepNumbers,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: () {
openScreen(
context,
MultiCustomerMapScreen(
pickup: pickup,
preservedStepNumbers: preservedStepNumbers ?? {},
),
swipeToGoBack: false,
);
},
child: Image.asset(
'assets/images/customermap.png',
color: ColorConstants.primary,
height: 32,
width: 32,
errorBuilder: (c, e, s) =>
Icon(Icons.map, size: 32, color: ColorConstants.primary),
),
);
}
}

View File

@@ -0,0 +1,878 @@
part of 'pickups.dart';
class _StepPoint {
final int step;
final LatLng position;
_StepPoint(this.step, this.position);
}
// -------------------------------------------------------------------------
// MULTI CUSTOMER MAP (Shows all pickup with real step numbers)
// -------------------------------------------------------------------------
class MultiCustomerMapScreen extends StatefulWidget {
final List<Map<String, dynamic>> pickup;
final Map<String, int> preservedStepNumbers;
const MultiCustomerMapScreen({
super.key,
required this.pickup,
this.preservedStepNumbers = const {},
});
@override
State<MultiCustomerMapScreen> createState() => _MultiCustomerMapScreenState();
}
class _MultiCustomerMapScreenState extends State<MultiCustomerMapScreen>
with SingleTickerProviderStateMixin {
final MapController mapController = MapController();
late final MilerMapCamera _camera;
List<Marker> markers = <Marker>[];
/// The route as known, faint, underneath.
List<Polyline> casingLines = <Polyline>[];
/// The bright line drawing itself in over the casing. Named rather than kept
/// in a set keyed by 'route_progress': `flutter_map` draws a list in order, so
/// the order is the z-order and the id had nothing left to do.
Polyline? progressLine;
List<Polyline> get routeLayers => <Polyline>[
...casingLines,
if (progressLine != null) progressLine!,
];
/// Bumped whenever a route layer changes mid-animation, so only the map
/// is rebuilt rather than the whole screen.
final ValueNotifier<int> _mapRepaint = ValueNotifier<int>(0);
LatLng? currentLocation;
bool _isLoading = true;
bool _mapReady = false;
final Map<String, Map<String, dynamic>> _pickupMap = {};
/// Every routed point, hub to last stop, in riding order.
///
/// The segments are fetched one leg at a time, but the *animation* has to run
/// across the whole route — drawing each leg in turn would restart at every
/// stop and read as five separate lines rather than one journey.
final List<LatLng> _fullRoute = [];
late final AnimationController _routeAnim;
@override
void initState() {
super.initState();
// Long enough to read as a route being traced rather than a line appearing;
// short enough that the rider is not waiting on it to see his stops.
_camera = MilerMapCamera(controller: mapController, vsync: this);
_routeAnim = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1400),
)..addListener(_onRouteTick);
if (widget.pickup.isNotEmpty) {
final firstPickup = widget.pickup.first;
final dropLat = _parseD(
firstPickup['droplat'] ?? firstPickup['pickuplat'] ?? 0,
);
final dropLon = _parseD(
firstPickup['droplon'] ?? firstPickup['pickuplong'] ?? 0,
);
if (dropLat != 0 && dropLon != 0) {
currentLocation = LatLng(dropLat, dropLon);
} else {
currentLocation = const LatLng(11.018356, 77.012596);
}
} else {
currentLocation = const LatLng(11.018356, 77.012596);
}
_loadMapData();
}
double _parseD(dynamic v) {
if (v == null) return 0.0;
if (v is num) return v.toDouble();
return double.tryParse(v.toString()) ?? 0.0;
}
int _getStepNumber(Map<String, dynamic> Booking) {
final dynamic raw = Booking['step'] ?? Booking['Step'];
final int step = raw == null
? 0
: (raw is num ? raw.toInt() : int.tryParse(raw.toString()) ?? 0);
return step;
}
String _getOrderKey(Map<String, dynamic> Booking) {
final pickupId = (Booking['pickupid'] ?? '').toString();
final orderId = (Booking['orderid'] ?? '').toString();
return pickupId.isNotEmpty ? 'pickup_$pickupId' : 'order_$orderId';
}
int _getPreservedOrCurrentStep(Map<String, dynamic> Booking) {
final orderKey = _getOrderKey(Booking);
if (widget.preservedStepNumbers.containsKey(orderKey)) {
return widget.preservedStepNumbers[orderKey]!;
}
final currentStep = _getStepNumber(Booking);
return currentStep;
}
Future<void> _loadMapData() async {
try {
if (mounted) {
setState(() {
_isLoading = false;
});
}
_getCurrentLocation().then((_) {
if (mounted) {
_createPickupMarkers();
}
});
} catch (e) {
debugPrint('[CUSTOMER_MAP] Error loading map data: $e');
if (mounted) {
setState(() => _isLoading = false);
}
}
}
Future<void> _getCurrentLocation() async {
try {
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
final lastPos = await Geolocator.getLastKnownPosition();
if (lastPos != null && mounted) {
setState(() {
currentLocation = LatLng(lastPos.latitude, lastPos.longitude);
});
}
return;
}
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) {
final lastPos = await Geolocator.getLastKnownPosition();
if (lastPos != null && mounted) {
setState(() {
currentLocation = LatLng(lastPos.latitude, lastPos.longitude);
});
}
return;
}
}
Position? position;
try {
position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.medium,
distanceFilter: 0,
timeLimit: Duration(seconds: 5),
),
);
} catch (e) {
debugPrint(
'[CUSTOMER_MAP] Timeout getting location, using last known: $e',
);
position = await Geolocator.getLastKnownPosition();
}
final safePosition = position;
if (safePosition != null && mounted) {
setState(() {
currentLocation = LatLng(
safePosition.latitude,
safePosition.longitude,
);
});
}
} catch (e) {
debugPrint('[CUSTOMER_MAP] Error getting location: $e');
try {
final lastPos = await Geolocator.getLastKnownPosition();
if (lastPos != null && mounted) {
setState(() {
currentLocation = LatLng(lastPos.latitude, lastPos.longitude);
});
}
} catch (_) {}
}
}
int? _getNextPickupStep() {
// Find the first non-skipped pickup (next pickup from user location)
for (int i = 0; i < widget.pickup.length; i++) {
final pickup = widget.pickup[i];
final status = (pickup['orderstatus']?.toString().toLowerCase() ?? '')
.trim();
if (status != 'skipped') {
final stepNumber = _getPreservedOrCurrentStep(pickup);
if (stepNumber > 0) {
return stepNumber;
} else {
// Calculate display step for bookings without step
final ordersWithStepBefore = widget.pickup
.sublist(0, i)
.where((o) => _getPreservedOrCurrentStep(o) > 0)
.length;
final totalOrdersWithStep = widget.pickup
.where((o) => _getPreservedOrCurrentStep(o) > 0)
.length;
return totalOrdersWithStep + (i - ordersWithStepBefore) + 1;
}
}
}
return null;
}
Future<void> _createPickupMarkers() async {
final tempMarkers = <Marker>[];
List<_StepPoint> stepPoints = [];
_pickupMap.clear();
final nextStep = _getNextPickupStep();
for (int i = 0; i < widget.pickup.length; i++) {
final pickup = widget.pickup[i];
final stepNumber = _getPreservedOrCurrentStep(pickup);
int displayStep;
if (stepNumber > 0) {
displayStep = stepNumber;
} else {
final ordersWithStepBefore = widget.pickup
.sublist(0, i)
.where((o) => _getPreservedOrCurrentStep(o) > 0)
.length;
final totalOrdersWithStep = widget.pickup
.where((o) => _getPreservedOrCurrentStep(o) > 0)
.length;
displayStep = totalOrdersWithStep + (i - ordersWithStepBefore) + 1;
}
final double lat = _parseD(pickup['droplat'] ?? pickup['pickuplat']);
final double lon = _parseD(pickup['droplon'] ?? pickup['pickuplong']);
if (lat == 0 || lon == 0) continue;
final orderId = (pickup['orderid'] ?? '').toString();
final status = (pickup['orderstatus']?.toString().toLowerCase() ?? '')
.trim();
final isSkipped = status == 'skipped';
final isNext = displayStep == nextStep;
// Save pickup data for dialog
_pickupMap['pickup_$orderId'] = pickup;
// ── A widget, not a bitmap ──
//
// This used to await `_createCircularMarkerBitmap`, a hundred lines that
// opened a `PictureRecorder`, painted a circle and a number onto a canvas,
// rasterised it to PNG bytes and handed the bytes to the platform — per
// marker, per rebuild, asynchronously, because a Google Maps marker is a
// texture and cannot contain a `Text`. A `flutter_map` marker is a widget,
// so the number is just a number, in the app's own font.
//
// The tap that opened the customer sheet moves onto the widget too. The
// `InfoWindow` that carried the name is gone with it: it only appeared on
// tap, covered the route it was anchored to, and said what the sheet the
// same tap opens says properly.
tempMarkers.add(
milerMarker(
point: LatLng(lat, lon),
width: MilerNumberPin.size + 8,
height: MilerNumberPin.size + MilerNumberPin.tail,
child: GestureDetector(
onTap: () => _showCustomerDetailsSheet(pickup, displayStep),
child: MilerNumberPin(
number: displayStep,
color: isSkipped
? ColorConstants.warning
: isNext
? ColorConstants.primary
: ColorConstants.secondaryText,
),
),
),
);
stepPoints.add(_StepPoint(displayStep, LatLng(lat, lon)));
}
if (currentLocation != null) {
tempMarkers.add(
milerMarker(
point: currentLocation!,
width: MilerRiderDot.size,
height: MilerRiderDot.size,
child: const MilerRiderDot(),
),
);
stepPoints.insert(0, _StepPoint(0, currentLocation!));
}
stepPoints.sort((a, b) => a.step.compareTo(b.step));
final newPolylines = <Polyline>[];
_fullRoute.clear();
for (int i = 0; i < stepPoints.length - 1; i++) {
final start = stepPoints[i].position;
final end = stepPoints[i + 1].position;
// OSRM instead of Directions — same answer, no key. See [MilerRouter].
final routePoints = await MilerRouter.route(start, end);
if (routePoints.isNotEmpty) {
_fullRoute.addAll(routePoints);
newPolylines.add(
Polyline(
points: routePoints,
strokeWidth: 6,
// The brand accent, matching the single-stop map. This was
// `Colors.blue`, so the two map screens drew the same route in two
// different colours.
color: ColorConstants.primary,
strokeCap: StrokeCap.round,
strokeJoin: StrokeJoin.round,
),
);
} else {
// A leg the router could not resolve still has to enter the animated
// path, or the trace stops dead at the first unroutable stop.
_fullRoute.addAll([start, end]);
newPolylines.add(
Polyline(
points: [start, end],
strokeWidth: 4,
// Straight-line fallback — lighter, because it is a guess at the
// route rather than the route.
color: ColorConstants.primary.withValues(alpha: 0.35),
pattern: StrokePattern.dashed(segments: const [20, 12]),
),
);
}
}
if (mounted) {
setState(() {
markers = tempMarkers;
// The resolved segments become the faint casing — the route as known —
// and the bright line is then traced over them. Same two-layer treatment
// the single-stop map uses, so the two screens animate identically.
casingLines = [
for (final p in newPolylines)
Polyline(
points: p.points,
color: ColorConstants.primary.withValues(alpha: 0.18),
strokeWidth: 9,
pattern: p.pattern,
strokeCap: StrokeCap.round,
strokeJoin: StrokeJoin.round,
),
];
});
if (_fullRoute.length > 1) {
_buildDistanceTable();
_routeAnim.forward(from: 0);
}
}
await Future.delayed(const Duration(milliseconds: 200));
_fitBoundsToMarkersAndPolylines();
}
/// Distance from the route's start to each point in [_fullRoute].
///
/// The animation walks this table rather than the point list, and that is the
/// difference between the head moving and the head stuttering. A routing API
/// puts vertices where the road bends, so a roundabout can carry twenty of
/// them across thirty metres while a bypass carries two across a kilometre.
/// Stepping by index spends equal time on each, which makes the head crawl
/// through every junction and leap down every straight.
List<double> _cumulative = <double>[];
void _buildDistanceTable() {
_cumulative = List<double>.filled(_fullRoute.length, 0);
if (_fullRoute.length < 2) return;
// Longitude degrees shrink with latitude, so scale them before measuring or
// an east–west leg is over-weighted. Plane geometry is ample here: the table
// only has to get the *ratios* between legs right.
final meanLat =
_fullRoute.map((p) => p.latitude).reduce((a, b) => a + b) /
_fullRoute.length;
final lonScale = math.cos(meanLat * math.pi / 180).abs().clamp(0.05, 1.0);
for (var i = 1; i < _fullRoute.length; i++) {
final a = _fullRoute[i - 1], b = _fullRoute[i];
final dy = b.latitude - a.latitude;
final dx = (b.longitude - a.longitude) * lonScale;
_cumulative[i] = _cumulative[i - 1] + math.sqrt(dy * dy + dx * dx);
}
}
/// The point [f] of the way along the route *by length*, as a segment index
/// plus a fraction into that segment.
(int, double) _atDistance(double f) {
final total = _cumulative.isEmpty ? 0.0 : _cumulative.last;
if (total <= 0) return (0, 0);
final target = (f.clamp(0.0, 1.0)) * total;
var lo = 0, hi = _cumulative.length - 1;
while (lo < hi) {
final mid = (lo + hi) >> 1;
if (_cumulative[mid] <= target) {
lo = mid + 1;
} else {
hi = mid;
}
}
final i = (lo - 1).clamp(0, _fullRoute.length - 2);
final span = _cumulative[i + 1] - _cumulative[i];
return (
i,
span <= 0 ? 0.0 : ((target - _cumulative[i]) / span).clamp(0.0, 1.0),
);
}
/// Reveals the bright line progressively, with an interpolated leading tip.
void _onRouteTick() {
if (!mounted || _fullRoute.length < 2) return;
if (_cumulative.length != _fullRoute.length) _buildDistanceTable();
final t = Curves.easeInOutCubic.transform(_routeAnim.value);
final (idx, frac) = _atDistance(t);
final shown = <LatLng>[..._fullRoute.sublist(0, idx + 1)];
if (idx < _fullRoute.length - 1) {
final a = _fullRoute[idx];
final b = _fullRoute[idx + 1];
shown.add(
LatLng(
a.latitude + (b.latitude - a.latitude) * frac,
a.longitude + (b.longitude - a.longitude) * frac,
),
);
}
progressLine = Polyline(
points: shown,
color: ColorConstants.primary,
strokeWidth: 6,
strokeCap: StrokeCap.round,
strokeJoin: StrokeJoin.round,
);
// Only the map rebuilds — a `setState` here redrew the whole screen, sheet
// and all, sixty times a second to move one line.
_mapRepaint.value++;
}
/// Frames every stop and every metre of route.
///
/// The retry-with-delay dance this replaces existed because
/// `GoogleMapController.animateCamera` threw until the platform view had
/// finished creating itself. `flutter_map`'s camera is available as soon as
/// the map is in the tree.
Future<void> _fitBoundsToMarkersAndPolylines() async {
if (!mounted) return;
final points = <LatLng>[
for (final m in markers) m.point,
for (final poly in routeLayers) ...poly.points,
];
if (points.isEmpty) return;
await _camera.fit(points, padding: const EdgeInsets.all(64));
}
void _showCustomerDetailsSheet(Map<String, dynamic> pickup, int stepNumber) {
showModalBottomSheet(
context: context,
sheetAnimationStyle: kMilerSheetStyle,
isScrollControlled: true,
isDismissible: true,
enableDrag: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (BuildContext context) {
final customerName = (pickup['pickupcustomer'] ?? 'Customer')
.toString();
final address = (pickup['pickupaddress'] ?? 'Address not available')
.toString();
final phone = (pickup['pickupcontactno'] ?? '').toString();
final orderId = (pickup['orderid'] ?? '').toString();
final status = (pickup['orderstatus']?.toString().toLowerCase() ?? '')
.trim();
final isSkipped = status == 'skipped';
return SafeArea(
top: false,
left: false,
right: false,
bottom: true,
child: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: Container(
padding: const EdgeInsets.all(24),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header with title and close icon
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
'Customer Details',
style: TextStyle(
fontSize: 22.sp,
fontWeight: FontWeight.bold,
fontFamily: FontConstants.fontFamily,
color: ColorConstants.primary,
),
),
),
InkWell(
onTap: () => Navigator.of(context).pop(),
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: ColorConstants.dividerColor,
shape: BoxShape.circle,
),
// A close affordance, not a destructive action.
// This was a 34px red `Icons.cancel` — the loudest
// thing in the sheet, competing with the call button
// and reading as "delete this stop".
child: Icon(
Icons.close_rounded,
size: 20.sp,
color: ColorConstants.slateText,
),
),
),
],
),
const Divider(thickness: 1.5),
// Step number and status
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: isSkipped
? ColorConstants.warning
: ColorConstants.primary,
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
child: Text(
'Step $stepNumber',
style: TextStyle(
fontFamily: FontConstants.fontFamily,
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 14.sp,
),
),
),
if (isSkipped) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: ColorConstants.warning,
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
child: Text(
'SKIPPED',
style: TextStyle(
fontFamily: FontConstants.fontFamily,
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 12.sp,
),
),
),
],
],
),
const SizedBox(height: 16),
// Customer name
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.person,
size: 20,
color: ColorConstants.secondaryText,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Customer Name',
style: TextStyle(
fontFamily: FontConstants.fontFamily,
fontSize: 16.sp,
color: ColorConstants.slateText,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
customerName,
style: TextStyle(
fontFamily: FontConstants.fontFamily,
fontSize: 18.sp,
fontWeight: FontWeight.w600,
color: ColorConstants.secondaryText,
),
),
],
),
),
],
),
const SizedBox(height: 16),
// Pickup address
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.location_on,
size: 20,
color: ColorConstants.secondaryText,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Recipient Address',
style: TextStyle(
fontFamily: FontConstants.fontFamily,
fontSize: 16.sp,
color: ColorConstants.slateText,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
address,
style: TextStyle(
fontFamily: FontConstants.fontFamily,
fontSize: 18.sp,
fontWeight: FontWeight.w600,
color: ColorConstants.secondaryText,
),
),
],
),
),
],
),
const SizedBox(height: 16),
// Phone number
if (phone.isNotEmpty)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.phone,
size: 20,
color: ColorConstants.secondaryText,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Contact Number',
style: TextStyle(
fontFamily: FontConstants.fontFamily,
fontSize: 16.sp,
color: ColorConstants.slateText,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
phone,
style: TextStyle(
fontFamily: FontConstants.fontFamily,
fontSize: 18.sp,
fontWeight: FontWeight.w600,
color: ColorConstants.secondaryText,
),
),
],
),
),
],
),
const SizedBox(height: 20),
// Booking ID
Text(
'Booking ID: $orderId',
style: TextStyle(
fontFamily: FontConstants.fontFamily,
fontSize: 16.sp,
color: ColorConstants.slateText,
fontStyle: FontStyle.italic,
),
),
const SizedBox(height: 20),
// Call button
MilerButton(
label: 'Call',
icon: Icons.phone,
color: ColorConstants.acceptGreen,
onPressed: () async {
// Close bottom sheet first
Navigator.of(context).pop();
// Small delay to ensure bottom sheet is closed
await Future.delayed(const Duration(milliseconds: 300));
// Then make the call
final bool success = await launchPhoneDialer(phone);
if (!success && context.mounted) {
AppFeedback.error(context, 'Could not open the dialer');
}
},
),
const SizedBox(height: 8),
],
),
),
),
),
);
},
);
}
@override
void dispose() {
// The class had no dispose before the route animation was added — a ticker
// outliving its State keeps the whole subtree alive and fires into a widget
// that is no longer mounted.
_routeAnim.dispose();
_mapRepaint.dispose();
_camera.dispose();
mapController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
LatLng initialTarget;
double initialZoom = 13;
if (currentLocation != null) {
initialTarget = currentLocation!;
} else if (widget.pickup.isNotEmpty) {
final firstPickup = widget.pickup.first;
final lat = _parseD(firstPickup['droplat'] ?? firstPickup['pickuplat']);
final lon = _parseD(firstPickup['droplon'] ?? firstPickup['pickuplong']);
initialTarget = (lat != 0 && lon != 0)
? LatLng(lat, lon)
: const LatLng(11.0168, 76.9558);
} else {
initialTarget = const LatLng(11.0168, 76.9558);
}
return Scaffold(
// ── A light app bar, not a red slab ──
//
// This was a solid brand-red bar with white text, 80pt tall, built out of
// a PreferredSize wrapping an AppBar whose `leading` was an infinitely
// wide Row holding both the back arrow and the title. Two problems: a
// saturated red band across the top of a map competes with the route drawn
// on it — the map is the content, and the chrome was louder than it — and
// every other pushed screen in the app has a white bar, so this one read
// as a different app.
//
// Inverted, and rebuilt as an ordinary AppBar: white surface, brand red on
// the title where it costs nothing, a real `leading` slot, and the same
// hairline the rest of the app uses to close a bar.
appBar: AppBar(
// The app-wide frosted brand wash — see [milerGlassSurface].
backgroundColor: Colors.transparent,
flexibleSpace: milerGlassSurface(),
surfaceTintColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
titleSpacing: 0,
leading: IconButton(
tooltip: 'Back',
icon: Icon(
Icons.arrow_back_rounded,
color: ColorConstants.slateText,
size: 22.sp,
),
onPressed: () => Navigator.pop(context),
),
title: Text(
'Route map',
style: TextStyle(
fontFamily: FontConstants.fontFamily,
fontSize: 19.sp,
color: ColorConstants.primary,
fontWeight: FontWeight.w800,
letterSpacing: -0.3,
),
),
centerTitle: false,
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: AfterEntrance(
placeholder: const MapPlaceholder(),
builder: (context) => ValueListenableBuilder<int>(
valueListenable: _mapRepaint,
builder: (context, _, _) => MilerMap(
controller: mapController,
initialCenter: initialTarget,
initialZoom: initialZoom,
polylines: routeLayers,
markers: markers,
onReady: () {
_mapReady = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_mapReady) _fitBoundsToMarkersAndPolylines();
});
},
),
),
),
);
}
}

View File

@@ -0,0 +1,853 @@
import 'package:flutter/material.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/app_widgets.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:slider_button_lite/feature/presentation/slider_button/slider.dart';
import 'package:slider_button_lite/feature/presentation/slider_button/slider_button_prop.dart';
class PaymentScreen extends StatefulWidget {
final Map<String, dynamic> pickup;
final Map<String, dynamic> verificationData;
const PaymentScreen({
super.key,
required this.pickup,
required this.verificationData,
});
@override
State<PaymentScreen> createState() => _PaymentScreenState();
}
class _PaymentScreenState extends State<PaymentScreen>
with TickerProviderStateMixin {
String _paymentMethod = 'cash';
bool _isProcessing = false;
bool _isSuccess = false;
bool _paymentVerified = false;
double _pickupCharge = 0.0;
double _totalAmount = 0.0;
late final String _customerName;
late final String _orderId;
late final String _bookingId;
late final String _trackingId;
late final String _weight;
late final String _description;
late AnimationController _successAnimController;
late Animation<double> _successScaleAnim;
late Animation<double> _successFadeAnim;
@override
void initState() {
super.initState();
final d = widget.pickup;
final v = widget.verificationData;
_customerName = (d['pickupcustomer'] ?? 'Customer').toString();
_orderId = (d['orderid'] ?? '').toString();
_bookingId = _orderId.isNotEmpty
? _orderId
: 'DM${DateTime.now().millisecondsSinceEpoch.toString().substring(5)}';
_trackingId =
(d['trackingid'] ??
'TRK${DateTime.now().millisecondsSinceEpoch.toString().substring(4)}')
.toString();
_weight = (v['weight'] ?? '0').toString();
_description = (v['description'] ?? '').toString();
_pickupCharge = double.tryParse(_weight) != null
? double.parse(_weight) * 15.0
: 30.0;
_totalAmount = _pickupCharge;
_successAnimController = AnimationController(
duration: const Duration(milliseconds: 600),
vsync: this,
);
_successScaleAnim = CurvedAnimation(
parent: _successAnimController,
curve: Curves.elasticOut,
);
_successFadeAnim = CurvedAnimation(
parent: _successAnimController,
curve: Curves.easeOut,
);
}
@override
void dispose() {
_successAnimController.dispose();
super.dispose();
}
Future<void> _processPayment() async {
if (_isProcessing) return;
setState(() => _isProcessing = true);
// Simulate backend payment verification
await Future.delayed(const Duration(milliseconds: 1500));
if (!mounted) return;
// Verify payment was successful
_paymentVerified = true;
setState(() {
_isProcessing = false;
_isSuccess = true;
});
// Start success animation
_successAnimController.forward();
// Auto-navigate after showing success (optional)
await Future.delayed(const Duration(milliseconds: 2500));
if (mounted && _isSuccess) {
_completePayment();
}
}
void _completePayment({bool goToBookings = false}) {
if (!mounted) return;
Navigator.pop(context, {
'paid': true,
'goto_bookings': goToBookings,
'verified': true,
});
}
@override
Widget build(BuildContext context) {
if (_isSuccess) return _buildSuccessView();
return _buildPaymentView();
}
// ────────────────────────────── SUCCESS VIEW ─────────────────────────────
Widget _buildSuccessView() {
return Scaffold(
backgroundColor: ColorConstants.surface,
body: SafeArea(
child: FadeTransition(
opacity: _successFadeAnim,
child: Padding(
padding: EdgeInsets.all(24.r),
child: Column(
children: [
const Spacer(flex: 2),
// Success animation circle
ScaleTransition(
scale: _successScaleAnim,
child: Container(
width: 110.r,
height: 110.r,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
ColorConstants.success,
ColorConstants.acceptGreen,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: ColorConstants.success.withValues(alpha: 0.3),
blurRadius: 20,
offset: const Offset(0, 8),
),
],
),
child: Icon(
Icons.check_rounded,
color: Colors.white,
size: 52.sp,
),
),
),
SizedBox(height: 24.h),
Text(
'Payment Collected Successfully',
style: TextStyle(
fontSize: 26.sp,
fontWeight: FontWeight.w900,
color: ColorConstants.onSurface,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 8.h),
Text(
'₹${_totalAmount.toStringAsFixed(0)} via ${_paymentMethod.toUpperCase()}',
style: TextStyle(
fontSize: 15.sp,
color: ColorConstants.onSurfaceVariant,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 32.h),
// Receipt Card - Corporate Design
Container(
width: double.infinity,
decoration: BoxDecoration(
color: ColorConstants.pureSurface,
borderRadius: BorderRadius.circular(
DesignConstants.radiusXl,
),
border: Border.all(color: ColorConstants.outlineVariant),
boxShadow: DesignConstants.shadowSm,
),
child: Column(
children: [
// Receipt header
Container(
padding: EdgeInsets.all(16.r),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
ColorConstants.primary.withValues(alpha: 0.08),
ColorConstants.primary.withValues(alpha: 0.03),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.vertical(
top: Radius.circular(18.r),
),
),
child: Row(
children: [
Container(
padding: EdgeInsets.all(10.r),
decoration: BoxDecoration(
color: ColorConstants.primaryContainer
.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(
DesignConstants.radiusLg,
),
),
child: Icon(
Icons.receipt_long_rounded,
color: ColorConstants.primary,
size: 18.sp,
),
),
SizedBox(width: 10.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Payment Receipt',
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w700,
color: ColorConstants.onSurface,
fontFamily: FontConstants.fontFamily,
),
),
Text(
'Payment verified and recorded',
style: TextStyle(
fontSize: 12.sp,
color: ColorConstants.onSurfaceVariant,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
],
),
),
Padding(
padding: EdgeInsets.all(16.r),
child: Column(
children: [
_receiptRow('Booking ID', '#$_bookingId'),
SizedBox(height: 10.h),
Divider(
color: ColorConstants.outlineVariant,
height: 1,
),
SizedBox(height: 10.h),
_receiptRow('Tracking ID', _trackingId),
SizedBox(height: 10.h),
Divider(
color: ColorConstants.outlineVariant,
height: 1,
),
SizedBox(height: 10.h),
_receiptRow('Customer', _customerName),
SizedBox(height: 10.h),
Divider(
color: ColorConstants.outlineVariant,
height: 1,
),
SizedBox(height: 10.h),
_receiptRow(
'Amount Collected',
'₹${_totalAmount.toStringAsFixed(0)}',
valueColor: ColorConstants.success,
valueBold: true,
),
],
),
),
],
),
),
const Spacer(flex: 2),
// Action Buttons
Column(
children: [
// Next Booking Button
MilerButton(
label: 'Move to next booking',
onPressed: () => _completePayment(goToBookings: true),
),
SizedBox(height: 12.h),
TextButton(
onPressed: () => _completePayment(goToBookings: false),
child: Text(
'Return to Collection',
style: TextStyle(
fontSize: 14.sp,
color: ColorConstants.onSurfaceVariant,
fontFamily: FontConstants.fontFamily,
),
),
),
],
),
SizedBox(height: 16.h),
],
),
),
),
),
);
}
Widget _receiptRow(
String label,
String value, {
Color? valueColor,
bool valueBold = false,
}) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: TextStyle(
fontSize: 13.sp,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
Text(
value,
style: TextStyle(
fontSize: 14.sp,
fontWeight: valueBold ? FontWeight.w700 : FontWeight.w600,
color: valueColor ?? ColorConstants.onSurface,
fontFamily: FontConstants.fontFamily,
),
),
],
);
}
// ────────────────────────────── PAYMENT VIEW ─────────────────────────────
Widget _buildPaymentView() {
return Scaffold(
backgroundColor: ColorConstants.surface,
body: Column(
children: [
// ── Premium Gradient Header ──────────────────────────────────
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
ColorConstants.primary,
ColorConstants.primaryContainer,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: SafeArea(
bottom: false,
child: Column(
children: [
// App bar row
Padding(
padding: EdgeInsets.symmetric(
horizontal: 16.w,
vertical: 8.h,
),
child: Row(
children: [
GestureDetector(
onTap: () => Navigator.pop(context),
child: Container(
width: 40.w,
height: 40.w,
decoration: BoxDecoration(
color: ColorConstants.pureSurface.withValues(
alpha: 0.15,
),
shape: BoxShape.circle,
),
child: Icon(
Icons.arrow_back_ios_new_rounded,
color: Colors.white,
size: 16.sp,
),
),
),
SizedBox(width: 12.w),
Text(
'Payment Collection',
style: TextStyle(
fontSize: 20.sp,
fontWeight: FontWeight.w800,
color: Colors.white,
fontFamily: FontConstants.fontFamily,
letterSpacing: 0.5,
),
),
],
),
),
// Amount display
Padding(
padding: EdgeInsets.fromLTRB(20.w, 8.h, 20.w, 24.h),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Amount to Collect',
style: TextStyle(
fontSize: 12.sp,
color: Colors.white.withValues(alpha: 0.75),
fontFamily: FontConstants.fontFamily,
letterSpacing: 0.3,
),
),
SizedBox(height: 4.h),
Text(
'₹${_totalAmount.toStringAsFixed(0)}',
style: TextStyle(
fontSize: 36.sp,
fontWeight: FontWeight.w900,
color: Colors.white,
fontFamily: FontConstants.fontFamily,
letterSpacing: -1,
),
),
],
),
Container(
padding: EdgeInsets.symmetric(
horizontal: 12.w,
vertical: 8.h,
),
decoration: BoxDecoration(
color: ColorConstants.pureSurface.withValues(
alpha: 0.15,
),
borderRadius: BorderRadius.circular(
DesignConstants.radiusLg,
),
border: Border.all(
color: Colors.white.withValues(alpha: 0.2),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'#$_orderId',
style: TextStyle(
fontSize: 12.sp,
fontWeight: FontWeight.w700,
color: Colors.white,
fontFamily: FontConstants.fontFamily,
),
),
Text(
_customerName,
style: TextStyle(
fontSize: 11.sp,
color: Colors.white.withValues(alpha: 0.8),
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
],
),
),
],
),
),
),
// ── Scrollable body ──────────────────────────────────────────
Expanded(
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: EdgeInsets.all(16.r),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: 4.h),
// ── Booking Summary Card ──────────────────────────────
_sectionLabel('Booking Summary'),
SizedBox(height: 10.h),
Container(
width: double.infinity,
padding: EdgeInsets.all(16.r),
decoration: _cardDecoration(),
child: Column(
children: [
_summaryRow('Customer', _customerName),
_divider(),
_summaryRow('Booking', '#$_orderId'),
_divider(),
_summaryRow('Weight', '$_weight kg'),
if (_description.isNotEmpty) ...[
_divider(),
_summaryRow('Item', _description),
],
_divider(),
_summaryRow(
'Collection Charge',
'₹${_pickupCharge.toStringAsFixed(2)}',
isBold: true,
),
Padding(
padding: EdgeInsets.symmetric(vertical: 8.h),
child: Container(
height: 1,
color: ColorConstants.primary.withValues(
alpha: 0.3,
),
),
),
_summaryRow(
'Total',
'₹${_totalAmount.toStringAsFixed(2)}',
isBold: true,
isTotal: true,
),
],
),
),
SizedBox(height: 20.h),
// ── Payment Method ──────────────────────────────────
_sectionLabel('Payment Method'),
SizedBox(height: 10.h),
_buildPaymentOption(
Icons.money_rounded,
'Cash',
'Pay with cash on collection',
'cash',
ColorConstants.success,
),
SizedBox(height: 10.h),
_buildPaymentOption(
Icons.qr_code_scanner_rounded,
'QR / UPI',
'Scan QR code to pay',
'upi',
ColorConstants.upiPurple,
),
SizedBox(height: 10.h),
_buildPaymentOption(
Icons.credit_card_rounded,
'Card',
'Swipe / tap card terminal',
'card',
ColorConstants.tertiary,
),
SizedBox(height: 28.h),
// ── Slide to Collect Button ─────────────────────────
_isProcessing
? Container(
height: ButtonSizes.primary,
decoration: BoxDecoration(
color: ColorConstants.primary,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
),
child: const Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
),
),
)
: ClipRRect(
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
child: Container(
color: ColorConstants.primary,
child: SliderButton(
properties: SliderButtonProperties(
radius: 24,
height: ButtonSizes.primary,
buttonSize: 48,
width: MediaQuery.of(context).size.width - 32.w,
backgroundColor: Colors.transparent,
dismissThresholds: 0.85,
action: () async {
debugPrint(
'[PAYMENT_SLIDER] Action called, processing payment',
);
await _processPayment();
debugPrint(
'[PAYMENT_SLIDER] _processPayment completed, returning true',
);
return true;
},
label: Text(
'Slide to Collect ₹${_totalAmount.toStringAsFixed(0)}',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w700,
color: Colors.white,
fontFamily: FontConstants.fontFamily,
),
),
icon: Container(
margin: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: ColorConstants.pureSurface,
shape: BoxShape.circle,
),
child: Icon(
Icons.payments_rounded,
size: 22.sp,
color: ColorConstants.primary,
),
),
),
),
),
),
SizedBox(height: 10.h),
Center(
child: Text(
'Collect from customer before starting pickup',
style: TextStyle(
fontSize: 12.sp,
color: ColorConstants.onSurfaceVariant,
fontFamily: FontConstants.fontFamily,
),
),
),
SizedBox(height: 20.h),
],
),
),
),
],
),
);
}
Widget _sectionLabel(String label) {
return Text(
label,
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w700,
color: ColorConstants.onSurfaceVariant,
fontFamily: FontConstants.fontFamily,
letterSpacing: 0.5,
),
);
}
Widget _buildPaymentOption(
IconData icon,
String title,
String subtitle,
String value,
Color accentColor,
) {
final isSelected = _paymentMethod == value;
return GestureDetector(
onTap: () => setState(() => _paymentMethod = value),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: double.infinity,
padding: EdgeInsets.all(14.r),
decoration: BoxDecoration(
color: isSelected
? accentColor.withValues(alpha: 0.06)
: ColorConstants.pureSurface,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
border: Border.all(
color: isSelected ? accentColor : ColorConstants.outlineVariant,
width: isSelected ? 2 : 1,
),
boxShadow: isSelected
? [
BoxShadow(
color: accentColor.withValues(alpha: 0.1),
blurRadius: 10,
offset: const Offset(0, 4),
),
]
: [],
),
child: Row(
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: EdgeInsets.all(10.r),
decoration: BoxDecoration(
color: isSelected
? accentColor.withValues(alpha: 0.12)
: const Color(0xFFF3F4F6),
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
child: Icon(
icon,
color: isSelected
? accentColor
: ColorConstants.onSurfaceVariant,
size: 22.sp,
),
),
SizedBox(width: 14.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w700,
color: ColorConstants.onSurface,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 2.h),
Text(
subtitle,
style: TextStyle(
fontSize: 12.sp,
color: ColorConstants.onSurfaceVariant,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 22.w,
height: 22.w,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isSelected ? accentColor : Colors.transparent,
border: Border.all(
color: isSelected ? accentColor : const Color(0xFFD1D5DB),
width: isSelected ? 0 : 2,
),
),
child: isSelected
? Icon(Icons.check_rounded, color: Colors.white, size: 14.sp)
: null,
),
],
),
),
);
}
Widget _summaryRow(
String label,
String value, {
bool isBold = false,
bool isTotal = false,
}) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 5.h),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: TextStyle(
fontSize: isTotal ? 15.sp : 13.sp,
color: ColorConstants.onSurfaceVariant,
fontWeight: isBold ? FontWeight.w600 : FontWeight.normal,
fontFamily: FontConstants.fontFamily,
),
),
Text(
value,
style: TextStyle(
fontSize: isTotal ? 17.sp : 14.sp,
fontWeight: isTotal
? FontWeight.w800
: (isBold ? FontWeight.w700 : FontWeight.w500),
color: isTotal
? ColorConstants.primary
: ColorConstants.onSurface,
fontFamily: FontConstants.fontFamily,
),
),
],
),
);
}
Widget _divider() => Padding(
padding: EdgeInsets.symmetric(vertical: 2.h),
child: Divider(color: ColorConstants.outlineVariant, height: 1),
);
BoxDecoration _cardDecoration() {
return BoxDecoration(
color: ColorConstants.pureSurface,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
border: Border.all(color: ColorConstants.outlineVariant),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.03),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
);
}
}

View File

@@ -0,0 +1,908 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.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:flutter_screenutil/flutter_screenutil.dart';
import 'package:miler/views/Dashboard/pickups/stop_type.dart';
import 'package:slider_button_lite/feature/presentation/slider_button/slider.dart';
import 'package:slider_button_lite/feature/presentation/slider_button/slider_button_prop.dart';
class PickupVerificationPage extends StatefulWidget {
final Map<String, dynamic> pickup;
const PickupVerificationPage({super.key, required this.pickup});
@override
State<PickupVerificationPage> createState() => _PickupVerificationPageState();
}
class _PickupVerificationPageState extends State<PickupVerificationPage>
with TickerProviderStateMixin {
File? _parcelImage;
final ImagePicker _picker = ImagePicker();
bool _isConfirming = false;
bool _photoVerified = false;
// Form controllers
final TextEditingController _weightController = TextEditingController();
final TextEditingController _descriptionController = TextEditingController();
final TextEditingController _otpController = TextEditingController();
final TextEditingController _dropAddressController = TextEditingController();
late final String _customerName;
late final String _pickupAddress;
late final String _orderId;
late final String _dropAddressDefault;
// Delivery stops (Amazon/Flipkart-style) verify differently: proof is
// OTP + photo (prepaid, no weight/signature). Pickups keep the full form.
late final bool _isDelivery;
late AnimationController _fadeController;
late Animation<double> _fadeAnim;
// Red Theme Colors
static Color get _primaryRed => ColorConstants.primary;
static Color get _darkRed => ColorConstants.primary;
static Color get _lightRed => ColorConstants.errorLight;
static Color get _successGreen => ColorConstants.acceptGreen;
@override
void initState() {
super.initState();
final d = widget.pickup;
_customerName = (d['PickupCustomer'] ?? d['pickupcustomer'] ?? 'Customer')
.toString();
_pickupAddress =
(d['pickupaddress'] ?? d['PickupAddress'] ?? 'Pickup Location')
.toString();
_orderId = (d['orderid'] ?? '').toString();
_dropAddressDefault =
(d['DropAddress'] ?? d['dropaddress'] ?? 'Drop Location').toString();
_dropAddressController.text = _dropAddressDefault;
_isDelivery = stopKindOf(d).isDelivery;
_fadeController = AnimationController(
duration: const Duration(milliseconds: 500),
vsync: this,
);
_fadeAnim = CurvedAnimation(parent: _fadeController, curve: Curves.easeOut);
_fadeController.forward();
}
@override
void dispose() {
_weightController.dispose();
_descriptionController.dispose();
_otpController.dispose();
_dropAddressController.dispose();
_fadeController.dispose();
super.dispose();
}
Future<void> _takeParcelPhoto() async {
try {
final XFile? photo = await _picker.pickImage(
source: ImageSource.camera,
imageQuality: 85,
);
if (photo != null && mounted) {
setState(() {
_parcelImage = File(photo.path);
_photoVerified = true;
});
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to capture photo')),
);
}
}
}
// Required proof per stop is kept light: Photo + Code (pickup) / Photo + OTP
// (delivery). Weight is optional — no keyboard entry needed at every doorstep.
bool get _isFormValid => _isDelivery
? (_photoVerified && _otpController.text.trim().isNotEmpty)
: (_photoVerified && _otpController.text.trim().isNotEmpty);
void _confirmPickup() {
if (!_isFormValid) {
String msg = 'Please complete all required fields';
if (!_photoVerified) {
msg = _isDelivery
? 'Take a photo of the delivered parcel first'
: 'Take a photo of the parcel first';
} else if (_isDelivery && _otpController.text.trim().isEmpty) {
msg = 'Enter the delivery OTP from the customer';
} else if (!_isDelivery && _otpController.text.trim().isEmpty) {
msg = 'Enter the pickup code from the customer';
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(msg),
backgroundColor: _primaryRed,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
),
);
return;
}
setState(() => _isConfirming = true);
final verified = {
'verified': true,
'parcelImage': _parcelImage?.path,
'weight': _weightController.text.trim(),
'description': _descriptionController.text.trim(),
'dropAddress': _dropAddressController.text.trim(),
'otp': _otpController.text.trim(),
};
debugPrint(
'[PICKUP_VERIFY] Confirming pickup and popping with verified data',
);
// Pop with verified data
Navigator.pop(context, verified);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: ColorConstants.daylightSurface,
appBar: _buildAppBar(),
body: FadeTransition(
opacity: _fadeAnim,
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: EdgeInsets.fromLTRB(16.w, 12.h, 16.w, 32.h),
child: SizedBox(
width: MediaQuery.of(context).size.width - 32.w,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildProgressSteps(),
SizedBox(height: 20.h),
_buildCustomerCard(),
SizedBox(height: 20.h),
_buildSectionHeader(
icon: Icons.camera_alt_rounded,
title: _isDelivery ? 'Delivery Photo' : 'Parcel Photo',
badge: 'Required',
isComplete: _photoVerified,
),
SizedBox(height: 10.h),
_buildPhotoCapture(),
SizedBox(height: 20.h),
// Weight is pickup-only. Deliveries are prepaid and proven by
// OTP + photo. Signature was dropped — photo is enough proof for
// a first-mile pickup, so it was pure friction on every stop.
if (!_isDelivery) ...[
_buildSectionHeader(
icon: Icons.inventory_2_rounded,
title: 'Parcel Details (Optional)',
isComplete: _weightController.text.isNotEmpty,
),
SizedBox(height: 10.h),
_buildParcelDetailsCard(),
SizedBox(height: 20.h),
],
_buildSectionHeader(
icon: Icons.lock_outline_rounded,
title: _isDelivery ? 'Delivery OTP' : 'Pickup Code',
badge: 'Required',
isComplete: _otpController.text.isNotEmpty,
),
SizedBox(height: 10.h),
_buildCard([
TextField(
controller: _otpController,
keyboardType: TextInputType.number,
maxLength: 6,
onChanged: (_) => setState(() {}),
decoration:
_inputDecoration(
_isDelivery
? 'Enter OTP from customer'
: 'Enter code from customer',
).copyWith(
counterText: '',
prefixIcon: Icon(
Icons.lock_outline_rounded,
color: ColorConstants.secondaryText,
size: 20.sp,
),
),
style: TextStyle(
fontSize: 15.sp,
fontFamily: FontConstants.fontFamily,
),
),
]),
SizedBox(height: 32.h),
_buildConfirmSlider(),
SizedBox(height: 20.h),
],
),
),
),
),
);
}
AppBar _buildAppBar() {
return AppBar(
backgroundColor: ColorConstants.pureSurface,
elevation: 0,
centerTitle: false,
surfaceTintColor: Colors.transparent,
// 56 → 68, the same air the tab bars gained. Height rather than a title
// inset so the back arrow moves down with the heading instead of the two
// drifting apart.
toolbarHeight: 68,
leading: IconButton(
tooltip: 'Back',
icon: Icon(
Icons.arrow_back_ios_new_rounded,
color: ColorConstants.onSurface,
size: 20.sp,
),
onPressed: () => Navigator.pop(context),
),
title: Row(
children: [
// No brand mark. It is gone from every bar in the app: on a pushed
// sub-page the back arrow beside it already says where the rider is,
// and the logo was repeating what the screen he came from said.
Text(
_isDelivery ? 'Verify Delivery' : 'Verify Pickup',
style: TextStyle(
fontSize: 21.sp,
color: ColorConstants.onSurface,
fontWeight: FontWeight.w800,
fontFamily: FontConstants.fontFamily,
),
),
],
),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(1),
child: Container(height: 1, color: ColorConstants.dividerColor),
),
);
}
Widget _buildProgressSteps() {
final steps = _isDelivery
? [
{'label': 'Photo', 'done': _photoVerified},
{'label': 'OTP', 'done': _otpController.text.isNotEmpty},
]
: [
{'label': 'Photo', 'done': _photoVerified},
{'label': 'Code', 'done': _otpController.text.isNotEmpty},
];
return Container(
padding: EdgeInsets.symmetric(horizontal: 4.w, vertical: 12.h),
decoration: BoxDecoration(
color: ColorConstants.pureSurface,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
border: Border.all(color: ColorConstants.dividerColor),
),
child: Row(
children: List.generate(steps.length * 2 - 1, (i) {
if (i.isOdd) {
final leftDone = steps[i ~/ 2]['done'] as bool;
return Expanded(
child: Container(
height: 2.h,
margin: EdgeInsets.symmetric(horizontal: 4.w),
decoration: BoxDecoration(
color: leftDone ? _successGreen : ColorConstants.dividerColor,
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
),
);
}
final idx = i ~/ 2;
final step = steps[idx];
final done = step['done'] as bool;
final label = step['label'] as String;
return Expanded(
child: Column(
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 300),
width: 32.w,
height: 32.w,
decoration: BoxDecoration(
color: done ? _successGreen : ColorConstants.neutralLight,
shape: BoxShape.circle,
border: Border.all(
color: done ? _successGreen : ColorConstants.dividerColor,
width: 2,
),
),
child: Icon(
done ? Icons.check_rounded : Icons.circle_outlined,
color: done ? Colors.white : ColorConstants.secondaryText,
size: 16.sp,
),
),
SizedBox(height: 4.h),
Text(
label,
style: TextStyle(
fontSize: 10.sp,
fontWeight: done ? FontWeight.w700 : FontWeight.w500,
color: done
? _successGreen
: ColorConstants.onSurfaceVariant,
fontFamily: FontConstants.fontFamily,
),
),
],
),
);
}),
),
);
}
Widget _buildCustomerCard() {
final initials = _customerName.trim().isNotEmpty
? _customerName
.trim()
.split(' ')
.map((w) => w.isNotEmpty ? w[0] : '')
.take(2)
.join()
.toUpperCase()
: 'C';
return Container(
padding: EdgeInsets.all(16.r),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [_primaryRed, _darkRed],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
boxShadow: [
BoxShadow(
color: _primaryRed.withValues(alpha: 0.25),
blurRadius: 16,
offset: const Offset(0, 6),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 44.w,
height: 44.w,
decoration: BoxDecoration(
color: ColorConstants.pureSurface.withValues(alpha: 0.2),
shape: BoxShape.circle,
border: Border.all(
color: Colors.white.withValues(alpha: 0.4),
),
),
child: Center(
child: Text(
initials,
style: TextStyle(
color: Colors.white,
fontSize: 16.sp,
fontWeight: FontWeight.w800,
fontFamily: FontConstants.fontFamily,
),
),
),
),
SizedBox(width: 12.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_customerName,
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w800,
color: Colors.white,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 2.h),
Text(
'Booking #$_orderId',
style: TextStyle(
fontSize: 12.sp,
color: Colors.white.withValues(alpha: 0.8),
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
],
),
SizedBox(height: 12.h),
Container(
height: 1,
color: ColorConstants.pureSurface.withValues(alpha: 0.2),
),
SizedBox(height: 12.h),
_buildLocationRow(
Icons.location_on_rounded,
_pickupAddress,
_isDelivery ? 'DELIVERY' : 'PICKUP',
ColorConstants.warning.withValues(alpha: 0.40),
),
],
),
);
}
Widget _buildLocationRow(
IconData icon,
String address,
String label,
Color accentColor,
) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: EdgeInsets.all(5.r),
decoration: BoxDecoration(
color: ColorConstants.pureSurface.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
child: Icon(icon, size: 14.sp, color: accentColor),
),
SizedBox(width: 10.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 9.sp,
fontWeight: FontWeight.w700,
color: accentColor,
letterSpacing: 0.8,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 1.h),
Text(
address,
style: TextStyle(
fontSize: 12.sp,
color: Colors.white.withValues(alpha: 0.9),
fontFamily: FontConstants.fontFamily,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
);
}
Widget _buildSectionHeader({
required IconData icon,
required String title,
String? badge,
bool isComplete = false,
}) {
return Row(
children: [
Container(
padding: EdgeInsets.all(7.r),
decoration: BoxDecoration(
color: isComplete
? ColorConstants.successLight
: ColorConstants.neutralLight,
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
child: Icon(
isComplete ? Icons.check_circle_rounded : icon,
size: 16.sp,
color: isComplete ? _successGreen : ColorConstants.onSurfaceVariant,
),
),
SizedBox(width: 10.w),
Expanded(
child: Text(
title,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w700,
color: ColorConstants.onSurface,
fontFamily: FontConstants.fontFamily,
),
),
),
if (badge != null)
Container(
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 3.h),
decoration: BoxDecoration(
color: _lightRed,
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
child: Text(
badge,
style: TextStyle(
fontSize: 10.sp,
fontWeight: FontWeight.w700,
color: _primaryRed,
fontFamily: FontConstants.fontFamily,
),
),
),
],
);
}
Widget _buildPhotoCapture() {
return GestureDetector(
onTap: _takeParcelPhoto,
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
width: double.infinity,
height: 180.h,
decoration: BoxDecoration(
color: _photoVerified
? Colors.transparent
: ColorConstants.pureSurface,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
border: Border.all(
color: _photoVerified ? _successGreen : ColorConstants.borderSubtle,
width: _photoVerified ? 2 : 1,
),
boxShadow: _photoVerified
? [
BoxShadow(
color: _successGreen.withValues(alpha: 0.1),
blurRadius: 12,
offset: const Offset(0, 4),
),
]
: [],
),
child: _parcelImage != null
? ClipRRect(
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
child: Stack(
fit: StackFit.expand,
children: [
Image.file(_parcelImage!, fit: BoxFit.cover),
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 14.w,
vertical: 10.h,
),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
Colors.black.withValues(alpha: 0.6),
Colors.transparent,
],
),
),
child: Row(
children: [
Icon(
Icons.check_circle_rounded,
color: Colors.white,
size: 16.sp,
),
SizedBox(width: 6.w),
Text(
'Photo captured — tap to retake',
style: TextStyle(
color: Colors.white,
fontSize: 12.sp,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
),
Positioned(
top: 10.h,
right: 10.w,
child: Container(
padding: EdgeInsets.all(6.r),
decoration: BoxDecoration(
color: _successGreen,
shape: BoxShape.circle,
),
child: Icon(
Icons.check_rounded,
color: Colors.white,
size: 14.sp,
),
),
),
],
),
)
: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: EdgeInsets.all(18.r),
decoration: BoxDecoration(
color: ColorConstants.neutralLight,
shape: BoxShape.circle,
),
child: Icon(
Icons.camera_alt_rounded,
color: ColorConstants.secondaryText,
size: 36.sp,
),
),
SizedBox(height: 10.h),
Text(
'Tap to take parcel photo',
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w600,
color: ColorConstants.onSurfaceVariant,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 4.h),
Text(
'Required for pickup confirmation',
style: TextStyle(
fontSize: 11.sp,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
);
}
Widget _buildParcelDetailsCard() {
return _buildCard([
_fieldLabel('Weight (kg)'),
SizedBox(height: 6.h),
TextField(
controller: _weightController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
onChanged: (_) => setState(() {}),
decoration: _inputDecoration('e.g. 2.5').copyWith(
suffixText: 'kg',
suffixStyle: TextStyle(
fontSize: 13.sp,
color: ColorConstants.onSurfaceVariant,
fontFamily: FontConstants.fontFamily,
),
),
style: TextStyle(fontSize: 15.sp, fontFamily: FontConstants.fontFamily),
),
SizedBox(height: 16.h),
_fieldLabel('Description (Optional)'),
SizedBox(height: 6.h),
TextField(
controller: _descriptionController,
maxLines: 3,
decoration: _inputDecoration('Item description, fragile, etc.'),
style: TextStyle(fontSize: 15.sp, fontFamily: FontConstants.fontFamily),
),
SizedBox(height: 16.h),
]);
}
Widget _fieldLabel(String label, {bool required = false}) {
return Row(
children: [
Text(
label,
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w600,
color: ColorConstants.onSurface,
fontFamily: FontConstants.fontFamily,
),
),
if (required) ...[
SizedBox(width: 4.w),
Text(
'*',
style: TextStyle(
fontSize: 13.sp,
color: _primaryRed,
fontWeight: FontWeight.w700,
fontFamily: FontConstants.fontFamily,
),
),
],
],
);
}
Widget _buildConfirmSlider() {
if (_isConfirming) {
return Container(
height: ButtonSizes.primary,
decoration: BoxDecoration(
color: ColorConstants.primary,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
),
child: const Center(
child: SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
),
),
);
}
if (!_isFormValid) {
return Container(
height: ButtonSizes.primary,
decoration: BoxDecoration(
color: ColorConstants.dividerColor,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.lock_outline_rounded,
color: ColorConstants.secondaryText,
size: 18.sp,
),
SizedBox(width: 8.w),
Text(
!_photoVerified
? 'Take a photo to continue'
: _otpController.text.isEmpty
? (_isDelivery
? 'Enter OTP to continue'
: 'Enter pickup code to continue')
: 'Complete all fields to continue',
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w600,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
],
),
);
}
return SliderButton(
properties: SliderButtonProperties(
height: ButtonSizes.primary,
buttonSize: 48,
width: MediaQuery.of(context).size.width - 32.w,
backgroundColor: ColorConstants.primary,
dismissThresholds: 0.85,
action: () async {
debugPrint(
'[PICKUP_VERIFY_SLIDER] Action called, form valid: $_isFormValid',
);
if (!_isFormValid) {
debugPrint(
'[PICKUP_VERIFY_SLIDER] Form not valid, returning false',
);
return false;
}
_confirmPickup();
debugPrint(
'[PICKUP_VERIFY_SLIDER] _confirmPickup called, returning true',
);
return true;
},
label: Text(
'Slide to Confirm Pickup',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w700,
color: Colors.white,
fontFamily: FontConstants.fontFamily,
),
),
icon: Container(
margin: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: ColorConstants.pureSurface,
shape: BoxShape.circle,
),
child: Icon(
Icons.check_rounded,
size: 22.sp,
color: ColorConstants.primary,
),
),
),
);
}
Widget _buildCard(List<Widget> children) {
return Container(
width: double.infinity,
padding: EdgeInsets.all(16.r),
decoration: BoxDecoration(
color: ColorConstants.pureSurface,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
border: Border.all(color: ColorConstants.borderSubtle),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: children,
),
);
}
InputDecoration _inputDecoration(String hint) {
return InputDecoration(
hintText: hint,
hintStyle: TextStyle(
fontSize: 14.sp,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
filled: true,
fillColor: ColorConstants.daylightSurface,
contentPadding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 12.h),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
borderSide: BorderSide(color: ColorConstants.borderStrong),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
borderSide: BorderSide(color: ColorConstants.borderStrong),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
borderSide: BorderSide(color: ColorConstants.primary, width: 1.5),
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,173 @@
part of 'pickups.dart';
// -------------------------------------------------------------------------
// PIP INFO CARD (Shown when PiP mode is enabled)
// -------------------------------------------------------------------------
class PipInfoCard extends StatefulWidget {
final String orderId;
final int etaMinutes; // ETA in minutes from API (always treat as minutes)
const PipInfoCard({
super.key,
required this.orderId,
required this.etaMinutes,
});
@override
State<PipInfoCard> createState() => _PipInfoCardState();
}
class _PipInfoCardState extends State<PipInfoCard> {
late final CountDownController _controller;
int _durationSeconds = 0;
@override
void initState() {
super.initState();
_controller = CountDownController();
// Load remaining ETA from SharedPreferences so timer doesn't reset
_initDuration();
}
Future<void> _initDuration() async {
try {
final prefs = await SharedPreferences.getInstance();
final endKey = 'eta_endtime_${widget.orderId}';
final endSeconds = prefs.getInt(endKey);
final nowSeconds = DateTime.now().millisecondsSinceEpoch ~/ 1000;
int remaining = 0;
if (endSeconds != null && endSeconds > nowSeconds) {
remaining = endSeconds - nowSeconds;
} else {
// Fallback: use full ETA from API
final int safeEtaMinutes = widget.etaMinutes < 0
? 0
: widget.etaMinutes;
remaining = safeEtaMinutes * 60;
}
if (!mounted) return;
setState(() {
_durationSeconds = remaining.clamp(0, 24 * 60 * 60);
});
} catch (_) {
// In case of error, just fall back to raw ETA minutes
final int safeEtaMinutes = widget.etaMinutes < 0 ? 0 : widget.etaMinutes;
if (!mounted) return;
setState(() {
_durationSeconds = safeEtaMinutes * 60;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
// Expanded PiP: timer + key pickup details, but still relatively compact
body: Center(
child: widget.orderId.isEmpty || widget.orderId == 'N/A'
? Text(
'No Active Booking',
style: TextStyle(
fontSize: 12,
fontFamily: FontConstants.fontFamily,
color: ColorConstants.primary,
),
)
: Card(
color: Colors.white,
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
child: Container(
width: 220,
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 6,
),
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.center,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Timer
SizedBox(
width: 72,
height: 72,
child: _durationSeconds <= 0
? Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: ColorConstants.primary,
width: 3,
),
color: Colors.white,
),
alignment: Alignment.center,
child: Text(
'Out',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: ColorConstants.primary,
fontFamily: FontConstants.fontFamily,
),
),
)
: CircularCountDownTimer(
duration: _durationSeconds,
initialDuration: 0,
controller: _controller,
width: 72,
height: 72,
ringColor: ColorConstants.primary.withValues(
alpha: 0.15,
),
fillColor: ColorConstants.primary,
backgroundColor: Colors.white,
strokeWidth: 5,
strokeCap: StrokeCap.round,
textStyle: TextStyle(
fontSize: 17,
fontWeight: FontWeight.bold,
color: ColorConstants.primary,
fontFamily: FontConstants.fontFamily,
),
isReverse: true,
isReverseAnimation: true,
isTimerTextShown: true,
autoStart: true,
timeFormatterFunction:
(defaultFormatter, duration) {
return duration.inSeconds <= 0
? 'Out'
: defaultFormatter(duration);
},
),
),
const SizedBox(height: 4),
// Active Booking id
Text(
'Active Booking ID: ${widget.orderId}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: ColorConstants.onSurface,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,189 @@
import 'dart:math' as math;
/// Live per-stop route metrics — how far away the next stop is, and when the
/// rider will get there.
///
/// Why this is its own file: distance was previously formatted in three
/// different places with three different rules, and always in kilometres. A
/// stop 80 metres away rendered as `0.1 km`, which is useless when you are
/// looking for a gate. Riders think in **metres up close and kilometres far
/// away**, so the unit has to switch at the threshold they actually use.
///
/// Everything here is pure — no Flutter, no I/O — so it is unit-testable and
/// safe to call from `build`.
class RouteMetricsHelper {
RouteMetricsHelper._();
/// Below this many metres, show metres. At or above it, show kilometres.
static const double kMetreThreshold = 1000;
/// Fallback speed when the rider's live speed is unknown or unusable:
/// 20 km/h, a realistic urban two-wheeler average once lights, turns and
/// traffic are counted. Riding speed alone would promise ETAs he can't hit.
static const double kDefaultSpeedMps = 5.55; // 20 km/h
/// Speeds below this are noise (stopped at a light, GPS jitter) and must not
/// drive an ETA — at 0.5 m/s a 2 km leg would read as over an hour.
static const double kMinUsableSpeedMps = 1.5; // ~5.4 km/h
/// Clamp on live speed. A GPS spike of 200 km/h on a bike is a bad fix, not
/// a fast rider, and would promise an ETA that has already passed.
static const double kMaxUsableSpeedMps = 22.0; // ~79 km/h
// ── Distance ───────────────────────────────────────────────────────────
/// Great-circle distance between two coordinates, in metres.
static double distanceMeters(
double fromLat,
double fromLng,
double toLat,
double toLng,
) {
const earthRadiusM = 6371000.0;
final dLat = _rad(toLat - fromLat);
final dLng = _rad(toLng - fromLng);
final h =
math.sin(dLat / 2) * math.sin(dLat / 2) +
math.cos(_rad(fromLat)) *
math.cos(_rad(toLat)) *
math.sin(dLng / 2) *
math.sin(dLng / 2);
return earthRadiusM * 2 * math.atan2(math.sqrt(h), math.sqrt(1 - h));
}
/// Formats a distance the way a rider reads it.
///
/// • `< 1000 m` → whole metres, rounded to 10 (`450 m`, `80 m`)
/// • `>= 1000 m` → kilometres with one decimal (`3.2 km`, `12.0 km`)
/// • `>= 100 km` → whole kilometres (`104 km`) — the decimal is noise
///
/// Returns `'—'` for unknown/zero, never a misleading `0 m`.
static String formatDistance(double? meters) {
if (meters == null || meters.isNaN || meters <= 0) return '—';
if (meters < kMetreThreshold) {
// Round to the nearest 10 m — GPS is not accurate to the metre, and
// "450 m" is easier to hold in your head than "447 m".
final rounded = (meters / 10).round() * 10;
return '${rounded < 10 ? 10 : rounded} m';
}
final km = meters / 1000;
if (km >= 100) return '${km.round()} km';
return '${km.toStringAsFixed(1)} km';
}
/// Distance with the trailing word riders expect on a card: `450 m away`.
static String formatDistanceAway(double? meters) {
final d = formatDistance(meters);
return d == '—' ? 'Distance unknown' : '$d away';
}
// ── Time ───────────────────────────────────────────────────────────────
/// Usable travel speed in m/s, given the rider's live speed.
///
/// Falls back to [kDefaultSpeedMps] whenever the live reading is missing,
/// too slow to be movement, or too fast to be real.
static double usableSpeedMps(double? liveSpeedMps) {
if (liveSpeedMps == null || liveSpeedMps.isNaN) return kDefaultSpeedMps;
if (liveSpeedMps < kMinUsableSpeedMps) return kDefaultSpeedMps;
if (liveSpeedMps > kMaxUsableSpeedMps) return kDefaultSpeedMps;
return liveSpeedMps;
}
/// Travel time for [meters] at the rider's usable speed.
static Duration travelTime(double? meters, {double? liveSpeedMps}) {
if (meters == null || meters.isNaN || meters <= 0) return Duration.zero;
final seconds = meters / usableSpeedMps(liveSpeedMps);
return Duration(seconds: seconds.round());
}
/// Arrival clock time for [meters] away, from [now] (defaults to real now).
static DateTime? eta(double? meters, {double? liveSpeedMps, DateTime? now}) {
if (meters == null || meters.isNaN || meters <= 0) return null;
return (now ?? DateTime.now()).add(
travelTime(meters, liveSpeedMps: liveSpeedMps),
);
}
/// ETA as a clock time — `4:35 PM`. Returns `'—'` when it can't be computed.
static String formatEta(
double? meters, {
double? liveSpeedMps,
DateTime? now,
}) {
final arrival = eta(meters, liveSpeedMps: liveSpeedMps, now: now);
if (arrival == null) return '—';
return formatClock(arrival);
}
/// `14:05` → `2:05 PM`.
static String formatClock(DateTime t) {
final hour12 = t.hour % 12 == 0 ? 12 : t.hour % 12;
final minute = t.minute.toString().padLeft(2, '0');
return '$hour12:$minute ${t.hour < 12 ? 'AM' : 'PM'}';
}
/// Duration as a rider reads a clock: `4 min`, `1h 05m`.
static String formatDuration(Duration d) {
if (d.inSeconds <= 0) return '—';
if (d.inMinutes < 1) return '1 min';
if (d.inMinutes < 60) return '${d.inMinutes} min';
final minutes = d.inMinutes.remainder(60).toString().padLeft(2, '0');
return '${d.inHours}h ${minutes}m';
}
/// The one-line strip shown on every stop card:
/// `450 m away · ETA 4:35 PM`. Degrades to whichever half is known.
static String formatStrip(
double? meters, {
double? liveSpeedMps,
DateTime? now,
}) {
final distance = formatDistanceAway(meters);
final arrival = formatEta(meters, liveSpeedMps: liveSpeedMps, now: now);
if (arrival == '—') return distance;
return '$distance · ETA $arrival';
}
// ── Stop-map convenience ───────────────────────────────────────────────
/// Resolves the distance from the rider to a stop, in metres.
///
/// Prefers a live rider fix; falls back to the coordinates the backend
/// stamped on the booking, then to its precomputed `kms` field. Returns null
/// when nothing usable exists — callers render `—` rather than a fake zero.
static double? metersToStop(
Map<String, dynamic> stop, {
double? riderLat,
double? riderLng,
}) {
final stopLat = _toDouble(stop['pickuplat'] ?? stop['PickupLat']);
final stopLng = _toDouble(stop['pickuplon'] ?? stop['PickupLon']);
final fromLat = (riderLat != null && riderLat != 0)
? riderLat
: _toDouble(stop['riderslat']);
final fromLng = (riderLng != null && riderLng != 0)
? riderLng
: _toDouble(stop['riderslon']);
if (stopLat != 0 && stopLng != 0 && fromLat != 0 && fromLng != 0) {
return distanceMeters(fromLat, fromLng, stopLat, stopLng);
}
// Backend-precomputed kilometres, if it sent any.
final km = _toDouble(stop['kms'] ?? stop['km']);
return km > 0 ? km * 1000 : null;
}
static double _toDouble(dynamic v) {
if (v == null) return 0;
if (v is num) return v.toDouble();
final cleaned = v.toString().replaceAll(RegExp(r'[^0-9.\-]'), '');
return double.tryParse(cleaned) ?? 0;
}
static double _rad(double deg) => deg * math.pi / 180.0;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,405 @@
part of 'pickups.dart';
Future<void> _showMyOptionsSheet(
BuildContext context,
Map<String, dynamic> pickup,
_MyPickupsState? parentState,
) async {
int selected = -1;
bool isLoading = false;
final List<String> skipReasons = [
'Customer unreachable',
'Customer not at the location',
];
await showModalBottomSheet(
context: context,
sheetAnimationStyle: kMilerSheetStyle,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) {
return SafeArea(
top: false,
left: false,
right: false,
bottom: true,
child: StatefulBuilder(
builder: (context, setState) {
// The reason list is the same question Update Status asks, so it is
// the same widget — see [_ReasonRow]. It used to be two outlined
// white cards on a white sheet, each with its icon inside a second
// box, and the selected one swapped its border for a thicker
// coloured one.
Widget reasonRow(String title, int index, IconData icon) {
return _ReasonRow(
label: title,
icon: icon,
accent: ColorConstants.primary,
selected: selected == index,
// No rule either side of the filled row: a hairline across the
// tint turns the chosen answer back into a box.
showRule:
index > 0 && selected != index && selected != index - 1,
onTap: isLoading
? null
: () => setState(() => selected = index),
);
}
Future<void> handleConfirm() async {
if (selected == -1 || isLoading) return;
final dc = Get.put(PickupsController(), permanent: true);
final skipStatus = await dc.checkSkipStatus();
final int skipCount = skipStatus['count'] ?? 0;
bool applyPenalty = false;
if (skipCount >= 2) {
final bool? confirm = await showAppDialog<bool>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
),
elevation: 0,
backgroundColor: ColorConstants.pureSurface,
child: Padding(
padding: EdgeInsets.all(24.r),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: EdgeInsets.all(16.r),
decoration: BoxDecoration(
color: ColorConstants.errorLight,
shape: BoxShape.circle,
),
child: Icon(
Icons.warning_amber_rounded,
color: ColorConstants.errorRed,
size: 36.sp,
),
),
SizedBox(height: 20.h),
Text(
'Skip Limit Reached',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 20.sp,
fontWeight: FontWeight.w700,
fontFamily: FontConstants.fontFamily,
color: ColorConstants.onSurface,
),
),
SizedBox(height: 10.h),
Text(
'You have exceeded the limit of 2 skips within 3 hours.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 15.sp,
fontFamily: FontConstants.fontFamily,
color: ColorConstants.onSurfaceVariant,
height: 1.4,
),
),
SizedBox(height: 14.h),
Container(
padding: EdgeInsets.all(12.r),
// Fill, no outline — the same rule the sheets
// now follow. A tinted panel is already a panel.
decoration: BoxDecoration(
color: ColorConstants.warningContainer,
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
child: Row(
children: [
Icon(
Icons.info_outline,
color: ColorConstants.warning,
size: 18.sp,
),
SizedBox(width: 8.w),
Expanded(
child: Text(
"Proceeding will forfeit your bonus points for this session.",
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w600,
color: ColorConstants.rewardAmber,
fontFamily: FontConstants.fontFamily,
height: 1.3,
),
),
),
],
),
),
SizedBox(height: 24.h),
Row(
children: [
Expanded(
child: MilerButton(
label: "Cancel",
onPressed: () =>
Navigator.pop(context, false),
variant: MilerButtonVariant.outlined,
color: ColorConstants.onSurfaceVariant,
height: ButtonSizes.secondary,
),
),
SizedBox(width: 12.w),
Expanded(
child: MilerButton(
label: "Confirm skip",
onPressed: () =>
Navigator.pop(context, true),
color: ColorConstants.errorRed,
height: ButtonSizes.secondary,
),
),
],
),
],
),
),
);
},
);
if (confirm != true) return;
applyPenalty = true;
}
setState(() => isLoading = true);
try {
final dc = Get.put(PickupsController(), permanent: true);
final d = pickup;
final int pickupId = int.tryParse('${d['pickupid'] ?? 0}') ?? 0;
final int orderHeaderId =
int.tryParse('${d['orderheaderid'] ?? 0}') ?? 0;
final String reason =
selected >= 0 && selected < skipReasons.length
? skipReasons[selected]
: '';
if (pickupId > 0 && orderHeaderId > 0) {
String riderLatStr = '0';
String riderLngStr = '0';
try {
final position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high,
timeLimit: const Duration(seconds: 5),
);
riderLatStr = position.latitude.toStringAsFixed(6);
riderLngStr = position.longitude.toStringAsFixed(6);
} catch (e) {
debugPrint('[SKIP] Error getting rider location: $e');
try {
final lastPos = await Geolocator.getLastKnownPosition();
if (lastPos != null) {
riderLatStr = lastPos.latitude.toStringAsFixed(6);
riderLngStr = lastPos.longitude.toStringAsFixed(6);
}
} catch (_) {}
}
debugPrint(
'[SKIP] Updating status for pickupId=$pickupId orderHeaderId=$orderHeaderId reason="$reason"',
);
final ok = await dc.updateSkippedStatus(
pickupId: pickupId,
orderHeaderId: orderHeaderId,
ridersLat: riderLatStr,
ridersLng: riderLngStr,
notes: reason,
);
debugPrint('[SKIP] Status update result: $ok');
// Optimistic skip — reflect it locally and move on even if the
// API fails (e.g. mock/offline data), so skip never appears
// unresponsive and the rider can proceed to the next stop.
await dc.registerSkip(applyPenalty: applyPenalty);
// Recorded before the sheet closes, and regardless of which
// screen the skip was taken from: the map screen passes no
// `parentState`, so the branch below is the only thing that
// remembered a skip and it did so in memory, on one screen.
// Activity is where the rider now finds a skipped stop, and
// it reads this store. See [addSkippedBooking].
await addSkippedBooking(d, reason: reason);
if (context.mounted) {
Navigator.pop(context);
if (parentState != null) {
parentState.markOrderAsSkipped(d, reason);
} else {
debugPrint(
'[SKIP] Skipped from Nav/Map screen, sheet closed.',
);
}
}
} else {
debugPrint(
'[SKIP] Invalid pickupId: $pickupId or orderHeaderId: $orderHeaderId',
);
if (context.mounted) {
AppFeedback.error(context, 'Invalid pickup information');
}
}
} catch (e) {
debugPrint('[SKIP] Error updating skip status: $e');
if (context.mounted) {
AppFeedback.error(context, 'Something went wrong — try again');
}
} finally {
if (context.mounted) {
setState(() => isLoading = false);
}
}
}
// Frosted, like every other sheet in the app — see
// [milerGlassSheet].
return milerGlassSheet(
child: Padding(
padding: EdgeInsets.fromLTRB(20.w, 12.h, 20.w, 20.h),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Center(
child: Container(
width: 40.w,
height: 4.h,
decoration: BoxDecoration(
color: ColorConstants.borderSubtle,
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
),
),
SizedBox(height: 16.h),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Skip this pickup',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 19.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.3,
fontFamily: FontConstants.fontFamily,
color: ColorConstants.slateText,
),
),
SizedBox(height: 2.h),
Text(
'Tell us why so we can reassign it',
style: TextStyle(
fontSize: 12.5.sp,
fontWeight: FontWeight.w600,
fontFamily: FontConstants.fontFamily,
color: ColorConstants.secondaryText,
),
),
],
),
),
SizedBox(width: 10.w),
InkWell(
onTap: isLoading
? null
: () => Navigator.pop(context),
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
child: Container(
padding: EdgeInsets.all(8.r),
decoration: BoxDecoration(
color: ColorConstants.neutralLight,
shape: BoxShape.circle,
),
child: Icon(
Icons.close_rounded,
color: ColorConstants.slateText,
size: 18.sp,
),
),
),
],
),
SizedBox(height: 16.h),
// ── Which pickup ──
//
// The sheet asked the rider to skip "this pickup" and never
// said which one. He arrives here from a swipe on a list of
// near-identical rows, and skipping the wrong stop is not an
// action he can take back — it goes to the office as a
// refusal against a customer who was standing there waiting.
_StopBrief(pickup: pickup, showRule: true),
SizedBox(height: 8.h),
reasonRow(
'Customer unreachable',
0,
Icons.phone_missed_rounded,
),
reasonRow(
'Customer not at the location',
1,
Icons.person_off_outlined,
),
SizedBox(height: 20.h),
if (isLoading)
Container(
height: ButtonSizes.primary,
alignment: Alignment.center,
decoration: BoxDecoration(
// Matches the button it stands in for.
color: ColorConstants.errorRed,
borderRadius: BorderRadius.circular(
ButtonSizes.radius,
),
),
child: const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: Colors.white,
),
),
)
else
// ── Skipping is destructive, and looks it ──
//
// This was `primary` while the penalty confirmation
// nested inside the same flow was `errorRed` — one action
// in two colours, two taps apart. Skipping tells the hub
// a customer was not served; it is the destructive branch
// of this screen and takes the destructive colour, the
// same one Reject uses on Home.
MilerButton(
label: 'Confirm skip',
icon: Icons.skip_next_rounded,
color: ColorConstants.errorRed,
onPressed: selected == -1 ? null : handleConfirm,
),
],
),
),
);
},
),
);
},
);
}

View File

@@ -0,0 +1,309 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
/// Mixed pickup + delivery routes (Amazon/Flipkart-style), including stops
/// where the rider must do BOTH at the same address.
///
/// A route stop is one of three operational modes:
///
/// • **pickup** — collect parcels from the customer. Photo + weight proof.
/// May carry COD (cash / UPI-QR).
/// • **delivery** — hand over a parcel carried from the hub. OTP + photo
/// proof. Prepaid, so nothing is collected.
/// • **combined** — one address, both jobs. e.g. hand over 1 parcel AND
/// collect 2 returns. This is a single stop with a two-leg
/// flow, NOT two stops — the rider parks once.
///
/// BACKEND DEPENDENCY: the app does not yet receive a stop `type` field. When
/// the backend starts sending `type`/`stoptype` = `pickup` | `delivery` |
/// `combined` (or sends both a delivery and a pickup quantity on one stop),
/// this helper picks it up automatically and the demo fallback is ignored.
enum StopKind { pickup, delivery, combined }
/// One unit of work inside a stop. A combined stop runs both, in order.
enum StopLeg { delivery, pickup }
/// Demo/mock switch. While the backend has no stop-type field, this makes
/// stop 2 a delivery and stop 3 a combined stop so all three card types and
/// flows can be exercised on real data.
///
/// Tied to [kDebugMode] so it is ALWAYS `false` in release builds. Once the
/// backend sends `type`, [stopKindOf] prefers it anyway and this is ignored.
const bool kDemoMixedRoute = kDebugMode;
/// Resolve a stop's kind.
///
/// Resolution order:
/// 1. An explicit backend `type` / `stoptype` / `stopType` string.
/// 2. Structural inference — a stop carrying BOTH a delivery quantity and a
/// pickup quantity is a combined stop even if never labelled one.
/// 3. The demo pattern, in debug builds only.
/// 4. Pickup — the app's original behaviour.
StopKind stopKindOf(Map<String, dynamic> item) {
final raw = _rawType(item);
if (raw == 'combined' ||
raw == 'both' ||
raw == 'pickup_delivery' ||
raw == 'pickupdelivery' ||
raw == 'delivery_pickup') {
return StopKind.combined;
}
if (raw == 'delivery' || raw == 'deliver' || raw == 'drop') {
return StopKind.delivery;
}
if (raw == 'pickup' || raw == 'pick' || raw == 'collect') {
return StopKind.pickup;
}
// Structural inference — trust the payload when the label is missing.
if (_explicitDeliveryQty(item) > 0 && _explicitPickupQty(item) > 0) {
return StopKind.combined;
}
if (kDemoMixedRoute) {
final stepRaw = item['step'] ?? item['Step'];
final step = stepRaw is num
? stepRaw.toInt()
: int.tryParse('${stepRaw ?? ''}') ?? 0;
// Stop 3, 6, 9… → combined; other even stops → delivery. A demo route
// therefore shows all three card types.
if (step > 0) {
if (step % 3 == 0) return StopKind.combined;
if (step.isEven) return StopKind.delivery;
}
}
return StopKind.pickup;
}
String _rawType(Map<String, dynamic> item) =>
(item['type'] ?? item['stoptype'] ?? item['stopType'] ?? '')
.toString()
.trim()
.toLowerCase();
int _explicitDeliveryQty(Map<String, dynamic> item) => _toInt(
item['deliveryqty'] ??
item['deliveryQty'] ??
item['dropqty'] ??
item['deliverycount'],
);
int _explicitPickupQty(Map<String, dynamic> item) => _toInt(
item['pickupqty'] ??
item['pickupQty'] ??
item['collectqty'] ??
item['returnqty'],
);
/// Parcels to be HANDED OVER at this stop (delivery leg). Zero for a
/// pickup-only stop.
int deliveryParcelCount(Map<String, dynamic> item) {
final explicit = _explicitDeliveryQty(item);
if (explicit > 0) return explicit;
// No explicit split: a delivery-labelled stop delivers its whole quantity.
return stopKindOf(item) == StopKind.delivery
? _toInt(item['Quantity'] ?? item['quantity'])
: 0;
}
/// Parcels to be COLLECTED at this stop (pickup leg). Zero for a
/// delivery-only stop.
int pickupParcelCount(Map<String, dynamic> item) {
final explicit = _explicitPickupQty(item);
if (explicit > 0) return explicit;
return stopKindOf(item) == StopKind.delivery
? 0
: _toInt(item['Quantity'] ?? item['quantity']);
}
/// Cash still to be collected at this stop, in rupees. Deliveries are prepaid,
/// so this only ever applies to a pickup leg.
double stopCollectionAmount(Map<String, dynamic> item) {
final v = item['collectionamt'] ?? item['pickupamt'] ?? item['codamount'];
if (v == null) return 0;
if (v is num) return v.toDouble();
final cleaned = v.toString().replaceAll(RegExp(r'[^0-9.\-]'), '');
return double.tryParse(cleaned) ?? 0;
}
/// The delivery OTP, when the stop has a delivery leg.
String stopOtp(Map<String, dynamic> item) =>
(item['otp'] ?? item['Otp'] ?? item['deliveryotp'] ?? '').toString().trim();
int _toInt(dynamic v) {
if (v == null) return 0;
if (v is num) return v.toInt();
return int.tryParse(v.toString().trim()) ?? 0;
}
extension StopKindUi on StopKind {
/// True only for a delivery-ONLY stop.
///
/// Deliberately narrow. The confirm/payment path keys off this to mean
/// "prepaid, no money, OTP required". A combined stop still has a pickup leg
/// that can carry COD, so it must NOT take the delivery-only path — ask
/// [hasDelivery] / [hasPickup] about legs instead.
bool get isDelivery => this == StopKind.delivery;
bool get isCombined => this == StopKind.combined;
/// Does this stop involve handing a parcel over? (delivery + combined)
bool get hasDelivery =>
this == StopKind.delivery || this == StopKind.combined;
/// Does this stop involve collecting a parcel? (pickup + combined)
bool get hasPickup => this == StopKind.pickup || this == StopKind.combined;
/// The legs to run, in execution order.
///
/// Deliver first on a combined stop: the rider walks up with the outbound
/// parcel already in his hands, so he hands it over before taking anything
/// back. Reversing this makes him juggle two loads at the doorstep.
List<StopLeg> get legs => switch (this) {
StopKind.pickup => const [StopLeg.pickup],
StopKind.delivery => const [StopLeg.delivery],
StopKind.combined => const [StopLeg.delivery, StopLeg.pickup],
};
/// Distinct accent per mode so the three are never confused at a glance.
Color get accent => switch (this) {
StopKind.pickup => ColorConstants.pickupAccent,
StopKind.delivery => ColorConstants.deliveryAccent,
StopKind.combined => ColorConstants.combinedAccent,
};
/// Fill for the small chip that names this mode, carrying white text.
///
/// A darkened [accent] — see the note on [ColorConstants.pickupChip]. Use this
/// for the tag and nothing else: the chip is the one place stop type is
/// allowed to be a colour, because it is a mark rather than a surface.
Color get chipColor => switch (this) {
StopKind.pickup => ColorConstants.pickupChip,
StopKind.delivery => ColorConstants.deliveryChip,
StopKind.combined => ColorConstants.combinedChip,
};
IconData get icon => switch (this) {
StopKind.pickup => Icons.inventory_2_rounded,
StopKind.delivery => Icons.local_shipping_rounded,
StopKind.combined => Icons.swap_horiz_rounded,
};
/// Short badge label.
String get badgeLabel => switch (this) {
StopKind.pickup => 'PICKUP',
StopKind.delivery => 'DELIVERY',
StopKind.combined => 'PICKUP & DELIVERY',
};
/// Sentence-case name of the mode, for places that state it as a *label*
/// rather than stamp it as a chip — see the handoff screen's next-stop block.
///
/// [badgeLabel] shouts because it is a filled mark competing with a card full
/// of other things. When the type is a quiet line under a hero heading there
/// is nothing to compete with, and all-caps at that size reads as a second
/// heading rather than as an attribute.
String get titleLabel => switch (this) {
StopKind.pickup => 'Pickup',
StopKind.delivery => 'Delivery',
StopKind.combined => 'Pickup & Delivery',
};
/// Label for the small chip on a stop card — **both** stop cards.
///
/// "PICKUP & DELIVERY" is 17 characters. On a stop row it shares a line with a
/// duration, a call button and a tick box, all of which have a fixed claim on
/// the width — so it was ellipsising down to "P…" and telling the rider
/// nothing at all. A chip that has to truncate is not a chip.
///
/// "P&D" fits at any width and, unlike the "BOTH" it replaces, still names
/// the two jobs rather than merely counting them: "BOTH" only means anything
/// if you already know what the two things are, which is exactly what a rider
/// glancing at an unfamiliar stop does not.
///
/// The Bookings card used [badgeLabel] here and now uses this, so the two
/// stop cards finally agree. The full wording survives where there is room
/// for it — the detail sheet and Activity's Type row — which is what
/// [badgeLabel] is still for.
String get chipLabel => switch (this) {
StopKind.pickup => 'PICKUP',
StopKind.delivery => 'DELIVERY',
StopKind.combined => 'P&D',
};
/// Location-row label.
String get locationLabel => switch (this) {
StopKind.pickup => 'PICKUP LOCATION',
StopKind.delivery => 'DELIVER TO',
StopKind.combined => 'DELIVER & COLLECT AT',
};
/// Verb for inline CTAs — "start pickup" / "start delivery" / …
String get startVerb => switch (this) {
StopKind.pickup => 'start pickup',
StopKind.delivery => 'start delivery',
StopKind.combined => 'start combined stop',
};
/// Full primary-CTA text on the stop card.
String get startCtaLabel => switch (this) {
StopKind.pickup => 'Start Pickup',
StopKind.delivery => 'Start Delivery',
StopKind.combined => 'Start Combined Stop',
};
/// Success verb once the WHOLE stop is finished.
String get completeLabel => switch (this) {
StopKind.pickup => 'Picked up',
StopKind.delivery => 'Delivered',
StopKind.combined => 'Complete Stop',
};
/// Failure / abort verb.
String get failLabel => switch (this) {
StopKind.pickup => 'Cancel',
StopKind.delivery => 'Undelivered',
StopKind.combined => 'Could not complete',
};
/// Noun used on the success screen.
String get noun => switch (this) {
StopKind.pickup => 'Pickup',
StopKind.delivery => 'Delivery',
StopKind.combined => 'Stop',
};
/// Wire value carried in the completion outcome map.
String get wireName => switch (this) {
StopKind.pickup => 'pickup',
StopKind.delivery => 'delivery',
StopKind.combined => 'combined',
};
}
/// Parse a [StopKind] back from [StopKindUi.wireName]. Unknown → pickup, the
/// app's safe default.
StopKind stopKindFromWire(dynamic raw) => switch (raw?.toString()) {
'delivery' => StopKind.delivery,
'combined' => StopKind.combined,
_ => StopKind.pickup,
};
extension StopLegUi on StopLeg {
bool get isDelivery => this == StopLeg.delivery;
String get title => isDelivery ? 'Hand over parcel' : 'Collect parcel';
String get proofHint =>
isDelivery ? 'OTP + photo required' : 'Photo + weight required';
IconData get icon =>
isDelivery ? Icons.local_shipping_rounded : Icons.inventory_2_rounded;
Color get accent =>
isDelivery ? ColorConstants.deliveryAccent : ColorConstants.pickupAccent;
}

File diff suppressed because it is too large Load Diff

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

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

View File

@@ -0,0 +1,927 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:lottie/lottie.dart' hide Marker;
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:miler/Models/summary/riderweeklykms.dart';
import 'package:miler/views/helpers/constants/Font_constant.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/helpers/widgets/miler_app_bar.dart';
import 'package:miler/controllers/summary_controller.dart';
import 'package:miler/controllers/rewards_controller.dart';
import 'package:miler/controllers/riderkm.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/views/helpers/widgets/app_widgets.dart';
import 'package:miler/views/helpers/constants/design_constants.dart';
import 'package:miler/views/helpers/constants/miler_type.dart';
class Summary extends StatefulWidget {
const Summary({super.key});
@override
State<Summary> createState() => _SummaryState();
}
class _SummaryState extends State<Summary> {
late final SummaryController controller;
late final RewardsController _rewards;
int _userId = 0;
int _refreshTick = 0;
int _selectedPeriod = 0; // 0=Today, 1=Week, 2=Month
final List<String> _periods = ['Today', 'This Week', 'This Month'];
@override
void initState() {
super.initState();
controller = Get.isRegistered<SummaryController>()
? Get.find<SummaryController>()
: Get.put(SummaryController());
_rewards = Get.isRegistered<RewardsController>()
? Get.find<RewardsController>()
: Get.put(RewardsController());
WidgetsBinding.instance.addPostFrameCallback((_) {
_refreshData();
});
}
@override
void dispose() {
super.dispose();
}
Future<void> _refreshData() async {
try {
final prefs = await SharedPreferences.getInstance();
final uid = prefs.getInt('userid') ?? prefs.getInt('userId') ?? 0;
if (mounted) setState(() => _userId = uid);
if (uid > 0) {
await controller.fetchSummaryStats(uid);
_rewards.fetchBonusSummary(uid);
}
} catch (e) {
debugPrint('Error fetching summary: $e');
} finally {
if (mounted) {
setState(() => _refreshTick++);
}
}
}
int get _periodCount {
switch (_selectedPeriod) {
case 1:
return controller.week.value;
case 2:
return controller.month.value;
default:
return controller.today.value;
}
}
int get _successRate {
final total = controller.total.value;
final cancelled = controller.cancelled.value;
final attempts = total + cancelled;
if (attempts <= 0) return 0;
return ((total / attempts) * 100).round();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: ColorConstants.surface,
// Earnings is no longer a tab — it opens from Account — so it needs a way
// back that the four tabs never did.
appBar: const MilerAppBar(title: 'Earnings', showBack: true),
body: RefreshIndicator(
onRefresh: _refreshData,
color: ColorConstants.primary,
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(
parent: BouncingScrollPhysics(),
),
slivers: [
SliverToBoxAdapter(
child: Column(
children: [
SizedBox(height: 16.h),
_buildPeriodTabs(),
SizedBox(height: 16.h),
Padding(
padding: EdgeInsets.symmetric(horizontal: 16.w),
child: _buildPickupsHero(),
),
SizedBox(height: 16.h),
Padding(
padding: EdgeInsets.symmetric(horizontal: 16.w),
child: _buildStatsGrid(),
),
SizedBox(height: 16.h),
Padding(
padding: EdgeInsets.symmetric(horizontal: 16.w),
child: TotalDistanceCard(
userId: _userId,
refreshTick: _refreshTick,
),
),
SizedBox(height: 32.h),
],
),
),
],
),
),
);
}
Widget _buildPeriodTabs() {
return Container(
margin: EdgeInsets.symmetric(horizontal: 16.w),
height: 44.h,
decoration: BoxDecoration(
color: ColorConstants.pureSurface,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
border: Border.all(color: ColorConstants.outlineVariant),
),
child: Row(
children: List.generate(_periods.length, (index) {
final isSelected = _selectedPeriod == index;
return Expanded(
child: GestureDetector(
onTap: () {
if (mounted) {
setState(() {
_selectedPeriod = index;
});
}
},
child: Container(
margin: EdgeInsets.all(4.r),
decoration: BoxDecoration(
color: isSelected
? ColorConstants.primary
: Colors.transparent,
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
alignment: Alignment.center,
child: Text(
_periods[index],
style: TextStyle(
fontSize: 13.sp,
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
color: isSelected
? Colors.white
: ColorConstants.onSurfaceVariant,
fontFamily: FontConstants.fontFamily,
),
),
),
),
);
}),
),
);
}
Widget _buildPickupsHero() {
final periodCount = _periodCount;
final total = controller.total.value;
final cancelled = controller.cancelled.value;
final points = _rewards.totalPoints.value;
return Container(
width: double.infinity,
// ── Flat, like the rest of the app ──
//
// A two-stop gradient plus a 20px coloured shadow, on the one tab whose
// neighbours are all flat fills and hairlines. Earnings was visibly from
// a different product. The brand colour still owns this block — it is the
// one celebratory surface in the app and keeps that — but it is a fill,
// not an effect. The two faint circles behind it survive: they are what
// stops a solid maroon slab reading as a error state.
decoration: BoxDecoration(
color: ColorConstants.primary,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
child: Stack(
children: [
Positioned(
top: -34,
right: -24,
child: Container(
width: 130.w,
height: 130.w,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: ColorConstants.pureSurface.withValues(alpha: 0.07),
),
),
),
Positioned(
bottom: -46,
left: -28,
child: Container(
width: 150.w,
height: 150.w,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: ColorConstants.pureSurface.withValues(alpha: 0.05),
),
),
),
Padding(
padding: EdgeInsets.all(20.r),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${_periods[_selectedPeriod].toUpperCase()} · PICKUPS/DELIVERIES',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.8),
fontSize: 11.sp,
fontWeight: FontWeight.w700,
letterSpacing: 1.2,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 14.h),
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
AnimatedSwitcher(
duration: const Duration(milliseconds: 320),
transitionBuilder: (child, anim) => FadeTransition(
opacity: anim,
child: ScaleTransition(
scale: Tween<double>(
begin: 0.82,
end: 1.0,
).animate(anim),
child: child,
),
),
// 46 → 28, the scale's hero size. A 46sp figure sharing
// a row with a label is the app's highest overflow risk
// at 2x text, and it was 18pt larger than the largest
// type anywhere else in the product.
child: Text(
'$periodCount',
key: ValueKey<int>(periodCount),
style: MilerType.hero.on(ColorConstants.onAccent),
),
),
SizedBox(width: 8.w),
Padding(
padding: EdgeInsets.only(bottom: 6.h),
child: Text(
periodCount == 1 ? 'order' : 'orders',
style: MilerType.label.on(
ColorConstants.onAccent.withValues(alpha: 0.75),
),
),
),
],
),
SizedBox(height: 4.h),
Text(
'$total completed all-time',
style: MilerType.label.on(
ColorConstants.onAccent.withValues(alpha: 0.75),
),
),
SizedBox(height: 20.h),
Row(
children: [
Expanded(
child: _buildHeroMetric(
'$_successRate%',
'Success rate',
icon: Icons.percent_rounded,
),
),
SizedBox(width: 12.w),
Expanded(
child: _buildHeroMetric(
'$cancelled',
'Cancelled',
icon: Icons.do_not_disturb_on_rounded,
),
),
SizedBox(width: 12.w),
Expanded(
child: _buildHeroMetric(
'$points',
'Reward pts',
icon: Icons.stars_rounded,
),
),
],
),
],
),
),
],
),
),
);
}
/// One chip on the hero.
///
/// The three used to be bare numbers over bare words — 92%, 4, 1,240 — which
/// on a saturated red panel is three identical grey-white blocks the rider
/// has to *read* to tell apart. A glyph makes each one findable at a glance,
/// which is the whole job of a hero: the number you came for, without
/// reading. Same rounded family as the cards below, at chip weight.
Widget _buildHeroMetric(String value, String label, {IconData? icon}) {
return Container(
padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 10.h),
decoration: BoxDecoration(
color: ColorConstants.pureSurface.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (icon != null) ...[
Icon(
icon,
size: 14.sp,
color: Colors.white.withValues(alpha: 0.85),
),
SizedBox(height: 6.h),
],
Text(
value,
style: TextStyle(
color: Colors.white,
fontSize: 15.sp,
fontWeight: FontWeight.w800,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 2.h),
Text(
label,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.65),
fontSize: 10.sp,
fontWeight: FontWeight.w500,
fontFamily: FontConstants.fontFamily,
),
),
],
),
);
}
/// ── Lifetime stops ──
///
/// Three stat cards, in the same language Home uses for a trip's duration,
/// distance and cash: a tinted icon disc, the figure, a small-caps caption.
/// Two screens showing the same *kind* of thing — a small set of headline
/// numbers — should not invent two ways to draw it, and this is the one the
/// rider sees first every morning.
///
/// ── What this replaced, and why twice ──
///
/// First it was two floating shadowed cards with a gap. Then one bordered
/// container split by a hairline, which fixed the wrong thing: it presented
/// the numbers as two unrelated metrics sharing a row when they are two parts
/// of one quantity. Then a proportional share bar, which showed the split
/// honestly but spent a lot of height on a figure — the success rate — that is
/// a single number and reads perfectly well as one.
///
/// So the rate is now simply the third card. It is the figure a rider is
/// judged on and the one he is proud of, and giving it equal billing with the
/// counts says that more plainly than a bar underneath them did.
Widget _buildStatsGrid() {
final total = controller.total.value;
final cancelled = controller.cancelled.value;
final attempts = total + cancelled;
final rate = attempts > 0 ? ((total / attempts) * 100).round() : 0;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: EdgeInsets.only(left: 4.w, bottom: 10.h),
child: Text(
'LIFETIME',
style: TextStyle(
fontSize: 11.sp,
fontWeight: FontWeight.w800,
letterSpacing: 1.0,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
),
// IntrinsicHeight so the three cards match height whatever their
// figures. `CrossAxisAlignment.stretch` alone would ask for infinite
// height inside this scroll view — the trap pinned by
// `card_stripe_layout_test.dart`.
IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: _statCard(
icon: Icons.check_circle_rounded,
value: '$total',
label: 'Completed',
accent: ColorConstants.acceptGreen,
),
),
SizedBox(width: 10.w),
Expanded(
child: _statCard(
// Filled, like the other two. This was the one *outlined*
// glyph in a row of three, so beside a solid tick it read as
// lighter and slightly unfinished — a set of icons has to
// agree on fill weight before it agrees on anything else.
icon: Icons.do_not_disturb_on_rounded,
value: '$cancelled',
label: 'Fell through',
// Grey, not red. A stop that fell through is usually a shut
// shop or an absent customer — a fact about the day's
// logistics, not the rider's fault — and this is the screen
// where he checks his earnings, not where he gets told off.
accent: ColorConstants.secondaryText,
),
),
SizedBox(width: 10.w),
Expanded(
child: _statCard(
// A percentage, not a trend. `trending_up` promises movement
// over time — an arrow climbing — and this is a ratio of two
// lifetime totals that mostly sits still. The glyph should
// say what the number is.
icon: Icons.percent_rounded,
value: attempts > 0 ? '$rate%' : '—',
label: 'Success',
accent: ColorConstants.acceptGreen,
),
),
],
),
),
],
);
}
/// One stat card. Mirrors `TripCard`'s metric card on Home, deliberately.
Widget _statCard({
required IconData icon,
required String value,
required String label,
required Color accent,
}) {
return Container(
padding: EdgeInsets.symmetric(vertical: 14.h, horizontal: 6.w),
decoration: BoxDecoration(
color: ColorConstants.pureSurface,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
boxShadow: const [
BoxShadow(
color: Color(0x0F000000),
blurRadius: 14,
offset: Offset(0, 5),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 36.w,
height: 36.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.10),
shape: BoxShape.circle,
),
child: Icon(icon, size: 18.sp, color: accent),
),
SizedBox(height: 11.h),
// Fixed height so a four-digit total scales down inside its own card
// rather than making one of the three taller than the other two.
SizedBox(
height: 24.h,
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
value,
style: TextStyle(
fontSize: 20.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.6,
height: 1.05,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
),
),
SizedBox(height: 4.h),
Text(
label.toUpperCase(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 9.5.sp,
fontWeight: FontWeight.w800,
letterSpacing: 0.7,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
],
),
);
}
}
/// Weekly distance chart backed by the real `getriderweeklykms` endpoint.
class TotalDistanceCard extends StatefulWidget {
final int userId;
final int refreshTick;
const TotalDistanceCard({
super.key,
required this.userId,
this.refreshTick = 0,
});
@override
State<TotalDistanceCard> createState() => _TotalDistanceCardState();
}
class _TotalDistanceCardState extends State<TotalDistanceCard> {
final RiderWeeklyKmController _kmController = RiderWeeklyKmController();
late Future<Map<String, dynamic>> _futureKms;
int _touchedIndex = -1;
@override
void initState() {
super.initState();
_futureKms = _fetchWeeklyKms();
}
@override
void didUpdateWidget(covariant TotalDistanceCard oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.userId != widget.userId ||
oldWidget.refreshTick != widget.refreshTick) {
// didUpdateWidget already runs as part of a rebuild, so assign directly.
// Calling setState() here re-enters the parent scroll view's viewport
// mid-update and trips the `!_doingMountOrUpdate` assertion.
_futureKms = _fetchWeeklyKms();
}
}
Future<Map<String, dynamic>> _fetchWeeklyKms() async {
if (widget.userId <= 0) {
return _fallbackWeeklyKms();
}
try {
final res = await _kmController.getRiderWeeklyKms(widget.userId);
final details = (res['details'] as List?) ?? const [];
if (details.isEmpty) return _fallbackWeeklyKms();
return res;
} catch (e) {
debugPrint('[WEEKLY_KMS] $e');
return _fallbackWeeklyKms();
}
}
/// Nothing to chart.
///
/// This used to return a week of seeded demo kilometres in debug builds. A
/// distance chart feeds a payout, so an invented week is the one thing this
/// card must not draw — and "debug only" is no protection when the person
/// looking at the debug build is the one deciding whether the feature works.
/// The empty state says "No distance recorded yet", which is true and is what
/// makes a silent endpoint visible.
Map<String, dynamic> _fallbackWeeklyKms() =>
{'details': const <RiderWeeklyKms>[], 'total_kms': 0.0};
String _shortDay(String day) => day.length <= 3 ? day : day.substring(0, 3);
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.all(18.r),
decoration: BoxDecoration(
color: ColorConstants.pureSurface,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
boxShadow: const [
BoxShadow(
color: Color(0x0A000000),
blurRadius: 20,
offset: Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
// `route_rounded` is a branching-path glyph — it reads as
// "route planning", which is the hub's job, not as distance the
// rider has already covered. A speedometer is the thing on his
// handlebars that counts exactly this, and it needs no tinted
// tile to be recognisable at 18sp.
// `speed_rounded` is a speedometer, and this card counts
// kilometres covered, not how fast he went. A route glyph is
// both accurate and the one every delivery app uses for distance.
Icon(
Icons.route_rounded,
color: ColorConstants.primary,
size: 20.sp,
),
SizedBox(width: 9.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Weekly Distance',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w700,
color: ColorConstants.onSurface,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 2.h),
Text(
'Kilometres ridden per day',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11.sp,
color: ColorConstants.onSurfaceVariant,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
],
),
SizedBox(height: 20.h),
FutureBuilder<Map<String, dynamic>>(
future: _futureKms,
builder: (context, snapshot) {
// A spinner in the space a chart is about to occupy tells the
// rider nothing about what is coming and then hands over in one
// frame. A block the size of the chart does both jobs.
if (snapshot.connectionState == ConnectionState.waiting) {
return SmoothSwap(
child: MilerShimmer(
key: const ValueKey('kms-loading'),
child: SkeletonBone(height: 180.h, radius: 16),
),
);
}
final details =
(snapshot.data?['details'] as List<RiderWeeklyKms>?) ??
<RiderWeeklyKms>[];
final totalKms = (snapshot.data?['total_kms'] as double?) ?? 0.0;
if (details.isEmpty) {
return SmoothSwap(
child: KeyedSubtree(
key: const ValueKey('kms-empty'),
child: _buildEmptyChart(),
),
);
}
return SmoothSwap(
child: KeyedSubtree(
key: const ValueKey('kms-chart'),
child: _buildChart(details, totalKms),
),
);
},
),
],
),
);
}
Widget _buildEmptyChart() {
return SizedBox(
height: 190.h,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 120.w,
height: 120.w,
child: Lottie.asset(
'assets/lotties/no_distance.json',
fit: BoxFit.contain,
repeat: true,
),
),
SizedBox(height: 6.h),
Text(
'No distance recorded yet',
style: TextStyle(
fontSize: 13.sp,
color: ColorConstants.onSurfaceVariant,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
);
}
Widget _buildChart(List<RiderWeeklyKms> details, double totalKms) {
final values = details.map((e) => e.kms).toList();
final maxVal = values.reduce((a, b) => a > b ? a : b);
final safeMax = maxVal <= 0 ? 1.0 : maxVal;
// Best day + averages for the summary row.
int bestIdx = 0;
for (int i = 1; i < values.length; i++) {
if (values[i] > values[bestIdx]) bestIdx = i;
}
final avgPerDay = details.isEmpty ? 0.0 : totalKms / details.length;
return Column(
children: [
SizedBox(
height: 160.h,
child: BarChart(
BarChartData(
maxY: safeMax * 1.3,
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: safeMax / 3,
getDrawingHorizontalLine: (value) => FlLine(
color: ColorConstants.surfaceContainerLow,
strokeWidth: 1,
),
),
borderData: FlBorderData(show: false),
alignment: BarChartAlignment.spaceAround,
barTouchData: BarTouchData(
touchTooltipData: BarTouchTooltipData(
getTooltipItem: (group, groupIndex, rod, rodIndex) {
return BarTooltipItem(
'${rod.toY.toStringAsFixed(1)} km',
TextStyle(
color: Colors.white,
fontWeight: FontWeight.w700,
fontSize: 12.sp,
fontFamily: FontConstants.fontFamily,
),
);
},
),
touchCallback: (event, response) {
if (!mounted) return;
final int newIndex =
(response != null &&
response.spot != null &&
event is! FlTapUpEvent)
? response.spot!.touchedBarGroupIndex
: -1;
if (newIndex != _touchedIndex) {
Future.microtask(() {
if (mounted) {
setState(() {
_touchedIndex = newIndex;
});
}
});
}
},
),
titlesData: FlTitlesData(
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
leftTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 30,
getTitlesWidget: (value, _) {
final idx = value.toInt();
if (idx >= 0 && idx < details.length) {
final isTouched = idx == _touchedIndex;
return Padding(
padding: EdgeInsets.only(top: 8.h),
child: Text(
_shortDay(details[idx].day),
style: TextStyle(
fontSize: 11.sp,
fontWeight: isTouched
? FontWeight.w800
: FontWeight.w500,
color: isTouched
? ColorConstants.primary
: ColorConstants.onSurfaceVariant,
fontFamily: FontConstants.fontFamily,
),
),
);
}
return const SizedBox.shrink();
},
),
),
),
barGroups: List.generate(values.length, (i) {
final isTouched = i == _touchedIndex;
return BarChartGroupData(
x: i,
barRods: [
BarChartRodData(
toY: values[i],
// Flat, and the selected bar carries the brand while the
// rest stay neutral — the comparison is the point of a
// bar chart, and a gradient on every bar competes with it.
color: isTouched
? ColorConstants.primary
: ColorConstants.outlineVariant,
width: 18.w,
borderRadius: BorderRadius.vertical(
top: Radius.circular(5.r),
),
),
],
);
}),
),
),
),
SizedBox(height: 12.h),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildChartStat(
'Best Day',
_shortDay(details[bestIdx].day),
ColorConstants.warning,
),
_buildChartStat(
'Total',
'${totalKms.toStringAsFixed(1)} km',
ColorConstants.primary,
),
_buildChartStat(
'Avg/Day',
'${avgPerDay.toStringAsFixed(1)} km',
ColorConstants.tertiary,
),
],
),
],
);
}
Widget _buildChartStat(String label, String value, Color color) {
return Column(
children: [
Text(
value,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w800,
color: color,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 2.h),
Text(
label,
style: TextStyle(
fontSize: 10.sp,
color: ColorConstants.onSurfaceVariant,
fontFamily: FontConstants.fontFamily,
),
),
],
);
}
}

View File

@@ -0,0 +1,271 @@
import 'package:flutter/material.dart';
/// Every colour in the app.
class ColorConstants {
// --- Rider Operations System Design System ---
/// The page itself. Every other surface is measured against this.
static const Color surface = Color(0xFFFCF9F8);
static const Color surfaceDim = Color(0xFFDCD9D9);
static const Color surfaceBright = Color(0xFFFCF9F8);
/// The lightest container — a white card.
static const Color surfaceContainerLowest = Color(0xFFFFFFFF);
static const Color surfaceContainerLow = Color(0xFFF6F3F2);
static const Color surfaceContainer = Color(0xFFF0EDED);
static const Color surfaceContainerHigh = Color(0xFFEAE7E7);
static const Color surfaceContainerHighest = Color(0xFFE4E2E1);
static const Color onSurface = Color(0xFF1B1C1C);
/// Body text on a surface. **Was #5C403E — a brown**, the M3 tonal derivation
/// of the red brand seed, on 56 call sites and in the app's `TextTheme`. Its
/// contrast was fine (9.30:1); it simply was not the same app as the slate
/// dashboard. Now a slate of matched luminance (9.53:1), so nothing gets
/// lighter and nothing is brown.
static const Color onSurfaceVariant = Color(0xFF3D4654);
static const Color inverseSurface = Color(0xFF303030);
static const Color inverseOnSurface = Color(0xFFFCF9F8);
static const Color outline = Color(0xFF906F6D);
/// Default outline. **Was #E5BDBB — a pink**, again derived from the red seed,
/// and it fed `ColorScheme.outlineVariant` plus every `OutlinedButton` side and
/// `TextField` border in [AppTheme]. So every input in the app had a pink edge.
/// Now the same slate hairline the dashboard uses for container edges.
static const Color outlineVariant = Color(0xFFCBD5E1);
static const Color surfaceTint = Color(0xFF960019);
// Primary Red
static const Color primary = Color(0xFF960019);
static const Color onPrimary = Color(0xFFFFFFFF);
static const Color primaryContainer = Color(0xFFC01227);
static const Color onPrimaryContainer = Color(0xFFFFD1CE);
static const Color inversePrimary = Color(0xFFFFB3AF);
// Secondary Dark
static const Color secondary = Color(0xFF5F5E5E);
static const Color onSecondary = Color(0xFFFFFFFF);
static const Color secondaryContainer = Color(0xFFE2DFDE);
static const Color onSecondaryContainer = Color(0xFF636262);
// Tertiary (Info Blue)
static const Color tertiary = Color(0xFF004E6F);
static const Color onTertiary = Color(0xFFFFFFFF);
static const Color tertiaryContainer = Color(0xFF006791);
static const Color onTertiaryContainer = Color(0xFFB8E1FF);
// Error
static const Color error = Color(0xFFBA1A1A);
static const Color onError = Color(0xFFFFFFFF);
static const Color errorContainer = Color(0xFFFFDAD6);
static const Color onErrorContainer = Color(0xFF93000A);
// Success Green
static const Color success = Color(0xFF006A46);
static const Color onSuccess = Color(0xFFFFFFFF);
static const Color successContainer = Color(0xFF008659);
/// The "go" green: Accept, accept-all, slide-to-duty, and the tick that marks
/// a finished stop.
///
/// **Was #12B76A, which failed WCAG outright as a button fill.** White text on
/// it measured **2.62:1** — under even the 3:1 floor for large text and
/// graphics — which means the label on the single most-pressed button in the
/// app washed out in exactly the daylight this app is used in.
///
/// Now 5.48:1 behind white, and ≥4.5:1 on all four surfaces the app draws on.
static const Color acceptGreen = Color(0xFF047857);
/// Kept as a name for the cash figure, now that [acceptGreen] is dark enough
/// to be read as text. Same value; the two greens collapsed into one.
static const Color moneyGreen = acceptGreen;
/// Reward points — the app's third currency, after cash ([moneyGreen]) and
/// completed work ([acceptGreen]).
///
/// Deliberately *not* [warning]: amber means "attention, something is
/// unfinished" everywhere else in this app, and a points balance is good news.
static const Color rewardAmber = Color(0xFF92400E);
// Warning Amber
//
// #B45309 → #AE5008. A three-percent darkening, for one reason: it cleared
// 4.5:1 on white (5.02) and on the page (4.79) but landed at **4.36:1 on
// `cardSurface`** — and the card fill is where warnings actually appear, on
// the skip sheet and the short-payment notice. A token that passes on the
// surface nobody uses it on has not passed.
static const Color warning = Color(0xFFAE5008);
static const Color onWarning = Color(0xFFFFFFFF);
static const Color warningContainer = Color(0xFFFEF3C7);
// Extended Color Palette for Professional UI
/// Captions, labels and secondary body text — the second most-used colour in
/// the app after [slateText], on 89 call sites across every screen.
///
/// **Was #8E8E93, which fails WCAG AA.** It measured 3.26:1 on white, against
/// a 4.5:1 floor — so on a phone clamped to a handlebar in daylight, every
/// caption in the app was the one thing a rider could not read. It is now
/// 6.00:1 on white.
static const Color secondaryText = Color(0xFF5A6472);
/// ── A fill, never a text colour ──
///
/// #C7C7CC measures **1.68:1 on white** — below even the 3:1 floor for large
/// text. It was being used for readable captions and hints on seven screens,
/// including the helper line under the phone field on the first screen a new
/// rider ever sees.
///
/// Renamed from `disabledText` so the name cannot invite the mistake again.
/// A genuinely disabled *control* may be filled with this; the words on it
/// use [secondaryText].
static const Color disabledFill = Color(0xFFC7C7CC);
static const Color dividerColor = Color(0xFFE9EBF0);
static const Color upiPurple = Color(0xFF7C3AED);
static const Color successLight = Color(0xFFF0FFF4);
static const Color errorLight = Color(0xFFFFF0F0);
static const Color neutralLight = Color(0xFFF2F4F8);
/// Reject, and inline validation errors.
static const Color errorRed = Color(0xFFB91C1C);
// ── Five tokens retired here ──
//
// `successGreen` #34C759 (2.22:1), `infoBlue` #007AFF (4.02:1),
// `warningOrange` #FF9500 (2.20:1), `cardBlue` #0EA5E9 (2.77:1) and
// `warningYellow` #FFB800 (1.73:1) were a leftover iOS-derived set living
// alongside the tuned palette. Every one of them failed WCAG AA as text, and
// `warningOrange` was carrying the "you skipped this, this is the retry"
// notice *as text* — the one class of message a rider must not miss was the
// one that disappeared in sunlight.
//
// They are gone rather than fixed, because the tuned equivalents already
// exist and already pass: [warning] 5.02:1, [acceptGreen] 5.48:1,
// [tertiary], [deliveryAccent] 5.17:1. Two palettes for one set of meanings
// was the actual defect.
// ── Stop-type accents ────────────────────────────────────────────────
// One accent per operational mode so a rider never confuses what he is
// supposed to DO at a stop. These are the single source — `stop_type.dart`
// reads them, nothing else should hardcode these hex values.
static const Color pickupAccent = primary;
// #2563EB → #2462E8, for the same reason as [warning]: 4.49:1 on
// `cardSurface`, a hair under the floor, on the accent that names every
// delivery leg in the app.
static const Color deliveryAccent = Color(0xFF2462E8);
static const Color combinedAccent = Color(0xFF5B21B6);
// ── Stop-type CHIP fills ─────────────────────────────────────────────
// Darkened siblings of the accents above, for the small filled tag that names
// a stop's mode. A tag is a mark, not a surface.
static const Color pickupChip = Color(0xFF7F0014);
static const Color deliveryChip = Color(0xFF1E40AF);
static const Color combinedChip = Color(0xFF4C1D95);
/// Text drawn *on* a chip fill.
static const Color onChip = Color(0xFFFFFFFF);
/// ── Borderless card fill ─────────────────────────────────────────────
///
/// For cards that carry no border and no shadow, and rely on a fill to read as
/// a distinct surface — the pattern most large apps use for list cards.
///
/// Measured at a 9.4% relative-luminance drop from the page: riders reported
/// being unable to tell cards apart when a card sat ~3% from its background,
/// and with no border and no shadow the fill carries that alone, outdoors.
static const Color cardSurface = Color(0xFFEDEFF4);
/// ── The white a surface is actually made of ─────────────────────────
///
/// The panel *inside* a card — the stat strip on an Activity row, the fact
/// tiles on the verification review.
static const Color pureSurface = Color(0xFFFFFFFF);
/// ── The neutral action colour ──
///
/// The colour Uber uses for anything that is *chrome* rather than brand: the
/// selected tab, a switch that is on, the fill behind a chosen option.
///
/// Those were all the brand red at 10–14% alpha, which reads as a faint blush
/// and is not a brand moment. The red is still the red where it means
/// something: the primary button, the stop-type chips, a destructive action.
///
/// The rule Uber follows, and this now does: colour marks meaning, neutral
/// marks state.
static const Color emphasis = Color(0xFF0F172A);
/// The wash behind a selected row or tab — [emphasis] at the weight that
/// reads on the page without becoming a surface of its own.
static const Color emphasisWash = Color(0x140F172A);
/// The same wash in the brand hue, for the one place the selected state is
/// deliberately branded rather than neutral: the bottom nav's travelling
/// pill. Kept at [emphasisWash]'s exact 8% so swapping the hue cannot also
/// change how heavy the pill reads.
static const Color primaryWash = Color(0x14960019);
/// Text and icons drawn on a filled accent — brand red, accept green, a dark
/// snackbar.
static const Color onAccent = Color(0xFFFFFFFF);
/// ── The frosted brand wash on every app bar ───────────────────────────
///
/// A translucent maroon laid over the page rather than a solid slab of it.
/// Painted behind a blur (see `milerGlassSurface`), so what shows through is
/// the page's own colour softened and tinted.
///
/// Deliberately weak. At 7% the surface stays light enough for near-black
/// type, which is what keeps a title readable through a windscreen mount in
/// sun.
static const Color glassRed = Color(0x12960019);
/// ── The fill on a frosted sheet ──
///
/// A sheet over a map has something worth seeing underneath it, and a solid
/// slab throws all of it away. Frosted, the map stays legible as context while
/// the sheet stays legible as content.
///
/// Deliberately high at 88%. This is not decoration: the text over it is an
/// address and an ETA, read one-handed at a doorstep in daylight, and every
/// point of transparency spent on the effect is contrast taken off the words.
static const Color glassSheet = Color(0xE0FDFDFE);
/// The specular rim along a glass edge — what makes frost read as a pane
/// rather than as a translucent rectangle.
static const Color glassRim = Color(0x8CFFFFFF);
// ── Daylight surfaces ────────────────────────────────────────────────
// Tuned for a phone clamped to a handlebar in direct sun. Slate-tinted grey
// separates from white far more strongly outdoors than the old warm surface.
static const Color daylightSurface = Color(0xFFF8FAFC);
/// The app's near-black body colour.
static const Color slateText = Color(0xFF0F172A);
/// Card/section borders. `borderStrong` is the default for anything that
/// must read as a distinct container in sunlight; `borderSubtle` is only
/// for internal dividers inside an already-bordered card.
static const Color borderStrong = Color(0xFFCBD5E1);
static const Color borderSubtle = Color(0xFFE2E8F0);
// Task categories
static Color parcelColor = Colors.amber[100]!;
static Color groceriesColor = Colors.purple[100]!;
static Color shoppingColor = Colors.orange[100]!;
static Color healthColor = Colors.cyan[100]!;
static Color handymanColor = Colors.red[100]!;
}
extension ColorExtension on String {
Color? toColor() {
var hexColor = replaceAll("#", "");
if (hexColor.length == 6) {
hexColor = "FF$hexColor";
}
if (hexColor.length == 8) {
return Color(int.parse("0x$hexColor"));
}
return null;
}
}

View File

@@ -0,0 +1,136 @@
import 'package:flutter/material.dart';
class FontConstants {
// Miler Design System: Manrope typography (variable font) — a free,
// OFL-licensed geometric sans chosen as the closest legal stand-in for
// Uber Move (which is proprietary and cannot be bundled).
static const String fontFamily = 'Manrope';
static String fontFamilyFallback = 'PlusJakartaSans';
// Typography scale
static const double displayLg = 32.0;
static const double headlineMd = 24.0;
static const double headlineSm = 20.0;
static const double bodyLg = 18.0;
static const double bodyMd = 16.0;
static const double labelBold = 14.0;
static const double labelSm = 12.0;
static TextStyle displayLgStyle = const TextStyle(
fontSize: 32,
fontWeight: FontWeight.w700,
letterSpacing: -0.64,
fontFamily: fontFamily,
);
static TextStyle headlineMdStyle = const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
letterSpacing: -0.24,
fontFamily: fontFamily,
);
static TextStyle headlineSmStyle = const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
fontFamily: fontFamily,
);
static TextStyle bodyLgStyle = const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w500,
fontFamily: fontFamily,
);
static TextStyle bodyMdStyle = const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w400,
fontFamily: fontFamily,
);
static TextStyle labelBoldStyle = const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
fontFamily: fontFamily,
);
static TextStyle labelSmStyle = const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
fontFamily: fontFamily,
);
static double getResponsiveFontSize(BuildContext context, double baseSize) {
return baseSize;
}
static double extraSmall(BuildContext context) =>
getResponsiveFontSize(context, 10);
static double small(BuildContext context) =>
getResponsiveFontSize(context, labelSm);
static double medium(BuildContext context) =>
getResponsiveFontSize(context, bodyMd);
static double regular(BuildContext context) =>
getResponsiveFontSize(context, bodyLg);
static double large(BuildContext context) =>
getResponsiveFontSize(context, headlineSm);
static double xLarge(BuildContext context) =>
getResponsiveFontSize(context, headlineMd);
static double xxLarge(BuildContext context) =>
getResponsiveFontSize(context, 28);
static double xxxLarge(BuildContext context) =>
getResponsiveFontSize(context, 30);
static double huge(BuildContext context) =>
getResponsiveFontSize(context, displayLg);
}
class ReusableTextWidget extends StatelessWidget {
final String text;
final double? fontSize;
final double? textHeight;
final String? fontFamily;
final FontWeight? fontWeight;
final FontStyle? fontStyle;
final Color? color;
final TextAlign? textAlign;
final int? maxLines;
final TextDecoration? isUnderText;
const ReusableTextWidget({
super.key,
required this.text,
this.fontSize,
this.textHeight,
this.fontFamily,
this.fontWeight,
this.fontStyle,
this.color,
this.textAlign,
this.maxLines,
this.isUnderText,
});
@override
Widget build(BuildContext context) {
return Text(
text,
softWrap: true,
style: TextStyle(
fontSize: fontSize ?? FontConstants.medium(context),
decoration: isUnderText,
fontFamily: fontFamily ?? FontConstants.fontFamily,
decorationColor: color,
fontWeight: fontWeight ?? FontWeight.normal,
fontStyle: fontStyle ?? FontStyle.normal,
color: color ?? Colors.grey.shade900,
overflow: TextOverflow.ellipsis,
decorationStyle: TextDecorationStyle.solid,
decorationThickness: 1,
height: textHeight,
),
maxLines: maxLines,
textAlign: textAlign ?? TextAlign.start,
);
}
}

View File

@@ -0,0 +1,90 @@
class ApiConstants {
static String mainDev = "dev";
static String mainRoute = "live";
// ── There is no maps key any more ──
//
// A `googleMapsApiKey` used to live here, feeding `flutter_polyline_points`
// and both native projects. It was revoked, so every tile request and every
// Directions call came back `REQUEST_DENIED` and the app looked broken rather
// than unconfigured.
//
// The maps no longer need one: tiles come from OpenStreetMap and routes from
// OSRM, both keyless. See `lib/views/helpers/widgets/miler_map.dart` and
// `lib/helpers/miler_router.dart` — including the note on what those free
// services are and are not suitable for.
//Collection Queue - v2
static String pickupQueueDev =
"https://jupiter.doormile.app/$mainDev/api/v2/pickups/getpickupqueues";
static String pickupQueueLive =
"https://jupiter.doormile.app/$mainRoute/api/v2/pickups/getpickupqueues";
//Current Collection - v1
static String currentPickupDev =
"https://jupiter.doormile.app/$mainDev/api/v1/pickups/getpickups";
static String currentPickupLive =
"https://jupiter.doormile.app/$mainRoute/api/v1/pickups/getpickups";
//Current Collection V3 - v3 (date-bounded)
static String currentPickupV3Dev =
"https://jupiter.doormile.app/$mainDev/api/v3/pickups/getpickups";
static String currentPickupV3Live =
"https://jupiter.doormile.app/$mainRoute/api/v3/pickups/getpickups";
//Update Collection - v1
static String updatePickupDev =
"https://queue.workolik.com/live/api/v1/pickups/updatepickup";
static String updatePickupLive =
"https://queue.workolik.com/live/api/v1/pickups/updatepickup";
//Get Rider Log - v1
static String getRiderLogDev =
"https://jupiter.doormile.app/$mainDev/api/v1/partners/getriderlog";
static String getRiderLogLive =
"https://jupiter.doormile.app/$mainRoute/api/v1/partners/getriderlog";
//Create Rider Log - v2
static String createRiderLogDev =
"https://queue.workolik.com/live/api/v2/partners/createriderlog";
static String createRiderLogLive =
"https://queue.workolik.com/live/api/v2/partners/createriderlog";
//Update Rider Log - v1
static String updateRiderLogDev =
"https://jupiter.doormile.app/$mainDev/api/v1/partners/updateriderlog";
static String updateRiderLogLive =
"https://jupiter.doormile.app/$mainRoute/api/v1/partners/updateriderlog";
//Get Rider Count - v1
static String getRiderCountDev =
"https://jupiter.doormile.app/$mainDev/api/v1/partners/getridercount";
static String getRiderCountLive =
"https://jupiter.doormile.app/$mainRoute/api/v1/partners/getridercount";
//Create Break Rider Log - v2
static String createBreakRiderLogDev =
"https://queue.workolik.com/live/api/v2/partners/createbreaklog";
static String createBreakRiderLogLive =
"https://queue.workolik.com/live/api/v2/partners/createbreaklog";
//Update Break Rider Log - v2
static String updateBreakRiderLogDev =
"https://queue.workolik.com/live/api/v2/partners/updatebreaklog";
static String updateBreakRiderLogLive =
"https://queue.workolik.com/live/api/v2/partners/updatebreaklog";
//Create Collection Log - v2
static String createPickupLogDev =
"https://queue.workolik.com/live/api/v2/pickups/createpickuplog";
static String createPickupLogLive =
"https://queue.workolik.com/live/api/v2/pickups/createpickuplog";
//Summary API - v2
static String summaryApiLive =
'https://jupiter.doormile.app/$mainRoute/api/v2/partners';
//Summary Rider Weekly KMs - v1
static String summaryriderkmLive =
'https://jupiter.doormile.app/$mainRoute/api/v1/partners';
}

View File

@@ -0,0 +1,206 @@
import 'package:flutter/cupertino.dart' show CupertinoPageTransitionsBuilder;
import 'package:flutter/material.dart';
import 'package:flutter/services.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';
/// Centralized app theme (single source of truth for colors, type, and
/// component styling). Screens should lean on this instead of re-specifying
/// `ColorConstants.*` + `FontConstants.fontFamily` on every widget.
///
/// Everything below maps to the existing red/maroon brand via [ColorConstants]
/// — no colors changed, just consolidated and extended with component themes
/// (buttons, inputs, snackbars, sheets, dividers) so inline styling can be
/// retired incrementally.
class AppTheme {
AppTheme._();
static const Color _primary = ColorConstants.primary;
/// The app's one theme.
static ThemeData get light => _build();
static ThemeData _build() => ThemeData(
useMaterial3: true,
brightness: Brightness.light,
// The floor for page motion. Named routes and any route pushed without
// going through `openScreen`/`openSheet` still slide horizontally with
// the parallax and edge-swipe-back, instead of Android's default
// fade-through zoom — which, on the mid-range phones riders carry,
// reads as the new screen simply appearing.
pageTransitionsTheme: const PageTransitionsTheme(
builders: {
TargetPlatform.android: CupertinoPageTransitionsBuilder(),
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
},
),
fontFamily: FontConstants.fontFamily,
fontFamilyFallback: const ['Roboto', 'sans-serif'],
textTheme: ThemeData.light().textTheme.apply(
fontFamily: FontConstants.fontFamily,
),
primaryTextTheme: ThemeData.light().primaryTextTheme.apply(
fontFamily: FontConstants.fontFamily,
),
colorScheme: const ColorScheme(
brightness: Brightness.light,
primary: ColorConstants.primary,
onPrimary: ColorConstants.onPrimary,
primaryContainer: ColorConstants.primaryContainer,
onPrimaryContainer: ColorConstants.onPrimaryContainer,
secondary: ColorConstants.secondary,
onSecondary: ColorConstants.onSecondary,
secondaryContainer: ColorConstants.secondaryContainer,
onSecondaryContainer: ColorConstants.onSurface,
tertiary: ColorConstants.tertiary,
onTertiary: ColorConstants.onTertiary,
tertiaryContainer: ColorConstants.tertiaryContainer,
onTertiaryContainer: ColorConstants.onPrimary,
error: ColorConstants.error,
onError: ColorConstants.onError,
errorContainer: ColorConstants.errorContainer,
onErrorContainer: ColorConstants.onSurface,
surface: ColorConstants.surface,
onSurface: ColorConstants.onSurface,
surfaceContainerHighest: ColorConstants.surfaceContainerHighest,
outline: ColorConstants.outline,
outlineVariant: ColorConstants.outlineVariant,
),
appBarTheme: const AppBarTheme(
backgroundColor: ColorConstants.pureSurface,
elevation: 0,
foregroundColor: ColorConstants.onSurface,
systemOverlayStyle: SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
statusBarBrightness: Brightness.light,
),
),
scaffoldBackgroundColor: ColorConstants.surface,
progressIndicatorTheme: const ProgressIndicatorThemeData(
color: _primary,
linearTrackColor: ColorConstants.surfaceContainerHigh,
),
dividerTheme: const DividerThemeData(
color: ColorConstants.dividerColor,
thickness: 1,
space: 1,
),
cardTheme: CardThemeData(
color: Colors.white,
surfaceTintColor: Colors.transparent,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
side: const BorderSide(color: ColorConstants.outlineVariant, width: 1),
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: _primary,
foregroundColor: Colors.white,
disabledBackgroundColor: _primary.withValues(alpha: 0.35),
disabledForegroundColor: Colors.white.withValues(alpha: 0.9),
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 18),
minimumSize: const Size(double.infinity, ButtonSizes.primary),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(ButtonSizes.radius),
),
textStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
fontFamily: FontConstants.fontFamily,
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: _primary,
side: const BorderSide(color: _primary, width: 1.4),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
// ── Why this line exists ──
//
// `ElevatedButton` above pins its height to 56. `OutlinedButton`
// used to pin nothing, so it fell back to Material's default 40dp
// floor and sized itself from its own padding instead. That is
// invisible until the two appear together — which is exactly where
// they always appear, as the Cancel/Confirm pair of a dialog. Each
// of those rows overrode padding on both buttons and assumed that
// settled the height; it did not, because `styleFrom` only replaces
// the keys you hand it. Cancel came out 44–49 and Confirm came out
// 56, side by side, in three separate dialogs.
//
// Matching the height here means a plain `OutlinedButton` and a
// plain `ElevatedButton` agree by default, and nobody has to know
// about the merge order to get a straight row.
//
// Deliberately `64` wide and not `double.infinity`: outlined
// buttons are the ones that share a row or hug their label, so
// forcing full width would be wrong more often than right. A
// button that must be shorter than 56 has to say so explicitly
// with `minimumSize: Size.zero` — see [SecondaryButton].
minimumSize: const Size(64, ButtonSizes.primary),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(ButtonSizes.radius),
),
textStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
fontFamily: FontConstants.fontFamily,
),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: _primary,
textStyle: const TextStyle(
fontWeight: FontWeight.w600,
fontFamily: FontConstants.fontFamily,
),
),
),
snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating,
backgroundColor: ColorConstants.onSurface,
contentTextStyle: const TextStyle(
color: Colors.white,
fontFamily: FontConstants.fontFamily,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
),
bottomSheetTheme: BottomSheetThemeData(
backgroundColor: ColorConstants.pureSurface,
surfaceTintColor: Colors.transparent,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: ColorConstants.pureSurface,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
borderSide: const BorderSide(color: ColorConstants.outlineVariant),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
borderSide: const BorderSide(color: ColorConstants.outlineVariant),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
borderSide: const BorderSide(color: _primary, width: 2),
),
labelStyle: const TextStyle(
color: ColorConstants.onSurfaceVariant,
fontFamily: FontConstants.fontFamily,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
);
}

View File

@@ -0,0 +1,171 @@
import 'package:flutter/material.dart';
/// Miler Professional Design System Constants
/// Centralized design tokens for spacing, radius, shadows, and layout
/// ─────────────────────────────────────────────────────────────────────────
/// SHARED RADII AND ELEVATION
///
/// ── Why this used to be much bigger ──
///
/// This class shipped 43 members: a nine-step spacing scale, eleven `EdgeInsets`
/// presets, six radii, five shadows, a `cardDecoration` helper and three named
/// border radii. An audit found **six of them used, 17 times in total.** The
/// spacing scale had never been referenced once; every screen writes `16.w` or
/// `12.h` inline, which is both more readable at the call site and correct for
/// this app, because those need ScreenUtil's per-device scaling and a `const
/// double` cannot have it.
///
/// The 37 dead members were not harmless. A token nobody uses still has to be
/// read, still looks authoritative, and is the reason someone reaches for
/// `Colors.grey.shade100` instead — a design system that is mostly aspirational
/// teaches people to ignore it. So the unused members are gone and what is left
/// is only what the app actually reaches for.
///
/// The audit metric that flagged this file was itself misleading, which is worth
/// recording: 13 files import `design_constants.dart` and only 5 mention
/// `DesignConstants`. The other 8 import it for [ButtonSizes], which lives in
/// this same file and is used 72 times. Colocating a thriving class with a dead
/// one hid both facts.
///
/// **Adding to this class:** don't, until a second call site needs the value.
/// Two uses make a token; one use is a local constant.
/// ─────────────────────────────────────────────────────────────────────────
class DesignConstants {
DesignConstants._();
// ── Motion ────────────────────────────────────────────────────────────
//
// The app was using 35 distinct durations. No shared vocabulary means every
// transition is a fresh guess, and some of the guesses sat directly in the
// rider's path between two stops — an 800ms entrance and several 500ms
// cross-fades on screens he passes through 40 times a shift.
//
// Motion on a driver app is a tax paid all day. It earns its place only when
// it explains a state change; anything else is time spent standing still.
/// Press and release. Fast enough to feel like the surface responded rather
/// than animated.
static const Duration motionPress = Duration(milliseconds: 120);
/// A state change in place: a chip selecting, a tick landing, a row opening.
static const Duration motionState = Duration(milliseconds: 200);
/// A page or a sheet arriving. The ceiling for anything on the critical path.
static const Duration motionPage = Duration(milliseconds: 300);
/// Celebration, and only celebration — once per completed route, never on a
/// screen the rider is trying to get through.
static const Duration motionCelebrate = Duration(milliseconds: 600);
// ── Border radius ─────────────────────────────────────────────────────
//
// ── Three, because 26 communicated nothing ──
//
// An audit counted 26 distinct radii in use against the four declared here,
// and found the most common value in the app was 14 — `ButtonSizes.radius`,
// a *button* token, being applied to cards and sheets. Radius is one of the
// strongest cues that two things belong to the same family; at 26 values it
// says nothing, and two elements side by side at 12 and 14 read as a
// rendering bug rather than a decision.
//
// `radius2xl` (20) is retired: it was the third-most-used value and had no
// job the container radius could not do.
/// **Inner** — inputs, chips, small tiles, panels *inside* a container.
static const double radiusLg = 12.0;
/// **Container** — cards, sheets, and anything that is a surface in its own
/// right. Buttons use this too, so a button and the card it sits on agree.
static const double radiusXl = 16.0;
/// **Pill** — fully rounded: pills, circular tracks, avatars.
static const double radiusFull = 999.0;
// ── Elevation ─────────────────────────────────────────────────────────
//
// Only two survive, and both are used sparingly. Most cards in the app are
// now borderless fills with no shadow at all — see
// `ColorConstants.cardSurface` — so elevation is the exception, not the
// default it once was.
/// A card lifted just off the page.
static const List<BoxShadow> shadowSm = [
BoxShadow(color: Color(0x1A000000), blurRadius: 4, offset: Offset(0, 2)),
];
/// Something floating over content: a menu, a dragged element.
static const List<BoxShadow> shadowLg = [
BoxShadow(color: Color(0x2A000000), blurRadius: 12, offset: Offset(0, 8)),
];
}
/// ─────────────────────────────────────────────────────────────────────────
/// BUTTON SIZING — one scale for the whole app.
///
/// Buttons had drifted to a dozen different heights (44, 48, 52, 54, 56, 58)
/// across the pickup flow, Home, auth and the sheets. On a screen the rider
/// uses 30–50 times a day that reads as sloppiness, and worse, it breaks the
/// muscle memory that lets him hit a control without looking: if "the green
/// one at the bottom" is a different size on every screen, he has to aim.
///
/// Three sizes, and only three. Every button in the app must pick one.
/// ─────────────────────────────────────────────────────────────────────────
class ButtonSizes {
ButtonSizes._();
/// **Primary** — the one action a screen exists for. Full-width CTAs:
/// Accept Trip, Start Pickup, Confirm, Navigate.
static const double primary = 56.0;
/// **Secondary** — supporting actions that sit beside or below a primary:
/// Reject, Skip, Details, filter chips with a tap target.
static const double secondary = 48.0;
/// **Compact** — dense rows where several actions share a line, e.g. the
/// per-stop Accept/Reject pair inside a trip card. Still above the 44dp
/// accessibility floor.
static const double compact = 44.0;
/// Square icon-only buttons (call, overflow) match [primary] so they line up
/// with the CTA they sit next to.
static const double icon = 56.0;
/// Corner radius, shared by every button size so they read as one family.
///
/// 14 → 16 so it matches [DesignConstants.radiusXl]. At 14 a button sitting
/// on a 16pt card had a visibly tighter corner than the card holding it,
/// which is the kind of two-point difference nobody can name and everybody
/// sees.
static const double radius = DesignConstants.radiusXl;
/// Absolute floor for any tappable target, per accessibility guidance.
static const double minTapTarget = 44.0;
}
/// ─────────────────────────────────────────────────────────────────────────
/// DESIGN TRIAL — full addresses on stop cards
///
/// Turn on to see every stop card carrying its **complete** address rather than
/// the trimmed one, over [kFullAddressMaxLines] lines instead of two.
///
/// Two separate things are suppressed normally, and both are lifted together
/// here or the trial would not show what it claims to:
///
/// • **Home shortens the text.** `Trip.shortAddress` strips the tail every stop
/// on the route shares — the city, the state, the pincode — because repeating
/// "Coimbatore, Tamil Nadu 641006" on all six stops tells the rider nothing
/// about which one he is looking at. With the trial on, the raw address is
/// used instead. Bookings already shows the raw value.
///
/// • **Both screens cap the lines.** Two, so a card's height does not depend on
/// how verbose a customer was — a list of cards that step unevenly down the
/// screen is harder to scan and fits fewer stops.
///
/// Both caps exist for measured reasons, so this is a switch rather than a
/// deletion. **Set to `false` to restore the shipped behaviour.**
const bool kFullAddressTrial = true;
/// Line cap while [kFullAddressTrial] is on. Four fits a full Indian address at
/// 13sp in a stop card's column without letting one pathological record run the
/// card off the screen.
const int kFullAddressMaxLines = 4;

View File

@@ -0,0 +1,198 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/helpers/constants/Font_constant.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// THE TYPE SCALE
///
/// ── Why this exists ──
///
/// An audit counted **51 distinct font sizes** across the app and **448 sites
/// repeating `fontFamily: FontConstants.fontFamily` by hand**. Sizes in use ran
/// 9, 9.5, 10, 10.5, 11, 11.5, 12, 12.5, 13, 13.5, 14, 14.5, 15, 16, 16.5, 17,
/// 18, 19, 20, 21, 22, 23, 24, 26, 27, 28, 30, 46.
///
/// That is not carelessness. There was simply nothing to look up: every screen
/// had to invent its own typography, so screens drifted the moment they were
/// written and a reviewer had nothing to compare a pull request against. It is
/// why the page title is 23sp on three tabs, 22sp on two flow screens and 21sp
/// on another.
///
/// ── Why not `Theme.of(context).textTheme` ──
///
/// The same reason [ColorConstants] is static: the app reads type on hundreds
/// of call sites that have no `BuildContext` anywhere near them, and `AppTheme`
/// already proves the point — it builds a full `TextTheme` that **nothing in
/// the app reads**. A scale nobody can reach is not a scale.
///
/// So these are plain static getters, in the shape the app already reaches for.
///
/// ── The eight roles ──
///
/// | role | size | where |
/// |---------------|------|-------|
/// | [hero] | 28 | the one thing a screen is about — the next stop's name |
/// | [pageTitle] | 22 | app bar titles, sheet titles |
/// | [cardTitle] | 19 | a stop name in a list, a section heading |
/// | [body] | 16 | addresses, running copy, list-row titles |
/// | [caption] | 15 | supporting copy under a title |
/// | [label] | 13 | metadata, timestamps, chip text |
/// | [micro] | 12.5 | the smallest readable size in the app |
/// | [eyebrow] | 11 | letterspaced uppercase group names |
///
/// [figure] and [buttonLabel] are roles rather than sizes — a figure is a
/// number read at a glance and takes the size the context demands, a button
/// label is fixed by [ButtonSizes].
///
/// ── Rules ──
///
/// * **Same role = same size.** Do not add a size; add a role, and only when a
/// second screen needs it.
/// * **No half points** unless the half point is doing real work. `12.5` earns
/// its place as the floor; `13.5`, `14.5` and `16.5` did not and are gone.
/// * Every style already carries the family, so **no call site should name it**.
/// * Colour is a separate axis. Pass `.on(...)` to recolour, or use the named
/// helpers, so a role never hardcodes a token.
/// ─────────────────────────────────────────────────────────────────────────
class MilerType {
MilerType._();
static const String _family = FontConstants.fontFamily;
/// The one thing a screen is about. One per screen, or none.
static TextStyle get hero => TextStyle(
fontSize: 28.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.9,
height: 1.08,
color: ColorConstants.slateText,
fontFamily: _family,
);
/// App bar titles and sheet headings.
static TextStyle get pageTitle => TextStyle(
fontSize: 22.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.6,
height: 1.15,
color: ColorConstants.slateText,
fontFamily: _family,
);
/// A stop name in a list, a section heading inside a screen.
static TextStyle get cardTitle => TextStyle(
fontSize: 19.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.4,
height: 1.2,
color: ColorConstants.slateText,
fontFamily: _family,
);
/// Addresses, running copy, the title line of a compact row.
static TextStyle get body => TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
height: 1.35,
color: ColorConstants.slateText,
fontFamily: _family,
);
/// Supporting copy under a title. Grey by default — this is the role that
/// explains, and explanation is secondary to the thing being explained.
static TextStyle get caption => TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w500,
height: 1.4,
color: ColorConstants.secondaryText,
fontFamily: _family,
);
/// Metadata, timestamps, chip text, the second line of a row.
static TextStyle get label => TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w600,
height: 1.35,
color: ColorConstants.secondaryText,
fontFamily: _family,
);
/// The floor. Nothing in this app may be smaller than this and still be
/// expected to be read outdoors.
static TextStyle get micro => TextStyle(
fontSize: 12.5.sp,
fontWeight: FontWeight.w600,
height: 1.3,
color: ColorConstants.secondaryText,
fontFamily: _family,
);
/// Letterspaced uppercase group names. The quietest way to name a group, and
/// the reason most groups in this app need no outline.
static TextStyle get eyebrow => TextStyle(
fontSize: 11.sp,
fontWeight: FontWeight.w800,
letterSpacing: 1.2,
height: 1.3,
color: ColorConstants.secondaryText,
fontFamily: _family,
);
/// [pageTitle] without ScreenUtil.
///
/// App bars are built eagerly as a `PreferredSizeWidget` argument, sometimes
/// before `ScreenUtilInit` has run — and `toolbarHeight` is a fixed logical
/// value anyway, so a title that scaled with the device inside a bar that did
/// not would only find new ways to clip. This is the same role at the same
/// number, resolved without `.sp`.
static const TextStyle pageTitleFixed = TextStyle(
fontSize: 22,
fontWeight: FontWeight.w800,
letterSpacing: -0.6,
height: 1.15,
color: ColorConstants.slateText,
fontFamily: _family,
);
/// A number read at a glance: a cash amount, a distance, a duration.
///
/// Sized by its context rather than fixed — a figure in a stat column is not
/// the same size as one in a hero — so this takes the size and supplies only
/// what makes a figure a figure: heavy, tight, and tabular so a column of
/// them lines up.
static TextStyle figure(double size, {Color? color}) => TextStyle(
fontSize: size.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.3,
height: 1.1,
fontFeatures: const [FontFeature.tabularFigures()],
color: color ?? ColorConstants.slateText,
fontFamily: _family,
);
/// The label inside a button. Sized against the button's own height, so a
/// compact button does not shout like a primary one.
static TextStyle buttonLabel(double height) => TextStyle(
fontSize: (height >= 56 ? 16 : height >= 48 ? 15 : 14).sp,
fontWeight: FontWeight.w700,
letterSpacing: -0.1,
color: ColorConstants.onAccent,
fontFamily: _family,
);
}
/// Recolour and reweight a role without leaving the scale.
///
/// `MilerType.body.on(ColorConstants.secondaryText)` keeps the size, family,
/// tracking and line height of the role and changes only what is asked for —
/// which is the whole point of having roles.
extension MilerTypeTint on TextStyle {
TextStyle on(Color color) => copyWith(color: color);
TextStyle get bold => copyWith(fontWeight: FontWeight.w800);
TextStyle get semibold => copyWith(fontWeight: FontWeight.w700);
}

View File

@@ -0,0 +1,19 @@
class MqttConstants {
static const String brokerHost = '66.116.225.226'; // Updated with VPS IP
static const int brokerPort = 1883;
static const String username = 'admin';
static const String passwordString = 'Package@321#'; // Provided by user
// Topic Structure
static const String topicRiderStatus = 'doormile/riders/{riderId}/status';
static const String topicRiderProfile = 'doormile/riders/{riderId}/profile';
static const String topicRiderLocation = 'doormile/riders/{riderId}/location';
static const String topicRiderTelemetry =
'doormile/riders/{riderId}/telemetry';
static const String topicRiderLogs = 'doormile/riders/{riderId}/logs';
// Status Values
static const String statusOnline = 'Online';
static const String statusOffline = 'Offline';
static const String statusIdle = 'Idle';
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,386 @@
import 'dart:io';
import 'dart:ui' show ImageFilter;
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:miler/controllers/profile_controller.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/helpers/constants/miler_type.dart';
import 'package:miler/views/helpers/constants/Font_constant.dart';
import 'package:miler/views/helpers/constants/design_constants.dart';
/// The frosted brand surface every app bar in the app sits on.
///
/// ── Why a filter and not just a tinted box ──
///
/// A flat 7% red over white is a colour; glass is a *relationship* — it takes
/// whatever is underneath and softens it. Content scrolls under these bars, so
/// the blur has something real to work on: a card edge passing beneath the bar
/// blurs out rather than sliding under a solid lid, which is the whole visual
/// difference between "the page continues up there" and "the page stops here".
///
/// Pass as `flexibleSpace` with a transparent `backgroundColor`, so the wash
/// covers the status-bar inset too — a bar tinted only below the notch reads as
/// a stripe rather than a surface.
///
/// ── And no line under it ──
///
/// The bar closed with a hairline for as long as it was white, because a white
/// bar over a near-white page has no edge of its own and would otherwise bleed
/// into the content. The wash *is* an edge — a tinted surface against an
/// untinted one — so the rule underneath became a second boundary drawn on top
/// of the first, which is exactly the doubled-up border this app has been
/// pulling out of its cards. Glass separates by being a different surface, not
/// by being fenced.
Widget milerGlassSurface() {
return ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18),
child: Container(color: ColorConstants.glassRed),
),
);
}
/// ── A bottom sheet as frosted glass ──
///
/// The two sheets a rider spends the most time in — the stop's map sheet and
/// Update Status — were opaque white slabs. On the map screen that is the
/// whole problem: the sheet opens over the one thing the screen exists to
/// show, and takes 45% of it away permanently.
///
/// Frosted instead. The map stays visible as context underneath, softened
/// enough that an address printed on top of it is still the sharpest thing in
/// the frame. It is the same material the app bar already uses
/// ([milerGlassSurface]) — the sheets now belong to the same app as the bars
/// above them, which is the thing that made them look borrowed from somewhere
/// else.
///
/// ── What it costs ──
///
/// A `BackdropFilter` re-samples everything behind it every frame, and this one
/// is full-width over a map that repaints as it pans. Sigma is held at 18 —
/// the same as the app bar, tuned on a mid-range Android — rather than the 30+
/// that reads as heavier frost, because on this phone the difference between
/// them is frames, and a sheet that stutters when dragged is worse-looking than
/// one that is merely less blurry.
Widget milerGlassSheet({required Widget child, double radius = 28}) {
final shape = BorderRadius.vertical(top: Radius.circular(radius));
return DecoratedBox(
// Outside the clip: a shadow drawn inside its own ClipRRect is a shadow
// that has been clipped away.
decoration: BoxDecoration(
borderRadius: shape,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.13),
blurRadius: 26,
offset: const Offset(0, -6),
),
],
),
child: ClipRRect(
borderRadius: shape,
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18),
child: DecoratedBox(
decoration: BoxDecoration(
color: ColorConstants.glassSheet,
borderRadius: shape,
// The rim, not a border: it is one pixel of light on the curve, and
// it is what separates "frosted glass" from "white box at 88%".
border: Border.all(color: ColorConstants.glassRim, width: 1),
),
child: child,
),
),
),
);
}
/// The Doormile mark, at the one size the whole app agrees on.
///
/// It no longer appears in any Dashboard app bar — see [MilerAppBar] — and now
/// belongs to the screens where the rider has not started working yet: intro,
/// sign-in, MPIN. One size across all of them so the mark does not appear to
/// resize as he moves through the flow.
///
/// ── Why this is not just a `SizedBox` around an `Image` ──
///
/// `doormile_icon.png` is a 1024×1024 app-icon export, and the artwork inside
/// it is inset: the ink spans x/y 170→853, so it fills **66.8%** of the canvas
/// and the outer third is transparent padding. With `BoxFit.contain` that
/// padding is laid out as if it were artwork, so a 34px box drew a mark only
/// ~23px wide. That is the real reason the logo read as small — most of the box
/// it was given was empty.
///
/// So the box grew *and* the dead margin is reclaimed: [_boxSize] sets the
/// footprint, and [_inkScale] scales the image up by the inverse of that fill
/// ratio so the artwork itself fills the box. Ink goes from ~23px to ~41px —
/// roughly 80% bigger — while the space the widget occupies grows by far less.
///
/// The scale is safe rather than clever: the padding is exactly symmetric (170
/// on every side), so scaling about the centre crops only transparent pixels.
/// [_inkScale] is deliberately a little under the theoretical 1.50 so the mark
/// keeps a hair of breathing room instead of touching its own bounds.
///
/// If the asset is ever re-exported tight to the artwork, drop [_inkScale] to
/// 1.0 — do not delete the box size with it.
/// ─────────────────────────────────────────────────────────────────────────
class MilerBrandMark extends StatelessWidget {
const MilerBrandMark({super.key});
/// Footprint. Fixed `.w` so font scaling never resizes the mark.
static const double _boxSize = 44;
/// 1 / 0.668, trimmed. See the class note.
static const double _inkScale = 1.42;
@override
Widget build(BuildContext context) {
return SizedBox(
width: _boxSize.w,
height: _boxSize.w,
// Excluded from semantics: the mark is decoration on a screen the rider
// is already looking at, and the page title beside it is the real label.
child: ExcludeSemantics(
child: Transform.scale(
scale: _inkScale,
child: Image.asset(
'assets/images/doormile_icon.png',
fit: BoxFit.contain,
// Crisp at 3x: the source is 1024px against a ~132px target.
filterQuality: FilterQuality.medium,
),
),
),
);
}
}
/// The rider's own face, at the size the brand mark used to occupy.
///
/// Home is the one screen where the top-left slot belongs to the rider rather
/// than to Doormile: it is his shift, his queue, his earnings underneath. So
/// the slot holds his profile photo, and when he has not set one it holds the
/// first letter of his name on a filled circle — the same fallback every
/// contact list uses, and one that still reads as *him* rather than as a
/// generic person glyph.
///
/// The photo is a local file path (set in Profile → change photo, persisted as
/// `profile_image_path`), so it is watched through [ProfileController] and the
/// bar repaints the moment he picks a new one. If the file has since been
/// deleted from disk — gallery cleanup, an OS cache sweep of a picked image —
/// [File.existsSync] catches it and the initial takes over rather than the
/// circle rendering as a broken box.
class MilerAvatar extends StatelessWidget {
/// Name to take the initial from. Falls back to [ProfileController.userName]
/// when null, so callers that already hold the name do not have to wait on
/// the controller and callers that do not can leave it off.
final String? name;
const MilerAvatar({super.key, this.name});
/// Matches [MilerBrandMark._boxSize] — this widget takes over that slot, and
/// the bar's height is built around it.
static const double _boxSize = 44;
@override
Widget build(BuildContext context) {
final ProfileController? controller = Get.isRegistered<ProfileController>()
? Get.find<ProfileController>()
: null;
if (controller == null) {
return _circle(_initialOf(name ?? ''), null);
}
return Obx(() {
final String path = controller.imagePath.value;
final String source = (name ?? '').trim().isNotEmpty
? name!
: controller.userName.value;
File? photo;
try {
if (path.isNotEmpty && File(path).existsSync()) photo = File(path);
} catch (_) {}
return _circle(_initialOf(source), photo);
});
}
/// First letter of the first word, upper-cased. 'M' for Miler when there is
/// no name yet — a blank circle would read as a loading state that never
/// resolves.
String _initialOf(String source) {
final String trimmed = source.trim();
if (trimmed.isEmpty) return 'M';
return trimmed.characters.first.toUpperCase();
}
Widget _circle(String initial, File? photo) {
return Semantics(
label: 'Your profile',
image: photo != null,
child: Container(
width: _boxSize.w,
height: _boxSize.w,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: photo != null
? ColorConstants.neutralLight
: ColorConstants.primary,
image: photo != null
? DecorationImage(image: FileImage(photo), fit: BoxFit.cover)
: null,
),
alignment: Alignment.center,
child: photo != null
? null
: Text(
initial,
style: TextStyle(
// Fixed .sp off a fixed box: the circle does not grow with
// the system font scale, so neither can the letter inside it.
fontSize: 19.sp,
fontWeight: FontWeight.w800,
height: 1,
color: Colors.white,
fontFamily: FontConstants.fontFamily,
),
),
),
);
}
}
/// One consistent top bar for every Dashboard page.
///
/// ── No brand mark here ──
///
/// The logo used to lead every bar. It is gone from all of them: the rider
/// opens this app to work, not to be told twice a minute whose app it is, and
/// on a pushed sub-page the mark was pure repetition of what the screen he came
/// from already said. Home keeps a mark-shaped slot but fills it with the
/// rider's own face — see [MilerAvatar]. Everywhere else the title starts at
/// the margin, which is also the widest the title has ever been able to run.
///
/// The title takes the type weight — 23sp, tight tracking, near-black — on the
/// frosted brand wash every bar in the app shares. Bar height is 76 with the
/// content pushed down [_titleTopInset], so the type sits off the status bar
/// instead of against it.
///
/// ── One line, not two ──
///
/// There used to be a caption under every title: "5 stops done today", "Trip 2 ·
/// 4 bookings in queue". It was the smallest type on the screen, in the one
/// place a rider never looks twice — he reads a page title once, on arrival,
/// and after that the bar is furniture. Worse, every one of those captions
/// duplicated something the page itself already said louder and lower down: the
/// trip and its progress are spelled out on the rail, the day's tally sits on
/// the Activity summary card. Two sources for one number is how they drift.
///
/// So the bar carries the page's name and nothing else, and the page carries
/// the facts.
///
/// Pass an optional [trailing] widget (status toggle, map button, notifications
/// icon).
class MilerAppBar extends StatelessWidget implements PreferredSizeWidget {
final String title;
final Widget? trailing;
/// Draws a back arrow before the title.
///
/// Off by default because this bar was built for the four tabs, which have
/// nothing to go back to. It has to be opt-in rather than automatic: the bar
/// sets `automaticallyImplyLeading: false`, so a page that is pushed rather
/// than tabbed to — Earnings, since it moved under Account — would otherwise
/// render with no way out at all.
final bool showBack;
const MilerAppBar({
super.key,
required this.title,
this.trailing,
this.showBack = false,
});
/// Fixed logical bar height. 64 → 76 to absorb [_titleTopInset] without
/// squeezing the title against the status bar.
static const double _barHeight = 76;
/// Pushes the type down off the status bar. The bar was flush against it,
/// which read as clipped rather than as a bar.
static const double _titleTopInset = 12;
@override
Size get preferredSize => Size.fromHeight(_barHeight);
@override
Widget build(BuildContext context) {
return AppBar(
backgroundColor: Colors.transparent,
flexibleSpace: milerGlassSurface(),
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: false,
titleSpacing: 16.w,
toolbarHeight: _barHeight,
automaticallyImplyLeading: false,
title: Padding(
padding: EdgeInsets.only(top: _titleTopInset.h),
child: Row(
children: [
if (showBack) ...[
// Sized to the tap-target floor and pulled left by its own
// padding, so the arrow's *glyph* still lands on the page margin
// rather than the button's box starting there.
Padding(
padding: EdgeInsets.only(right: 4.w),
child: Semantics(
button: true,
label: 'Back',
child: InkWell(
onTap: () => Navigator.of(context).maybePop(),
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
child: SizedBox(
width: 48.w,
height: 48.w,
// One back glyph app-wide — this was one of three.
child: Icon(
Icons.arrow_back_rounded,
size: 22.sp,
color: ColorConstants.slateText,
),
),
),
),
),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// One page-title size app-wide. This was 23 while pushed
// screens were 22 and 21 — a one-point difference nobody can
// name and everybody sees as the heading jumping on tab
// change.
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: MilerType.pageTitleFixed,
),
],
),
),
if (trailing != null) ...[SizedBox(width: 12.w), trailing!],
],
),
),
);
}
}

View File

@@ -0,0 +1,447 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart' show LatLng;
import 'package:miler/helpers/miler_router.dart';
/// The router travels with the map: every screen that draws one also asks for a
/// route, and neither is any use without the other.
export 'package:miler/helpers/miler_router.dart' show MilerRouter;
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/helpers/constants/Font_constant.dart';
import 'package:miler/views/helpers/constants/design_constants.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// MAPS, WITHOUT A KEY
///
/// Every map in this app was a `GoogleMap`, and every one of them rendered
/// blank: the key the app shipped with is revoked, so the tile requests, the
/// marker layer and the Directions call all failed at once. A blank grey
/// rectangle where the route should be is indistinguishable from a broken app,
/// and no amount of Dart fixes it — the failure is entirely in credentials.
///
/// So the maps no longer need credentials.
///
/// • **Tiles** — OpenStreetMap raster tiles, served free and keyless, drawn by
/// `flutter_map`. Nothing to provision, nothing to revoke.
/// • **Routes** — OSRM's public router, also free and keyless, in place of the
/// Directions API. It returns the same thing the app used: a driving
/// geometry between two points.
///
/// ── What this costs, and what it buys ──
///
/// Both services are community-run and neither is contracted for production
/// traffic: OSM's tile policy and OSRM's demo server are fine for development
/// and a small fleet, and will rate-limit a large one. Swapping either is one
/// constant ([kMilerTileUrl]) and one function ([MilerRouter.route]) — that is
/// why they are here rather than inlined at four call sites. See the note in
/// README before shipping to a real fleet.
///
/// What it buys immediately: the map is visible again on every screen, and
/// markers stop being textures. A Google Maps marker is a `BitmapDescriptor` —
/// a bitmap the app has to *paint on a canvas* to put a number in a circle
/// (`multi_map.dart` had 100 lines doing exactly that). Here a marker is a
/// widget, so it is drawn with the same colours, fonts and radii as the rest of
/// the app, and it changes when the theme does.
/// ─────────────────────────────────────────────────────────────────────────
/// Standard OSM raster tiles. Keyless.
const String kMilerTileUrl = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
/// Sent with every tile request. OSM's policy requires an identifiable agent —
/// an app that does not send one is the first thing they block.
const String kMilerTileAgent = 'com.doormile.partner';
/// ── The map surface ──
///
/// One widget for all four map screens, so tile source, attribution, gesture
/// rules and the "no rotation" decision are made once. A rider on a bike does
/// not want a map that spins because his thumb dragged crooked, and a rotated
/// map with north somewhere else is unreadable at a glance — which is the only
/// way this map is ever read.
class MilerMap extends StatelessWidget {
/// Optional: a map nobody drives — a static thumbnail in a sheet — needs no
/// controller, and creating one per build would leak a disposable.
final MapController? controller;
final LatLng initialCenter;
final double initialZoom;
final List<Polyline> polylines;
final List<Marker> markers;
final VoidCallback? onReady;
/// Extra layers drawn under the markers — accuracy circles, and the like.
final List<Widget> underlays;
const MilerMap({
super.key,
this.controller,
required this.initialCenter,
this.initialZoom = 14,
this.polylines = const [],
this.markers = const [],
this.underlays = const [],
this.onReady,
});
@override
Widget build(BuildContext context) {
return FlutterMap(
mapController: controller,
options: MapOptions(
initialCenter: initialCenter,
initialZoom: initialZoom,
minZoom: 3,
maxZoom: 18,
backgroundColor: ColorConstants.surfaceContainerLow,
interactionOptions: const InteractionOptions(
// Everything except rotation.
flags:
InteractiveFlag.drag |
InteractiveFlag.flingAnimation |
InteractiveFlag.pinchMove |
InteractiveFlag.pinchZoom |
InteractiveFlag.doubleTapZoom,
),
onMapReady: onReady,
),
children: [
TileLayer(
urlTemplate: kMilerTileUrl,
userAgentPackageName: kMilerTileAgent,
maxNativeZoom: 19,
// Keeps the last good tile on screen while the next zoom loads,
// instead of flashing the background through.
keepBuffer: 4,
),
if (polylines.isNotEmpty) PolylineLayer(polylines: polylines),
...underlays,
if (markers.isNotEmpty) MarkerLayer(markers: markers),
const _MilerAttribution(),
],
);
}
}
/// OSM's licence requires visible credit. Kept to a hairline chip in the
/// corner: it is a legal obligation, not a piece of the interface, and the
/// rider is here for the route.
class _MilerAttribution extends StatelessWidget {
const _MilerAttribution();
@override
Widget build(BuildContext context) {
return Align(
alignment: Alignment.bottomLeft,
child: Padding(
padding: const EdgeInsets.only(left: 6, bottom: 4),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.72),
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
child: Text(
'© OpenStreetMap',
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w600,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
),
),
);
}
}
// ── Camera ──────────────────────────────────────────────────────────────────
/// Animated camera moves for a [MapController].
///
/// `flutter_map`'s controller only teleports — `move` and `fitCamera` both take
/// effect on the next frame. The route screens were built around a camera that
/// eases: the line waits for the camera to land before it draws, and a cut
/// makes that choreography read as a glitch instead of as a flight. So the
/// tween lives here, over the controller's instant moves.
class MilerMapCamera {
final MapController controller;
final TickerProvider vsync;
AnimationController? _anim;
MilerMapCamera({required this.controller, required this.vsync});
void dispose() {
_anim?.dispose();
_anim = null;
}
/// Eases to the camera that frames [points], and completes when it lands.
Future<void> fit(
List<LatLng> points, {
EdgeInsets padding = const EdgeInsets.all(60),
Duration duration = const Duration(milliseconds: 650),
double maxZoom = 16.5,
}) async {
if (points.isEmpty) return;
final fit = CameraFit.coordinates(
coordinates: points,
padding: padding,
maxZoom: maxZoom,
).fit(controller.camera);
await moveTo(fit.center, fit.zoom, duration: duration);
}
/// Eases to a centre and zoom, and completes when it lands.
Future<void> moveTo(
LatLng target,
double zoom, {
Duration duration = const Duration(milliseconds: 650),
}) async {
_anim?.dispose();
final startCentre = controller.camera.center;
final startZoom = controller.camera.zoom;
if (duration == Duration.zero) {
controller.move(target, zoom);
return;
}
final anim = AnimationController(vsync: vsync, duration: duration);
_anim = anim;
final curve = CurvedAnimation(parent: anim, curve: Curves.easeInOutCubic);
final completer = Completer<void>();
void tick() {
final t = curve.value;
controller.move(
LatLng(
startCentre.latitude + (target.latitude - startCentre.latitude) * t,
startCentre.longitude +
(target.longitude - startCentre.longitude) * t,
),
startZoom + (zoom - startZoom) * t,
);
}
anim.addListener(tick);
anim.forward().whenComplete(() {
if (!completer.isCompleted) completer.complete();
});
return completer.future;
}
}
// ── Pins ────────────────────────────────────────────────────────────────────
/// A map pin, as a widget.
///
/// The stock Google teardrop came in nine fixed hues, none of which is this
/// app's maroon, so a pickup and a delivery were told apart by "red-ish" and
/// "green-ish" and by nothing else. These take [StopKindUi.accent] directly, so
/// the pin on the map is the exact colour of the chip on the card the rider
/// tapped to get here.
class MilerPin extends StatelessWidget {
final Color color;
final IconData icon;
/// Draws the white ring and shadow larger, for the stop being worked.
final bool emphasised;
const MilerPin({
super.key,
required this.color,
required this.icon,
this.emphasised = false,
});
static const double size = 40;
static const double tail = 10;
@override
Widget build(BuildContext context) {
final head = emphasised ? size : size - 6;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: head,
height: head,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 3),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.28),
blurRadius: 8,
offset: const Offset(0, 3),
),
],
),
child: Icon(icon, size: head * 0.46, color: Colors.white),
),
// The stem that makes a circle read as "this exact point" rather than
// "somewhere under here".
CustomPaint(
size: const Size(12, tail),
painter: _PinTailPainter(color),
),
],
);
}
}
/// A numbered stop on a multi-stop route. Same shape as [MilerPin], carrying
/// the stop's position in the run instead of an icon — which is what the rider
/// is matching against the list below.
class MilerNumberPin extends StatelessWidget {
final int number;
final Color color;
final bool done;
const MilerNumberPin({
super.key,
required this.number,
required this.color,
this.done = false,
});
static const double size = 34;
static const double tail = 8;
@override
Widget build(BuildContext context) {
final fill = done ? ColorConstants.acceptGreen : color;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: size,
height: size,
alignment: Alignment.center,
decoration: BoxDecoration(
color: fill,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2.5),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.25),
blurRadius: 7,
offset: const Offset(0, 3),
),
],
),
child: done
? const Icon(Icons.check_rounded, size: 16, color: Colors.white)
: Text(
'$number',
style: const TextStyle(
fontSize: 14,
height: 1,
fontWeight: FontWeight.w800,
color: Colors.white,
fontFamily: FontConstants.fontFamily,
),
),
),
CustomPaint(size: const Size(10, tail), painter: _PinTailPainter(fill)),
],
);
}
}
/// The rider's own position: a dot, not a pin. A pin says "a thing is here";
/// a dot says "you are here", and every map app in the world agrees on it.
class MilerRiderDot extends StatelessWidget {
const MilerRiderDot({super.key});
static const double size = 22;
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: ColorConstants.deliveryAccent,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 3),
boxShadow: [
BoxShadow(
color: ColorConstants.deliveryAccent.withValues(alpha: 0.45),
blurRadius: 10,
spreadRadius: 2,
),
],
),
);
}
}
class _PinTailPainter extends CustomPainter {
final Color color;
const _PinTailPainter(this.color);
@override
void paint(Canvas canvas, Size size) {
final path = Path()
..moveTo(0, 0)
..lineTo(size.width / 2, size.height)
..lineTo(size.width, 0)
..close();
canvas.drawPath(
path,
Paint()
..color = color
..style = PaintingStyle.fill,
);
}
@override
bool shouldRepaint(_PinTailPainter old) => old.color != color;
}
/// A marker anchored so its *point* sits on the coordinate, not its centre.
Marker milerMarker({
required LatLng point,
required Widget child,
required double width,
required double height,
}) {
return Marker(
point: point,
width: width,
height: height,
// The pin's tip is at the bottom edge of its box, so the box hangs above
// the coordinate. Google did this for us; flutter_map centres by default,
// which puts the tip half a pin south of the address.
alignment: Alignment.topCenter,
child: child,
);
}
/// Metres between two coordinates, on the great circle.
///
/// `Geolocator.distanceBetween` does this too, but it is a platform call: it
/// cannot run in a test, and the route screens ask for it once per point of a
/// several-hundred-point line.
double milerDistanceMetres(LatLng a, LatLng b) {
const earth = 6371000.0;
final dLat = _rad(b.latitude - a.latitude);
final dLng = _rad(b.longitude - a.longitude);
final h =
math.sin(dLat / 2) * math.sin(dLat / 2) +
math.cos(_rad(a.latitude)) *
math.cos(_rad(b.latitude)) *
math.sin(dLng / 2) *
math.sin(dLng / 2);
return earth * 2 * math.atan2(math.sqrt(h), math.sqrt(1 - h));
}
double _rad(double deg) => deg * math.pi / 180.0;

View File

@@ -0,0 +1,278 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:miler/views/helpers/constants/design_constants.dart';
/// ───────────────────────────────────────────────────────────────────────────
/// PAGE MOTION — one vocabulary for every screen you can navigate into
///
/// Before this file the app had three different answers to "what happens when
/// I tap a row": `Get.to` with GetX's default transition, a bare
/// `MaterialPageRoute` (which on Android is the fade-through zoom, and reads as
/// a hard cut on mid-range hardware), and a `pushReplacement` with no
/// transition intent at all. Tapping *Edit Profile* looked nothing like tapping
/// *Open map*, and neither looked like anything.
///
/// Two motions, and only two, from here on:
///
/// • [MilerSlideRoute] — right → left. For the *work*: a stop's map, the
/// multi-stop route, the next step of onboarding. These are places the
/// rider goes on his way somewhere else, so they behave like pages — the
/// one below parallaxes, and a left-edge swipe goes back.
///
/// • [MilerSheetRoute] — bottom → top, and back down the same way. For
/// anything laid *over* what the rider was doing: every screen under
/// Account, plus stop verification and payment collection. It rises, it
/// covers, it drops away.
///
/// ── Why Account is all sheets ──
///
/// These screens are leaves. You open Notifications, Saved Address, FAQ or
/// Terms to do one thing, and then you are done with it — there is nowhere
/// onward to go, which is why they all close with an × rather than a back
/// arrow. A cross that dismissed a page sideways contradicted itself: the icon
/// promised a layer dropping away and the motion showed a page being left
/// behind. Now the icon and the direction say the same thing, which is what
/// Uber's account screens do.
///
/// Both routes share the same expo-out entrance — fast off the mark, long soft
/// landing, motion that arrives rather than stops. They differ on the way out,
/// and deliberately: see [kMilerExitCurve] and [kMilerSheetExitCurve].
/// ───────────────────────────────────────────────────────────────────────────
/// Fast start, long settle. The thing that makes a push feel "smooth" rather
/// than "fast" — most of the distance is covered early, so the eye reads it as
/// responsive, and the tail is slow enough that it never appears to snap.
const Curve kMilerEnterCurve = Cubic(0.16, 1.0, 0.30, 1.0);
/// Leaving a *screen* — a map, a stop, something you were working inside.
/// The rider's decision is already made, so this gets out of the way.
const Curve kMilerExitCurve = Cubic(0.4, 0.0, 0.7, 0.2);
/// Leaving a *sheet*, which is a different gesture entirely.
///
/// A task screen dropping back down is the app putting something away, and the
/// eye follows it the whole way — so it is eased at both ends rather than
/// snatched off the screen. [kMilerExitCurve] was written for a page sliding
/// sideways out of view, where nobody is watching the tail; used on a
/// full-height sheet it read as the page being deleted rather than closed.
const Curve kMilerSheetExitCurve = Cubic(0.33, 0.0, 0.15, 1.0);
// ── The 300ms ceiling ──
//
// 360/400/380 were within a hair of each other and all above the ceiling the
// motion scale sets for anything on the rider's critical path. He crosses these
// transitions 40 times a shift; 60–100ms each way is time spent watching the
// app instead of working. The curves are unchanged — they are what make the
// motion read as considered — only the duration comes down.
const Duration kMilerPushIn = DesignConstants.motionPage;
const Duration kMilerPushOut = Duration(milliseconds: 240);
const Duration kMilerSheetIn = DesignConstants.motionPage;
/// Deliberately close to the entrance, and much slower than the 290ms it was.
///
/// Dismissal had been treated as dead time to be minimised — the standard
/// instinct, and wrong here. Tapping × sent a full-height page down the screen
/// in under a third of a second, which does not read as "closing", it reads as
/// the screen vanishing. Going and coming should take about the same time,
/// because they are the same distance.
const Duration kMilerSheetOut = Duration(milliseconds: 240);
/// Right → left drill-down, with the iOS parallax on the page underneath and
/// an interactive left-edge back-swipe.
///
/// Cupertino's own 500ms is a beat too slow for a screen a rider opens forty
/// times a shift, so the duration is pulled in; the curve stays Cupertino's
/// because it is what the back-gesture physics are written against.
class MilerSlideRoute<T> extends CupertinoPageRoute<T> {
/// Off for screens whose own content wants horizontal drags — a full-bleed
/// Google Map, most of all. There, an edge swipe meant to nudge the map would
/// throw the rider out of live navigation, so the back arrow is the only way
/// out and that is the correct trade.
final bool swipeToGoBack;
MilerSlideRoute({
required super.builder,
this.swipeToGoBack = true,
super.settings,
super.maintainState,
});
@override
Duration get transitionDuration => kMilerPushIn;
@override
Duration get reverseTransitionDuration => kMilerPushOut;
@override
bool get popGestureEnabled => swipeToGoBack && super.popGestureEnabled;
}
/// Bottom → top task screen, Uber-style.
///
/// The incoming page rises the full height of the screen on the expo-out curve
/// while the page it covers eases back a few percent — the small counter-motion
/// is what stops a full-screen slab from reading as a jump-cut. When a further
/// screen is pushed on top of *this* one, it slides up and out of the way by
/// the same small amount, so a two-deep task flow still parallaxes.
class MilerSheetRoute<T> extends PageRouteBuilder<T> {
MilerSheetRoute({
required WidgetBuilder builder,
super.settings,
super.maintainState,
}) : super(
pageBuilder: (context, animation, secondaryAnimation) =>
builder(context),
transitionDuration: kMilerSheetIn,
reverseTransitionDuration: kMilerSheetOut,
// A task screen is a modal presentation: Flutter uses this to keep
// the page below from running its own outgoing slide, which would
// fight the upward motion.
fullscreenDialog: true,
transitionsBuilder: _buildSheetTransition,
);
static Widget _buildSheetTransition(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
final Animation<Offset> enter =
Tween<Offset>(begin: const Offset(0, 1), end: Offset.zero).animate(
CurvedAnimation(
parent: animation,
curve: kMilerEnterCurve,
reverseCurve: kMilerSheetExitCurve,
),
);
// Pushed over by yet another screen: retreat slightly upward instead of
// sitting frozen under the new page.
final Animation<Offset> recede =
Tween<Offset>(begin: Offset.zero, end: const Offset(0, -0.06)).animate(
CurvedAnimation(
parent: secondaryAnimation,
curve: kMilerEnterCurve,
reverseCurve: kMilerSheetExitCurve,
),
);
return SlideTransition(
position: recede,
child: SlideTransition(position: enter, child: child),
);
}
}
/// ── Call sites ──────────────────────────────────────────────────────────────
///
/// `openScreen(context, const FaqPage())` reads at the tap site as "go deeper",
/// `openSheet(context, const Profile())` as "do a thing and come back". Both
/// return the popped result, so they drop straight into the places that were
/// awaiting a `Navigator.push`.
/// Right → left. The default for anything you navigate *into*.
///
/// Pass `swipeToGoBack: false` when the destination is a map or anything else
/// that handles its own horizontal drags.
Future<T?> openScreen<T>(
BuildContext context,
Widget page, {
bool swipeToGoBack = true,
}) {
return Navigator.of(context).push<T>(
MilerSlideRoute<T>(builder: (_) => page, swipeToGoBack: swipeToGoBack),
);
}
/// Bottom → top. For task screens you complete or abandon.
Future<T?> openSheet<T>(BuildContext context, Widget page) {
return Navigator.of(
context,
).push<T>(MilerSheetRoute<T>(builder: (_) => page));
}
/// Bottom → top, replacing the current screen — for the end of a flow, where
/// going "back" to the screen you just finished would be wrong.
Future<T?> replaceWithSheet<T, TO>(BuildContext context, Widget page) {
return Navigator.of(
context,
).pushReplacement<T, TO>(MilerSheetRoute<T>(builder: (_) => page));
}
/// ───────────────────────────────────────────────────────────────────────────
/// THE SMALLER SURFACES — dialogs and bottom sheets
///
/// A page is not the only thing that arrives. The app asks for confirmation in
/// dialogs and offers choices in sheets far more often than it pushes a screen,
/// and both were using framework defaults that predate everything above.
/// ───────────────────────────────────────────────────────────────────────────
/// Bottom sheets: Flutter's default is 250ms in / 200ms out on
/// `decelerate` — quick enough to register as a pop rather than a rise, which
/// is especially wrong for the sheets a rider reads (skip reasons, stop
/// details, payment) before deciding something. Same curve as the pages, so a
/// sheet and a pushed screen feel like the same hand moved them.
///
/// Pass as `sheetAnimationStyle:` to `showModalBottomSheet`.
const AnimationStyle kMilerSheetStyle = AnimationStyle(
duration: Duration(milliseconds: 340),
curve: kMilerEnterCurve,
reverseDuration: Duration(milliseconds: 300),
reverseCurve: kMilerSheetExitCurve,
);
/// For the tall ones — a sheet that covers three-quarters of the screen is
/// closer to a page than to a picker, and travelling that far in 340ms reads as
/// a slam. Distance wants time: the same curve, given room to decelerate.
const AnimationStyle kMilerLargeSheetStyle = AnimationStyle(
duration: Duration(milliseconds: 430),
curve: kMilerEnterCurve,
reverseDuration: Duration(milliseconds: 360),
reverseCurve: kMilerSheetExitCurve,
);
/// Confirmation dialogs that grow into place instead of blinking on.
///
/// Material's `showDialog` is a 150ms straight fade with no movement at all —
/// the dialog is simply there, at full size, before the eye has found it. This
/// keeps the fade but adds the small scale-up that tells you *where* it came
/// from, and gives the barrier the same clock so the screen dims with it rather
/// than a beat ahead.
///
/// Drop-in for `showDialog`: same `context`/`builder`/`barrierDismissible`.
Future<T?> showAppDialog<T>({
required BuildContext context,
required WidgetBuilder builder,
bool barrierDismissible = true,
Color barrierColor = const Color(0x8A000000),
bool useRootNavigator = true,
}) {
return showGeneralDialog<T>(
context: context,
barrierDismissible: barrierDismissible,
barrierLabel: barrierDismissible
? MaterialLocalizations.of(context).modalBarrierDismissLabel
: null,
barrierColor: barrierColor,
useRootNavigator: useRootNavigator,
transitionDuration: const Duration(milliseconds: 260),
pageBuilder: (context, _, _) => Builder(builder: builder),
transitionBuilder: (context, animation, _, child) {
// `drive` rather than a `CurvedAnimation`: this builder runs every frame
// of the transition, and a CurvedAnimation built here would be a new
// listener-holding object each time. A CurveTween is a pure function and
// reads the same in reverse, so dismissal is the arrival played backwards.
final Animation<double> curved = animation.drive(
CurveTween(curve: Curves.easeOutCubic),
);
return FadeTransition(
opacity: curved,
child: ScaleTransition(
scale: curved.drive(Tween<double>(begin: 0.94, end: 1.0)),
child: child,
),
);
},
);
}

View File

@@ -0,0 +1,336 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.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/constants/design_constants.dart';
/// ───────────────────────────────────────────────────────────────────────────
/// THE ACCOUNT SECTION'S VISUAL LANGUAGE
///
/// Account is nine screens deep — Edit Profile, Saved Addresses, Alert Sound,
/// Notifications, FAQ, Help, Contact Support, Terms, Rewards — and until now
/// each one had been drawn by hand. They disagreed about the page colour (a
/// warm surface on one, `grey.shade50` on the next, `grey.shade100` on a
/// third), about the card (18r hairline here, 16r `grey.shade100` there, a
/// 1.5px `borderStrong` on a fourth), about text colour (tokens on some pages,
/// `Colors.black87` and `grey.shade600` on others), and about whether type
/// scaled with the device at all. Tapping the bell from Account moved the rider
/// to what looked like a different product.
///
/// The fix is not to restyle nine files nine times. It is to have one card, one
/// row and one page body, and to let every screen in the section be assembled
/// from them — which is what this file is. The look is the Account page's own,
/// because that is the screen the rider sees first and the one the others have
/// to agree with.
///
/// Colour comes from [ColorConstants] only. Nothing in this file, and nothing
/// built on it, should name a hex value or a `Colors.*` swatch: the brand is
/// red, the neutrals are tokens, and a screen that invents its own grey is a
/// screen that will drift again the next time someone edits it.
/// ───────────────────────────────────────────────────────────────────────────
/// The one card. White, softly lifted, hairline edge — the Account page's.
class SettingsCard extends StatelessWidget {
final Widget child;
final EdgeInsetsGeometry? padding;
const SettingsCard({super.key, required this.child, this.padding});
static BoxDecoration decoration() => BoxDecoration(
color: ColorConstants.surfaceContainerLowest,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
border: Border.all(color: ColorConstants.dividerColor),
boxShadow: [
BoxShadow(
color: ColorConstants.onSurface.withValues(alpha: 0.04),
blurRadius: 16,
offset: const Offset(0, 6),
),
],
);
@override
Widget build(BuildContext context) {
return Container(padding: padding, decoration: decoration(), child: child);
}
}
/// A list of [SettingsRow]s, hairline-separated, sitting directly on the page.
///
/// ── No card ──
///
/// These groups used to be cards: a filled panel with a hairline border and a
/// soft shadow, one per section. On the Account page that meant five stacked
/// rectangles — Money, Account, Appearance, Support, Logout — each drawn round
/// rows that were already separated by a section heading and a 22pt gap. The
/// box was saying, a second time, what the heading and the whitespace had
/// already said.
///
/// It cost something, too. A card is a surface, and a surface has to be told
/// apart from the page behind it — so the whole Account page became a stack of
/// grey slabs, with nothing on screen that was actually a *thing* rather than a
/// container for things.
///
/// Uber's account page has no cards at all: section label, rows, hairline
/// between them, gap, next section. The list *is* the structure. That is what
/// this is now — and because every screen in this section is assembled from
/// this file, all nine of them lose the boxes together.
///
/// ── And no rules between the rows ──
///
/// With the card gone, the hairline under every row was the last thing still
/// drawing boxes: nine rules down the Account page, each separating two lines
/// that are already a title and a subtitle apart. Uber's account list has none.
/// The rows sit close, the type does the separating, and the section headings
/// are the only structure on the page.
///
/// [ruled] puts them back for the few lists that genuinely need one — a long
/// choice list of identical single-line rows, where nothing else distinguishes
/// one from the next.
///
/// Pass the rows in order; the divider is drawn here rather than by each row,
/// so a row never has to know whether it is last.
class SettingsGroup extends StatelessWidget {
final List<Widget> rows;
/// Draws a hairline between rows. Off by default.
final bool ruled;
/// Where that hairline starts, measured from the group's left edge. Clears
/// the icon column so it lines up under the text.
final double dividerIndent;
const SettingsGroup({
super.key,
required this.rows,
this.ruled = false,
double? dividerIndent,
}) : dividerIndent = dividerIndent ?? 38;
@override
Widget build(BuildContext context) {
if (!ruled) return Column(children: rows);
final List<Widget> children = [];
for (int i = 0; i < rows.length; i++) {
children.add(rows[i]);
if (i != rows.length - 1) {
children.add(
Padding(
padding: EdgeInsets.only(left: dividerIndent.w),
child: Divider(
height: 1,
thickness: 1,
color: ColorConstants.dividerColor,
),
),
);
}
}
return Column(children: children);
}
}
/// One tappable line in a [SettingsGroup]: icon, title, optional second line,
/// and a chevron unless something else is given.
///
/// [PressScale] rather than a bare tap target, because most of these rows open
/// a screen that takes ~360ms to arrive — without a press state the rider gets
/// no acknowledgement at all in that gap.
class SettingsRow extends StatelessWidget {
final IconData icon;
final String title;
final String? subtitle;
final VoidCallback? onTap;
/// Replaces the chevron — a switch, a value, a badge.
final Widget? trailing;
/// Destructive actions (Logout). Draws in the brand red and drops the
/// chevron, since nothing is being navigated to.
final bool danger;
/// Overrides the icon tint — for a row whose icon carries state, such as the
/// filled check on the chosen alert sound.
final Color? iconColor;
const SettingsRow({
super.key,
required this.icon,
required this.title,
this.subtitle,
this.onTap,
this.trailing,
this.danger = false,
this.iconColor,
});
@override
Widget build(BuildContext context) {
final Color fg = danger ? ColorConstants.primary : ColorConstants.onSurface;
return PressScale(
onTap: onTap,
// Announced as one button named by its title and second line, rather than
// three unlabelled text fragments and an icon.
semanticLabel: subtitle == null || subtitle!.trim().isEmpty
? title
: '$title. $subtitle',
child: Padding(
// No horizontal inset: the group is no longer inside a card, so the row
// starts on the page's own gutter and the section heading above it
// lines up with the icon rather than floating inboard of it.
padding: EdgeInsets.symmetric(vertical: 12.h),
child: Row(
children: [
Icon(icon, color: iconColor ?? fg, size: 22.sp),
SizedBox(width: 14.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w600,
color: fg,
fontFamily: FontConstants.fontFamily,
),
),
if (subtitle != null && subtitle!.trim().isNotEmpty) ...[
SizedBox(height: 2.h),
Text(
subtitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12.sp,
color: ColorConstants.onSurfaceVariant,
fontFamily: FontConstants.fontFamily,
),
),
],
],
),
),
if (trailing != null)
trailing!
else if (!danger)
Icon(
Icons.chevron_right_rounded,
color: ColorConstants.onSurfaceVariant,
size: 20.sp,
),
],
),
),
);
}
}
/// The small caps label that names a group of cards.
class SettingsLabel extends StatelessWidget {
final String text;
const SettingsLabel(this.text, {super.key});
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(bottom: 4.h),
child: Text(
text.toUpperCase(),
style: TextStyle(
fontSize: 11.sp,
fontWeight: FontWeight.w800,
letterSpacing: 1.1,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
);
}
}
/// A short explanatory line under a card — the place for the sentence that
/// stops a rider guessing what a setting does.
class SettingsHint extends StatelessWidget {
final String text;
const SettingsHint(this.text, {super.key});
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.fromLTRB(4.w, 8.h, 4.w, 0),
child: Text(
text,
style: TextStyle(
fontSize: 12.sp,
height: 1.45,
color: ColorConstants.onSurfaceVariant,
fontFamily: FontConstants.fontFamily,
),
),
);
}
}
/// The page body every screen in the section uses: one scroll view, one set of
/// margins, one entrance animation, and — crucially — one pull-to-refresh
/// behaviour.
///
/// ── Why the physics matter ──
///
/// Several of these pages used `BouncingScrollPhysics`, which forces the iOS
/// rubber-band on every platform. Rubber-band overscroll is *unbounded*: with
/// no `RefreshIndicator` to catch the gesture and stop it, a pull-down dragged
/// the whole page off the bottom of the screen against a blank gap and sprang
/// back having done nothing. The gesture responded, so it read as a refresh
/// that had failed. Home was fixed this way months ago; this brings the rest of
/// the app in line.
///
/// Pass [onRefresh] and the pull does what it looks like it does. Omit it and
/// the page still refuses to rubber-band into empty space.
class SettingsBody extends StatelessWidget {
final List<Widget> children;
final Future<void> Function()? onRefresh;
final EdgeInsetsGeometry? padding;
const SettingsBody({
super.key,
required this.children,
this.onRefresh,
this.padding,
});
@override
Widget build(BuildContext context) {
final Widget scroll = SingleChildScrollView(
// `AlwaysScrollable` so a short page can still be pulled, composed with
// the platform default rather than overriding it — clamped on Android,
// bouncing on iOS, bounded on both.
physics: const AlwaysScrollableScrollPhysics(),
padding: padding ?? EdgeInsets.fromLTRB(16.w, 14.h, 16.w, 28.h),
child: Reveal(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: children,
),
),
);
return SafeArea(
bottom: false,
child: onRefresh == null
? scroll
: RefreshIndicator(
onRefresh: onRefresh!,
color: ColorConstants.primary,
backgroundColor: ColorConstants.surfaceContainerLowest,
child: scroll,
),
);
}
}

View File

@@ -0,0 +1,646 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/views/onboardscreens/Sign_in.dart';
import 'package:miler/views/onboardscreens/auth_scaffold.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/helpers/constants/Font_constant.dart';
import 'package:miler/views/helpers/widgets/miler_app_bar.dart';
import 'package:miler/views/helpers/constants/design_constants.dart';
/// ═════════════════════════════════════════════════════════════════════════
/// FIRST RUN — three slides, then sign in.
///
/// ── The copy described a different app ──
///
/// This is the most important change here, and it is not a visual one. The old
/// slides said "Find pickups near you — see nearby pickup requests the moment
/// they arrive, accept in a single tap" and "Navigate the smart way". That is a
/// gig-marketplace pitch, and Miler is not a marketplace: the **hub assigns a
/// slot**, the route comes with its stops and its order already set, it is a
/// **round trip** that starts and ends at the hub, and a stop that is not ready
/// gets **skipped, never reordered**. A rider onboarded on the old copy would
/// open the app looking for a feed of nearby jobs to claim and not find one.
///
/// The third slide also carried a "Powered by AI" chip next to a claim about
/// real-time bonuses. Neither was backed by anything in the app, and a promise
/// made during onboarding is the one a rider remembers when it does not hold.
///
/// ── The illustrations show the actual product ──
///
/// Each slide used to draw a 320×320 rounded panel, containing a 156px tinted
/// circle, containing an 84px Material icon — three nested shapes to present one
/// glyph, on a `repeat(reverse: true)` pulse that never stopped. A location pin
/// does not teach anyone what a slot route is.
///
/// So the panels now hold small, honest mock-ups built from the app's own
/// vocabulary — the same slot chip, stop rows and hub markers the rider will see
/// on Home an hour later. Onboarding that previews the real interface is doing
/// two jobs at once: it explains the model *and* it makes the real screen
/// familiar on arrival.
/// ═════════════════════════════════════════════════════════════════════════
class _Slide {
final String title;
final String body;
final Widget art;
const _Slide({required this.title, required this.body, required this.art});
}
class Introscreen extends StatefulWidget {
const Introscreen({super.key});
@override
State<Introscreen> createState() => _IntroscreenState();
}
class _IntroscreenState extends State<Introscreen> {
final PageController _controller = PageController();
int _index = 0;
static final _slides = <_Slide>[
_Slide(
title: 'Your route arrives ready',
body:
'Your hub assigns each shift as a time slot, with the stops and '
'their order already set. Take the whole trip, or accept it stop by '
'stop.',
art: _SlotArt(),
),
_Slide(
title: 'Work the stops in order',
body:
'Follow the list as it stands. If a stop is not ready, skip it and '
'keep moving — the order is never rearranged underneath you.',
art: _StopsArt(),
),
_Slide(
title: 'Collect, then close the loop',
body:
'Take the parcels and any cash due, capture the proof, and finish '
'back at the hub. Every stop is logged as you go.',
art: _HubArt(),
),
];
Future<void> _finish() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('has_seen_intro', true);
} catch (_) {}
if (!mounted) return;
Get.offAll(() => const SignIn());
}
void _next() {
if (_index < _slides.length - 1) {
_controller.nextPage(
duration: const Duration(milliseconds: 380),
curve: Curves.easeOutCubic,
);
} else {
_finish();
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final isLast = _index == _slides.length - 1;
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.dark.copyWith(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
),
child: Scaffold(
backgroundColor: ColorConstants.surface,
body: SafeArea(
child: Column(
children: [
// ── Top bar: mark, and a way out ──
Padding(
padding: EdgeInsets.fromLTRB(20.w, 8.h, 12.w, 0),
child: Row(
children: [
const MilerBrandMark(),
const Spacer(),
// Skip stays mounted on the last slide instead of fading to
// `opacity: 0` — an invisible-but-present control is a trap
// for screen readers, and on the last slide the primary
// button already does what Skip would.
if (!isLast)
AuthTextAction(label: 'Skip', onPressed: _finish),
],
),
),
Expanded(
child: PageView.builder(
controller: _controller,
onPageChanged: (i) => setState(() => _index = i),
itemCount: _slides.length,
itemBuilder: (_, i) => _SlideView(
slide: _slides[i],
controller: _controller,
index: i,
),
),
),
// ── Position ──
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(_slides.length, (i) {
final active = i == _index;
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
margin: EdgeInsets.symmetric(horizontal: 3.w),
height: 6.h,
width: active ? 26.w : 6.h,
decoration: BoxDecoration(
color: active
? ColorConstants.primary
: ColorConstants.borderStrong,
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
),
);
}),
),
Padding(
padding: EdgeInsets.fromLTRB(24.w, 22.h, 24.w, 14.h),
child: AuthPrimaryButton(
label: isLast ? 'Get started' : 'Next',
onPressed: _next,
),
),
],
),
),
),
);
}
}
/// One slide. Parallax is driven by an [AnimatedBuilder] on the page controller
/// rather than by a listener calling `setState` on the whole screen, which is
/// what the old version did on every pixel of every drag.
class _SlideView extends StatelessWidget {
final _Slide slide;
final PageController controller;
final int index;
const _SlideView({
required this.slide,
required this.controller,
required this.index,
});
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: controller,
builder: (context, _) {
// `page` is null until the viewport has been laid out once.
final page = controller.hasClients && controller.position.haveDimensions
? (controller.page ?? controller.initialPage.toDouble())
: controller.initialPage.toDouble();
final delta = (page - index).abs().clamp(0.0, 1.0);
return Opacity(
opacity: 1 - (delta * 0.55),
child: Transform.scale(
scale: 1 - (delta * 0.08),
child: SingleChildScrollView(
physics: const ClampingScrollPhysics(),
padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 8.h),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
slide.art,
SizedBox(height: 34.h),
Text(
slide.title,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 25.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.6,
height: 1.2,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 10.h),
Text(
slide.body,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14.5.sp,
fontWeight: FontWeight.w500,
height: 1.5,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
),
);
},
);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// ILLUSTRATIONS
//
// Built from the app's real vocabulary — white surfaces, 1.5px `borderStrong`
// edges, slate type, the hub teal — so the first thing the rider sees is the
// thing he will be using. Static: no controllers, nothing to dispose, nothing
// left repainting behind the keyboard.
// ═══════════════════════════════════════════════════════════════════════════
/// Shared frame: the "screen" each mock sits on.
class _ArtFrame extends StatelessWidget {
final Widget child;
const _ArtFrame({required this.child});
@override
Widget build(BuildContext context) {
return Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: 300.w),
child: Container(
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
color: ColorConstants.pureSurface,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
border: Border.all(color: ColorConstants.borderStrong, width: 1.5),
boxShadow: const [
BoxShadow(
color: Color(0x0F000000),
blurRadius: 20,
offset: Offset(0, 8),
),
],
),
child: child,
),
),
);
}
}
const Color _hubTeal = Color(0xFF0E7C7B);
TextStyle _artTitle(double size) => TextStyle(
fontSize: size.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.3,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
);
TextStyle _artMeta(double size) => TextStyle(
fontSize: size.sp,
fontWeight: FontWeight.w700,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
);
/// Slide 1 — a slot, its load, and the shape of the trip.
class _SlotArt extends StatelessWidget {
@override
Widget build(BuildContext context) {
return _ArtFrame(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('12:00 PM – 3:00 PM', style: _artTitle(16)),
SizedBox(height: 3.h),
Text('6 stops', style: _artMeta(11.5)),
],
),
),
Container(
padding: EdgeInsets.symmetric(horizontal: 9.w, vertical: 5.h),
decoration: BoxDecoration(
color: ColorConstants.neutralLight,
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
border: Border.all(color: ColorConstants.borderStrong),
),
child: Row(
children: [
Icon(
Icons.inventory_2_rounded,
size: 11.sp,
color: ColorConstants.slateText,
),
SizedBox(width: 4.w),
Text('15', style: _artTitle(12)),
],
),
),
],
),
SizedBox(height: 14.h),
// The metric strip, in miniature.
Container(
padding: EdgeInsets.symmetric(vertical: 8.h),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
border: Border.all(
color: ColorConstants.borderStrong,
width: 1.5,
),
),
child: Row(
children: [
_cell('≈3h', 'Duration', ColorConstants.primary),
_divider(),
_cell('50 km', 'Round trip', ColorConstants.slateText),
_divider(),
_cell('₹1,030', 'To collect', ColorConstants.acceptGreen),
],
),
),
SizedBox(height: 14.h),
Row(
children: [
Icon(Icons.warehouse_rounded, size: 13.sp, color: _hubTeal),
SizedBox(width: 6.w),
Text(
'HUB',
style: TextStyle(
fontSize: 10.sp,
fontWeight: FontWeight.w800,
letterSpacing: 0.6,
color: _hubTeal,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(width: 7.w),
for (var i = 0; i < 4; i++) ...[
Container(
width: 14.w,
height: 2,
color: ColorConstants.borderStrong,
),
Container(
width: 7.w,
height: 7.w,
decoration: BoxDecoration(
color: ColorConstants.primary,
shape: BoxShape.circle,
),
),
],
Container(
width: 14.w,
height: 2,
color: ColorConstants.borderStrong,
),
Icon(Icons.warehouse_rounded, size: 13.sp, color: _hubTeal),
],
),
],
),
);
}
Widget _cell(String value, String label, Color accent) => Expanded(
child: Column(
children: [
Text(
value,
style: TextStyle(
fontSize: 12.5.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.3,
color: accent,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 1.h),
Text(label, style: _artMeta(9)),
],
),
);
Widget _divider() =>
Container(width: 1, height: 24.h, color: ColorConstants.borderSubtle);
}
/// Slide 2 — the list, in order, with one done and one skipped.
class _StopsArt extends StatelessWidget {
@override
Widget build(BuildContext context) {
return _ArtFrame(
child: Column(
children: [
_row(
1,
'Sri Balaji Stores',
'Done',
ColorConstants.acceptGreen,
Icons.check_rounded,
),
SizedBox(height: 9.h),
_row(
2,
'Anand Traders',
'Skipped',
ColorConstants.warning,
Icons.redo_rounded,
),
SizedBox(height: 9.h),
_row(3, 'Kumar Textiles', 'Next', ColorConstants.primary, null),
SizedBox(height: 9.h),
_row(4, 'MS Enterprises', null, ColorConstants.borderStrong, null),
],
),
);
}
Widget _row(
int n,
String name,
String? status,
Color accent,
IconData? badge,
) {
final pending = status == null;
return Row(
children: [
Container(
width: 20.w,
height: 20.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color: pending ? ColorConstants.pureSurface : accent,
shape: BoxShape.circle,
border: Border.all(color: accent, width: 1.8),
),
child: badge != null
? Icon(badge, size: 11.sp, color: Colors.white)
: Text(
'$n',
style: TextStyle(
fontSize: 10.sp,
fontWeight: FontWeight.w800,
color: pending
? ColorConstants.secondaryText
: Colors.white,
fontFamily: FontConstants.fontFamily,
),
),
),
SizedBox(width: 10.w),
Expanded(
child: Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.2,
color: pending
? ColorConstants.secondaryText
: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
),
if (status != null)
Text(
status,
style: TextStyle(
fontSize: 10.5.sp,
fontWeight: FontWeight.w800,
color: accent,
fontFamily: FontConstants.fontFamily,
),
),
],
);
}
}
/// Slide 3 — what leaves the stop with you, and where it ends up.
class _HubArt extends StatelessWidget {
@override
Widget build(BuildContext context) {
return _ArtFrame(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'COLLECT AT THIS STOP',
style: TextStyle(
fontSize: 9.5.sp,
fontWeight: FontWeight.w800,
letterSpacing: 0.9,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 11.h),
_line(
Icons.inventory_2_rounded,
'3 parcels',
ColorConstants.pickupAccent,
),
SizedBox(height: 9.h),
_line(
Icons.payments_rounded,
'₹340 cash',
ColorConstants.acceptGreen,
),
SizedBox(height: 9.h),
_line(
Icons.photo_camera_rounded,
'Photo proof',
ColorConstants.slateText,
),
SizedBox(height: 14.h),
Container(height: 1, color: ColorConstants.borderSubtle),
SizedBox(height: 13.h),
Row(
children: [
Container(
width: 22.w,
height: 22.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color: _hubTeal.withValues(alpha: 0.14),
shape: BoxShape.circle,
border: Border.all(color: _hubTeal, width: 1.5),
),
child: Icon(
Icons.warehouse_rounded,
size: 12.sp,
color: _hubTeal,
),
),
SizedBox(width: 9.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'RETURN · HUB',
style: TextStyle(
fontSize: 10.sp,
fontWeight: FontWeight.w800,
letterSpacing: 0.7,
color: _hubTeal,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 2.h),
Text('Hand everything over', style: _artMeta(10.5)),
],
),
),
],
),
],
),
);
}
Widget _line(IconData icon, String text, Color accent) => Row(
children: [
Icon(icon, size: 15.sp, color: accent),
SizedBox(width: 9.w),
Text(
text,
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.2,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
],
);
}

View File

@@ -0,0 +1,110 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/helpers/constants/design_constants.dart';
import 'package:miler/views/helpers/constants/miler_type.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// OFFLINE, WITHOUT TAKING THE JOB AWAY
///
/// ── What this replaces ──
///
/// Losing signal used to lay an opaque full-screen page over the entire app:
/// `Stack(children: [baseChild, const OfflinePage()])`. Not a warning — a
/// replacement.
///
/// A rider standing at a doorstep in a basement, a lift, a stairwell or a rural
/// lane — which is where Indian first-mile delivery actually happens — lost the
/// address, the customer's name, their phone number, the parcel count and the
/// OTP field. He was holding a parcel and could not see who it was for. The app
/// then told him it was "retrying automatically every 5 seconds", which is not
/// something he can act on while a customer waits.
///
/// ── Why a strip is the right shape ──
///
/// Everything the rider needs at the door was already fetched. Connectivity is
/// needed to *report* the stop, not to work it. So the honest message is not
/// "you are offline" but "keep going, this will sync" — and that is a line of
/// text, not a screen.
///
/// The full-screen page survives for the one case where it is truthful: a cold
/// start with no cached session, where there genuinely is nothing to show.
///
/// ── Sizing ──
///
/// Deliberately thin, and it does **not** overlay the content — [OfflineScope]
/// gives it its own row above the app, so it can never cover a field the rider
/// is typing into. The one thing worse than a blocker is a floating bar sitting
/// on top of the OTP box.
/// ─────────────────────────────────────────────────────────────────────────
class OfflineBanner extends StatelessWidget {
const OfflineBanner({super.key});
@override
Widget build(BuildContext context) {
return Semantics(
liveRegion: true,
label: 'Offline. Your changes will sync when you are back online.',
excludeSemantics: true,
child: Material(
color: ColorConstants.warning,
child: SafeArea(
bottom: false,
child: Padding(
padding: EdgeInsets.fromLTRB(16.w, 7.h, 16.w, 7.h),
child: Row(
children: [
Icon(
Icons.cloud_off_rounded,
size: 15.sp,
color: ColorConstants.onAccent,
),
SizedBox(width: 9.w),
Expanded(
child: Text(
// States what is true and what happens next. "You're
// offline" alone tells a rider mid-stop nothing he can use.
'Offline · Changes will sync when you’re back online',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: MilerType.micro
.on(ColorConstants.onAccent)
.semibold,
),
),
],
),
),
),
),
);
}
}
/// Puts [OfflineBanner] above the app rather than over it.
///
/// A `Column` rather than a `Stack` on purpose: the strip takes its own height
/// out of the layout, so nothing underneath is ever covered. The app simply
/// starts a few points lower while the connection is down.
class OfflineScope extends StatelessWidget {
final Widget child;
final bool offline;
const OfflineScope({super.key, required this.child, required this.offline});
@override
Widget build(BuildContext context) {
return Column(
children: [
AnimatedSize(
duration: DesignConstants.motionState,
curve: Curves.easeOutCubic,
alignment: Alignment.bottomCenter,
child: offline ? const OfflineBanner() : const SizedBox(width: double.infinity),
),
Expanded(child: child),
],
);
}
}

View File

@@ -0,0 +1,241 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:miler/helpers/app_bootstrap.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/helpers/constants/design_constants.dart';
import 'package:miler/views/helpers/constants/miler_type.dart';
import 'package:miler/views/helpers/widgets/app_widgets.dart';
class OfflinePage extends StatefulWidget {
const OfflinePage({super.key});
@override
State<OfflinePage> createState() => _OfflinePageState();
}
class _OfflinePageState extends State<OfflinePage>
with SingleTickerProviderStateMixin {
late final AnimationController _pulse;
bool _checking = false;
Timer? _autoRetry;
@override
void initState() {
super.initState();
_pulse = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1800),
)..repeat(reverse: true);
// Automatically retry every 5 seconds.
_autoRetry = Timer.periodic(const Duration(seconds: 5), (_) {
if (mounted && !_checking) {
_handleRetry();
}
});
}
@override
void dispose() {
_pulse.dispose();
_autoRetry?.cancel();
super.dispose();
}
Future<void> _handleRetry() async {
if (_checking) return;
setState(() => _checking = true);
try {
final results = await Connectivity().checkConnectivity();
final isOnline =
results.isNotEmpty &&
results.any((r) => r != ConnectivityResult.none);
if (isOnline) {
Get.offAll(() => const AppBootstrap());
return;
}
if (mounted) {
AppFeedback.info(context, "Still offline. We'll keep checking.");
}
} finally {
if (mounted) {
setState(() => _checking = false);
}
}
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
return Scaffold(
backgroundColor: const Color(0xffF7F8FC),
body: SafeArea(
child: Stack(
children: [
Positioned(
bottom: -size.height * .10,
left: -40,
right: -40,
child: Container(
height: size.height * .25,
decoration: const BoxDecoration(
color: ColorConstants.neutralLight,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(250),
topRight: Radius.circular(250),
),
),
),
),
Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: TweenAnimationBuilder<double>(
duration: const Duration(milliseconds: 700),
tween: Tween(begin: 0, end: 1),
curve: Curves.easeOut,
builder: (context, value, child) {
return Opacity(
opacity: value,
child: Transform.translate(
offset: Offset(0, (1 - value) * 30),
child: child,
),
);
},
child: Container(
padding: const EdgeInsets.all(28),
decoration: BoxDecoration(
color: ColorConstants.pureSurface,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
boxShadow: [
BoxShadow(
color: ColorConstants.slateText.withValues(alpha: .05),
blurRadius: 25,
offset: const Offset(0, 12),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedBuilder(
animation: _pulse,
builder: (_, child) {
final t = Curves.easeInOut.transform(_pulse.value);
return Container(
width: 130,
height: 130,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: ColorConstants.primary.withValues(alpha: .08),
boxShadow: [
BoxShadow(
color: ColorConstants.primary.withValues(alpha:
.10 + (.15 * t),
),
blurRadius: 25 + (18 * t),
spreadRadius: 2 + (8 * t),
),
],
),
child: child,
);
},
child: Icon(
Icons.wifi_off_rounded,
color: ColorConstants.primary,
size: 68,
),
),
const SizedBox(height: 28),
Text("You're offline", style: MilerType.hero),
const SizedBox(height: 12),
Text(
"We can't reach Miler right now. Your shift will "
"load as soon as you're back online.",
textAlign: TextAlign.center,
style: MilerType.caption,
),
const SizedBox(height: 28),
SizedBox(
width: double.infinity,
height: ButtonSizes.primary,
child: ElevatedButton(
onPressed: _checking ? null : _handleRetry,
style: ElevatedButton.styleFrom(
elevation: 0,
backgroundColor: ColorConstants.primary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 16,
),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
ButtonSizes.radius,
),
),
),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
child: _checking
? const SizedBox(
key: ValueKey("loading"),
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: Colors.white,
),
)
: Text(
"Try again",
key: const ValueKey("retry"),
style: MilerType.buttonLabel(
ButtonSizes.primary,
),
),
),
),
),
const SizedBox(height: 16),
Text(
"Checking every few seconds",
style: MilerType.label,
),
],
),
),
),
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,260 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:miler/views/helpers/widgets/page_transitions.dart';
import 'package:miler/controllers/auth.dart';
import 'package:miler/views/helpers/constants/Font_constant.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/onboardscreens/Mpin.dart';
import 'package:miler/views/onboardscreens/auth_scaffold.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// CREATE MPIN — step 3 of 3.
///
/// ── What changed and why ──
///
/// **A mismatch now says so.** Previously the CTA was simply disabled while
/// `isMpinMatched` was false, and nothing on screen explained why: the rider
/// entered four digits, entered four more, and the button stayed dead. Two
/// non-matching 4-digit PINs look identical when both are rendered as `••••`, so
/// there was no way to find the mistake by looking. The confirm row now turns red
/// and prints the reason, and it clears itself so he can retype rather than
/// hunting for which box is wrong.
///
/// **Weak PINs are refused.** `1111` and `1234` were accepted, on a credential
/// that unlocks a rider's account and their COD cash reconciliation. Both are in
/// every attacker's first ten guesses — and `1234` is this app's own documented
/// master PIN, so a rider could set a PIN that collides with it.
///
/// **The decorative shield tile is gone**, and the two 4-digit rows are drawn by
/// the shared [AuthCodeRow] instead of a private copy — this screen and the
/// unlock screen were rendering the same control two different ways.
/// ─────────────────────────────────────────────────────────────────────────
class CreateMpin extends GetResponsiveView {
CreateMpin({super.key});
@override
Widget builder() {
return const _CreateMpinBody();
}
}
class _CreateMpinBody extends StatefulWidget {
const _CreateMpinBody();
@override
State<_CreateMpinBody> createState() => _CreateMpinBodyState();
}
class _CreateMpinBodyState extends State<_CreateMpinBody> {
final AuthController _auth = Get.put(AuthController());
final List<TextEditingController> _newMpinControllers = List.generate(
4,
(_) => TextEditingController(),
);
final List<TextEditingController> _confirmMpinControllers = List.generate(
4,
(_) => TextEditingController(),
);
final List<FocusNode> _newFocusNodes = List.generate(4, (_) => FocusNode());
final List<FocusNode> _confirmFocusNodes = List.generate(
4,
(_) => FocusNode(),
);
bool isLoading = false;
String? _error;
/// PINs that are trivially guessable. `1234` is also this build's master PIN,
/// so letting a rider choose it would collide with the override path.
static const _tooWeak = <String>{
'1234',
'4321',
'0000',
'1111',
'2222',
'3333',
'4444',
'5555',
'6666',
'7777',
'8888',
'9999',
'1122',
'2580',
};
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback(
(_) => _newFocusNodes[0].requestFocus(),
);
}
@override
void dispose() {
for (var c in [..._newMpinControllers, ..._confirmMpinControllers]) {
c.dispose();
}
for (var f in [..._newFocusNodes, ..._confirmFocusNodes]) {
f.dispose();
}
super.dispose();
}
String _pin(List<TextEditingController> c) => c.map((e) => e.text).join();
bool get _newFilled => _newMpinControllers.every((c) => c.text.isNotEmpty);
bool get _confirmFilled =>
_confirmMpinControllers.every((c) => c.text.isNotEmpty);
bool get isMpinMatched =>
_pin(_newMpinControllers) == _pin(_confirmMpinControllers);
bool get isAllFilled => _newFilled && _confirmFilled;
void _onChanged(
String value,
int index,
List<TextEditingController> controllers,
List<FocusNode> nodes,
) {
if (value.isNotEmpty && index < 3) {
nodes[index + 1].requestFocus();
} else if (value.isEmpty && index > 0) {
nodes[index - 1].requestFocus();
}
final isGroupFilled = controllers.every((c) => c.text.isNotEmpty);
// Hand off from the first row to the second automatically; the old version
// did this too and it is the right behaviour.
if (controllers == _newMpinControllers && isGroupFilled) {
_confirmFocusNodes[0].requestFocus();
}
setState(() {
_error = null;
// Judge only once both rows are complete — grading a half-typed
// confirmation would flash a mismatch on every single keystroke.
if (controllers == _confirmMpinControllers && isGroupFilled) {
FocusScope.of(context).unfocus();
_error = _validate();
}
});
}
/// Null when the pair is acceptable.
String? _validate() {
final pin = _pin(_newMpinControllers);
if (!isMpinMatched)
return 'Those PINs do not match. Re-enter the second one.';
if (_tooWeak.contains(pin)) {
return 'That PIN is too easy to guess. Avoid runs and repeats.';
}
return null;
}
void _clearConfirm() {
for (final c in _confirmMpinControllers) {
c.clear();
}
_confirmFocusNodes[0].requestFocus();
}
Future<void> _save() async {
final problem = _validate();
if (problem != null) {
setState(() => _error = problem);
// A mismatch is almost always a typo in the confirmation, so clear that
// row and put the caret in it. Making him find the wrong digit himself,
// through four dots, is not a task anyone can do.
if (!isMpinMatched) _clearConfirm();
return;
}
setState(() => isLoading = true);
final ok = await _auth.setPin(_pin(_newMpinControllers));
if (!mounted) return;
setState(() => isLoading = false);
if (ok) {
openScreen(context, Mpin());
} else {
setState(() => _error = 'Could not save your PIN. Please try again.');
}
}
@override
Widget build(BuildContext context) {
final mismatch = _error != null && isAllFilled;
return AuthScaffold(
onBack: () => Get.back(),
// The last of the two counted screens — see the note in `otp_page`.
step: 2,
totalSteps: 2,
// Compact: this screen focuses its first box on the first frame, so the
// full block would render once and then animate itself away.
banner: const AuthBrandBanner(compact: true),
title: 'Create your MPIN',
subtitle:
'Four digits, used to unlock the app from now on. Do not share it — '
'it also signs off the cash you collect.',
footer: AuthPrimaryButton(
label: 'Save & continue',
loading: isLoading,
onPressed: isLoading || !isAllFilled ? null : _save,
),
children: [
Text('NEW MPIN', style: AuthType.fieldLabel),
SizedBox(height: 10.h),
AuthCodeRow(
controllers: _newMpinControllers,
nodes: _newFocusNodes,
obscure: true,
onChanged: (v, i) =>
_onChanged(v, i, _newMpinControllers, _newFocusNodes),
),
SizedBox(height: 26.h),
Row(
children: [
Text('CONFIRM MPIN', style: AuthType.fieldLabel),
const Spacer(),
// A quiet, non-alarming confirmation that the two rows agree —
// the only feedback available when both read as four dots.
if (isAllFilled && _error == null)
Row(
children: [
Icon(
Icons.check_circle_rounded,
size: 14.sp,
color: ColorConstants.acceptGreen,
),
SizedBox(width: 5.w),
Text(
'Matches',
style: TextStyle(
fontSize: 11.5.sp,
fontWeight: FontWeight.w800,
color: ColorConstants.acceptGreen,
fontFamily: FontConstants.fontFamily,
),
),
],
),
],
),
SizedBox(height: 10.h),
AuthCodeRow(
controllers: _confirmMpinControllers,
nodes: _confirmFocusNodes,
obscure: true,
hasError: mismatch,
onChanged: (v, i) =>
_onChanged(v, i, _confirmMpinControllers, _confirmFocusNodes),
),
AuthInlineError(message: _error),
],
);
}
}

View File

@@ -0,0 +1,198 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:miler/views/helpers/widgets/page_transitions.dart';
import 'package:miler/views/onboardscreens/otp_page.dart';
import 'package:miler/views/onboardscreens/auth_scaffold.dart';
import 'package:miler/widget/Bottom_page.dart';
import 'package:miler/controllers/auth.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// UNLOCK — the returning rider's front door.
///
/// Deliberately **not** given a step indicator. This is the screen a rider sees
/// every morning, and it is one screen long; labelling it "step 1 of 3" would
/// imply two more to go. The step rail belongs to the one-time sign-up flow.
///
/// ── What changed and why ──
///
/// **It says whose account it is.** A shared or handed-down phone is normal in
/// this fleet, and the screen gave no clue which rider's account was about to be
/// unlocked. It now shows the name on the session, which is also the fastest way
/// to notice you are on the wrong one.
///
/// **The boxes match the rest of the flow.** This screen drew its four boxes at
/// 70×70, radius 16, filled grey with a *transparent* border that only appeared
/// on focus — so an unfocused box had no edge at all, while the create-MPIN
/// screen two taps earlier drew the same control white with a visible border.
/// Both now use the shared [AuthCodeRow].
///
/// **"Access your account securely" is gone.** It is filler: the rider knows what
/// a PIN box is, and no screen has ever been made more secure by claiming to be.
/// ─────────────────────────────────────────────────────────────────────────
class Mpin extends GetResponsiveView {
Mpin({super.key});
@override
Widget builder() {
return const _MpinView();
}
}
class _MpinView extends StatefulWidget {
const _MpinView();
@override
State<_MpinView> createState() => _MpinViewState();
}
class _MpinViewState extends State<_MpinView> {
final AuthController _auth = Get.put(AuthController());
final List<TextEditingController> _mpinControllers = List.generate(
4,
(_) => TextEditingController(),
);
final List<FocusNode> _focusNodes = List.generate(4, (_) => FocusNode());
bool isVerifying = false;
bool _rejected = false;
String _riderName = '';
@override
void initState() {
super.initState();
_loadRider();
WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNodes[0].requestFocus();
});
}
Future<void> _loadRider() async {
try {
final prefs = await SharedPreferences.getInstance();
final name = (prefs.getString('user_name') ?? '').trim();
if (mounted && name.isNotEmpty) {
setState(() => _riderName = name.split(' ').first);
}
} catch (_) {
// Non-essential: the screen works without a name.
}
}
@override
void dispose() {
for (var c in _mpinControllers) {
c.dispose();
}
for (var f in _focusNodes) {
f.dispose();
}
super.dispose();
}
bool get _isComplete => _mpinControllers.every((c) => c.text.isNotEmpty);
void _onMpinChange(String value, int index) {
if (value.isNotEmpty && index < 3) {
_focusNodes[index + 1].requestFocus();
} else if (value.isEmpty && index > 0) {
_focusNodes[index - 1].requestFocus();
}
setState(() {
if (_rejected) _rejected = false;
});
if (_isComplete) {
FocusScope.of(context).unfocus();
_submitMpin();
}
}
/// Wired to [AuthController.onPinRetry] — the controller calls this after it
/// has reported a bad PIN, so the boxes clear and the caret comes back.
void _clearMpinAndFocus() {
for (final c in _mpinControllers) {
c.clear();
}
_focusNodes[0].requestFocus();
if (mounted) setState(() => _rejected = true);
}
Future<void> _submitMpin() async {
if (!mounted || isVerifying) return;
_auth.onPinRetry = _clearMpinAndFocus;
setState(() => isVerifying = true);
final ok = await _auth.verifyPinWithServer(
_mpinControllers.map((c) => c.text).join(),
);
if (!mounted) return;
setState(() => isVerifying = false);
if (ok) {
// Straight to the app. There used to be a "Go on duty" gate between the
// PIN and the tabs, which made every launch a two-step sign-in for a
// decision the rider can make — and change — from the switch in Home's
// app bar at any moment of the shift.
Get.offAll(() => const BottomPage());
}
}
@override
Widget build(BuildContext context) {
return AuthScaffold(
onBack: () => Get.back(),
// The strip, not the full block, and fixed rather than adaptive: this
// screen focuses its first box on the first frame, and it is the one the
// rider opens every morning. See [AuthBrandBanner].
//
// The small corner mark that used to sit here is gone with it — two
// Doormile logos on one screen is one more than the screen needs.
banner: const AuthBrandBanner(compact: true),
title: _riderName.isEmpty
? 'Enter your MPIN'
: 'Welcome back, $_riderName',
subtitle: 'Enter your 4-digit MPIN to unlock.',
footer: Column(
mainAxisSize: MainAxisSize.min,
children: [
AuthPrimaryButton(
label: 'Unlock',
icon: Icons.lock_open_rounded,
loading: isVerifying,
onPressed: isVerifying || !_isComplete ? null : _submitMpin,
),
SizedBox(height: 6.h),
AuthTextAction(
label: 'Forgot MPIN?',
onPressed: isVerifying
? null
: () async {
await _auth.sendOtp();
if (mounted) openScreen(context, OtpPage());
},
),
],
),
children: [
AuthCodeRow(
controllers: _mpinControllers,
nodes: _focusNodes,
obscure: true,
hasError: _rejected,
onChanged: _onMpinChange,
),
// The controller owns the failure message (it distinguishes a wrong PIN
// from a dead session), so this only marks that the attempt was
// rejected and the boxes were reset — otherwise they empty themselves
// with no stated reason.
AuthInlineError(
message: _rejected ? 'Incorrect MPIN. Try again.' : null,
),
],
);
}
}

View File

@@ -0,0 +1,443 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:miler/views/helpers/widgets/page_transitions.dart';
import 'package:miler/views/onboardscreens/otp_page.dart';
import 'package:miler/views/onboardscreens/Mpin.dart';
import 'package:miler/views/onboardscreens/auth_scaffold.dart';
import 'package:miler/controllers/auth.dart';
import 'package:flutter/gestures.dart';
import 'package:miler/views/Dashboard/profile/informations/terms_condition.dart';
import 'package:miler/providers/notifications/notificationservce.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';
class LoginController extends GetxController {
var isChecked = true.obs;
final AuthController auth = Get.put(AuthController());
}
/// ─────────────────────────────────────────────────────────────────────────
/// SIGN IN — step 1 of 3.
///
/// ── What changed and why ──
///
/// **The logo stopped performing.** This screen led with a 104px mark wrapped in
/// a `repeat(reverse: true)` controller that breathed a brand-coloured glow and
/// floated it up and down, forever, at 60fps — behind which sat a 1100ms
/// staggered cascade that faded in eight elements one at a time. So the first
/// second of the app was spent watching a logo pulse while the field the rider
/// needed was still arriving, and the animation kept running afterwards under
/// the keyboard where nobody could see it. No serious app animates its own logo
/// on a login screen; the mark is now the same small [MilerBrandMark] the rest
/// of the app uses, sitting quietly in the corner.
///
/// **The question became the heading.** "Welcome back" is a greeting, not an
/// instruction, and it was followed by a sentence explaining what the field
/// below it obviously did. Uber asks "What's your mobile number?" because the
/// heading and the task are then the same thing, and the screen needs no second
/// line of prose to explain itself.
///
/// **The error stopped crying wolf.** The old validator showed
/// "Enter a valid 10-digit number" as soon as `text.isNotEmpty` and the number
/// was not yet valid — i.e. from the first keystroke, for eight straight
/// keystrokes, on every correct entry. An error that fires while you are still
/// typing the right answer trains people to ignore errors. It now waits until
/// there are ten digits to judge (or until Continue is pressed).
/// ─────────────────────────────────────────────────────────────────────────
class SignIn extends StatefulWidget {
const SignIn({super.key});
@override
State<SignIn> createState() => _SignInState();
}
class _SignInState extends State<SignIn> {
final LoginController controller = Get.put(LoginController());
final TextEditingController phoneController = TextEditingController();
final FocusNode _phoneFocusNode = FocusNode();
bool isPhoneValid = false;
bool isLoading = false;
/// Errors appear once the rider has had a fair chance to be right — ten
/// digits typed, or Continue pressed — never mid-entry.
bool _showError = false;
String? _errorText;
// Held as fields so they can be disposed (an inline recognizer leaks).
late final TapGestureRecognizer _termsRecognizer = TapGestureRecognizer()
..onTap = () => openSheet(context, TermsCondition());
late final TapGestureRecognizer _privacyRecognizer = TapGestureRecognizer()
..onTap = () => openSheet(context, TermsCondition());
@override
void initState() {
super.initState();
_phoneFocusNode.addListener(() {
if (mounted) setState(() {});
});
// ── No autofocus ──
//
// This used to open the keyboard on the first frame, which on a screen with
// a brand block means the rider never sees it: the banner halves itself the
// moment the keyboard appears, so the app would animate its own masthead
// away before he had looked at it. Sign-in happens about once per install,
// so the tap it costs him is paid once; the alternative is a brand moment
// that exists in the code and never on the screen.
WidgetsBinding.instance.addPostFrameCallback((_) {
NotificationServce.initialize(context);
});
}
bool _validatePhoneNumber(String number) {
final RegExp regExp = RegExp(r'^[6-9]\d{9}$');
return regExp.hasMatch(number);
}
void _onPhoneChanged(String value) {
setState(() {
isPhoneValid = _validatePhoneNumber(value);
// Judge only a complete entry. A 10-digit number that still fails the
// pattern started with 0–5, which is worth saying out loud.
if (value.length == 10) {
_showError = !isPhoneValid;
_errorText = isPhoneValid
? null
: 'Indian mobile numbers start with 6, 7, 8 or 9.';
} else {
_showError = false;
_errorText = null;
}
});
if (value.length == 10 && isPhoneValid) {
FocusScope.of(context).unfocus();
}
}
Future<void> _onContinue() async {
if (!isPhoneValid) {
setState(() {
_showError = true;
_errorText = 'Enter your 10-digit mobile number.';
});
return;
}
setState(() {
isLoading = true;
_showError = false;
_errorText = null;
});
final decision = await controller.auth.precheckPhone(phoneController.text);
if (!mounted) return;
setState(() => isLoading = false);
if (decision == AuthNext.otp) {
await controller.auth.sendOtp(phoneController.text);
if (!mounted) return;
openScreen(context, OtpPage());
} else if (decision == AuthNext.verifyPin) {
openScreen(context, Mpin());
} else {
// notRegistered / error. Reported on the screen rather than in a snackbar
// under the keyboard, where the old version put it.
setState(() {
_showError = true;
_errorText =
'We could not sign you in with that number. Check it and try '
'again, or contact your hub manager.';
});
}
}
@override
void dispose() {
_phoneFocusNode.dispose();
phoneController.dispose();
_termsRecognizer.dispose();
_privacyRecognizer.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return GetBuilder<LoginController>(
builder: (_) => AuthScaffold(
// ── No step rail on this screen ──
//
// It used to say "STEP 1 OF 3", which turned signing in into a form
// with a length. Most of the people who open this screen are not
// signing up at all — a returning rider types his number and, on the
// very next screen, his MPIN, which carries no counter. So the flow
// announced itself as three steps long and then stopped counting,
// which is worse than never having counted.
//
// The rail survives where it is honestly true: the code and PIN
// screens of a genuine first-time sign-up, renumbered to 1 and 2 of 2
// so the rider still meets a counter that starts at the beginning.
// The brand block replaces the small corner mark this screen used to
// carry. See [AuthBrandBanner] for why this screen gets one and the
// rest of the flow does not.
banner: const AuthBrandBanner(),
// "What's your mobile number?" was a good heading and this is a
// shorter one. The screen has a banner above it now, so the title is no
// longer the first thing on the page and does not have to introduce it
// — it only has to name the task.
title: 'Enter your number',
footer: _buildFooter(),
children: [
// The standalone 'MOBILE NUMBER' eyebrow is gone: the field carries
// its own label in the notch of its border now, which is the same
// information in one element instead of two.
_buildPhoneField(context),
AuthInlineError(message: _showError ? _errorText : null),
],
),
);
}
/// ── The number field ──
///
/// Was a hand-rolled `AnimatedContainer` holding a borderless `TextField`,
/// with the label printed as a separate eyebrow above it. That is two
/// elements and about eighty lines to reproduce what an `OutlineInputBorder`
/// does natively — including the notch the label sits in, which is the part
/// that lets the label stay visible *while* the rider types. An eyebrow above
/// the box scrolls away from its field; a notched label never can.
///
/// The border colour is the only state signal: neutral at rest, brand when
/// focused, red on a judged error. The focus glow is gone with the hand-rolled
/// box — a 2px brand edge already says "here", and a coloured shadow under one
/// field on an otherwise flat screen was the only elevation in the flow.
Widget _buildPhoneField(BuildContext context) {
final focused = _phoneFocusNode.hasFocus;
final error = _showError && _errorText != null;
final Color edge = error
? ColorConstants.errorRed
: focused
? ColorConstants.primary
: ColorConstants.borderStrong;
OutlineInputBorder side(Color colour, double width) => OutlineInputBorder(
borderRadius: BorderRadius.circular(ButtonSizes.radius.r),
borderSide: BorderSide(color: colour, width: width),
);
return TextField(
controller: phoneController,
focusNode: _phoneFocusNode,
keyboardType: TextInputType.phone,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
maxLength: 10,
autofillHints: const [AutofillHints.telephoneNumberNational],
cursorColor: ColorConstants.primary,
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.w700,
letterSpacing: 1.2,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
onChanged: _onPhoneChanged,
onSubmitted: (_) {
if (isPhoneValid && !isLoading) _onContinue();
},
decoration: InputDecoration(
counterText: '',
filled: true,
fillColor: ColorConstants.pureSurface,
labelText: 'Mobile number',
// Always floated, so the field reads as labelled before it is touched
// rather than showing a placeholder that vanishes on the first digit.
floatingLabelBehavior: FloatingLabelBehavior.always,
labelStyle: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w700,
letterSpacing: -0.1,
color: error
? ColorConstants.errorRed
: focused
? ColorConstants.primary
: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
hintText: '00000 00000',
hintStyle: TextStyle(
color: ColorConstants.secondaryText,
fontSize: 18.sp,
fontWeight: FontWeight.w600,
letterSpacing: 1.2,
fontFamily: FontConstants.fontFamily,
),
contentPadding: EdgeInsets.symmetric(vertical: 18.h),
// ── Country prefix ──
//
// No chevron. Instamart's field has one because it serves more than one
// country; Doormile does not, and an affordance that opens nothing is a
// promise the screen cannot keep.
prefixIcon: Padding(
padding: EdgeInsets.only(left: 14.w, right: 11.w),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const _IndiaFlag(),
SizedBox(width: 8.w),
Text(
'+91',
style: TextStyle(
fontSize: 17.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.2,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(width: 11.w),
Container(
width: 1,
height: 24.h,
color: ColorConstants.borderStrong,
),
],
),
),
prefixIconConstraints: const BoxConstraints(minWidth: 0, minHeight: 0),
suffixIcon: isPhoneValid
? Padding(
padding: EdgeInsets.only(right: 14.w),
child: Icon(
Icons.check_circle_rounded,
size: 20.sp,
color: ColorConstants.acceptGreen,
),
)
: null,
suffixIconConstraints: const BoxConstraints(minWidth: 0, minHeight: 0),
border: side(edge, 1.5),
enabledBorder: side(ColorConstants.borderStrong, 1.5),
focusedBorder: side(ColorConstants.primary, 2),
errorBorder: side(ColorConstants.errorRed, 2),
focusedErrorBorder: side(ColorConstants.errorRed, 2),
),
);
}
Widget _buildFooter() {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
AuthPrimaryButton(
label: 'Continue',
loading: isLoading,
// Enabled whenever it is not mid-request, so pressing it on an
// incomplete number *says why* instead of doing nothing. A dead
// button gives the rider no way to find out what is wrong.
onPressed: isLoading ? null : _onContinue,
),
SizedBox(height: 12.h),
RichText(
textAlign: TextAlign.center,
text: TextSpan(
style: TextStyle(
fontSize: 11.5.sp,
fontWeight: FontWeight.w500,
color: ColorConstants.secondaryText,
height: 1.5,
fontFamily: FontConstants.fontFamily,
),
children: [
const TextSpan(text: 'By continuing you agree to our '),
TextSpan(
text: 'Terms of Service',
style: TextStyle(
color: ColorConstants.slateText,
fontWeight: FontWeight.w800,
decoration: TextDecoration.underline,
decorationColor: ColorConstants.borderStrong,
fontFamily: FontConstants.fontFamily,
),
recognizer: _termsRecognizer,
),
const TextSpan(text: ' and '),
TextSpan(
text: 'Privacy Policy',
style: TextStyle(
color: ColorConstants.slateText,
fontWeight: FontWeight.w800,
decoration: TextDecoration.underline,
decorationColor: ColorConstants.borderStrong,
fontFamily: FontConstants.fontFamily,
),
recognizer: _privacyRecognizer,
),
const TextSpan(text: '.'),
],
),
),
],
);
}
}
/// ── The flag is drawn, not typed ──
///
/// 🇮🇳 renders as a grey "IN" tofu box on a fair number of Android builds, which
/// is exactly why the flag was taken off this field once before and replaced
/// with a bare "+91". Three bars and a ring cost nothing, render identically
/// everywhere, and give the prefix the thing it was missing: something to
/// recognise before reading.
///
/// The three hex values are the flag's own — saffron, navy, India green. They
/// are deliberately not tokens: a national flag is a literal, not a semantic
/// role, and putting it in the palette would invite it to be reused as one.
class _IndiaFlag extends StatelessWidget {
const _IndiaFlag();
static const Color _saffron = Color(0xFFFF9933);
static const Color _navy = Color(0xFF06038D);
static const Color _green = Color(0xFF138808);
@override
Widget build(BuildContext context) {
// Decoration beside a "+91" that already says which country this is.
return ExcludeSemantics(
child: Container(
width: 21.w,
height: 15.w,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(3.r),
border: Border.all(color: ColorConstants.borderStrong, width: 0.5),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(2.5.r),
child: Column(
children: [
const Expanded(child: ColoredBox(color: _saffron, child: SizedBox.expand())),
Expanded(
child: Container(
color: Colors.white,
alignment: Alignment.center,
child: Container(
width: 4.w,
height: 4.w,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: _navy, width: 0.9),
),
),
),
),
const Expanded(child: ColoredBox(color: _green, child: SizedBox.expand())),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,986 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/helpers/constants/Font_constant.dart';
import 'package:miler/views/helpers/constants/design_constants.dart';
import 'package:miler/views/helpers/widgets/app_widgets.dart';
/// ═════════════════════════════════════════════════════════════════════════
/// ONBOARDING DESIGN SYSTEM
///
/// Sign-in was five screens that each invented their own layout: a 104px logo
/// with a forever-pulsing glow, then a 64px tinted SMS tile, then a 64px tinted
/// lock tile, then a 64px tinted shield tile, then a 120px logo. Same flow,
/// five different first impressions, and the rider could not tell how far
/// through it he was on any of them.
///
/// What Uber, Amazon and Stripe all do with a phone → code → PIN flow is the
/// opposite: **one frame, repeated.** Back arrow, where-am-I, a large question,
/// the single input that answers it, one button. The frame never moves, so the
/// only thing that changes between steps is the question — and that is exactly
/// what should be changing.
///
/// This file is that frame. Three rules it enforces:
///
/// 1. **No decoration above the question.** The tinted icon tiles are gone. An
/// SMS glyph tells the rider nothing that "Enter the 6-digit code" does not,
/// and it pushed the actual content a third of the way down the screen. This
/// is the same call already made on the stop card, where tinted icon tiles
/// lost to plain rows — onboarding was the last place still doing it.
///
/// 2. **Slate, not brown.** The dashboard is slate-on-white
/// ([ColorConstants.slateText] / [ColorConstants.borderStrong]). Onboarding
/// was built on the M3 tonal leftovers of the red brand seed —
/// `onSurfaceVariant` is **#5C403E, a brown**, and `outlineVariant` is
/// **#E5BDBB, a pink**. Every body line and every input border in the flow
/// was one of those two, so the app opened brown-and-pink and then turned
/// slate-and-white the moment the rider signed in.
///
/// 3. **Nothing animates forever.** The old sign-in ran a 3.2s glow +
/// float on `repeat(reverse: true)`, and every intro slide ran its own
/// 1.8s pulse — permanent 60fps repaints on screens whose entire job is to
/// let someone type. Entrances here are one-shot and short; see
/// [AuthEntrance].
/// ═════════════════════════════════════════════════════════════════════════
/// Shared type scale. Every heading in the flow is the same size and weight,
/// which is what makes the five screens read as one thing.
abstract final class AuthType {
static TextStyle get title => TextStyle(
fontSize: 27.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.7,
height: 1.15,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
);
static TextStyle get subtitle => TextStyle(
fontSize: 14.5.sp,
fontWeight: FontWeight.w500,
height: 1.45,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
);
static TextStyle get fieldLabel => TextStyle(
fontSize: 12.sp,
fontWeight: FontWeight.w800,
letterSpacing: 0.5,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
);
static TextStyle get meta => TextStyle(
fontSize: 12.5.sp,
fontWeight: FontWeight.w600,
height: 1.45,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
);
}
/// One-shot fade + rise for a screen's content.
///
/// Replaces sign-in's 1100ms per-element cascade, which staggered eight items
/// at 80ms apart and so took over a second to finish — on a screen where the
/// keyboard is already up and the rider is trying to type. A single short move
/// on the whole block reads as "the screen arrived" without making anyone wait
/// for it, and it cannot be left running.
class AuthEntrance extends StatefulWidget {
final Widget child;
const AuthEntrance({super.key, required this.child});
@override
State<AuthEntrance> createState() => _AuthEntranceState();
}
class _AuthEntranceState extends State<AuthEntrance>
with SingleTickerProviderStateMixin {
late final AnimationController _c = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 320),
);
late final Animation<double> _a = CurvedAnimation(
parent: _c,
curve: Curves.easeOutCubic,
);
@override
void initState() {
super.initState();
_c.forward();
}
@override
void dispose() {
_c.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _a,
builder: (_, child) => Opacity(
opacity: _a.value,
child: Transform.translate(
offset: Offset(0, 12 * (1 - _a.value)),
child: child,
),
),
child: widget.child,
);
}
}
/// ── Where am I in this flow ──
///
/// The flow is phone → code → PIN, and none of the three screens said so. A
/// rider who has just typed a code has no way to know whether he is nearly done
/// or has five screens to go, and "how much longer is this" is the single
/// biggest reason people abandon a sign-up half way.
///
/// The count starts at the *code* screen, not the number screen. Typing a
/// phone number is the door to two different journeys — a returning rider goes
/// straight to an MPIN screen that has no counter and never needed one — so
/// counting from there promised a three-step form to people who had two
/// screens left, and to others who had one. What the rail measures now is the
/// part that is genuinely a multi-screen sign-up.
///
/// Deliberately a bar and a count rather than dots: dots say "there are three
/// of these" but not which are behind you, and this reuses the segmented-bar
/// language already used for route progress on Bookings.
class AuthSteps extends StatelessWidget {
final int step;
final int total;
const AuthSteps({super.key, required this.step, this.total = 3});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'STEP $step OF $total',
style: TextStyle(
fontSize: 10.5.sp,
fontWeight: FontWeight.w800,
letterSpacing: 1.0,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 7.h),
Row(
children: [
for (var i = 1; i <= total; i++) ...[
if (i > 1) SizedBox(width: 5.w),
Expanded(
child: AnimatedContainer(
duration: const Duration(milliseconds: 260),
curve: Curves.easeOut,
height: 4.h,
decoration: BoxDecoration(
color: i <= step
? ColorConstants.primary
: ColorConstants.borderStrong,
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
),
),
),
],
],
),
],
);
}
}
/// The one frame every auth screen wears.
///
/// Content is **top-anchored, not centred**: the question and its input sit
/// high, where they stay put when the keyboard opens, instead of being shoved
/// around by it. The CTA is pinned to the bottom of the remaining space, so it
/// rides just above the keyboard rather than hiding behind it.
class AuthScaffold extends StatelessWidget {
/// Back affordance. Null hides it — used on the first screen of the flow and
/// on any screen there is no going back from.
final VoidCallback? onBack;
/// 1-based position in the sign-up flow. Null on screens outside it — the
/// returning-user unlock is not "step 1 of 2" of anything, and neither is
/// the number screen that leads to it.
final int? step;
final int totalSteps;
final String title;
final String? subtitle;
/// Everything under the title block.
final List<Widget> children;
/// Pinned to the bottom, above the keyboard. Usually an [AuthPrimaryButton]
/// plus whatever legal text belongs with it.
final Widget footer;
/// Optional trailing widget on the top bar (e.g. a brand mark).
final Widget? topRight;
/// A full-bleed brand block above the content, which then sits on a rounded
/// sheet lifted over it.
///
/// Null on every screen but the first, and deliberately so — see
/// [AuthBrandBanner]. When null this widget renders exactly the layout it
/// always has, so opting one screen in cannot move the other three.
final Widget? banner;
const AuthScaffold({
super.key,
this.onBack,
this.step,
this.totalSteps = 3,
required this.title,
this.subtitle,
required this.children,
required this.footer,
this.topRight,
this.banner,
});
@override
Widget build(BuildContext context) {
if (banner != null) return _banneredLayout(context);
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.dark.copyWith(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
),
child: Scaffold(
backgroundColor: ColorConstants.surface,
body: SafeArea(
child: Column(
children: [
// ── Top bar ──
// A 44dp back target, not a bare 20sp glyph in an IconButton with
// 8px of padding. This is the control a rider hits with a glove on.
if (onBack != null || topRight != null)
Padding(
padding: EdgeInsets.fromLTRB(10.w, 6.h, 16.w, 0),
child: Row(
children: [
if (onBack != null)
_TapTarget(
onTap: onBack!,
semanticLabel: 'Back',
child: Icon(
Icons.arrow_back_rounded,
size: 22.sp,
color: ColorConstants.slateText,
),
)
else
SizedBox(width: 14.w),
const Spacer(),
if (topRight != null) topRight!,
],
),
),
Expanded(
child: SingleChildScrollView(
physics: const ClampingScrollPhysics(),
padding: EdgeInsets.fromLTRB(24.w, 0, 24.w, 8.h),
child: AuthEntrance(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: 14.h),
if (step != null) ...[
AuthSteps(step: step!, total: totalSteps),
SizedBox(height: 26.h),
],
Text(title, style: AuthType.title),
if (subtitle != null) ...[
SizedBox(height: 9.h),
Text(subtitle!, style: AuthType.subtitle),
],
SizedBox(height: 30.h),
...children,
],
),
),
),
),
Padding(
padding: EdgeInsets.fromLTRB(24.w, 4.h, 24.w, 10.h),
child: footer,
),
],
),
),
),
);
}
/// ── Banner + sheet ──
///
/// The brand block runs full-bleed to the top of the screen, status bar
/// included, and the content rides on a rounded sheet lifted [_lift] over it
/// so the two read as one card sitting on a coloured ground rather than as
/// two stacked bands.
///
/// The lift is a `Transform.translate` rather than a `Stack`, so there is
/// exactly one animated height on the screen — the banner's. A Stack would
/// need the banner and the content's top offset to animate in lockstep, and
/// two controllers describing one movement is how they end up disagreeing.
/// The sheet finishes [_lift] short of the bottom as a result, which is
/// invisible: the Scaffold behind it is the same colour. The footer's bottom
/// padding carries that back so the button does not ride high.
Widget _banneredLayout(BuildContext context) {
return AnnotatedRegion<SystemUiOverlayStyle>(
// Light glyphs: the status bar sits on the brand block, not on the page.
value: SystemUiOverlayStyle.light.copyWith(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light,
statusBarBrightness: Brightness.dark,
),
child: Scaffold(
backgroundColor: ColorConstants.pureSurface,
body: Column(
children: [
// ── The banner has to be told about the keyboard ──
//
// `Scaffold` with `resizeToAvoidBottomInset` (the default, and what
// lifts the footer clear of the keyboard) *consumes* the bottom
// view inset: everything inside `body` sees `viewInsets.bottom == 0`
// whether the keyboard is up or not. So a child that wants to react
// to the keyboard cannot ask the obvious question from in there.
//
// This context is AuthScaffold's own, above the Scaffold, so it
// still carries the real value. Re-providing it to the banner's
// subtree hands the untouched insets back to exactly the one widget
// that needs them, and leaves the Scaffold's own resizing alone.
Stack(
children: [
MediaQuery(data: MediaQuery.of(context), child: banner!),
// ── Back rides on the banner ──
//
// The plain layout gives back its own row above the content.
// There is no such row here, and three of the four screens in
// this flow pass an `onBack` — so without this the banner would
// silently swallow the only way out of OTP, Create MPIN and
// Unlock. White, because it sits on the brand block.
if (onBack != null)
PositionedDirectional(
start: 4.w,
top: MediaQuery.paddingOf(context).top + 2.h,
child: _TapTarget(
onTap: onBack!,
semanticLabel: 'Back',
child: Icon(
Icons.arrow_back_rounded,
size: 22.sp,
color: ColorConstants.onAccent,
),
),
),
],
),
Expanded(
child: Transform.translate(
offset: Offset(0, -_lift.h),
child: Container(
decoration: BoxDecoration(
color: ColorConstants.pureSurface,
borderRadius: BorderRadius.vertical(
top: Radius.circular(28.r),
),
),
child: SafeArea(
top: false,
child: Column(
children: [
Expanded(
child: SingleChildScrollView(
physics: const ClampingScrollPhysics(),
padding: EdgeInsets.fromLTRB(24.w, 0, 24.w, 8.h),
child: AuthEntrance(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: 26.h),
if (step != null) ...[
AuthSteps(step: step!, total: totalSteps),
SizedBox(height: 24.h),
],
Text(title, style: AuthType.title),
if (subtitle != null) ...[
SizedBox(height: 9.h),
Text(subtitle!, style: AuthType.subtitle),
],
SizedBox(height: 28.h),
...children,
],
),
),
),
),
Padding(
padding: EdgeInsets.fromLTRB(
24.w,
4.h,
24.w,
10.h + _lift.h,
),
child: footer,
),
],
),
),
),
),
),
],
),
),
);
}
/// How far the sheet is pulled up over the banner.
static const double _lift = 22;
}
/// ─────────────────────────────────────────────────────────────────────────
/// THE BRAND BLOCK
///
/// The masthead every screen in the onboarding flow wears. One block across
/// Sign In, OTP, Create MPIN and Unlock, so the four screens read as one
/// doorway rather than as four forms that happen to follow each other.
///
/// ── The wordmark is knocked out, not re-exported ──
///
/// `doormile-logo.png` is RGBA, so `BlendMode.srcIn` fills every
/// non-transparent pixel white and gives a clean reverse of the red original.
/// That is deliberately preferred to shipping a second white asset: two logo
/// files drift, and this one cannot.
///
/// ── Full block, or strip ──
///
/// The rule is what the screen does on arrival, and there are only two cases:
///
/// • **Opens with the keyboard down** — Sign In, the one screen with nothing
/// focused. It gets the full 208dp block, and gives half of it back the
/// moment the rider taps the field. The brand moment is worth the height
/// while he is deciding to start, and worth nothing while he is typing.
///
/// • **Opens with the keyboard already up** — OTP, Create MPIN, Unlock, all of
/// which focus their first box in a post-frame callback. These pass
/// [compact] and get the strip immediately, with no animation at all.
/// Adaptive sizing here would render the full block for one frame and then
/// shrink it, so every launch would open by animating the masthead away —
/// which is the splash screen this app deleted, wearing a different hat.
///
/// Unlock is the screen that makes the second case matter: the rider opens it
/// every morning, and a daily animation is a daily tax.
/// ─────────────────────────────────────────────────────────────────────────
class AuthBrandBanner extends StatelessWidget {
/// Forces the strip. See the class note — this is for screens that raise the
/// keyboard themselves on the first frame.
final bool compact;
const AuthBrandBanner({super.key, this.compact = false});
static const double _restHeight = 208;
static const double _typingHeight = 128;
/// Source wordmark is 748×100, and the ratio is held explicitly so the mark
/// cannot be squashed by a layout change.
static const double _markAspect = 748 / 100;
@override
Widget build(BuildContext context) {
final typing = compact || MediaQuery.viewInsetsOf(context).bottom > 0;
final markWidth = (typing ? 150.0 : 196.0).w;
return AnimatedContainer(
duration: DesignConstants.motionState,
curve: Curves.easeOutCubic,
width: double.infinity,
height: (typing ? _typingHeight : _restHeight).h +
MediaQuery.paddingOf(context).top,
color: ColorConstants.primary,
child: ClipRect(
child: Stack(
children: [
// ── The rings are the logo's own motif ──
//
// "mile" is drawn as concentric arcs in the Doormile mark, so the
// block is decorated with the same shape rather than with imported
// ornament. Bled off both edges and held at 7% white: at that
// weight they read as texture on the brand colour and never as
// objects the rider might try to tap.
Positioned(left: -78.w, top: -34.h, child: const _BrandRing(size: 196)),
Positioned(right: -92.w, bottom: -78.h, child: const _BrandRing(size: 244)),
Center(
child: Padding(
padding: EdgeInsets.only(
top: MediaQuery.paddingOf(context).top * 0.5,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Semantics(
label: 'Doormile',
image: true,
child: SizedBox(
width: markWidth,
height: markWidth / _markAspect,
child: ColorFiltered(
colorFilter: const ColorFilter.mode(
Colors.white,
BlendMode.srcIn,
),
child: Image.asset(
'assets/images/doormile-logo.png',
fit: BoxFit.contain,
filterQuality: FilterQuality.medium,
),
),
),
),
SizedBox(height: 12.h),
// Names the product under the company that makes it. The
// rider was handed "the Miler app" by his hub; the mark
// above says Doormile. This is the line that ties the two
// together, and it is the reason it is words and not a
// decorative flourish.
Text(
'MILER · RIDER APP',
style: TextStyle(
fontSize: 10.5.sp,
fontWeight: FontWeight.w800,
letterSpacing: 1.6,
color: ColorConstants.onAccent.withValues(alpha: 0.72),
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
),
],
),
),
);
}
}
/// One faint concentric ring from the wordmark's arc motif.
class _BrandRing extends StatelessWidget {
final double size;
const _BrandRing({required this.size});
@override
Widget build(BuildContext context) {
return SizedBox(
width: size.w,
height: size.w,
child: CustomPaint(painter: _RingPainter()),
);
}
}
class _RingPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final centre = size.center(Offset.zero);
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = size.width * 0.055
..color = Colors.white.withValues(alpha: 0.07);
// Three rings, stepping inward by the stroke plus its own gap — the same
// rhythm the "m" and "i" of the wordmark are built from.
for (var i = 0; i < 3; i++) {
canvas.drawCircle(centre, size.width * (0.46 - i * 0.13), paint);
}
}
@override
bool shouldRepaint(covariant _RingPainter oldDelegate) => false;
}
/// 44dp-minimum tap target around a small glyph.
class _TapTarget extends StatelessWidget {
final VoidCallback onTap;
final Widget child;
final String semanticLabel;
const _TapTarget({
required this.onTap,
required this.child,
required this.semanticLabel,
});
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
label: semanticLabel,
child: InkResponse(
onTap: onTap,
radius: 24.r,
child: SizedBox(
width: ButtonSizes.minTapTarget,
height: ButtonSizes.minTapTarget,
child: Center(child: child),
),
),
);
}
}
/// The single action a screen exists for.
///
/// Was copy-pasted three times, identically, with a `Row` of label + arrow
/// inside an `ElevatedButton.styleFrom` that overrode colour and shape only.
/// That last detail matters: the app theme styles `ElevatedButton` for
/// full-width form buttons with `vertical: 18` padding and a 56dp
/// `minimumSize`, and `styleFrom` keeps whatever you do not replace — so the
/// button only ever fitted because 56 happened to be the exact height these
/// screens asked for. The overrides below make the height this widget's own
/// business, so it cannot be broken by changing a number.
class AuthPrimaryButton extends StatelessWidget {
final String label;
final IconData? icon;
final bool loading;
final VoidCallback? onPressed;
const AuthPrimaryButton({
super.key,
required this.label,
this.icon = Icons.arrow_forward_rounded,
this.loading = false,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
final enabled = onPressed != null && !loading;
return SizedBox(
width: double.infinity,
height: ButtonSizes.primary,
child: ElevatedButton(
onPressed: enabled ? onPressed : null,
style: ElevatedButton.styleFrom(
backgroundColor: ColorConstants.primary,
foregroundColor: Colors.white,
// A disabled CTA reads as "not yet", so it stays recognisably the
// same button rather than turning grey and looking broken.
disabledBackgroundColor: ColorConstants.primary.withValues(
alpha: 0.30,
),
disabledForegroundColor: Colors.white.withValues(alpha: 0.85),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(ButtonSizes.radius.r),
),
padding: EdgeInsets.symmetric(horizontal: 16.w),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: loading
? SizedBox(
height: 22.sp,
width: 22.sp,
child: const CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2.5,
),
)
: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 16.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.2,
fontFamily: FontConstants.fontFamily,
),
),
),
if (icon != null) ...[
SizedBox(width: 8.w),
Icon(icon, color: Colors.white, size: 19.sp),
],
],
),
),
);
}
}
/// Quiet text action — Resend, Forgot MPIN, Change number.
class AuthTextAction extends StatelessWidget {
final String label;
final IconData? icon;
final VoidCallback? onPressed;
const AuthTextAction({
super.key,
required this.label,
this.icon,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
final on = onPressed != null;
final color = on ? ColorConstants.primary : ColorConstants.secondaryText;
return TextButton(
onPressed: onPressed,
style: TextButton.styleFrom(
minimumSize: Size(0, ButtonSizes.minTapTarget),
padding: EdgeInsets.symmetric(horizontal: 12.w),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 15.sp, color: color),
SizedBox(width: 6.w),
],
Text(
label,
style: TextStyle(
fontSize: 13.5.sp,
fontWeight: FontWeight.w800,
color: color,
fontFamily: FontConstants.fontFamily,
),
),
],
),
);
}
}
/// ── Inline error ──
///
/// The flow used to report a wrong or expired code with a floating `SnackBar`
/// at the bottom of the screen — under the keyboard, on the one screen
/// guaranteed to have the keyboard up. The rider saw the boxes clear themselves
/// and nothing else, which reads as a crash rather than a rejection.
///
/// An error belongs next to the thing that caused it, and the input it refers to
/// turns red at the same time (see [AuthCodeRow.hasError]).
class AuthInlineError extends StatelessWidget {
final String? message;
const AuthInlineError({super.key, this.message});
@override
Widget build(BuildContext context) {
return AnimatedSize(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOut,
alignment: Alignment.topLeft,
child: message == null
? const SizedBox(width: double.infinity)
: Padding(
padding: EdgeInsets.only(top: 10.h),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.error_outline_rounded,
size: 15.sp,
color: ColorConstants.errorRed,
),
SizedBox(width: 6.w),
Expanded(
child: Text(
message!,
style: TextStyle(
color: ColorConstants.errorRed,
fontSize: 12.5.sp,
fontWeight: FontWeight.w700,
height: 1.35,
fontFamily: FontConstants.fontFamily,
),
),
),
],
),
),
);
}
}
/// ── One row of single-character boxes ──
///
/// Shared by the 6-digit OTP screen and both 4-digit MPIN screens, which
/// previously drew this control three times in three different ways: 46×56 at
/// radius 12 with a pink border; 70×70 at radius 16 filled grey with a
/// transparent border; 70×70 at radius 14 white with a pink border. Same
/// control, same flow, three looks.
///
/// The boxes **flex** rather than carrying fixed widths. The old versions hard-
/// coded `46.w` / `70.w` inside a `spaceBetween` row, which only fits because
/// the design width happens to divide that way — four 70s plus gaps is 316 of a
/// 342 usable width, so a slightly wider gutter or a fifth box overflows. Sizing
/// from the space available cannot overflow at any count or screen width.
class AuthCodeRow extends StatelessWidget {
final List<TextEditingController> controllers;
final List<FocusNode> nodes;
final bool obscure;
final bool hasError;
final void Function(String value, int index) onChanged;
const AuthCodeRow({
super.key,
required this.controllers,
required this.nodes,
required this.onChanged,
this.obscure = false,
this.hasError = false,
});
@override
Widget build(BuildContext context) {
final count = controllers.length;
// Taller boxes when there are fewer of them: a 4-digit PIN gets room to
// feel deliberate, a 6-digit code has to stay inside the width.
final ratio = count > 4 ? 1.20 : 1.05;
return Row(
children: [
for (var i = 0; i < count; i++) ...[
if (i > 0) SizedBox(width: MilerCodeBox.gap(context).w),
Expanded(
child: AspectRatio(
aspectRatio: 1 / ratio,
child: _Box(
controller: controllers[i],
node: nodes[i],
obscure: obscure,
hasError: hasError,
onChanged: (v) => onChanged(v, i),
),
),
),
],
],
);
}
}
class _Box extends StatefulWidget {
final TextEditingController controller;
final FocusNode node;
final bool obscure;
final bool hasError;
final ValueChanged<String> onChanged;
const _Box({
required this.controller,
required this.node,
required this.obscure,
required this.hasError,
required this.onChanged,
});
@override
State<_Box> createState() => _BoxState();
}
class _BoxState extends State<_Box> {
@override
void initState() {
super.initState();
// Repaints only this box on focus, rather than the whole screen. The old
// screens called setState on every focus change and every keystroke to
// restyle six boxes, rebuilding the entire page each time.
widget.node.addListener(_onFocus);
}
@override
void dispose() {
widget.node.removeListener(_onFocus);
super.dispose();
}
void _onFocus() {
if (mounted) setState(() {});
}
@override
Widget build(BuildContext context) {
final focused = widget.node.hasFocus;
final filled = widget.controller.text.isNotEmpty;
// The shared spec — see [MilerCodeBox]. The login code and the delivery OTP
// are two input models (per-box controllers here, one hidden field there)
// but the rider must meet one control.
return AnimatedContainer(
duration: DesignConstants.motionState,
decoration: MilerCodeBox.decoration(
focused: focused,
filled: filled,
hasError: widget.hasError,
accent: ColorConstants.primary,
),
child: Center(
child: TextField(
controller: widget.controller,
focusNode: widget.node,
obscureText: widget.obscure,
obscuringCharacter: '•',
keyboardType: TextInputType.number,
textAlign: TextAlign.center,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(1),
],
style: MilerCodeBox.digit(
ColorConstants.primary,
hasError: widget.hasError,
),
cursorColor: ColorConstants.primary,
cursorWidth: 2,
decoration: const InputDecoration(
counterText: '',
filled: false,
isDense: true,
contentPadding: EdgeInsets.zero,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
errorBorder: InputBorder.none,
disabledBorder: InputBorder.none,
),
onChanged: widget.onChanged,
),
),
);
}
}

View File

@@ -0,0 +1,260 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:miler/views/helpers/widgets/page_transitions.dart';
import 'package:miler/views/helpers/constants/Font_constant.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/controllers/auth.dart';
import 'package:miler/views/onboardscreens/Creat_mpin.dart';
import 'package:miler/views/onboardscreens/auth_scaffold.dart';
import 'package:sms_autofill/sms_autofill.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// VERIFY — step 2 of 3.
///
/// ── What changed and why ──
///
/// **The number is now on screen.** The old copy said "Enter the 6-digit code
/// sent to your device", which is the one screen in the flow where the rider
/// most needs to check a fact — *which* number did it go to — and it did not
/// tell him. If he fat-fingered a digit on the previous screen, he waits out a
/// 60-second timer for a code that was never coming, with no way to tell. It now
/// echoes the number with a "Change" action beside it, which is what every app
/// that has thought about this does.
///
/// **Failure is visible.** A wrong or expired code silently cleared all six
/// boxes and dropped a floating `SnackBar` at the bottom of the screen —
/// underneath the keyboard, which is up by definition here. The rider saw six
/// boxes empty themselves for no stated reason. The boxes now turn red and the
/// reason is printed under them.
///
/// **The decorative tile is gone**, along with the 64px tinted SMS glyph inside
/// it: it pushed the code boxes a third of the way down the screen to illustrate
/// a word already in the heading.
/// ─────────────────────────────────────────────────────────────────────────
class OtpPage extends GetResponsiveView {
OtpPage({super.key});
@override
Widget builder() => const _OtpPageLayout();
}
class _OtpPageLayout extends StatefulWidget {
const _OtpPageLayout();
@override
State<_OtpPageLayout> createState() => _OtpPageLayoutState();
}
class _OtpPageLayoutState extends State<_OtpPageLayout> with CodeAutoFill {
final AuthController _auth = Get.put(AuthController());
final List<TextEditingController> _otpControllers = List.generate(
6,
(_) => TextEditingController(),
);
final List<FocusNode> _focusNodes = List.generate(6, (_) => FocusNode());
bool isVerifying = false;
String? _error;
int _secondsRemaining = 60;
Timer? _timer;
@override
void initState() {
super.initState();
_startTimer();
listenForCode();
WidgetsBinding.instance.addPostFrameCallback(
(_) => _focusNodes[0].requestFocus(),
);
}
void _startTimer() {
_secondsRemaining = 60;
_timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (!mounted) {
timer.cancel();
return;
}
if (_secondsRemaining > 0) {
setState(() => _secondsRemaining--);
} else {
timer.cancel();
}
});
}
bool get _isComplete => _otpControllers.every((c) => c.text.isNotEmpty);
String get _code => _otpControllers.map((c) => c.text).join();
Future<void> _autoVerify() async {
if (isVerifying || !_isComplete) return;
setState(() {
isVerifying = true;
_error = null;
});
final ok = await _auth.verifyOtp(_code);
if (!mounted) return;
setState(() => isVerifying = false);
if (ok) {
openScreen(context, CreateMpin());
} else {
// Clear and refocus as before, but say so — see the class note.
for (final c in _otpControllers) {
c.clear();
}
_focusNodes[0].requestFocus();
setState(() {
_error = _secondsRemaining == 0
? 'That code is incorrect or has expired. Request a new one.'
: 'That code is incorrect. Check the SMS and try again.';
});
}
}
@override
void codeUpdated() {
final received = code ?? '';
if (received.length >= 6) {
for (int i = 0; i < 6; i++) {
_otpControllers[i].text = received[i];
}
setState(() => _error = null);
_autoVerify();
}
}
void _onDigitChanged(String val, int index) {
if (val.isNotEmpty && index < 5) {
_focusNodes[index + 1].requestFocus();
}
if (val.isEmpty && index > 0) {
_focusNodes[index - 1].requestFocus();
}
// Typing again clears a previous rejection — the red boxes refer to the code
// he just replaced, not the one he is entering now.
setState(() {
if (_error != null) _error = null;
});
if (_isComplete) _autoVerify();
}
/// `+91 98765 43210`, grouped so a rider can actually proof-read it.
String get _maskedPhone {
final raw = (_auth.currentPhone ?? '').replaceAll(RegExp(r'\D'), '');
if (raw.length != 10) return 'your mobile number';
return '+91 ${raw.substring(0, 5)} ${raw.substring(5)}';
}
@override
void dispose() {
for (var c in _otpControllers) {
c.dispose();
}
for (var f in _focusNodes) {
f.dispose();
}
_timer?.cancel();
cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final canResend = _secondsRemaining == 0;
return AuthScaffold(
onBack: () => Get.back(),
// 1 of 2, not 2 of 3: the number screen no longer counts itself, and a
// counter whose first sighting is "step 2" is a counter the rider has
// already lost track of. See the note at the top of `Sign_in`.
step: 1,
totalSteps: 2,
// Compact, for the same reason as the two screens either side of it: the
// first box takes focus on the first frame. Included so the flow does not
// gain and lose its masthead between the number screen and the PIN one.
banner: const AuthBrandBanner(compact: true),
title: 'Enter the 6-digit code',
footer: AuthPrimaryButton(
label: 'Verify & continue',
loading: isVerifying,
onPressed: isVerifying || !_isComplete ? null : _autoVerify,
),
children: [
// ── Which number, and a way out ──
// The single most useful fact on this screen, and the old version left
// it off entirely.
Padding(
padding: EdgeInsets.only(bottom: 26.h),
child: Row(
children: [
Expanded(
child: Text.rich(
TextSpan(
style: AuthType.subtitle,
children: [
const TextSpan(text: 'Sent by SMS to '),
TextSpan(
text: _maskedPhone,
style: TextStyle(
fontWeight: FontWeight.w800,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
),
SizedBox(width: 8.w),
AuthTextAction(
label: 'Change',
onPressed: isVerifying ? null : () => Get.back(),
),
],
),
),
AuthCodeRow(
controllers: _otpControllers,
nodes: _focusNodes,
hasError: _error != null,
onChanged: _onDigitChanged,
),
AuthInlineError(message: _error),
SizedBox(height: 22.h),
// ── Resend ──
//
// The countdown used to be a disabled `TextButton` reading
// "Resend in 00:42", so the affordance and the timer were the same
// control and it looked broken for its first sixty seconds. The wait is
// now a plain sentence, and the button only exists once it can be used.
Center(
child: canResend
? AuthTextAction(
label: 'Send a new code',
icon: Icons.refresh_rounded,
onPressed: () async {
setState(() => _error = null);
_startTimer();
await _auth.sendOtp();
},
)
: Text(
'You can request a new code in '
'0:${_secondsRemaining.toString().padLeft(2, '0')}',
style: AuthType.meta,
),
),
],
);
}
}

View File

@@ -0,0 +1,241 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:miler/views/helpers/constants/Font_constant.dart';
import 'dart:io';
import 'package:url_launcher/url_launcher.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/helpers/widgets/app_widgets.dart';
import 'package:new_version_plus/new_version_plus.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/views/introscreens/introscreen.dart';
import 'package:miler/widget/Bottom_page.dart';
class UpdateScreen extends StatefulWidget {
final bool mIsForceUpdate;
final String mCurrentVersion;
final String mUpdateVersion;
const UpdateScreen({
super.key,
this.mIsForceUpdate = true,
required this.mCurrentVersion,
required this.mUpdateVersion,
});
@override
State<UpdateScreen> createState() => _UpdateScreenState();
}
class _UpdateScreenState extends State<UpdateScreen>
with WidgetsBindingObserver {
bool _isCheckingVersion = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed && !_isCheckingVersion) {
checkVersionAndNavigate();
}
}
Future<void> checkVersionAndNavigate() async {
if (_isCheckingVersion) return;
_isCheckingVersion = true;
try {
await Future.delayed(const Duration(seconds: 1));
final newVersion = NewVersionPlus(
iOSId: '284882215',
androidId: "com.doormile.partner",
);
final status = await newVersion.getVersionStatus();
if (status != null) {
if (!status.canUpdate) {
if (mounted) {
_navigateToNextScreen();
}
}
}
} catch (e) {
debugPrint("Error checking version: $e");
} finally {
_isCheckingVersion = false;
}
}
Future<void> _navigateToNextScreen() async {
final prefs = await SharedPreferences.getInstance();
final isLoggedOut = prefs.getBool('logged_out') == true;
final savedUserId = prefs.getInt('userid');
if (!mounted) return;
if (!isLoggedOut && savedUserId != null && savedUserId > 0) {
// No duty gate — see the note in `app_bootstrap.dart`.
Get.offAll(() => const BottomPage());
} else {
Get.offAll(() => Introscreen());
}
}
@override
Widget build(BuildContext context) {
double h = Get.height;
double w = Get.width;
return WillPopScope(
onWillPop: () async {
if (widget.mIsForceUpdate) {
SystemNavigator.pop();
return false;
}
return false;
},
child: Scaffold(
backgroundColor: const Color(0xFFF8F9FB),
extendBodyBehindAppBar: true,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
surfaceTintColor: Colors.transparent,
systemOverlayStyle: const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
statusBarBrightness: Brightness.light,
),
),
body: SafeArea(
top: true,
bottom: false, // Bottom safe area handled in bottomNavigationBar
child: LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: IntrinsicHeight(
child: Column(
children: [
/// top spacing (responsive)
SizedBox(height: h * 0.06),
/// 🚀 Image (auto scales)
Padding(
padding: EdgeInsets.symmetric(horizontal: w * 0.08),
child: Image.asset(
"assets/images/update.png",
height: h * 0.28,
fit: BoxFit.contain,
),
),
SizedBox(height: h * 0.03),
/// 🔥 Title
Text(
"A New Update is Available!",
style: TextStyle(
fontSize: h * 0.025,
fontWeight: FontWeight.bold,
fontFamily: FontConstants.fontFamily,
color: ColorConstants.onSurface,
),
textAlign: TextAlign.center,
),
SizedBox(height: h * 0.015),
/// 📄 Subtitle
Padding(
padding: EdgeInsets.symmetric(horizontal: w * 0.08),
child: Text(
"New features are here to make your app experience even smoother and more user-friendly!",
textAlign: TextAlign.center,
style: TextStyle(
fontSize: h * 0.018,
color: ColorConstants.secondaryText,
height: 1.4,
fontFamily: FontConstants.fontFamily,
),
),
),
SizedBox(height: h * 0.03),
/// Version Text
Text(
"Available version: ${widget.mUpdateVersion}",
style: TextStyle(
fontSize: h * 0.017,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
Spacer(), // pushes content for large screens
],
),
),
),
);
},
),
),
/// ⭐ Bottom Button (fixed, responsive) with bottom safe area
bottomNavigationBar: SafeArea(
top: false,
bottom: true,
child: Padding(
padding: EdgeInsets.fromLTRB(w * 0.06, 0, w * 0.06, h * 0.03),
// Height and label size used to be `h * 0.065` and `h * 0.022`,
// so this button was a different size on every device — ~42dp on a
// small phone, ~52 on a large one, and far taller on a tablet. It
// is the same button doing the same job either way, so it takes the
// same fixed size as every other primary action.
child: MilerButton(
label: "Update now",
onPressed: () => downloadActions(),
),
),
),
),
);
}
void downloadActions() async {
String url;
var s = Platform.isAndroid ? "Android" : "Ios";
if (s == "Android") {
url =
'https://play.google.com/store/apps/details?id=com.doormile.partner';
} else {
url = 'https://apps.apple.com/us/app/doormile/id1596895375ls=1';
}
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
throw 'Could not launch App';
}
}
}