647 lines
21 KiB
Dart
647 lines
21 KiB
Dart
import 'package:flutter/material.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.w800,
|
||
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.w800,
|
||
letterSpacing: -0.3,
|
||
color: ColorConstants.slateText,
|
||
fontFamily: FontConstants.fontFamily,
|
||
);
|
||
|
||
TextStyle _artMeta(double size) => TextStyle(
|
||
fontSize: size.sp,
|
||
fontWeight: FontWeight.w700,
|
||
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(
|
||
Icons.inventory_2_rounded,
|
||
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(Icons.warehouse_rounded, size: 13.sp, color: _hubTeal),
|
||
SizedBox(width: 6.w),
|
||
Text(
|
||
'HUB',
|
||
style: TextStyle(
|
||
fontSize: 10.sp,
|
||
fontWeight: FontWeight.w800,
|
||
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(Icons.warehouse_rounded, 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.w800,
|
||
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,
|
||
Icons.check_rounded,
|
||
),
|
||
SizedBox(height: 9.h),
|
||
_row(
|
||
2,
|
||
'Anand Traders',
|
||
'Skipped',
|
||
ColorConstants.warning,
|
||
Icons.redo_rounded,
|
||
),
|
||
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.w800,
|
||
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.w800,
|
||
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.w800,
|
||
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.w800,
|
||
letterSpacing: 0.9,
|
||
color: ColorConstants.secondaryText,
|
||
fontFamily: FontConstants.fontFamily,
|
||
),
|
||
),
|
||
SizedBox(height: 11.h),
|
||
_line(
|
||
Icons.inventory_2_rounded,
|
||
'3 parcels',
|
||
ColorConstants.pickupAccent,
|
||
),
|
||
SizedBox(height: 9.h),
|
||
_line(
|
||
Icons.payments_rounded,
|
||
'₹340 cash',
|
||
ColorConstants.acceptGreen,
|
||
),
|
||
SizedBox(height: 9.h),
|
||
_line(
|
||
Icons.photo_camera_rounded,
|
||
'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(
|
||
Icons.warehouse_rounded,
|
||
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.w800,
|
||
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.w800,
|
||
letterSpacing: -0.2,
|
||
color: ColorConstants.slateText,
|
||
fontFamily: FontConstants.fontFamily,
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|