payfrit-app/lib/widgets/sign_in_dialog.dart
John Mizerek c4792189dd App Store Version 2: Beacon scanning, preload caching, business selector
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>
2026-01-23 19:51:54 -08:00

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'),
),
],
),
],
);
}
}