Merge pull request #12 from sBubshait/feature/profile-update

Feature/profile update
This commit is contained in:
Saleh Bubshait 2025-08-03 10:07:29 +03:00 committed by GitHub
commit 7bb6bad5c9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 1251 additions and 293 deletions

View File

@ -69,18 +69,39 @@ public class AuthController {
}
@PostMapping("/updateUser")
@Operation(summary = "Update user information", description = "Update authenticated user's fcmToken, displayName, avatar, or password")
@Operation(summary = "Update user information", description = "Update authenticated user's fcmToken, displayName, avatar, password, or username")
public ResponseEntity<Map<String, Object>> updateUser(@Valid @RequestBody UpdateUserRequest request) {
try {
User user = userService.updateUser(
request.getFcmToken(),
request.getDisplayName(),
request.getAvatar(),
request.getPassword()
request.getPassword(),
request.getUsername()
);
return ResponseEntity.ok(Map.of("status", 200, "user", user));
// Create user data without password
Map<String, Object> userData = Map.of(
"id", user.getId(),
"email", user.getEmail(),
"username", user.getUsername() != null ? user.getUsername() : "",
"displayName", user.getDisplayName(),
"avatar", user.getAvatar() != null ? user.getAvatar() : "",
"fcmToken", user.getFcmToken() != null ? user.getFcmToken() : "",
"activated", user.isActivated(),
"role", user.getRole()
);
return ResponseEntity.ok(Map.of(
"status", true,
"message", "Profile updated successfully",
"data", userData
));
} catch (RuntimeException e) {
return ResponseEntity.badRequest().body(Map.of("status", 400, "message", e.getMessage()));
return ResponseEntity.ok(Map.of(
"status", false,
"message", e.getMessage()
));
}
}

View File

@ -10,6 +10,8 @@ public class UpdateUserRequest {
private String avatar;
private String username;
@Size(min = 6, message = "Password must be at least 8 characters long")
private String password;
@ -46,4 +48,12 @@ public class UpdateUserRequest {
public void setPassword(String password) {
this.password = password;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
}

View File

@ -56,8 +56,9 @@ public class UserService {
return userRepository.save(user);
}
public User updateUser(String fcmToken, String displayName, String avatar, String password) {
public User updateUser(String fcmToken, String displayName, String avatar, String password, String username) {
User user = getCurrentUser();
boolean wasNotActivated = !user.isActivated();
if (fcmToken != null && !fcmToken.trim().isEmpty()) {
user.setFcmToken(fcmToken);
@ -75,6 +76,23 @@ public class UserService {
user.setPassword(passwordEncoder.encode(password));
}
return userRepository.save(user);
if (username != null && !username.trim().isEmpty()) {
// Check if username is different from current username to avoid false positives
if (!username.equals(user.getUsername()) && userRepository.existsByUsername(username)) {
System.out.println("Username taken!");
throw new RuntimeException("@" + username + " is already taken. Try another username");
}
System.out.println("It still happened here");
user.setUsername(username);
}
User savedUser = userRepository.save(user);
if (wasNotActivated) {
savedUser.setActivated(true);
savedUser = userRepository.save(savedUser);
}
return savedUser;
}
}

11
frontend/app_info.txt Normal file
View File

@ -0,0 +1,11 @@
Wesal App - Connecting Colleagues
Wesal (وصال) is a social networking mobile application designed specifically for connecting colleagues and transforming workplace social interactions. The app name is Arabic for "connection" or "union."
It is a platform that enhances communication and collaboration among division members allowing them to build deeper connections and share experiences beyond formal work-related discussions.
The app was fully developed by Saleh Bubshait within the COD/DPSD/ERP Mgmt Group. However, the app was proposed by Weed Batarfi and Insijam team.
The app is being minimally maintained but is not actively developed at the moment as the developer is currently on assignment. For any help or inquiries, please reach out to the DPSD/ERP Mgmt Group.
Thank you for using Wesal!

View File

@ -0,0 +1,16 @@
Wesal App - Connecting Colleagues
Wesal (وصال) is a social networking mobile application designed specifically for connecting colleagues and transforming workplace social interactions. The app name is Arabic for "connection" or "union."
Features:
• Social feed for sharing updates and thoughts
• Event invitations and RSVP management
• Real-time notifications for engagement
• Profile management and customization
• Secure authentication and data protection
This application was developed to foster better communication and stronger relationships among team members, making workplace collaboration more enjoyable and effective.
For technical support or account-related inquiries, please contact the ERP Management Group from your official company email address.
Thank you for using Wesal!

View File

@ -3,7 +3,7 @@ import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart';
import 'screens/home_screen.dart';
import 'screens/notification_permission_screen.dart';
import 'services/notification_service.dart';
import 'screens/edit_profile_screen.dart';
import 'services/auth_service.dart';
import 'services/user_service.dart';
import 'services/app_lifecycle_service.dart';
@ -53,11 +53,25 @@ class _SplashScreenState extends State<SplashScreen> {
if (isLoggedIn) {
final userResult = await UserService.getCurrentUser();
if (userResult['success'] == true) {
// Start polling services now that user is logged in and going to main screen
AppLifecycleService.startAllPolling();
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (context) => HomeScreen()),
);
final userData = userResult['data'];
// Check if user needs onboarding (activated = 0)
if (userData['activated'] == 0) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (context) => EditProfileScreen(
userData: userData,
isOnboarding: true,
),
),
);
} else {
// Start polling services now that user is logged in and going to main screen
AppLifecycleService.startAllPolling();
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (context) => HomeScreen()),
);
}
} else {
await AuthService.logout();
Navigator.of(context).pushReplacement(
@ -344,14 +358,32 @@ class _SignInPageState extends State<SignInPage> {
if (result['success'] == true) {
final userResult = await UserService.getCurrentUser(forceRefresh: true);
// Start polling services now that user is logged in
AppLifecycleService.startAllPolling();
if (userResult['success'] == true) {
final userData = userResult['data'];
// Check if user needs onboarding (activated = 0)
if (userData['activated'] == 0) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (context) => EditProfileScreen(
userData: userData,
isOnboarding: true,
),
),
);
} else {
// Start polling services now that user is logged in
AppLifecycleService.startAllPolling();
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (context) => NotificationPermissionScreen(),
),
);
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (context) => NotificationPermissionScreen(),
),
);
}
} else {
_showErrorAlert('Failed to load user data');
}
} else {
_showErrorAlert(result['message'] ?? 'Login failed');
}

