Design system - MilerSurface ladder (canvas → working → raised → floating) with MilerPanel as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1. - Visible vocabulary applied across Home, Deliveries, Activity, Account and the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x), canvas wells for anything that opens, small filled tags for shelf labels, demoted placeholders. Recorded in DESIGN_SYSTEM.md §6. - One icon family: 222 Material glyphs migrated to Lucide; none left outside lib/xpress. - Colour semantics corrected: amber only for what is genuinely owed, brand red reserved for the live stop, disabled primaries go neutral rather than pale. Data and lifecycle - lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart makes admin sequence the single ordering authority; service_day.dart, and stop_area.dart rewritten against live Coimbatore addresses (digit-token stripping, city stoplist, street suffixes, stammer collapse). - countLabel states the load once, in bags. Testing - 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity, sheets and verify, with test/failures/ now gitignored (diff debris). - New pins: home_gutter_test, stop_area_test, plus updated structural bounds. Note: this commit also carries pre-existing working-tree deletions that were present before this work (API_SPEC.md, README.md, demo test fixtures). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
336 lines
13 KiB
Dart
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.w700,
|
|
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.w500,
|
|
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.w700,
|
|
letterSpacing: 0.7,
|
|
fontFamily: FontConstants.fontFamily,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|