import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; import 'package:miler/views/helpers/widgets/app_widgets.dart'; import 'package:miler/views/helpers/constants/Font_constant.dart'; import 'package:miler/widget/Bottom_page.dart'; import 'package:miler/Models/stop_status.dart'; import 'package:miler/views/helpers/constants/design_constants.dart'; /// The live strip that sits at the foot of Home and Bookings while a pickup is /// under way — the rider's way back into the stop he is already on. /// /// ── One banner, not two ── /// /// Home and Bookings each had their own copy. They had drifted into different /// widgets: one a gradient card with a double drop shadow, an order-id badge and /// a status pill; the other a flat red box with a LIVE chip and a hard 40- /// character address truncation. Same job, same data, two designs — which is how /// the same screen came to look older on one tab than the other. /// /// ── It has to clear the nav bar ── /// /// Both copies pinned themselves to `bottom: 0`. The nav bar *floats*: it is a /// `Positioned` pill in the shell's `Stack`, not a `bottomNavigationBar`, so the /// Scaffold reserves nothing for it and `SafeArea` cannot see it. The banner /// therefore sat underneath it, with the bar covering the tap target of the one /// control telling the rider he has a job open. /// /// So the banner lifts itself clear by [BottomPage.bottomInset]. Both call sites /// just pin it to `bottom: 0` and let it place itself. class ActivePickupBanner extends StatefulWidget { /// The tallest this banner gets, excluding the device inset. /// /// Declared here because the surface that overlays a list is the only thing /// that knows how much of the list it covers. The Deliveries queue reserved /// a flat `80.h` for it — a number measured once, by eye, that every later /// change to this widget made wrong silently by hiding the last row of the /// run behind it. static double get maxHeight => ButtonSizes.primary + 46.h; final List> activePickups; final void Function(Map) onTap; const ActivePickupBanner({ super.key, required this.activePickups, required this.onTap, }); @override State createState() => _ActivePickupBannerState(); } class _ActivePickupBannerState extends State with SingleTickerProviderStateMixin { /// The live dot actually pulses. /// /// A static dot beside the word LIVE is a claim the interface does not back /// up; movement is what makes "there is a job running right now" read without /// being read. Initialised lazily on the declaration rather than in /// `initState` so hot reload cannot leave it unset — see the note in /// `Bottom_page.dart`. /// Created on first *use*, not on first touch of any kind. /// /// This was a `late final` initialised on the declaration, which meant /// `dispose()` calling `_pulse.dispose()` *constructed* the controller during /// teardown whenever it had never been used — and `build` returns early with /// no active pickups, so that is the normal path when a rider's last stop /// finishes and the banner unmounts. `createTicker` then looked up /// `TickerMode` on a deactivated element and threw. Two of the app's own /// tests caught it; it needs the nullable so dispose can ask "was there /// ever one?" instead of answering "there is now". AnimationController? _pulseController; AnimationController get _pulse => _pulseController ??= AnimationController( vsync: this, duration: const Duration(milliseconds: 1100), )..repeat(reverse: true); @override void dispose() { _pulseController?.dispose(); super.dispose(); } String _address(Map pickup) => (pickup['pickupaddress'] ?? pickup['PickupAddress'] ?? pickup['address'] ?? '') .toString() .trim(); /// Where the rider has got to on this stop — from the one place the app /// names its statuses, so the banner cannot say "At pickup" while the card /// behind it says something else about the same stop. See [StopStatusX.label]. String _status(Map pickup) => stopStatusOf(pickup).label; @override Widget build(BuildContext context) { if (widget.activePickups.isEmpty) return const SizedBox.shrink(); final pickup = widget.activePickups.first; final count = widget.activePickups.length; final address = _address(pickup); // Rides the floating nav bar: it is `Positioned` outside the Scaffold's // body, so `SafeArea` knows nothing about it and anything pinned to the // bottom lands underneath. See [BottomPage.bottomInset]. // // A fixed inset now. This used to follow a `navVisible` notifier up and // down, because the bar left the screen entirely on scroll. It compacts // instead of leaving, so there is no longer a state in which the room is // free — and chasing the 20pt difference re-laid this out every time the // rider changed scroll direction, for a gap he would never notice. return Padding( padding: EdgeInsets.only(bottom: BottomPage.bottomInset(context) + 10.h), child: Padding( // 16 — the same gutter the nav bar above it uses, so the two stack as // one column rather than as two floating objects of different widths. padding: EdgeInsets.symmetric(horizontal: 16.w), child: _Card( pulse: _pulse, count: count, address: address, status: _status(pickup), onTap: () => widget.onTap(pickup), ), ), ); } } class _Card extends StatelessWidget { final Animation pulse; final int count; final String address; final String status; final VoidCallback onTap; const _Card({ required this.pulse, required this.count, required this.address, required this.status, required this.onTap, }); @override Widget build(BuildContext context) { return Material( color: ColorConstants.primary, // 20 — between the stop cards' 16 and the nav bar's 32, which is where it // sits in the visual stack. borderRadius: BorderRadius.circular(DesignConstants.radiusXl), clipBehavior: Clip.antiAlias, // Tinted rather than black: a neutral shadow under a saturated red surface // reads as grime. The gradient and second shadow the Home copy carried are // gone — one flat fill and one shadow is the language the rest of the app // now speaks. shadowColor: ColorConstants.primary.withValues(alpha: 0.45), elevation: 10, child: InkWell( onTap: onTap, splashColor: Colors.white.withValues(alpha: 0.10), highlightColor: Colors.white.withValues(alpha: 0.06), child: Padding( padding: EdgeInsets.fromLTRB(12.w, 11.h, 12.w, 11.h), child: Row( children: [ Container( width: 42.w, height: 42.w, decoration: BoxDecoration( color: ColorConstants.pureSurface.withValues(alpha: 0.16), borderRadius: BorderRadius.circular(DesignConstants.radiusXl), ), child: Icon(LucideIcons.bike, color: Colors.white, size: 22.sp), ), SizedBox(width: 11.w), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Row( children: [ _LiveChip(pulse: pulse), SizedBox(width: 7.w), // Flexible, not bare: this row has a fixed-width chip on // one side and a title that grows with the pickup count // and the text scale on the other. Every unflexed row of // that shape in this app has overflowed eventually. Flexible( child: Text( count > 1 ? '$count Active Pickups' : 'Active Pickup', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( color: Colors.white, fontSize: 15.sp, fontWeight: FontWeight.w800, letterSpacing: -0.3, fontFamily: FontConstants.fontFamily, ), ), ), ], ), SizedBox(height: 3.h), Row( children: [ Icon( LucideIcons.mapPin, color: Colors.white.withValues(alpha: 0.72), size: 13.sp, ), SizedBox(width: 3.w), Expanded( child: Text( // Ellipsised by the layout, not chopped at 40 // characters — the old truncation cut mid-word and // then appended its own "…", which on a narrow phone // ellipsised a second time. address.isEmpty ? status : '$status · $address', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( color: Colors.white.withValues(alpha: 0.82), fontSize: 12.5.sp, fontWeight: FontWeight.w600, fontFamily: FontConstants.fontFamily, ), ), ), ], ), ], ), ), SizedBox(width: 8.w), Container( width: 30.w, height: 30.w, decoration: BoxDecoration( color: ColorConstants.pureSurface.withValues(alpha: 0.16), shape: BoxShape.circle, ), child: Icon( LucideIcons.arrowRight, color: Colors.white, size: 16.sp, ), ), ], ), ), ), ); } } /// The word LIVE, with a dot that breathes. class _LiveChip extends StatelessWidget { final Animation pulse; const _LiveChip({required this.pulse}); @override Widget build(BuildContext context) { return Container( padding: EdgeInsets.fromLTRB(6.w, 2.5.h, 7.w, 2.5.h), decoration: BoxDecoration( color: ColorConstants.pureSurface.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(DesignConstants.radiusFull), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ AnimatedBuilder( animation: pulse, builder: (context, _) { // A halo that grows and fades around a dot that stays put. Scaling // the dot itself would shift the text beside it every frame. return SizedBox( width: 7.w, height: 7.w, child: Stack( alignment: Alignment.center, children: [ Transform.scale( scale: 1.0 + 1.1 * pulse.value, child: DecoratedBox( decoration: BoxDecoration( color: ColorConstants.pureSurface.withValues( alpha: 0.55 * (1 - pulse.value), ), shape: BoxShape.circle, ), child: SizedBox(width: 7.w, height: 7.w), ), ), Container( width: 6.w, height: 6.w, decoration: BoxDecoration( color: ColorConstants.pureSurface, shape: BoxShape.circle, ), ), ], ), ); }, ), SizedBox(width: 5.w), Text( 'Active', style: TextStyle( color: Colors.white, fontSize: 9.sp, fontWeight: FontWeight.w800, letterSpacing: 0.7, fontFamily: FontConstants.fontFamily, ), ), ], ), ); } }