payfrit-app/lib/screens/login_screen.dart
John Mizerek ef8421c88a Display cart modifiers with category breadcrumbs
- OrderLineItem now includes itemName, itemParentName, and
  isCheckedByDefault from API response
- Cart view displays modifiers as "Category: Selection" format
  (e.g., "Select Drink: Coke") instead of just item IDs
- No longer requires menu item lookup for modifier names

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 19:06:54 -08:00

399 lines
11 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 "../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 and navigate
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),
),
);
// Navigate to main app
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
} else {
Navigator.of(context).pushReplacementNamed(AppRoutes.splash);
}
} 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(
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),
),
),
],
),
);
}
}