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 '../state/session_controller.dart'; import '../widgets/common.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), ], ), ); } } class _Permission { const _Permission(this.icon, this.step, this.title, this.body, this.reasons); final IconData icon; final String step; final String title; final String body; final List reasons; } /// The three-step permission run. Each grant is recorded in the session so the /// app knows what it is allowed to do later. class PermissionsScreen extends ConsumerStatefulWidget { const PermissionsScreen({super.key}); @override ConsumerState createState() => _PermissionsScreenState(); } class _PermissionsScreenState extends ConsumerState { int _step = 0; static const _permissions = [ _Permission( LucideIcons.mapPin, 'Step 1 of 3', 'Let KROW see where you are', 'Clock-in is locked until you are within 150 m of the venue. Without ' 'location you cannot start a shift.', [ 'Unlocks clock in when you arrive', 'Sorts shifts by distance from you', 'Only read while a shift is active', ], ), _Permission( LucideIcons.bell, 'Step 2 of 3', 'Turn on notifications', 'Shift offers are first-come. If your phone is silent someone else takes ' 'the shift.', [ 'New shift offers matched to you', 'Clock-in reminders 30 min before', 'Pay alerts when money lands', ], ), _Permission( LucideIcons.camera, 'Step 3 of 3', 'Camera and photos', 'Attire and document checks need a photo of the real item. Nothing is ' 'uploaded until you tap send.', [ 'Photograph your uniform items', 'Capture ID and certificates', 'Nothing is stored on our servers unsent', ], ), ]; void _advance({required bool granted}) { final notifier = ref.read(sessionProvider.notifier); if (granted) { switch (_step) { case 0: notifier.grantLocation(); case 1: notifier.grantNotifications(); case 2: notifier.grantCamera(); } } if (_step == _permissions.length - 1) { context.go('/today'); } else { setState(() => _step++); } } @override Widget build(BuildContext context) { final p = _permissions[_step]; return Scaffold( body: SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(22, 24, 22, 16), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // The copy scrolls if it has to; the actions stay pinned, so a // short screen or a large text scale can never push them off. Expanded( child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( width: 60, height: 60, decoration: BoxDecoration( color: KrowPalette.blue, borderRadius: BorderRadius.circular(18), ), child: Icon(p.icon, size: 27, color: Colors.white), ), SectionLabel(p.step), Text( p.title, style: const TextStyle( fontSize: 25, fontWeight: FontWeight.w700, height: 1.15, letterSpacing: -0.9, ), ), const SizedBox(height: 10), Text( p.body, style: const TextStyle( fontSize: 14, height: 1.55, color: KrowPalette.mute, ), ), const SizedBox(height: 18), for (final reason in p.reasons) Padding( padding: const EdgeInsets.only(bottom: 12), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( width: 20, height: 20, // Nudged to sit on the first line's optical // centre, so wrapped reasons stay top-aligned. margin: const EdgeInsets.only(top: 1), decoration: BoxDecoration( color: KrowPalette.blueTint, borderRadius: BorderRadius.circular(7), ), child: const Icon( LucideIcons.check, size: 12, color: KrowPalette.blue, ), ), const SizedBox(width: 11), Expanded( child: Text( reason, style: const TextStyle( fontSize: 13, height: 1.45, color: KrowPalette.mute, ), ), ), ], ), ), ], ), ), ), const SizedBox(height: 16), FilledButton( onPressed: () => _advance(granted: true), child: Text(switch (_step) { 0 => 'Allow location', 1 => 'Turn on notifications', _ => 'Allow camera', }), ), const SizedBox(height: 4), // Full width so the secondary action shares the primary's edges // rather than hugging the left. TextButton( onPressed: () => _advance(granted: false), style: TextButton.styleFrom( minimumSize: const Size.fromHeight(44), ), child: const Text('Not now'), ), ], ), ), ), ); } }