Files
doormile_milderapp/lib/views/introscreens/introscreen.dart
Thiru-tenext d7348e253f Miler rider app: surface system, visible design language, backend lifecycle
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>
2026-08-22 05:40:35 +05:30

640 lines
21 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:flutter/services.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/views/onboardscreens/Sign_in.dart';
import 'package:miler/views/onboardscreens/auth_scaffold.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/helpers/constants/Font_constant.dart';
import 'package:miler/views/helpers/widgets/miler_app_bar.dart';
import 'package:miler/views/helpers/constants/design_constants.dart';
/// ═════════════════════════════════════════════════════════════════════════
/// FIRST RUN — three slides, then sign in.
///
/// ── The copy described a different app ──
///
/// This is the most important change here, and it is not a visual one. The old
/// slides said "Find pickups near you — see nearby pickup requests the moment
/// they arrive, accept in a single tap" and "Navigate the smart way". That is a
/// gig-marketplace pitch, and Miler is not a marketplace: the **hub assigns a
/// slot**, the route comes with its stops and its order already set, it is a
/// **round trip** that starts and ends at the hub, and a stop that is not ready
/// gets **skipped, never reordered**. A rider onboarded on the old copy would
/// open the app looking for a feed of nearby jobs to claim and not find one.
///
/// The third slide also carried a "Powered by AI" chip next to a claim about
/// real-time bonuses. Neither was backed by anything in the app, and a promise
/// made during onboarding is the one a rider remembers when it does not hold.
///
/// ── The illustrations show the actual product ──
///
/// Each slide used to draw a 320×320 rounded panel, containing a 156px tinted
/// circle, containing an 84px Material icon — three nested shapes to present one
/// glyph, on a `repeat(reverse: true)` pulse that never stopped. A location pin
/// does not teach anyone what a slot route is.
///
/// So the panels now hold small, honest mock-ups built from the app's own
/// vocabulary — the same slot chip, stop rows and hub markers the rider will see
/// on Home an hour later. Onboarding that previews the real interface is doing
/// two jobs at once: it explains the model *and* it makes the real screen
/// familiar on arrival.
/// ═════════════════════════════════════════════════════════════════════════
class _Slide {
final String title;
final String body;
final Widget art;
const _Slide({required this.title, required this.body, required this.art});
}
class Introscreen extends StatefulWidget {
const Introscreen({super.key});
@override
State<Introscreen> createState() => _IntroscreenState();
}
class _IntroscreenState extends State<Introscreen> {
final PageController _controller = PageController();
int _index = 0;
static final _slides = <_Slide>[
_Slide(
title: 'Your route arrives ready',
body:
'Your hub assigns each shift as a time slot, with the stops and '
'their order already set. Take the whole trip, or accept it stop by '
'stop.',
art: _SlotArt(),
),
_Slide(
title: 'Work the stops in order',
body:
'Follow the list as it stands. If a stop is not ready, skip it and '
'keep moving — the order is never rearranged underneath you.',
art: _StopsArt(),
),
_Slide(
title: 'Collect, then close the loop',
body:
'Take the parcels and any cash due, capture the proof, and finish '
'back at the hub. Every stop is logged as you go.',
art: _HubArt(),
),
];
Future<void> _finish() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('has_seen_intro', true);
} catch (_) {}
if (!mounted) return;
Get.offAll(() => const SignIn());
}
void _next() {
if (_index < _slides.length - 1) {
_controller.nextPage(
duration: const Duration(milliseconds: 380),
curve: Curves.easeOutCubic,
);
} else {
_finish();
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final isLast = _index == _slides.length - 1;
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.dark.copyWith(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
),
child: Scaffold(
backgroundColor: ColorConstants.surface,
body: SafeArea(
child: Column(
children: [
// ── Top bar: mark, and a way out ──
Padding(
padding: EdgeInsets.fromLTRB(20.w, 8.h, 12.w, 0),
child: Row(
children: [
const MilerBrandMark(),
const Spacer(),
// Skip stays mounted on the last slide instead of fading to
// `opacity: 0` — an invisible-but-present control is a trap
// for screen readers, and on the last slide the primary
// button already does what Skip would.
if (!isLast)
AuthTextAction(label: 'Skip', onPressed: _finish),
],
),
),
Expanded(
child: PageView.builder(
controller: _controller,
onPageChanged: (i) => setState(() => _index = i),
itemCount: _slides.length,
itemBuilder: (_, i) => _SlideView(
slide: _slides[i],
controller: _controller,
index: i,
),
),
),
// ── Position ──
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(_slides.length, (i) {
final active = i == _index;
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
margin: EdgeInsets.symmetric(horizontal: 3.w),
height: 6.h,
width: active ? 26.w : 6.h,
decoration: BoxDecoration(
color: active
? ColorConstants.primary
: ColorConstants.borderStrong,
borderRadius: BorderRadius.circular(
DesignConstants.radiusFull,
),
),
);
}),
),
Padding(
padding: EdgeInsets.fromLTRB(24.w, 22.h, 24.w, 14.h),
child: AuthPrimaryButton(
label: isLast ? 'Get started' : 'Next',
onPressed: _next,
),
),
],
),
),
),
);
}
}
/// One slide. Parallax is driven by an [AnimatedBuilder] on the page controller
/// rather than by a listener calling `setState` on the whole screen, which is
/// what the old version did on every pixel of every drag.
class _SlideView extends StatelessWidget {
final _Slide slide;
final PageController controller;
final int index;
const _SlideView({
required this.slide,
required this.controller,
required this.index,
});
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: controller,
builder: (context, _) {
// `page` is null until the viewport has been laid out once.
final page = controller.hasClients && controller.position.haveDimensions
? (controller.page ?? controller.initialPage.toDouble())
: controller.initialPage.toDouble();
final delta = (page - index).abs().clamp(0.0, 1.0);
return Opacity(
opacity: 1 - (delta * 0.55),
child: Transform.scale(
scale: 1 - (delta * 0.08),
child: SingleChildScrollView(
physics: const ClampingScrollPhysics(),
padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 8.h),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
slide.art,
SizedBox(height: 34.h),
Text(
slide.title,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 25.sp,
fontWeight: FontWeight.w700,
letterSpacing: -0.6,
height: 1.2,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 10.h),
Text(
slide.body,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14.5.sp,
fontWeight: FontWeight.w500,
height: 1.5,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
),
);
},
);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// ILLUSTRATIONS
//
// Built from the app's real vocabulary — white surfaces, 1.5px `borderStrong`
// edges, slate type, the hub teal — so the first thing the rider sees is the
// thing he will be using. Static: no controllers, nothing to dispose, nothing
// left repainting behind the keyboard.
// ═══════════════════════════════════════════════════════════════════════════
/// Shared frame: the "screen" each mock sits on.
class _ArtFrame extends StatelessWidget {
final Widget child;
const _ArtFrame({required this.child});
@override
Widget build(BuildContext context) {
return Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: 300.w),
child: Container(
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
color: ColorConstants.pureSurface,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
border: Border.all(color: ColorConstants.borderStrong, width: 1.5),
boxShadow: const [
BoxShadow(
color: Color(0x0F000000),
blurRadius: 20,
offset: Offset(0, 8),
),
],
),
child: child,
),
),
);
}
}
const Color _hubTeal = Color(0xFF0E7C7B);
TextStyle _artTitle(double size) => TextStyle(
fontSize: size.sp,
fontWeight: FontWeight.w700,
letterSpacing: -0.3,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
);
TextStyle _artMeta(double size) => TextStyle(
fontSize: size.sp,
fontWeight: FontWeight.w600,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
);
/// Slide 1 — a slot, its load, and the shape of the trip.
class _SlotArt extends StatelessWidget {
@override
Widget build(BuildContext context) {
return _ArtFrame(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('12:00 PM – 3:00 PM', style: _artTitle(16)),
SizedBox(height: 3.h),
Text('6 stops', style: _artMeta(11.5)),
],
),
),
Container(
padding: EdgeInsets.symmetric(horizontal: 9.w, vertical: 5.h),
decoration: BoxDecoration(
color: ColorConstants.neutralLight,
borderRadius: BorderRadius.circular(
DesignConstants.radiusFull,
),
border: Border.all(color: ColorConstants.borderStrong),
),
child: Row(
children: [
Icon(
LucideIcons.package,
size: 11.sp,
color: ColorConstants.slateText,
),
SizedBox(width: 4.w),
Text('15', style: _artTitle(12)),
],
),
),
],
),
SizedBox(height: 14.h),
// The metric strip, in miniature.
Container(
padding: EdgeInsets.symmetric(vertical: 8.h),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
border: Border.all(
color: ColorConstants.borderStrong,
width: 1.5,
),
),
child: Row(
children: [
_cell('≈3h', 'Duration', ColorConstants.primary),
_divider(),
_cell('50 km', 'Round trip', ColorConstants.slateText),
_divider(),
_cell('₹1,030', 'To collect', ColorConstants.acceptGreen),
],
),
),
SizedBox(height: 14.h),
Row(
children: [
Icon(LucideIcons.warehouse, size: 13.sp, color: _hubTeal),
SizedBox(width: 6.w),
Text(
'HUB',
style: TextStyle(
fontSize: 10.sp,
fontWeight: FontWeight.w700,
letterSpacing: 0.6,
color: _hubTeal,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(width: 7.w),
for (var i = 0; i < 4; i++) ...[
Container(
width: 14.w,
height: 2,
color: ColorConstants.borderStrong,
),
Container(
width: 7.w,
height: 7.w,
decoration: BoxDecoration(
color: ColorConstants.primary,
shape: BoxShape.circle,
),
),
],
Container(
width: 14.w,
height: 2,
color: ColorConstants.borderStrong,
),
Icon(LucideIcons.warehouse, size: 13.sp, color: _hubTeal),
],
),
],
),
);
}
Widget _cell(String value, String label, Color accent) => Expanded(
child: Column(
children: [
Text(
value,
style: TextStyle(
fontSize: 12.5.sp,
fontWeight: FontWeight.w700,
letterSpacing: -0.3,
color: accent,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 1.h),
Text(label, style: _artMeta(9)),
],
),
);
Widget _divider() =>
Container(width: 1, height: 24.h, color: ColorConstants.borderSubtle);
}
/// Slide 2 — the list, in order, with one done and one skipped.
class _StopsArt extends StatelessWidget {
@override
Widget build(BuildContext context) {
return _ArtFrame(
child: Column(
children: [
_row(
1,
'Sri Balaji Stores',
'Done',
ColorConstants.acceptGreen,
LucideIcons.check,
),
SizedBox(height: 9.h),
_row(
2,
'Anand Traders',
'Skipped',
ColorConstants.warning,
LucideIcons.redo2,
),
SizedBox(height: 9.h),
_row(3, 'Kumar Textiles', 'Next', ColorConstants.primary, null),
SizedBox(height: 9.h),
_row(4, 'MS Enterprises', null, ColorConstants.borderStrong, null),
],
),
);
}
Widget _row(
int n,
String name,
String? status,
Color accent,
IconData? badge,
) {
final pending = status == null;
return Row(
children: [
Container(
width: 20.w,
height: 20.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color: pending ? ColorConstants.pureSurface : accent,
shape: BoxShape.circle,
border: Border.all(color: accent, width: 1.8),
),
child: badge != null
? Icon(badge, size: 11.sp, color: Colors.white)
: Text(
'$n',
style: TextStyle(
fontSize: 10.sp,
fontWeight: FontWeight.w700,
color: pending
? ColorConstants.secondaryText
: Colors.white,
fontFamily: FontConstants.fontFamily,
),
),
),
SizedBox(width: 10.w),
Expanded(
child: Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w700,
letterSpacing: -0.2,
color: pending
? ColorConstants.secondaryText
: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
),
if (status != null)
Text(
status,
style: TextStyle(
fontSize: 10.5.sp,
fontWeight: FontWeight.w700,
color: accent,
fontFamily: FontConstants.fontFamily,
),
),
],
);
}
}
/// Slide 3 — what leaves the stop with you, and where it ends up.
class _HubArt extends StatelessWidget {
@override
Widget build(BuildContext context) {
return _ArtFrame(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'COLLECT AT THIS STOP',
style: TextStyle(
fontSize: 9.5.sp,
fontWeight: FontWeight.w700,
letterSpacing: 0.9,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 11.h),
_line(LucideIcons.package, '3 parcels', ColorConstants.pickupAccent),
SizedBox(height: 9.h),
_line(LucideIcons.banknote, '₹340 cash', ColorConstants.acceptGreen),
SizedBox(height: 9.h),
_line(LucideIcons.camera, 'Photo proof', ColorConstants.slateText),
SizedBox(height: 14.h),
Container(height: 1, color: ColorConstants.borderSubtle),
SizedBox(height: 13.h),
Row(
children: [
Container(
width: 22.w,
height: 22.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color: _hubTeal.withValues(alpha: 0.14),
shape: BoxShape.circle,
border: Border.all(color: _hubTeal, width: 1.5),
),
child: Icon(
LucideIcons.warehouse,
size: 12.sp,
color: _hubTeal,
),
),
SizedBox(width: 9.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'RETURN · HUB',
style: TextStyle(
fontSize: 10.sp,
fontWeight: FontWeight.w700,
letterSpacing: 0.7,
color: _hubTeal,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 2.h),
Text('Hand everything over', style: _artMeta(10.5)),
],
),
),
],
),
],
),
);
}
Widget _line(IconData icon, String text, Color accent) => Row(
children: [
Icon(icon, size: 15.sp, color: accent),
SizedBox(width: 9.w),
Text(
text,
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w700,
letterSpacing: -0.2,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
],
);
}