Features: - Beacon scanner service for detecting nearby beacons - Beacon cache for offline-first beacon resolution - Preload cache for instant menu display - Business selector screen for multi-location support - Rescan button widget for quick beacon refresh - Sign-in dialog for guest checkout flow - Task type model for server tasks Improvements: - Enhanced menu browsing with category filtering - Improved cart view with better modifier display - Order history with detailed order tracking - Chat screen improvements - Better error handling in API service Fixes: - CashApp payment return crash fix - Modifier nesting issues resolved - Auto-expand modifier groups Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
404 lines
12 KiB
Dart
404 lines
12 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../app/app_state.dart';
|
|
import '../services/api.dart';
|
|
import '../services/auth_storage.dart';
|
|
|
|
/// A dialog that handles phone number + OTP sign-in inline.
|
|
/// Returns true if sign-in was successful, false if cancelled.
|
|
class SignInDialog extends StatefulWidget {
|
|
const SignInDialog({super.key});
|
|
|
|
/// Shows the sign-in dialog and returns true if authenticated successfully
|
|
static Future<bool> show(BuildContext context) async {
|
|
final result = await showDialog<bool>(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (context) => const SignInDialog(),
|
|
);
|
|
return result ?? false;
|
|
}
|
|
|
|
@override
|
|
State<SignInDialog> createState() => _SignInDialogState();
|
|
}
|
|
|
|
enum _SignInStep { phone, otp }
|
|
|
|
class _SignInDialogState extends State<SignInDialog> {
|
|
_SignInStep _currentStep = _SignInStep.phone;
|
|
|
|
final _phoneController = TextEditingController();
|
|
final _otpController = TextEditingController();
|
|
final _phoneFocus = FocusNode();
|
|
final _otpFocus = FocusNode();
|
|
|
|
String _uuid = '';
|
|
String _phone = '';
|
|
|
|
bool _isLoading = false;
|
|
String? _errorMessage;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
// Auto-focus the phone field when dialog opens
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_phoneFocus.requestFocus();
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_phoneController.dispose();
|
|
_otpController.dispose();
|
|
_phoneFocus.dispose();
|
|
_otpFocus.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
String _formatPhoneNumber(String input) {
|
|
final digits = input.replaceAll(RegExp(r'[^\d]'), '');
|
|
if (digits.length == 11 && digits.startsWith('1')) {
|
|
return digits.substring(1);
|
|
}
|
|
return digits;
|
|
}
|
|
|
|
String _formatPhoneDisplay(String phone) {
|
|
if (phone.length == 10) {
|
|
return '(${phone.substring(0, 3)}) ${phone.substring(3, 6)}-${phone.substring(6)}';
|
|
}
|
|
return phone;
|
|
}
|
|
|
|
Future<void> _handleSendOtp() async {
|
|
final phone = _formatPhoneNumber(_phoneController.text);
|
|
|
|
if (phone.length != 10) {
|
|
setState(() {
|
|
_errorMessage = 'Please enter a valid 10-digit phone number';
|
|
});
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_isLoading = true;
|
|
_errorMessage = null;
|
|
});
|
|
|
|
try {
|
|
final response = await Api.sendLoginOtp(phone: phone);
|
|
|
|
if (!mounted) return;
|
|
|
|
if (response.uuid.isEmpty) {
|
|
setState(() {
|
|
_errorMessage = 'Server error - please try again';
|
|
_isLoading = false;
|
|
});
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_uuid = response.uuid;
|
|
_phone = phone;
|
|
_currentStep = _SignInStep.otp;
|
|
_isLoading = false;
|
|
});
|
|
|
|
// Auto-focus the OTP field
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_otpFocus.requestFocus();
|
|
});
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_errorMessage = e.toString().replaceFirst('StateError: ', '');
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _handleVerifyOtp() async {
|
|
if (_uuid.isEmpty) {
|
|
setState(() {
|
|
_errorMessage = 'Session expired. Please go back and try again.';
|
|
});
|
|
return;
|
|
}
|
|
|
|
final otp = _otpController.text.trim();
|
|
|
|
if (otp.length != 6) {
|
|
setState(() {
|
|
_errorMessage = 'Please enter the 6-digit code';
|
|
});
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_isLoading = true;
|
|
_errorMessage = null;
|
|
});
|
|
|
|
try {
|
|
final response = await Api.verifyLoginOtp(uuid: _uuid, otp: otp);
|
|
|
|
if (!mounted) return;
|
|
|
|
// Save credentials for persistent login
|
|
await AuthStorage.saveAuth(
|
|
userId: response.userId,
|
|
token: response.token,
|
|
);
|
|
|
|
// Update app state
|
|
final appState = context.read<AppState>();
|
|
appState.setUserId(response.userId);
|
|
|
|
// Close dialog with success
|
|
Navigator.of(context).pop(true);
|
|
|
|
// Show welcome message
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
'Welcome${response.userFirstName.isNotEmpty ? ', ${response.userFirstName}' : ''}!',
|
|
style: const TextStyle(color: Colors.black),
|
|
),
|
|
backgroundColor: const Color(0xFF90EE90),
|
|
behavior: SnackBarBehavior.floating,
|
|
margin: const EdgeInsets.only(bottom: 80, left: 16, right: 16),
|
|
),
|
|
);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_errorMessage = e.toString().replaceFirst('StateError: ', '');
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _handleResendOtp() async {
|
|
setState(() {
|
|
_isLoading = true;
|
|
_errorMessage = null;
|
|
});
|
|
|
|
try {
|
|
final response = await Api.sendLoginOtp(phone: _phone);
|
|
|
|
if (!mounted) return;
|
|
|
|
setState(() {
|
|
_uuid = response.uuid;
|
|
_isLoading = false;
|
|
});
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('New code sent!', style: TextStyle(color: Colors.black)),
|
|
backgroundColor: Color(0xFF90EE90),
|
|
behavior: SnackBarBehavior.floating,
|
|
),
|
|
);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_errorMessage = e.toString().replaceFirst('StateError: ', '');
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Dialog(
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
// Header
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
_currentStep == _SignInStep.phone ? 'Sign In to Continue' : 'Enter Code',
|
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.close),
|
|
onPressed: _isLoading ? null : () => Navigator.of(context).pop(false),
|
|
padding: EdgeInsets.zero,
|
|
constraints: const BoxConstraints(),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
_currentStep == _SignInStep.phone
|
|
? 'Enter your phone number to add items to your cart'
|
|
: 'We sent a code to ${_formatPhoneDisplay(_phone)}',
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color: Colors.grey.shade600,
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
|
|
// Form content
|
|
if (_currentStep == _SignInStep.phone) _buildPhoneStep(),
|
|
if (_currentStep == _SignInStep.otp) _buildOtpStep(),
|
|
|
|
// Error message
|
|
if (_errorMessage != null) ...[
|
|
const SizedBox(height: 16),
|
|
Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: Colors.red.shade50,
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(color: Colors.red.shade200),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.error_outline, color: Colors.red.shade600, size: 20),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
_errorMessage!,
|
|
style: TextStyle(color: Colors.red.shade800, fontSize: 13),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildPhoneStep() {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
TextFormField(
|
|
controller: _phoneController,
|
|
focusNode: _phoneFocus,
|
|
decoration: InputDecoration(
|
|
labelText: 'Phone Number',
|
|
hintText: '(555) 123-4567',
|
|
hintStyle: TextStyle(color: Colors.grey.shade400),
|
|
border: const OutlineInputBorder(),
|
|
prefixIcon: const Icon(Icons.phone),
|
|
prefixText: '+1 ',
|
|
),
|
|
keyboardType: TextInputType.phone,
|
|
inputFormatters: [
|
|
FilteringTextInputFormatter.digitsOnly,
|
|
LengthLimitingTextInputFormatter(10),
|
|
],
|
|
enabled: !_isLoading,
|
|
onFieldSubmitted: (_) => _handleSendOtp(),
|
|
),
|
|
const SizedBox(height: 20),
|
|
FilledButton(
|
|
onPressed: _isLoading ? null : _handleSendOtp,
|
|
child: _isLoading
|
|
? const SizedBox(
|
|
height: 20,
|
|
width: 20,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
|
),
|
|
)
|
|
: const Text('Send Code'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildOtpStep() {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
TextFormField(
|
|
controller: _otpController,
|
|
focusNode: _otpFocus,
|
|
decoration: InputDecoration(
|
|
labelText: 'Verification Code',
|
|
hintText: '123456',
|
|
hintStyle: TextStyle(color: Colors.grey.shade400),
|
|
border: const OutlineInputBorder(),
|
|
prefixIcon: const Icon(Icons.lock),
|
|
),
|
|
keyboardType: TextInputType.number,
|
|
inputFormatters: [
|
|
FilteringTextInputFormatter.digitsOnly,
|
|
LengthLimitingTextInputFormatter(6),
|
|
],
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(
|
|
fontSize: 20,
|
|
letterSpacing: 6,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
enabled: !_isLoading,
|
|
onFieldSubmitted: (_) => _handleVerifyOtp(),
|
|
),
|
|
const SizedBox(height: 20),
|
|
FilledButton(
|
|
onPressed: _isLoading ? null : _handleVerifyOtp,
|
|
child: _isLoading
|
|
? const SizedBox(
|
|
height: 20,
|
|
width: 20,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
|
),
|
|
)
|
|
: const Text('Verify & Continue'),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
TextButton(
|
|
onPressed: _isLoading ? null : _handleResendOtp,
|
|
child: const Text('Resend Code'),
|
|
),
|
|
const SizedBox(width: 8),
|
|
TextButton(
|
|
onPressed: _isLoading
|
|
? null
|
|
: () {
|
|
setState(() {
|
|
_currentStep = _SignInStep.phone;
|
|
_otpController.clear();
|
|
_errorMessage = null;
|
|
});
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_phoneFocus.requestFocus();
|
|
});
|
|
},
|
|
child: const Text('Change Number'),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|