View File

@ -0,0 +1,428 @@
import 'package:flutter/material.dart';
import '../services/user_service.dart';
import '../services/app_lifecycle_service.dart';
import 'notification_permission_screen.dart';
class EditProfileScreen extends StatefulWidget {
final Map<String, dynamic>? userData;
final bool isOnboarding;
const EditProfileScreen({
Key? key,
this.userData,
this.isOnboarding = false,
}) : super(key: key);
@override
_EditProfileScreenState createState() => _EditProfileScreenState();
}
class _EditProfileScreenState extends State<EditProfileScreen> {
final _formKey = GlobalKey<FormState>();
final TextEditingController _displayNameController = TextEditingController();
final TextEditingController _usernameController = TextEditingController();
final TextEditingController _emailController = TextEditingController();
bool _isLoading = false;
@override
void initState() {
super.initState();
_initializeFields();
}
void _initializeFields() {
if (widget.userData != null) {
_displayNameController.text = widget.userData!['displayName'] ?? '';
_emailController.text = widget.userData!['email'] ?? '';
if (!widget.isOnboarding) {
_usernameController.text = widget.userData!['username'] ?? '';
}
}
}
@override
void dispose() {
_displayNameController.dispose();
_usernameController.dispose();
_emailController.dispose();
super.dispose();
}
String? _validateDisplayName(String? value) {
if (value == null || value.trim().isEmpty) {
return 'Display name is required';
}
if (value.trim().length < 2) {
return 'Display name must be at least 2 characters';
}
if (value.trim().length > 50) {
return 'Display name cannot exceed 50 characters';
}
return null;
}
String? _validateUsername(String? value) {
if (widget.isOnboarding) {
if (value == null || value.trim().isEmpty) {
return 'Username is required';
}
}
if (value != null && value.trim().isNotEmpty) {
if (value.trim().length < 3) {
return 'Username must be at least 3 characters';
}
if (value.trim().length > 30) {
return 'Username cannot exceed 30 characters';
}
// Basic username validation
if (!RegExp(r'^[a-zA-Z0-9_]+$').hasMatch(value.trim())) {
return 'Username can only contain letters, numbers, and underscores';
}
}
return null;
}
Future<void> _saveProfile() async {
if (!_formKey.currentState!.validate()) {
return;
}
setState(() {
_isLoading = true;
});
try {
final Map<String, dynamic> updateData = {
'displayName': _displayNameController.text.trim(),
};
if (widget.isOnboarding || _usernameController.text.trim().isNotEmpty) {
updateData['username'] = _usernameController.text.trim();
}
final result = await UserService.updateUser(
displayName: updateData['displayName'],
username: updateData['username'],
);
if (mounted) {
setState(() {
_isLoading = false;
});
if (result['success'] == true) {
_showSuccessMessage(result['message'] ?? 'Profile updated successfully');
if (widget.isOnboarding) {
// Start polling services now that user has completed onboarding
AppLifecycleService.startAllPolling();
// Navigate to notification permission screen after successful onboarding
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (context) => NotificationPermissionScreen()),
);
} else {
// Go back to profile page
Navigator.of(context).pop(true); // Return true to indicate update
}
} else {
_showErrorMessage(result['message'] ?? 'Failed to update profile');
}
}
} catch (e) {
if (mounted) {
setState(() {
_isLoading = false;
});
_showErrorMessage('An error occurred. Please try again.');
}
}
}
void _showSuccessMessage(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Color(0xFF6A4C93),
behavior: SnackBarBehavior.floating,
),
);
}
void _showErrorMessage(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.red,
behavior: SnackBarBehavior.floating,
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(
widget.isOnboarding ? 'Setup Profile' : 'Edit Profile',
style: TextStyle(fontWeight: FontWeight.w600),
),
backgroundColor: Colors.white,
foregroundColor: Color(0xFF6A4C93),
elevation: 0,
bottom: PreferredSize(
preferredSize: Size.fromHeight(1),
child: Container(height: 1, color: Colors.grey[200]),
),
automaticallyImplyLeading: !widget.isOnboarding,
),
body: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xFF32B0A5),
Color(0xFF4600B9),
],
),
),
child: SafeArea(
child: Padding(
padding: EdgeInsets.all(24),
child: Column(
children: [
Expanded(
child: SingleChildScrollView(
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.isOnboarding) ...[
SizedBox(height: 20),
Center(
child: Column(
children: [
Text(
'وصال',
style: TextStyle(
fontFamily: 'Blaka',
fontSize: 48,
fontWeight: FontWeight.w200,
color: Colors.white,
),
),
SizedBox(height: 8),
Text(
'Complete your profile setup',
style: TextStyle(
fontSize: 18,
color: Colors.white.withOpacity(0.9),
),
),
],
),
),
SizedBox(height: 40),
] else ...[
SizedBox(height: 20),
],
Container(
padding: EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
spreadRadius: 0,
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Profile Information',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
color: Color(0xFF6A4C93),
),
),
SizedBox(height: 24),
// Display Name Field
Text(
'Display Name',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.black87,
),
),
SizedBox(height: 8),
TextFormField(
controller: _displayNameController,
validator: _validateDisplayName,
decoration: InputDecoration(
hintText: 'Enter your display name',
prefixIcon: Icon(Icons.person, color: Color(0xFF6A4C93)),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey[300]!),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Color(0xFF6A4C93), width: 2),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.red, width: 2),
),
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 16),
),
),
SizedBox(height: 20),
// Email Field (Read-only)
Text(
'Email',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.black87,
),
),
SizedBox(height: 8),
TextFormField(
controller: _emailController,
readOnly: true,
decoration: InputDecoration(
hintText: 'Email address',
prefixIcon: Icon(Icons.email, color: Colors.grey[400]),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey[300]!),
),
disabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey[300]!),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey[300]!),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey[300]!),
),
filled: true,
fillColor: Colors.grey[50],
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 16),
),
style: TextStyle(color: Colors.grey[600]),
),
SizedBox(height: 8),
Text(
'Email cannot be changed',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
SizedBox(height: 20),
// Username Field
Text(
'Username${widget.isOnboarding ? ' *' : ''}',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.black87,
),
),
SizedBox(height: 8),
TextFormField(
controller: _usernameController,
validator: _validateUsername,
decoration: InputDecoration(
hintText: widget.isOnboarding
? 'Choose a username'
: 'Enter your username (optional)',
prefixIcon: Icon(Icons.alternate_email, color: Color(0xFF6A4C93)),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey[300]!),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Color(0xFF6A4C93), width: 2),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.red, width: 2),
),
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 16),
),
),
SizedBox(height: 8),
Text(
'Username can contain letters, numbers, and underscores only',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
],
),
),
],
),
),
),
),
SizedBox(height: 24),
Container(
width: double.infinity,
child: ElevatedButton(
onPressed: _isLoading ? null : _saveProfile,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Color(0xFF6A4C93),
padding: EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 2,
),
child: _isLoading
? SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFF6A4C93)),
),
)
: Text(
widget.isOnboarding ? 'Complete Setup' : 'Save Changes',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
),
),
),
);
}
}

