payfrit-app/lib/screens/signup_screen.dart
John Mizerek 65b5b82546 Add customer-to-staff chat feature and group order invites
- Add real-time chat between customers and staff via WebSocket
- Add HTTP polling fallback when WebSocket unavailable
- Chat auto-closes when worker ends conversation with dialog notification
- Add user search API for group order invites (phone/email/name)
- Store group order invites in app state
- Add login check before starting chat with sign-in prompt
- Remove table change button (not allowed currently)
- Fix About screen to show dynamic version from pubspec
- Update snackbar styling to green with black text

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

621 lines
18 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 SignupStep { phone, otp, profile }
class SignupScreen extends StatefulWidget {
const SignupScreen({super.key});
@override
State<SignupScreen> createState() => _SignupScreenState();
}
class _SignupScreenState extends State<SignupScreen> {
SignupStep _currentStep = SignupStep.phone;
// Phone step
final _phoneController = TextEditingController();
// OTP step
final _otpController = TextEditingController();
String _uuid = "";
String _phone = "";
// Profile step
final _firstNameController = TextEditingController();
final _lastNameController = TextEditingController();
final _emailController = TextEditingController();
bool _isLoading = false;
String? _errorMessage;
int? _userId;
String? _token;
@override
void dispose() {
_phoneController.dispose();
_otpController.dispose();
_firstNameController.dispose();
_lastNameController.dispose();
_emailController.dispose();
super.dispose();
}
String _formatPhoneNumber(String input) {
// Remove all non-digits
final digits = input.replaceAll(RegExp(r'[^\d]'), '');
// Remove leading 1 if 11 digits
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.sendOtp(phone: phone);
if (!mounted) return;
if (response.uuid.isEmpty) {
setState(() {
_errorMessage = "Server returned empty UUID - please try again";
_isLoading = false;
});
return;
}
setState(() {
_uuid = response.uuid;
_phone = phone;
_currentStep = SignupStep.otp;
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_errorMessage = "Error: ${e.toString().replaceFirst("StateError: ", "")}";
_isLoading = false;
});
}
}
Future<void> _handleVerifyOtp() async {
// Validate UUID first
if (_uuid.isEmpty) {
setState(() {
_errorMessage = "Session expired - UUID is empty. Please go back and resend code.";
});
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 {
print('[Signup] Calling verifyOtp...');
final response = await Api.verifyOtp(uuid: _uuid, otp: otp);
print('[Signup] verifyOtp success: userId=${response.userId}, needsProfile=${response.needsProfile}');
if (!mounted) return;
_userId = response.userId;
_token = response.token;
// Save credentials for persistent login
await AuthStorage.saveAuth(
userId: response.userId,
token: response.token,
);
print('[Signup] Auth saved, token set');
if (response.needsProfile) {
print('[Signup] Profile needed, going to profile step');
// Go to profile step
setState(() {
_currentStep = SignupStep.profile;
_isLoading = false;
});
} else {
print('[Signup] Profile complete, finishing signup');
// Profile already complete - go to app
_completeSignup();
}
} catch (e) {
print('[Signup] verifyOtp error: $e');
if (!mounted) return;
setState(() {
_errorMessage = e.toString().replaceFirst("StateError: ", "");
_isLoading = false;
});
}
}
Future<void> _handleCompleteProfile() async {
final firstName = _firstNameController.text.trim();
final lastName = _lastNameController.text.trim();
final email = _emailController.text.trim();
if (firstName.isEmpty) {
setState(() => _errorMessage = "First name is required");
return;
}
if (lastName.isEmpty) {
setState(() => _errorMessage = "Last name is required");
return;
}
if (email.isEmpty || !email.contains("@")) {
setState(() => _errorMessage = "Please enter a valid email address");
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
print('[Signup] Calling completeProfile: firstName=$firstName, lastName=$lastName, email=$email');
await Api.completeProfile(
firstName: firstName,
lastName: lastName,
email: email,
);
print('[Signup] completeProfile success');
if (!mounted) return;
_completeSignup();
} catch (e) {
print('[Signup] completeProfile error: $e');
if (!mounted) return;
setState(() {
_errorMessage = e.toString().replaceFirst("StateError: ", "");
_isLoading = false;
});
}
}
void _completeSignup() {
final appState = context.read<AppState>();
if (_userId != null) {
appState.setUserId(_userId!);
}
// Show success and navigate
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text("Account created! Check your email to confirm.", style: 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);
}
}
Future<void> _handleResendOtp() async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final response = await Api.resendOtp(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(_getStepTitle()),
),
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: [
// Progress indicator
_buildProgressIndicator(),
const SizedBox(height: 32),
// Step content
if (_currentStep == SignupStep.phone) _buildPhoneStep(),
if (_currentStep == SignupStep.otp) _buildOtpStep(),
if (_currentStep == SignupStep.profile) _buildProfileStep(),
// Error message
if (_errorMessage != null) ...[
const SizedBox(height: 16),
_buildErrorMessage(),
],
],
),
),
),
),
);
}
String _getStepTitle() {
switch (_currentStep) {
case SignupStep.phone:
return "Create Account";
case SignupStep.otp:
return "Verify Phone";
case SignupStep.profile:
return "Your Info";
}
}
Widget _buildProgressIndicator() {
return Row(
children: [
_buildStepDot(0, _currentStep.index >= 0),
Expanded(child: _buildStepLine(_currentStep.index >= 1)),
_buildStepDot(1, _currentStep.index >= 1),
Expanded(child: _buildStepLine(_currentStep.index >= 2)),
_buildStepDot(2, _currentStep.index >= 2),
],
);
}
Widget _buildStepDot(int step, bool isActive) {
return Container(
width: 32,
height: 32,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isActive
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.surfaceContainerHighest,
),
child: Center(
child: Text(
"${step + 1}",
style: TextStyle(
color: isActive
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.onSurfaceVariant,
fontWeight: FontWeight.bold,
),
),
),
);
}
Widget _buildStepLine(bool isActive) {
return Container(
height: 2,
color: isActive
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.surfaceContainerHighest,
);
}
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 get started",
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
color: Colors.grey,
),
),
const SizedBox(height: 32),
TextFormField(
controller: _phoneController,
decoration: const InputDecoration(
labelText: "Phone Number",
hintText: "(555) 123-4567",
border: OutlineInputBorder(),
prefixIcon: 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 Verification Code"),
),
const SizedBox(height: 16),
TextButton(
onPressed: _isLoading
? null
: () {
Navigator.of(context).pushReplacementNamed(AppRoutes.login);
},
child: const Text("Already have an account? Login"),
),
],
);
}
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: const InputDecoration(
labelText: "Verification Code",
hintText: "123456",
border: OutlineInputBorder(),
prefixIcon: 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("Verify"),
),
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 = SignupStep.phone;
_otpController.clear();
_errorMessage = null;
});
},
child: const Text("Change Number"),
),
],
),
],
);
}
Widget _buildProfileStep() {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Icon(
Icons.person_add,
size: 64,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 16),
Text(
"Almost done!",
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
"Tell us a bit about yourself",
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey,
),
),
const SizedBox(height: 32),
TextFormField(
controller: _firstNameController,
decoration: const InputDecoration(
labelText: "First Name",
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.person),
),
textCapitalization: TextCapitalization.words,
textInputAction: TextInputAction.next,
enabled: !_isLoading,
),
const SizedBox(height: 16),
TextFormField(
controller: _lastNameController,
decoration: const InputDecoration(
labelText: "Last Name",
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.person_outline),
),
textCapitalization: TextCapitalization.words,
textInputAction: TextInputAction.next,
enabled: !_isLoading,
),
const SizedBox(height: 16),
TextFormField(
controller: _emailController,
decoration: const InputDecoration(
labelText: "Email Address",
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.email),
helperText: "We'll send a confirmation email",
),
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.done,
enabled: !_isLoading,
onFieldSubmitted: (_) => _handleCompleteProfile(),
),
const SizedBox(height: 24),
FilledButton(
onPressed: _isLoading ? null : _handleCompleteProfile,
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: const Text("Complete Sign Up"),
),
],
);
}
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),
),
),
],
),
);
}
}