- Restore FOREGROUND_SERVICE permission for beacon scanning - Remove FOREGROUND_SERVICE_LOCATION (no video required) - Update all SnackBars to Payfrit green (#90EE90) with black text - Float SnackBars with 80px bottom margin to avoid buttons - Add signup screen with OTP verification flow - Fix build.gradle.kts to use Flutter version system Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
596 lines
16 KiB
Dart
596 lines
16 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;
|
|
|
|
setState(() {
|
|
_uuid = response.uuid;
|
|
_phone = phone;
|
|
_currentStep = SignupStep.otp;
|
|
_isLoading = false;
|
|
});
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_errorMessage = e.toString().replaceFirst("StateError: ", "");
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _handleVerifyOtp() async {
|
|
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.verifyOtp(uuid: _uuid, otp: otp);
|
|
|
|
if (!mounted) return;
|
|
|
|
_userId = response.userId;
|
|
_token = response.token;
|
|
|
|
// Save credentials for persistent login
|
|
await AuthStorage.saveAuth(
|
|
userId: response.userId,
|
|
token: response.token,
|
|
);
|
|
|
|
if (response.needsProfile) {
|
|
// Go to profile step
|
|
setState(() {
|
|
_currentStep = SignupStep.profile;
|
|
_isLoading = false;
|
|
});
|
|
} else {
|
|
// Profile already complete - go to app
|
|
_completeSignup();
|
|
}
|
|
} catch (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 {
|
|
await Api.completeProfile(
|
|
firstName: firstName,
|
|
lastName: lastName,
|
|
email: email,
|
|
);
|
|
|
|
if (!mounted) return;
|
|
|
|
_completeSignup();
|
|
} catch (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: 32),
|
|
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),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|