View File

@ -0,0 +1,181 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class InformationScreen extends StatefulWidget {
const InformationScreen({Key? key}) : super(key: key);
@override
_InformationScreenState createState() => _InformationScreenState();
}
class _InformationScreenState extends State<InformationScreen> {
String appInfo = '';
bool isLoading = true;
@override
void initState() {
super.initState();
_loadAppInfo();
}
Future<void> _loadAppInfo() async {
try {
final String info = await rootBundle.loadString('assets/app_info.txt');
setState(() {
appInfo = info;
isLoading = false;
});
} catch (e) {
print('Error loading app info: $e');
setState(() {
appInfo = 'Unable to load app information.';
isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(
'Information',
style: TextStyle(fontWeight: FontWeight.w600),
),
backgroundColor: Colors.white,
foregroundColor: Color(0xFF6A4C93),
elevation: 0,
bottom: PreferredSize(
preferredSize: Size.fromHeight(1),
child: Container(height: 1, color: Colors.grey[200]),
),
),
body: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF32B0A5), Color(0xFF4600B9)],
),
),
child: SafeArea(
child: Padding(
padding: EdgeInsets.all(24),
child: Column(
children: [
Expanded(
child: SingleChildScrollView(
child: Column(
children: [
SizedBox(height: 20),
// App Logo
Container(
padding: EdgeInsets.all(24),
child: Text(
'وصال',
style: TextStyle(
fontFamily: 'Blaka',
fontSize: 80,
fontWeight: FontWeight.w200,
color: Colors.white,
shadows: [
Shadow(
offset: Offset(2, 2),
blurRadius: 4,
color: Colors.black.withOpacity(0.3),
),
],
),
),
),
SizedBox(height: 20),
// App Version
Container(
padding: EdgeInsets.symmetric(
horizontal: 20,
vertical: 8,
),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(20),
),
child: Text(
'Version 1.0.0',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.white,
),
),
),
SizedBox(height: 40),
// App Information
Container(
padding: EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Colors.white.withOpacity(0.3),
),
),
child: isLoading
? Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(
Colors.white,
),
),
)
: Text(
appInfo,
style: TextStyle(
fontSize: 16,
color: Colors.white.withOpacity(0.9),
height: 1.6,
),
textAlign: TextAlign.center,
),
),
// Additional info
],
),
),
),
SizedBox(height: 24),
Container(
width: double.infinity,
height: 56,
child: ElevatedButton(
onPressed: () => Navigator.of(context).pop(),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Color(0xFF6A4C93),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 4,
),
child: Text(
'Back to Settings',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
),
),
),
);
}
}

