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 createState() => _ActivityPageState(); } class _ActivityPageState extends State { final PickupProvider _provider = PickupProvider(); List> _done = >[]; /// 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> _skipped = >[]; /// Order ids with a resume in flight, so one cannot be fired twice. final Set _busyIds = {}; /// 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 _openIds = {}; 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> 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 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> 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> skipped, List> 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 _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> merged = {}; for (final row in results[0].whereType>()) { merged[_keyOf(row)] = row; } for (final row in results[1].whereType>()) { 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> _safe(Future> Function() call) async { try { return await call(); } catch (e) { debugPrint('[ACTIVITY] endpoint failed: $e'); return []; } } String _keyOf(Map 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 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(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 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 _resume(Map 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 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 stop) { final kind = stopKindOf(stop); final proof = stop['proof'] is Map ? Map.from(stop['proof'] as Map) : const {}; 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 rows) { final live = rows.whereType().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 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 stop, List 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 _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 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.from(stop['proof'] as Map) : const {}; 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, ), ), ], ], ), ), ); } }