Files
doormile_milderapp/lib/views/Dashboard/activity/activity_page.dart
2026-08-11 13:16:33 +05:30

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