View File

@ -80,165 +80,166 @@ class _NotificationPermissionScreenState
@override
Widget build(BuildContext context) {
return PopScope(
canPop: false, // Prevent back navigation from notification permission screen
canPop:
false, // Prevent back navigation from notification permission screen
child: Scaffold(
body: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF32B0A5), Color(0xFF4600B9)],
stops: [0.0, 0.5],
body: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF32B0A5), Color(0xFF4600B9)],
stops: [0.0, 0.5],
),
),
),
child: SafeArea(
child: Padding(
padding: EdgeInsets.all(24),
child: Column(
children: [
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
child: SafeArea(
child: Padding(
padding: EdgeInsets.all(24),
child: Column(
children: [
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 200,
height: 200,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
shape: BoxShape.circle,
),
child: Icon(
Icons.rocket_launch,
size: 100,
color: Colors.white,
),
),
SizedBox(height: 40),
Text(
'All Set!',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
SizedBox(height: 16),
Text(
'But one last thing...',
style: TextStyle(
fontSize: 18,
color: Colors.white.withOpacity(0.9),
),
),
SizedBox(height: 40),
Container(
padding: EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Colors.white.withOpacity(0.3),
),
),
child: Column(
children: [
Icon(
Icons.notifications_active,
size: 40,
color: Colors.white,
),
SizedBox(height: 16),
Text(
'Stay Connected',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
SizedBox(height: 8),
Text(
'We will only send you updates on your own invitations or posts. You can always change your notification settings later.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
color: Colors.white.withOpacity(0.8),
height: 1.4,
),
),
],
),
),
],
),
),
Column(
children: [
Container(
width: 200,
height: 200,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
shape: BoxShape.circle,
),
child: Icon(
Icons.rocket_launch,
size: 100,
color: Colors.white,
),
),
SizedBox(height: 40),
Text(
'All Set!',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: Colors.white,
width: double.infinity,
height: 56,
child: ElevatedButton(
onPressed: _isLoading
? null
: _requestNotificationPermission,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Color(0xFF6A4C93),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 4,
),
child: _isLoading
? CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(
Color(0xFF6A4C93),
),
)
: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.notifications, size: 20),
SizedBox(width: 8),
Text(
'Enable Notifications',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
SizedBox(height: 16),
Text(
'But one last thing...',
style: TextStyle(
fontSize: 18,
color: Colors.white.withOpacity(0.9),
),
),
SizedBox(height: 40),
Container(
padding: EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Colors.white.withOpacity(0.3),
TextButton(
onPressed: _isLoading ? null : _skipNotifications,
child: Text(
'Skip for now',
style: TextStyle(
color: Colors.white.withOpacity(0.8),
fontSize: 16,
decoration: TextDecoration.underline,
),
),
child: Column(
children: [
Icon(
Icons.notifications_active,
size: 40,
color: Colors.white,
),
SizedBox(height: 16),
Text(
'Stay Connected',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
SizedBox(height: 8),
Text(
'We will only send you updates on your own invitations or posts. You can always change your notification settings later.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
color: Colors.white.withOpacity(0.8),
height: 1.4,
),
),
],
),
),
],
),
),
Column(
children: [
Container(
width: double.infinity,
height: 56,
child: ElevatedButton(
onPressed: _isLoading
? null
: _requestNotificationPermission,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Color(0xFF6A4C93),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 4,
),
child: _isLoading
? CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(
Color(0xFF6A4C93),
),
)
: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.notifications, size: 20),
SizedBox(width: 8),
Text(
'Enable Notifications',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
SizedBox(height: 16),
TextButton(
onPressed: _isLoading ? null : _skipNotifications,
child: Text(
'Skip for now',
style: TextStyle(
color: Colors.white.withOpacity(0.8),
fontSize: 16,
decoration: TextDecoration.underline,
),
),
),
],
),
],
],
),
),
),
),
),
),
);
}

