feat(profile): enhance officer and user profile details with improved null checks and formatting

- Updated OfficerProfileDetail to include better null checks for officer attributes and added patrol statistics.
- Introduced a new method to format role names for better display.
- Enhanced UserProfileDetail to handle cases where user data may not be available, displaying appropriate messages.
- Added address display functionality in UserProfileDetail.
- Improved SettingsScreen to fetch user profile data if not already loaded and display it with a loading indicator.
- Refactored SecuritySettingsScreen to use reactive state management with GetX for better performance and user experience.
- Created ProfileEditForm for editing user profiles with validation and image upload options.
- Added a custom circular loader widget for consistent loading indicators across the app.
This commit is contained in:
vergiLgood1 2025-05-27 08:39:46 +07:00
parent c6931619de
commit 0f3cb701c4
18 changed files with 1399 additions and 349 deletions

View File

@ -4,6 +4,7 @@ import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart'; import 'package:get_storage/get_storage.dart';
import 'package:mapbox_maps_flutter/mapbox_maps_flutter.dart'; import 'package:mapbox_maps_flutter/mapbox_maps_flutter.dart';
import 'package:sigap/app.dart';
import 'package:sigap/navigation_menu.dart'; import 'package:sigap/navigation_menu.dart';
import 'package:sigap/src/features/personalization/presentasion/bindings/personalization_bindings.dart'; import 'package:sigap/src/features/personalization/presentasion/bindings/personalization_bindings.dart';
import 'package:sigap/src/utils/theme/theme.dart'; import 'package:sigap/src/utils/theme/theme.dart';
@ -48,22 +49,6 @@ Future<void> main() async {
MapboxOptions.setAccessToken(mapboxAccesToken); MapboxOptions.setAccessToken(mapboxAccesToken);
runApp(const MyApp()); runApp(const App());
} }
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return GetMaterialApp(
title: 'SIGAP',
debugShowCheckedModeBanner: false,
theme: TAppTheme.lightTheme,
darkTheme: TAppTheme.darkTheme,
themeMode: ThemeMode.system,
initialBinding: PersonalizationBindings(),
home: const NavigationMenu(),
);
}
}

View File

@ -1,6 +1,7 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:sigap/src/features/auth/presentasion/bindings/auth_bindings.dart'; import 'package:sigap/src/features/auth/presentasion/bindings/auth_bindings.dart';
import 'package:sigap/src/features/onboarding/presentasion/bindings/onboarding_binding.dart'; import 'package:sigap/src/features/onboarding/presentasion/bindings/onboarding_binding.dart';
import 'package:sigap/src/features/personalization/presentasion/bindings/personalization_bindings.dart';
class ControllerBindings extends Bindings { class ControllerBindings extends Bindings {
@override @override
@ -13,5 +14,8 @@ class ControllerBindings extends Bindings {
// Auth Bindings // Auth Bindings
AuthControllerBindings().dependencies(); AuthControllerBindings().dependencies();
// Personalization Bindings
PersonalizationBindings().dependencies();
} }
} }

View File

@ -11,6 +11,8 @@ import 'package:sigap/src/features/onboarding/presentasion/pages/location-warnin
import 'package:sigap/src/features/onboarding/presentasion/pages/onboarding/onboarding_screen.dart'; import 'package:sigap/src/features/onboarding/presentasion/pages/onboarding/onboarding_screen.dart';
import 'package:sigap/src/features/onboarding/presentasion/pages/role-selection/role_signup_pageview.dart'; import 'package:sigap/src/features/onboarding/presentasion/pages/role-selection/role_signup_pageview.dart';
import 'package:sigap/src/features/onboarding/presentasion/pages/welcome/welcome_screen.dart'; import 'package:sigap/src/features/onboarding/presentasion/pages/welcome/welcome_screen.dart';
import 'package:sigap/src/features/personalization/presentasion/pages/profile/profile_screen.dart';
import 'package:sigap/src/features/personalization/presentasion/pages/settings/setting_screen.dart';
import 'package:sigap/src/utils/constants/app_routes.dart'; import 'package:sigap/src/utils/constants/app_routes.dart';
class AppPages { class AppPages {
@ -67,6 +69,20 @@ class AppPages {
page: () => const LivenessDetectionPage(), page: () => const LivenessDetectionPage(),
), ),
// Personalization
GetPage(
name: AppRoutes.settings,
page: () => const SettingsScreen(),
preventDuplicates: false,
),
GetPage(
name: AppRoutes.profile,
page: () => const ProfileScreen(isCurrentUser: true),
),
]; ];
} }

View File

