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 {
if (!await _biometricService.isBiometricLoginEnabled()) {
return false;
}
String? refreshToken = await _biometricService.attemptBiometricLogin();
if (refreshToken == null || refreshToken.isEmpty) {
return false;
}
try { try {
// Use the refresh token to recover the session if (!await _biometricService.isBiometricLoginEnabled()) {
final response = await _supabase.auth.refreshSession(refreshToken); return false;
if (response.session != null) {
Get.offAllNamed(AppRoutes.explore);
return true;
} }
return false;
String? sessionToken = await _biometricService.attemptBiometricLogin();
if (sessionToken == null || sessionToken.isEmpty) {
return false;
}
// Use the session token to restore the session
final response = await restoreSession(sessionToken);
return response;
} 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;
@ -32,18 +33,18 @@ class ProfileController extends BaseProfileController {
// Original data for comparison // Original data for comparison
ProfileModel? originalProfile; ProfileModel? originalProfile;
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
// Initialize text controllers // Initialize text controllers
firstNameController = TextEditingController(); firstNameController = TextEditingController();
lastNameController = TextEditingController(); lastNameController = TextEditingController();
bioController = TextEditingController(); bioController = TextEditingController();
phoneController = TextEditingController(); phoneController = TextEditingController();
birthPlaceController = TextEditingController(); birthPlaceController = TextEditingController();
// Load initial data // Load initial data
loadData(); loadData();
} }
@ -67,7 +68,7 @@ class ProfileController extends BaseProfileController {
_loadDataToFormFields(); _loadDataToFormFields();
} }
} }
// Reload profile data // Reload profile data
Future<void> refreshProfile() async { Future<void> refreshProfile() async {
await fetchUserProfile(); await fetchUserProfile();
@ -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
} }
} }
@ -107,7 +110,7 @@ class ProfileController extends BaseProfileController {
// Get user and profile data // Get user and profile data
final userData = await _userRepository.getCurrentUserData(); final userData = await _userRepository.getCurrentUserData();
user.value = userData; user.value = userData;
// If profile exists in user data, use it // If profile exists in user data, use it
if (userData.profile != null) { if (userData.profile != null) {
profile.value = userData.profile; profile.value = userData.profile;
@ -129,7 +132,7 @@ class ProfileController extends BaseProfileController {
// Get officer data // Get officer data
final officerData = await _officerRepository.getOfficerData(); final officerData = await _officerRepository.getOfficerData();
officer.value = officerData; officer.value = officerData;
// Get additional user data // Get additional user data
final userData = await _userRepository.getCurrentUserData(); final userData = await _userRepository.getCurrentUserData();
user.value = userData; user.value = userData;
@ -139,7 +142,7 @@ class ProfileController extends BaseProfileController {
setError('Failed to load officer data: ${e.toString()}'); setError('Failed to load officer data: ${e.toString()}');
} }
} }
// Toggle edit mode // Toggle edit mode
void toggleEditMode() { void toggleEditMode() {
isEditMode.value = !isEditMode.value; isEditMode.value = !isEditMode.value;
@ -149,7 +152,7 @@ class ProfileController extends BaseProfileController {
hasChanges.value = false; hasChanges.value = false;
} }
} }
// Load current data into form fields // Load current data into form fields
void _loadDataToFormFields() { void _loadDataToFormFields() {
if (profile.value != null) { if (profile.value != null) {
@ -163,31 +166,31 @@ class ProfileController extends BaseProfileController {
if (user.value != null) { if (user.value != null) {
phoneController.text = user.value!.phone ?? ''; phoneController.text = user.value!.phone ?? '';
} }
// Setup listeners to track changes // Setup listeners to track changes
_setupTextChangeListeners(); _setupTextChangeListeners();
} }
// Setup listeners for text controllers to track changes // Setup listeners for text controllers to track changes
void _setupTextChangeListeners() { void _setupTextChangeListeners() {
void onTextChanged() { void onTextChanged() {
_checkForChanges(); _checkForChanges();
} }
firstNameController.addListener(onTextChanged); firstNameController.addListener(onTextChanged);
lastNameController.addListener(onTextChanged); lastNameController.addListener(onTextChanged);
bioController.addListener(onTextChanged); bioController.addListener(onTextChanged);
phoneController.addListener(onTextChanged); phoneController.addListener(onTextChanged);
birthPlaceController.addListener(onTextChanged); birthPlaceController.addListener(onTextChanged);
} }
// Check for changes in form data // Check for changes in form data
void _checkForChanges() { void _checkForChanges() {
if (profile.value == null) { if (profile.value == null) {
hasChanges.value = false; hasChanges.value = false;
return; return;
} }
// Check profile fields // Check profile fields
bool profileChanged = bool profileChanged =
firstNameController.text != (profile.value!.firstName ?? '') || firstNameController.text != (profile.value!.firstName ?? '') ||
@ -195,25 +198,26 @@ class ProfileController extends BaseProfileController {
bioController.text != (profile.value!.bio ?? '') || bioController.text != (profile.value!.bio ?? '') ||
birthPlaceController.text != (profile.value!.placeOfBirth ?? '') || birthPlaceController.text != (profile.value!.placeOfBirth ?? '') ||
birthDate.value != profile.value!.birthDate; birthDate.value != profile.value!.birthDate;
// Check phone // Check phone
bool phoneChanged = false; bool phoneChanged = false;
if (user.value != null) { if (user.value != null) {
phoneChanged = phoneController.text != (user.value!.phone ?? ''); phoneChanged = phoneController.text != (user.value!.phone ?? '');
} }
hasChanges.value = profileChanged || phoneChanged; hasChanges.value = profileChanged || phoneChanged;
} }
// Set birth date // Set birth date
void setBirthDate(DateTime? date) { void setBirthDate(DateTime? date) {
birthDate.value = date; birthDate.value = date;
_checkForChanges(); _checkForChanges();
} }
// 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;
} }
} }
@ -266,17 +273,17 @@ class ProfileController extends BaseProfileController {
setLoading(false); setLoading(false);
} }
} }
// Save changes // Save changes
@override @override
Future<bool> saveChanges() async { Future<bool> saveChanges() async {
if (!formKey.currentState!.validate()) { if (!formKey.currentState!.validate()) {
return false; return false;
} }
try { try {
setLoading(true); setLoading(true);
// Update profile data // Update profile data
if (profile.value != null) { if (profile.value != null) {
final updatedProfile = profile.value!.copyWith( final updatedProfile = profile.value!.copyWith(
@ -286,17 +293,17 @@ class ProfileController extends BaseProfileController {
placeOfBirth: birthPlaceController.text.trim(), placeOfBirth: birthPlaceController.text.trim(),
birthDate: birthDate.value, birthDate: birthDate.value,
); );
await _profileRepository.updateProfile(updatedProfile); await _profileRepository.updateProfile(updatedProfile);
profile.value = updatedProfile; profile.value = updatedProfile;
} }
// Update phone if changed // Update phone if changed
final phone = phoneController.text.trim(); final phone = phoneController.text.trim();
if (user.value != null && phone != user.value!.phone) { if (user.value != null && phone != user.value!.phone) {
await _userRepository.updateUserPhone(phone); await _userRepository.updateUserPhone(phone);
} }
await refreshProfile(); await refreshProfile();
hasChanges.value = false; hasChanges.value = false;
isEditMode.value = false; isEditMode.value = false;
@ -310,7 +317,7 @@ class ProfileController extends BaseProfileController {
setLoading(false); setLoading(false);
} }
} }
// Discard changes // Discard changes
@override @override
void discardChanges() { void discardChanges() {

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,22 +21,30 @@ class ProfileScreen extends StatelessWidget {
this.profile, this.profile,
this.officer, this.officer,
this.isCurrentUser = false, this.isCurrentUser = false,
this.userId,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final screenHeight = MediaQuery.of(context).size.height; final screenHeight = MediaQuery.of(context).size.height;
// Get the profile controller
final controller = Get.find<ProfileController>();
// For demo purposes, using dummy data if no data is provided // If userId is provided, fetch that specific profile
final displayName = if (userId != null && controller.user.value?.id != userId) {
profile?.fullName ?? user?.profile?.fullName ?? 'Anita Rose'; WidgetsBinding.instance.addPostFrameCallback((_) {
final location = _getLocationString(); controller.fetchProfileByUserId(userId!);
final avatarUrl = profile?.avatar ?? user?.profile?.avatar; });
final bio = }
profile?.bio ?? // Otherwise if this is current user and not loaded yet, fetch current profile
'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'; else if (isCurrentUser && !controller.isInitialized.value) {
WidgetsBinding.instance.addPostFrameCallback((_) {
controller.fetchUserProfile();
});
}
return Scaffold( return Scaffold(
backgroundColor: const Color( backgroundColor: const Color(
0xFFF2F2F7, 0xFFF2F2F7,
@ -45,177 +57,266 @@ class ProfileScreen extends StatelessWidget {
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
), ),
actions: [ actions: [
CircleAvatar( if (isCurrentUser)
backgroundColor: theme.primaryColor.withOpacity(0.1), Obx(
child: IconButton( () => CircleAvatar(
icon: Icon(Icons.person_outline, color: theme.primaryColor), backgroundColor: theme.primaryColor.withOpacity(0.1),
onPressed: () { child: IconButton(
// Show profile options icon: Icon(
}, controller.isEditMode.value
? Icons.close
: Icons.edit_outlined,
color: theme.primaryColor,
),
onPressed: () {
controller.toggleEditMode();
},
),
),
), ),
),
const SizedBox(width: 16), const SizedBox(width: 16),
], ],
), ),
body: Stack( body: Obx(() {
children: [ // Show loading state
// Top section with avatar, name, and location if (controller.isFetching.value) {
Column( return Center(
children: [ child: Column(
SizedBox(height: screenHeight * 0.05), mainAxisAlignment: MainAxisAlignment.center,
// Profile Avatar children: [
Center( CircularProgressIndicator(color: theme.primaryColor),
child: Container( const SizedBox(height: 16),
decoration: BoxDecoration( Text('Loading profile...', style: theme.textTheme.bodyMedium),
shape: BoxShape.circle, ],
boxShadow: [ ),
BoxShadow( );
color: Colors.black.withOpacity(0.1), }
blurRadius: 10,
spreadRadius: 1, // Show error state
), if (controller.errorMessage.value.isNotEmpty) {
], return Center(
), child: Column(
child: CircleAvatar( mainAxisAlignment: MainAxisAlignment.center,
radius: 50, children: [
backgroundColor: Colors.grey.shade300, Icon(Icons.error_outline, size: 48, color: Colors.red),
backgroundImage: const SizedBox(height: 16),
avatarUrl != null ? NetworkImage(avatarUrl) : null, Text(
child: 'Error loading profile',
avatarUrl == null style: theme.textTheme.titleMedium?.copyWith(
? const Icon( color: Colors.red,
Icons.person,
size: 50,
color: Colors.white,
)
: null,
), ),
), ),
), const SizedBox(height: 8),
const SizedBox(height: 16), Text(
// Name controller.errorMessage.value,
Text( style: theme.textTheme.bodyMedium,
displayName, textAlign: TextAlign.center,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
), ),
), const SizedBox(height: 24),
const SizedBox(height: 4), ElevatedButton(
// Location with icon onPressed: () => controller.refreshProfile(),
Row( child: Text('Retry'),
mainAxisSize: MainAxisSize.min, ),
children: [ ],
Icon( ),
Icons.location_on_outlined, );
size: 16, }
color: theme.hintColor,
// 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: [
// Top section with avatar, name, and location
Column(
children: [
SizedBox(height: screenHeight * 0.05),
// Profile Avatar
Center(
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
spreadRadius: 1,
),
],
),
child: CircleAvatar(
radius: 50,
backgroundColor: Colors.grey.shade300,
backgroundImage:
avatarUrl != null ? NetworkImage(avatarUrl) : null,
child:
avatarUrl == null
? const Icon(
Icons.person,
size: 50,
color: Colors.white,
)
: null,
),
), ),
const SizedBox(width: 4), ),
Text( const SizedBox(height: 16),
location, // Name
style: theme.textTheme.bodyMedium?.copyWith( Text(
displayName,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
// Location with icon
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.location_on_outlined,
size: 16,
color: theme.hintColor, color: theme.hintColor,
), ),
), const SizedBox(width: 4),
], Text(
), locationString,
], style: theme.textTheme.bodyMedium?.copyWith(
), color: theme.hintColor,
),
// Bottom sheet with profile details ),
Positioned( ],
left: 0,
right: 0,
bottom: 0,
top: screenHeight * 0.32, // Adjust position to show top content
child: Container(
decoration: BoxDecoration(
color: theme.cardColor,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(25),
), ),
boxShadow: [ ],
BoxShadow( ),
color: Colors.black.withOpacity(0.05),
blurRadius: 10, // Bottom sheet with profile details
offset: const Offset(0, -2), Positioned(
left: 0,
right: 0,
bottom: 0,
top: screenHeight * 0.32, // Adjust position to show top content
child: Container(
decoration: BoxDecoration(
color: theme.cardColor,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(25),
), ),
], boxShadow: [
), BoxShadow(
child: SingleChildScrollView( color: Colors.black.withOpacity(0.05),
child: Column( blurRadius: 10,
crossAxisAlignment: CrossAxisAlignment.start, offset: const Offset(0, -2),
children: [ ),
const SizedBox(height: 6), ],
// Center gray line indicator for draggable bottom sheet look ),
Center( child: SingleChildScrollView(
child: Container( child: Column(
height: 4, crossAxisAlignment: CrossAxisAlignment.start,
width: 40, children: [
decoration: BoxDecoration( const SizedBox(height: 6),
color: Colors.grey.withOpacity(0.3), // Center gray line indicator for draggable bottom sheet look
borderRadius: BorderRadius.circular(10), Center(
child: Container(
height: 4,
width: 40,
decoration: BoxDecoration(
color: Colors.grey.withOpacity(0.3),
borderRadius: BorderRadius.circular(10),
),
), ),
), ),
), const SizedBox(height: 16),
const SizedBox(height: 16),
// About Section // About Section
Padding( Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 16), padding: const EdgeInsets.fromLTRB(20, 0, 20, 16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
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
?.withOpacity(0.8), ?.withOpacity(0.8),
),
), ),
), ],
], ),
), ),
),
// 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

@ -17,27 +17,26 @@ class OfficerProfileDetail extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const SizedBox(height: 10), const SizedBox(height: 10),
Text('Official Information', style: theme.textTheme.titleLarge), Text('Official Information', style: theme.textTheme.titleLarge),
const SizedBox(height: 16), const SizedBox(height: 16),
// Officer information rows
if (officer.nrp != null)
_buildInfoRow(context, 'NRP',
officer.nrp.toString()),
// Officer information if (officer.rank != null && officer.rank!.isNotEmpty)
_buildInfoRow(
context,
'NRP',
officer.nrp?.toString() ?? 'Not Available',
),
if (officer.rank != null)
_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,11 +46,14 @@ 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(
@ -61,12 +63,19 @@ 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) ...[
const SizedBox(height: 24), const SizedBox(height: 24),
Row( Row(
children: [ children: [
Icon(Icons.warning_amber_rounded, color: Colors.red, size: 20), Icon(Icons.warning_amber_rounded, color: Colors.red, size: 20),
@ -74,10 +83,15 @@ class OfficerProfileDetail extends StatelessWidget {
Text('Status Information', style: theme.textTheme.titleLarge), Text('Status Information', style: theme.textTheme.titleLarge),
], ],
), ),
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,47 +58,93 @@ class _SettingsScreenState extends State<SettingsScreen>
), ),
body: Column( body: Column(
children: [ children: [
// Profile Section - Now clickable // Profile Section with real data
InkWell( Obx(() {
onTap: () { // Show loading profile if data is being fetched
// Navigate to the ProfileScreen when tapped if (_profileController.isFetching.value) {
Navigator.push( return Container(
context, padding: const EdgeInsets.all(20),
MaterialPageRoute( color: theme.scaffoldBackgroundColor,
builder: child: Center(
(context) => const ProfileScreen(isCurrentUser: true), child: CircularProgressIndicator(color: theme.primaryColor),
), ),
); );
}, }
child: Container(
padding: const EdgeInsets.all(20), // Show error state if there is an error
color: theme.scaffoldBackgroundColor, if (_profileController.errorMessage.value.isNotEmpty) {
child: Row( return Container(
children: [ padding: const EdgeInsets.all(20),
CircleAvatar( color: theme.scaffoldBackgroundColor,
radius: 30, child: Center(
backgroundColor: theme.hintColor, child: Text(
child: Icon( 'Failed to load profile',
Icons.person, style: theme.textTheme.bodyMedium?.copyWith(
size: 35, color: Colors.red,
color: theme.scaffoldBackgroundColor,
), ),
), ),
const SizedBox(width: 15), ),
Expanded( );
child: Column( }
crossAxisAlignment: CrossAxisAlignment.start,
children: [ // Get user data
Text('Anita Rose', style: theme.textTheme.titleLarge), final profile = _profileController.profile.value;
Text('anitarose', style: theme.textTheme.bodySmall), 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: () {
// Navigate to the ProfileScreen when tapped
Navigator.push(
context,
MaterialPageRoute(
builder:
(context) => const ProfileScreen(isCurrentUser: true),
), ),
Icon(Icons.chevron_right, color: theme.hintColor), );
], },
child: Container(
padding: const EdgeInsets.all(20),
color: theme.scaffoldBackgroundColor,
child: Row(
children: [
CircleAvatar(
radius: 30,
backgroundColor: theme.hintColor,
backgroundImage:
avatarUrl != null ? NetworkImage(avatarUrl) : null,
child:
avatarUrl == null
? Icon(
Icons.person,
size: 35,
color: theme.scaffoldBackgroundColor,
)
: null,
),
const SizedBox(width: 15),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(displayName, style: theme.textTheme.titleLarge),
Text(username, style: theme.textTheme.bodySmall),
],
),
),
Icon(Icons.chevron_right, color: theme.hintColor),
],
),
), ),
), );
), }),
// 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,74 +12,90 @@ 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(() {
children: [ if (controller.isLoading.value) {
_buildSectionHeader('App Lock'), return const Center(child: DCircularLoader());
_buildSwitchItem( }
icon: Icons.fingerprint_outlined,
title: 'Require biometric',
value: requireBiometric,
onChanged: (value) {
setState(() {
requireBiometric = value;
});
},
),
_buildNavigationItem(
icon: Icons.lock_outline,
title: 'Change passcode',
onTap: () {},
),
_buildSectionHeader('Two-Factor Authentication'), return ListView(
_buildSwitchItem( children: [
icon: Icons.security_outlined, _buildSectionHeader('App Lock'),
title: 'Enable 2FA', Obx(
value: enable2FA, () => _buildSwitchItem(
onChanged: (value) { icon: Icons.fingerprint_outlined,
setState(() { title: 'Biometric Authentication',
enable2FA = value; subtitle: 'Use your fingerprint or face to sign in',
}); value: controller.requireBiometric.value,
}, onChanged: (value) async {
), // Show loading
_buildNavigationItem( final wasLoading = controller.isLoading.value;
icon: Icons.backup_outlined, if (!wasLoading) controller.setLoading(true);
title: 'Manage backup codes',
onTap: () {},
),
_buildSectionHeader('Login Alerts'), // Toggle biometric
_buildSwitchItem( await controller.toggleBiometricAuthentication(value);
icon: Icons.email_outlined,
title: 'Send email alerts', // Hide loading if we showed it
value: sendEmailAlerts, if (!wasLoading) controller.setLoading(false);
onChanged: (value) { },
setState(() { ),
sendEmailAlerts = value; ),
}); _buildNavigationItem(
}, icon: Icons.lock_outline,
), title: 'Change passcode',
_buildSwitchItem( onTap: () {},
icon: Icons.notifications_outlined, ),
title: 'Send push notification',
value: sendPushNotification, _buildSectionHeader('Two-Factor Authentication'),
onChanged: (value) { Obx(
setState(() { () => _buildSwitchItem(
sendPushNotification = value; icon: Icons.security_outlined,
}); title: 'Enable 2FA',
}, value: controller.enable2FA.value,
), onChanged: (value) {
], controller.toggleSecuritySetting('enable_2fa', value);
), },
),
),
_buildNavigationItem(
icon: Icons.backup_outlined,
title: 'Manage backup codes',
onTap: () {},
),
_buildSectionHeader('Login Alerts'),
Obx(
() => _buildSwitchItem(
icon: Icons.email_outlined,
title: 'Send email alerts',
value: controller.sendEmailAlerts.value,
onChanged: (value) {
controller.toggleSecuritySetting('send_email_alerts', value);
},
),
),
Obx(
() => _buildSwitchItem(
icon: Icons.notifications_outlined,
title: 'Send push notification',
value: controller.sendPushNotification.value,
onChanged: (value) {
controller.toggleSecuritySetting(
'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));
}
}