View File

@ -9,6 +9,9 @@ import '../../services/app_lifecycle_service.dart';
import '../../models/post_models.dart';
import '../../widgets/posts_list.dart';
import '../../utils/password_generator.dart';
import '../edit_profile_screen.dart';
import '../support_screen.dart';
import '../information_screen.dart';
class ProfilePage extends StatefulWidget {
@override
@ -168,6 +171,19 @@ class _ProfilePageState extends State<ProfilePage> {
).push(MaterialPageRoute(builder: (context) => SettingsPage()));
}
void _navigateToEditProfile() async {
final result = await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => EditProfileScreen(userData: userData),
),
);
if (result == true) {
// Profile was updated, refresh user data
_loadUserData(forceRefresh: true);
}
}
@override
Widget build(BuildContext context) {
return PopScope(
@ -185,6 +201,12 @@ class _ProfilePageState extends State<ProfilePage> {
foregroundColor: Color(0xFF6A4C93),
elevation: 0,
actions: [
if (userData != null)
IconButton(
onPressed: _navigateToEditProfile,
icon: Icon(Icons.edit),
tooltip: 'Edit Profile',
),
IconButton(
onPressed: _navigateToSettings,
icon: Icon(Icons.settings),
@ -338,10 +360,6 @@ class SettingsPage extends StatefulWidget {
}
class _SettingsPageState extends State<SettingsPage> {
String? fcmToken;
final TextEditingController _tokenController = TextEditingController();
bool isLoading = false;
// Admin user creation
Map<String, dynamic>? userData;
bool isLoadingUser = true;
@ -353,52 +371,18 @@ class _SettingsPageState extends State<SettingsPage> {
@override
void initState() {
super.initState();
_loadFCMToken();
_loadUserData();
_generatePassword();
}
@override
void dispose() {
_tokenController.dispose();
_emailController.dispose();
_passwordController.dispose();
_displayNameController.dispose();
super.dispose();
}
Future<void> _loadFCMToken() async {
setState(() {
isLoading = true;
});
try {
final token = await NotificationService().getToken();
setState(() {
fcmToken = token;
_tokenController.text = token ?? 'Token not available';
isLoading = false;
});
} catch (e) {
setState(() {
fcmToken = null;
_tokenController.text = 'Error loading token: $e';
isLoading = false;
});
}
}
Future<void> _copyToClipboard() async {
if (fcmToken != null && fcmToken!.isNotEmpty) {
await Clipboard.setData(ClipboardData(text: fcmToken!));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('FCM Token copied to clipboard'),
backgroundColor: Color(0xFF6A4C93),
),
);
}
}
Future<void> _loadUserData() async {
setState(() {
@ -715,17 +699,18 @@ class _SettingsPageState extends State<SettingsPage> {
Container(
width: double.infinity,
height: 56,
child: ElevatedButton.icon(
onPressed: () => _showCreateAccountDialog(context),
icon: Icon(Icons.person_add, size: 18),
icon: Icon(Icons.person_add, size: 20),
label: Text('Create an Account'),
style: ElevatedButton.styleFrom(
backgroundColor: Color(0xFF6A4C93),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(12),
),
padding: EdgeInsets.symmetric(vertical: 12, horizontal: 16),
padding: EdgeInsets.symmetric(horizontal: 16),
),
),
),
@ -734,7 +719,7 @@ class _SettingsPageState extends State<SettingsPage> {
],
Text(
'Development Tools',
'App Information',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
@ -743,92 +728,88 @@ class _SettingsPageState extends State<SettingsPage> {
),
SizedBox(height: 16),
Text(
'FCM Token:',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.black87,
Container(
width: double.infinity,
height: 56,
child: ElevatedButton.icon(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(builder: (context) => SupportScreen()),
),
icon: Icon(Icons.help_outline, size: 20),
label: Text('Support'),
style: ElevatedButton.styleFrom(
backgroundColor: Color(0xFF6A4C93),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: EdgeInsets.symmetric(horizontal: 16),
),
),
),
SizedBox(height: 8),
if (isLoading)
Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFF6A4C93)),
SizedBox(height: 16),
Container(
width: double.infinity,
height: 56,
child: ElevatedButton.icon(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(builder: (context) => InformationScreen()),
),
)
else
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
decoration: BoxDecoration(
border: Border.all(color: Colors.grey[300]!),
borderRadius: BorderRadius.circular(8),
),
child: TextField(
controller: _tokenController,
readOnly: true,
maxLines: 4,
style: TextStyle(fontSize: 12, fontFamily: 'monospace'),
decoration: InputDecoration(
border: InputBorder.none,
contentPadding: EdgeInsets.all(12),
hintText: 'FCM Token will appear here...',
),
),
icon: Icon(Icons.info_outline, size: 20),
label: Text('Information'),
style: ElevatedButton.styleFrom(
backgroundColor: Color(0xFF6A4C93),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
SizedBox(height: 8),
Row(
children: [
ElevatedButton.icon(
onPressed: fcmToken != null ? _copyToClipboard : null,
icon: Icon(Icons.copy, size: 18),
label: Text('Copy Token'),
style: ElevatedButton.styleFrom(
backgroundColor: Color(0xFF6A4C93),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
SizedBox(width: 12),
OutlinedButton.icon(
onPressed: _loadFCMToken,
icon: Icon(Icons.refresh, size: 18),
label: Text('Refresh'),
style: OutlinedButton.styleFrom(
foregroundColor: Color(0xFF6A4C93),
side: BorderSide(color: Color(0xFF6A4C93)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
],
),
],
padding: EdgeInsets.symmetric(horizontal: 16),
),
),
),
SizedBox(height: 32),
Container(
width: double.infinity,
height: 56,
child: ElevatedButton.icon(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => EditProfileScreen(userData: userData),
),
),
icon: Icon(Icons.edit, size: 20),
label: Text('Update your Profile'),
style: ElevatedButton.styleFrom(
backgroundColor: Color(0xFF6A4C93),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: EdgeInsets.symmetric(horizontal: 16),
),
),
),
SizedBox(height: 16),
Container(
width: double.infinity,
height: 56,
child: ElevatedButton.icon(
onPressed: _signOut,
icon: Icon(Icons.logout, color: Colors.red),
icon: Icon(Icons.logout, color: Colors.red, size: 20),
label: Text('Sign Out', style: TextStyle(color: Colors.red)),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
side: BorderSide(color: Colors.red),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(12),
),
padding: EdgeInsets.symmetric(vertical: 16),
padding: EdgeInsets.symmetric(horizontal: 16),
),
),
),

View File

@ -0,0 +1,163 @@
import 'package:flutter/material.dart';
class SupportScreen extends StatelessWidget {
const SupportScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Support', style: TextStyle(fontWeight: FontWeight.w600)),
backgroundColor: Colors.white,
foregroundColor: Color(0xFF6A4C93),
elevation: 0,
bottom: PreferredSize(
preferredSize: Size.fromHeight(1),
child: Container(height: 1, color: Colors.grey[200]),
),
),
body: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xFF32B0A5),
Color(0xFF4600B9),
],
),
),
child: SafeArea(
child: Padding(
padding: EdgeInsets.all(24),
child: Column(
children: [
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 120,
height: 120,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
shape: BoxShape.circle,
),
child: Icon(
Icons.contact_support,
size: 60,
color: Colors.white,
),
),
SizedBox(height: 40),
Text(
'Need Help?',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
SizedBox(height: 16),
Text(
'We\'re here to help you!',
style: TextStyle(
fontSize: 18,
color: Colors.white.withOpacity(0.9),
),
),
SizedBox(height: 40),
Container(
padding: EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Colors.white.withOpacity(0.3),
),
),
child: Column(
children: [
Icon(
Icons.email_outlined,
size: 40,
color: Colors.white,
),
SizedBox(height: 16),
Text(
'Contact Support',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
SizedBox(height: 12),
Text(
'For account creation, password reset, or any other assistance, please contact ERP Management Group from your official company email address.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
color: Colors.white.withOpacity(0.9),
height: 1.4,
),
),
SizedBox(height: 20),
Container(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'ERP Management Group',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
],
),
),
],
),
),
Container(
width: double.infinity,
height: 56,
child: ElevatedButton(
onPressed: () => Navigator.of(context).pop(),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Color(0xFF6A4C93),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 4,
),
child: Text(
'Back to Settings',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
),
),
),
);
}
}