@ -24,6 +24,7 @@ class AuthenticationRepository extends GetxController {
final _supabase = SupabaseService.instance.client; final _supabase = SupabaseService.instance.client;
final _locationService = LocationService.instance; final _locationService = LocationService.instance;
final _biometricService = Get.find<BiometricService>(); final _biometricService = Get.find<BiometricService>();
final _logger = Logger();
// Getters that use the Supabase service // Getters that use the Supabase service
User? get authUser => SupabaseService.instance.currentUser; User? get authUser => SupabaseService.instance.currentUser;
@ -41,26 +42,23 @@ class AuthenticationRepository extends GetxController {
// Check for biometric login on app start // Check for biometric login on app start
Future<bool> attemptBiometricLogin() async { Future<bool> attemptBiometricLogin() async {
try {
if (!await _biometricService.isBiometricLoginEnabled()) { if (!await _biometricService.isBiometricLoginEnabled()) {
return false; return false;
} }
String? refreshToken = await _biometricService.attemptBiometricLogin(); String? sessionToken = await _biometricService.attemptBiometricLogin();
if (refreshToken == null || refreshToken.isEmpty) { if (sessionToken == null || sessionToken.isEmpty) {
return false; return false;
} }
try { // Use the session token to restore the session
// Use the refresh token to recover the session final response = await restoreSession(sessionToken);
final response = await _supabase.auth.refreshSession(refreshToken); return response;
if (response.session != null) {
Get.offAllNamed(AppRoutes.explore);
return true;
}
return false;
} catch (e) { } catch (e) {
// If refresh token is invalid or expired, disable biometric login // If token is invalid or expired, disable biometric login
await _biometricService.disableBiometricLogin(); await _biometricService.disableBiometricLogin();
_logger.e('Error during biometric login: $e');
return false; return false;
} }
} }
@ -827,4 +825,23 @@ class AuthenticationRepository extends GetxController {
throw TExceptions('Something went wrong. Please try again later.'); throw TExceptions('Something went wrong. Please try again later.');
} }
} }
// Restore session using a stored token (for biometric authentication)
Future<bool> restoreSession(String sessionToken) async {
try {
// Use the token to restore a session
await _supabase.auth.recoverSession(sessionToken);
// Check if session was successfully restored
final session = _supabase.auth.currentSession;
if (session != null) {
return true;
} else {
throw 'Failed to restore session';
}
} catch (e) {
_logger.e('Error restoring session: $e');
throw 'Session restoration failed: $e';
}
}
} }

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:logger/logger.dart'; import 'package:logger/logger.dart';
import 'package:sigap/src/cores/services/biometric_service.dart';
import 'package:sigap/src/features/auth/data/repositories/authentication_repository.dart'; import 'package:sigap/src/features/auth/data/repositories/authentication_repository.dart';
import 'package:sigap/src/utils/constants/app_routes.dart'; import 'package:sigap/src/utils/constants/app_routes.dart';
import 'package:sigap/src/utils/popups/loaders.dart'; import 'package:sigap/src/utils/popups/loaders.dart';
@ -10,6 +11,7 @@ class SignInController extends GetxController {
final _logger = Logger(); final _logger = Logger();
final _authRepo = Get.find<AuthenticationRepository>(); final _authRepo = Get.find<AuthenticationRepository>();
final _biometricService = Get.find<BiometricService>();
// Form controllers // Form controllers
final email = TextEditingController(); final email = TextEditingController();
@ -22,6 +24,13 @@ class SignInController extends GetxController {
// States // States
final RxBool isLoading = RxBool(false); final RxBool isLoading = RxBool(false);
final RxBool isPasswordVisible = RxBool(false); final RxBool isPasswordVisible = RxBool(false);
final RxBool isBiometricAvailable = RxBool(false);
@override
void onInit() {
super.onInit();
_checkBiometricAvailability();
}
@override @override
void onClose() { void onClose() {
@ -30,6 +39,48 @@ class SignInController extends GetxController {
super.onClose(); super.onClose();
} }
// Check if biometrics is available and enabled for the user
Future<void> _checkBiometricAvailability() async {
try {
final isAvailable = _biometricService.isBiometricAvailable.value;
final isEnabled = await _biometricService.isBiometricLoginEnabled();
isBiometricAvailable.value = isAvailable && isEnabled;
} catch (e) {
_logger.e('Error checking biometric availability: $e');
isBiometricAvailable.value = false;
}
}
// Attempt to sign in with biometrics
Future<void> signInWithBiometrics() async {
try {
isLoading.value = true;
// Reuse the existing method from AuthRepository
bool success = await _authRepo.attemptBiometricLogin();
if (success) {
// Redirect is handled automatically by restoreSession
_authRepo.screenRedirect();
} else {
// Show error if biometric authentication failed
TLoaders.errorSnackBar(
title: 'Authentication Failed',
message:
'Biometric authentication unsuccessful. Please try again or use email and password.',
);
}
} catch (e) {
_logger.e('Biometric sign-in error: $e');
TLoaders.errorSnackBar(
title: 'Biometric Sign In Failed',
message: e.toString(),
);
} finally {
isLoading.value = false;
}
}
// Toggle password visibility // Toggle password visibility
void togglePasswordVisibility() { void togglePasswordVisibility() {
isPasswordVisible.value = !isPasswordVisible.value; isPasswordVisible.value = !isPasswordVisible.value;

View File

@ -108,6 +108,36 @@ class SignInScreen extends StatelessWidget {
), ),
), ),
const SizedBox(height: 16),
// Biometric Sign In Button (shown only if available)
Obx(() {
return controller.isBiometricAvailable.value
? ElevatedButton.icon(
onPressed: controller.signInWithBiometrics,
style: ElevatedButton.styleFrom(
backgroundColor:
Theme.of(context).colorScheme.surface,
foregroundColor:
Theme.of(context).colorScheme.primary,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(30),
side: BorderSide(
color: Theme.of(
context,
).colorScheme.outline.withOpacity(0.3),
),
),
padding: const EdgeInsets.symmetric(vertical: 12),
minimumSize: const Size(double.infinity, 0),
),
icon: const Icon(TablerIcons.fingerprint),
label: const Text('Sign In with Biometrics'),
)
: const SizedBox.shrink();
}),
const SizedBox(height: 24), const SizedBox(height: 24),
// Or divider // Or divider

View File

@ -1,8 +1,5 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:logger/logger.dart'; import 'package:logger/logger.dart';
import 'package:sigap/src/features/personalization/data/repositories/officers_repository.dart';
import 'package:sigap/src/features/personalization/data/repositories/profile_repository.dart';
import 'package:sigap/src/features/personalization/data/repositories/users_repository.dart';
import 'package:sigap/src/features/personalization/presentasion/controllers/profile/officer_profile_controller.dart'; import 'package:sigap/src/features/personalization/presentasion/controllers/profile/officer_profile_controller.dart';
import 'package:sigap/src/features/personalization/presentasion/controllers/profile/profile_controller.dart'; import 'package:sigap/src/features/personalization/presentasion/controllers/profile/profile_controller.dart';
import 'package:sigap/src/features/personalization/presentasion/controllers/settings/display_mode_controller.dart'; import 'package:sigap/src/features/personalization/presentasion/controllers/settings/display_mode_controller.dart';
@ -22,9 +19,6 @@ class PersonalizationBindings extends Bindings {
Get.lazyPut<Logger>(() => Logger(), fenix: true); Get.lazyPut<Logger>(() => Logger(), fenix: true);
} }
// Register repositories
_registerRepositories();
// Register profile controllers // Register profile controllers
_registerProfileControllers(); _registerProfileControllers();
@ -32,22 +26,11 @@ class PersonalizationBindings extends Bindings {
_registerSettingsControllers(); _registerSettingsControllers();
} }
void _registerRepositories() {
// Register repositories with fenix: true to keep them alive when not in use
// but recreate them if they were destroyed
Get.lazyPut<UserRepository>(() => UserRepository(), fenix: true);
Get.lazyPut<ProfileRepository>(() => ProfileRepository(), fenix: true);
Get.lazyPut<OfficerRepository>(() => OfficerRepository(), fenix: true);
}
void _registerProfileControllers() { void _registerProfileControllers() {
// Register profile-related controllers // Register profile-related controllers
Get.lazyPut<ProfileController>(() => ProfileController(), fenix: true); Get.lazyPut<ProfileController>(() => ProfileController(), fenix: true);
Get.lazyPut<OfficerProfileController>( Get.lazyPut<OfficerController>(() => OfficerController(),
() => OfficerProfileController(),
fenix: true, fenix: true,
); );
} }

View File

@ -5,7 +5,7 @@ import 'package:sigap/src/features/personalization/data/repositories/officers_re
import 'package:sigap/src/features/personalization/presentasion/controllers/profile/base_profile_controller.dart'; import 'package:sigap/src/features/personalization/presentasion/controllers/profile/base_profile_controller.dart';
import 'package:sigap/src/features/personalization/presentasion/controllers/profile/profile_controller.dart'; import 'package:sigap/src/features/personalization/presentasion/controllers/profile/profile_controller.dart';
class OfficerProfileController extends BaseProfileController { class OfficerController extends BaseProfileController {
// Use find to get previously registered repository // Use find to get previously registered repository
final _officerRepository = Get.find<OfficerRepository>(); final _officerRepository = Get.find<OfficerRepository>();

View File

@ -23,6 +23,6 @@ class ProfileBinding extends Bindings {
// Register controllers // Register controllers
Get.lazyPut(() => ProfileController(), fenix: true); Get.lazyPut(() => ProfileController(), fenix: true);
Get.lazyPut(() => OfficerProfileController(), fenix: true); Get.lazyPut(() => OfficerController(), fenix: true);
} }
} }

View File

