290 lines
10 KiB
Dart
290 lines
10 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:lucide_icons_flutter/lucide_icons.dart';
|
||
import 'package:shared_preferences/shared_preferences.dart';
|
||
|
||
import 'package:miler/views/onboardscreens/Sign_in.dart';
|
||
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
||
import 'package:miler/views/helpers/constants/Font_constant.dart';
|
||
import 'package:miler/views/helpers/constants/design_constants.dart';
|
||
|
||
/// ═════════════════════════════════════════════════════════════════════════
|
||
/// FIRST RUN — three full-bleed brand slides, then sign in.
|
||
///
|
||
/// ── The artwork is the slide ──
|
||
///
|
||
/// Each slide is a finished poster: headline, sub-copy, product shot and the
|
||
/// MILER by DOORMILE lockup are all baked into the image. So this screen draws
|
||
/// no title or body text of its own — a Flutter caption under the picture would
|
||
/// simply say the same thing twice, in a second typeface, at a second size.
|
||
/// The screen's whole job is to present the image edge to edge and put the
|
||
/// controls somewhere they stay readable.
|
||
///
|
||
/// ── Controls that survive three different backdrops ──
|
||
///
|
||
/// Slides 1 and 3 end in the brand's red wave; slide 2 ends on dark tarmac.
|
||
/// [ColorConstants.primary] would vanish into the first and get lost in the
|
||
/// second, so the CTA inverts: a white pill carrying the red label, which holds
|
||
/// its contrast on red, on grey and on black. A short scrim under the controls
|
||
/// does the rest without dulling the wave. `Skip` sits top-right, where all
|
||
/// three images are near-white, so it stays dark type on a light plate.
|
||
/// ═════════════════════════════════════════════════════════════════════════
|
||
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;
|
||
bool _precached = false;
|
||
|
||
/// Order is the order the rider sees: trust → the work → the pay.
|
||
static const _slides = <String>[
|
||
'assets/images/onboard_1.png',
|
||
'assets/images/onboard_2.png',
|
||
'assets/images/onboard_3.png',
|
||
];
|
||
|
||
@override
|
||
void didChangeDependencies() {
|
||
super.didChangeDependencies();
|
||
// Decode all three up front. Paging onto an undecoded 940×1672 PNG shows a
|
||
// blank frame first, which on the very first run reads as a broken app.
|
||
if (_precached) return;
|
||
_precached = true;
|
||
for (final path in _slides) {
|
||
precacheImage(AssetImage(path), context);
|
||
}
|
||
}
|
||
|
||
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,
|
||
systemNavigationBarColor: Colors.black,
|
||
),
|
||
child: Scaffold(
|
||
backgroundColor: ColorConstants.pureSurface,
|
||
// The image runs under the status bar; only the controls are inset.
|
||
extendBodyBehindAppBar: true,
|
||
body: Stack(
|
||
fit: StackFit.expand,
|
||
children: [
|
||
// ── The slide itself ──
|
||
PageView.builder(
|
||
controller: _controller,
|
||
onPageChanged: (i) => setState(() => _index = i),
|
||
itemCount: _slides.length,
|
||
itemBuilder: (_, i) => Image.asset(
|
||
_slides[i],
|
||
fit: BoxFit.cover,
|
||
alignment: Alignment.topCenter,
|
||
filterQuality: FilterQuality.medium,
|
||
),
|
||
),
|
||
|
||
// ── Scrim: just enough to seat the dots and the CTA ──
|
||
Positioned(
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
child: IgnorePointer(
|
||
child: Container(
|
||
height: 210.h,
|
||
decoration: const BoxDecoration(
|
||
gradient: LinearGradient(
|
||
begin: Alignment.topCenter,
|
||
end: Alignment.bottomCenter,
|
||
colors: [Color(0x00000000), Color(0x73000000)],
|
||
stops: [0.0, 0.85],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
|
||
SafeArea(
|
||
child: Column(
|
||
children: [
|
||
// ── A way out ──
|
||
Padding(
|
||
padding: EdgeInsets.fromLTRB(20.w, 8.h, 16.w, 0),
|
||
child: Row(
|
||
children: [
|
||
const Spacer(),
|
||
// Skip stays unmounted on the last slide rather than
|
||
// fading to `opacity: 0` — an invisible-but-present
|
||
// control is a trap for screen readers, and by then the
|
||
// CTA already does what Skip would.
|
||
if (!isLast) _SkipAction(onPressed: _finish),
|
||
],
|
||
),
|
||
),
|
||
|
||
const Spacer(),
|
||
|
||
// ── 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
|
||
? Colors.white
|
||
: Colors.white.withValues(alpha: 0.45),
|
||
borderRadius: BorderRadius.circular(
|
||
DesignConstants.radiusFull,
|
||
),
|
||
),
|
||
);
|
||
}),
|
||
),
|
||
|
||
Padding(
|
||
padding: EdgeInsets.fromLTRB(24.w, 20.h, 24.w, 16.h),
|
||
child: _OnboardButton(
|
||
label: isLast ? 'Get started' : 'Next',
|
||
onPressed: _next,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// The CTA, inverted. White plate, red label — the one pairing that holds on
|
||
/// the red wave of slides 1 and 3 *and* on the tarmac of slide 2.
|
||
class _OnboardButton extends StatelessWidget {
|
||
final String label;
|
||
final VoidCallback onPressed;
|
||
|
||
const _OnboardButton({required this.label, required this.onPressed});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return SizedBox(
|
||
width: double.infinity,
|
||
height: ButtonSizes.primary,
|
||
child: ElevatedButton(
|
||
onPressed: onPressed,
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: ColorConstants.pureSurface,
|
||
foregroundColor: ColorConstants.primary,
|
||
elevation: 0,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(ButtonSizes.radius.r),
|
||
),
|
||
padding: EdgeInsets.symmetric(horizontal: 16.w),
|
||
minimumSize: Size.zero,
|
||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||
),
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Flexible(
|
||
child: Text(
|
||
label,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
color: ColorConstants.primary,
|
||
fontSize: 16.sp,
|
||
fontWeight: FontWeight.w800,
|
||
letterSpacing: -0.2,
|
||
fontFamily: FontConstants.fontFamily,
|
||
),
|
||
),
|
||
),
|
||
SizedBox(width: 8.w),
|
||
Icon(
|
||
LucideIcons.arrowRight,
|
||
size: 18.sp,
|
||
color: ColorConstants.primary,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Skip, on a translucent plate. The top of every slide is near-white, so the
|
||
/// label is dark type; the plate keeps it legible if artwork is ever swapped
|
||
/// for something busier.
|
||
class _SkipAction extends StatelessWidget {
|
||
final VoidCallback onPressed;
|
||
|
||
const _SkipAction({required this.onPressed});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return TextButton(
|
||
onPressed: onPressed,
|
||
style: TextButton.styleFrom(
|
||
minimumSize: Size(0, ButtonSizes.minTapTarget),
|
||
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h),
|
||
backgroundColor: Colors.white.withValues(alpha: 0.72),
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
|
||
),
|
||
),
|
||
child: Text(
|
||
'Skip',
|
||
style: TextStyle(
|
||
fontSize: 13.5.sp,
|
||
fontWeight: FontWeight.w800,
|
||
color: ColorConstants.slateText,
|
||
fontFamily: FontConstants.fontFamily,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|