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