@ -9,10 +9,10 @@ import 'package:sigap/src/features/personalization/data/repositories/users_repos
import 'package:sigap/src/features/personalization/presentasion/controllers/profile/base_profile_controller.dart'; import 'package:sigap/src/features/personalization/presentasion/controllers/profile/base_profile_controller.dart';
class ProfileController extends BaseProfileController { class ProfileController extends BaseProfileController {
// Repositories // Repositories using Get.find() instead of direct initialization
final _userRepository = Get.find<UserRepository>(); final UserRepository _userRepository = Get.find<UserRepository>();
final _profileRepository = Get.find<ProfileRepository>(); final ProfileRepository _profileRepository = Get.find<ProfileRepository>();
final _officerRepository = Get.find<OfficerRepository>(); final OfficerRepository _officerRepository = Get.find<OfficerRepository>();
// Observable state variables // Observable state variables
final Rx<UserModel?> user = Rx<UserModel?>(null); final Rx<UserModel?> user = Rx<UserModel?>(null);
@ -21,6 +21,7 @@ class ProfileController extends BaseProfileController {
final RxBool isOfficer = false.obs; final RxBool isOfficer = false.obs;
final RxBool isInitialized = false.obs; final RxBool isInitialized = false.obs;
final RxBool isEditMode = false.obs; final RxBool isEditMode = false.obs;
final RxBool isFetching = false.obs; // Track initial data loading
// Form controllers for edit mode // Form controllers for edit mode
late TextEditingController firstNameController; late TextEditingController firstNameController;
@ -76,6 +77,7 @@ class ProfileController extends BaseProfileController {
// Fetch the user profile data // Fetch the user profile data
Future<void> fetchUserProfile() async { Future<void> fetchUserProfile() async {
try { try {
isFetching.value = true; // Start fetching
setLoading(true); setLoading(true);
clearError(); clearError();
@ -98,6 +100,7 @@ class ProfileController extends BaseProfileController {
); );
} finally { } finally {
setLoading(false); setLoading(false);
isFetching.value = false; // Done fetching
} }
} }
@ -214,6 +217,7 @@ class ProfileController extends BaseProfileController {
// Fetch profile by user ID // Fetch profile by user ID
Future<void> fetchProfileByUserId(String userId) async { Future<void> fetchProfileByUserId(String userId) async {
try { try {
isFetching.value = true;
setLoading(true); setLoading(true);
clearError(); clearError();
@ -237,6 +241,8 @@ class ProfileController extends BaseProfileController {
officer.value = null; officer.value = null;
} }
} }
isInitialized.value = true;
} catch (e) { } catch (e) {
setError('Failed to load profile: ${e.toString()}'); setError('Failed to load profile: ${e.toString()}');
showError( showError(
@ -245,6 +251,7 @@ class ProfileController extends BaseProfileController {
); );
} finally { } finally {
setLoading(false); setLoading(false);
isFetching.value = false;
} }
} }

View File

@ -1,9 +1,17 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:logger/logger.dart';
import 'package:sigap/src/cores/services/biometric_service.dart';
import 'package:sigap/src/features/auth/data/repositories/authentication_repository.dart';
import 'package:sigap/src/features/personalization/presentasion/controllers/settings/base_settings_controller.dart'; import 'package:sigap/src/features/personalization/presentasion/controllers/settings/base_settings_controller.dart';
import 'package:sigap/src/utils/popups/loaders.dart';
class SecurityController extends BaseSettingsController { class SecurityController extends BaseSettingsController {
final _logger = Logger();
final _biometricService = Get.find<BiometricService>();
final _authRepo = Get.find<AuthenticationRepository>();
// Security settings // Security settings
final RxBool requireBiometric = true.obs; final RxBool requireBiometric = false.obs;
final RxBool enable2FA = false.obs; final RxBool enable2FA = false.obs;
final RxBool sendEmailAlerts = true.obs; final RxBool sendEmailAlerts = true.obs;
final RxBool sendPushNotification = false.obs; final RxBool sendPushNotification = false.obs;
@ -21,7 +29,64 @@ class SecurityController extends BaseSettingsController {
loadSettings(); loadSettings();
} }
// Toggle security setting // Toggle biometric security setting
Future<bool> toggleBiometricAuthentication(bool enable) async {
try {
setLoading(true);
if (enable) {
// Check if biometrics is available
if (!_biometricService.isBiometricAvailable.value) {
setError('Biometric authentication is not available on this device');
TLoaders.warningSnackBar(
title: 'Not Available',
message: 'Biometric authentication is not available on your device',
);
return false;
}
// Try to authenticate before enabling
bool authenticated = await _biometricService.authenticate(
reason: 'Authenticate to enable biometric login',
);
if (!authenticated) {
setError('Biometric authentication failed');
return false;
}
// Enable biometric login
await _biometricService.enableBiometricLogin();
requireBiometric.value = true;
TLoaders.successSnackBar(
title: 'Enabled',
message: 'Biometric authentication has been enabled',
);
} else {
// Disable biometric login
await _biometricService.disableBiometricLogin();
requireBiometric.value = false;
TLoaders.successSnackBar(
title: 'Disabled',
message: 'Biometric authentication has been disabled',
);
}
return true;
} catch (e) {
_logger.e('Error toggling biometric authentication: $e');
setError('Failed to change biometric settings: ${e.toString()}');
TLoaders.errorSnackBar(
title: 'Error',
message: 'Failed to change biometric settings',
);
return false;
} finally {
setLoading(false);
}
}
// Toggle other security settings
void toggleSecuritySetting(String setting, bool value) { void toggleSecuritySetting(String setting, bool value) {
switch (setting) { switch (setting) {
case 'require_biometric': case 'require_biometric':
@ -123,6 +188,10 @@ class SecurityController extends BaseSettingsController {
try { try {
setLoading(true); setLoading(true);
// Load biometric setting from actual service
requireBiometric.value =
await _biometricService.isBiometricLoginEnabled();
// TODO: Load security settings from persistent storage // TODO: Load security settings from persistent storage
final prefs = Get.find<dynamic>(); // Replace with your storage solution final prefs = Get.find<dynamic>(); // Replace with your storage solution
// requireBiometric.value = prefs.getBool('require_biometric') ?? true; // requireBiometric.value = prefs.getBool('require_biometric') ?? true;
@ -133,6 +202,7 @@ class SecurityController extends BaseSettingsController {
// passcodeLastChanged.value = prefs.getString('passcode_last_changed') ?? ''; // passcodeLastChanged.value = prefs.getString('passcode_last_changed') ?? '';
// backupCodes.value = prefs.getStringList('backup_codes') ?? []; // backupCodes.value = prefs.getStringList('backup_codes') ?? [];
} catch (e) { } catch (e) {
_logger.e('Failed to load security settings: $e');
setError('Failed to load security settings: ${e.toString()}'); setError('Failed to load security settings: ${e.toString()}');
} finally { } finally {
setLoading(false); setLoading(false);

View File

@ -1,8 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:sigap/src/features/daily-ops/data/models/models/officers_model.dart'; import 'package:sigap/src/features/daily-ops/data/models/models/officers_model.dart';
import 'package:sigap/src/features/personalization/data/models/models/profile_model.dart'; import 'package:sigap/src/features/personalization/data/models/models/profile_model.dart';
import 'package:sigap/src/features/personalization/data/models/models/users_model.dart'; import 'package:sigap/src/features/personalization/data/models/models/users_model.dart';
import 'package:sigap/src/features/personalization/presentasion/controllers/profile/profile_controller.dart';
import 'package:sigap/src/features/personalization/presentasion/pages/profile/widgets/officer_profile_detail.dart'; import 'package:sigap/src/features/personalization/presentasion/pages/profile/widgets/officer_profile_detail.dart';
import 'package:sigap/src/features/personalization/presentasion/pages/profile/widgets/profile_edit_form.dart';
import 'package:sigap/src/features/personalization/presentasion/pages/profile/widgets/user_profile_detail.dart'; import 'package:sigap/src/features/personalization/presentasion/pages/profile/widgets/user_profile_detail.dart';
class ProfileScreen extends StatelessWidget { class ProfileScreen extends StatelessWidget {
@ -10,6 +13,7 @@ class ProfileScreen extends StatelessWidget {
final ProfileModel? profile; final ProfileModel? profile;
final OfficerModel? officer; final OfficerModel? officer;
final bool isCurrentUser; final bool isCurrentUser;
final String? userId;
const ProfileScreen({ const ProfileScreen({
super.key, super.key,
@ -17,6 +21,7 @@ class ProfileScreen extends StatelessWidget {
this.profile, this.profile,
this.officer, this.officer,
this.isCurrentUser = false, this.isCurrentUser = false,
this.userId,
}); });
@override @override
@ -24,14 +29,21 @@ class ProfileScreen extends StatelessWidget {
final theme = Theme.of(context); final theme = Theme.of(context);
final screenHeight = MediaQuery.of(context).size.height; final screenHeight = MediaQuery.of(context).size.height;
// For demo purposes, using dummy data if no data is provided // Get the profile controller
final displayName = final controller = Get.find<ProfileController>();
profile?.fullName ?? user?.profile?.fullName ?? 'Anita Rose';
final location = _getLocationString(); // If userId is provided, fetch that specific profile
final avatarUrl = profile?.avatar ?? user?.profile?.avatar; if (userId != null && controller.user.value?.id != userId) {
final bio = WidgetsBinding.instance.addPostFrameCallback((_) {
profile?.bio ?? controller.fetchProfileByUserId(userId!);
'I am currently pursuing a major in Management Economics and Finance at the University of Guelph, Ontario, Canada. Please let me know if I can help you in any way'; });
}
// Otherwise if this is current user and not loaded yet, fetch current profile
else if (isCurrentUser && !controller.isInitialized.value) {
WidgetsBinding.instance.addPostFrameCallback((_) {
controller.fetchUserProfile();
});
}
return Scaffold( return Scaffold(
backgroundColor: const Color( backgroundColor: const Color(
@ -45,19 +57,99 @@ class ProfileScreen extends StatelessWidget {
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
), ),
actions: [ actions: [
CircleAvatar( if (isCurrentUser)
Obx(
() => CircleAvatar(
backgroundColor: theme.primaryColor.withOpacity(0.1), backgroundColor: theme.primaryColor.withOpacity(0.1),
child: IconButton( child: IconButton(
icon: Icon(Icons.person_outline, color: theme.primaryColor), icon: Icon(
controller.isEditMode.value
? Icons.close
: Icons.edit_outlined,
color: theme.primaryColor,
),
onPressed: () { onPressed: () {
// Show profile options controller.toggleEditMode();
}, },
), ),
), ),
),
const SizedBox(width: 16), const SizedBox(width: 16),
], ],
), ),
body: Stack( body: Obx(() {
// Show loading state
if (controller.isFetching.value) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(color: theme.primaryColor),
const SizedBox(height: 16),
Text('Loading profile...', style: theme.textTheme.bodyMedium),
],
),
);
}
// Show error state
if (controller.errorMessage.value.isNotEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 48, color: Colors.red),
const SizedBox(height: 16),
Text(
'Error loading profile',
style: theme.textTheme.titleMedium?.copyWith(
color: Colors.red,
),
),
const SizedBox(height: 8),
Text(
controller.errorMessage.value,
style: theme.textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () => controller.refreshProfile(),
child: Text('Retry'),
),
],
),
);
}
// Extract user data from controller or props
final userData = controller.user.value ?? user;
final profileData = controller.profile.value ?? profile;
final officerData = controller.officer.value ?? officer;
final isOfficerUser = controller.isOfficer.value || officerData != null;
// Check if we're in edit mode
if (controller.isEditMode.value && isCurrentUser) {
return ProfileEditForm(
profile: profileData,
user: userData,
officer: isOfficerUser ? officerData : null,
isOfficer: isOfficerUser,
controller: controller,
);
}
// For display purposes, using dummy data if no data is provided
final displayName =
profileData?.fullName ?? userData?.profile?.fullName ?? 'User';
final locationString = _getLocationString(profileData, officerData);
final avatarUrl =
profileData?.avatar ??
userData?.profile?.avatar ??
officerData?.avatar;
final bioText = profileData?.bio ?? 'No bio information available';
return Stack(
children: [ children: [
// Top section with avatar, name, and location // Top section with avatar, name, and location
Column( Column(
@ -112,7 +204,7 @@ class ProfileScreen extends StatelessWidget {
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
location, locationString,
style: theme.textTheme.bodyMedium?.copyWith( style: theme.textTheme.bodyMedium?.copyWith(
color: theme.hintColor, color: theme.hintColor,
), ),
@ -169,7 +261,7 @@ class ProfileScreen extends StatelessWidget {
Text('About', style: theme.textTheme.titleLarge), Text('About', style: theme.textTheme.titleLarge),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
bio, bioText,
style: theme.textTheme.bodyMedium?.copyWith( style: theme.textTheme.bodyMedium?.copyWith(
height: 1.5, height: 1.5,
color: theme.textTheme.bodyMedium?.color color: theme.textTheme.bodyMedium?.color
@ -180,42 +272,51 @@ class ProfileScreen extends StatelessWidget {
), ),
), ),
// User-specific information // User/Officer-specific information
officer != null isOfficerUser
? OfficerProfileDetail(officer: officer!) ? OfficerProfileDetail(officer: officerData!)
: UserProfileDetail(user: user, profile: profile), : UserProfileDetail(
user: userData,
profile: profileData,
),
], ],
), ),
), ),
), ),
), ),
], ],
), );
}),
); );
} }
String _getLocationString() { // Helper for getting location string from profile/officer data
String _getLocationString(ProfileModel? profile, OfficerModel? officer) {
if (officer?.placeOfBirth != null) { if (officer?.placeOfBirth != null) {
return officer!.placeOfBirth!; return officer!.placeOfBirth!;
} else if (profile?.placeOfBirth != null) { } else if (profile?.placeOfBirth != null &&
return profile!.placeOfBirth!; profile!.placeOfBirth!.isNotEmpty) {
return profile.placeOfBirth!;
} }
// Fallback to address if available // Fallback to address if available
if (profile?.address != null) { if (profile?.address != null) {
final address = profile!.address!; final address = profile!.address!;
final fullAddress = address['full_address'];
final city = address['city']; final city = address['city'];
final country = address['country']; final country = address['country'];
if (city != null && country != null) { if (fullAddress != null && fullAddress.toString().isNotEmpty) {
return fullAddress.toString();
} else if (city != null && country != null) {
return '$city, $country'; return '$city, $country';
} else if (city != null) { } else if (city != null) {
return city; return city.toString();
} else if (country != null) { } else if (country != null) {
return country; return country.toString();
} }
} }
return 'Ontario, Canada'; // Default fallback return 'Location not specified'; // Default fallback
} }
} }

