import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../core/theme.dart'; import '../core/permissions.dart'; import '../state/session_controller.dart'; import '../widgets/common.dart'; import 'today_screen.dart'; import '../widgets/krow_nav_bar.dart'; /// Step 1 of 2: the mobile number a shift manager would text. class PhoneScreen extends ConsumerStatefulWidget { const PhoneScreen({super.key}); @override ConsumerState createState() => _PhoneScreenState(); } class _PhoneScreenState extends ConsumerState { final _controller = TextEditingController(); bool get _valid => _controller.text.replaceAll(RegExp(r'\D'), '').length >= 10; @override void dispose() { _controller.dispose(); super.dispose(); } void _send() { final phone = '+1 ${_controller.text.trim()}'; ref.read(sessionProvider.notifier).sendCode(phone); context.push('/otp'); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(leading: const BackButton()), body: SafeArea( child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(20, 4, 20, 24), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const SectionLabel('Step 1 of 2'), const Text( "What's your\nmobile number?", style: TextStyle( fontSize: 29, fontWeight: FontWeight.w700, height: 1.1, letterSpacing: -1, ), ), const SizedBox(height: 10), const Text( 'Shift managers text you here, and we will send a 6-digit code ' 'to confirm it is yours.', style: TextStyle( fontSize: 14, height: 1.55, color: KrowPalette.mute, ), ), const SizedBox(height: 24), TextField( controller: _controller, autofocus: true, keyboardType: TextInputType.phone, onChanged: (_) => setState(() {}), inputFormatters: [ FilteringTextInputFormatter.allow(RegExp(r'[0-9 ]')), LengthLimitingTextInputFormatter(14), ], style: const TextStyle( fontSize: 17, fontWeight: FontWeight.w600, letterSpacing: 0.6, ), decoration: const InputDecoration( hintText: '408 555 0148', prefixIcon: Padding( padding: EdgeInsets.fromLTRB(16, 15, 10, 15), child: Text( '+1', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, ), ), ), prefixIconConstraints: BoxConstraints(minWidth: 0), ), ), const SizedBox(height: 10), const Text( 'Message and data rates may apply. Reply STOP any time to opt ' 'out.', style: TextStyle( fontSize: 12, height: 1.5, color: Color(0xFF949FA5), ), ), const SizedBox(height: 26), FilledButton( onPressed: _valid ? _send : null, child: const Text('Send code'), ), const SizedBox(height: 10), Center( child: TextButton( onPressed: () => ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Email sign-in is coming soon'), ), ), child: const Text('Verify with email instead'), ), ), ], ), ), ), ); } } /// Step 2 of 2: the code, with a resend countdown and a limited try budget. class OtpScreen extends ConsumerStatefulWidget { const OtpScreen({super.key}); @override ConsumerState createState() => _OtpScreenState(); } class _OtpScreenState extends ConsumerState { final _controller = TextEditingController(); Timer? _timer; int _secondsLeft = 30; int _triesLeft = 3; String? _error; @override void initState() { super.initState(); _startCountdown(); } @override void dispose() { _timer?.cancel(); _controller.dispose(); super.dispose(); } void _startCountdown() { _timer?.cancel(); setState(() => _secondsLeft = 30); _timer = Timer.periodic(const Duration(seconds: 1), (t) { if (!mounted) return; if (_secondsLeft <= 1) { t.cancel(); setState(() => _secondsLeft = 0); } else { setState(() => _secondsLeft--); } }); } void _verify() { final ok = ref.read(sessionProvider.notifier).verifyCode(_controller.text); if (ok) { context.go('/verified'); return; } setState(() { _triesLeft--; _error = _triesLeft > 0 ? 'That code does not match. Check the most recent text - older ' 'codes stop working. $_triesLeft tries left.' : 'Too many attempts. Request a new code to try again.'; }); } @override Widget build(BuildContext context) { final phone = ref.watch(sessionProvider).phone ?? ''; final canVerify = _controller.text.length == 6 && _triesLeft > 0; return Scaffold( appBar: AppBar(leading: const BackButton()), body: SafeArea( child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(20, 4, 20, 24), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const SectionLabel('Step 2 of 2'), const Text( 'Enter the code', style: TextStyle( fontSize: 29, fontWeight: FontWeight.w700, letterSpacing: -1, ), ), const SizedBox(height: 10), Row( children: [ Text( 'Texted to $phone', style: const TextStyle( fontSize: 13.5, color: KrowPalette.mute, ), ), TextButton( onPressed: () => context.pop(), child: const Text('Change'), ), ], ), const SizedBox(height: 8), TextField( controller: _controller, autofocus: true, keyboardType: TextInputType.number, textAlign: TextAlign.center, maxLength: 6, onChanged: (_) => setState(() => _error = null), inputFormatters: [FilteringTextInputFormatter.digitsOnly], style: const TextStyle( fontSize: 30, fontWeight: FontWeight.w700, letterSpacing: 14, ), decoration: InputDecoration( counterText: '', hintText: '------', errorText: _error, errorMaxLines: 3, hintStyle: const TextStyle( fontSize: 30, letterSpacing: 14, color: Color(0xFFC4D4C9), ), ), ), const SizedBox(height: 14), const InfoBanner('Demo build: the code is always 123456.'), const SizedBox(height: 16), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text( "Didn't get the text?", style: TextStyle(fontSize: 13, color: KrowPalette.mute), ), if (_secondsLeft > 0) Text( 'Resend in 0:${_secondsLeft.toString().padLeft(2, '0')}', style: const TextStyle( fontSize: 12.5, color: Color(0xFF949FA5), ), ) else TextButton( onPressed: () { setState(() { _triesLeft = 3; _error = null; }); _startCountdown(); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('New code sent')), ); }, child: const Text('Resend code'), ), ], ), const SizedBox(height: 18), FilledButton( onPressed: canVerify ? _verify : null, child: const Text('Verify number'), ), ], ), ), ), ); } } /// Confirmation, plus the two things still standing between the worker and /// their first shift. class VerifiedScreen extends ConsumerWidget { const VerifiedScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final phone = ref.watch(sessionProvider).phone ?? ''; return Scaffold( backgroundColor: KrowPalette.blue, body: Column( children: [ Expanded( child: SafeArea( bottom: false, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 26), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Container( width: 86, height: 86, decoration: const BoxDecoration( color: KrowPalette.yellow, shape: BoxShape.circle, ), child: const Icon( LucideIcons.check, size: 40, color: KrowPalette.ink, ), ), const SizedBox(height: 26), const Text( 'Number verified', style: TextStyle( fontSize: 30, fontWeight: FontWeight.w700, letterSpacing: -1, color: Colors.white, ), ), const SizedBox(height: 10), Text( phone, style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w600, color: KrowPalette.yellow, ), ), const SizedBox(height: 14), Text( 'Two things left before you can accept shifts.', textAlign: TextAlign.center, style: TextStyle( fontSize: 14, height: 1.55, color: Colors.white.withValues(alpha: 0.62), ), ), const SizedBox(height: 26), const _TodoRow( title: 'Upload your ID', subtitle: 'State ID or passport', badge: '2 min', ), const SizedBox(height: 10), const _TodoRow( title: 'Set your availability', subtitle: 'So we only send shifts you can work', badge: '1 min', ), ], ), ), ), ), Container( width: double.infinity, decoration: const BoxDecoration( color: KrowPalette.sage, borderRadius: BorderRadius.vertical(top: Radius.circular(24)), ), child: SafeArea( top: false, child: Padding( padding: const EdgeInsets.fromLTRB(20, 22, 20, 16), child: Column( mainAxisSize: MainAxisSize.min, children: [ FilledButton( onPressed: () => context.go('/permissions'), child: const Text('Continue'), ), ], ), ), ), ), ], ), ); } } class _TodoRow extends StatelessWidget { const _TodoRow({ required this.title, required this.subtitle, required this.badge, }); final String title; final String subtitle; final String badge; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(14), border: Border.all(color: Colors.white24), ), child: Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w700, color: Colors.white, ), ), const SizedBox(height: 2), Text( subtitle, style: TextStyle( fontSize: 12.5, color: Colors.white.withValues(alpha: 0.55), ), ), ], ), ), StatusChip(badge, tone: ChipTone.yellow), ], ), ); } } /// One permission ask, as the prototype words it. class _Permission { const _Permission({ required this.icon, required this.step, required this.title, required this.body, required this.reasons, required this.allow, required this.defer, required this.permission, }); final IconData icon; final String step; final String title; final String body; /// Each reason as `(before, emphasis, after)` — the prototype bolds the part /// that says what you actually get. final List<(String, String, String)> reasons; final String allow; /// The way out. Worded differently each time, per the prototype. final String defer; /// What the OS is actually asked for when Allow is tapped. final AppPermission permission; } const _permissions = [ _Permission( icon: LucideIcons.mapPin, step: 'Step 1 of 3', title: 'Let KROW see where you are', body: 'Clock-in is locked until you are within 150 m of the venue. Without ' 'location you cannot start a shift.', reasons: [ ('Unlocks ', 'clock in', ' when you arrive'), ('Sorts shifts by ', 'distance from you', ''), ('Only read ', 'while a shift is active', ''), ], allow: 'Allow location', defer: 'Not now', permission: AppPermission.location, ), _Permission( icon: LucideIcons.bell, step: 'Step 2 of 3', title: 'Turn on notifications', body: 'Shift offers are first-come. If your phone is silent someone else ' 'takes the shift.', reasons: [ ('', 'New shift offers', ' matched to you'), ('', 'Clock-in reminders', ' 30 min before'), ('', 'Pay alerts', ' when money lands'), ], allow: 'Turn on notifications', defer: 'Maybe later', permission: AppPermission.notifications, ), _Permission( icon: LucideIcons.camera, step: 'Step 3 of 3', title: 'Camera and photos', body: 'Attire and document checks need a photo of the real item. Nothing is ' 'uploaded until you tap send.', reasons: [ ('Photograph ', 'your uniform items', ''), ('Capture ', 'ID and certificates', ''), ('Nothing is stored ', 'on our servers unsent', ''), ], allow: 'Allow camera', defer: 'Choose from library instead', permission: AppPermission.camera, ), ]; /// The three permission asks, each a sheet over the home screen. /// /// Shown this way rather than as full pages because that is what the worker /// is actually looking at: the app is already there behind the scrim, so the /// ask reads as a step before using it rather than another sign-up screen. class PermissionsScreen extends ConsumerStatefulWidget { const PermissionsScreen({super.key}); @override ConsumerState createState() => _PermissionsScreenState(); } class _PermissionsScreenState extends ConsumerState { int _step = 0; bool _asking = false; void _next() { if (_step == _permissions.length - 1) { context.go('/today'); } else { setState(() => _step++); } } /// Hands off to the operating system's own dialog. Whatever it answers is /// recorded, and the run continues either way — a refused permission is a /// smaller problem than a worker stuck on a sign-up step. Future _allow(_Permission permission) async { if (_asking) return; setState(() => _asking = true); final granted = await ref .read(sessionProvider.notifier) .request(permission.permission); if (!mounted) return; setState(() => _asking = false); if (!granted) { final blocked = await ref .read(permissionServiceProvider) .isPermanentlyDenied(permission.permission); // Once the OS stops showing the dialog, tapping Allow again does // nothing — the only way back is the system settings page. if (blocked && mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: const Text('Turn this on in system settings'), action: SnackBarAction( label: 'Open', textColor: KrowPalette.yellow, onPressed: ref.read(permissionServiceProvider).openSettings, ), ), ); } } if (mounted) _next(); } @override Widget build(BuildContext context) { final permission = _permissions[_step]; return Scaffold( backgroundColor: KrowPalette.sage, body: Stack( children: [ // The app itself, already built, sitting behind the ask. const IgnorePointer( child: Scaffold( body: TodayScreen(), bottomNavigationBar: KrowNavBar( items: [ KrowNavItem(icon: LucideIcons.briefcase, label: 'Shifts'), KrowNavItem(icon: LucideIcons.wallet, label: 'Pay'), KrowNavItem(icon: LucideIcons.house, label: 'Today'), KrowNavItem(icon: LucideIcons.clock, label: 'Clock'), KrowNavItem(icon: LucideIcons.user, label: 'Profile'), ], currentIndex: 2, onSelect: _ignoreTap, ), ), ), const ModalBarrier(color: Color(0x8F101A14), dismissible: false), Align( alignment: Alignment.bottomCenter, child: _PermissionSheet( permission: permission, busy: _asking, onAllow: () => _allow(permission), onDefer: _next, ), ), ], ), ); } static void _ignoreTap(int _) {} } class _PermissionSheet extends StatelessWidget { const _PermissionSheet({ required this.permission, required this.busy, required this.onAllow, required this.onDefer, }); final _Permission permission; final bool busy; final VoidCallback onAllow; final VoidCallback onDefer; @override Widget build(BuildContext context) { return Container( width: double.infinity, decoration: const BoxDecoration( color: Colors.white, borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), child: SafeArea( top: false, child: ConstrainedBox( // Scrolls rather than overflows if the copy outgrows the screen. constraints: BoxConstraints( maxHeight: MediaQuery.sizeOf(context).height * 0.86, ), child: SingleChildScrollView( child: Padding( padding: const EdgeInsets.fromLTRB(22, 12, 22, 16), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ Center( child: Container( width: 40, height: 4, decoration: BoxDecoration( color: const Color(0xFFCFDDD4), borderRadius: BorderRadius.circular(2), ), ), ), const SizedBox(height: 20), // Aligned rather than stretched: the surrounding column // stretches its children so the buttons fill the sheet, and // that would pull this 60px tile across the whole width. Align( alignment: Alignment.centerLeft, child: Container( width: 60, height: 60, decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), gradient: const LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Color(0xFF4A69C6), KrowPalette.blue], ), ), child: Icon( permission.icon, size: 27, color: Colors.white, ), ), ), SectionLabel(permission.step), Text( permission.title, style: const TextStyle( fontSize: 24, fontWeight: FontWeight.w700, height: 1.15, letterSpacing: -0.7, ), ), const SizedBox(height: 10), Text( permission.body, style: const TextStyle( fontSize: 14, height: 1.55, fontWeight: FontWeight.w500, color: KrowPalette.mute, ), ), const SizedBox(height: 14), for (final (before, emphasis, after) in permission.reasons) Padding( padding: const EdgeInsets.only(bottom: 10), child: _Reason( before: before, emphasis: emphasis, after: after, ), ), const SizedBox(height: 10), FilledButton( onPressed: busy ? null : onAllow, child: Text(permission.allow), ), TextButton( onPressed: onDefer, style: TextButton.styleFrom( minimumSize: const Size.fromHeight(44), foregroundColor: KrowPalette.mute, ), child: Text(permission.defer), ), ], ), ), ), ), ), ); } } class _Reason extends StatelessWidget { const _Reason({ required this.before, required this.emphasis, required this.after, }); final String before; final String emphasis; final String after; @override Widget build(BuildContext context) { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( width: 20, height: 20, margin: const EdgeInsets.only(top: 1), decoration: BoxDecoration( color: KrowPalette.blueTint, borderRadius: BorderRadius.circular(7), ), child: const Icon( LucideIcons.check, size: 11, color: KrowPalette.blue, ), ), const SizedBox(width: 11), Expanded( child: Text.rich( TextSpan( children: [ TextSpan(text: before), TextSpan( text: emphasis, style: const TextStyle( fontWeight: FontWeight.w600, color: KrowPalette.ink, ), ), TextSpan(text: after), ], ), style: const TextStyle( fontSize: 13, height: 1.45, fontWeight: FontWeight.w500, color: KrowPalette.mute, ), ), ), ], ); } }