feat: registration using invitation codes in UI

This commit is contained in:
sBubshait 2025-08-07 03:50:04 +03:00
parent 2e7033791a
commit e85a3bebb8
4 changed files with 403 additions and 109 deletions

View File

@ -3,6 +3,8 @@ class ApiConstants {
// Auth endpoints
static const String loginEndpoint = '/login';
static const String registerEndpoint = '/register';
static const String checkInvitationEndpoint = '/checkInvitation';
// User endpoints
static const String getUserEndpoint = '/getUser';

View File

@ -7,6 +7,8 @@ import 'screens/edit_profile_screen.dart';
import 'services/auth_service.dart';
import 'services/user_service.dart';
import 'services/app_lifecycle_service.dart';
import 'services/http_service.dart';
import 'dart:convert';
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
@ -260,10 +262,14 @@ class SignInPage extends StatefulWidget {
class _SignInPageState extends State<SignInPage> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _phoneController = TextEditingController();
final _passwordController = TextEditingController();
final _invitationCodeController = TextEditingController();
final _countryCodeController = TextEditingController(text: '+966');
bool _isPasswordVisible = false;
bool _isLoading = false;
bool _isRegistering = false;
int _registrationStep = 1; // 1 = invitation code, 2 = phone & password
void _showHelpBottomSheet() {
showModalBottomSheet(
@ -297,7 +303,7 @@ class _SignInPageState extends State<SignInPage> {
),
SizedBox(height: 16),
Text(
'For account creation or password reset, please contact ERP Management Group from your Aramco email address.',
'Registration is by invitation only. If you don\'t have an invitation code, please contact COD/DPSD.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
@ -331,10 +337,51 @@ class _SignInPageState extends State<SignInPage> {
);
}
Future<void> _checkInvitationCode() async {
if (_invitationCodeController.text.trim().isEmpty) {
_showErrorAlert('Please enter your invitation code');
return;
}
setState(() {
_isLoading = true;
});
try {
final response = await HttpService.post('/checkInvitation', {
'code': _invitationCodeController.text.trim().toUpperCase(),
});
final data = json.decode(response.body);
setState(() {
_isLoading = false;
});
if (data['status'] == true &&
data['data'] != null &&
data['data']['valid'] == true) {
// Invitation code is valid, move to step 2
setState(() {
_registrationStep = 2;
});
} else {
_showErrorAlert(data['message'] ?? 'Invalid invitation code');
}
} catch (e) {
setState(() {
_isLoading = false;
});
_showErrorAlert('Network error. Please try again.');
}
}
@override
void dispose() {
_emailController.dispose();
_phoneController.dispose();
_passwordController.dispose();
_invitationCodeController.dispose();
_countryCodeController.dispose();
super.dispose();
}
@ -344,8 +391,10 @@ class _SignInPageState extends State<SignInPage> {
_isLoading = true;
});
final phoneNumber =
_countryCodeController.text.trim() + _phoneController.text.trim();
final result = await AuthService.login(
_emailController.text.trim(),
phoneNumber,
_passwordController.text,
);
@ -386,11 +435,83 @@ class _SignInPageState extends State<SignInPage> {
}
}
void _handleRegister() async {
if (_formKey.currentState!.validate()) {
setState(() {
_isLoading = true;
});
try {
final phoneNumber =
_countryCodeController.text.trim() + _phoneController.text.trim();
final response = await HttpService.post('/register', {
'code': _invitationCodeController.text.trim().toUpperCase(),
'phoneNumber': phoneNumber,
'password': _passwordController.text,
});
final data = json.decode(response.body);
setState(() {
_isLoading = false;
});
if (data['status'] == true && data['data'] != null) {
// Registration successful, save token and navigate
final token = data['data']['token'];
if (token != null) {
await AuthService.saveToken(token);
final userResult = await UserService.getCurrentUser(
forceRefresh: true,
);
if (userResult['success'] == true) {
final userData = userResult['data'];
// Check if user needs onboarding (activated = false)
if (userData['activated'] == false) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (context) => EditProfileScreen(
userData: userData,
isOnboarding: true,
),
),
);
} else {
AppLifecycleService.startAllPolling();
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (context) => NotificationPermissionScreen(),
),
);
}
} else {
_showErrorAlert(
'Registration successful but failed to load user data',
);
}
} else {
_showErrorAlert('Registration successful but no token received');
}
} else {
_showErrorAlert(data['message'] ?? 'Registration failed');
}
} catch (e) {
setState(() {
_isLoading = false;
});
_showErrorAlert('Network error. Please try again.');
}
}
}
void _showErrorAlert(String message) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('Login Failed'),
title: Text(_isRegistering ? 'Registration' : 'Login Failed'),
content: Text(message),
actions: [
TextButton(
@ -430,7 +551,7 @@ class _SignInPageState extends State<SignInPage> {
icon: Icon(Icons.arrow_back, color: Colors.white),
),
Text(
'Sign In',
_isRegistering ? 'Register' : 'Sign In',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
@ -482,7 +603,9 @@ class _SignInPageState extends State<SignInPage> {
// Welcome text
Text(
'Welcome Back!',
_isRegistering
? 'Create Account'
: 'Welcome Back!',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
@ -494,7 +617,9 @@ class _SignInPageState extends State<SignInPage> {
SizedBox(height: 8),
Text(
'Sign in to socialize with your colleagues\nand transform your social life!',
_isRegistering
? 'Join with an invitation code to connect\nwith your colleagues!'
: 'Sign in to socialize with your colleagues\nand transform your social life!',
style: TextStyle(
fontSize: 16,
color: Colors.grey[600],
@ -504,100 +629,201 @@ class _SignInPageState extends State<SignInPage> {
SizedBox(height: 40),
// Email field
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: InputDecoration(
labelText: 'Email',
prefixIcon: Icon(Icons.email_outlined),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Color(0xFF6A4C93),
// Registration Step 1: Invitation Code
if (_isRegistering && _registrationStep == 1) ...[
TextFormField(
controller: _invitationCodeController,
decoration: InputDecoration(
labelText: 'Invitation Code',
prefixIcon: Icon(
Icons.confirmation_number_outlined,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Color(0xFF6A4C93),
),
),
helperText:
'6-character code with letters and numbers',
),
maxLength: 6,
textCapitalization:
TextCapitalization.characters,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your invitation code';
}
if (value.length != 6) {
return 'Invitation code must be 6 characters';
}
if (!RegExp(
r'^[A-Z0-9]{6}$',
).hasMatch(value)) {
return 'Code must contain only letters and numbers';
}
return null;
},
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your email';
}
if (!value.contains('@')) {
return 'Please enter a valid email';
}
return null;
},
),
],
SizedBox(height: 20),
// Password field
TextFormField(
controller: _passwordController,
obscureText: !_isPasswordVisible,
decoration: InputDecoration(
labelText: 'Password',
prefixIcon: Icon(Icons.lock_outline),
suffixIcon: IconButton(
icon: Icon(
_isPasswordVisible
? Icons.visibility_off
: Icons.visibility,
// Login or Registration Step 2: Phone & Password
if (!_isRegistering || _registrationStep == 2) ...[
// Phone number field with country code input
Row(
children: [
SizedBox(
width: 100,
child: TextFormField(
controller: _countryCodeController,
keyboardType: TextInputType.phone,
decoration: InputDecoration(
labelText: 'Code',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(
12,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(
12,
),
borderSide: BorderSide(
color: Color(0xFF6A4C93),
),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Required';
}
if (!value.startsWith('+')) {
return 'Must start with +';
}
return null;
},
),
),
onPressed: () {
setState(() {
_isPasswordVisible = !_isPasswordVisible;
});
},
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Color(0xFF6A4C93),
SizedBox(width: 12),
Expanded(
child: TextFormField(
controller: _phoneController,
keyboardType: TextInputType.phone,
decoration: InputDecoration(
labelText: 'Phone Number',
prefixIcon: Icon(Icons.phone_outlined),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(
12,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(
12,
),
borderSide: BorderSide(
color: Color(0xFF6A4C93),
),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your phone number';
}
if (value.length < 8) {
return 'Please enter a valid phone number';
}
return null;
},
),
),
),
],
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your password';
}
if (value.length < 6) {
return 'Password must be at least 6 characters';
}
return null;
},
),
],
// Password field (only for login or registration step 2)
if (!_isRegistering || _registrationStep == 2) ...[
SizedBox(height: 20),
TextFormField(
controller: _passwordController,
obscureText: !_isPasswordVisible,
decoration: InputDecoration(
labelText: 'Password',
prefixIcon: Icon(Icons.lock_outline),
suffixIcon: IconButton(
icon: Icon(
_isPasswordVisible
? Icons.visibility_off
: Icons.visibility,
),
onPressed: () {
setState(() {
_isPasswordVisible =
!_isPasswordVisible;
});
},
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Color(0xFF6A4C93),
),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your password';
}
if (value.length < 6) {
return 'Password must be at least 6 characters';
}
return null;
},
),
],
SizedBox(height: 12),
// Forgot password
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: _showHelpBottomSheet,
child: Text(
'Forgot Password?',
style: TextStyle(
color: Color(0xFF6A4C93),
fontWeight: FontWeight.w600,
// Forgot password (only show for login)
if (!_isRegistering)
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: _showHelpBottomSheet,
child: Text(
'Forgot Password?',
style: TextStyle(
color: Color(0xFF6A4C93),
fontWeight: FontWeight.w600,
),
),
),
),
),
SizedBox(height: 30),
// Sign in button
// Action button based on current state
Container(
height: 56,
child: ElevatedButton(
onPressed: _isLoading ? null : _handleSignIn,
onPressed: _isLoading
? null
: () {
if (!_isRegistering) {
_handleSignIn();
} else if (_registrationStep == 1) {
_checkInvitationCode();
} else {
_handleRegister();
}
},
style: ElevatedButton.styleFrom(
backgroundColor: Color(0xFF6A4C93),
foregroundColor: Colors.white,
@ -614,7 +840,11 @@ class _SignInPageState extends State<SignInPage> {
),
)
: Text(
'Sign In',
!_isRegistering
? 'Sign In'
: (_registrationStep == 1
? 'Continue'
: 'Register'),
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
@ -623,28 +853,86 @@ class _SignInPageState extends State<SignInPage> {
),
),
SizedBox(height: 20),
// Contact link for new accounts
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"Don't have an account? ",
style: TextStyle(color: Colors.grey[600]),
),
GestureDetector(
onTap: _showHelpBottomSheet,
// Back button for registration step 2
if (_isRegistering && _registrationStep == 2) ...[
SizedBox(height: 16),
Container(
height: 56,
child: OutlinedButton(
onPressed: () {
setState(() {
_registrationStep = 1;
_phoneController.clear();
_passwordController.clear();
});
},
style: OutlinedButton.styleFrom(
side: BorderSide(color: Color(0xFF6A4C93)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(
'Contact Support',
'Back to Invitation Code',
style: TextStyle(
color: Color(0xFF6A4C93),
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(0xFF6A4C93),
),
),
),
],
),
),
],
SizedBox(height: 20),
// Toggle between login and registration (only show on step 1)
if (!_isRegistering || _registrationStep == 1)
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
_isRegistering
? 'Already have an account? '
: "Don't have an account? ",
style: TextStyle(color: Colors.grey[600]),
),
GestureDetector(
onTap: () {
setState(() {
_isRegistering = !_isRegistering;
_registrationStep = 1;
// Clear form when switching modes
_phoneController.clear();
_passwordController.clear();
_invitationCodeController.clear();
});
},
child: Text(
_isRegistering
? 'Sign In'
: 'Register Now!',
style: TextStyle(
color: Color(0xFF6A4C93),
fontWeight: FontWeight.w600,
),
),
),
],
),
if (_isRegistering) ...[
SizedBox(height: 16),
Text(
'Registration is only by invitation.\nIf you don\'t have an invite, contact COD/DPSD.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
height: 1.3,
),
),
],
SizedBox(height: 40),
],

View File

@ -21,7 +21,7 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
final _formKey = GlobalKey<FormState>();
final TextEditingController _displayNameController = TextEditingController();
final TextEditingController _usernameController = TextEditingController();
final TextEditingController _emailController = TextEditingController();
final TextEditingController _phoneController = TextEditingController();
bool _isLoading = false;
@override
@ -33,7 +33,7 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
void _initializeFields() {
if (widget.userData != null) {
_displayNameController.text = widget.userData!['displayName'] ?? '';
_emailController.text = widget.userData!['email'] ?? '';
_phoneController.text = widget.userData!['phoneNumber'] ?? '';
if (!widget.isOnboarding) {
_usernameController.text = widget.userData!['username'] ?? '';
}
@ -44,7 +44,7 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
void dispose() {
_displayNameController.dispose();
_usernameController.dispose();
_emailController.dispose();
_phoneController.dispose();
super.dispose();
}
@ -299,11 +299,11 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
),
SizedBox(height: 8),
TextFormField(
controller: _emailController,
controller: _phoneController,
readOnly: true,
decoration: InputDecoration(
hintText: 'Email address',
prefixIcon: Icon(Icons.email, color: Colors.grey[400]),
hintText: 'Phone number',
prefixIcon: Icon(Icons.phone, color: Colors.grey[400]),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey[300]!),
@ -328,7 +328,7 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
),
SizedBox(height: 8),
Text(
'Email cannot be changed',
'Phone number cannot be changed',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],

View File

@ -12,7 +12,7 @@ class AuthService {
static const String _userDataKey = 'user_data';
static Future<Map<String, dynamic>> login(
String emailOrUsername,
String phoneNumberOrUsername,
String password,
) async {
try {
@ -20,7 +20,7 @@ class AuthService {
Uri.parse('${ApiConstants.baseUrl}${ApiConstants.loginEndpoint}'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'emailOrUsername': emailOrUsername,
'phoneNumberOrUsername': phoneNumberOrUsername,
'password': password,
}),
);
@ -57,6 +57,10 @@ class AuthService {
return await _storage.read(key: _tokenKey);
}
static Future<void> saveToken(String token) async {
await _storage.write(key: _tokenKey, value: token);
}
static Future<bool> isLoggedIn() async {
final token = await getToken();
return token != null && token.isNotEmpty;