- Fix login to check for existing cart after OTP verification - Add abandonOrder API call for Start Fresh functionality - Fix stale service point showing for non-dine-in orders - Add Chat button for non-dine-in orders (was only Call Server) - Add quantity selector in item customization sheet - Compact cart layout with quantity badge, accordion modifiers - Add scroll indicator when cart has hidden content - Fix restaurant list tappable before images load - Add ForceNew parameter to setLineItem for customized items Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
461 lines
13 KiB
Dart
461 lines
13 KiB
Dart
import "package:flutter/material.dart";
|
|
import "package:flutter/services.dart";
|
|
import "package:provider/provider.dart";
|
|
|
|
import "../app/app_router.dart";
|
|
import "../app/app_state.dart";
|
|
import "../models/cart.dart";
|
|
import "../services/api.dart";
|
|
import "../services/auth_storage.dart";
|
|
|
|
enum LoginStep { phone, otp }
|
|
|
|
class LoginScreen extends StatefulWidget {
|
|
const LoginScreen({super.key});
|
|
|
|
@override
|
|
State<LoginScreen> createState() => _LoginScreenState();
|
|
}
|
|
|
|
class _LoginScreenState extends State<LoginScreen> {
|
|
LoginStep _currentStep = LoginStep.phone;
|
|
|
|
final _phoneController = TextEditingController();
|
|
final _otpController = TextEditingController();
|
|
|
|
String _uuid = "";
|
|
String _phone = "";
|
|
|
|
bool _isLoading = false;
|
|
String? _errorMessage;
|
|
|
|
@override
|
|
void dispose() {
|
|
_phoneController.dispose();
|
|
_otpController.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;
|
|
}
|
|
|
|
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 = LoginStep.otp;
|
|
_isLoading = false;
|
|
});
|
|
} 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,
|
|
);
|
|
|
|
final appState = context.read<AppState>();
|
|
appState.setUserId(response.userId);
|
|
|
|
// Show success
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
"Welcome back${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),
|
|
),
|
|
);
|
|
|
|
// Check for existing cart
|
|
ActiveCartInfo? existingCart;
|
|
try {
|
|
existingCart = await Api.getActiveCart(userId: response.userId);
|
|
if (existingCart != null && !existingCart.hasItems) {
|
|
existingCart = null;
|
|
}
|
|
} catch (e) {
|
|
// Ignore - treat as no cart
|
|
}
|
|
|
|
if (!mounted) return;
|
|
|
|
if (existingCart != null) {
|
|
// Show continue or start fresh dialog
|
|
_showExistingCartDialog(existingCart);
|
|
} else {
|
|
// No existing cart - just pop back
|
|
if (Navigator.of(context).canPop()) {
|
|
Navigator.of(context).pop();
|
|
} else {
|
|
Navigator.of(context).pushReplacementNamed(AppRoutes.restaurantSelect);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_errorMessage = e.toString().replaceFirst("StateError: ", "");
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
void _showExistingCartDialog(ActiveCartInfo cart) {
|
|
showDialog(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text("Existing Order Found"),
|
|
content: Text(
|
|
"You have ${cart.itemCount} item${cart.itemCount == 1 ? '' : 's'} in your cart at ${cart.businessName}.\n\nWould you like to continue that order or start fresh?",
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () {
|
|
Navigator.of(context).pop();
|
|
// Start fresh - go to restaurant select
|
|
Navigator.of(this.context).pushReplacementNamed(AppRoutes.restaurantSelect);
|
|
},
|
|
child: const Text("Start Fresh"),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () async {
|
|
Navigator.of(context).pop();
|
|
// Continue existing order - load cart and go to menu
|
|
final appState = this.context.read<AppState>();
|
|
appState.setBusinessAndServicePoint(
|
|
cart.businessId,
|
|
cart.servicePointId,
|
|
businessName: cart.businessName,
|
|
servicePointName: cart.servicePointName,
|
|
);
|
|
appState.setCartOrder(
|
|
orderId: cart.orderId,
|
|
orderUuid: cart.orderUuid,
|
|
itemCount: cart.itemCount,
|
|
);
|
|
Navigator.of(this.context).pushReplacementNamed(AppRoutes.menuBrowse);
|
|
},
|
|
child: const Text("Continue Order"),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
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(
|
|
SnackBar(
|
|
content: const Text("New code sent!", style: 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;
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text(_currentStep == LoginStep.phone ? "Login" : "Verify Phone"),
|
|
),
|
|
body: Center(
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.all(24),
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 400),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
if (_currentStep == LoginStep.phone) _buildPhoneStep(),
|
|
if (_currentStep == LoginStep.otp) _buildOtpStep(),
|
|
|
|
// Error message
|
|
if (_errorMessage != null) ...[
|
|
const SizedBox(height: 16),
|
|
_buildErrorMessage(),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildPhoneStep() {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
const Text(
|
|
"PAYFRIT",
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
fontSize: 32,
|
|
fontWeight: FontWeight.bold,
|
|
letterSpacing: 2,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
const Text(
|
|
"Enter your phone number to login",
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
color: Colors.grey,
|
|
),
|
|
),
|
|
const SizedBox(height: 32),
|
|
TextFormField(
|
|
controller: _phoneController,
|
|
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: 24),
|
|
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 Login Code"),
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextButton(
|
|
onPressed: _isLoading
|
|
? null
|
|
: () {
|
|
Navigator.of(context).pushReplacementNamed(AppRoutes.signup);
|
|
},
|
|
child: const Text("Don't have an account? Sign Up"),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildOtpStep() {
|
|
final formattedPhone = _phone.length == 10
|
|
? "(${_phone.substring(0, 3)}) ${_phone.substring(3, 6)}-${_phone.substring(6)}"
|
|
: _phone;
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Icon(
|
|
Icons.sms,
|
|
size: 64,
|
|
color: Theme.of(context).colorScheme.primary,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
"We sent a code to",
|
|
textAlign: TextAlign.center,
|
|
style: Theme.of(context).textTheme.titleMedium,
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
formattedPhone,
|
|
textAlign: TextAlign.center,
|
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
TextFormField(
|
|
controller: _otpController,
|
|
decoration: InputDecoration(
|
|
labelText: "Login 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: 24,
|
|
letterSpacing: 8,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
enabled: !_isLoading,
|
|
onFieldSubmitted: (_) => _handleVerifyOtp(),
|
|
),
|
|
const SizedBox(height: 24),
|
|
FilledButton(
|
|
onPressed: _isLoading ? null : _handleVerifyOtp,
|
|
child: _isLoading
|
|
? const SizedBox(
|
|
height: 20,
|
|
width: 20,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
|
),
|
|
)
|
|
: const Text("Login"),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
TextButton(
|
|
onPressed: _isLoading ? null : _handleResendOtp,
|
|
child: const Text("Resend Code"),
|
|
),
|
|
const SizedBox(width: 16),
|
|
TextButton(
|
|
onPressed: _isLoading
|
|
? null
|
|
: () {
|
|
setState(() {
|
|
_currentStep = LoginStep.phone;
|
|
_otpController.clear();
|
|
_errorMessage = null;
|
|
});
|
|
},
|
|
child: const Text("Change Number"),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildErrorMessage() {
|
|
return Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: Colors.red.shade50,
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(color: Colors.red.shade300),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.error_outline, color: Colors.red.shade700),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
_errorMessage!,
|
|
style: TextStyle(color: Colors.red.shade900),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|