1540 lines
59 KiB
Dart
1540 lines
59 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
|
import 'package:lottie/lottie.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
import 'package:miler/Models/stop_status.dart';
|
|
import 'package:miler/data/accepted_store.dart';
|
|
import 'package:miler/data/stop_compliance.dart';
|
|
import 'package:miler/providers/pickup/pickup_provider.dart';
|
|
import 'package:miler/views/Dashboard/home/trip.dart';
|
|
import 'package:miler/views/Dashboard/pickups/pickups.dart';
|
|
import 'package:miler/views/Dashboard/home/trip_tabs.dart';
|
|
import 'package:miler/views/helpers/constants/apiconstants.dart';
|
|
import 'package:miler/views/Dashboard/pickups/stop_type.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/miler_app_bar.dart';
|
|
import 'package:miler/widget/Bottom_page.dart';
|
|
|
|
/// ─────────────────────────────────────────────────────────────────────────
|
|
/// ACTIVITY — the stops the rider has finished
|
|
///
|
|
/// ── Why this tab exists ──
|
|
///
|
|
/// Home used to keep a stop after it was completed, greyed out with a
|
|
/// "Completed" chip. It was the wrong place for it twice over: Home is the
|
|
/// screen where the rider decides what to take, so a finished stop is the one
|
|
/// kind of row he can do nothing about — and there is no upper bound on them.
|
|
/// Six stops into a shift he was scrolling past six dead cards to reach the one
|
|
/// live one, so the screen got harder to use in exact proportion to how much
|
|
/// work he had done.
|
|
///
|
|
/// Finished work is still worth showing — it is how he checks what he did, and
|
|
/// how he answers "did that drop actually go through?" — it just is not
|
|
/// *decision* material. So it moves here, to the tab that used to be Earnings.
|
|
/// Earnings did not need a permanent tab: it is a once-or-twice-a-day read, and
|
|
/// it now opens from Account. What he did today is the thing he glances at
|
|
/// between stops.
|
|
///
|
|
/// ── What counts as finished ──
|
|
///
|
|
/// [StopStatusX.isFinishedPickup] — picked up or cancelled — which is the same
|
|
/// test Bookings uses to drop a stop off its working list. So every stop that
|
|
/// leaves Bookings arrives here, and the two can never disagree about what
|
|
/// "done" means.
|
|
///
|
|
/// ── And what is parked ──
|
|
///
|
|
/// Skipped stops are here too, in their own group above the finished ones, with
|
|
/// the reason the rider gave and a Resume control. They used to sit on Home,
|
|
/// among the stops still waiting to be accepted, and that was the wrong screen
|
|
/// for the same reason finished stops were: Home answers "what am I taking?",
|
|
/// and a skip is not on offer — it is something that already happened at a
|
|
/// door. It also accumulated. By mid-shift the accept list carried a permanent
|
|
/// tail of stops the rider had already put down, and he had to read past his
|
|
/// own morning to find the afternoon's work.
|
|
///
|
|
/// They lead this tab rather than trailing it because they are the one thing on
|
|
/// it that is not finished: a skipped stop is a promise to go back, and the
|
|
/// rider has until the end of the shift to keep it.
|
|
/// ─────────────────────────────────────────────────────────────────────────
|
|
class ActivityPage extends StatefulWidget {
|
|
const ActivityPage({super.key});
|
|
|
|
@override
|
|
State<ActivityPage> createState() => _ActivityPageState();
|
|
}
|
|
|
|
class _ActivityPageState extends State<ActivityPage> {
|
|
final PickupProvider _provider = PickupProvider();
|
|
|
|
List<Map<String, dynamic>> _done = <Map<String, dynamic>>[];
|
|
|
|
/// Stops parked for a return visit, newest first. Kept apart from [_done]
|
|
/// because they are the opposite kind of row: unfinished, and the only ones
|
|
/// on this page that still carry an action.
|
|
List<Map<String, dynamic>> _skipped = <Map<String, dynamic>>[];
|
|
|
|
/// Order ids with a resume in flight, so one cannot be fired twice.
|
|
final Set<String> _busyIds = <String>{};
|
|
|
|
/// Rows the rider has opened.
|
|
///
|
|
/// Held by order id rather than list index: the list re-sorts on every poll,
|
|
/// and an index would quietly move the open row onto a different stop.
|
|
final Set<String> _openIds = <String>{};
|
|
|
|
bool _firstLoadDone = false;
|
|
bool _failed = false;
|
|
bool _fetching = false;
|
|
|
|
/// Which trip tab is open. Held across refreshes; clamped at read time, so a
|
|
/// trip disappearing between polls cannot leave the page pointing past the
|
|
/// end of the list.
|
|
int _selectedTripIndex = 0;
|
|
|
|
/// Everything this page accounts for: what he put down, then what he
|
|
/// finished. One list so the trip grouping sees the whole run.
|
|
List<Map<String, dynamic>> get _all => [..._skipped, ..._done];
|
|
|
|
/// The day's stops, grouped into the same trips Home groups them into.
|
|
///
|
|
/// The rider's day is Trip 1 / 2 / 3 everywhere else in the app; a flat list
|
|
/// of everything he finished would be the one screen that made him work out
|
|
/// for himself which run a stop belonged to. Same grouping function as Home,
|
|
/// so the two can never disagree about where a stop sits.
|
|
List<Trip> get _trips => Trip.groupIntoTrips(_all);
|
|
|
|
/// Stops on the selected tab. Empty — not a fallback to another trip — when
|
|
/// the tab is one of the slots [TripTabs] always draws for a day the hub has
|
|
/// not filled: showing Trip 1's stops under a "Trip 3" label would be a lie
|
|
/// about which run the rider is looking at.
|
|
List<Map<String, dynamic>> get _visible {
|
|
final trips = _trips;
|
|
if (_selectedTripIndex < 0 || _selectedTripIndex >= trips.length) {
|
|
return const [];
|
|
}
|
|
return trips[_selectedTripIndex].stops;
|
|
}
|
|
|
|
/// The selected trip's rows, split the way the page draws them.
|
|
(List<Map<String, dynamic>> skipped, List<Map<String, dynamic>> done)
|
|
get _visibleSplit {
|
|
final stops = _visible;
|
|
return (
|
|
stops.where((s) => stopStatusOf(s).isSkipped).toList()
|
|
..sort((a, b) => _happenedAt(b).compareTo(_happenedAt(a))),
|
|
stops.where((s) => stopStatusOf(s).isFinishedPickup).toList()
|
|
..sort((a, b) => _happenedAt(b).compareTo(_happenedAt(a))),
|
|
);
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
|
|
// ── Re-read whenever the tab comes forward ──
|
|
//
|
|
// The shell keeps all four tabs alive in an `IndexedStack`, so this
|
|
// `initState` runs once at app launch — before the rider has completed
|
|
// anything. Without this listener the tab showed that first, empty result
|
|
// for the rest of the session: he finished a stop, came here, and found
|
|
// nothing, which is exactly the bug this page was added to prevent.
|
|
BottomPage.currentTab.addListener(_onTabChanged);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
BottomPage.currentTab.removeListener(_onTabChanged);
|
|
super.dispose();
|
|
}
|
|
|
|
void _onTabChanged() {
|
|
if (BottomPage.currentTab.value == _activityTabIndex && mounted) _load();
|
|
}
|
|
|
|
/// This page's slot in the shell. See [BottomPage.goToTab].
|
|
static const int _activityTabIndex = 2;
|
|
|
|
/// Today's finished stops, from the same two endpoints Bookings reads.
|
|
///
|
|
/// Both are asked for, not just the "picked" one: v3 carries the completed
|
|
/// records and v1 carries the live statuses, and when they disagree about a
|
|
/// stop the live one is right — the same precedence Bookings applies, for the
|
|
/// same reason.
|
|
Future<void> _load() async {
|
|
if (_fetching) return;
|
|
_fetching = true;
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final userId = prefs.getInt('userId') ?? prefs.getInt('userid') ?? 0;
|
|
|
|
final results = await Future.wait([
|
|
_safe(() => _provider.getPickupQueuesPicked()),
|
|
_safe(() => _provider.getCurrentPickups()),
|
|
]);
|
|
|
|
final Map<String, Map<String, dynamic>> merged = {};
|
|
for (final row in results[0].whereType<Map<String, dynamic>>()) {
|
|
merged[_keyOf(row)] = row;
|
|
}
|
|
for (final row in results[1].whereType<Map<String, dynamic>>()) {
|
|
final key = _keyOf(row);
|
|
merged[key] = {...?merged[key], ...row};
|
|
}
|
|
|
|
// ── The local record wins ──
|
|
//
|
|
// Finishing a stop is optimistic: the rider is moved on whether or not
|
|
// the status call landed, and the queue endpoints will not report the
|
|
// stop as finished for a poll or two — on demo data, never. Reading only
|
|
// the API meant a rider could complete a booking and find this tab empty,
|
|
// which is precisely what it exists to disprove.
|
|
//
|
|
// So the completed store is merged in last and overwrites: it is the
|
|
// newest thing the app knows, it carries the completion time the API does
|
|
// not, and it is what the Bookings tab acted on when it dropped the stop.
|
|
for (final row in await getCompletedBookings()) {
|
|
final key = _keyOf(row);
|
|
merged[key] = {...?merged[key], ...row};
|
|
}
|
|
|
|
// Skips last of all, and for the same reason: the store is the newest
|
|
// thing the app knows about one, it carries the reason and the time the
|
|
// API has no field for, and on demo data it is the *only* record. A stop
|
|
// resumed since is not in it, so it cannot re-park something the rider
|
|
// has taken back.
|
|
final skippedStore = await getSkippedBookings();
|
|
for (final row in skippedStore) {
|
|
final key = _keyOf(row);
|
|
merged[key] = {...?merged[key], ...row};
|
|
}
|
|
final skippedIds = skippedStore
|
|
.map((r) => (r['orderid'] ?? '').toString())
|
|
.toSet();
|
|
// Resuming writes the stop into the accepted store, so that is the record
|
|
// that says "taken back" while the queue endpoint still reports 'skipped'.
|
|
final acceptedIds = await getAcceptedOrderIds();
|
|
|
|
final rows = merged.values.toList();
|
|
// Newest first: the stop he just finished is the one he is most likely
|
|
// checking, and it should not be at the bottom of the day.
|
|
final done = rows.where((o) => stopStatusOf(o).isFinishedPickup).toList()
|
|
..sort((a, b) => _happenedAt(b).compareTo(_happenedAt(a)));
|
|
final skipped = rows.where((o) {
|
|
if (!stopStatusOf(o).isSkipped) return false;
|
|
final id = (o['orderid'] ?? '').toString();
|
|
// In the store → parked, full stop. Otherwise trust the backend
|
|
// unless the rider has since taken it back: a stop the queue still
|
|
// calls 'skipped' but which sits in the accepted store has been
|
|
// resumed, and offering Resume on it again would be nonsense.
|
|
return skippedIds.contains(id) || !acceptedIds.contains(id);
|
|
}).toList()..sort((a, b) => _happenedAt(b).compareTo(_happenedAt(a)));
|
|
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_done = done;
|
|
_skipped = skipped;
|
|
_failed = false;
|
|
_firstLoadDone = true;
|
|
});
|
|
} catch (e) {
|
|
debugPrint('[ACTIVITY] load failed: $e');
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_failed = true;
|
|
_firstLoadDone = true;
|
|
});
|
|
} finally {
|
|
_fetching = false;
|
|
}
|
|
}
|
|
|
|
Future<List<dynamic>> _safe(Future<List<dynamic>> Function() call) async {
|
|
try {
|
|
return await call();
|
|
} catch (e) {
|
|
debugPrint('[ACTIVITY] endpoint failed: $e');
|
|
return <dynamic>[];
|
|
}
|
|
}
|
|
|
|
String _keyOf(Map<String, dynamic> o) {
|
|
final id = (o['orderid'] ?? '').toString();
|
|
return id.isNotEmpty ? id : (o['pickupid'] ?? o.hashCode).toString();
|
|
}
|
|
|
|
/// Best available "when did this happen" time, epoch-comparable. Falls back
|
|
/// to zero so a record with no timestamp sinks rather than jumping to the top.
|
|
DateTime _happenedAt(Map<String, dynamic> o) {
|
|
for (final key in const [
|
|
// The app's own stamps first: they are written at the moment the rider
|
|
// acts, so they are both the most accurate and the only ones present on
|
|
// a stop the backend has not caught up with yet.
|
|
'completedat',
|
|
'skippedat',
|
|
'pickedtime',
|
|
'picked_time',
|
|
'deliverytime',
|
|
'updatedon',
|
|
'modifiedon',
|
|
'expected_pickup_time',
|
|
]) {
|
|
final raw = (o[key] ?? '').toString().trim();
|
|
if (raw.isEmpty) continue;
|
|
final parsed = DateTime.tryParse(raw);
|
|
if (parsed != null) return parsed;
|
|
}
|
|
return DateTime.fromMillisecondsSinceEpoch(0);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
backgroundColor: ColorConstants.pureSurface,
|
|
appBar: const MilerAppBar(title: 'Activity'),
|
|
body: RefreshIndicator(
|
|
onRefresh: _load,
|
|
color: ColorConstants.primary,
|
|
// ── The trip bar is furniture, not content ──
|
|
//
|
|
// It used to live inside the populated branch, so a rider who had
|
|
// finished nothing yet saw a bare empty state and asked where his
|
|
// Trip 1 / 2 / 3 had gone. The bar answers "which run am I looking at",
|
|
// which is a question worth answering on an empty morning too — Home
|
|
// draws all three slots before the hub has assigned any of them, for
|
|
// exactly the same reason. So it sits above the swap, and only what is
|
|
// *under* it changes.
|
|
child: Column(
|
|
children: [
|
|
if (_firstLoadDone)
|
|
TripTabs(
|
|
trips: _trips,
|
|
selectedIndex: _selectedTripIndex,
|
|
onSelect: (i) => setState(() => _selectedTripIndex = i),
|
|
),
|
|
Expanded(
|
|
child: SmoothSwap(
|
|
child: !_firstLoadDone
|
|
? const SkeletonList(key: ValueKey('activity-loading'))
|
|
: _failed && _all.isEmpty
|
|
? ErrorRetry(
|
|
key: const ValueKey('activity-error'),
|
|
onRetry: _load,
|
|
)
|
|
: _visible.isEmpty
|
|
? _empty()
|
|
: _list(),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Nothing finished on the *selected* trip, which is a different sentence
|
|
/// depending on whether the day is empty or just this tab is.
|
|
///
|
|
/// The tinted-halo icon the shared [MilerEmptyState] draws is fine for an
|
|
/// error or a filter with no matches; on the first screen of a tab it is a
|
|
/// grey glyph on a grey page. The animation says the same thing with some
|
|
/// warmth, and Bookings already greets an empty queue this way — a rider
|
|
/// switching tabs should meet the same kind of screen, not two conventions.
|
|
Widget _empty() {
|
|
final dayEmpty = _all.isEmpty;
|
|
return ListView(
|
|
key: const ValueKey('activity-empty'),
|
|
// A scrollable, so the pull-to-refresh still works on an empty day.
|
|
physics: const AlwaysScrollableScrollPhysics(),
|
|
children: [
|
|
SizedBox(height: 24.h),
|
|
// One empty-state component app-wide — see [MilerEmptyState]. The copy
|
|
// stays screen-specific, because "nothing yet today" and "you have not
|
|
// worked this trip" are different sentences; only the chrome unifies.
|
|
MilerEmptyState(
|
|
icon: Icons.history_rounded,
|
|
illustration: Lottie.asset(
|
|
// "Nothing yet" on the whole day vs. a trip he simply has not
|
|
// worked: the parcel-in-motion loop reads as waiting, the tick as a
|
|
// run that is genuinely clear.
|
|
dayEmpty
|
|
? 'assets/lotties/no_bookings.json'
|
|
: 'assets/lotties/all_caught_up.json',
|
|
fit: BoxFit.contain,
|
|
repeat: true,
|
|
// A missing or corrupt animation must not take the tab down with
|
|
// it — an empty state is the one screen that has to render.
|
|
errorBuilder: (_, __, ___) => Icon(
|
|
Icons.history_rounded,
|
|
size: 64.sp,
|
|
color: ColorConstants.borderStrong,
|
|
),
|
|
),
|
|
title: dayEmpty
|
|
? 'Nothing finished yet'
|
|
: 'Nothing finished on this trip',
|
|
message: dayEmpty
|
|
? 'Stops you complete today land here, newest first — with '
|
|
'whether you made the ETA and stayed on route.'
|
|
: 'Stops you complete on it will be listed here.',
|
|
accent: ColorConstants.primary,
|
|
actionLabel: dayEmpty ? 'Go to Bookings' : null,
|
|
onAction: dayEmpty ? () => BottomPage.goToTab(1) : null,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// The trip, in the order the rider needs it: what still owes him a return
|
|
/// visit, then what is settled.
|
|
///
|
|
/// ── One list, not a stack of cards ──
|
|
///
|
|
/// Every finished stop used to be a filled, rounded card with 12pt of air
|
|
/// under it, and inside each one a *second*, white panel holding the figures.
|
|
/// A day's work therefore came down the screen as a column of grey slabs with
|
|
/// white slabs punched into them — the card-inside-card shape, repeated once
|
|
/// per stop.
|
|
///
|
|
/// Nothing about a finished stop needs a container. They are all the same kind
|
|
/// of thing, arriving in one order, read by scanning down: that is a list, and
|
|
/// a list is separated by rules. The rows are on the page now, with a hairline
|
|
/// between them, which also lets the figure columns line up with the ones in
|
|
/// the row above — the comparison the strip was built for and never quite
|
|
/// delivered while every row sat in its own box.
|
|
Widget _list() {
|
|
final (skipped, done) = _visibleSplit;
|
|
final cash = done.fold<double>(0, (s, o) => s + stopCollectionAmount(o));
|
|
|
|
return ListView(
|
|
key: const ValueKey('activity-list'),
|
|
physics: const AlwaysScrollableScrollPhysics(
|
|
parent: BouncingScrollPhysics(),
|
|
),
|
|
padding: EdgeInsets.fromLTRB(
|
|
20.w,
|
|
4.h,
|
|
20.w,
|
|
BottomPage.bottomInset(context) + 12.h,
|
|
),
|
|
children: [
|
|
_daySummary(cash, done.length, ComplianceSummary.from(done)),
|
|
if (skipped.isNotEmpty) ...[
|
|
_groupHeader(
|
|
icon: Icons.replay_rounded,
|
|
label:
|
|
'Go back to ${skipped.length == 1 ? 'this stop' : 'these stops'}',
|
|
accent: ColorConstants.warning,
|
|
count: skipped.length,
|
|
),
|
|
for (final stop in skipped) _skippedRow(stop),
|
|
],
|
|
if (done.isNotEmpty) ...[
|
|
if (skipped.isNotEmpty)
|
|
_groupHeader(
|
|
icon: Icons.check_circle_outline_rounded,
|
|
label: 'Finished',
|
|
accent: ColorConstants.acceptGreen,
|
|
count: done.length,
|
|
),
|
|
// The rule goes *between* rows, so the list has no trailing edge
|
|
// hanging under its last stop.
|
|
for (final (i, stop) in done.indexed) ...[
|
|
if (i > 0)
|
|
Container(height: 1, color: ColorConstants.borderSubtle),
|
|
_row(stop),
|
|
],
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
/// A quiet rule-and-label between the two kinds of row. Only drawn when both
|
|
/// kinds are present — a single group needs no heading to tell it apart from
|
|
/// the other one.
|
|
Widget _groupHeader({
|
|
required IconData icon,
|
|
required String label,
|
|
required Color accent,
|
|
required int count,
|
|
}) {
|
|
return Padding(
|
|
padding: EdgeInsets.only(top: 4.h, bottom: 10.h),
|
|
child: Row(
|
|
children: [
|
|
Icon(icon, size: 15.sp, color: accent),
|
|
SizedBox(width: 7.w),
|
|
Text(
|
|
label.toUpperCase(),
|
|
style: TextStyle(
|
|
fontSize: 11.sp,
|
|
fontWeight: FontWeight.w800,
|
|
letterSpacing: 0.6,
|
|
color: accent,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
SizedBox(width: 8.w),
|
|
Expanded(
|
|
child: Divider(height: 1, color: ColorConstants.dividerColor),
|
|
),
|
|
SizedBox(width: 8.w),
|
|
Text(
|
|
'$count',
|
|
style: TextStyle(
|
|
fontSize: 11.sp,
|
|
fontWeight: FontWeight.w800,
|
|
color: ColorConstants.secondaryText,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// One parked stop: why it was put down, when, and the single way back.
|
|
///
|
|
/// Amber rather than red, and worded as a return visit rather than a failure.
|
|
/// A skip is a legitimate call at a closed shutter — the rider is expected to
|
|
/// make it and expected to come back — and colouring it like an error would
|
|
/// teach him to wait at doors instead, which costs the whole route.
|
|
Widget _skippedRow(Map<String, dynamic> stop) {
|
|
final accent = ColorConstants.warning;
|
|
final kind = stopKindOf(stop);
|
|
final id = (stop['orderid'] ?? '').toString();
|
|
final busy = _busyIds.contains(id);
|
|
final name = (stop['pickupcustomer'] ?? stop['tenantname'] ?? 'Stop')
|
|
.toString();
|
|
final address = (stop['pickupaddress'] ?? stop['PickupAddress'] ?? '')
|
|
.toString()
|
|
.trim();
|
|
final reason = (stop['skipreason'] ?? stop['notes'] ?? '')
|
|
.toString()
|
|
.trim();
|
|
final at = _happenedAt(stop);
|
|
final hasTime = at.millisecondsSinceEpoch > 0;
|
|
|
|
return Container(
|
|
margin: EdgeInsets.only(bottom: 10.h),
|
|
decoration: BoxDecoration(
|
|
// Amber-tinted rather than outlined: the wash is what separates a stop
|
|
// still owed a visit from the finished ones under it.
|
|
color: accent.withValues(alpha: 0.08),
|
|
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Padding(
|
|
padding: EdgeInsets.fromLTRB(12.w, 12.h, 12.w, 0),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Container(
|
|
width: 34.w,
|
|
height: 34.w,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: accent.withValues(alpha: 0.12),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: Icon(kind.icon, size: 17.sp, color: accent),
|
|
),
|
|
SizedBox(width: 12.w),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
name,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 14.5.sp,
|
|
fontWeight: FontWeight.w800,
|
|
letterSpacing: -0.2,
|
|
color: ColorConstants.slateText,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
),
|
|
if (hasTime) ...[
|
|
SizedBox(width: 8.w),
|
|
Text(
|
|
'Skipped ${_clock(at)}',
|
|
style: TextStyle(
|
|
fontSize: 11.5.sp,
|
|
fontWeight: FontWeight.w700,
|
|
color: accent,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
if (address.isNotEmpty) ...[
|
|
SizedBox(height: 3.h),
|
|
Text(
|
|
address,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 12.5.sp,
|
|
fontWeight: FontWeight.w600,
|
|
height: 1.35,
|
|
color: ColorConstants.secondaryText,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
],
|
|
if (reason.isNotEmpty) ...[
|
|
SizedBox(height: 7.h),
|
|
Container(
|
|
padding: EdgeInsets.symmetric(
|
|
horizontal: 8.w,
|
|
vertical: 4.h,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: accent.withValues(alpha: 0.10),
|
|
borderRadius: BorderRadius.circular(
|
|
DesignConstants.radiusFull,
|
|
),
|
|
),
|
|
child: Text(
|
|
reason,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 10.5.sp,
|
|
fontWeight: FontWeight.w800,
|
|
letterSpacing: -0.1,
|
|
color: accent,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Padding(
|
|
padding: EdgeInsets.fromLTRB(12.w, 10.h, 12.w, 12.h),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
'Still to be collected before the end of your shift',
|
|
style: TextStyle(
|
|
fontSize: 11.sp,
|
|
height: 1.3,
|
|
fontWeight: FontWeight.w600,
|
|
color: ColorConstants.secondaryText,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
),
|
|
SizedBox(width: 10.w),
|
|
MilerButton(
|
|
label: 'Resume',
|
|
icon: Icons.play_arrow_rounded,
|
|
variant: MilerButtonVariant.outlined,
|
|
color: ColorConstants.primary,
|
|
height: ButtonSizes.compact,
|
|
expand: false,
|
|
loading: busy,
|
|
onPressed: busy ? null : () => _resume(stop),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Puts a parked stop back into the working queue and takes the rider there.
|
|
///
|
|
/// The wire call, the two stores and the poller's skip cache are all handled
|
|
/// by [MyPickups.resumeSkippedStop] — this is only the screen's half: keep the
|
|
/// button from firing twice, drop the row, and follow the stop to Bookings,
|
|
/// because a rider who taps Resume is going there next either way.
|
|
Future<void> _resume(Map<String, dynamic> stop) async {
|
|
final id = (stop['orderid'] ?? '').toString();
|
|
if (id.isEmpty || _busyIds.contains(id)) return;
|
|
|
|
setState(() => _busyIds.add(id));
|
|
try {
|
|
final ok = await MyPickups.resumeSkippedStop(stop);
|
|
if (!mounted) return;
|
|
if (!ok) {
|
|
AppFeedback.error(context, 'Could not resume this stop');
|
|
return;
|
|
}
|
|
setState(() {
|
|
_skipped = _skipped
|
|
.where((s) => (s['orderid'] ?? '').toString() != id)
|
|
.toList();
|
|
});
|
|
AppFeedback.success(context, 'Back on your Bookings tab');
|
|
BottomPage.goToTab(1);
|
|
} finally {
|
|
if (mounted) setState(() => _busyIds.remove(id));
|
|
}
|
|
}
|
|
|
|
/// The trip in two lines: what he finished and what he is carrying, then how
|
|
/// the run scored.
|
|
///
|
|
/// Cash is the figure with consequences — Miler is the carrier, so that money
|
|
/// is his personal liability until he reconciles it at the hub. The
|
|
/// compliance line under it is the same pair the reward is paid on, totalled,
|
|
/// so he does not have to add up the chips himself.
|
|
Widget _daySummary(double cash, int count, ComplianceSummary c) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
SizedBox(height: 8.h),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
// "…completed on this trip" ellipsised to "…on this t…" the
|
|
// moment there was cash beside it. The trip tab directly above
|
|
// already says which run this is, so the words were paying for
|
|
// width to repeat it.
|
|
'$count ${count == 1 ? 'stop' : 'stops'} completed',
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 15.sp,
|
|
fontWeight: FontWeight.w800,
|
|
letterSpacing: -0.3,
|
|
color: ColorConstants.slateText,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
),
|
|
if (cash > 0) ...[
|
|
SizedBox(width: 10.w),
|
|
Text(
|
|
'₹${cash % 1 == 0 ? cash.toStringAsFixed(0) : cash.toStringAsFixed(1)} collected',
|
|
style: TextStyle(
|
|
fontSize: 13.5.sp,
|
|
fontWeight: FontWeight.w800,
|
|
letterSpacing: -0.2,
|
|
color: ColorConstants.moneyGreen,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
if (c.measured > 0) ...[
|
|
SizedBox(height: 7.h),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: _tally(
|
|
icon: Icons.schedule_rounded,
|
|
label: 'on time',
|
|
hit: c.onTime,
|
|
total: c.measured,
|
|
),
|
|
),
|
|
Expanded(
|
|
child: _tally(
|
|
icon: Icons.alt_route_rounded,
|
|
label: 'on route',
|
|
hit: c.onRoute,
|
|
total: c.measured,
|
|
),
|
|
),
|
|
Expanded(
|
|
child: _tally(
|
|
icon: Icons.workspace_premium_rounded,
|
|
label: c.points == 1 ? 'point' : 'points',
|
|
hit: c.points,
|
|
total: null,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
SizedBox(height: 18.h),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// `4/5 on time`. [total] null prints the figure alone — points are a count,
|
|
/// not a fraction of anything.
|
|
Widget _tally({
|
|
required IconData icon,
|
|
required String label,
|
|
required int hit,
|
|
required int? total,
|
|
}) {
|
|
final bool full = total == null ? hit > 0 : hit == total;
|
|
final Color accent = full
|
|
? ColorConstants.acceptGreen
|
|
: ColorConstants.secondaryText;
|
|
|
|
return Row(
|
|
children: [
|
|
Icon(icon, size: 13.sp, color: accent),
|
|
SizedBox(width: 5.w),
|
|
Flexible(
|
|
child: Text(
|
|
total == null ? '$hit $label' : '$hit/$total $label',
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 11.sp,
|
|
fontWeight: FontWeight.w800,
|
|
letterSpacing: -0.1,
|
|
color: accent,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// One finished stop: a summary line that opens into the whole record.
|
|
///
|
|
/// ── Why the detail is behind a tap ──
|
|
///
|
|
/// Everything a rider asks of a finished stop is a *specific* question asked
|
|
/// rarely — "what time did I get there?", "how far was that one?", "did I
|
|
/// take the cash?" — usually because someone in the office is asking him. The
|
|
/// answers were being measured and thrown away, or buried in a payload nobody
|
|
/// rendered. But printing all of them on every row would turn a day's work
|
|
/// into a wall of figures and make the list useless for the thing it is for,
|
|
/// which is scanning what happened.
|
|
///
|
|
/// So the row stays a row, and the record is one tap under it. Uber, Rapido
|
|
/// and every banking app the rider uses put trip detail exactly here, for
|
|
/// exactly this reason.
|
|
Widget _row(Map<String, dynamic> stop) {
|
|
final id = (stop['orderid'] ?? '').toString();
|
|
final open = _openIds.contains(id);
|
|
final kind = stopKindOf(stop);
|
|
final status = stopStatusOf(stop);
|
|
final cancelled = status.isCancelled;
|
|
final name = (stop['pickupcustomer'] ?? stop['tenantname'] ?? 'Stop')
|
|
.toString();
|
|
final address = (stop['pickupaddress'] ?? stop['PickupAddress'] ?? '')
|
|
.toString()
|
|
.trim();
|
|
final at = _happenedAt(stop);
|
|
final hasTime = at.millisecondsSinceEpoch > 0;
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
InkWell(
|
|
onTap: id.isEmpty
|
|
? null
|
|
: () => setState(() {
|
|
open ? _openIds.remove(id) : _openIds.add(id);
|
|
}),
|
|
child: Padding(
|
|
// ── No avatar, no card, no inset ──
|
|
//
|
|
// The row opened with a 40pt tinted disc holding a tick, on every
|
|
// row, on a list where every row is a finished stop. A mark that is
|
|
// identical on all of them separates nothing — it just moved the
|
|
// name 53pt right and stole the width the address needed. What the
|
|
// disc was actually encoding, stop type, is a word now.
|
|
padding: EdgeInsets.symmetric(vertical: 15.h),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
name,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 16.sp,
|
|
fontWeight: FontWeight.w800,
|
|
letterSpacing: -0.3,
|
|
color: ColorConstants.slateText,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
),
|
|
if (hasTime) ...[
|
|
SizedBox(width: 8.w),
|
|
Text(
|
|
_clock(at),
|
|
style: TextStyle(
|
|
fontSize: 12.5.sp,
|
|
fontWeight: FontWeight.w700,
|
|
color: ColorConstants.secondaryText,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
if (address.isNotEmpty) ...[
|
|
SizedBox(height: 4.h),
|
|
Text(
|
|
address,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 13.sp,
|
|
fontWeight: FontWeight.w500,
|
|
height: 1.35,
|
|
color: ColorConstants.secondaryText,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
],
|
|
SizedBox(height: 8.h),
|
|
// ── Two filled chips became one dotted word ──
|
|
//
|
|
// The type was a tinted tag and the order id another one
|
|
// beside it, so every row carried two small coloured
|
|
// rectangles under the address — the loudest things on a
|
|
// card whose subject is the figures below them. Same
|
|
// information, ranked: a 6pt dot for the type, the
|
|
// reference in the grey it deserves, still one tap to
|
|
// copy.
|
|
Row(
|
|
children: [
|
|
Container(
|
|
width: 6.w,
|
|
height: 6.w,
|
|
margin: EdgeInsets.only(right: 7.w),
|
|
decoration: BoxDecoration(
|
|
color: cancelled
|
|
? ColorConstants.secondaryText
|
|
: kind.accent,
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
Flexible(
|
|
child: Text(
|
|
cancelled ? 'Cancelled' : kind.titleLabel,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 12.sp,
|
|
fontWeight: FontWeight.w700,
|
|
color: cancelled
|
|
? ColorConstants.secondaryText
|
|
: kind.accent,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
),
|
|
if (id.isNotEmpty) ...[
|
|
SizedBox(width: 10.w),
|
|
_orderIdChip(id),
|
|
],
|
|
],
|
|
),
|
|
|
|
// ── Figures you can hold two stops up against ──
|
|
//
|
|
// Three cells, always in the same order, always in the
|
|
// same place, so the eye can run straight down a list and
|
|
// compare like with like. Prose does not line up; columns
|
|
// do. The verdicts colour the figures they belong to
|
|
// rather than sitting apart from them — being late is a
|
|
// fact about the time, not a separate badge.
|
|
//
|
|
// They used to line up only *within* a card, because each
|
|
// card was inset by its own padding on a page of cards.
|
|
// Now that the rows share the page's margin, column two of
|
|
// one stop sits directly above column two of the next.
|
|
if (!cancelled) ...[
|
|
SizedBox(height: 13.h),
|
|
_statStrip(stop),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
SizedBox(width: 10.w),
|
|
// The one affordance that says the row does something. It
|
|
// turns rather than swaps, so the row reads as opening rather
|
|
// than as having been replaced.
|
|
AnimatedRotation(
|
|
turns: open ? 0.5 : 0,
|
|
duration: const Duration(milliseconds: 200),
|
|
child: Icon(
|
|
Icons.keyboard_arrow_down_rounded,
|
|
size: 20.sp,
|
|
color: ColorConstants.secondaryText,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
AnimatedCrossFade(
|
|
firstChild: const SizedBox(width: double.infinity),
|
|
secondChild: _stopDetail(stop),
|
|
crossFadeState: open
|
|
? CrossFadeState.showSecond
|
|
: CrossFadeState.showFirst,
|
|
duration: const Duration(milliseconds: 220),
|
|
sizeCurve: Curves.easeOutCubic,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
// ── The record behind a finished stop ────────────────────────────────────
|
|
//
|
|
// Four questions, in the order they get asked of a rider: when did this
|
|
// happen, how far did I ride, what was it, and did money change hands.
|
|
//
|
|
// ── "Not recorded" is a real answer here ──
|
|
//
|
|
// Every figure below comes from somewhere that can legitimately be empty — a
|
|
// stop closed in bulk from Home never opened the map, so it has no departure
|
|
// time; a hub that assigns no ETA leaves nothing to judge. Saying so is the
|
|
// whole point: a record that invents a plausible number is worse than one
|
|
// with a gap in it, because the gap can be investigated and the invention
|
|
// cannot be caught. Same rule the compliance chips already follow.
|
|
Widget _stopDetail(Map<String, dynamic> stop) {
|
|
final kind = stopKindOf(stop);
|
|
final proof = stop['proof'] is Map
|
|
? Map<String, dynamic>.from(stop['proof'] as Map)
|
|
: const <String, dynamic>{};
|
|
final c = StopCompliance.of(stop);
|
|
|
|
final started = _stamp(stop, const ['startedat']);
|
|
final arrived = _stamp(stop, const ['arrivedat', 'arrivedtime']);
|
|
final done = _stamp(stop, const [
|
|
'completedat',
|
|
'pickedtime',
|
|
'picked_time',
|
|
]);
|
|
|
|
final rode = (started != null && arrived != null)
|
|
? arrived.difference(started)
|
|
: null;
|
|
final atDoor = (arrived != null && done != null)
|
|
? done.difference(arrived)
|
|
: null;
|
|
|
|
final cash = stopCollectionAmount(stop);
|
|
final collected = proof['amountcollected'] is num
|
|
? (proof['amountcollected'] as num).toDouble()
|
|
: null;
|
|
final method = (proof['paymentmethod'] ?? '').toString().trim();
|
|
final weight = (proof['weight'] ?? stop['weight'] ?? '').toString().trim();
|
|
final parcels = proof['collected'] ?? proof['handedover'];
|
|
final address = (stop['pickupaddress'] ?? stop['PickupAddress'] ?? '')
|
|
.toString()
|
|
.trim();
|
|
|
|
return Padding(
|
|
padding: EdgeInsets.only(bottom: 16.h),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_detailGroup('Timing', [
|
|
_detailRow('Set off', started == null ? null : _clock(started)),
|
|
_detailRow(
|
|
'Arrived',
|
|
arrived == null ? null : _clock(arrived),
|
|
note: rode == null ? null : '${_short(rode)} on the road',
|
|
),
|
|
_detailRow(
|
|
kind.isDelivery ? 'Delivered' : 'Collected',
|
|
done == null ? null : _clock(done),
|
|
note: atDoor == null ? null : '${_short(atDoor)} at the stop',
|
|
),
|
|
_detailRow('ETA given', _plannedEta(stop)),
|
|
if (c.onTime != null)
|
|
_detailRow(
|
|
'Against the ETA',
|
|
c.onTime!
|
|
? 'On time'
|
|
: 'Late by ${_short(c.lateBy ?? Duration.zero)}',
|
|
good: c.onTime,
|
|
),
|
|
]),
|
|
_detailGroup('Distance', [
|
|
_detailRow(
|
|
'Ridden to this stop',
|
|
c.actualKm == null ? null : '${_km(c.actualKm!)} km',
|
|
),
|
|
_detailRow(
|
|
'Route allowed for',
|
|
c.plannedKm == null ? null : '${_km(c.plannedKm!)} km',
|
|
),
|
|
if (c.extraKm != null)
|
|
_detailRow('Extra ridden', '+${_km(c.extraKm!)} km', good: false),
|
|
]),
|
|
_detailGroup('Order', [
|
|
_detailRow('Order ID', (stop['orderid'] ?? '').toString()),
|
|
_detailRow('Type', kind.badgeLabel),
|
|
_detailRow(
|
|
kind.isDelivery ? 'Parcels handed over' : 'Parcels collected',
|
|
parcels == null ? null : '$parcels',
|
|
),
|
|
_detailRow('Weight', weight.isEmpty ? null : '$weight kg'),
|
|
if ((stop['tenantname'] ?? '').toString().trim().isNotEmpty)
|
|
_detailRow('Shipper', stop['tenantname'].toString()),
|
|
_detailRow('Address', address.isEmpty ? null : address, wrap: true),
|
|
]),
|
|
_detailGroup('Payment', [
|
|
if (cash <= 0 && collected == null)
|
|
_detailRow('Collected', 'Nothing to collect — prepaid')
|
|
else ...[
|
|
_detailRow(
|
|
'Due at this stop',
|
|
cash > 0 ? '₹${_money(cash)}' : null,
|
|
),
|
|
_detailRow(
|
|
'Collected',
|
|
collected == null ? null : '₹${_money(collected)}',
|
|
good: collected == null ? null : collected >= cash,
|
|
),
|
|
_detailRow(
|
|
'Method',
|
|
method.isEmpty
|
|
? null
|
|
: (method.toLowerCase().contains('upi')
|
|
? 'UPI / QR'
|
|
: 'Cash'),
|
|
),
|
|
],
|
|
]),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// A titled block of rows. Drops itself when every row is unknown — a section
|
|
/// made entirely of "Not recorded" is noise.
|
|
Widget _detailGroup(String title, List<Widget?> rows) {
|
|
final live = rows.whereType<Widget>().toList();
|
|
if (live.isEmpty) return const SizedBox.shrink();
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
SizedBox(height: 12.h),
|
|
Text(
|
|
title.toUpperCase(),
|
|
style: TextStyle(
|
|
fontSize: 10.sp,
|
|
fontWeight: FontWeight.w800,
|
|
letterSpacing: 0.8,
|
|
color: ColorConstants.secondaryText,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
SizedBox(height: 6.h),
|
|
...live,
|
|
],
|
|
);
|
|
}
|
|
|
|
/// `Arrived 09:42`, with an optional grey note under the value and
|
|
/// an optional green/amber tint when the figure is a verdict.
|
|
///
|
|
/// Returns null for an unknown value so [_detailGroup] can drop a whole
|
|
/// section that turned out to have nothing in it.
|
|
Widget? _detailRow(
|
|
String label,
|
|
String? value, {
|
|
String? note,
|
|
bool? good,
|
|
bool wrap = false,
|
|
}) {
|
|
if (value == null || value.trim().isEmpty) return null;
|
|
|
|
final Color valueColor = good == null
|
|
? ColorConstants.slateText
|
|
: good
|
|
? ColorConstants.acceptGreen
|
|
: ColorConstants.warning;
|
|
|
|
return Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 5.h),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
SizedBox(
|
|
width: 116.w,
|
|
child: Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: 12.sp,
|
|
fontWeight: FontWeight.w600,
|
|
color: ColorConstants.secondaryText,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
),
|
|
SizedBox(width: 8.w),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
Text(
|
|
value,
|
|
textAlign: TextAlign.right,
|
|
maxLines: wrap ? 3 : 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 12.5.sp,
|
|
height: 1.35,
|
|
fontWeight: FontWeight.w800,
|
|
letterSpacing: -0.1,
|
|
color: valueColor,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
if (note != null)
|
|
Text(
|
|
note,
|
|
textAlign: TextAlign.right,
|
|
style: TextStyle(
|
|
fontSize: 11.sp,
|
|
fontWeight: FontWeight.w600,
|
|
color: ColorConstants.secondaryText,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// The ETA the hub gave for this stop — minutes allowed, or the clock time it
|
|
/// was due by, whichever the booking carries.
|
|
String? _plannedEta(Map<String, dynamic> stop) {
|
|
final minutes = int.tryParse((stop['eta'] ?? '').toString().trim());
|
|
if (minutes != null && minutes > 0) return '$minutes min allowed';
|
|
|
|
final due = _stamp(stop, const [
|
|
'expected_pickup_time',
|
|
'expectedpickuptime',
|
|
'slotendtime',
|
|
]);
|
|
return due == null ? null : 'By ${_clock(due)}';
|
|
}
|
|
|
|
/// First parseable timestamp among [keys].
|
|
DateTime? _stamp(Map<String, dynamic> stop, List<String> keys) {
|
|
for (final k in keys) {
|
|
final raw = (stop[k] ?? '').toString().trim();
|
|
if (raw.isEmpty) continue;
|
|
final parsed = DateTime.tryParse(raw);
|
|
if (parsed != null) return parsed;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
String _km(double v) => v >= 10 ? v.toStringAsFixed(0) : v.toStringAsFixed(1);
|
|
|
|
String _money(double v) =>
|
|
v % 1 == 0 ? v.toStringAsFixed(0) : v.toStringAsFixed(1);
|
|
|
|
/// `14:05` → `2:05 PM`. The same shape as the clock the map sheet's ETA
|
|
/// prints, so a time read on the way to a stop and the time recorded against
|
|
/// it afterwards are written the same way.
|
|
String _clock(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'}';
|
|
}
|
|
|
|
/// A duration at a glance: `6m`, `1h 12m`. Seconds are never shown — nothing
|
|
/// on this screen is decided to the second, and they only make the figure
|
|
/// harder to compare against the one on the card below it.
|
|
String _short(Duration d) {
|
|
final total = d.inMinutes;
|
|
if (total < 1) return '<1m';
|
|
final hours = total ~/ 60;
|
|
final minutes = total % 60;
|
|
if (hours == 0) return '${minutes}m';
|
|
return minutes == 0 ? '${hours}h' : '${hours}h ${minutes}m';
|
|
}
|
|
|
|
/// The booking reference, and one tap to put it on the clipboard.
|
|
///
|
|
/// ── Why this needed to be a control at all ──
|
|
///
|
|
/// The order ID was a line of grey text inside the expanded detail. It is the
|
|
/// one field on this screen a rider ever has to reproduce somewhere else — in
|
|
/// a WhatsApp message to the hub, on a support call, in a complaint about a
|
|
/// stop that was marked wrong — and reproducing a twelve-character reference
|
|
/// by reading it off a phone and typing it into another app is exactly the
|
|
/// task people get wrong. Now it is on the card, in a monospaced-feeling
|
|
/// tracked-out face so the characters are individually legible, and one tap
|
|
/// copies it.
|
|
Widget _orderIdChip(String id) {
|
|
return Flexible(
|
|
child: Semantics(
|
|
button: true,
|
|
label: 'Copy order ID $id',
|
|
excludeSemantics: true,
|
|
child: InkWell(
|
|
onTap: () => _copyOrderId(id),
|
|
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
|
// Unfilled. A tinted rectangle made the reference compete with the
|
|
// stop type beside it and with the figures below, and it is neither
|
|
// a state nor a category — it is a string the rider occasionally has
|
|
// to read out. Grey text and a copy glyph say "reproducible" without
|
|
// claiming any of the row's rank; the tap target is unchanged.
|
|
child: Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 2.h),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Flexible(
|
|
child: Text(
|
|
'#$id',
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 11.5.sp,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: 0.4,
|
|
color: ColorConstants.secondaryText,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
),
|
|
SizedBox(width: 4.w),
|
|
Icon(
|
|
Icons.copy_rounded,
|
|
size: 11.5.sp,
|
|
color: ColorConstants.secondaryText,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _copyOrderId(String id) async {
|
|
await Clipboard.setData(ClipboardData(text: id));
|
|
if (!mounted) return;
|
|
HapticFeedback.selectionClick();
|
|
ScaffoldMessenger.of(context)
|
|
..hideCurrentSnackBar()
|
|
..showSnackBar(
|
|
SnackBar(
|
|
behavior: SnackBarBehavior.floating,
|
|
backgroundColor: ColorConstants.slateText,
|
|
duration: const Duration(seconds: 2),
|
|
// Clear of the floating nav bar, which is Positioned outside the
|
|
// Scaffold body and would otherwise sit on top of this.
|
|
margin: EdgeInsets.fromLTRB(
|
|
16.w,
|
|
0,
|
|
16.w,
|
|
BottomPage.bottomInset(context) + 8.h,
|
|
),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
|
),
|
|
content: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.check_circle_rounded,
|
|
size: 16.sp,
|
|
color: Colors.white,
|
|
),
|
|
SizedBox(width: 8.w),
|
|
Expanded(
|
|
child: Text(
|
|
'Order $id copied',
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 13.sp,
|
|
fontWeight: FontWeight.w700,
|
|
color: Colors.white,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// ── Three figures, in three fixed columns ──
|
|
///
|
|
/// This replaces a `_complianceRow` of loose chips. The chips said "On time"
|
|
/// and "Took the route" without the numbers behind them, so a rider could see
|
|
/// that a stop had gone wrong but not how badly, and nothing lined up between
|
|
/// one card and the next.
|
|
///
|
|
/// Each cell is a caption, a figure, and a note — and the figure carries the
|
|
/// verdict as its colour, because being late is a fact about the time rather
|
|
/// than a separate badge sitting near it. An unmeasured figure says so; see
|
|
/// the note on [_stopDetail] for why inventing one would be worse.
|
|
Widget _statStrip(Map<String, dynamic> stop) {
|
|
final c = StopCompliance.of(stop);
|
|
|
|
final started = _stamp(stop, const ['startedat']);
|
|
final done = _stamp(stop, const [
|
|
'completedat',
|
|
'pickedtime',
|
|
'picked_time',
|
|
]);
|
|
final took = (started != null && done != null)
|
|
? done.difference(started)
|
|
: null;
|
|
|
|
final cash = stopCollectionAmount(stop);
|
|
final proof = stop['proof'] is Map
|
|
? Map<String, dynamic>.from(stop['proof'] as Map)
|
|
: const <String, dynamic>{};
|
|
final collected = proof['amountcollected'] is num
|
|
? (proof['amountcollected'] as num).toDouble()
|
|
: null;
|
|
|
|
// ── No panel, no rules ──
|
|
//
|
|
// This was a white block with two vertical dividers, sitting inside the
|
|
// grey card that held the row: the card-inside-card the whole page was made
|
|
// of. Both were doing the job that alignment already does — three `Expanded`
|
|
// cells land in the same three places on every row whether or not anything
|
|
// is drawn around them, and on a ruled list they now line up with the row
|
|
// above as well as within themselves.
|
|
return IntrinsicHeight(
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
_statCell(
|
|
'TIME',
|
|
took == null ? '—' : _short(took),
|
|
note: c.onTime == null
|
|
? null
|
|
: c.onTime!
|
|
? 'On time'
|
|
: 'Late ${_short(c.lateBy ?? Duration.zero)}',
|
|
good: c.onTime,
|
|
),
|
|
_statCell(
|
|
'RIDDEN',
|
|
c.actualKm == null ? '—' : '${_km(c.actualKm!)} km',
|
|
note: c.plannedKm == null
|
|
? null
|
|
: c.extraKm == null
|
|
? 'On route'
|
|
: '+${_km(c.extraKm!)} over',
|
|
good: c.onRoute,
|
|
),
|
|
_statCell(
|
|
'COLLECTED',
|
|
cash <= 0 && collected == null
|
|
? 'Prepaid'
|
|
: '₹${_money(collected ?? cash)}',
|
|
note: collected == null || cash <= 0
|
|
? null
|
|
: collected >= cash
|
|
? 'Settled'
|
|
: 'Short ₹${_money(cash - collected)}',
|
|
good: collected == null || cash <= 0 ? null : collected >= cash,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _statCell(String caption, String value, {String? note, bool? good}) {
|
|
final Color valueColor = good == null
|
|
? ColorConstants.slateText
|
|
: good
|
|
? ColorConstants.acceptGreen
|
|
: ColorConstants.warning;
|
|
|
|
return Expanded(
|
|
child: Padding(
|
|
// Only trailing: column one starts flush with the name and the address
|
|
// above it, so the row has a single left edge rather than a card's
|
|
// worth of inset inside it.
|
|
padding: EdgeInsets.only(right: 10.w),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Text(
|
|
caption,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 9.5.sp,
|
|
fontWeight: FontWeight.w800,
|
|
letterSpacing: 0.7,
|
|
color: ColorConstants.secondaryText,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
SizedBox(height: 4.h),
|
|
Text(
|
|
value,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 17.sp,
|
|
height: 1.1,
|
|
fontWeight: FontWeight.w800,
|
|
letterSpacing: -0.3,
|
|
color: valueColor,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
if (note != null) ...[
|
|
SizedBox(height: 3.h),
|
|
Text(
|
|
note,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 10.5.sp,
|
|
fontWeight: FontWeight.w700,
|
|
color: good == null
|
|
? ColorConstants.secondaryText
|
|
: valueColor.withValues(alpha: 0.85),
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|