View File

@ -7,21 +7,32 @@ import 'cache_service.dart';
class InvitationsService {
static bool _pollingStarted = false;
/// Get all invitations with caching - returns cached data if available and fresh
static Future<Map<String, dynamic>> getAllInvitations({bool forceRefresh = false}) async {
static Future<Map<String, dynamic>> getAllInvitations({
bool forceRefresh = false,
}) async {
// Return cached data if available and not forcing refresh
if (!forceRefresh) {
final cached = CacheService.getCached<InvitationsData>(CacheService.invitationsAllKey);
if (cached != null && !CacheService.isCacheExpired(CacheService.invitationsAllKey, CacheService.invitationsCacheDuration)) {
final cached = CacheService.getCached<InvitationsData>(
CacheService.invitationsAllKey,
);
if (cached != null &&
!CacheService.isCacheExpired(
CacheService.invitationsAllKey,
CacheService.invitationsCacheDuration,
)) {
return {
'success': true,
'message': 'Loaded from cache',
'invitations': cached,
};
}
// Return stale data while fetching fresh data in background
final stale = CacheService.getCachedStale<InvitationsData>(CacheService.invitationsAllKey);
final stale = CacheService.getCachedStale<InvitationsData>(
CacheService.invitationsAllKey,
);
if (stale != null && !forceRefresh) {
// Trigger background refresh
_fetchAndCacheInvitations();
@ -37,7 +48,9 @@ class InvitationsService {
return await _fetchAndCacheInvitations();
}
static Future<Map<String, dynamic>> _fetchAndCacheInvitations([bool fromPolling = false]) async {
static Future<Map<String, dynamic>> _fetchAndCacheInvitations([
bool fromPolling = false,
]) async {
try {
final response = await HttpService.get(
ApiConstants.getAllInvitationsEndpoint,
@ -49,22 +62,30 @@ class InvitationsService {
if (invitationsResponse.status) {
final invitationsData = invitationsResponse.data;
// Only cache when not called from polling to prevent conflicts
if (!fromPolling) {
CacheService.setCached(CacheService.invitationsAllKey, invitationsData);
CacheService.setCached(
CacheService.invitationsAllKey,
invitationsData,
);
}
return {
'success': true,
'success': true,
'invitations': invitationsData,
'message': invitationsResponse.message ?? '',
};
} else {
return {
'success': false,
'message': invitationsResponse.message ?? 'Failed to fetch invitations',
'invitations': InvitationsData(created: [], accepted: [], available: []),
'message':
invitationsResponse.message ?? 'Failed to fetch invitations',
'invitations': InvitationsData(
created: [],
accepted: [],
available: [],
),
};
}
} else if (response.statusCode == 401 || response.statusCode == 403) {
@ -72,13 +93,21 @@ class InvitationsService {
return {
'success': false,
'message': 'Session expired. Please login again.',
'invitations': InvitationsData(created: [], accepted: [], available: []),
'invitations': InvitationsData(
created: [],
accepted: [],
available: [],
),
};
} else {
return {
'success': false,
'message': 'Server error (${response.statusCode})',
'invitations': InvitationsData(created: [], accepted: [], available: []),
'invitations': InvitationsData(
created: [],
accepted: [],
available: [],
),
};
}
} catch (e) {
@ -86,7 +115,11 @@ class InvitationsService {
return {
'success': false,
'message': 'Network error. Please check your connection.',
'invitations': InvitationsData(created: [], accepted: [], available: []),
'invitations': InvitationsData(
created: [],
accepted: [],
available: [],
),
};
}
}
@ -110,7 +143,9 @@ class InvitationsService {
/// Get invitations stream for real-time updates
static Stream<InvitationsData> getInvitationsStream() {
return CacheService.getStream<InvitationsData>(CacheService.invitationsAllKey);
return CacheService.getStream<InvitationsData>(
CacheService.invitationsAllKey,
);
}
/// Check if polling is active
@ -161,7 +196,9 @@ class InvitationsService {
}
}
static Future<Map<String, dynamic>> createInvitation(Map<String, dynamic> invitationData) async {
static Future<Map<String, dynamic>> createInvitation(
Map<String, dynamic> invitationData,
) async {
try {
final response = await HttpService.post(
ApiConstants.createInvitationEndpoint,
@ -209,7 +246,9 @@ class InvitationsService {
}
}
static Future<Map<String, dynamic>> getInvitationDetails(int invitationId) async {
static Future<Map<String, dynamic>> getInvitationDetails(
int invitationId,
) async {
try {
final response = await HttpService.get(
'${ApiConstants.invitationsEndpoint}/get?id=$invitationId',
@ -218,15 +257,15 @@ class InvitationsService {
if (response.statusCode == 200) {
final responseData = jsonDecode(response.body);
if (responseData['status'] == true) {
final invitationDetails = InvitationDetails.fromJson(responseData['data']);
return {
'success': true,
'data': invitationDetails,
};
final invitationDetails = InvitationDetails.fromJson(
responseData['data'],
);
return {'success': true, 'data': invitationDetails};
} else {
return {
'success': false,
'message': responseData['message'] ?? 'Failed to get invitation details',
'message':
responseData['message'] ?? 'Failed to get invitation details',
};
}
} else if (response.statusCode == 401) {

View File

@ -165,6 +165,61 @@ class UserService {
}
}
static Future<Map<String, dynamic>> updateUser({
String? displayName,
String? username,
String? avatar,
}) async {
try {
final Map<String, dynamic> updateData = {};
if (displayName != null) updateData['displayName'] = displayName;
if (username != null) updateData['username'] = username;
if (avatar != null) updateData['avatar'] = avatar;
final response = await HttpService.post(ApiConstants.updateUserEndpoint, updateData);
if (response.statusCode == 200) {
final responseData = jsonDecode(response.body);
if (responseData['status'] == true) {
// Update cache with new user data
CacheService.setCached(CacheService.userProfileKey, responseData['data']);
// Also save to AuthService for backward compatibility
await AuthService.saveUserData(responseData['data']);
return {
'success': true,
'message': responseData['message'] ?? 'Profile updated successfully',
'data': responseData['data'],
};
} else {
return {
'success': false,
'message': responseData['message'] ?? 'Failed to update profile',
};
}
} else if (response.statusCode == 401 || response.statusCode == 403) {
await AuthService.handleAuthenticationError();
return {
'success': false,
'message': 'Session expired. Please login again.',
};
} else {
final responseData = jsonDecode(response.body);
return {
'success': false,
'message': responseData['message'] ?? 'Server error (${response.statusCode})',
};
}
} catch (e) {
print('Error updating user: $e');
return {
'success': false,
'message': 'Network error. Please check your connection.',
};
}
}
static Future<Map<String, dynamic>> createUser({
required String email,
required String password,

View File

@ -25,6 +25,8 @@ dev_dependencies:
flutter:
uses-material-design: true
assets:
- assets/app_info.txt
fonts:
- family: Blaka
fonts: