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