Files
doormile_milderapp/lib/views/Dashboard/home/homepage_banner.dart
2026-09-09 12:55:23 +05:30

336 lines
13 KiB
Dart

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<Map<String, dynamic>> activePickups;
final void Function(Map<String, dynamic>) onTap;
const ActivePickupBanner({
super.key,
required this.activePickups,
required this.onTap,
});
@override
State<ActivePickupBanner> createState() => _ActivePickupBannerState();
}
class _ActivePickupBannerState extends State<ActivePickupBanner>
with SingleTickerProviderStateMixin {
/// The live dot actually pulses.
///
/// A static dot beside the word LIVE is a claim the interface does not back
/// up; movement is what makes "there is a job running right now" read without
/// being read. Initialised lazily on the declaration rather than in
/// `initState` so hot reload cannot leave it unset — see the note in
/// `Bottom_page.dart`.
/// Created on first *use*, not on first touch of any kind.
///
/// This was a `late final` initialised on the declaration, which meant
/// `dispose()` calling `_pulse.dispose()` *constructed* the controller during
/// teardown whenever it had never been used — and `build` returns early with
/// no active pickups, so that is the normal path when a rider's last stop
/// finishes and the banner unmounts. `createTicker` then looked up
/// `TickerMode` on a deactivated element and threw. Two of the app's own
/// tests caught it; it needs the nullable so dispose can ask "was there
/// ever one?" instead of answering "there is now".
AnimationController? _pulseController;
AnimationController get _pulse => _pulseController ??= AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1100),
)..repeat(reverse: true);
@override
void dispose() {
_pulseController?.dispose();
super.dispose();
}
String _address(Map<String, dynamic> pickup) =>
(pickup['pickupaddress'] ??
pickup['PickupAddress'] ??
pickup['address'] ??
'')
.toString()
.trim();
/// Where the rider has got to on this stop — from the one place the app
/// names its statuses, so the banner cannot say "At pickup" while the card
/// behind it says something else about the same stop. See [StopStatusX.label].
String _status(Map<String, dynamic> pickup) => stopStatusOf(pickup).label;
@override
Widget build(BuildContext context) {
if (widget.activePickups.isEmpty) return const SizedBox.shrink();
final pickup = widget.activePickups.first;
final count = widget.activePickups.length;
final address = _address(pickup);
// Rides the floating nav bar: it is `Positioned` outside the Scaffold's
// body, so `SafeArea` knows nothing about it and anything pinned to the
// bottom lands underneath. See [BottomPage.bottomInset].
//
// A fixed inset now. This used to follow a `navVisible` notifier up and
// down, because the bar left the screen entirely on scroll. It compacts
// instead of leaving, so there is no longer a state in which the room is
// free — and chasing the 20pt difference re-laid this out every time the
// rider changed scroll direction, for a gap he would never notice.
return Padding(
padding: EdgeInsets.only(bottom: BottomPage.bottomInset(context) + 10.h),
child: Padding(
// 16 — the same gutter the nav bar above it uses, so the two stack as
// one column rather than as two floating objects of different widths.
padding: EdgeInsets.symmetric(horizontal: 16.w),
child: _Card(
pulse: _pulse,
count: count,
address: address,
status: _status(pickup),
onTap: () => widget.onTap(pickup),
),
),
);
}
}
class _Card extends StatelessWidget {
final Animation<double> pulse;
final int count;
final String address;
final String status;
final VoidCallback onTap;
const _Card({
required this.pulse,
required this.count,
required this.address,
required this.status,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Material(
color: ColorConstants.primary,
// 20 — between the stop cards' 16 and the nav bar's 32, which is where it
// sits in the visual stack.
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
clipBehavior: Clip.antiAlias,
// Tinted rather than black: a neutral shadow under a saturated red surface
// reads as grime. The gradient and second shadow the Home copy carried are
// gone — one flat fill and one shadow is the language the rest of the app
// now speaks.
shadowColor: ColorConstants.primary.withValues(alpha: 0.45),
elevation: 10,
child: InkWell(
onTap: onTap,
splashColor: Colors.white.withValues(alpha: 0.10),
highlightColor: Colors.white.withValues(alpha: 0.06),
child: Padding(
padding: EdgeInsets.fromLTRB(12.w, 11.h, 12.w, 11.h),
child: Row(
children: [
Container(
width: 42.w,
height: 42.w,
decoration: BoxDecoration(
color: ColorConstants.pureSurface.withValues(alpha: 0.16),
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
),
child: Icon(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<double> pulse;
const _LiveChip({required this.pulse});
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.fromLTRB(6.w, 2.5.h, 7.w, 2.5.h),
decoration: BoxDecoration(
color: ColorConstants.pureSurface.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedBuilder(
animation: pulse,
builder: (context, _) {
// A halo that grows and fades around a dot that stays put. Scaling
// the dot itself would shift the text beside it every frame.
return SizedBox(
width: 7.w,
height: 7.w,
child: Stack(
alignment: Alignment.center,
children: [
Transform.scale(
scale: 1.0 + 1.1 * pulse.value,
child: DecoratedBox(
decoration: BoxDecoration(
color: ColorConstants.pureSurface.withValues(
alpha: 0.55 * (1 - pulse.value),
),
shape: BoxShape.circle,
),
child: SizedBox(width: 7.w, height: 7.w),
),
),
Container(
width: 6.w,
height: 6.w,
decoration: BoxDecoration(
color: ColorConstants.pureSurface,
shape: BoxShape.circle,
),
),
],
),
);
},
),
SizedBox(width: 5.w),
Text(
'Active',
style: TextStyle(
color: Colors.white,
fontSize: 9.sp,
fontWeight: FontWeight.w800,
letterSpacing: 0.7,
fontFamily: FontConstants.fontFamily,
),
),
],
),
);
}
}