View File

@ -22,22 +22,21 @@ class OfficerProfileDetail extends StatelessWidget {
const SizedBox(height: 16), const SizedBox(height: 16),
// Officer information // Officer information rows
_buildInfoRow( if (officer.nrp != null)
context, _buildInfoRow(context, 'NRP',
'NRP', officer.nrp.toString()),
officer.nrp?.toString() ?? 'Not Available',
),
if (officer.rank != null) if (officer.rank != null && officer.rank!.isNotEmpty)
_buildInfoRow(context, 'Rank', officer.rank!), _buildInfoRow(context, 'Rank', officer.rank!),
if (officer.position != null) if (officer.position != null && officer.position!.isNotEmpty)
_buildInfoRow(context, 'Position', officer.position!), _buildInfoRow(context, 'Position', officer.position!),
_buildInfoRow(context, 'Email', officer.email ?? 'Not Available'), if (officer.email != null && officer.email!.isNotEmpty)
_buildInfoRow(context, 'Email', officer.email!),
if (officer.phone != null) if (officer.phone != null && officer.phone!.isNotEmpty)
_buildInfoRow(context, 'Phone', officer.phone!), _buildInfoRow(context, 'Phone', officer.phone!),
if (officer.dateOfBirth != null) if (officer.dateOfBirth != null)
@ -47,12 +46,15 @@ class OfficerProfileDetail extends StatelessWidget {
DateFormat('dd MMMM yyyy').format(officer.dateOfBirth!), DateFormat('dd MMMM yyyy').format(officer.dateOfBirth!),
), ),
if (officer.placeOfBirth != null) if (officer.placeOfBirth != null && officer.placeOfBirth!.isNotEmpty)
_buildInfoRow(context, 'Place of Birth', officer.placeOfBirth!), _buildInfoRow(context, 'Place of Birth', officer.placeOfBirth!),
if (officer.unitId != null) if (officer.unitId != null && officer.unitId!.isNotEmpty)
_buildInfoRow(context, 'Unit ID', officer.unitId!), _buildInfoRow(context, 'Unit ID', officer.unitId!),
if (officer.patrolUnitId != null && officer.patrolUnitId!.isNotEmpty)
_buildInfoRow(context, 'Patrol Unit', officer.patrolUnitId!),
if (officer.validUntil != null) if (officer.validUntil != null)
_buildInfoRow( _buildInfoRow(
context, context,
@ -61,7 +63,14 @@ class OfficerProfileDetail extends StatelessWidget {
), ),
if (officer.role != null) if (officer.role != null)
_buildInfoRow(context, 'Role', officer.role!.name ?? 'Officer'), _buildInfoRow(context, 'Role', _formatRoleName(officer.role!.name)),
if (officer.createdAt != null)
_buildInfoRow(
context,
'Registered On',
DateFormat('dd MMMM yyyy').format(officer.createdAt!),
),
// Status information if banned // Status information if banned
if (officer.isBanned) ...[ if (officer.isBanned) ...[
@ -77,7 +86,12 @@ class OfficerProfileDetail extends StatelessWidget {
const SizedBox(height: 16), const SizedBox(height: 16),
_buildInfoRow(context, 'Status', 'Banned', valueColor: Colors.red), _buildInfoRow(
context,
'Status',
'Account Suspended',
valueColor: Colors.red,
),
_buildInfoRow( _buildInfoRow(
context, context,
@ -88,10 +102,34 @@ class OfficerProfileDetail extends StatelessWidget {
if (officer.bannedUntil != null) if (officer.bannedUntil != null)
_buildInfoRow( _buildInfoRow(
context, context,
'Banned Until', 'Suspended Until',
DateFormat('dd MMMM yyyy, HH:mm').format(officer.bannedUntil!), DateFormat('dd MMMM yyyy, HH:mm').format(officer.bannedUntil!),
), ),
], ],
// Patrol Stats
if (officer.panicStrike > 0 || officer.spoofingAttempts > 0) ...[
const SizedBox(height: 24),
Text('Patrol Statistics', style: theme.textTheme.titleLarge),
const SizedBox(height: 16),
if (officer.panicStrike > 0)
_buildInfoRow(
context,
'Panic Button Strike',
officer.panicStrike.toString(),
valueColor: officer.panicStrike > 2 ? Colors.orange : null,
),
if (officer.spoofingAttempts > 0)
_buildInfoRow(
context,
'Spoofing Attempts',
officer.spoofingAttempts.toString(),
valueColor: Colors.red,
),
],
], ],
), ),
); );
@ -128,4 +166,18 @@ class OfficerProfileDetail extends StatelessWidget {
), ),
); );
} }
// Format role name for better display
String _formatRoleName(String roleName) {
// Convert snake_case or kebab-case to Title Case
return roleName
.split(RegExp(r'[_\-]'))
.map(
(word) =>
word.isEmpty
? ''
: '${word[0].toUpperCase()}${word.substring(1)}',
)
.join(' ');
}
} }

