diff --git a/sigap-mobile/lib/main.dart b/sigap-mobile/lib/main.dart index f208464..80c294c 100644 --- a/sigap-mobile/lib/main.dart +++ b/sigap-mobile/lib/main.dart @@ -4,6 +4,7 @@ import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:get/get.dart'; import 'package:get_storage/get_storage.dart'; import 'package:mapbox_maps_flutter/mapbox_maps_flutter.dart'; +import 'package:sigap/app.dart'; import 'package:sigap/navigation_menu.dart'; import 'package:sigap/src/features/personalization/presentasion/bindings/personalization_bindings.dart'; import 'package:sigap/src/utils/theme/theme.dart'; @@ -48,22 +49,6 @@ Future main() async { 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(), - ); - } -} diff --git a/sigap-mobile/lib/src/cores/bindings/controller_bindings.dart b/sigap-mobile/lib/src/cores/bindings/controller_bindings.dart index d86329a..e4eab58 100644 --- a/sigap-mobile/lib/src/cores/bindings/controller_bindings.dart +++ b/sigap-mobile/lib/src/cores/bindings/controller_bindings.dart @@ -1,6 +1,7 @@ import 'package:get/get.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/personalization/presentasion/bindings/personalization_bindings.dart'; class ControllerBindings extends Bindings { @override @@ -13,5 +14,8 @@ class ControllerBindings extends Bindings { // Auth Bindings AuthControllerBindings().dependencies(); + // Personalization Bindings + PersonalizationBindings().dependencies(); + } } diff --git a/sigap-mobile/lib/src/cores/routes/app_pages.dart b/sigap-mobile/lib/src/cores/routes/app_pages.dart index b9e4042..b83d124 100644 --- a/sigap-mobile/lib/src/cores/routes/app_pages.dart +++ b/sigap-mobile/lib/src/cores/routes/app_pages.dart @@ -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/role-selection/role_signup_pageview.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'; class AppPages { @@ -67,6 +69,20 @@ class AppPages { page: () => const LivenessDetectionPage(), ), + // Personalization + GetPage( + name: AppRoutes.settings, + page: () => const SettingsScreen(), + preventDuplicates: false, + + ), + + GetPage( + name: AppRoutes.profile, + page: () => const ProfileScreen(isCurrentUser: true), + ), + + ]; } diff --git a/sigap-mobile/lib/src/features/auth/data/repositories/authentication_repository.dart b/sigap-mobile/lib/src/features/auth/data/repositories/authentication_repository.dart index 5f235da..157b43a 100644 --- a/sigap-mobile/lib/src/features/auth/data/repositories/authentication_repository.dart +++ b/sigap-mobile/lib/src/features/auth/data/repositories/authentication_repository.dart @@ -24,6 +24,7 @@ class AuthenticationRepository extends GetxController { final _supabase = SupabaseService.instance.client; final _locationService = LocationService.instance; final _biometricService = Get.find(); + final _logger = Logger(); // Getters that use the Supabase service User? get authUser => SupabaseService.instance.currentUser; @@ -41,26 +42,23 @@ class AuthenticationRepository extends GetxController { // Check for biometric login on app start Future attemptBiometricLogin() async { - if (!await _biometricService.isBiometricLoginEnabled()) { - return false; - } - - String? refreshToken = await _biometricService.attemptBiometricLogin(); - if (refreshToken == null || refreshToken.isEmpty) { - return false; - } - try { - // Use the refresh token to recover the session - final response = await _supabase.auth.refreshSession(refreshToken); - if (response.session != null) { - Get.offAllNamed(AppRoutes.explore); - return true; + if (!await _biometricService.isBiometricLoginEnabled()) { + return false; } - 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) { - // If refresh token is invalid or expired, disable biometric login + // If token is invalid or expired, disable biometric login await _biometricService.disableBiometricLogin(); + _logger.e('Error during biometric login: $e'); return false; } } @@ -827,4 +825,23 @@ class AuthenticationRepository extends GetxController { throw TExceptions('Something went wrong. Please try again later.'); } } + + // Restore session using a stored token (for biometric authentication) + Future 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'; + } + } } diff --git a/sigap-mobile/lib/src/features/auth/presentasion/controllers/signin/signin_controller.dart b/sigap-mobile/lib/src/features/auth/presentasion/controllers/signin/signin_controller.dart index 172b215..57c0d61 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/controllers/signin/signin_controller.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/controllers/signin/signin_controller.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.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/utils/constants/app_routes.dart'; import 'package:sigap/src/utils/popups/loaders.dart'; @@ -10,6 +11,7 @@ class SignInController extends GetxController { final _logger = Logger(); final _authRepo = Get.find(); + final _biometricService = Get.find(); // Form controllers final email = TextEditingController(); @@ -22,6 +24,13 @@ class SignInController extends GetxController { // States final RxBool isLoading = RxBool(false); final RxBool isPasswordVisible = RxBool(false); + final RxBool isBiometricAvailable = RxBool(false); + + @override + void onInit() { + super.onInit(); + _checkBiometricAvailability(); + } @override void onClose() { @@ -30,6 +39,48 @@ class SignInController extends GetxController { super.onClose(); } + // Check if biometrics is available and enabled for the user + Future _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 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 void togglePasswordVisibility() { isPasswordVisible.value = !isPasswordVisible.value; diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/signin/signin_screen.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/signin/signin_screen.dart index 2e3942c..fb24f1b 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/pages/signin/signin_screen.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/signin/signin_screen.dart @@ -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), // Or divider diff --git a/sigap-mobile/lib/src/features/personalization/presentasion/bindings/personalization_bindings.dart b/sigap-mobile/lib/src/features/personalization/presentasion/bindings/personalization_bindings.dart index 92bd807..1fd04aa 100644 --- a/sigap-mobile/lib/src/features/personalization/presentasion/bindings/personalization_bindings.dart +++ b/sigap-mobile/lib/src/features/personalization/presentasion/bindings/personalization_bindings.dart @@ -1,8 +1,5 @@ import 'package:get/get.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/profile_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(), fenix: true); } - // Register repositories - _registerRepositories(); - // Register profile controllers _registerProfileControllers(); @@ -32,22 +26,11 @@ class PersonalizationBindings extends Bindings { _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(), fenix: true); - - Get.lazyPut(() => ProfileRepository(), fenix: true); - - Get.lazyPut(() => OfficerRepository(), fenix: true); - } - void _registerProfileControllers() { // Register profile-related controllers Get.lazyPut(() => ProfileController(), fenix: true); - Get.lazyPut( - () => OfficerProfileController(), + Get.lazyPut(() => OfficerController(), fenix: true, ); } diff --git a/sigap-mobile/lib/src/features/personalization/presentasion/controllers/profile/officer_profile_controller.dart b/sigap-mobile/lib/src/features/personalization/presentasion/controllers/profile/officer_profile_controller.dart index dab46a6..a0016e9 100644 --- a/sigap-mobile/lib/src/features/personalization/presentasion/controllers/profile/officer_profile_controller.dart +++ b/sigap-mobile/lib/src/features/personalization/presentasion/controllers/profile/officer_profile_controller.dart @@ -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/profile_controller.dart'; -class OfficerProfileController extends BaseProfileController { +class OfficerController extends BaseProfileController { // Use find to get previously registered repository final _officerRepository = Get.find(); diff --git a/sigap-mobile/lib/src/features/personalization/presentasion/controllers/profile/profile_binding.dart b/sigap-mobile/lib/src/features/personalization/presentasion/controllers/profile/profile_binding.dart index 0ca3d75..c5df6a7 100644 --- a/sigap-mobile/lib/src/features/personalization/presentasion/controllers/profile/profile_binding.dart +++ b/sigap-mobile/lib/src/features/personalization/presentasion/controllers/profile/profile_binding.dart @@ -23,6 +23,6 @@ class ProfileBinding extends Bindings { // Register controllers Get.lazyPut(() => ProfileController(), fenix: true); - Get.lazyPut(() => OfficerProfileController(), fenix: true); + Get.lazyPut(() => OfficerController(), fenix: true); } } diff --git a/sigap-mobile/lib/src/features/personalization/presentasion/controllers/profile/profile_controller.dart b/sigap-mobile/lib/src/features/personalization/presentasion/controllers/profile/profile_controller.dart index fc4e5be..b357d69 100644 --- a/sigap-mobile/lib/src/features/personalization/presentasion/controllers/profile/profile_controller.dart +++ b/sigap-mobile/lib/src/features/personalization/presentasion/controllers/profile/profile_controller.dart @@ -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'; class ProfileController extends BaseProfileController { - // Repositories - final _userRepository = Get.find(); - final _profileRepository = Get.find(); - final _officerRepository = Get.find(); + // Repositories using Get.find() instead of direct initialization + final UserRepository _userRepository = Get.find(); + final ProfileRepository _profileRepository = Get.find(); + final OfficerRepository _officerRepository = Get.find(); // Observable state variables final Rx user = Rx(null); @@ -21,6 +21,7 @@ class ProfileController extends BaseProfileController { final RxBool isOfficer = false.obs; final RxBool isInitialized = false.obs; final RxBool isEditMode = false.obs; + final RxBool isFetching = false.obs; // Track initial data loading // Form controllers for edit mode late TextEditingController firstNameController; @@ -32,18 +33,18 @@ class ProfileController extends BaseProfileController { // Original data for comparison ProfileModel? originalProfile; - + @override void onInit() { super.onInit(); - + // Initialize text controllers firstNameController = TextEditingController(); lastNameController = TextEditingController(); bioController = TextEditingController(); phoneController = TextEditingController(); birthPlaceController = TextEditingController(); - + // Load initial data loadData(); } @@ -67,7 +68,7 @@ class ProfileController extends BaseProfileController { _loadDataToFormFields(); } } - + // Reload profile data Future refreshProfile() async { await fetchUserProfile(); @@ -76,6 +77,7 @@ class ProfileController extends BaseProfileController { // Fetch the user profile data Future fetchUserProfile() async { try { + isFetching.value = true; // Start fetching setLoading(true); clearError(); @@ -98,6 +100,7 @@ class ProfileController extends BaseProfileController { ); } finally { setLoading(false); + isFetching.value = false; // Done fetching } } @@ -107,7 +110,7 @@ class ProfileController extends BaseProfileController { // Get user and profile data final userData = await _userRepository.getCurrentUserData(); user.value = userData; - + // If profile exists in user data, use it if (userData.profile != null) { profile.value = userData.profile; @@ -129,7 +132,7 @@ class ProfileController extends BaseProfileController { // Get officer data final officerData = await _officerRepository.getOfficerData(); officer.value = officerData; - + // Get additional user data final userData = await _userRepository.getCurrentUserData(); user.value = userData; @@ -139,7 +142,7 @@ class ProfileController extends BaseProfileController { setError('Failed to load officer data: ${e.toString()}'); } } - + // Toggle edit mode void toggleEditMode() { isEditMode.value = !isEditMode.value; @@ -149,7 +152,7 @@ class ProfileController extends BaseProfileController { hasChanges.value = false; } } - + // Load current data into form fields void _loadDataToFormFields() { if (profile.value != null) { @@ -163,31 +166,31 @@ class ProfileController extends BaseProfileController { if (user.value != null) { phoneController.text = user.value!.phone ?? ''; } - + // Setup listeners to track changes _setupTextChangeListeners(); } - + // Setup listeners for text controllers to track changes void _setupTextChangeListeners() { void onTextChanged() { _checkForChanges(); } - + firstNameController.addListener(onTextChanged); lastNameController.addListener(onTextChanged); bioController.addListener(onTextChanged); phoneController.addListener(onTextChanged); birthPlaceController.addListener(onTextChanged); } - + // Check for changes in form data void _checkForChanges() { if (profile.value == null) { hasChanges.value = false; return; } - + // Check profile fields bool profileChanged = firstNameController.text != (profile.value!.firstName ?? '') || @@ -195,25 +198,26 @@ class ProfileController extends BaseProfileController { bioController.text != (profile.value!.bio ?? '') || birthPlaceController.text != (profile.value!.placeOfBirth ?? '') || birthDate.value != profile.value!.birthDate; - + // Check phone bool phoneChanged = false; if (user.value != null) { phoneChanged = phoneController.text != (user.value!.phone ?? ''); } - + hasChanges.value = profileChanged || phoneChanged; } - + // Set birth date void setBirthDate(DateTime? date) { birthDate.value = date; _checkForChanges(); } - + // Fetch profile by user ID Future fetchProfileByUserId(String userId) async { try { + isFetching.value = true; setLoading(true); clearError(); @@ -237,6 +241,8 @@ class ProfileController extends BaseProfileController { officer.value = null; } } + + isInitialized.value = true; } catch (e) { setError('Failed to load profile: ${e.toString()}'); showError( @@ -245,6 +251,7 @@ class ProfileController extends BaseProfileController { ); } finally { setLoading(false); + isFetching.value = false; } } @@ -266,17 +273,17 @@ class ProfileController extends BaseProfileController { setLoading(false); } } - + // Save changes @override Future saveChanges() async { if (!formKey.currentState!.validate()) { return false; } - + try { setLoading(true); - + // Update profile data if (profile.value != null) { final updatedProfile = profile.value!.copyWith( @@ -286,17 +293,17 @@ class ProfileController extends BaseProfileController { placeOfBirth: birthPlaceController.text.trim(), birthDate: birthDate.value, ); - + await _profileRepository.updateProfile(updatedProfile); profile.value = updatedProfile; } - + // Update phone if changed final phone = phoneController.text.trim(); if (user.value != null && phone != user.value!.phone) { await _userRepository.updateUserPhone(phone); } - + await refreshProfile(); hasChanges.value = false; isEditMode.value = false; @@ -310,7 +317,7 @@ class ProfileController extends BaseProfileController { setLoading(false); } } - + // Discard changes @override void discardChanges() { diff --git a/sigap-mobile/lib/src/features/personalization/presentasion/controllers/settings/security_controller.dart b/sigap-mobile/lib/src/features/personalization/presentasion/controllers/settings/security_controller.dart index 06464bd..1fb6113 100644 --- a/sigap-mobile/lib/src/features/personalization/presentasion/controllers/settings/security_controller.dart +++ b/sigap-mobile/lib/src/features/personalization/presentasion/controllers/settings/security_controller.dart @@ -1,9 +1,17 @@ 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/utils/popups/loaders.dart'; class SecurityController extends BaseSettingsController { + final _logger = Logger(); + final _biometricService = Get.find(); + final _authRepo = Get.find(); + // Security settings - final RxBool requireBiometric = true.obs; + final RxBool requireBiometric = false.obs; final RxBool enable2FA = false.obs; final RxBool sendEmailAlerts = true.obs; final RxBool sendPushNotification = false.obs; @@ -21,7 +29,64 @@ class SecurityController extends BaseSettingsController { loadSettings(); } - // Toggle security setting + // Toggle biometric security setting + Future 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) { switch (setting) { case 'require_biometric': @@ -123,6 +188,10 @@ class SecurityController extends BaseSettingsController { try { setLoading(true); + // Load biometric setting from actual service + requireBiometric.value = + await _biometricService.isBiometricLoginEnabled(); + // TODO: Load security settings from persistent storage final prefs = Get.find(); // Replace with your storage solution // requireBiometric.value = prefs.getBool('require_biometric') ?? true; @@ -133,6 +202,7 @@ class SecurityController extends BaseSettingsController { // passcodeLastChanged.value = prefs.getString('passcode_last_changed') ?? ''; // backupCodes.value = prefs.getStringList('backup_codes') ?? []; } catch (e) { + _logger.e('Failed to load security settings: $e'); setError('Failed to load security settings: ${e.toString()}'); } finally { setLoading(false); diff --git a/sigap-mobile/lib/src/features/personalization/presentasion/pages/profile/profile_screen.dart b/sigap-mobile/lib/src/features/personalization/presentasion/pages/profile/profile_screen.dart index 05b9143..af77afc 100644 --- a/sigap-mobile/lib/src/features/personalization/presentasion/pages/profile/profile_screen.dart +++ b/sigap-mobile/lib/src/features/personalization/presentasion/pages/profile/profile_screen.dart @@ -1,8 +1,11 @@ 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/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/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'; class ProfileScreen extends StatelessWidget { @@ -10,6 +13,7 @@ class ProfileScreen extends StatelessWidget { final ProfileModel? profile; final OfficerModel? officer; final bool isCurrentUser; + final String? userId; const ProfileScreen({ super.key, @@ -17,22 +21,30 @@ class ProfileScreen extends StatelessWidget { this.profile, this.officer, this.isCurrentUser = false, + this.userId, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final screenHeight = MediaQuery.of(context).size.height; + + // Get the profile controller + final controller = Get.find(); - // For demo purposes, using dummy data if no data is provided - final displayName = - profile?.fullName ?? user?.profile?.fullName ?? 'Anita Rose'; - final location = _getLocationString(); - final avatarUrl = profile?.avatar ?? user?.profile?.avatar; - final bio = - profile?.bio ?? - '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'; - + // If userId is provided, fetch that specific profile + if (userId != null && controller.user.value?.id != userId) { + WidgetsBinding.instance.addPostFrameCallback((_) { + controller.fetchProfileByUserId(userId!); + }); + } + // 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( backgroundColor: const Color( 0xFFF2F2F7, @@ -45,177 +57,266 @@ class ProfileScreen extends StatelessWidget { onPressed: () => Navigator.pop(context), ), actions: [ - CircleAvatar( - backgroundColor: theme.primaryColor.withOpacity(0.1), - child: IconButton( - icon: Icon(Icons.person_outline, color: theme.primaryColor), - onPressed: () { - // Show profile options - }, + if (isCurrentUser) + Obx( + () => CircleAvatar( + backgroundColor: theme.primaryColor.withOpacity(0.1), + child: IconButton( + icon: Icon( + controller.isEditMode.value + ? Icons.close + : Icons.edit_outlined, + color: theme.primaryColor, + ), + onPressed: () { + controller.toggleEditMode(); + }, + ), + ), ), - ), const SizedBox(width: 16), ], ), - body: 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, + 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: 16), - // Name - Text( - displayName, - style: theme.textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, + const SizedBox(height: 8), + Text( + controller.errorMessage.value, + style: theme.textTheme.bodyMedium, + textAlign: TextAlign.center, ), - ), - const SizedBox(height: 4), - // Location with icon - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.location_on_outlined, - size: 16, - color: theme.hintColor, + 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: [ + // 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( - location, - style: theme.textTheme.bodyMedium?.copyWith( + ), + const SizedBox(height: 16), + // Name + 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, ), - ), - ], - ), - ], - ), - - // 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), + const SizedBox(width: 4), + Text( + locationString, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.hintColor, + ), + ), + ], ), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 10, - offset: const Offset(0, -2), + ], + ), + + // 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), ), - ], - ), - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 6), - // Center gray line indicator for draggable bottom sheet look - Center( - child: Container( - height: 4, - width: 40, - decoration: BoxDecoration( - color: Colors.grey.withOpacity(0.3), - borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, -2), + ), + ], + ), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 6), + // Center gray line indicator for draggable bottom sheet look + 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 - Padding( - padding: const EdgeInsets.fromLTRB(20, 0, 20, 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('About', style: theme.textTheme.titleLarge), - const SizedBox(height: 8), - Text( - bio, - style: theme.textTheme.bodyMedium?.copyWith( - height: 1.5, - color: theme.textTheme.bodyMedium?.color - ?.withOpacity(0.8), + // About Section + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('About', style: theme.textTheme.titleLarge), + const SizedBox(height: 8), + Text( + bioText, + style: theme.textTheme.bodyMedium?.copyWith( + height: 1.5, + color: theme.textTheme.bodyMedium?.color + ?.withOpacity(0.8), + ), ), - ), - ], + ], + ), ), - ), - // User-specific information - officer != null - ? OfficerProfileDetail(officer: officer!) - : UserProfileDetail(user: user, profile: profile), - ], + // User/Officer-specific information + isOfficerUser + ? OfficerProfileDetail(officer: officerData!) + : 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) { return officer!.placeOfBirth!; - } else if (profile?.placeOfBirth != null) { - return profile!.placeOfBirth!; + } else if (profile?.placeOfBirth != null && + profile!.placeOfBirth!.isNotEmpty) { + return profile.placeOfBirth!; } // Fallback to address if available if (profile?.address != null) { final address = profile!.address!; + final fullAddress = address['full_address']; final city = address['city']; 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'; } else if (city != null) { - return city; + return city.toString(); } else if (country != null) { - return country; + return country.toString(); } } - return 'Ontario, Canada'; // Default fallback + return 'Location not specified'; // Default fallback } } diff --git a/sigap-mobile/lib/src/features/personalization/presentasion/pages/profile/widgets/officer_profile_detail.dart b/sigap-mobile/lib/src/features/personalization/presentasion/pages/profile/widgets/officer_profile_detail.dart index e17cc40..acd44de 100644 --- a/sigap-mobile/lib/src/features/personalization/presentasion/pages/profile/widgets/officer_profile_detail.dart +++ b/sigap-mobile/lib/src/features/personalization/presentasion/pages/profile/widgets/officer_profile_detail.dart @@ -17,27 +17,26 @@ class OfficerProfileDetail extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 10), - + Text('Official Information', style: theme.textTheme.titleLarge), - + const SizedBox(height: 16), + + // Officer information rows + if (officer.nrp != null) + _buildInfoRow(context, 'NRP', + officer.nrp.toString()), - // Officer information - _buildInfoRow( - context, - 'NRP', - officer.nrp?.toString() ?? 'Not Available', - ), - - if (officer.rank != null) + if (officer.rank != null && officer.rank!.isNotEmpty) _buildInfoRow(context, 'Rank', officer.rank!), - if (officer.position != null) + if (officer.position != null && officer.position!.isNotEmpty) _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!), if (officer.dateOfBirth != null) @@ -47,11 +46,14 @@ class OfficerProfileDetail extends StatelessWidget { 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!), - if (officer.unitId != null) + if (officer.unitId != null && officer.unitId!.isNotEmpty) _buildInfoRow(context, 'Unit ID', officer.unitId!), + + if (officer.patrolUnitId != null && officer.patrolUnitId!.isNotEmpty) + _buildInfoRow(context, 'Patrol Unit', officer.patrolUnitId!), if (officer.validUntil != null) _buildInfoRow( @@ -61,12 +63,19 @@ class OfficerProfileDetail extends StatelessWidget { ), 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 if (officer.isBanned) ...[ const SizedBox(height: 24), - + Row( children: [ 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), ], ), - + const SizedBox(height: 16), - - _buildInfoRow(context, 'Status', 'Banned', valueColor: Colors.red), + + _buildInfoRow( + context, + 'Status', + 'Account Suspended', + valueColor: Colors.red, + ), _buildInfoRow( context, @@ -88,10 +102,34 @@ class OfficerProfileDetail extends StatelessWidget { if (officer.bannedUntil != null) _buildInfoRow( context, - 'Banned Until', + 'Suspended Until', 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(' '); + } } diff --git a/sigap-mobile/lib/src/features/personalization/presentasion/pages/profile/widgets/profile_edit_form.dart b/sigap-mobile/lib/src/features/personalization/presentasion/pages/profile/widgets/profile_edit_form.dart new file mode 100644 index 0000000..d841438 --- /dev/null +++ b/sigap-mobile/lib/src/features/personalization/presentasion/pages/profile/widgets/profile_edit_form.dart @@ -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? 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 _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), + ], + ), + ), + ); + } +} diff --git a/sigap-mobile/lib/src/features/personalization/presentasion/pages/profile/widgets/user_profile_detail.dart b/sigap-mobile/lib/src/features/personalization/presentasion/pages/profile/widgets/user_profile_detail.dart index 19da0a7..7901356 100644 --- a/sigap-mobile/lib/src/features/personalization/presentasion/pages/profile/widgets/user_profile_detail.dart +++ b/sigap-mobile/lib/src/features/personalization/presentasion/pages/profile/widgets/user_profile_detail.dart @@ -13,6 +13,26 @@ class UserProfileDetail extends StatelessWidget { Widget build(BuildContext 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( padding: const EdgeInsets.fromLTRB(20, 8, 20, 30), child: Column( @@ -22,15 +42,22 @@ class UserProfileDetail extends StatelessWidget { Text('Personal Information', style: theme.textTheme.titleLarge), const SizedBox(height: 16), - // User information - if (profile?.nik != null) + // NIK (National ID) + if (profile?.nik != null && profile!.nik!.isNotEmpty) _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!), + // Birth Date if (profile?.birthDate != null) _buildInfoRow( context, @@ -38,9 +65,21 @@ class UserProfileDetail extends StatelessWidget { 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!), + // Address + if (profile?.address != null && + _getDisplayAddress(profile!.address!).isNotEmpty) + _buildInfoRow( + context, + 'Address', + _getDisplayAddress(profile!.address!), + ), + + // Last Sign In if (user?.lastSignInAt != null) _buildInfoRow( context, @@ -48,8 +87,21 @@ class UserProfileDetail extends StatelessWidget { 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) - _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 addressMap) { + final fullAddress = addressMap['full_address']?.toString(); + if (fullAddress != null && fullAddress.isNotEmpty) { + return fullAddress; + } + + final parts = []; + 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(' '); + } } diff --git a/sigap-mobile/lib/src/features/personalization/presentasion/pages/settings/setting_screen.dart b/sigap-mobile/lib/src/features/personalization/presentasion/pages/settings/setting_screen.dart index 5eec4a4..d5a9764 100644 --- a/sigap-mobile/lib/src/features/personalization/presentasion/pages/settings/setting_screen.dart +++ b/sigap-mobile/lib/src/features/personalization/presentasion/pages/settings/setting_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.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/pages/profile/profile_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/terms_services_screen.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 { const SettingsScreen({super.key}); @@ -24,11 +26,18 @@ class _SettingsScreenState extends State late TabController _tabController; // Use find instead of implicit creation with Get.put final SettingsController _settingsController = Get.find(); + // Get the profile controller to access user data + final ProfileController _profileController = Get.find(); @override void initState() { super.initState(); _tabController = TabController(length: 3, vsync: this); + + // Fetch user profile data if it's not already loaded + if (!_profileController.isInitialized.value) { + _profileController.fetchUserProfile(); + } } @override @@ -49,47 +58,93 @@ class _SettingsScreenState extends State ), body: Column( children: [ - // Profile Section - Now clickable - InkWell( - onTap: () { - // Navigate to the ProfileScreen when tapped - Navigator.push( - context, - MaterialPageRoute( - builder: - (context) => const ProfileScreen(isCurrentUser: true), + // Profile Section with real data + 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), ), ); - }, - child: Container( - padding: const EdgeInsets.all(20), - color: theme.scaffoldBackgroundColor, - child: Row( - children: [ - CircleAvatar( - radius: 30, - backgroundColor: theme.hintColor, - child: Icon( - Icons.person, - size: 35, - color: theme.scaffoldBackgroundColor, + } + + // 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, ), ), - const SizedBox(width: 15), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Anita Rose', style: theme.textTheme.titleLarge), - Text('anitarose', style: theme.textTheme.bodySmall), - ], - ), + ), + ); + } + + // 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: () { + // 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 Container( @@ -132,6 +187,7 @@ class _SettingsScreenState extends State return Container( color: theme.scaffoldBackgroundColor, + padding: const EdgeInsets.symmetric(horizontal: TSizes.defaultSpace / 2), child: ListView( children: [ _buildSettingsItem( diff --git a/sigap-mobile/lib/src/features/personalization/presentasion/pages/settings/widgets/security_setting.dart b/sigap-mobile/lib/src/features/personalization/presentasion/pages/settings/widgets/security_setting.dart index 7e95858..25cf7f8 100644 --- a/sigap-mobile/lib/src/features/personalization/presentasion/pages/settings/widgets/security_setting.dart +++ b/sigap-mobile/lib/src/features/personalization/presentasion/pages/settings/widgets/security_setting.dart @@ -1,5 +1,8 @@ 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/shared/widgets/loaders/custom_circular_loader.dart'; class SecuritySettingsScreen extends StatefulWidget { const SecuritySettingsScreen({super.key}); @@ -9,74 +12,90 @@ class SecuritySettingsScreen extends StatefulWidget { } class _SecuritySettingsScreenState extends State { - bool requireBiometric = true; - bool enable2FA = false; - bool sendEmailAlerts = true; - bool sendPushNotification = false; + // Get the controller + final SecurityController controller = Get.put(SecurityController()); @override Widget build(BuildContext context) { return BaseDetailScreen( title: 'Security Settings', - content: ListView( - children: [ - _buildSectionHeader('App Lock'), - _buildSwitchItem( - icon: Icons.fingerprint_outlined, - title: 'Require biometric', - value: requireBiometric, - onChanged: (value) { - setState(() { - requireBiometric = value; - }); - }, - ), - _buildNavigationItem( - icon: Icons.lock_outline, - title: 'Change passcode', - onTap: () {}, - ), + content: Obx(() { + if (controller.isLoading.value) { + return const Center(child: DCircularLoader()); + } - _buildSectionHeader('Two-Factor Authentication'), - _buildSwitchItem( - icon: Icons.security_outlined, - title: 'Enable 2FA', - value: enable2FA, - onChanged: (value) { - setState(() { - enable2FA = value; - }); - }, - ), - _buildNavigationItem( - icon: Icons.backup_outlined, - title: 'Manage backup codes', - onTap: () {}, - ), + return ListView( + children: [ + _buildSectionHeader('App Lock'), + Obx( + () => _buildSwitchItem( + icon: Icons.fingerprint_outlined, + title: 'Biometric Authentication', + subtitle: 'Use your fingerprint or face to sign in', + value: controller.requireBiometric.value, + onChanged: (value) async { + // Show loading + final wasLoading = controller.isLoading.value; + if (!wasLoading) controller.setLoading(true); - _buildSectionHeader('Login Alerts'), - _buildSwitchItem( - icon: Icons.email_outlined, - title: 'Send email alerts', - value: sendEmailAlerts, - onChanged: (value) { - setState(() { - sendEmailAlerts = value; - }); - }, - ), - _buildSwitchItem( - icon: Icons.notifications_outlined, - title: 'Send push notification', - value: sendPushNotification, - onChanged: (value) { - setState(() { - sendPushNotification = value; - }); - }, - ), - ], - ), + // Toggle biometric + await controller.toggleBiometricAuthentication(value); + + // Hide loading if we showed it + if (!wasLoading) controller.setLoading(false); + }, + ), + ), + _buildNavigationItem( + icon: Icons.lock_outline, + title: 'Change passcode', + onTap: () {}, + ), + + _buildSectionHeader('Two-Factor Authentication'), + Obx( + () => _buildSwitchItem( + 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 { Widget _buildSwitchItem({ required IconData icon, required String title, + String? subtitle, required bool value, required ValueChanged onChanged, }) { @@ -131,7 +151,21 @@ class _SecuritySettingsScreenState extends State { children: [ Icon(icon, size: 24, color: theme.iconTheme.color), 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( value: value, onChanged: onChanged, diff --git a/sigap-mobile/lib/src/shared/widgets/loaders/custom_circular_loader.dart b/sigap-mobile/lib/src/shared/widgets/loaders/custom_circular_loader.dart new file mode 100644 index 0000000..e805bb9 --- /dev/null +++ b/sigap-mobile/lib/src/shared/widgets/loaders/custom_circular_loader.dart @@ -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(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)); + } +}