View File

@ -0,0 +1,480 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:sigap/src/features/daily-ops/data/models/models/officers_model.dart';
import 'package:sigap/src/features/personalization/data/models/models/profile_model.dart';
import 'package:sigap/src/features/personalization/data/models/models/users_model.dart';
import 'package:sigap/src/features/personalization/presentasion/controllers/profile/profile_controller.dart';
import 'package:sigap/src/utils/constants/sizes.dart';
import 'package:sigap/src/utils/validators/validation.dart';
class ProfileEditForm extends StatelessWidget {
final ProfileModel? profile;
final UserModel? user;
final OfficerModel? officer;
final bool isOfficer;
final ProfileController controller;
const ProfileEditForm({
super.key,
required this.profile,
required this.user,
this.officer,
required this.isOfficer,
required this.controller,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Form(
key: controller.formKey,
child: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Avatar Edit Section
Center(
child: Column(
children: [
// Profile Avatar with edit button
Stack(
children: [
Container(
width: 100,
height: 100,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: theme.cardColor,
),
child: Center(
child:
profile?.avatar != null
? ClipRRect(
borderRadius: BorderRadius.circular(50),
child: Image.network(
profile!.avatar!,
width: 100,
height: 100,
fit: BoxFit.cover,
),
)
: Icon(
Icons.person,
size: 50,
color: theme.iconTheme.color,
),
),
),
Positioned(
right: 0,
bottom: 0,
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: theme.primaryColor,
shape: BoxShape.circle,
),
child: IconButton(
padding: EdgeInsets.zero,
icon: Icon(
Icons.camera_alt,
size: 18,
color: theme.colorScheme.onPrimary,
),
onPressed: () => _showImageSourceOptions(context),
),
),
),
],
),
],
),
),
const SizedBox(height: TSizes.spaceBtwSections),
// Fields section
Container(
padding: const EdgeInsets.only(bottom: TSizes.spaceBtwSections),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// First Name field
_buildTextField(
context: context,
controller: controller.firstNameController,
label: 'First Name',
validator:
(v) => TValidators.validateUserInput(
'First Name',
v,
50,
required: true,
),
),
// Last Name field
_buildTextField(
context: context,
controller: controller.lastNameController,
label: 'Last Name',
validator:
(v) =>
TValidators.validateUserInput('Last Name', v, 50),
),
// Bio field (multiline)
_buildTextField(
context: context,
controller: controller.bioController,
label: 'Bio',
validator:
(v) => TValidators.validateUserInput('Bio', v, 500),
maxLines: 3,
hint: 'Tell us about yourself...',
),
// Phone field
_buildTextField(
context: context,
controller: controller.phoneController,
label: 'Phone Number',
validator: TValidators.validatePhoneNumber,
keyboardType: TextInputType.phone,
hint: 'Enter phone number',
),
// Place of Birth field
_buildTextField(
context: context,
controller: controller.birthPlaceController,
label: 'Place of Birth',
validator:
(v) => TValidators.validateUserInput(
'Place of Birth',
v,
100,
),
hint: 'Enter your place of birth',
),
// Date of Birth picker
Padding(
padding: const EdgeInsets.only(
bottom: TSizes.spaceBtwInputFields,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Date of Birth',
style: theme.textTheme.labelLarge,
),
const SizedBox(height: 8),
InkWell(
onTap: () => _selectDate(context),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(
TSizes.inputFieldRadius,
),
decoration: BoxDecoration(
border: Border.all(color: theme.dividerColor),
borderRadius: BorderRadius.circular(
TSizes.buttonRadius,
),
),
child: Row(
children: [
Expanded(
child: Obx(
() => Text(
controller.birthDate.value != null
? DateFormat(
'dd MMM yyyy',
).format(controller.birthDate.value!)
: 'Select date of birth',
style:
controller.birthDate.value != null
? theme.textTheme.bodyMedium
: theme.textTheme.bodyMedium
?.copyWith(
color: theme.hintColor,
),
),
),
),
Icon(
Icons.calendar_today,
color: theme.hintColor,
size: 20,
),
],
),
),
),
],
),
),
],
),
),
// Officer-specific fields
if (isOfficer && officer != null) ...[
_buildSectionHeader('Officer Information', theme),
// NRP field
_buildReadOnlyField(
context: context,
label: 'NRP',
value: officer?.nrp?.toString() ?? 'Not Available',
),
// Rank field
_buildReadOnlyField(
context: context,
label: 'Rank',
value: officer?.rank ?? 'Not Available',
),
// Position field
_buildReadOnlyField(
context: context,
label: 'Position',
value: officer?.position ?? 'Not Available',
),
],
const SizedBox(height: TSizes.spaceBtwSections),
// Save button
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed:
controller.isLoading.value
? null
: () => controller.saveChanges(),
child:
controller.isLoading.value
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: theme.colorScheme.onPrimary,
),
)
: const Text('Save Changes'),
),
),
// Error message
Obx(
() =>
controller.errorMessage.value.isNotEmpty
? Container(
margin: const EdgeInsets.only(
top: TSizes.spaceBtwItems,
),
padding: const EdgeInsets.all(TSizes.md),
decoration: BoxDecoration(
color: theme.colorScheme.error.withOpacity(0.1),
borderRadius: BorderRadius.circular(
TSizes.cardRadiusMd,
),
),
child: Row(
children: [
Icon(
Icons.error_outline,
color: theme.colorScheme.error,
),
const SizedBox(width: TSizes.sm),
Expanded(
child: Text(
controller.errorMessage.value,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.error,
),
),
),
],
),
)
: const SizedBox.shrink(),
),
],
),
),
);
}
Widget _buildSectionHeader(String title, ThemeData theme) {
return Padding(
padding: const EdgeInsets.only(
bottom: TSizes.spaceBtwItems,
top: TSizes.spaceBtwSections,
),
child: Text(
title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
);
}
// Helper to build a text field
Widget _buildTextField({
required BuildContext context,
required String label,
TextEditingController? controller,
FormFieldValidator<String>? validator,
TextInputType keyboardType = TextInputType.text,
int maxLines = 1,
String? hint,
}) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.only(bottom: TSizes.spaceBtwInputFields),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: theme.textTheme.labelLarge),
const SizedBox(height: 8),
TextFormField(
controller: controller,
validator: validator,
keyboardType: keyboardType,
maxLines: maxLines,
style: theme.textTheme.bodyMedium,
decoration: InputDecoration(
hintText: hint,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(TSizes.buttonRadius),
),
contentPadding: const EdgeInsets.all(TSizes.md),
isDense: true,
),
),
],
),
);
}
// Helper to build a read-only field
Widget _buildReadOnlyField({
required BuildContext context,
required String label,
required String value,
}) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.only(bottom: TSizes.spaceBtwInputFields),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: theme.textTheme.labelLarge),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(TSizes.md),
decoration: BoxDecoration(
border: Border.all(color: theme.dividerColor),
borderRadius: BorderRadius.circular(TSizes.buttonRadius),
color: theme.disabledColor.withOpacity(0.1),
),
child: Text(value, style: theme.textTheme.bodyMedium),
),
],
),
);
}
// Date picker dialog
Future<void> _selectDate(BuildContext context) async {
final theme = Theme.of(context);
final DateTime? picked = await showDatePicker(
context: context,
initialDate: controller.birthDate.value ?? DateTime(2000),
firstDate: DateTime(1920),
lastDate: DateTime.now(),
builder: (context, child) {
return Theme(
data: Theme.of(context).copyWith(colorScheme: theme.colorScheme),
child: child!,
);
},
);
if (picked != null) {
controller.setBirthDate(picked);
}
}
// Image source picker
void _showImageSourceOptions(BuildContext context) {
final theme = Theme.of(context);
showModalBottomSheet(
context: context,
backgroundColor: theme.scaffoldBackgroundColor,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(TSizes.cardRadiusLg),
),
),
builder:
(context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: TSizes.sm),
// Drag handle
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: theme.disabledColor,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: TSizes.md),
ListTile(
leading: Icon(Icons.photo_camera, color: theme.primaryColor),
title: Text('Take a photo', style: theme.textTheme.bodyLarge),
onTap: () {
Navigator.pop(context);
// TODO: Implement camera functionality
},
),
ListTile(
leading: Icon(Icons.photo_library, color: theme.primaryColor),
title: Text(
'Choose from gallery',
style: theme.textTheme.bodyLarge,
),
onTap: () {
Navigator.pop(context);
// TODO: Implement gallery functionality
},
),
const SizedBox(height: TSizes.defaultSpace),
],
),
),
);
}
}

View File

@ -13,6 +13,26 @@ class UserProfileDetail extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
// Check if we have user data
if (user == null && profile == null) {
return Padding(
padding: const EdgeInsets.all(20.0),
child: Center(
child: Column(
children: [
Icon(Icons.person_off_outlined, size: 48, color: theme.hintColor),
const SizedBox(height: 16),
Text(
'User profile information not available',
style: theme.textTheme.bodyLarge,
textAlign: TextAlign.center,
),
],
),
),
);
}
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 30), padding: const EdgeInsets.fromLTRB(20, 8, 20, 30),
child: Column( child: Column(
@ -22,15 +42,22 @@ class UserProfileDetail extends StatelessWidget {
Text('Personal Information', style: theme.textTheme.titleLarge), Text('Personal Information', style: theme.textTheme.titleLarge),
const SizedBox(height: 16), const SizedBox(height: 16),
// User information // NIK (National ID)
if (profile?.nik != null) if (profile?.nik != null && profile!.nik!.isNotEmpty)
_buildInfoRow(context, 'NIK', profile!.nik!), _buildInfoRow(context, 'NIK', profile!.nik!),
_buildInfoRow(context, 'Email', user?.email ?? 'Not Available'), // Email
if (user?.email != null) _buildInfoRow(context, 'Email', user!.email),
if (user?.phone != null) // Username
if (profile?.username != null && profile!.username!.isNotEmpty)
_buildInfoRow(context, 'Username', profile!.username!),
// Phone
if (user?.phone != null && user!.phone!.isNotEmpty)
_buildInfoRow(context, 'Phone', user!.phone!), _buildInfoRow(context, 'Phone', user!.phone!),
// Birth Date
if (profile?.birthDate != null) if (profile?.birthDate != null)
_buildInfoRow( _buildInfoRow(
context, context,
@ -38,9 +65,21 @@ class UserProfileDetail extends StatelessWidget {
DateFormat('dd MMMM yyyy').format(profile!.birthDate!), DateFormat('dd MMMM yyyy').format(profile!.birthDate!),
), ),
if (profile?.placeOfBirth != null) // Place of Birth
if (profile?.placeOfBirth != null &&
profile!.placeOfBirth!.isNotEmpty)
_buildInfoRow(context, 'Place of Birth', profile!.placeOfBirth!), _buildInfoRow(context, 'Place of Birth', profile!.placeOfBirth!),
// Address
if (profile?.address != null &&
_getDisplayAddress(profile!.address!).isNotEmpty)
_buildInfoRow(
context,
'Address',
_getDisplayAddress(profile!.address!),
),
// Last Sign In
if (user?.lastSignInAt != null) if (user?.lastSignInAt != null)
_buildInfoRow( _buildInfoRow(
context, context,
@ -48,8 +87,21 @@ class UserProfileDetail extends StatelessWidget {
DateFormat('dd MMM yyyy, HH:mm').format(user!.lastSignInAt!), DateFormat('dd MMM yyyy, HH:mm').format(user!.lastSignInAt!),
), ),
// Account Created
if (user?.createdAt != null)
_buildInfoRow(
context,
'Account Created',
DateFormat('dd MMMM yyyy').format(user!.createdAt),
),
// Role
if (user?.role != null) if (user?.role != null)
_buildInfoRow(context, 'Role', user!.role!.name ?? 'User'), _buildInfoRow(
context,
'Role',
_formatRoleName(user!.role!.name ?? 'User'),
),
], ],
), ),
); );
@ -78,4 +130,35 @@ class UserProfileDetail extends StatelessWidget {
), ),
); );
} }
// Helper to get displayable address from address map
String _getDisplayAddress(Map<String, dynamic> addressMap) {
final fullAddress = addressMap['full_address']?.toString();
if (fullAddress != null && fullAddress.isNotEmpty) {
return fullAddress;
}
final parts = <String>[];
if (addressMap['street'] != null) parts.add(addressMap['street']);
if (addressMap['city'] != null) parts.add(addressMap['city']);
if (addressMap['state'] != null) parts.add(addressMap['state']);
if (addressMap['country'] != null) parts.add(addressMap['country']);
if (addressMap['postal_code'] != null) parts.add(addressMap['postal_code']);
return parts.join(', ');
}
// Format role name for better display
String _formatRoleName(String roleName) {
// Convert snake_case or kebab-case to Title Case
return roleName
.split(RegExp(r'[_\-]'))
.map(
(word) =>
word.isEmpty
? ''
: '${word[0].toUpperCase()}${word.substring(1)}',
)
.join(' ');
}
} }

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:sigap/src/features/personalization/presentasion/controllers/profile/profile_controller.dart';
import 'package:sigap/src/features/personalization/presentasion/controllers/settings/settings_controller.dart'; import 'package:sigap/src/features/personalization/presentasion/controllers/settings/settings_controller.dart';
import 'package:sigap/src/features/personalization/presentasion/pages/profile/profile_screen.dart'; import 'package:sigap/src/features/personalization/presentasion/pages/profile/profile_screen.dart';
import 'package:sigap/src/features/personalization/presentasion/pages/settings/widgets/contact_screen.dart'; import 'package:sigap/src/features/personalization/presentasion/pages/settings/widgets/contact_screen.dart';
@ -11,6 +12,7 @@ import 'package:sigap/src/features/personalization/presentasion/pages/settings/w
import 'package:sigap/src/features/personalization/presentasion/pages/settings/widgets/security_setting.dart'; import 'package:sigap/src/features/personalization/presentasion/pages/settings/widgets/security_setting.dart';
import 'package:sigap/src/features/personalization/presentasion/pages/settings/widgets/terms_services_screen.dart'; import 'package:sigap/src/features/personalization/presentasion/pages/settings/widgets/terms_services_screen.dart';
import 'package:sigap/src/features/personalization/presentasion/pages/settings/widgets/text_size_setting.dart'; import 'package:sigap/src/features/personalization/presentasion/pages/settings/widgets/text_size_setting.dart';
import 'package:sigap/src/utils/constants/sizes.dart';
class SettingsScreen extends StatefulWidget { class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key}); const SettingsScreen({super.key});
@ -24,11 +26,18 @@ class _SettingsScreenState extends State<SettingsScreen>
late TabController _tabController; late TabController _tabController;
// Use find instead of implicit creation with Get.put // Use find instead of implicit creation with Get.put
final SettingsController _settingsController = Get.find<SettingsController>(); final SettingsController _settingsController = Get.find<SettingsController>();
// Get the profile controller to access user data
final ProfileController _profileController = Get.find<ProfileController>();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_tabController = TabController(length: 3, vsync: this); _tabController = TabController(length: 3, vsync: this);
// Fetch user profile data if it's not already loaded
if (!_profileController.isInitialized.value) {
_profileController.fetchUserProfile();
}
} }
@override @override
@ -49,8 +58,48 @@ class _SettingsScreenState extends State<SettingsScreen>
), ),
body: Column( body: Column(
children: [ children: [
// Profile Section - Now clickable // Profile Section with real data
InkWell( Obx(() {
// Show loading profile if data is being fetched
if (_profileController.isFetching.value) {
return Container(
padding: const EdgeInsets.all(20),
color: theme.scaffoldBackgroundColor,
child: Center(
child: CircularProgressIndicator(color: theme.primaryColor),
),
);
}
// Show error state if there is an error
if (_profileController.errorMessage.value.isNotEmpty) {
return Container(
padding: const EdgeInsets.all(20),
color: theme.scaffoldBackgroundColor,
child: Center(
child: Text(
'Failed to load profile',
style: theme.textTheme.bodyMedium?.copyWith(
color: Colors.red,
),
),
),
);
}
// Get user data
final profile = _profileController.profile.value;
final user = _profileController.user.value;
final officer = _profileController.officer.value;
// Get display name and avatar
final displayName =
profile?.fullName ?? user?.email.split('@').first ?? 'User';
final username =
profile?.username ?? user?.email.split('@').first ?? '';
final avatarUrl = profile?.avatar ?? officer?.avatar;
return InkWell(
onTap: () { onTap: () {
// Navigate to the ProfileScreen when tapped // Navigate to the ProfileScreen when tapped
Navigator.push( Navigator.push(
@ -69,19 +118,24 @@ class _SettingsScreenState extends State<SettingsScreen>
CircleAvatar( CircleAvatar(
radius: 30, radius: 30,
backgroundColor: theme.hintColor, backgroundColor: theme.hintColor,
child: Icon( backgroundImage:
avatarUrl != null ? NetworkImage(avatarUrl) : null,
child:
avatarUrl == null
? Icon(
Icons.person, Icons.person,
size: 35, size: 35,
color: theme.scaffoldBackgroundColor, color: theme.scaffoldBackgroundColor,
), )
: null,
), ),
const SizedBox(width: 15), const SizedBox(width: 15),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('Anita Rose', style: theme.textTheme.titleLarge), Text(displayName, style: theme.textTheme.titleLarge),
Text('anitarose', style: theme.textTheme.bodySmall), Text(username, style: theme.textTheme.bodySmall),
], ],
), ),
), ),
@ -89,7 +143,8 @@ class _SettingsScreenState extends State<SettingsScreen>
], ],
), ),
), ),
), );
}),
// Tab Bar // Tab Bar
Container( Container(
@ -132,6 +187,7 @@ class _SettingsScreenState extends State<SettingsScreen>
return Container( return Container(
color: theme.scaffoldBackgroundColor, color: theme.scaffoldBackgroundColor,
padding: const EdgeInsets.symmetric(horizontal: TSizes.defaultSpace / 2),
child: ListView( child: ListView(
children: [ children: [
_buildSettingsItem( _buildSettingsItem(

View File

@ -1,5 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:sigap/src/features/personalization/presentasion/controllers/settings/security_controller.dart';
import 'package:sigap/src/features/personalization/presentasion/pages/settings/widgets/base_detail_screen.dart'; import 'package:sigap/src/features/personalization/presentasion/pages/settings/widgets/base_detail_screen.dart';
import 'package:sigap/src/shared/widgets/loaders/custom_circular_loader.dart';
class SecuritySettingsScreen extends StatefulWidget { class SecuritySettingsScreen extends StatefulWidget {
const SecuritySettingsScreen({super.key}); const SecuritySettingsScreen({super.key});
@ -9,28 +12,40 @@ class SecuritySettingsScreen extends StatefulWidget {
} }
class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> { class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
bool requireBiometric = true; // Get the controller
bool enable2FA = false; final SecurityController controller = Get.put(SecurityController());
bool sendEmailAlerts = true;
bool sendPushNotification = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BaseDetailScreen( return BaseDetailScreen(
title: 'Security Settings', title: 'Security Settings',
content: ListView( content: Obx(() {
if (controller.isLoading.value) {
return const Center(child: DCircularLoader());
}
return ListView(
children: [ children: [
_buildSectionHeader('App Lock'), _buildSectionHeader('App Lock'),
_buildSwitchItem( Obx(
() => _buildSwitchItem(
icon: Icons.fingerprint_outlined, icon: Icons.fingerprint_outlined,
title: 'Require biometric', title: 'Biometric Authentication',
value: requireBiometric, subtitle: 'Use your fingerprint or face to sign in',
onChanged: (value) { value: controller.requireBiometric.value,
setState(() { onChanged: (value) async {
requireBiometric = value; // Show loading
}); final wasLoading = controller.isLoading.value;
if (!wasLoading) controller.setLoading(true);
// Toggle biometric
await controller.toggleBiometricAuthentication(value);
// Hide loading if we showed it
if (!wasLoading) controller.setLoading(false);
}, },
), ),
),
_buildNavigationItem( _buildNavigationItem(
icon: Icons.lock_outline, icon: Icons.lock_outline,
title: 'Change passcode', title: 'Change passcode',
@ -38,16 +53,16 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
), ),
_buildSectionHeader('Two-Factor Authentication'), _buildSectionHeader('Two-Factor Authentication'),
_buildSwitchItem( Obx(
() => _buildSwitchItem(
icon: Icons.security_outlined, icon: Icons.security_outlined,
title: 'Enable 2FA', title: 'Enable 2FA',
value: enable2FA, value: controller.enable2FA.value,
onChanged: (value) { onChanged: (value) {
setState(() { controller.toggleSecuritySetting('enable_2fa', value);
enable2FA = value;
});
}, },
), ),
),
_buildNavigationItem( _buildNavigationItem(
icon: Icons.backup_outlined, icon: Icons.backup_outlined,
title: 'Manage backup codes', title: 'Manage backup codes',
@ -55,28 +70,32 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
), ),
_buildSectionHeader('Login Alerts'), _buildSectionHeader('Login Alerts'),
_buildSwitchItem( Obx(
() => _buildSwitchItem(
icon: Icons.email_outlined, icon: Icons.email_outlined,
title: 'Send email alerts', title: 'Send email alerts',
value: sendEmailAlerts, value: controller.sendEmailAlerts.value,
onChanged: (value) { onChanged: (value) {
setState(() { controller.toggleSecuritySetting('send_email_alerts', value);
sendEmailAlerts = value;
});
}, },
), ),
_buildSwitchItem( ),
Obx(
() => _buildSwitchItem(
icon: Icons.notifications_outlined, icon: Icons.notifications_outlined,
title: 'Send push notification', title: 'Send push notification',
value: sendPushNotification, value: controller.sendPushNotification.value,
onChanged: (value) { onChanged: (value) {
setState(() { controller.toggleSecuritySetting(
sendPushNotification = value; 'send_push_notification',
}); value,
);
}, },
), ),
],
), ),
],
);
}),
); );
} }
@ -120,6 +139,7 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
Widget _buildSwitchItem({ Widget _buildSwitchItem({
required IconData icon, required IconData icon,
required String title, required String title,
String? subtitle,
required bool value, required bool value,
required ValueChanged<bool> onChanged, required ValueChanged<bool> onChanged,
}) { }) {
@ -131,7 +151,21 @@ class _SecuritySettingsScreenState extends State<SecuritySettingsScreen> {
children: [ children: [
Icon(icon, size: 24, color: theme.iconTheme.color), Icon(icon, size: 24, color: theme.iconTheme.color),
const SizedBox(width: 15), const SizedBox(width: 15),
Expanded(child: Text(title, style: theme.textTheme.bodyLarge)), Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: theme.textTheme.bodyLarge),
if (subtitle != null)
Text(
subtitle,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.textTheme.bodySmall?.color?.withOpacity(0.7),
),
),
],
),
),
Switch( Switch(
value: value, value: value,
onChanged: onChanged, onChanged: onChanged,

View File

@ -0,0 +1,81 @@
import 'package:flutter/material.dart';
import 'package:sigap/src/utils/helpers/helper_functions.dart';
class DCircularLoader extends StatelessWidget {
final double? size;
final double width;
final Color? color;
final String? text;
final TextStyle? textStyle;
final MainAxisAlignment alignment;
const DCircularLoader({
super.key,
this.size,
this.width = 2.0,
this.color,
this.text,
this.textStyle,
this.alignment = MainAxisAlignment.center,
});
@override
Widget build(BuildContext context) {
final isDarkMode = THelperFunctions.isDarkMode(context);
final loaderColor =
color ??
(isDarkMode
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.primary);
return Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: alignment,
children: [
SizedBox(
width: size,
height: size,
child: CircularProgressIndicator(
strokeWidth: width,
valueColor: AlwaysStoppedAnimation<Color>(loaderColor),
),
),
if (text != null) ...[
const SizedBox(height: 16),
Text(
text!,
style:
textStyle ??
Theme.of(context).textTheme.bodyMedium?.copyWith(
color: isDarkMode ? Colors.white70 : Colors.black54,
),
textAlign: TextAlign.center,
),
],
],
);
}
}
/// A utility class providing different variations of custom loaders
class TLoader {
/// Returns a full-screen loader with optional text
static Widget fullScreenLoader({String? text}) {
return Center(child: DCircularLoader(text: text));
}
/// Returns a small loader for buttons or small components
static Widget smallLoader({Color? color, double size = 20}) {
return DCircularLoader(size: size, width: 1.5, color: color);
}
/// Returns a text loader with a message
static Widget textLoader({required String text, Color? color}) {
return DCircularLoader(text: text, color: color);
}
/// Returns a centered loader with configurable size
static Widget centeredLoader({double? size, Color? color}) {
return Center(child: DCircularLoader(size: size, color: color));
}
}