From 803a28494dc6be8ae5f5a9879e10cce03cecd687 Mon Sep 17 00:00:00 2001 From: vergiLgood1 Date: Sat, 17 May 2025 07:10:00 +0700 Subject: [PATCH] feat: Implement user profile and role management features - Added ProfileRepository for managing user profile data including fetching, updating, and uploading avatars. - Introduced RolesRepository to handle role-related operations such as fetching all roles and retrieving roles by ID or name. - Created UserRepository for user data management, including user authentication checks and profile updates. - Developed OfficerModel to represent officer data with JSON serialization. - Implemented RoleSelectionController to manage role selection logic during onboarding. - Added LocationWarningScreen to handle location validation and user notifications. - Created RoleSelectionScreen for users to select their roles during onboarding. - Developed RoleCard widget for displaying role options in a user-friendly manner. --- .../android/app/src/main/AndroidManifest.xml | 22 +- sigap-mobile/auth_and_panic_button_flow.md | 34 + sigap-mobile/ios/Runner/Info.plist | 2 + sigap-mobile/lib/main.dart | 10 +- .../cores/bindings/controller_bindings.dart | 6 +- .../cores/bindings/repository_bindings.dart | 33 + .../src/cores/bindings/service_bindings.dart | 14 + .../repositories/auth/auth_repositories.dart | 149 ++--- .../authentication_repositories.dart | 582 ++++++++++++++++++ .../officers_repository.dart | 4 +- .../patrol_units_repository.dart} | 0 .../unit_statistics_repository.dart | 0 .../units_repository.dart} | 0 .../repositories/map/cities_repository.dart | 0 .../map/demographics_repository.dart | 0 .../map/districts_repository.dart | 0 .../map/geogrpaphics_repository.dart | 0 .../map/location_logs_repository.dart | 0 .../map/locations_repository.dart | 0 .../crime_incidents_repository.dart | 0 .../panic-button/crimes_repository.dart | 0 .../panic-button/events_repository.dart | 0 .../panic-button/evidences_repository.dart | 0 .../incident_logs_repository.dart | 0 .../panic-button/panic_button_repository.dart | 487 +++++++++++++++ .../panic-button/sessions_repository.dart | 0 .../panic/panic_button_repository.dart | 446 -------------- .../permissions_repository.dart | 0 .../personalization/profile_repository.dart | 117 ++++ .../personalization/resources_repository.dart | 0 .../roles_repository.dart | 0 .../users_repository.dart | 0 .../profile/profile_repository.dart | 117 ---- .../repositories/units/units_repository.dart | 20 - .../repositories/user/user_repository.dart | 0 .../lib/src/cores/routes/app_pages.dart | 8 +- .../src/cores/services/biometric_service.dart | 8 +- .../src/cores/services/location_service.dart | 298 ++++++++- .../src/cores/services/supabase_service.dart | 33 +- .../email_verification_controller.dart | 30 +- .../forgot_password_controller.dart | 11 +- .../auth/controllers/signin_controller.dart | 2 +- .../auth/controllers/signup_controller.dart | 221 ++++--- .../controllers/step_form_controller.dart | 242 +++++--- .../auth/models/user_metadata_model.dart | 204 +++--- .../email_verification_screen.dart | 13 +- .../auth/screens/signup/signup_screen.dart | 169 ++++- .../screens/step-form/step_form_screen.dart | 73 ++- .../src/features/daily-ops/models/index.dart | 1 + .../daily-ops/models/officers_model.dart | 159 +++++ .../daily-ops/models/patrol_units_model.dart | 2 +- .../daily-ops/models/units_model.dart | 2 +- .../controllers/onboarding_controller.dart | 39 +- ...er.dart => role_selection_controller.dart} | 14 +- .../location_warning_screen.dart | 117 ++++ .../role_selection_screen.dart} | 10 +- .../widgets/role_card.dart | 0 .../personalization/models/index.dart | 1 - .../models/officers_model.dart | 159 ----- .../personalization/models/profile_model.dart | 16 + .../widgets/text/custom_text_field.dart | 4 + .../lib/src/utils/constants/app_routes.dart | 5 +- 62 files changed, 2600 insertions(+), 1284 deletions(-) create mode 100644 sigap-mobile/lib/src/cores/bindings/repository_bindings.dart create mode 100644 sigap-mobile/lib/src/cores/bindings/service_bindings.dart create mode 100644 sigap-mobile/lib/src/cores/repositories/authentication/authentication_repositories.dart rename sigap-mobile/lib/src/cores/repositories/{officers => daily-ops}/officers_repository.dart (96%) rename sigap-mobile/lib/src/cores/repositories/{unit/patrol_unit_repository.dart => daily-ops/patrol_units_repository.dart} (100%) rename sigap-mobile/lib/src/cores/repositories/{unit => daily-ops}/unit_statistics_repository.dart (100%) rename sigap-mobile/lib/src/cores/repositories/{unit/unit_repository.dart => daily-ops/units_repository.dart} (100%) create mode 100644 sigap-mobile/lib/src/cores/repositories/map/cities_repository.dart create mode 100644 sigap-mobile/lib/src/cores/repositories/map/demographics_repository.dart create mode 100644 sigap-mobile/lib/src/cores/repositories/map/districts_repository.dart create mode 100644 sigap-mobile/lib/src/cores/repositories/map/geogrpaphics_repository.dart create mode 100644 sigap-mobile/lib/src/cores/repositories/map/location_logs_repository.dart create mode 100644 sigap-mobile/lib/src/cores/repositories/map/locations_repository.dart create mode 100644 sigap-mobile/lib/src/cores/repositories/panic-button/crime_incidents_repository.dart create mode 100644 sigap-mobile/lib/src/cores/repositories/panic-button/crimes_repository.dart create mode 100644 sigap-mobile/lib/src/cores/repositories/panic-button/events_repository.dart create mode 100644 sigap-mobile/lib/src/cores/repositories/panic-button/evidences_repository.dart create mode 100644 sigap-mobile/lib/src/cores/repositories/panic-button/incident_logs_repository.dart create mode 100644 sigap-mobile/lib/src/cores/repositories/panic-button/panic_button_repository.dart create mode 100644 sigap-mobile/lib/src/cores/repositories/panic-button/sessions_repository.dart create mode 100644 sigap-mobile/lib/src/cores/repositories/personalization/permissions_repository.dart create mode 100644 sigap-mobile/lib/src/cores/repositories/personalization/profile_repository.dart create mode 100644 sigap-mobile/lib/src/cores/repositories/personalization/resources_repository.dart rename sigap-mobile/lib/src/cores/repositories/{roles => personalization}/roles_repository.dart (100%) rename sigap-mobile/lib/src/cores/repositories/{users => personalization}/users_repository.dart (100%) delete mode 100644 sigap-mobile/lib/src/cores/repositories/units/units_repository.dart create mode 100644 sigap-mobile/lib/src/cores/repositories/user/user_repository.dart create mode 100644 sigap-mobile/lib/src/features/daily-ops/models/officers_model.dart rename sigap-mobile/lib/src/features/onboarding/controllers/{choose_role_controller.dart => role_selection_controller.dart} (85%) create mode 100644 sigap-mobile/lib/src/features/onboarding/screens/location-warning/location_warning_screen.dart rename sigap-mobile/lib/src/features/onboarding/screens/{choose-role/choose_role_screen.dart => role-selection/role_selection_screen.dart} (87%) rename sigap-mobile/lib/src/features/onboarding/screens/{choose-role => role-selection}/widgets/role_card.dart (100%) diff --git a/sigap-mobile/android/app/src/main/AndroidManifest.xml b/sigap-mobile/android/app/src/main/AndroidManifest.xml index 4d85294..2e8d653 100644 --- a/sigap-mobile/android/app/src/main/AndroidManifest.xml +++ b/sigap-mobile/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,10 @@ + + + + + android:host="signup" /> + android:host="forgotpassword" /> + android:host="resetpassword" /> + android:host="verifyemail" /> + android:host="verifyphone" /> + android:host="verifyemailotp" /> + android:host="verifyphoneotp" /> + android:host="verifyemailchange" /> + android:host="verifyphonechange" /> + NSLocationWhenInUseUsageDescription + This app needs access to location when open. \ No newline at end of file diff --git a/sigap-mobile/lib/main.dart b/sigap-mobile/lib/main.dart index b6fab31..862d855 100644 --- a/sigap-mobile/lib/main.dart +++ b/sigap-mobile/lib/main.dart @@ -4,10 +4,7 @@ import 'package:flutter_native_splash/flutter_native_splash.dart'; import 'package:get/get.dart'; import 'package:get_storage/get_storage.dart'; import 'package:sigap/app.dart'; -import 'package:sigap/src/cores/repositories/panic/panic_button_repository.dart'; -import 'package:sigap/src/cores/services/biometric_service.dart'; -import 'package:sigap/src/cores/services/location_service.dart'; -import 'package:sigap/src/cores/services/supabase_service.dart'; +import 'package:sigap/src/cores/repositories/panic-button/panic_button_repository.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; Future main() async { @@ -35,11 +32,6 @@ Future main() async { storageOptions: const StorageClientOptions(retryAttempts: 10), ); - // Initialize services - await Get.putAsync(() => SupabaseService().init()); - await Get.putAsync(() => BiometricService().init()); - await Get.putAsync(() => LocationService().init()); - // Initialize repositories Get.put(PanicButtonRepository()); await Get.find().init(); diff --git a/sigap-mobile/lib/src/cores/bindings/controller_bindings.dart b/sigap-mobile/lib/src/cores/bindings/controller_bindings.dart index fe96f78..817bf46 100644 --- a/sigap-mobile/lib/src/cores/bindings/controller_bindings.dart +++ b/sigap-mobile/lib/src/cores/bindings/controller_bindings.dart @@ -2,8 +2,8 @@ import 'package:get/get.dart'; import 'package:sigap/src/features/auth/controllers/forgot_password_controller.dart'; import 'package:sigap/src/features/auth/controllers/signin_controller.dart'; import 'package:sigap/src/features/auth/controllers/signup_controller.dart'; -import 'package:sigap/src/features/onboarding/controllers/choose_role_controller.dart'; import 'package:sigap/src/features/onboarding/controllers/onboarding_controller.dart'; +import 'package:sigap/src/features/onboarding/controllers/role_selection_controller.dart'; // Onboarding controller bindings class OnboardingControllerBinding extends Bindings { @@ -13,10 +13,10 @@ class OnboardingControllerBinding extends Bindings { } } -class ChooseRoleControllerBinding extends Bindings { +class RoleSelectionControllerBinding extends Bindings { @override void dependencies() { - Get.lazyPut(() => ChooseRoleController()); + Get.lazyPut(() => RoleSelectionController()); } } diff --git a/sigap-mobile/lib/src/cores/bindings/repository_bindings.dart b/sigap-mobile/lib/src/cores/bindings/repository_bindings.dart new file mode 100644 index 0000000..f28c3ff --- /dev/null +++ b/sigap-mobile/lib/src/cores/bindings/repository_bindings.dart @@ -0,0 +1,33 @@ +import 'package:get/get.dart'; +import 'package:sigap/src/cores/repositories/authentication/authentication_repositories.dart'; +import 'package:sigap/src/cores/repositories/daily-ops/officers_repository.dart'; +import 'package:sigap/src/cores/repositories/daily-ops/units_repository.dart'; +import 'package:sigap/src/cores/repositories/personalization/profile_repository.dart'; +import 'package:sigap/src/cores/repositories/personalization/roles_repository.dart'; +import 'package:sigap/src/cores/repositories/personalization/users_repository.dart'; + +class RepositoryBindings extends Bindings { + @override + void dependencies() { + // Auth Repository + Get.lazyPut( + () => AuthenticationRepository(), + fenix: true, + ); + + // User Repository + Get.lazyPut(() => UserRepository(), fenix: true); + + // Officer Repository + Get.lazyPut(() => OfficerRepository(), fenix: true); + + // Unit Repository + Get.lazyPut(() => UnitRepository(), fenix: true); + + // Profile Repository + Get.lazyPut(() => ProfileRepository(), fenix: true); + + // Role Repository + Get.lazyPut(() => RolesRepository(), fenix: true); + } +} diff --git a/sigap-mobile/lib/src/cores/bindings/service_bindings.dart b/sigap-mobile/lib/src/cores/bindings/service_bindings.dart new file mode 100644 index 0000000..fe8edbc --- /dev/null +++ b/sigap-mobile/lib/src/cores/bindings/service_bindings.dart @@ -0,0 +1,14 @@ +import 'package:get/get.dart'; +import 'package:sigap/src/cores/services/biometric_service.dart'; +import 'package:sigap/src/cores/services/location_service.dart'; +import 'package:sigap/src/cores/services/supabase_service.dart'; + +class ServiceBindings extends Bindings { + @override + Future dependencies() async { + // Initialize services + await Get.putAsync(() => SupabaseService().init()); + await Get.putAsync(() => BiometricService().init()); + await Get.putAsync(() => LocationService().init()); + } +} diff --git a/sigap-mobile/lib/src/cores/repositories/auth/auth_repositories.dart b/sigap-mobile/lib/src/cores/repositories/auth/auth_repositories.dart index 830edb9..d7a7155 100644 --- a/sigap-mobile/lib/src/cores/repositories/auth/auth_repositories.dart +++ b/sigap-mobile/lib/src/cores/repositories/auth/auth_repositories.dart @@ -6,7 +6,6 @@ import 'package:sigap/src/cores/services/biometric_service.dart'; import 'package:sigap/src/cores/services/location_service.dart'; import 'package:sigap/src/cores/services/supabase_service.dart'; import 'package:sigap/src/features/auth/screens/signin/signin_screen.dart'; -import 'package:sigap/src/features/onboarding/screens/onboarding/onboarding_screen.dart'; import 'package:sigap/src/utils/constants/app_routes.dart'; import 'package:sigap/src/utils/exceptions/exceptions.dart'; import 'package:sigap/src/utils/exceptions/format_exceptions.dart'; @@ -58,20 +57,37 @@ class AuthenticationRepository extends GetxController { } } - screenRedirect() async { + // Check if the onboarding process is complete + Future isOnboardingComplete() async { + return storage.read('ONBOARDING_COMPLETED') ?? false; + } + + // Screen redirect based on auth status + void screenRedirect() async { final user = _supabase.auth.currentUser; + if (user != null) { - // local storage - storage.writeIfNull('isFirstTime', true); - // check if user is already logged in - storage.read('isFirstTime') != true - ? Get.offAll(() => const SignInScreen()) - : Get.offAll(() => const OnboardingScreen()); + // User is authenticated + Get.offAllNamed(AppRoutes.panicButton); } else { - // Try biometric login first - bool biometricSuccess = await attemptBiometricLogin(); - if (!biometricSuccess) { - Get.offAll(() => const SignInScreen()); + // Check if onboarding is completed + final onboardingCompleted = await isOnboardingComplete(); + + if (onboardingCompleted) { + // Check location validity + final isLocationValid = + await _locationService.isLocationValidForFeature(); + + if (isLocationValid) { + // Go to role selection + Get.offAllNamed(AppRoutes.roleSelection); + } else { + // Show location warning + Get.offAllNamed(AppRoutes.locationWarning); + } + } else { + // Show onboarding + Get.offAllNamed(AppRoutes.onboarding); } } } @@ -155,7 +171,7 @@ class AuthenticationRepository extends GetxController { } // [Email Reset Password ] - RESET PASSWORD - Future sendOtpResetPassword(String email) async { + Future sendResetPasswordForEmail(String email) async { try { await _supabase.auth.resetPasswordForEmail(email); } on AuthException catch (e) { @@ -365,15 +381,11 @@ class AuthenticationRepository extends GetxController { } } - // [Email AUTH] - SIGN UP with role selection and location verification - Future signUpWithCredential( - String email, - String password, - String identifier, // NIK for users or NRP for officers - { + // [Email AUTH] - SIGN UP + Future signUpWithEmailPassword({ + required String email, + required String password, Map? userMetadata, - bool isOfficer = false, - Map? officerData, }) async { try { // Validate location for registration @@ -381,104 +393,23 @@ class AuthenticationRepository extends GetxController { if (!isLocationValid) { throw TExceptions('Registration is only available within Jember area. Please ensure your location services are enabled and you are not using a mock location app.'); } - - // Check if identifier (NIK/NRP) is already registered - final identifierField = isOfficer ? 'nrp' : 'nik'; - final identifierType = isOfficer ? 'NRP' : 'NIK'; - - if (isOfficer) { - final existingOfficer = await _supabase - .from('officers') - .select('id, is_banned') - .eq('nrp', identifier) - .maybeSingle(); - - if (existingOfficer != null) { - bool isBanned = existingOfficer['is_banned'] as bool? ?? false; - if (isBanned) { - throw TExceptions('This $identifierType is associated with a banned account.'); - } else { - throw TExceptions('This $identifierType is already registered.'); - } - } - } else { - // For regular users, check NIK in profiles - final existingUser = await _supabase - .from('profiles') - .select('id, user_id') - .eq('nik', identifier) - .maybeSingle(); - - if (existingUser != null) { - // Check if user is banned - final userId = existingUser['user_id']; - final userBanCheck = await _supabase - .from('users') - .select('is_banned') - .eq('id', userId) - .maybeSingle(); - - if (userBanCheck != null && (userBanCheck['is_banned'] as bool? ?? false)) { - throw TExceptions('This $identifierType is associated with a banned account.'); - } else { - throw TExceptions('This $identifierType is already registered.'); - } - } - } - - // Prepare the complete user metadata - final metadata = userMetadata ?? {}; - - // Add officer flag and data if needed - metadata['is_officer'] = isOfficer; - if (isOfficer && officerData != null) { - metadata['officer_data'] = officerData; - } - - // Add identifier to metadata - if (isOfficer) { - if (metadata['officer_data'] == null) { - metadata['officer_data'] = {}; - } - metadata['officer_data']['nrp'] = identifier; - } else { - metadata['nik'] = identifier; - } - - final authResponse = await _supabase.auth.signUp( + + // Create user with email and password + final response = await _supabase.auth.signUp( email: email, password: password, - data: metadata, + data: userMetadata, ); - - final Session? session = authResponse.session; - final User? user = authResponse.user; - - if (session == null && user == null) { - throw TExceptions('Failed to sign up. Please try again.'); - } - - // Create profile with NIK if user is not an officer - if (!isOfficer && user != null) { - await _supabase.from('profiles').insert({ - 'user_id': user.id, - 'nik': identifier, - }); - } - - return authResponse; + + return response; } on AuthException catch (e) { throw TExceptions(e.message); } on FormatException catch (_) { throw const TFormatException(); } on PlatformException catch (e) { throw TPlatformException(e.code).message; - } on PostgrestException catch (error) { - throw TExceptions.fromCode(error.code ?? 'unknown_error'); } catch (e) { - if (e is TExceptions) { - rethrow; - } + if (e is TExceptions) rethrow; throw TExceptions('Something went wrong. Please try again later.'); } } diff --git a/sigap-mobile/lib/src/cores/repositories/authentication/authentication_repositories.dart b/sigap-mobile/lib/src/cores/repositories/authentication/authentication_repositories.dart new file mode 100644 index 0000000..d5faf79 --- /dev/null +++ b/sigap-mobile/lib/src/cores/repositories/authentication/authentication_repositories.dart @@ -0,0 +1,582 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_native_splash/flutter_native_splash.dart'; +import 'package:get/get.dart'; +import 'package:get_storage/get_storage.dart'; +import 'package:sigap/src/cores/services/biometric_service.dart'; +import 'package:sigap/src/cores/services/location_service.dart'; +import 'package:sigap/src/cores/services/supabase_service.dart'; +import 'package:sigap/src/features/auth/screens/signin/signin_screen.dart'; +import 'package:sigap/src/features/onboarding/screens/onboarding/onboarding_screen.dart'; +import 'package:sigap/src/utils/constants/app_routes.dart'; +import 'package:sigap/src/utils/exceptions/exceptions.dart'; +import 'package:sigap/src/utils/exceptions/format_exceptions.dart'; +import 'package:sigap/src/utils/exceptions/platform_exceptions.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +class AuthenticationRepository extends GetxController { + static AuthenticationRepository get instance => Get.find(); + + // Variable + final storage = GetStorage(); + final _supabase = SupabaseService.instance.client; + final _locationService = LocationService.instance; + final _biometricService = Get.put(BiometricService()); + + // Getters that use the Supabase service + User? get authUser => SupabaseService.instance.currentUser; + String? get currentUserId => SupabaseService.instance.currentUserId; + + @override + void onReady() { + FlutterNativeSplash.remove(); + screenRedirect(); + } + + // 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; + } + return false; + } catch (e) { + // If refresh token is invalid or expired, disable biometric login + await _biometricService.disableBiometricLogin(); + return false; + } + } + + screenRedirect() async { + final user = _supabase.auth.currentUser; + if (user != null) { + // local storage + storage.writeIfNull('isFirstTime', true); + // check if user is already logged in + storage.read('isFirstTime') != true + ? Get.offAll(() => const SignInScreen()) + : Get.offAll(() => const OnboardingScreen()); + } else { + // Try biometric login first + bool biometricSuccess = await attemptBiometricLogin(); + if (!biometricSuccess) { + Get.offAll(() => const SignInScreen()); + } + } + } + + // ----------------- Email and Password Sign In ----------------- + Future signInWithEmailPassword({ + required String email, + required String password, + }) async { + try { + // Check if the user is banned + final bannedUser = await _supabase.rpc( + 'check_if_banned', + params: {'user_email': email}, + ); + if (bannedUser != null && bannedUser == true) { + throw TExceptions( + 'This account has been banned due to violation of terms.', + ); + } + + final response = await _supabase.auth.signInWithPassword( + email: email, + password: password, + ); + + // Setup biometric login if available + if (_biometricService.isBiometricAvailable.value) { + // Ask user if they want to enable biometric login + // This would typically be done in the UI, but setting up the flow here + await _biometricService.enableBiometricLogin(); + } + + return response; + } on AuthException catch (e) { + throw TExceptions(e.message); + } on FormatException catch (_) { + throw const TFormatException(); + } on PlatformException catch (e) { + throw TPlatformException(e.code).message; + } on PostgrestException catch (error) { + throw TExceptions.fromCode(error.code ?? 'unknown_error'); + } catch (e) { + throw TExceptions('Something went wrong. Please try again later.'); + } + } + + // [SESSION] - CHECK SESSION + Future?> getSession() async { + try { + final session = _supabase.auth.currentSession; + if (session != null) { + return {'user': session.user, 'session': session}; + } + return null; + } on PostgrestException catch (error) { + throw TExceptions.fromCode(error.code ?? 'unknown_error'); + } catch (e) { + throw TExceptions('Something went wrong. Please try again later.'); + } + } + + // [Email Verification] - EMAIL VERIFICATION + Future sendEmailVerification() async { + try { + await _supabase.auth.resend( + type: OtpType.signup, + email: _supabase.auth.currentUser!.email, + ); + } on AuthException catch (e) { + throw TExceptions(e.message); + } on FormatException catch (_) { + throw const TFormatException(); + } on PlatformException catch (e) { + throw TPlatformException(e.code).message; + } on PostgrestException catch (error) { + throw TExceptions.fromCode(error.code ?? 'unknown_error'); + } catch (e) { + throw TExceptions('Something went wrong. Please try again later.'); + } + } + + // [Email Reset Password ] - RESET PASSWORD + Future sendResetPasswordForEmail(String email) async { + try { + await _supabase.auth.resetPasswordForEmail(email); + } on AuthException catch (e) { + throw TExceptions(e.message); + } on FormatException catch (_) { + throw const TFormatException(); + } on PlatformException catch (e) { + throw TPlatformException(e.code).message; + } on PostgrestException catch (error) { + throw TExceptions.fromCode(error.code ?? 'unknown_error'); + } catch (e) { + throw TExceptions('Something went wrong. Please try again later.'); + } + } + + // Compare OTP + Future verifyOtp(String otp) async { + try { + final AuthResponse res = await _supabase.auth.verifyOTP( + type: OtpType.signup, + token: otp, + ); + + final Session? session = res.session; + final User? user = res.user; + + if (session == null && user == null) { + throw TExceptions('Failed to verify OTP. Please try again.'); + } + + return res; + } on AuthException catch (e) { + throw TExceptions(e.message); + } on FormatException catch (_) { + throw const TFormatException(); + } on PlatformException catch (e) { + throw TPlatformException(e.code).message; + } on PostgrestException catch (error) { + throw TExceptions.fromCode(error.code ?? 'unknown_error'); + } catch (e) { + throw TExceptions('Something went wrong. Please try again later.'); + } + } + + // Update password after reset + Future updatePasswordAfterReset(String newPassword) async { + try { + final response = await _supabase.auth.updateUser( + UserAttributes(password: newPassword), + ); + return response; + } on AuthException catch (e) { + throw TExceptions(e.message); + } on FormatException catch (_) { + throw const TFormatException(); + } on PlatformException catch (e) { + throw TPlatformException(e.code).message; + } on PostgrestException catch (error) { + throw TExceptions.fromCode(error.code ?? 'unknown_error'); + } catch (e) { + throw TExceptions('Something went wrong. Please try again later.'); + } + } + + Future changePassword( + String currentPassword, + String newPassword, + String email, + ) async { + try { + // Reauthenticate user + await _supabase.auth.reauthenticate(); + + // Update password + await _supabase.auth.updateUser(UserAttributes(password: newPassword)); + + // Password changed successfully + return 'Password changed successfully.'; + } on AuthException catch (e) { + throw TExceptions(e.message); + } on FormatException catch (_) { + throw const TFormatException(); + } on PlatformException catch (e) { + throw TPlatformException(e.code).message; + } on PostgrestException catch (error) { + throw TExceptions.fromCode(error.code ?? 'unknown_error'); + } catch (e) { + throw TExceptions('Something went wrong. Please try again later.'); + } + } + + // [Email Verification] - CREATE NEW VERIFICATION USER EMAIL + Future resendEmailVerification(String email) async { + try { + await _supabase.auth.resend(type: OtpType.signup, email: email); + } on AuthException catch (e) { + throw TExceptions(e.message); + } on FormatException catch (_) { + throw const TFormatException(); + } on PlatformException catch (e) { + throw TPlatformException(e.code).message; + } on PostgrestException catch (error) { + throw TExceptions.fromCode(error.code ?? 'unknown_error'); + } catch (e) { + throw TExceptions('Something went wrong. Please try again later.'); + } + } + + // ----------------- Social Sign In ----------------- + // [GoogleAuthentication] - GOOGLE + Future signInWithGoogle() async { + try { + return await _supabase.auth.signInWithOAuth( + OAuthProvider.google, + authScreenLaunchMode: LaunchMode.inAppWebView, + ); + } on AuthException catch (e) { + throw TExceptions(e.message); + } on FormatException catch (_) { + throw const TFormatException(); + } on PlatformException catch (e) { + throw TPlatformException(e.code).message; + } on PostgrestException catch (error) { + throw TExceptions.fromCode(error.code ?? 'unknown_error'); + } catch (e) { + throw TExceptions('Something went wrong. Please try again later.'); + } + } + + // [FacebookAuthentication] - FACEBOOK + Future signInWithFacebook() async { + try { + return await _supabase.auth.signInWithOAuth( + OAuthProvider.facebook, + authScreenLaunchMode: LaunchMode.inAppWebView, + ); + } on AuthException catch (e) { + throw TExceptions(e.message); + } on FormatException catch (_) { + throw const TFormatException(); + } on PlatformException catch (e) { + throw TPlatformException(e.code).message; + } on PostgrestException catch (error) { + throw TExceptions.fromCode(error.code ?? 'unknown_error'); + } catch (e) { + throw TExceptions('Something went wrong. Please try again later.'); + } + } + + // [AppleAuthentication] - APPLE + Future signInWithApple() async { + try { + return await _supabase.auth.signInWithOAuth( + OAuthProvider.apple, + authScreenLaunchMode: LaunchMode.inAppWebView, + ); + } on AuthException catch (e) { + throw TExceptions(e.message); + } on FormatException catch (_) { + throw const TFormatException(); + } on PlatformException catch (e) { + throw TPlatformException(e.code).message; + } on PostgrestException catch (error) { + throw TExceptions.fromCode(error.code ?? 'unknown_error'); + } catch (e) { + throw TExceptions('Something went wrong. Please try again later.'); + } + } + + // [GithubAuthentication] - GITHUB + Future signInWithGithub() async { + try { + return await _supabase.auth.signInWithOAuth( + OAuthProvider.github, + authScreenLaunchMode: LaunchMode.inAppWebView, + ); + } on AuthException catch (e) { + throw TExceptions(e.message); + } on FormatException catch (_) { + throw const TFormatException(); + } on PlatformException catch (e) { + throw TPlatformException(e.code).message; + } on PostgrestException catch (error) { + throw TExceptions.fromCode(error.code ?? 'unknown_error'); + } catch (e) { + throw TExceptions('Something went wrong. Please try again later.'); + } + } + + // [TwitterAuthentication] - TWITTER + Future signInWithTwitter() async { + try { + return await _supabase.auth.signInWithOAuth( + OAuthProvider.twitter, + authScreenLaunchMode: LaunchMode.inAppWebView, + ); + } on AuthException catch (e) { + throw TExceptions(e.message); + } on FormatException catch (_) { + throw const TFormatException(); + } on PlatformException catch (e) { + throw TPlatformException(e.code).message; + } on PostgrestException catch (error) { + throw TExceptions.fromCode(error.code ?? 'unknown_error'); + } catch (e) { + throw TExceptions('Something went wrong. Please try again later.'); + } + } + + // [Email AUTH] - SIGN UP with role selection and location verification + Future signUpWithCredential( + String email, + String password, + String identifier, { // NIK for users or NRP for officers + Map? userMetadata, + bool isOfficer = false, + Map? officerData, + }) async { + try { + // Validate location for registration + bool isLocationValid = await _locationService.isLocationValidForFeature(); + if (!isLocationValid) { + throw TExceptions( + 'Registration is only available within Jember area. Please ensure your location services are enabled and you are not using a mock location app.', + ); + } + + // Check if identifier (NIK/NRP) is already registered + final identifierField = isOfficer ? 'nrp' : 'nik'; + final identifierType = isOfficer ? 'NRP' : 'NIK'; + + if (isOfficer) { + final existingOfficer = + await _supabase + .from('officers') + .select('id, is_banned') + .eq('nrp', identifier) + .maybeSingle(); + + if (existingOfficer != null) { + bool isBanned = existingOfficer['is_banned'] as bool? ?? false; + if (isBanned) { + throw TExceptions( + 'This $identifierType is associated with a banned account.', + ); + } else { + throw TExceptions('This $identifierType is already registered.'); + } + } + } else { + // For regular users, check NIK in profiles + final existingUser = + await _supabase + .from('profiles') + .select('id, user_id') + .eq('nik', identifier) + .maybeSingle(); + + if (existingUser != null) { + // Check if user is banned + final userId = existingUser['user_id']; + final userBanCheck = + await _supabase + .from('users') + .select('is_banned') + .eq('id', userId) + .maybeSingle(); + + if (userBanCheck != null && + (userBanCheck['is_banned'] as bool? ?? false)) { + throw TExceptions( + 'This $identifierType is associated with a banned account.', + ); + } else { + throw TExceptions('This $identifierType is already registered.'); + } + } + } + + // Prepare the complete user metadata + final metadata = userMetadata ?? {}; + + // Add officer flag and data if needed + metadata['is_officer'] = isOfficer; + if (isOfficer && officerData != null) { + metadata['officer_data'] = officerData; + } + + // Add identifier to metadata + if (isOfficer) { + if (metadata['officer_data'] == null) { + metadata['officer_data'] = {}; + } + metadata['officer_data']['nrp'] = identifier; + } else { + metadata['nik'] = identifier; + } + + final authResponse = await _supabase.auth.signUp( + email: email, + password: password, + data: metadata, + ); + + final Session? session = authResponse.session; + final User? user = authResponse.user; + + if (session == null && user == null) { + throw TExceptions('Failed to sign up. Please try again.'); + } + + // Create profile with NIK if user is not an officer + if (!isOfficer && user != null) { + await _supabase.from('profiles').insert({ + 'user_id': user.id, + 'nik': identifier, + }); + } + + return authResponse; + } on AuthException catch (e) { + throw TExceptions(e.message); + } on FormatException catch (_) { + throw const TFormatException(); + } on PlatformException catch (e) { + throw TPlatformException(e.code).message; + } on PostgrestException catch (error) { + throw TExceptions.fromCode(error.code ?? 'unknown_error'); + } catch (e) { + if (e is TExceptions) { + rethrow; + } + throw TExceptions('Something went wrong. Please try again later.'); + } + } + + // Enable or disable biometric login + Future toggleBiometricLogin(bool enable) async { + if (enable) { + await _biometricService.enableBiometricLogin(); + } else { + await _biometricService.disableBiometricLogin(); + } + } + + // Check if biometric login is enabled + Future isBiometricLoginEnabled() async { + return await _biometricService.isBiometricLoginEnabled(); + } + + // Check if biometrics are available on the device + Future isBiometricAvailable() async { + return _biometricService.isBiometricAvailable.value; + } + + // ----------------- Logout ----------------- + // [Sign Out] - SIGN OUT + Future signOut() async { + try { + await _supabase.auth.signOut(); + Get.offAll(() => const SignInScreen()); + } on AuthException catch (e) { + throw TExceptions(e.message); + } on FormatException catch (_) { + throw const TFormatException(); + } on PlatformException catch (e) { + throw TPlatformException(e.code).message; + } on PostgrestException catch (error) { + throw TExceptions.fromCode(error.code ?? 'unknown_error'); + } catch (e) { + throw TExceptions('Something went wrong. Please try again later.'); + } + } + + // ----------------- Delete Account ----------------- + Future deleteAccount() async { + try { + final userId = _supabase.auth.currentUser!.id; + await _supabase.rpc('delete_account', params: {'user_id': userId}); + } on AuthException catch (e) { + throw TExceptions(e.message); + } on FormatException catch (_) { + throw const TFormatException(); + } on PlatformException catch (e) { + throw TPlatformException(e.code).message; + } on PostgrestException catch (error) { + throw TExceptions.fromCode(error.code ?? 'unknown_error'); + } catch (e) { + throw TExceptions('Something went wrong. Please try again later.'); + } + } + + /// Updates a user's profile with the officer status and metadata + Future updateUserRole({ + required bool isOfficer, + Map? officerData, + }) async { + try { + // Prepare metadata with the officer flag + final userMetadata = {'is_officer': isOfficer}; + + // Add officer data if provided + if (isOfficer && officerData != null) { + userMetadata['officer_data'] = officerData; + } + + // Update the user metadata which will trigger the role change function + final response = await _supabase.auth.updateUser( + UserAttributes(data: userMetadata), + ); + return response; + } on AuthException catch (e) { + throw TExceptions(e.message); + } on FormatException catch (_) { + throw const TFormatException(); + } on PlatformException catch (e) { + throw TPlatformException(e.code).message; + } on PostgrestException catch (error) { + throw TExceptions.fromCode(error.code ?? 'unknown_error'); + } catch (e) { + throw TExceptions('Something went wrong. Please try again later.'); + } + } +} diff --git a/sigap-mobile/lib/src/cores/repositories/officers/officers_repository.dart b/sigap-mobile/lib/src/cores/repositories/daily-ops/officers_repository.dart similarity index 96% rename from sigap-mobile/lib/src/cores/repositories/officers/officers_repository.dart rename to sigap-mobile/lib/src/cores/repositories/daily-ops/officers_repository.dart index 446bcb3..c61818a 100644 --- a/sigap-mobile/lib/src/cores/repositories/officers/officers_repository.dart +++ b/sigap-mobile/lib/src/cores/repositories/daily-ops/officers_repository.dart @@ -1,7 +1,7 @@ import 'package:flutter/services.dart'; import 'package:get/get.dart'; -import 'package:sigap/src/cores/repositories/auth/auth_repositories.dart'; -import 'package:sigap/src/features/personalization/models/index.dart'; +import 'package:sigap/src/cores/repositories/authentication/authentication_repositories.dart'; +import 'package:sigap/src/features/daily-ops/models/officers_model.dart'; import 'package:sigap/src/utils/exceptions/exceptions.dart'; import 'package:sigap/src/utils/exceptions/format_exceptions.dart'; import 'package:sigap/src/utils/exceptions/platform_exceptions.dart'; diff --git a/sigap-mobile/lib/src/cores/repositories/unit/patrol_unit_repository.dart b/sigap-mobile/lib/src/cores/repositories/daily-ops/patrol_units_repository.dart similarity index 100% rename from sigap-mobile/lib/src/cores/repositories/unit/patrol_unit_repository.dart rename to sigap-mobile/lib/src/cores/repositories/daily-ops/patrol_units_repository.dart diff --git a/sigap-mobile/lib/src/cores/repositories/unit/unit_statistics_repository.dart b/sigap-mobile/lib/src/cores/repositories/daily-ops/unit_statistics_repository.dart similarity index 100% rename from sigap-mobile/lib/src/cores/repositories/unit/unit_statistics_repository.dart rename to sigap-mobile/lib/src/cores/repositories/daily-ops/unit_statistics_repository.dart diff --git a/sigap-mobile/lib/src/cores/repositories/unit/unit_repository.dart b/sigap-mobile/lib/src/cores/repositories/daily-ops/units_repository.dart similarity index 100% rename from sigap-mobile/lib/src/cores/repositories/unit/unit_repository.dart rename to sigap-mobile/lib/src/cores/repositories/daily-ops/units_repository.dart diff --git a/sigap-mobile/lib/src/cores/repositories/map/cities_repository.dart b/sigap-mobile/lib/src/cores/repositories/map/cities_repository.dart new file mode 100644 index 0000000..e69de29 diff --git a/sigap-mobile/lib/src/cores/repositories/map/demographics_repository.dart b/sigap-mobile/lib/src/cores/repositories/map/demographics_repository.dart new file mode 100644 index 0000000..e69de29 diff --git a/sigap-mobile/lib/src/cores/repositories/map/districts_repository.dart b/sigap-mobile/lib/src/cores/repositories/map/districts_repository.dart new file mode 100644 index 0000000..e69de29 diff --git a/sigap-mobile/lib/src/cores/repositories/map/geogrpaphics_repository.dart b/sigap-mobile/lib/src/cores/repositories/map/geogrpaphics_repository.dart new file mode 100644 index 0000000..e69de29 diff --git a/sigap-mobile/lib/src/cores/repositories/map/location_logs_repository.dart b/sigap-mobile/lib/src/cores/repositories/map/location_logs_repository.dart new file mode 100644 index 0000000..e69de29 diff --git a/sigap-mobile/lib/src/cores/repositories/map/locations_repository.dart b/sigap-mobile/lib/src/cores/repositories/map/locations_repository.dart new file mode 100644 index 0000000..e69de29 diff --git a/sigap-mobile/lib/src/cores/repositories/panic-button/crime_incidents_repository.dart b/sigap-mobile/lib/src/cores/repositories/panic-button/crime_incidents_repository.dart new file mode 100644 index 0000000..e69de29 diff --git a/sigap-mobile/lib/src/cores/repositories/panic-button/crimes_repository.dart b/sigap-mobile/lib/src/cores/repositories/panic-button/crimes_repository.dart new file mode 100644 index 0000000..e69de29 diff --git a/sigap-mobile/lib/src/cores/repositories/panic-button/events_repository.dart b/sigap-mobile/lib/src/cores/repositories/panic-button/events_repository.dart new file mode 100644 index 0000000..e69de29 diff --git a/sigap-mobile/lib/src/cores/repositories/panic-button/evidences_repository.dart b/sigap-mobile/lib/src/cores/repositories/panic-button/evidences_repository.dart new file mode 100644 index 0000000..e69de29 diff --git a/sigap-mobile/lib/src/cores/repositories/panic-button/incident_logs_repository.dart b/sigap-mobile/lib/src/cores/repositories/panic-button/incident_logs_repository.dart new file mode 100644 index 0000000..e69de29 diff --git a/sigap-mobile/lib/src/cores/repositories/panic-button/panic_button_repository.dart b/sigap-mobile/lib/src/cores/repositories/panic-button/panic_button_repository.dart new file mode 100644 index 0000000..5a2a857 --- /dev/null +++ b/sigap-mobile/lib/src/cores/repositories/panic-button/panic_button_repository.dart @@ -0,0 +1,487 @@ +import 'package:get/get.dart'; +import 'package:sigap/src/cores/services/location_service.dart'; +import 'package:sigap/src/cores/services/supabase_service.dart'; +import 'package:sigap/src/features/auth/models/user_metadata_model.dart'; +import 'package:sigap/src/utils/exceptions/exceptions.dart'; + +class PanicButtonRepository extends GetxController { + static PanicButtonRepository get instance => Get.find(); + + final _supabase = SupabaseService.instance.client; + final _locationService = Get.find(); + + // Rate limiting + final RxInt panicAttemptsInWindow = 0.obs; + final RxBool isRateLimited = false.obs; + final RxInt panicStrikeCount = 0.obs; + final Rx lastPanicTime = Rx(null); + final RxBool isPanicActive = false.obs; + + // Constants + static const int maxPanicAlertsIn5Minutes = 3; + static const int strikesTillBan = 3; + static const int rateLimitWindowMinutes = 5; + + @override + void onInit() { + super.onInit(); + _loadPanicStrikeCount(); + // Start location monitoring + _locationService.init(); + } + + // Check if user can use panic button + Future> canUsePanicButton() async { + // Check if user is logged in + final user = SupabaseService.instance.currentUser; + if (user == null) { + return { + 'allowed': false, + 'reason': 'not_authenticated', + 'message': 'You need to sign in to use the panic button.', + }; + } + + // Check if user is banned + final isBanned = await _checkIfUserIsBanned(); + if (isBanned) { + return { + 'allowed': false, + 'reason': 'banned', + 'message': + 'Your account is temporarily banned from using the panic button due to misuse.', + }; + } + + // Check rate limit + if (_isRateLimited()) { + return { + 'allowed': false, + 'reason': 'rate_limited', + 'message': + 'You have reached the maximum number of panic alerts. Please try again later.', + 'minutesLeft': _getRateLimitMinutesLeft(), + }; + } + + // Validate location + final locationValid = + await _locationService.validateLocationForPanicButton(); + if (locationValid['valid'] != true) { + return { + 'allowed': false, + 'reason': locationValid['reason'], + 'message': locationValid['message'], + }; + } + + return {'allowed': true, 'position': locationValid['position']}; + } + + // Send a panic alert + Future> sendPanicAlert({ + required String description, + required String categoryId, + String? evidenceUrl, + }) async { + try { + // Check if panic button can be used + final canUse = await canUsePanicButton(); + if (canUse['allowed'] != true) { + return canUse; + } + + final position = canUse['position']; + final userId = SupabaseService.instance.currentUser!.id; + + // Set panic as active + isPanicActive.value = true; + + // Create a new location entry + final locationId = await _createLocation( + latitude: position['latitude'], + longitude: position['longitude'], + ); + + // Create incident log + final incidentId = await _createIncidentLog( + userId: userId, + locationId: locationId, + description: description, + categoryId: categoryId, + ); + + // Create panic button log + await _createPanicButtonLog(userId: userId, incidentId: incidentId); + + // Add evidence if provided + if (evidenceUrl != null && evidenceUrl.isNotEmpty) { + await _addEvidence(incidentId: incidentId, url: evidenceUrl); + } + + // Update rate limiting + _incrementPanicCount(); + lastPanicTime.value = DateTime.now(); + + return { + 'success': true, + 'incidentId': incidentId, + 'message': 'Panic alert sent successfully. Help is on the way!', + }; + } on TExceptions catch (e) { + isPanicActive.value = false; + return {'success': false, 'message': e.message}; + } catch (e) { + isPanicActive.value = false; + return { + 'success': false, + 'message': 'Failed to send panic alert: ${e.toString()}', + }; + } + } + + // Cancel active panic alert + Future> cancelPanicAlert(String incidentId) async { + try { + // Update the incident status + await _supabase + .from('incident_logs') + .update({ + 'status': 'cancelled', + 'updated_at': DateTime.now().toIso8601String(), + }) + .eq('id', incidentId); + + isPanicActive.value = false; + + return { + 'success': true, + 'message': 'Panic alert cancelled successfully.', + }; + } catch (e) { + return { + 'success': false, + 'message': 'Failed to cancel panic alert: ${e.toString()}', + }; + } + } + + // HELPER METHODS + + Future _createLocation({ + required double latitude, + required double longitude, + }) async { + try { + final response = await _supabase.rpc( + 'create_location', + params: { + 'lat': latitude, + 'lng': longitude, + }); + + return response as String; + } catch (e) { + throw TExceptions('Failed to create location record'); + } + } + + Future _createIncidentLog({ + required String userId, + required String locationId, + required String description, + required String categoryId, + }) async { + try { + final response = + await _supabase + .from('incident_logs') + .insert({ + 'user_id': userId, + 'location_id': locationId, + 'category_id': categoryId, + 'description': description, + 'source': 'panic_button', + 'time': DateTime.now().toIso8601String(), + }).select('id').single(); + + return response['id'] as String; + } catch (e) { + throw TExceptions('Failed to create incident log'); + } + } + + Future _createPanicButtonLog({ + required String userId, + required String incidentId, + }) async { + try { + // Get officer ID if user is an officer + String? officerId; + final userMetadata = UserMetadataModel.fromJson( + SupabaseService.instance.currentUser?.userMetadata, + ); + + if (userMetadata.isOfficer && userMetadata.officerData != null) { + final officer = + await _supabase + .from('officers') + .select('id') + .eq('nrp', userMetadata.officerData!.nrp) + .maybeSingle(); + + if (officer != null) { + officerId = officer['id']; + } + } + + await _supabase.from('panic_button_logs').insert({ + 'user_id': userId, + 'officer_id': officerId, + 'incident_id': incidentId, + 'timestamp': DateTime.now().toIso8601String(), + }); + } catch (e) { + throw TExceptions('Failed to create panic button log'); + } + } + + Future _addEvidence({ + required String incidentId, + required String url, + }) async { + try { + await _supabase.from('evidence').insert({ + 'id': 'E${DateTime.now().millisecondsSinceEpoch}', + 'incident_id': incidentId, + 'type': 'photo', + 'url': url, + 'uploaded_at': DateTime.now().toIso8601String(), + }); + } catch (e) { + // Don't fail the whole panic alert if evidence fails + print('Failed to add evidence: $e'); + } + } + + // Check if user is rate limited + bool _isRateLimited() { + if (isRateLimited.value) return true; + + if (lastPanicTime.value != null) { + final timeSinceLastPanic = DateTime.now().difference( + lastPanicTime.value!, + ); + if (timeSinceLastPanic.inMinutes < rateLimitWindowMinutes) { + if (panicAttemptsInWindow.value >= maxPanicAlertsIn5Minutes) { + isRateLimited.value = true; + return true; + } + } else { + // Reset counter if window has passed + panicAttemptsInWindow.value = 0; + } + } + + return false; + } + + // Get minutes left in rate limit + int _getRateLimitMinutesLeft() { + if (lastPanicTime.value == null) return 0; + + final timeSinceLastPanic = DateTime.now().difference(lastPanicTime.value!); + final minutesLeft = rateLimitWindowMinutes - timeSinceLastPanic.inMinutes; + + return minutesLeft > 0 ? minutesLeft : 0; + } + + // Increment panic count for rate limiting + void _incrementPanicCount() { + panicAttemptsInWindow.value++; + + if (panicAttemptsInWindow.value >= maxPanicAlertsIn5Minutes) { + isRateLimited.value = true; + _incrementPanicStrike(); + } + } + + // Increment panic strike count + Future _incrementPanicStrike() async { + try { + final userId = SupabaseService.instance.currentUser?.id; + if (userId == null) return; + + // Check if user is an officer + final userMetadata = UserMetadataModel.fromJson( + SupabaseService.instance.currentUser?.userMetadata, + ); + + panicStrikeCount.value++; + + // Add strike in database based on user type + if (userMetadata.isOfficer && userMetadata.officerData != null) { + // Officer + await _supabase + .from('officers') + .update({ + 'panic_strike': panicStrikeCount.value, + 'is_banned': panicStrikeCount.value >= strikesTillBan, + 'banned_reason': + panicStrikeCount.value >= strikesTillBan + ? 'Panic button misuse' + : null, + 'banned_until': + panicStrikeCount.value >= strikesTillBan + ? DateTime.now() + .add(const Duration(days: 3)) + .toIso8601String() + : null, + }) + .eq('nrp', userMetadata.officerData!.nrp); + } else { + // Regular user + await _supabase + .from('users') + .update({ + 'panic_strike': panicStrikeCount.value, + 'is_banned': panicStrikeCount.value >= strikesTillBan, + 'banned_reason': + panicStrikeCount.value >= strikesTillBan + ? 'Panic button misuse' + : null, + 'banned_until': + panicStrikeCount.value >= strikesTillBan + ? DateTime.now() + .add(const Duration(days: 3)) + .toIso8601String() + : null, + }) + .eq('id', userId); + } + } catch (e) { + print('Failed to update panic strike: $e'); + } + } + + // Check if user is banned + Future _checkIfUserIsBanned() async { + try { + final userId = SupabaseService.instance.currentUser?.id; + if (userId == null) return false; + + // Check if user is an officer + final userMetadata = UserMetadataModel.fromJson( + SupabaseService.instance.currentUser?.userMetadata, + ); + + if (userMetadata.isOfficer && userMetadata.officerData != null) { + // Officer + final officer = + await _supabase + .from('officers') + .select('is_banned, banned_until') + .eq('nrp', userMetadata.officerData!.nrp) + .maybeSingle(); + + if (officer == null) return false; + + bool isBanned = officer['is_banned'] ?? false; + if (!isBanned) return false; + + // Check if ban has expired + final bannedUntil = officer['banned_until']; + if (bannedUntil != null) { + final banEndDate = DateTime.parse(bannedUntil); + if (DateTime.now().isAfter(banEndDate)) { + // Ban expired, remove it + await _supabase + .from('officers') + .update({ + 'is_banned': false, + 'banned_until': null, + 'banned_reason': null, + }) + .eq('nrp', userMetadata.officerData!.nrp); + return false; + } + } + + return isBanned; + } else { + // Regular user + final user = + await _supabase + .from('users') + .select('is_banned, banned_until') + .eq('id', userId) + .single(); + + bool isBanned = user['is_banned'] ?? false; + if (!isBanned) return false; + + // Check if ban has expired + final bannedUntil = user['banned_until']; + if (bannedUntil != null) { + final banEndDate = DateTime.parse(bannedUntil); + if (DateTime.now().isAfter(banEndDate)) { + // Ban expired, remove it + await _supabase + .from('users') + .update({ + 'is_banned': false, + 'banned_until': null, + 'banned_reason': null, + }) + .eq('id', userId); + return false; + } + } + + return isBanned; + } + } catch (e) { + print('Failed to check ban status: $e'); + return false; + } + } + + // Load panic strike count from database + Future _loadPanicStrikeCount() async { + try { + final userId = SupabaseService.instance.currentUser?.id; + if (userId == null) return; + + // Check if user is an officer + final userMetadata = UserMetadataModel.fromJson( + SupabaseService.instance.currentUser?.userMetadata, + ); + + if (userMetadata.isOfficer && userMetadata.officerData != null) { + // Officer + final officer = + await _supabase + .from('officers') + .select('panic_strike') + .eq('nrp', userMetadata.officerData!.nrp) + .maybeSingle(); + + if (officer != null) { + panicStrikeCount.value = officer['panic_strike'] ?? 0; + } + } else { + // Regular user + final user = + await _supabase + .from('users') + .select('panic_strike') + .eq('id', userId) + .single(); + + panicStrikeCount.value = user['panic_strike'] ?? 0; + } + } catch (e) { + print('Failed to load panic strike count: $e'); + } + } +} diff --git a/sigap-mobile/lib/src/cores/repositories/panic-button/sessions_repository.dart b/sigap-mobile/lib/src/cores/repositories/panic-button/sessions_repository.dart new file mode 100644 index 0000000..e69de29 diff --git a/sigap-mobile/lib/src/cores/repositories/panic/panic_button_repository.dart b/sigap-mobile/lib/src/cores/repositories/panic/panic_button_repository.dart index 7b699ec..e69de29 100644 --- a/sigap-mobile/lib/src/cores/repositories/panic/panic_button_repository.dart +++ b/sigap-mobile/lib/src/cores/repositories/panic/panic_button_repository.dart @@ -1,446 +0,0 @@ -import 'package:get/get.dart'; -import 'package:sigap/src/cores/services/location_service.dart'; -import 'package:sigap/src/cores/services/supabase_service.dart'; -import 'package:sigap/src/utils/exceptions/exceptions.dart'; -import 'package:supabase_flutter/supabase_flutter.dart'; - -class PanicButtonRepository extends GetxController { - static PanicButtonRepository get instance => Get.find(); - - final _supabase = SupabaseService.instance.client; - final _locationService = LocationService.instance; - - // Rate limiting - final RxInt recentPanicCount = 0.obs; - final RxBool isRateLimited = false.obs; - final RxInt panicStrikeCount = 0.obs; - - // Constants - static const int maxPanicAlertsIn5Minutes = 3; - static const int strikesTillBan = 3; - - // Initialize - Future init() async { - await _loadPanicStrikeCount(); - _startRateLimitCooldown(); - } - - // Send a panic alert - Future sendPanicAlert({ - required String description, - required String categoryId, - String? mediaUrl, - }) async { - try { - final userId = SupabaseService.instance.currentUserId; - if (userId == null) { - throw TExceptions('User not logged in.'); - } - - // Check if user is banned - bool isBanned = await _checkIfUserIsBanned(); - if (isBanned) { - throw TExceptions('Your account has been temporarily banned from using the panic button due to misuse.'); - } - - // Check location validity - if (!await _locationService.isLocationValidForFeature()) { - _incrementSpoofingAttempts(); - throw TExceptions('Location validation failed. Please ensure you are in Jember and not using a mock location app.'); - } - - // Check rate limit - if (isRateLimited.value || recentPanicCount.value >= maxPanicAlertsIn5Minutes) { - _incrementPanicStrike(); - throw TExceptions('You have reached the maximum number of panic alerts. Please try again later.'); - } - - // Get current location - final position = await _locationService.getCurrentPosition(); - if (position == null) { - throw TExceptions('Failed to get current location.'); - } - - // Create a new incident log - final incidentId = await _createIncidentLog( - userId: userId, - latitude: position.latitude, - longitude: position.longitude, - description: description, - categoryId: categoryId, - ); - - // Create panic button log - await _createPanicButtonLog( - userId: userId, - incidentId: incidentId, - ); - - // Add media if provided - if (mediaUrl != null && mediaUrl.isNotEmpty) { - await _addEvidenceToIncident(incidentId, mediaUrl); - } - - // Increment panic count for rate limiting - recentPanicCount.value++; - _checkRateLimit(); - - return true; - } on TExceptions { - rethrow; - } catch (e) { - throw TExceptions('Failed to send panic alert: ${e.toString()}'); - } - } - - // Create an incident log - Future _createIncidentLog({ - required String userId, - required double latitude, - required double longitude, - required String description, - required String categoryId, - }) async { - try { - // First create a location - final locationResponse = await _supabase.rpc('create_location', params: { - 'lat': latitude, - 'long': longitude, - 'district_id': null, // Will be filled by database function - 'event_id': null, // Will be filled by database function - }); - - final locationId = locationResponse as String; - - // Then create incident log - final incidentResponse = await _supabase.from('incident_logs').insert({ - 'user_id': userId, - 'location_id': locationId, - 'category_id': categoryId, - 'description': description, - 'source': 'panic_button', - 'time': DateTime.now().toIso8601String(), - 'verified': false, - }).select('id').single(); - - return incidentResponse['id'] as String; - } catch (e) { - throw TExceptions('Failed to create incident log: ${e.toString()}'); - } - } - - // Create panic button log - Future _createPanicButtonLog({ - required String userId, - required String incidentId, - }) async { - try { - // Get officer ID if the user is an officer - String? officerId; - final user = SupabaseService.instance.currentUser; - if (user != null && user.userMetadata?['is_officer'] == true) { - final officerQuery = await _supabase - .from('officers') - .select('id') - .eq('nrp', user.userMetadata?['officer_data']?['nrp']) - .maybeSingle(); - - if (officerQuery != null) { - officerId = officerQuery['id'] as String; - } - } - - await _supabase.from('panic_button_logs').insert({ - 'user_id': userId, - 'incident_id': incidentId, - 'officer_id': officerId, - 'timestamp': DateTime.now().toIso8601String(), - }); - } catch (e) { - throw TExceptions('Failed to create panic button log: ${e.toString()}'); - } - } - - // Add evidence to incident - Future _addEvidenceToIncident(String incidentId, String mediaUrl) async { - try { - await _supabase.from('evidence').insert({ - 'id': 'EV-${DateTime.now().millisecondsSinceEpoch}', - 'incident_id': incidentId, - 'type': 'photo', - 'url': mediaUrl, - 'uploaded_at': DateTime.now().toIso8601String(), - }); - } catch (e) { - // Non-critical error, just log it - print('Failed to add evidence: $e'); - } - } - - // Check rate limit - void _checkRateLimit() { - if (recentPanicCount.value >= maxPanicAlertsIn5Minutes) { - isRateLimited.value = true; - _incrementPanicStrike(); - } - } - - // Start cooldown for rate limiting - void _startRateLimitCooldown() { - // Reset panic count every 5 minutes - Future.delayed(const Duration(minutes: 5), () { - recentPanicCount.value = 0; - isRateLimited.value = false; - _startRateLimitCooldown(); - }); - } - - // Increment panic strike - Future _incrementPanicStrike() async { - try { - final userId = SupabaseService.instance.currentUserId; - if (userId == null) return; - - // Check if user is officer - final user = SupabaseService.instance.currentUser; - bool isOfficer = user?.userMetadata?['is_officer'] == true; - - // Increment strike in the appropriate table - if (isOfficer) { - final officerQuery = await _supabase - .from('officers') - .select('id, nrp, panic_strike') - .eq('nrp', user?.userMetadata?['officer_data']?['nrp']) - .single(); - - int currentStrikes = officerQuery['panic_strike'] as int; - panicStrikeCount.value = currentStrikes + 1; - - await _supabase - .from('officers') - .update({ - 'panic_strike': panicStrikeCount.value, - 'is_banned': panicStrikeCount.value >= strikesTillBan, - 'banned_reason': panicStrikeCount.value >= strikesTillBan - ? 'Exceeded panic button usage limit' - : null, - 'banned_until': panicStrikeCount.value >= strikesTillBan - ? DateTime.now().add(const Duration(days: 3)).toIso8601String() - : null, - }) - .eq('id', officerQuery['id']); - } else { - // Regular user - final userQuery = await _supabase - .from('users') - .select('panic_strike') - .eq('id', userId) - .single(); - - int currentStrikes = userQuery['panic_strike'] as int; - panicStrikeCount.value = currentStrikes + 1; - - await _supabase - .from('users') - .update({ - 'panic_strike': panicStrikeCount.value, - 'is_banned': panicStrikeCount.value >= strikesTillBan, - 'banned_reason': panicStrikeCount.value >= strikesTillBan - ? 'Exceeded panic button usage limit' - : null, - 'banned_until': panicStrikeCount.value >= strikesTillBan - ? DateTime.now().add(const Duration(days: 3)).toIso8601String() - : null, - }) - .eq('id', userId); - } - } catch (e) { - print('Failed to update panic strike: $e'); - } - } - - // Increment spoofing attempts - Future _incrementSpoofingAttempts() async { - try { - final userId = SupabaseService.instance.currentUserId; - if (userId == null) return; - - // Check if user is officer - final user = SupabaseService.instance.currentUser; - bool isOfficer = user?.userMetadata?['is_officer'] == true; - - // Increment spoofing attempts in the appropriate table - if (isOfficer) { - final officerQuery = await _supabase - .from('officers') - .select('id, nrp, spoofing_attempts') - .eq('nrp', user?.userMetadata?['officer_data']?['nrp']) - .single(); - - int currentAttempts = officerQuery['spoofing_attempts'] as int; - int newAttempts = currentAttempts + 1; - - await _supabase - .from('officers') - .update({ - 'spoofing_attempts': newAttempts, - 'is_banned': newAttempts >= 2, - 'banned_reason': newAttempts >= 2 - ? 'Suspected location spoofing' - : null, - 'banned_until': newAttempts >= 2 - ? DateTime.now().add(const Duration(days: 7)).toIso8601String() - : null, - }) - .eq('id', officerQuery['id']); - } else { - // Regular user - final userQuery = await _supabase - .from('users') - .select('spoofing_attempts') - .eq('id', userId) - .single(); - - int currentAttempts = userQuery['spoofing_attempts'] as int; - int newAttempts = currentAttempts + 1; - - await _supabase - .from('users') - .update({ - 'spoofing_attempts': newAttempts, - 'is_banned': newAttempts >= 2, - 'banned_reason': newAttempts >= 2 - ? 'Suspected location spoofing' - : null, - 'banned_until': newAttempts >= 2 - ? DateTime.now().add(const Duration(days: 7)).toIso8601String() - : null, - }) - .eq('id', userId); - } - } catch (e) { - print('Failed to update spoofing attempts: $e'); - } - } - - // Check if user is banned - Future _checkIfUserIsBanned() async { - try { - final userId = SupabaseService.instance.currentUserId; - if (userId == null) return false; - - // Check if user is officer - final user = SupabaseService.instance.currentUser; - bool isOfficer = user?.userMetadata?['is_officer'] == true; - - if (isOfficer) { - final officerQuery = await _supabase - .from('officers') - .select('is_banned, banned_until') - .eq('nrp', user?.userMetadata?['officer_data']?['nrp']) - .maybeSingle(); - - if (officerQuery == null) return false; - - bool isBanned = officerQuery['is_banned'] as bool? ?? false; - String? bannedUntil = officerQuery['banned_until'] as String?; - - // Check if ban has expired - if (isBanned && bannedUntil != null) { - DateTime banEnd = DateTime.parse(bannedUntil); - if (DateTime.now().isAfter(banEnd)) { - // Ban has expired, remove it - await _supabase - .from('officers') - .update({ - 'is_banned': false, - 'banned_until': null, - 'banned_reason': null, - }) - .eq('nrp', user?.userMetadata?['officer_data']?['nrp']); - return false; - } - } - - return isBanned; - } else { - // Regular user - final userQuery = await _supabase - .from('users') - .select('is_banned, banned_until') - .eq('id', userId) - .single(); - - bool isBanned = userQuery['is_banned'] as bool? ?? false; - String? bannedUntil = userQuery['banned_until'] as String?; - - // Check if ban has expired - if (isBanned && bannedUntil != null) { - DateTime banEnd = DateTime.parse(bannedUntil); - if (DateTime.now().isAfter(banEnd)) { - // Ban has expired, remove it - await _supabase - .from('users') - .update({ - 'is_banned': false, - 'banned_until': null, - 'banned_reason': null, - }) - .eq('id', userId); - return false; - } - } - - return isBanned; - } - } catch (e) { - print('Failed to check ban status: $e'); - return false; - } - } - - // Load panic strike count - Future _loadPanicStrikeCount() async { - try { - final userId = SupabaseService.instance.currentUserId; - if (userId == null) { - panicStrikeCount.value = 0; - return; - } - - // Check if user is officer - final user = SupabaseService.instance.currentUser; - bool isOfficer = user?.userMetadata?['is_officer'] == true; - - if (isOfficer) { - final officerQuery = await _supabase - .from('officers') - .select('panic_strike') - .eq('nrp', user?.userMetadata?['officer_data']?['nrp']) - .maybeSingle(); - - if (officerQuery != null) { - panicStrikeCount.value = officerQuery['panic_strike'] as int? ?? 0; - } else { - panicStrikeCount.value = 0; - } - } else { - // Regular user - final userQuery = await _supabase - .from('users') - .select('panic_strike') - .eq('id', userId) - .maybeSingle(); - - if (userQuery != null) { - panicStrikeCount.value = userQuery['panic_strike'] as int? ?? 0; - } else { - panicStrikeCount.value = 0; - } - } - } catch (e) { - panicStrikeCount.value = 0; - print('Failed to load panic strike count: $e'); - } - } -} diff --git a/sigap-mobile/lib/src/cores/repositories/personalization/permissions_repository.dart b/sigap-mobile/lib/src/cores/repositories/personalization/permissions_repository.dart new file mode 100644 index 0000000..e69de29 diff --git a/sigap-mobile/lib/src/cores/repositories/personalization/profile_repository.dart b/sigap-mobile/lib/src/cores/repositories/personalization/profile_repository.dart new file mode 100644 index 0000000..7b0e3d4 --- /dev/null +++ b/sigap-mobile/lib/src/cores/repositories/personalization/profile_repository.dart @@ -0,0 +1,117 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:sigap/src/features/personalization/models/profile_model.dart'; +import 'package:sigap/src/utils/exceptions/exceptions.dart'; +import 'package:sigap/src/utils/exceptions/format_exceptions.dart'; +import 'package:sigap/src/utils/exceptions/platform_exceptions.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +class ProfileRepository extends GetxController { + static ProfileRepository get instance => Get.find(); + + final _supabase = Supabase.instance.client; + + // Get current user ID + String? get currentUserId => _supabase.auth.currentUser?.id; + + // Get user profile data + Future getProfileData() async { + try { + if (currentUserId == null) { + throw 'User not authenticated'; + } + + final profileData = + await _supabase + .from('profiles') + .select() + .eq('user_id', currentUserId!) + .single(); + + return ProfileModel.fromJson(profileData); + } on PostgrestException catch (error) { + throw TExceptions.fromCode(error.code!); + } on FormatException catch (_) { + throw const TFormatException(); + } on PlatformException catch (e) { + throw TPlatformException(e.code).message; + } catch (e) { + throw 'Failed to fetch profile data: ${e.toString()}'; + } + } + + // Update avatar + Future uploadAvatar(String filePath) async { + try { + if (currentUserId == null) { + throw 'User not authenticated'; + } + + final fileName = + '${currentUserId}_${DateTime.now().millisecondsSinceEpoch}.jpg'; + final storageResponse = await _supabase.storage + .from('avatars') + .upload(fileName, File(filePath)); + + final avatarUrl = _supabase.storage + .from('avatars') + .getPublicUrl(fileName); + + // Update the profile with the new avatar URL + await _supabase + .from('profiles') + .update({'avatar': avatarUrl}) + .eq('user_id', currentUserId!); + + return avatarUrl; + } on StorageException catch (e) { + throw 'Storage error: ${e.message}'; + } on PostgrestException catch (error) { + throw TExceptions.fromCode(error.code!); + } catch (e) { + throw 'Failed to upload avatar: ${e.toString()}'; + } + } + + // Update profile information + Future updateProfile(ProfileModel profile) async { + try { + if (currentUserId == null) { + throw 'User not authenticated'; + } + + final updatedProfileData = profile.toJson(); + + await _supabase + .from('profiles') + .update(updatedProfileData) + .eq('user_id', currentUserId!); + + return await getProfileData(); + } on PostgrestException catch (error) { + throw TExceptions.fromCode(error.code!); + } catch (e) { + throw 'Failed to update profile: ${e.toString()}'; + } + } + + // Get profile by user ID + Future getProfileByUserId(String userId) async { + try { + final profileData = + await _supabase + .from('profiles') + .select() + .eq('user_id', userId) + .single(); + + return ProfileModel.fromJson(profileData); + } on PostgrestException catch (error) { + throw TExceptions.fromCode(error.code!); + } catch (e) { + throw 'Failed to fetch profile data: ${e.toString()}'; + } + } +} diff --git a/sigap-mobile/lib/src/cores/repositories/personalization/resources_repository.dart b/sigap-mobile/lib/src/cores/repositories/personalization/resources_repository.dart new file mode 100644 index 0000000..e69de29 diff --git a/sigap-mobile/lib/src/cores/repositories/roles/roles_repository.dart b/sigap-mobile/lib/src/cores/repositories/personalization/roles_repository.dart similarity index 100% rename from sigap-mobile/lib/src/cores/repositories/roles/roles_repository.dart rename to sigap-mobile/lib/src/cores/repositories/personalization/roles_repository.dart diff --git a/sigap-mobile/lib/src/cores/repositories/users/users_repository.dart b/sigap-mobile/lib/src/cores/repositories/personalization/users_repository.dart similarity index 100% rename from sigap-mobile/lib/src/cores/repositories/users/users_repository.dart rename to sigap-mobile/lib/src/cores/repositories/personalization/users_repository.dart diff --git a/sigap-mobile/lib/src/cores/repositories/profile/profile_repository.dart b/sigap-mobile/lib/src/cores/repositories/profile/profile_repository.dart index 7b0e3d4..e69de29 100644 --- a/sigap-mobile/lib/src/cores/repositories/profile/profile_repository.dart +++ b/sigap-mobile/lib/src/cores/repositories/profile/profile_repository.dart @@ -1,117 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/services.dart'; -import 'package:get/get.dart'; -import 'package:sigap/src/features/personalization/models/profile_model.dart'; -import 'package:sigap/src/utils/exceptions/exceptions.dart'; -import 'package:sigap/src/utils/exceptions/format_exceptions.dart'; -import 'package:sigap/src/utils/exceptions/platform_exceptions.dart'; -import 'package:supabase_flutter/supabase_flutter.dart'; - -class ProfileRepository extends GetxController { - static ProfileRepository get instance => Get.find(); - - final _supabase = Supabase.instance.client; - - // Get current user ID - String? get currentUserId => _supabase.auth.currentUser?.id; - - // Get user profile data - Future getProfileData() async { - try { - if (currentUserId == null) { - throw 'User not authenticated'; - } - - final profileData = - await _supabase - .from('profiles') - .select() - .eq('user_id', currentUserId!) - .single(); - - return ProfileModel.fromJson(profileData); - } on PostgrestException catch (error) { - throw TExceptions.fromCode(error.code!); - } on FormatException catch (_) { - throw const TFormatException(); - } on PlatformException catch (e) { - throw TPlatformException(e.code).message; - } catch (e) { - throw 'Failed to fetch profile data: ${e.toString()}'; - } - } - - // Update avatar - Future uploadAvatar(String filePath) async { - try { - if (currentUserId == null) { - throw 'User not authenticated'; - } - - final fileName = - '${currentUserId}_${DateTime.now().millisecondsSinceEpoch}.jpg'; - final storageResponse = await _supabase.storage - .from('avatars') - .upload(fileName, File(filePath)); - - final avatarUrl = _supabase.storage - .from('avatars') - .getPublicUrl(fileName); - - // Update the profile with the new avatar URL - await _supabase - .from('profiles') - .update({'avatar': avatarUrl}) - .eq('user_id', currentUserId!); - - return avatarUrl; - } on StorageException catch (e) { - throw 'Storage error: ${e.message}'; - } on PostgrestException catch (error) { - throw TExceptions.fromCode(error.code!); - } catch (e) { - throw 'Failed to upload avatar: ${e.toString()}'; - } - } - - // Update profile information - Future updateProfile(ProfileModel profile) async { - try { - if (currentUserId == null) { - throw 'User not authenticated'; - } - - final updatedProfileData = profile.toJson(); - - await _supabase - .from('profiles') - .update(updatedProfileData) - .eq('user_id', currentUserId!); - - return await getProfileData(); - } on PostgrestException catch (error) { - throw TExceptions.fromCode(error.code!); - } catch (e) { - throw 'Failed to update profile: ${e.toString()}'; - } - } - - // Get profile by user ID - Future getProfileByUserId(String userId) async { - try { - final profileData = - await _supabase - .from('profiles') - .select() - .eq('user_id', userId) - .single(); - - return ProfileModel.fromJson(profileData); - } on PostgrestException catch (error) { - throw TExceptions.fromCode(error.code!); - } catch (e) { - throw 'Failed to fetch profile data: ${e.toString()}'; - } - } -} diff --git a/sigap-mobile/lib/src/cores/repositories/units/units_repository.dart b/sigap-mobile/lib/src/cores/repositories/units/units_repository.dart deleted file mode 100644 index 7849244..0000000 --- a/sigap-mobile/lib/src/cores/repositories/units/units_repository.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:get/get.dart'; - -class UnitsRepository extends GetxController { - static UnitsRepository get instance => Get.find(); - - // Add your methods and properties here - // For example, you can add methods to fetch units, create units, etc. - - // Example method to fetch all units - Future> getAllUnits() async { - try { - final response = await _supabase.from('units').select(); - return (response as List) - .map((unit) => UnitModel.fromJson(unit)) - .toList(); - } catch (e) { - throw 'Failed to fetch units: ${e.toString()}'; - } - } -} diff --git a/sigap-mobile/lib/src/cores/repositories/user/user_repository.dart b/sigap-mobile/lib/src/cores/repositories/user/user_repository.dart new file mode 100644 index 0000000..e69de29 diff --git a/sigap-mobile/lib/src/cores/routes/app_pages.dart b/sigap-mobile/lib/src/cores/routes/app_pages.dart index f867d56..6be5326 100644 --- a/sigap-mobile/lib/src/cores/routes/app_pages.dart +++ b/sigap-mobile/lib/src/cores/routes/app_pages.dart @@ -3,8 +3,8 @@ import 'package:sigap/src/cores/bindings/controller_bindings.dart'; import 'package:sigap/src/features/auth/screens/forgot-password/forgot_password.dart'; import 'package:sigap/src/features/auth/screens/signin/signin_screen.dart'; import 'package:sigap/src/features/auth/screens/signup/signup_screen.dart'; -import 'package:sigap/src/features/onboarding/screens/choose-role/choose_role_screen.dart'; import 'package:sigap/src/features/onboarding/screens/onboarding/onboarding_screen.dart'; +import 'package:sigap/src/features/onboarding/screens/role-selection/role_selection_screen.dart'; import 'package:sigap/src/utils/constants/app_routes.dart'; class AppPages { @@ -19,9 +19,9 @@ class AppPages { ), GetPage( - name: AppRoutes.chooseRole, - page: () => const ChooseRoleScreen(), - binding: ChooseRoleControllerBinding(), + name: AppRoutes.roleSelection, + page: () => const RoleSelectionScreen(), + binding: RoleSelectionControllerBinding(), ), // Auth diff --git a/sigap-mobile/lib/src/cores/services/biometric_service.dart b/sigap-mobile/lib/src/cores/services/biometric_service.dart index 8bcc841..febc97a 100644 --- a/sigap-mobile/lib/src/cores/services/biometric_service.dart +++ b/sigap-mobile/lib/src/cores/services/biometric_service.dart @@ -83,10 +83,10 @@ class BiometricService extends GetxService { // Store user email and hashed password for session recovery final userMetadata = UserMetadataModel.fromJson(user.userMetadata); - if (userMetadata.officer?.email != null) { + if (userMetadata.officerData?.email != null) { await _secureStorage.write( key: 'user_email', - value: userMetadata.officer?.email, + value: userMetadata.officerData?.email, ); } @@ -99,8 +99,8 @@ class BiometricService extends GetxService { // Parse user metadata with our type-safe model String? identifier; - if (userMetadata.isOfficer && userMetadata.officer != null) { - identifier = userMetadata.officer!.nrp; + if (userMetadata.isOfficer && userMetadata.officerData != null) { + identifier = userMetadata.officerData!.nrp; } else if (userMetadata.nik != null) { identifier = userMetadata.nik; } diff --git a/sigap-mobile/lib/src/cores/services/location_service.dart b/sigap-mobile/lib/src/cores/services/location_service.dart index bf8b273..9a313bb 100644 --- a/sigap-mobile/lib/src/cores/services/location_service.dart +++ b/sigap-mobile/lib/src/cores/services/location_service.dart @@ -1,24 +1,76 @@ -import 'package:get/get.dart'; -import 'package:geolocator/geolocator.dart'; +import 'package:flutter/foundation.dart'; import 'package:geocoding/geocoding.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:get/get.dart'; import 'package:sigap/src/utils/exceptions/exceptions.dart'; class LocationService extends GetxService { static LocationService get instance => Get.find(); - + final RxBool isLocationServiceEnabled = false.obs; final RxBool isPermissionGranted = false.obs; final Rx currentPosition = Rx(null); final RxString currentCity = ''.obs; final RxBool isMockedLocation = false.obs; - + // Jember's center coordinate (approximate) static const double jemberLatitude = -8.168333; static const double jemberLongitude = 113.702778; - + // Max distance from Jember in meters (approximately 30km radius) static const double maxDistanceFromJember = 30000; - + + late LocationSettings locationSettings; + + // Add count for spoofing attempts + final RxInt spoofingAttempts = 0.obs; + + // Add more detailed location status + final RxString locationStatus = 'unknown'.obs; + + // Add last check timestamp + final Rx lastLocationCheckTime = Rx(null); + + LocationService() { + if (defaultTargetPlatform == TargetPlatform.android) { + locationSettings = AndroidSettings( + accuracy: LocationAccuracy.high, + distanceFilter: 100, + forceLocationManager: true, + intervalDuration: const Duration(seconds: 10), + //(Optional) Set foreground notification config to keep the app alive + //when going to the background + foregroundNotificationConfig: const ForegroundNotificationConfig( + notificationText: + "Example app will continue to receive your location even when you aren't using it", + notificationTitle: "Running in Background", + enableWakeLock: true, + ), + ); + } else if (defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.macOS) { + locationSettings = AppleSettings( + accuracy: LocationAccuracy.high, + activityType: ActivityType.fitness, + distanceFilter: 100, + pauseLocationUpdatesAutomatically: true, + // Only set to true if our app will be started up in the background. + showBackgroundLocationIndicator: false, + ); + } else if (kIsWeb) { + locationSettings = WebSettings( + accuracy: LocationAccuracy.high, + distanceFilter: 100, + maximumAge: Duration(minutes: 5), + ); + } else { + locationSettings = LocationSettings( + accuracy: LocationAccuracy.high, + distanceFilter: 100, + ); + } + } + // Initialize the service Future init() async { await _checkLocationService(); @@ -29,14 +81,15 @@ class LocationService extends GetxService { Future _checkLocationService() async { try { // Check if location service is enabled - isLocationServiceEnabled.value = await Geolocator.isLocationServiceEnabled(); + isLocationServiceEnabled.value = + await Geolocator.isLocationServiceEnabled(); if (!isLocationServiceEnabled.value) { return false; } // Check location permission var permission = await Geolocator.checkPermission(); - + if (permission == LocationPermission.denied) { permission = await Geolocator.requestPermission(); if (permission == LocationPermission.denied) { @@ -44,12 +97,12 @@ class LocationService extends GetxService { return false; } } - + if (permission == LocationPermission.deniedForever) { isPermissionGranted.value = false; return false; } - + isPermissionGranted.value = true; return true; } catch (e) { @@ -63,8 +116,9 @@ class LocationService extends GetxService { Future requestLocationPermission() async { try { var permission = await Geolocator.requestPermission(); - isPermissionGranted.value = permission == LocationPermission.always || - permission == LocationPermission.whileInUse; + isPermissionGranted.value = + permission == LocationPermission.always || + permission == LocationPermission.whileInUse; return isPermissionGranted.value; } catch (e) { isPermissionGranted.value = false; @@ -81,34 +135,71 @@ class LocationService extends GetxService { } currentPosition.value = await Geolocator.getCurrentPosition( - desiredAccuracy: LocationAccuracy.high, - timeLimit: const Duration(seconds: 10), + locationSettings: locationSettings, ); - + // Check if location is mocked isMockedLocation.value = currentPosition.value?.isMocked ?? false; - + // Get city name from coordinates if (currentPosition.value != null) { await _updateCityName(); } - + return currentPosition.value; } catch (e) { throw TExceptions('Failed to get location: ${e.toString()}'); } } - + + Future getLastKnownPosition() async { + try { + if (!isPermissionGranted.value) { + bool hasPermission = await _checkLocationService(); + if (!hasPermission) return null; + } + + currentPosition.value = await Geolocator.getLastKnownPosition(); + + // Check if location is mocked + isMockedLocation.value = currentPosition.value?.isMocked ?? false; + + // Get city name from coordinates + if (currentPosition.value != null) { + await _updateCityName(); + } + + return currentPosition.value; + } catch (e) { + throw TExceptions('Failed to get last known location: ${e.toString()}'); + } + } + + // Start listening to location updates + void startListeningToLocationUpdates() { + Geolocator.getPositionStream(locationSettings: locationSettings).listen(( + Position position, + ) { + currentPosition.value = position; + + // Check if location is mocked + isMockedLocation.value = position.isMocked; + + // Update city name based on new position + _updateCityName(); + }); + } + // Update the city name based on current position Future _updateCityName() async { if (currentPosition.value == null) return; - + try { List placemarks = await placemarkFromCoordinates( currentPosition.value!.latitude, currentPosition.value!.longitude, ); - + if (placemarks.isNotEmpty) { currentCity.value = placemarks.first.locality ?? ''; } @@ -116,16 +207,16 @@ class LocationService extends GetxService { currentCity.value = ''; } } - + // Check if the user is in Jember bool isInJember() { if (currentPosition.value == null) return false; - + // First check by city name if available if (currentCity.value.toLowerCase().contains('jember')) { return true; } - + // Then check by distance from Jember's center double distanceInMeters = Geolocator.distanceBetween( currentPosition.value!.latitude, @@ -133,20 +224,171 @@ class LocationService extends GetxService { jemberLatitude, jemberLongitude, ); - + return distanceInMeters <= maxDistanceFromJember; } - + // Check if location is valid for registration or panic button Future isLocationValidForFeature() async { await getCurrentPosition(); - + if (currentPosition.value == null) return false; - + // Check if location is mocked if (isMockedLocation.value) return false; - + // Check if in Jember return isInJember(); } + + // Enhanced location validation for panic button + Future> validateLocationForPanicButton() async { + lastLocationCheckTime.value = DateTime.now(); + + try { + // First check if location service is available + if (!isLocationServiceEnabled.value) { + bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + locationStatus.value = 'service_disabled'; + return { + 'valid': false, + 'reason': 'location_service_disabled', + 'message': 'Location service is disabled. Please enable GPS.', + }; + } + isLocationServiceEnabled.value = true; + } + + // Then check permission + if (!isPermissionGranted.value) { + bool hasPermission = await _checkLocationService(); + if (!hasPermission) { + locationStatus.value = 'permission_denied'; + return { + 'valid': false, + 'reason': 'location_permission_denied', + 'message': + 'Location permission denied. Please grant permission in settings.', + }; + } + } + + // Get current position with timeout + Position? position; + try { + position = await Geolocator.getCurrentPosition( + locationSettings: locationSettings, + timeLimit: const Duration(seconds: 10), + ); + currentPosition.value = position; + } catch (e) { + locationStatus.value = 'timeout'; + return { + 'valid': false, + 'reason': 'location_timeout', + 'message': 'Could not determine your location. Please try again.', + }; + } + + // Check for mock location + if (position.isMocked) { + isMockedLocation.value = true; + spoofingAttempts.value++; + locationStatus.value = 'mocked'; + + return { + 'valid': false, + 'reason': 'mock_location', + 'message': + 'Mock location detected. The panic button requires your real location.', + 'spoofingAttempts': spoofingAttempts.value, + }; + } + + // Update city name from coordinates + await _updateCityName(); + + // Check if in Jember + if (!isInJember()) { + locationStatus.value = 'outside_area'; + return { + 'valid': false, + 'reason': 'outside_jember', + 'message': + 'Your location is outside of Jember area. Panic button is only available within Jember.', + }; + } + + // All checks passed + locationStatus.value = 'valid'; + return { + 'valid': true, + 'city': currentCity.value, + 'position': { + 'latitude': position.latitude, + 'longitude': position.longitude, + 'accuracy': position.accuracy, + }, + }; + } catch (e) { + locationStatus.value = 'error'; + return { + 'valid': false, + 'reason': 'unknown_error', + 'message': 'An error occurred while checking location: ${e.toString()}', + }; + } + } + + // Get address from coordinates + Future getAddressFromCoordinates() async { + if (currentPosition.value == null) { + await getCurrentPosition(); + if (currentPosition.value == null) return ''; + } + + try { + List placemarks = await placemarkFromCoordinates( + currentPosition.value!.latitude, + currentPosition.value!.longitude, + ); + + if (placemarks.isNotEmpty) { + final placemark = placemarks.first; + + // Create a formatted address + final List addressComponents = + [ + placemark.street ?? '', + placemark.subLocality ?? '', + placemark.locality ?? '', + placemark.subAdministrativeArea ?? '', + placemark.administrativeArea ?? '', + placemark.postalCode ?? '', + ].where((component) => component.isNotEmpty).toList(); + + return addressComponents.join(', '); + } + + return ''; + } catch (e) { + return ''; + } + } + + // Calculate distance between two points in kilometers + double calculateDistance(double lat1, double lon1, double lat2, double lon2) { + return Geolocator.distanceBetween(lat1, lon1, lat2, lon2) / 1000; + } + + // Reset spoofing attempts counter + void resetSpoofingAttempts() { + spoofingAttempts.value = 0; + } + + // Check if location is mocked without getting new location + bool isLocationMocked() { + return currentPosition.value?.isMocked ?? false; + } } diff --git a/sigap-mobile/lib/src/cores/services/supabase_service.dart b/sigap-mobile/lib/src/cores/services/supabase_service.dart index 105141f..941b883 100644 --- a/sigap-mobile/lib/src/cores/services/supabase_service.dart +++ b/sigap-mobile/lib/src/cores/services/supabase_service.dart @@ -17,8 +17,8 @@ class SupabaseService extends GetxService { String? get currentUserId => _client.auth.currentUser?.id; /// Get type-safe user metadata - UserMetadataModel? get userMetadata { - if (currentUser == null) return null; + UserMetadataModel get userMetadata { + if (currentUser == null) return UserMetadataModel(); return UserMetadataModel.fromJson(currentUser!.userMetadata); } @@ -26,17 +26,17 @@ class SupabaseService extends GetxService { bool get isAuthenticated => currentUser != null; /// Check if current user is an officer based on metadata - bool get isOfficer => userMetadata?.isOfficer ?? false; + bool get isOfficer => userMetadata.isOfficer ?? false; /// Get the stored identifier (NIK or NRP) of the current user String? get userIdentifier { if (currentUser == null) return null; final metadata = userMetadata; - if (metadata?.isOfficer == true && metadata?.officer != null) { - return metadata!.officer!.nrp; + if (metadata.isOfficer == true && metadata.officerData != null) { + return metadata.officerData?.nrp; } else { - return metadata?.nik; + return metadata.profileData?.nik; } } @@ -45,8 +45,20 @@ class SupabaseService extends GetxService { return this; } - /// Update user metadata with type safety - Future updateUserMetadata(UserMetadataModel metadata) async { + /// Update user metadata with raw Map + Future updateUserMetadata(Map metadata) async { + try { + final response = await client.auth.updateUser( + UserAttributes(data: metadata), + ); + return response.user; + } catch (e) { + throw Exception('Failed to update user metadata: $e'); + } + } + + /// Update user metadata with our model + Future updateUserMetadataModel(UserMetadataModel metadata) async { try { final response = await client.auth.updateUser( UserAttributes(data: metadata.toJson()), @@ -56,4 +68,9 @@ class SupabaseService extends GetxService { throw Exception('Failed to update user metadata: $e'); } } + + /// Check if current user is an officer + bool get isUserOfficer => userMetadata.isOfficer; + + } diff --git a/sigap-mobile/lib/src/features/auth/controllers/email_verification_controller.dart b/sigap-mobile/lib/src/features/auth/controllers/email_verification_controller.dart index f1a6e5c..24ce664 100644 --- a/sigap-mobile/lib/src/features/auth/controllers/email_verification_controller.dart +++ b/sigap-mobile/lib/src/features/auth/controllers/email_verification_controller.dart @@ -2,14 +2,14 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:sigap/src/cores/repositories/auth/auth_repositories.dart'; +import 'package:sigap/src/cores/repositories/authentication/authentication_repositories.dart'; import 'package:sigap/src/utils/constants/app_routes.dart'; import 'package:sigap/src/utils/popups/loaders.dart'; class EmailVerificationController extends GetxController { // OTP text controllers final List otpControllers = List.generate( - 4, + 6, (_) => TextEditingController(), ); @@ -94,21 +94,21 @@ class EmailVerificationController extends GetxController { isLoading.value = true; verificationError.value = ''; - // Simulate API call - await Future.delayed(const Duration(seconds: 2)); + final authuser = await AuthenticationRepository.instance.verifyOtp(otp); - // TODO: Implement actual OTP verification - // For demo, we'll consider "1234" as valid OTP - if (otp == "1234") { - isVerified.value = true; - - // Navigate to role selection after successful verification - await Future.delayed(const Duration(seconds: 1)); - Get.offNamed(AppRoutes.chooseRole); - } else { - verificationError.value = - 'Invalid verification code. Please try again.'; + if (authuser.session == null || authuser.user == null) { + verificationError.value = 'Invalid OTP. Please try again.'; + return; } + + isVerified.value = true; + + TLoaders.successSnackBar( + title: 'Verification Successful', + message: 'Your email has been verified successfully.', + ); + + Get.offNamed(AppRoutes.roleSelection); } catch (e) { verificationError.value = 'Verification failed: ${e.toString()}'; } finally { diff --git a/sigap-mobile/lib/src/features/auth/controllers/forgot_password_controller.dart b/sigap-mobile/lib/src/features/auth/controllers/forgot_password_controller.dart index 6d815ca..a38ab35 100644 --- a/sigap-mobile/lib/src/features/auth/controllers/forgot_password_controller.dart +++ b/sigap-mobile/lib/src/features/auth/controllers/forgot_password_controller.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:sigap/src/cores/repositories/auth/auth_repositories.dart'; +import 'package:sigap/src/utils/popups/loaders.dart'; import 'package:sigap/src/utils/validators/validation.dart'; class ForgotPasswordController extends GetxController { @@ -43,11 +45,16 @@ class ForgotPasswordController extends GetxController { // Simulate API call await Future.delayed(const Duration(seconds: 2)); - // TODO: Implement actual password reset logic - // This would typically involve calling an authentication service + await AuthenticationRepository.instance.sendResetPasswordForEmail( + emailController.text, + ); // Show success message isEmailSent.value = true; + TLoaders.successSnackBar( + title: 'Success', + message: 'Reset password email sent successfully.', + ); } catch (e) { Get.snackbar( 'Error', diff --git a/sigap-mobile/lib/src/features/auth/controllers/signin_controller.dart b/sigap-mobile/lib/src/features/auth/controllers/signin_controller.dart index 401986e..e1892ef 100644 --- a/sigap-mobile/lib/src/features/auth/controllers/signin_controller.dart +++ b/sigap-mobile/lib/src/features/auth/controllers/signin_controller.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:get_storage/get_storage.dart'; -import 'package:sigap/src/cores/repositories/auth/auth_repositories.dart'; +import 'package:sigap/src/cores/repositories/authentication/authentication_repositories.dart'; import 'package:sigap/src/utils/constants/app_routes.dart'; import 'package:sigap/src/utils/helpers/network_manager.dart'; import 'package:sigap/src/utils/popups/loaders.dart'; diff --git a/sigap-mobile/lib/src/features/auth/controllers/signup_controller.dart b/sigap-mobile/lib/src/features/auth/controllers/signup_controller.dart index f98294f..74d6a30 100644 --- a/sigap-mobile/lib/src/features/auth/controllers/signup_controller.dart +++ b/sigap-mobile/lib/src/features/auth/controllers/signup_controller.dart @@ -1,81 +1,120 @@ -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:get_storage/get_storage.dart'; import 'package:logger/logger.dart'; import 'package:sigap/src/cores/repositories/auth/auth_repositories.dart'; -import 'package:sigap/src/utils/constants/image_strings.dart'; +import 'package:sigap/src/features/auth/models/user_metadata_model.dart'; +import 'package:sigap/src/utils/constants/app_routes.dart'; import 'package:sigap/src/utils/helpers/network_manager.dart'; -import 'package:sigap/src/utils/popups/full_screen_loader.dart'; import 'package:sigap/src/utils/popups/loaders.dart'; +import 'package:sigap/src/utils/validators/validation.dart'; class SignUpController extends GetxController { static SignUpController get instance => Get.find(); // Variable final storage = GetStorage(); + final formKey = GlobalKey(); - final hidePassword = true.obs; - final hideConfirmPassword = true.obs; + // Privacy policy final privacyPolicy = false.obs; - final isOfficer = false.obs; // Add flag for officer registration - final email = TextEditingController(); - final firstName = TextEditingController(); - final lastName = TextEditingController(); - final username = TextEditingController(); + // Controllers for form fields + final emailController = TextEditingController(); + final passwordController = TextEditingController(); + final confirmPasswordController = TextEditingController(); - final phoneNumber = TextEditingController(); - final password = TextEditingController(); - final confirmPassword = TextEditingController(); - - // Officer specific fields - final nrp = TextEditingController(); - final rank = TextEditingController(); - final position = TextEditingController(); - final unitId = - TextEditingController(); // This should be selected from a dropdown - - // State variables + // Observable error messages final emailError = ''.obs; - final firstNameError = ''.obs; - final lastNameError = ''.obs; - final usernameError = ''.obs; - final phoneNumberError = ''.obs; final passwordError = ''.obs; final confirmPasswordError = ''.obs; + // Observable states final isPasswordVisible = false.obs; final isConfirmPasswordVisible = false.obs; - final isPrivacyPolicyAccepted = false.obs; - - final isOfficerMode = false.obs; - final isOfficerError = ''.obs; - final isUnitIdError = ''.obs; - final isNrpError = ''.obs; - final isRankError = ''.obs; - final isPositionError = ''.obs; - final isLoading = false.obs; + final userMetadata = Rx(null); + final selectedRole = Rx(null); - GlobalKey signupFormKey = GlobalKey(); + @override + void onInit() { + super.onInit(); + + // Get arguments from StepForm + final arguments = Get.arguments; + if (arguments != null) { + if (arguments['userMetadata'] != null) { + userMetadata.value = arguments['userMetadata'] as UserMetadataModel; + } + if (arguments['role'] != null) { + selectedRole.value = arguments['role']; + } + } + } + + // Validators + String? validateEmail(String? value) { + final error = TValidators.validateEmail(value); + emailError.value = error ?? ''; + return error; + } + + String? validatePassword(String? value) { + final error = TValidators.validatePassword(value); + passwordError.value = error ?? ''; + return error; + } + + String? validateConfirmPassword(String? value) { + final error = TValidators.validateConfirmPassword( + passwordController.text, + value, + ); + confirmPasswordError.value = error ?? ''; + return error; + } + + // Toggle password visibility + void togglePasswordVisibility() { + isPasswordVisible.value = !isPasswordVisible.value; + } + + // Toggle confirm password visibility + void toggleConfirmPasswordVisibility() { + isConfirmPasswordVisible.value = !isConfirmPasswordVisible.value; + } // Sign Up Function void signUp() async { Logger().i('SignUp process started'); try { + // Form validation + if (!formKey.currentState!.validate()) { + Logger().w('Form validation failed'); + return; + } + + // Check privacy policy acceptance + if (!privacyPolicy.value) { + Logger().w('Privacy policy not accepted'); + TLoaders.warningSnackBar( + title: 'Accept Privacy Policy', + message: + 'In order to create account, you must accept the privacy policy & terms of use.', + ); + return; + } + // Start loading - Logger().i('Opening loading dialog'); - TFullScreenLoader.openLoadingDialog( - 'Processing your information....', - TImages.amongUsLoading, - ); + isLoading.value = true; + Logger().i('Starting signup process'); // Check connection Logger().i('Checking network connection'); final isConnected = await NetworkManager.instance.isConnected(); if (!isConnected) { Logger().w('No internet connection'); - TFullScreenLoader.stopLoading(); + isLoading.value = false; TLoaders.errorSnackBar( title: 'No Internet Connection', message: 'Please check your internet connection and try again.', @@ -83,88 +122,54 @@ class SignUpController extends GetxController { return; } - // Form validation - Logger().i('Validating form'); - if (!signupFormKey.currentState!.validate()) { - Logger().w('Form validation failed'); - TFullScreenLoader.stopLoading(); - return; - } - - // Check if user agreed to the terms and conditions - Logger().i('Checking privacy policy acceptance'); - if (!privacyPolicy.value) { - Logger().w('Privacy policy not accepted'); - TFullScreenLoader.stopLoading(); - - TLoaders.warningSnackBar( - title: 'Accept Privacy Policy', - message: - 'In order to create account, you must have to read and accept the privacy policy & terms of use.', + // Make sure user metadata is available + if (userMetadata.value == null) { + Logger().w('User metadata is missing'); + isLoading.value = false; + TLoaders.errorSnackBar( + title: 'Missing Information', + message: 'Please complete your profile information first.', ); return; } - // Prepare user metadata based on user type (officer or viewer) - Map userMetadata = { - 'first_name': firstName.text.trim(), - 'last_name': lastName.text.trim(), - 'phone': phoneNumber.text.trim(), - 'is_officer': isOfficer.value, - }; - - // Add officer-specific data if registering as officer - if (isOfficer.value) { - if (unitId.text.isEmpty) { - Logger().w('Unit ID is required for officer registration'); - TFullScreenLoader.stopLoading(); - TLoaders.errorSnackBar( - title: 'Missing Information', - message: 'Unit ID is required for officer registration.', - ); - return; - } - - userMetadata['officer_data'] = { - 'unit_id': unitId.text.trim(), - 'nrp': nrp.text.trim(), - 'name': "${firstName.text.trim()} ${lastName.text.trim()}", - 'rank': rank.text.trim(), - 'position': position.text.trim(), - 'phone': phoneNumber.text.trim(), - }; - } + // Add email to user metadata + final updatedMetadata = userMetadata.value!.copyWith( + email: emailController.text.trim(), + ); // Register user with Supabase Auth Logger().i('Registering user with Supabase Auth'); final authResponse = await AuthenticationRepository.instance - .signUpWithCredential( - email.text.trim(), - password.text.trim(), - userMetadata: userMetadata, + .signUpWithEmailPassword( + email: emailController.text.trim(), + password: passwordController.text.trim(), + userMetadata: updatedMetadata.toJson(), ); - // Store email for verification screen - storage.write('CURRENT_USER_EMAIL', email.text.trim()); + // Store email for verification or next steps + storage.write('CURRENT_USER_EMAIL', emailController.text.trim()); // Remove loading - Logger().i('Stopping loading dialog'); - TFullScreenLoader.stopLoading(); + Logger().i('Signup process completed'); + isLoading.value = false; // Show success message Logger().i('Showing success message'); TLoaders.successSnackBar( - title: 'Congratulations', - message: 'Your account has been created! Verify email to continue.', + title: 'Account Created', + message: 'Please check your email to verify your account!', ); - // Move to verification screen - Logger().i('Navigating to VerifyEmailScreen'); - // Get.to(() => VerifyEmailScreen(email: email.text.trim())); + // Navigate to email verification + Get.offNamed( + AppRoutes.emailVerification, + arguments: {'email': authResponse.user?.email}, + ); } catch (e) { - // Remove loading + // Handle error Logger().e('Error occurred: $e'); - TFullScreenLoader.stopLoading(); + isLoading.value = false; // Show error to the user TLoaders.errorSnackBar( @@ -174,14 +179,8 @@ class SignUpController extends GetxController { } } - // Toggle officer registration mode - void toggleOfficerMode(bool value) { - isOfficer.value = value; - update(); - } - // Navigate to sign in screen void goToSignIn() { - Get.back(); + Get.offNamed(AppRoutes.signIn); } } diff --git a/sigap-mobile/lib/src/features/auth/controllers/step_form_controller.dart b/sigap-mobile/lib/src/features/auth/controllers/step_form_controller.dart index 8cdb2ee..b13916b 100644 --- a/sigap-mobile/lib/src/features/auth/controllers/step_form_controller.dart +++ b/sigap-mobile/lib/src/features/auth/controllers/step_form_controller.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:sigap/src/cores/services/supabase_service.dart'; -import 'package:sigap/src/features/daily-ops/models/units_model.dart'; +import 'package:sigap/src/features/auth/models/user_metadata_model.dart'; +import 'package:sigap/src/features/daily-ops/models/index.dart'; import 'package:sigap/src/features/personalization/models/index.dart'; import 'package:sigap/src/utils/constants/app_routes.dart'; import 'package:sigap/src/utils/popups/loaders.dart'; @@ -12,7 +12,6 @@ class StepFormController extends GetxController { // Role information final Rx selectedRole = Rx(null); - // Current step index final RxInt currentStep = 0.obs; @@ -21,14 +20,16 @@ class StepFormController extends GetxController { late List> stepFormKeys; // Common information (for all roles) - final nameController = TextEditingController(); + final firstNameController = TextEditingController(); + final lastNameController = TextEditingController(); + final nameController = TextEditingController(); // For combined name final phoneController = TextEditingController(); final addressController = TextEditingController(); // Viewer-specific fields final nikController = TextEditingController(); - final emergencyPhoneController = TextEditingController(); - final relationshipController = TextEditingController(); + final bioController = TextEditingController(); + final birthDateController = TextEditingController(); // Officer-specific fields final nrpController = TextEditingController(); @@ -36,15 +37,20 @@ class StepFormController extends GetxController { final positionController = TextEditingController(); final unitIdController = TextEditingController(); + // User metadata model + final Rx userMetadata = UserMetadataModel().obs; + // Error states - Common + final RxString firstNameError = ''.obs; + final RxString lastNameError = ''.obs; final RxString nameError = ''.obs; final RxString phoneError = ''.obs; final RxString addressError = ''.obs; // Error states - Viewer final RxString nikError = ''.obs; - final RxString emergencyPhoneError = ''.obs; - final RxString relationshipError = ''.obs; + final RxString bioError = ''.obs; + final RxString birthDateError = ''.obs; // Error states - Officer final RxString nrpError = ''.obs; @@ -61,10 +67,21 @@ class StepFormController extends GetxController { @override void onInit() { super.onInit(); + + // Add listeners to first name and last name controllers to update the combined name + firstNameController.addListener(_updateCombinedName); + lastNameController.addListener(_updateCombinedName); + // Get role from arguments final arguments = Get.arguments; if (arguments != null && arguments['role'] != null) { selectedRole.value = arguments['role'] as RoleModel; + + // Initialize userMetadata with the selected role information + userMetadata.value = UserMetadataModel( + isOfficer: selectedRole.value?.isOfficer ?? false, + ); + _initializeBasedOnRole(); } else { Get.snackbar( @@ -81,6 +98,22 @@ class StepFormController extends GetxController { } } + // Update the combined name when either first or last name changes + void _updateCombinedName() { + final firstName = firstNameController.text.trim(); + final lastName = lastNameController.text.trim(); + + if (firstName.isEmpty && lastName.isEmpty) { + nameController.text = ''; + } else if (lastName.isEmpty) { + nameController.text = firstName; + } else if (firstName.isEmpty) { + nameController.text = lastName; + } else { + nameController.text = '$firstName $lastName'; + } + } + void _initializeBasedOnRole() { if (selectedRole.value?.isOfficer == true) { stepFormKeys = List.generate(3, (_) => GlobalKey()); @@ -96,7 +129,6 @@ class StepFormController extends GetxController { // Here we would fetch units from repository // For now we'll use dummy data await Future.delayed(const Duration(seconds: 1)); - } catch (e) { TLoaders.errorSnackBar( title: 'Error', @@ -109,14 +141,20 @@ class StepFormController extends GetxController { @override void onClose() { + // Remove listeners + firstNameController.removeListener(_updateCombinedName); + lastNameController.removeListener(_updateCombinedName); + // Dispose all controllers + firstNameController.dispose(); + lastNameController.dispose(); nameController.dispose(); phoneController.dispose(); addressController.dispose(); nikController.dispose(); - emergencyPhoneController.dispose(); - relationshipController.dispose(); + bioController.dispose(); + birthDateController.dispose(); nrpController.dispose(); rankController.dispose(); @@ -155,14 +193,16 @@ class StepFormController extends GetxController { void clearErrors() { // Clear common errors + firstNameError.value = ''; + lastNameError.value = ''; nameError.value = ''; phoneError.value = ''; addressError.value = ''; // Clear viewer-specific errors nikError.value = ''; - emergencyPhoneError.value = ''; - relationshipError.value = ''; + bioError.value = ''; + birthDateError.value = ''; // Clear officer-specific errors nrpError.value = ''; @@ -174,13 +214,24 @@ class StepFormController extends GetxController { bool validatePersonalInfo() { bool isValid = true; - final nameValidation = TValidators.validateUserInput( - 'Full name', - nameController.text, - 100, + final firstNameValidation = TValidators.validateUserInput( + 'First name', + firstNameController.text, + 50, ); - if (nameValidation != null) { - nameError.value = nameValidation; + if (firstNameValidation != null) { + firstNameError.value = firstNameValidation; + isValid = false; + } + + final lastNameValidation = TValidators.validateUserInput( + 'Last name', + lastNameController.text, + 50, + required: false, // Last name can be optional + ); + if (lastNameValidation != null) { + lastNameError.value = lastNameValidation; isValid = false; } @@ -208,31 +259,36 @@ class StepFormController extends GetxController { bool validateEmergencyContact() { bool isValid = true; - final nameValidation = TValidators.validateUserInput( - 'Emergency contact name', + final nikValidation = TValidators.validateUserInput( + 'NIK', nikController.text, - 100, + 16, ); - if (nameValidation != null) { - nikError.value = nameValidation; + if (nikValidation != null) { + nikError.value = nikValidation; isValid = false; } - final phoneValidation = TValidators.validatePhoneNumber( - emergencyPhoneController.text, + // Bio can be optional, so we validate with required: false + final bioValidation = TValidators.validateUserInput( + 'Bio', + bioController.text, + 255, + required: false, ); - if (phoneValidation != null) { - emergencyPhoneError.value = phoneValidation; + if (bioValidation != null) { + bioError.value = bioValidation; isValid = false; } - final relationshipValidation = TValidators.validateUserInput( - 'Relationship', - relationshipController.text, - 50, + // Birth date validation + final birthDateValidation = TValidators.validateUserInput( + 'Birth Date', + birthDateController.text, + 10, ); - if (relationshipValidation != null) { - relationshipError.value = relationshipValidation; + if (birthDateValidation != null) { + birthDateError.value = birthDateValidation; isValid = false; } @@ -343,56 +399,55 @@ class StepFormController extends GetxController { try { isLoading.value = true; - // Prepare data based on role - final Map userData = { - 'name': nameController.text, - 'phone': phoneController.text, - 'address': addressController.text, - }; - + // Prepare UserMetadataModel based on role if (selectedRole.value?.isOfficer == true) { - // Officer role - final officerData = { - 'nrp': nrpController.text, - 'rank': rankController.text, - 'position': positionController.text, - 'unit_id': unitIdController.text, - }; + // Officer role - create OfficerModel with the data + final officerData = OfficerModel( + id: '', // Will be assigned by backend + unitId: unitIdController.text, + roleId: selectedRole.value!.id, + nrp: nrpController.text, + name: nameController.text, // Use the combined name here + rank: rankController.text, + position: positionController.text, + phone: phoneController.text, + ); - // Update auth user with officer flag and data - await SupabaseService.instance.updateUserMetadata({ - 'is_officer': true, - 'officer_data': { - ...officerData, - 'name': userData['name'], - 'phone': userData['phone'], - }, - }); + userMetadata.value = UserMetadataModel( + isOfficer: true, + name: nameController.text, // Use the combined name + phone: phoneController.text, + officerData: officerData, + additionalData: {'address': addressController.text}, + ); } else { - // Viewer role - final emergencyContact = { - 'name': nikController.text, - 'phone': emergencyPhoneController.text, - 'relationship': relationshipController.text, - }; - - // Update auth user with viewer role - await SupabaseService.instance.updateUserMetadata({ - 'is_officer': false, - 'emergency_contact': emergencyContact, - 'address': userData['address'], - }); + // Regular user - create profile-related data + userMetadata.value = UserMetadataModel( + isOfficer: false, + nik: nikController.text, + name: nameController.text, // Use the combined name + phone: phoneController.text, + profileData: ProfileModel( + id: '', // Will be assigned by backend + userId: '', // Will be assigned by backend + nik: nikController.text, + firstName: firstNameController.text.trim(), + lastName: lastNameController.text.trim(), + bio: bioController.text, // Add the bio field + birthDate: _parseBirthDate( + birthDateController.text, + ), // Parse birth date + ), + additionalData: {'address': addressController.text}, + ); } - // Navigate to success screen + // Navigate to the signup screen with the prepared metadata Get.toNamed( - AppRoutes.stateScreen, + AppRoutes.signUp, arguments: { - 'type': 'success', - 'title': 'Profile Completed', - 'message': 'Your profile information has been successfully saved.', - 'buttonText': 'Continue', - 'onButtonPressed': () => Get.offAllNamed(AppRoutes.explore), + 'userMetadata': userMetadata.value, + 'role': selectedRole.value, }, ); } catch (e) { @@ -400,8 +455,9 @@ class StepFormController extends GetxController { AppRoutes.stateScreen, arguments: { 'type': 'error', - 'title': 'Submission Failed', - 'message': 'There was an error saving your profile: ${e.toString()}', + 'title': 'Data Preparation Failed', + 'message': + 'There was an error preparing your profile: ${e.toString()}', 'buttonText': 'Try Again', 'onButtonPressed': () => Get.back(), }, @@ -410,4 +466,32 @@ class StepFormController extends GetxController { isLoading.value = false; } } + + // Parse birth date string to DateTime + DateTime? _parseBirthDate(String dateStr) { + try { + // Try to parse in format YYYY-MM-DD + if (dateStr.isEmpty) return null; + + // Add validation for different date formats as needed + if (dateStr.contains('-')) { + return DateTime.parse(dateStr); + } + + // Handle other formats like DD/MM/YYYY + if (dateStr.contains('/')) { + final parts = dateStr.split('/'); + if (parts.length == 3) { + final day = int.parse(parts[0]); + final month = int.parse(parts[1]); + final year = int.parse(parts[2]); + return DateTime(year, month, day); + } + } + + return null; + } catch (e) { + return null; + } + } } diff --git a/sigap-mobile/lib/src/features/auth/models/user_metadata_model.dart b/sigap-mobile/lib/src/features/auth/models/user_metadata_model.dart index bd9000e..78f5b3d 100644 --- a/sigap-mobile/lib/src/features/auth/models/user_metadata_model.dart +++ b/sigap-mobile/lib/src/features/auth/models/user_metadata_model.dart @@ -1,33 +1,101 @@ -import 'package:sigap/src/features/personalization/models/index.dart'; +import 'package:sigap/src/features/daily-ops/models/officers_model.dart'; +import 'package:sigap/src/features/personalization/models/profile_model.dart'; class UserMetadataModel { final bool isOfficer; final String? nik; - final OfficerModel? officer; + final String? email; + final String? phone; + final String? name; + final OfficerModel? officerData; + final ProfileModel? profileData; final Map? additionalData; + + // Emergency contact data frequently used in the app + final Map? emergencyContact; UserMetadataModel({ this.isOfficer = false, this.nik, - this.officer, + this.email, + this.phone, + this.name, + this.officerData, + this.profileData, + this.emergencyContact, this.additionalData, }); /// Create a UserMetadataModel from raw Map data (from Supabase Auth) factory UserMetadataModel.fromJson(Map? json) { if (json == null) return UserMetadataModel(); + + // Extract officer data if available + OfficerModel? officerData; + if (json['officer_data'] != null && json['is_officer'] == true) { + try { + // Create temporary ID and role fields if missing + final officerJson = Map.from(json['officer_data']); + if (!officerJson.containsKey('id')) { + officerJson['id'] = json['id'] ?? ''; + } + if (!officerJson.containsKey('role_id')) { + officerJson['role_id'] = ''; + } + if (!officerJson.containsKey('unit_id')) { + officerJson['unit_id'] = officerJson['unit_id'] ?? ''; + } + officerData = OfficerModel.fromJson(officerJson); + } catch (e) { + print('Error parsing officer data: $e'); + } + } + + // Extract profile data if available + ProfileModel? profileData; + if (json['profile_data'] != null) { + try { + final profileJson = Map.from(json['profile_data']); + if (!profileJson.containsKey('id')) { + profileJson['id'] = ''; + } + if (!profileJson.containsKey('user_id')) { + profileJson['user_id'] = json['id'] ?? ''; + } + if (!profileJson.containsKey('nik')) { + profileJson['nik'] = json['nik'] ?? ''; + } + + profileData = ProfileModel.fromJson(profileJson); + } catch (e) { + print('Error parsing profile data: $e'); + } + } + return UserMetadataModel( isOfficer: json['is_officer'] == true, nik: json['nik'] as String?, - officer: - json['officer_data'] != null - ? OfficerModel.fromJson( - json['officer_data'] as Map, - ) + email: json['email'] as String?, + phone: json['phone'] as String?, + name: json['name'] as String?, + officerData: officerData, + profileData: profileData, + emergencyContact: + json['emergency_contact'] != null + ? Map.from(json['emergency_contact']) : null, additionalData: Map.from(json)..removeWhere( - (key, _) => ['is_officer', 'nik', 'officer_data'].contains(key), + (key, _) => [ + 'is_officer', + 'nik', + 'email', + 'phone', + 'name', + 'officer_data', + 'profile_data', + 'emergency_contact', + ].contains(key), ), ); } @@ -36,12 +104,38 @@ class UserMetadataModel { Map toJson() { final Map data = {'is_officer': isOfficer}; - if (nik != null) { - data['nik'] = nik; + if (nik != null) data['nik'] = nik; + if (email != null) data['email'] = email; + if (phone != null) data['phone'] = phone; + if (name != null) data['name'] = name; + + if (officerData != null && isOfficer) { + // Extract only the necessary fields for the officerData + // to prevent circular references and reduce data size + final officerJson = { + 'nrp': officerData!.nrp, + 'name': officerData!.name, + 'rank': officerData!.rank, + 'position': officerData!.position, + 'phone': officerData!.phone, + 'unit_id': officerData!.unitId, + }; + data['officer_data'] = officerJson; + } + + if (profileData != null) { + // Extract only the necessary profile fields + final profileJson = { + 'nik': profileData!.nik, + 'first_name': profileData!.firstName, + 'last_name': profileData!.lastName, + 'address': profileData!.address, + }; + data['profile_data'] = profileJson; } - if (officer != null) { - data['officer_data'] = officer!.toJson(); + if (emergencyContact != null) { + data['emergency_contact'] = emergencyContact; } if (additionalData != null) { @@ -50,77 +144,41 @@ class UserMetadataModel { return data; } - + /// Create a copy with updated fields UserMetadataModel copyWith({ bool? isOfficer, String? nik, - OfficerModel? officer, + String? email, + String? phone, + String? name, + OfficerModel? officerData, + ProfileModel? profileData, + Map? emergencyContact, Map? additionalData, }) { return UserMetadataModel( isOfficer: isOfficer ?? this.isOfficer, nik: nik ?? this.nik, - officer: officer ?? this.officer, + email: email ?? this.email, + phone: phone ?? this.phone, + name: name ?? this.name, + officerData: officerData ?? this.officerData, + profileData: profileData ?? this.profileData, + emergencyContact: emergencyContact ?? this.emergencyContact, additionalData: additionalData ?? this.additionalData, ); } + + /// Identifier for the user (NRP for officers, NIK for normal users) + String? get identifier => isOfficer ? officerData?.nrp : nik; + + /// Get full name from name field, officer data, or profile data + String? get fullName { + if (name != null) return name; + if (isOfficer && officerData != null) return officerData!.name; + if (profileData != null) return profileData!.fullName; + return null; + } } -// class OfficerModel { -// final String nrp; -// final String? name; -// final String? rank; -// final String? position; -// final String? phone; -// final String? unitId; - -// OfficerModel({ -// required this.nrp, -// this.name, -// this.rank, -// this.position, -// this.phone, -// this.unitId, -// }); - -// factory OfficerModel.fromJson(Map json) { -// return OfficerModel( -// nrp: json['nrp'] as String, -// name: json['name'] as String?, -// rank: json['rank'] as String?, -// position: json['position'] as String?, -// phone: json['phone'] as String?, -// unitId: json['unit_id'] as String?, -// ); -// } - -// Map toJson() { -// return { -// 'nrp': nrp, -// if (name != null) 'name': name, -// if (rank != null) 'rank': rank, -// if (position != null) 'position': position, -// if (phone != null) 'phone': phone, -// if (unitId != null) 'unit_id': unitId, -// }; -// } - -// OfficerModel copyWith({ -// String? nrp, -// String? name, -// String? rank, -// String? position, -// String? phone, -// String? unitId, -// }) { -// return OfficerModel( -// nrp: nrp ?? this.nrp, -// name: name ?? this.name, -// rank: rank ?? this.rank, -// position: position ?? this.position, -// phone: phone ?? this.phone, -// unitId: unitId ?? this.unitId, -// ); -// } -// } diff --git a/sigap-mobile/lib/src/features/auth/screens/email-verification/email_verification_screen.dart b/sigap-mobile/lib/src/features/auth/screens/email-verification/email_verification_screen.dart index 05d164f..cb136ea 100644 --- a/sigap-mobile/lib/src/features/auth/screens/email-verification/email_verification_screen.dart +++ b/sigap-mobile/lib/src/features/auth/screens/email-verification/email_verification_screen.dart @@ -15,6 +15,7 @@ class EmailVerificationScreen extends StatelessWidget { // Get the controller final controller = Get.find(); + // Set system overlay style SystemChrome.setSystemUIOverlayStyle( const SystemUiOverlayStyle( @@ -50,13 +51,15 @@ class EmailVerificationScreen extends StatelessWidget { } Widget _buildVerificationForm(EmailVerificationController controller) { + final isResendEnabled = controller.isResendEnabled.value; + return Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ // Header const AuthHeader( title: 'Email Verification', - subtitle: 'Enter the 4-digit code sent to your email', + subtitle: 'Enter the 6-digit code sent to your email', ), const SizedBox(height: 32), @@ -65,7 +68,7 @@ class EmailVerificationScreen extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: List.generate( - 4, + 6, (index) => OtpInputField( controller: controller.otpControllers[index], focusNode: controller.focusNodes[index], @@ -115,16 +118,16 @@ class EmailVerificationScreen extends StatelessWidget { Obx( () => TextButton( onPressed: - controller.isResendEnabled.value + isResendEnabled ? () => controller.resendCode : null, child: Text( - controller.isResendEnabled.value + isResendEnabled ? 'Resend' : 'Resend in ${controller.resendCountdown.value}s', style: TextStyle( color: - controller.isResendEnabled.value + isResendEnabled ? TColors.primary : TColors.textSecondary, fontWeight: FontWeight.w500, diff --git a/sigap-mobile/lib/src/features/auth/screens/signup/signup_screen.dart b/sigap-mobile/lib/src/features/auth/screens/signup/signup_screen.dart index f3f1a16..4a12106 100644 --- a/sigap-mobile/lib/src/features/auth/screens/signup/signup_screen.dart +++ b/sigap-mobile/lib/src/features/auth/screens/signup/signup_screen.dart @@ -3,10 +3,7 @@ import 'package:flutter/services.dart'; import 'package:get/get.dart'; import 'package:sigap/src/features/auth/controllers/signup_controller.dart'; import 'package:sigap/src/features/auth/screens/widgets/auth_button.dart'; -import 'package:sigap/src/features/auth/screens/widgets/auth_divider.dart'; -import 'package:sigap/src/features/auth/screens/widgets/auth_header.dart'; import 'package:sigap/src/features/auth/screens/widgets/password_field.dart'; -import 'package:sigap/src/features/auth/screens/widgets/social_button.dart'; import 'package:sigap/src/shared/widgets/text/custom_text_field.dart'; import 'package:sigap/src/utils/constants/colors.dart'; @@ -31,6 +28,14 @@ class SignUpScreen extends StatelessWidget { appBar: AppBar( backgroundColor: Colors.transparent, elevation: 0, + title: Text( + 'Create Account', + style: TextStyle( + color: TColors.textPrimary, + fontWeight: FontWeight.bold, + ), + ), + centerTitle: true, leading: IconButton( icon: Icon(Icons.arrow_back, color: TColors.textPrimary), onPressed: () => Get.back(), @@ -45,22 +50,28 @@ class SignUpScreen extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Header - const AuthHeader( - title: 'Create Account', - subtitle: 'Sign up to get started with the app', - ), + // Header with profile summary + Obx(() => _buildProfileSummary(controller)), - // Name field - Obx( - () => CustomTextField( - label: 'Full Name', - controller: controller.nameController, - validator: controller.validateName, - errorText: controller.nameError.value, - textInputAction: TextInputAction.next, + // Credentials section header + const SizedBox(height: 24), + Text( + 'Set Your Login Credentials', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: TColors.textPrimary, ), ), + const SizedBox(height: 8), + Text( + 'Create your login information to secure your account', + style: TextStyle( + fontSize: 14, + color: TColors.textSecondary, + ), + ), + const SizedBox(height: 16), // Email field Obx( @@ -100,33 +111,39 @@ class SignUpScreen extends StatelessWidget { ), ), + const SizedBox(height: 16), + + // Privacy policy checkbox + Row( + children: [ + Obx( + () => Checkbox( + value: controller.privacyPolicy.value, + onChanged: + (value) => + controller.privacyPolicy.value = value!, + ), + ), + Expanded( + child: Text( + 'I agree to the Terms of Service and Privacy Policy', + style: TextStyle(color: TColors.textSecondary), + ), + ), + ], + ), + const SizedBox(height: 24), // Sign up button Obx( () => AuthButton( - text: 'Sign Up', + text: 'Create Account', onPressed: controller.signUp, isLoading: controller.isLoading.value, ), ), - const SizedBox(height: 24), - - // Or divider - const AuthDivider(text: 'OR'), - - const SizedBox(height: 24), - - // Social sign up button - SocialButton( - text: 'Continue with Google', - icon: Icons.g_mobiledata, - onPressed: () { - // TODO: Implement Google sign up - }, - ), - const SizedBox(height: 16), // Already have an account @@ -157,4 +174,90 @@ class SignUpScreen extends StatelessWidget { ), ); } + + Widget _buildProfileSummary(SignUpController controller) { + if (controller.userMetadata.value == null) { + return Container(); // Return empty container if no metadata + } + + final metadata = controller.userMetadata.value!; + final isOfficer = metadata.isOfficer; + final name = metadata.name ?? 'User'; + final identifier = + isOfficer + ? 'NRP: ${metadata.officerData?.nrp ?? 'N/A'}' + : 'NIK: ${metadata.nik ?? 'N/A'}'; + final role = isOfficer ? 'Officer' : 'Viewer'; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: TColors.primary.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Profile Summary', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: TColors.textPrimary, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + CircleAvatar( + backgroundColor: TColors.primary, + radius: 24, + child: Text( + name.isNotEmpty ? name[0].toUpperCase() : 'U', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 20, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: TColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + identifier, + style: TextStyle( + fontSize: 14, + color: TColors.textSecondary, + ), + ), + const SizedBox(height: 2), + Text( + 'Role: $role', + style: TextStyle( + fontSize: 14, + color: TColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), + ], + ), + ); + } } diff --git a/sigap-mobile/lib/src/features/auth/screens/step-form/step_form_screen.dart b/sigap-mobile/lib/src/features/auth/screens/step-form/step_form_screen.dart index 1bd7a1e..549b16a 100644 --- a/sigap-mobile/lib/src/features/auth/screens/step-form/step_form_screen.dart +++ b/sigap-mobile/lib/src/features/auth/screens/step-form/step_form_screen.dart @@ -181,15 +181,32 @@ class StepFormScreen extends StatelessWidget { ), const SizedBox(height: 24), - // Name field + // First Name field Obx( () => CustomTextField( - label: 'Full Name', - controller: controller.nameController, + label: 'First Name', + controller: controller.firstNameController, validator: (value) => - TValidators.validateUserInput('Full name', value, 100), - errorText: controller.nameError.value, + TValidators.validateUserInput('First name', value, 50), + errorText: controller.firstNameError.value, + textInputAction: TextInputAction.next, + ), + ), + + // Last Name field + Obx( + () => CustomTextField( + label: 'Last Name', + controller: controller.lastNameController, + validator: + (value) => TValidators.validateUserInput( + 'Last name', + value, + 50, + required: false, + ), + errorText: controller.lastNameError.value, textInputAction: TextInputAction.next, ), ), @@ -231,7 +248,7 @@ class StepFormScreen extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Emergency Contact', + 'Additional Information', style: TextStyle( fontSize: 20, fontWeight: FontWeight.bold, @@ -240,49 +257,55 @@ class StepFormScreen extends StatelessWidget { ), const SizedBox(height: 8), Text( - 'Please provide emergency contact details', + 'Please provide additional personal details', style: TextStyle(fontSize: 14, color: TColors.textSecondary), ), const SizedBox(height: 24), - // Emergency contact name field + // NIK field Obx( () => CustomTextField( - label: 'NIK', + label: 'NIK (Identity Number)', controller: controller.nikController, validator: - (value) => TValidators.validateUserInput( - 'Emergency contact name', - value, - 100, - ), + (value) => TValidators.validateUserInput('NIK', value, 16), errorText: controller.nikError.value, textInputAction: TextInputAction.next, + keyboardType: TextInputType.number, ), ), - // Emergency contact phone field + // Bio field Obx( () => CustomTextField( - label: 'Contact Phone', - controller: controller.emergencyPhoneController, - validator: TValidators.validatePhoneNumber, - errorText: controller.emergencyPhoneError.value, - keyboardType: TextInputType.phone, + label: 'Bio', + controller: controller.bioController, + validator: + (value) => TValidators.validateUserInput( + 'Bio', + value, + 255, + required: false, + ), + errorText: controller.bioError.value, textInputAction: TextInputAction.next, + maxLines: 3, + hintText: 'Tell us a little about yourself (optional)', ), ), - // Relationship field + // Birth Date field Obx( () => CustomTextField( - label: 'Relationship', - controller: controller.relationshipController, + label: 'Birth Date (YYYY-MM-DD)', + controller: controller.birthDateController, validator: (value) => - TValidators.validateUserInput('Relationship', value, 50), - errorText: controller.relationshipError.value, + TValidators.validateUserInput('Birth date', value, 10), + errorText: controller.birthDateError.value, textInputAction: TextInputAction.done, + keyboardType: TextInputType.datetime, + hintText: 'e.g., 1990-01-31', ), ), ], diff --git a/sigap-mobile/lib/src/features/daily-ops/models/index.dart b/sigap-mobile/lib/src/features/daily-ops/models/index.dart index d42354a..f994a2d 100644 --- a/sigap-mobile/lib/src/features/daily-ops/models/index.dart +++ b/sigap-mobile/lib/src/features/daily-ops/models/index.dart @@ -1,3 +1,4 @@ +export 'officers_model.dart'; export 'patrol_units_model.dart'; export 'unit_statistics_model.dart'; export 'units_model.dart'; diff --git a/sigap-mobile/lib/src/features/daily-ops/models/officers_model.dart b/sigap-mobile/lib/src/features/daily-ops/models/officers_model.dart new file mode 100644 index 0000000..6839bc1 --- /dev/null +++ b/sigap-mobile/lib/src/features/daily-ops/models/officers_model.dart @@ -0,0 +1,159 @@ +import 'package:sigap/src/features/personalization/models/roles_model.dart'; + +class OfficerModel { + final String id; + final String unitId; + final String roleId; + final String? patrolUnitId; + final String nrp; + final String name; + final String? rank; + final String? position; + final String? phone; + final String? email; + final String? avatar; + final DateTime? validUntil; + final String? qrCode; + final DateTime? createdAt; + final DateTime? updatedAt; + final RoleModel? role; + + OfficerModel({ + required this.id, + required this.unitId, + required this.roleId, + this.patrolUnitId, + required this.nrp, + required this.name, + this.rank, + this.position, + this.phone, + this.email, + this.avatar, + this.validUntil, + this.qrCode, + this.createdAt, + this.updatedAt, + this.role, + }); + + // Create an OfficerModel instance from a JSON object + factory OfficerModel.fromJson(Map json) { + return OfficerModel( + id: json['id'] as String, + unitId: json['unit_id'] as String, + roleId: json['role_id'] as String, + patrolUnitId: json['patrol_unit_id'] as String?, + nrp: json['nrp'] as String, + name: json['name'] as String, + rank: json['rank'] as String?, + position: json['position'] as String?, + phone: json['phone'] as String?, + email: json['email'] as String?, + avatar: json['avatar'] as String?, + validUntil: + json['valid_until'] != null + ? DateTime.parse(json['valid_until'] as String) + : null, + qrCode: json['qr_code'] as String?, + createdAt: + json['created_at'] != null + ? DateTime.parse(json['created_at'] as String) + : null, + updatedAt: + json['updated_at'] != null + ? DateTime.parse(json['updated_at'] as String) + : null, + role: + json['roles'] != null + ? RoleModel.fromJson(json['roles'] as Map) + : null, + ); + } + + // Convert an OfficerModel instance to a JSON object + Map toJson() { + return { + 'id': id, + 'unit_id': unitId, + 'role_id': roleId, + 'patrol_unit_id': patrolUnitId, + 'nrp': nrp, + 'name': name, + 'rank': rank, + 'position': position, + 'phone': phone, + 'email': email, + 'avatar': avatar, + 'valid_until': validUntil?.toIso8601String(), + 'qr_code': qrCode, + 'created_at': createdAt?.toIso8601String(), + 'updated_at': updatedAt?.toIso8601String(), + if (role != null) 'roles': role!.toJson(), + }; + } + + // Create a copy of the OfficerModel with updated fields + OfficerModel copyWith({ + String? id, + String? unitId, + String? roleId, + String? patrolUnitId, + String? nrp, + String? name, + String? rank, + String? position, + String? phone, + String? email, + String? avatar, + DateTime? validUntil, + String? qrCode, + DateTime? createdAt, + DateTime? updatedAt, + RoleModel? role, + }) { + return OfficerModel( + id: id ?? this.id, + unitId: unitId ?? this.unitId, + roleId: roleId ?? this.roleId, + patrolUnitId: patrolUnitId ?? this.patrolUnitId, + nrp: nrp ?? this.nrp, + name: name ?? this.name, + rank: rank ?? this.rank, + position: position ?? this.position, + phone: phone ?? this.phone, + email: email ?? this.email, + avatar: avatar ?? this.avatar, + validUntil: validUntil ?? this.validUntil, + qrCode: qrCode ?? this.qrCode, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + role: role ?? this.role, + ); + } + + // Create an OfficerModel from a User metadata + factory OfficerModel.fromUserMetadata( + String userId, + Map metadata, + ) { + final officerData = metadata['officer_data'] ?? {}; + + return OfficerModel( + id: userId, + unitId: officerData['unit_id'] ?? '', + roleId: '', // This would need to be fetched or defined elsewhere + nrp: officerData['nrp'] ?? '', + name: officerData['name'] ?? '', + rank: officerData['rank'], + position: officerData['position'], + phone: officerData['phone'], + email: metadata['email'], + ); + } + + @override + String toString() { + return 'OfficerModel(id: $id, name: $name, nrp: $nrp)'; + } +} diff --git a/sigap-mobile/lib/src/features/daily-ops/models/patrol_units_model.dart b/sigap-mobile/lib/src/features/daily-ops/models/patrol_units_model.dart index cbf6a2e..fc1d885 100644 --- a/sigap-mobile/lib/src/features/daily-ops/models/patrol_units_model.dart +++ b/sigap-mobile/lib/src/features/daily-ops/models/patrol_units_model.dart @@ -1,6 +1,6 @@ import 'package:sigap/src/features/daily-ops/models/units_model.dart'; import 'package:sigap/src/features/map/models/locations_model.dart'; -import 'package:sigap/src/features/personalization/models/officers_model.dart'; +import 'package:sigap/src/features/daily-ops/models/officers_model.dart'; class PatrolUnitModel { final String id; diff --git a/sigap-mobile/lib/src/features/daily-ops/models/units_model.dart b/sigap-mobile/lib/src/features/daily-ops/models/units_model.dart index 43e748f..a49b345 100644 --- a/sigap-mobile/lib/src/features/daily-ops/models/units_model.dart +++ b/sigap-mobile/lib/src/features/daily-ops/models/units_model.dart @@ -1,6 +1,6 @@ import 'package:sigap/src/features/daily-ops/models/patrol_units_model.dart'; import 'package:sigap/src/features/daily-ops/models/unit_statistics_model.dart'; -import 'package:sigap/src/features/personalization/models/officers_model.dart'; +import 'package:sigap/src/features/daily-ops/models/officers_model.dart'; enum UnitType { polda, polsek, polres, other } diff --git a/sigap-mobile/lib/src/features/onboarding/controllers/onboarding_controller.dart b/sigap-mobile/lib/src/features/onboarding/controllers/onboarding_controller.dart index 440f75f..2b4f898 100644 --- a/sigap-mobile/lib/src/features/onboarding/controllers/onboarding_controller.dart +++ b/sigap-mobile/lib/src/features/onboarding/controllers/onboarding_controller.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:get_storage/get_storage.dart'; +import 'package:sigap/src/cores/services/location_service.dart'; import 'package:sigap/src/features/onboarding/datas/onboarding_data.dart'; import 'package:sigap/src/utils/constants/app_routes.dart'; @@ -8,9 +10,16 @@ class OnboardingController extends GetxController // Singleton instance static OnboardingController get instance => Get.find(); + // Storage for onboarding state + final _storage = GetStorage(); + + // Location service + final _locationService = Get.find(); + // Observable variables final RxInt currentIndex = 0.obs; final PageController pageController = PageController(initialPage: 0); + final RxBool isLocationChecking = false.obs; // Animation controllers late AnimationController animationController; @@ -74,18 +83,36 @@ class OnboardingController extends GetxController // Method to navigate to welcome screen void navigateToWelcomeScreen() { + // Mark onboarding as completed in storage + _storage.write('ONBOARDING_COMPLETED', true); Get.offAllNamed(AppRoutes.welcome); } - void getStarted() { - Get.offAllNamed(AppRoutes.chooseRole); + // Method to check location validity and proceed with auth flow + Future getStarted() async { + isLocationChecking.value = true; + + try { + // Verify location is valid (in Jember and not mocked) + final isLocationValid = + await _locationService.isLocationValidForFeature(); + + if (isLocationValid) { + // If location is valid, proceed to role selection + Get.offAllNamed(AppRoutes.roleSelection); + } else { + // If location is invalid, show warning screen + Get.offAllNamed(AppRoutes.locationWarning); + } + } catch (e) { + // If there's an error, show the location warning screen + Get.offAllNamed(AppRoutes.locationWarning); + } finally { + isLocationChecking.value = false; + } } void goToSignIn() { Get.offAllNamed(AppRoutes.signIn); } - - void goToSignUp() { - Get.offAllNamed(AppRoutes.signUp); - } } diff --git a/sigap-mobile/lib/src/features/onboarding/controllers/choose_role_controller.dart b/sigap-mobile/lib/src/features/onboarding/controllers/role_selection_controller.dart similarity index 85% rename from sigap-mobile/lib/src/features/onboarding/controllers/choose_role_controller.dart rename to sigap-mobile/lib/src/features/onboarding/controllers/role_selection_controller.dart index 36251f6..d1aade3 100644 --- a/sigap-mobile/lib/src/features/onboarding/controllers/choose_role_controller.dart +++ b/sigap-mobile/lib/src/features/onboarding/controllers/role_selection_controller.dart @@ -1,11 +1,11 @@ import 'package:get/get.dart'; -import 'package:sigap/src/cores/repositories/roles/roles_repository.dart'; +import 'package:sigap/src/cores/repositories/personalization/roles_repository.dart'; import 'package:sigap/src/features/personalization/models/roles_model.dart'; import 'package:sigap/src/utils/constants/app_routes.dart'; import 'package:sigap/src/utils/popups/loaders.dart'; -class ChooseRoleController extends GetxController { - static ChooseRoleController get instance => Get.find(); +class RoleSelectionController extends GetxController { + static RoleSelectionController get instance => Get.find(); // Repositories final _rolesRepository = Get.find(); @@ -78,10 +78,10 @@ class ChooseRoleController extends GetxController { isOfficer.value = false; } - // Navigate to sign up screen with selected role - Get.offNamed( - AppRoutes.signUp, - arguments: {'role': selectedRole.value, 'isOfficer': isOfficer.value}, + // Navigate directly to step form with selected role + Get.toNamed( + AppRoutes.formRegistration, + arguments: {'role': selectedRole.value}, ); } catch (e) { TLoaders.errorSnackBar( diff --git a/sigap-mobile/lib/src/features/onboarding/screens/location-warning/location_warning_screen.dart b/sigap-mobile/lib/src/features/onboarding/screens/location-warning/location_warning_screen.dart new file mode 100644 index 0000000..93674ee --- /dev/null +++ b/sigap-mobile/lib/src/features/onboarding/screens/location-warning/location_warning_screen.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:sigap/src/cores/services/location_service.dart'; +import 'package:sigap/src/utils/constants/app_routes.dart'; +import 'package:sigap/src/utils/constants/colors.dart'; + +class LocationWarningScreen extends StatelessWidget { + const LocationWarningScreen({super.key}); + + @override + Widget build(BuildContext context) { + final locationService = Get.find(); + final isMocked = locationService.isMockedLocation.value; + final isOutsideJember = !locationService.isInJember(); + + return Scaffold( + backgroundColor: TColors.light, + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Warning Icon + Icon(Icons.location_off, size: 80, color: TColors.error), + + const SizedBox(height: 32), + + // Warning Title + Text( + 'Location Restriction', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: TColors.textPrimary, + ), + textAlign: TextAlign.center, + ), + + const SizedBox(height: 16), + + // Warning Message + Text( + isMocked + ? 'We detected that you are using a mock location app. Please disable it to continue.' + : isOutsideJember + ? 'This application is only available within Jember region. Please try again when you are in Jember.' + : 'We could not verify your location. Please ensure your location services are enabled and try again.', + style: TextStyle( + fontSize: 16, + color: TColors.textSecondary, + height: 1.5, + ), + textAlign: TextAlign.center, + ), + + const SizedBox(height: 40), + + // Try Again Button + SizedBox( + width: double.infinity, + height: 56, + child: ElevatedButton( + onPressed: () async { + final isValid = + await locationService.isLocationValidForFeature(); + if (isValid) { + Get.offAllNamed(AppRoutes.roleSelection); + } else { + Get.snackbar( + 'Location Issue', + 'Your location is still not valid. Please ensure you are in Jember with location services enabled.', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: TColors.error.withOpacity(0.1), + colorText: TColors.error, + ); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: TColors.primary, + foregroundColor: TColors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + elevation: 0, + ), + child: const Text( + 'Try Again', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + ), + ), + + const SizedBox(height: 16), + + // Exit App Button + TextButton( + onPressed: () { + // This will close the app + Get.back(); + }, + child: Text( + 'Exit App', + style: TextStyle( + color: TColors.textSecondary, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/sigap-mobile/lib/src/features/onboarding/screens/choose-role/choose_role_screen.dart b/sigap-mobile/lib/src/features/onboarding/screens/role-selection/role_selection_screen.dart similarity index 87% rename from sigap-mobile/lib/src/features/onboarding/screens/choose-role/choose_role_screen.dart rename to sigap-mobile/lib/src/features/onboarding/screens/role-selection/role_selection_screen.dart index 036c1cf..9e01c96 100644 --- a/sigap-mobile/lib/src/features/onboarding/screens/choose-role/choose_role_screen.dart +++ b/sigap-mobile/lib/src/features/onboarding/screens/role-selection/role_selection_screen.dart @@ -3,17 +3,17 @@ import 'package:flutter/services.dart'; import 'package:get/get.dart'; import 'package:sigap/src/features/auth/screens/widgets/auth_button.dart'; import 'package:sigap/src/features/auth/screens/widgets/auth_header.dart'; -import 'package:sigap/src/features/onboarding/controllers/choose_role_controller.dart'; -import 'package:sigap/src/features/onboarding/screens/choose-role/widgets/role_card.dart'; +import 'package:sigap/src/features/onboarding/controllers/role_selection_controller.dart'; +import 'package:sigap/src/features/onboarding/screens/role-selection/widgets/role_card.dart'; import 'package:sigap/src/utils/constants/colors.dart'; -class ChooseRoleScreen extends StatelessWidget { - const ChooseRoleScreen({super.key}); +class RoleSelectionScreen extends StatelessWidget { + const RoleSelectionScreen({super.key}); @override Widget build(BuildContext context) { // Get the controller - final controller = Get.find(); + final controller = Get.find(); // Set system overlay style SystemChrome.setSystemUIOverlayStyle( diff --git a/sigap-mobile/lib/src/features/onboarding/screens/choose-role/widgets/role_card.dart b/sigap-mobile/lib/src/features/onboarding/screens/role-selection/widgets/role_card.dart similarity index 100% rename from sigap-mobile/lib/src/features/onboarding/screens/choose-role/widgets/role_card.dart rename to sigap-mobile/lib/src/features/onboarding/screens/role-selection/widgets/role_card.dart diff --git a/sigap-mobile/lib/src/features/personalization/models/index.dart b/sigap-mobile/lib/src/features/personalization/models/index.dart index fe4b3fb..681e0bb 100644 --- a/sigap-mobile/lib/src/features/personalization/models/index.dart +++ b/sigap-mobile/lib/src/features/personalization/models/index.dart @@ -1,4 +1,3 @@ -export 'officers_model.dart'; export 'permissions_model.dart'; export 'profile_model.dart'; export 'resources_model.dart'; diff --git a/sigap-mobile/lib/src/features/personalization/models/officers_model.dart b/sigap-mobile/lib/src/features/personalization/models/officers_model.dart index 6839bc1..e69de29 100644 --- a/sigap-mobile/lib/src/features/personalization/models/officers_model.dart +++ b/sigap-mobile/lib/src/features/personalization/models/officers_model.dart @@ -1,159 +0,0 @@ -import 'package:sigap/src/features/personalization/models/roles_model.dart'; - -class OfficerModel { - final String id; - final String unitId; - final String roleId; - final String? patrolUnitId; - final String nrp; - final String name; - final String? rank; - final String? position; - final String? phone; - final String? email; - final String? avatar; - final DateTime? validUntil; - final String? qrCode; - final DateTime? createdAt; - final DateTime? updatedAt; - final RoleModel? role; - - OfficerModel({ - required this.id, - required this.unitId, - required this.roleId, - this.patrolUnitId, - required this.nrp, - required this.name, - this.rank, - this.position, - this.phone, - this.email, - this.avatar, - this.validUntil, - this.qrCode, - this.createdAt, - this.updatedAt, - this.role, - }); - - // Create an OfficerModel instance from a JSON object - factory OfficerModel.fromJson(Map json) { - return OfficerModel( - id: json['id'] as String, - unitId: json['unit_id'] as String, - roleId: json['role_id'] as String, - patrolUnitId: json['patrol_unit_id'] as String?, - nrp: json['nrp'] as String, - name: json['name'] as String, - rank: json['rank'] as String?, - position: json['position'] as String?, - phone: json['phone'] as String?, - email: json['email'] as String?, - avatar: json['avatar'] as String?, - validUntil: - json['valid_until'] != null - ? DateTime.parse(json['valid_until'] as String) - : null, - qrCode: json['qr_code'] as String?, - createdAt: - json['created_at'] != null - ? DateTime.parse(json['created_at'] as String) - : null, - updatedAt: - json['updated_at'] != null - ? DateTime.parse(json['updated_at'] as String) - : null, - role: - json['roles'] != null - ? RoleModel.fromJson(json['roles'] as Map) - : null, - ); - } - - // Convert an OfficerModel instance to a JSON object - Map toJson() { - return { - 'id': id, - 'unit_id': unitId, - 'role_id': roleId, - 'patrol_unit_id': patrolUnitId, - 'nrp': nrp, - 'name': name, - 'rank': rank, - 'position': position, - 'phone': phone, - 'email': email, - 'avatar': avatar, - 'valid_until': validUntil?.toIso8601String(), - 'qr_code': qrCode, - 'created_at': createdAt?.toIso8601String(), - 'updated_at': updatedAt?.toIso8601String(), - if (role != null) 'roles': role!.toJson(), - }; - } - - // Create a copy of the OfficerModel with updated fields - OfficerModel copyWith({ - String? id, - String? unitId, - String? roleId, - String? patrolUnitId, - String? nrp, - String? name, - String? rank, - String? position, - String? phone, - String? email, - String? avatar, - DateTime? validUntil, - String? qrCode, - DateTime? createdAt, - DateTime? updatedAt, - RoleModel? role, - }) { - return OfficerModel( - id: id ?? this.id, - unitId: unitId ?? this.unitId, - roleId: roleId ?? this.roleId, - patrolUnitId: patrolUnitId ?? this.patrolUnitId, - nrp: nrp ?? this.nrp, - name: name ?? this.name, - rank: rank ?? this.rank, - position: position ?? this.position, - phone: phone ?? this.phone, - email: email ?? this.email, - avatar: avatar ?? this.avatar, - validUntil: validUntil ?? this.validUntil, - qrCode: qrCode ?? this.qrCode, - createdAt: createdAt ?? this.createdAt, - updatedAt: updatedAt ?? this.updatedAt, - role: role ?? this.role, - ); - } - - // Create an OfficerModel from a User metadata - factory OfficerModel.fromUserMetadata( - String userId, - Map metadata, - ) { - final officerData = metadata['officer_data'] ?? {}; - - return OfficerModel( - id: userId, - unitId: officerData['unit_id'] ?? '', - roleId: '', // This would need to be fetched or defined elsewhere - nrp: officerData['nrp'] ?? '', - name: officerData['name'] ?? '', - rank: officerData['rank'], - position: officerData['position'], - phone: officerData['phone'], - email: metadata['email'], - ); - } - - @override - String toString() { - return 'OfficerModel(id: $id, name: $name, nrp: $nrp)'; - } -} diff --git a/sigap-mobile/lib/src/features/personalization/models/profile_model.dart b/sigap-mobile/lib/src/features/personalization/models/profile_model.dart index 787a9c3..da83b6d 100644 --- a/sigap-mobile/lib/src/features/personalization/models/profile_model.dart +++ b/sigap-mobile/lib/src/features/personalization/models/profile_model.dart @@ -1,6 +1,7 @@ class ProfileModel { final String id; final String userId; + final String nik; final String? avatar; final String? username; final String? firstName; @@ -12,6 +13,7 @@ class ProfileModel { ProfileModel({ required this.id, required this.userId, + required this.nik, this.avatar, this.username, this.firstName, @@ -26,6 +28,7 @@ class ProfileModel { return ProfileModel( id: json['id'] as String, userId: json['user_id'] as String, + nik: json['nik'] as String, avatar: json['avatar'] as String?, username: json['username'] as String?, firstName: json['first_name'] as String?, @@ -47,6 +50,7 @@ class ProfileModel { return { 'id': id, 'user_id': userId, + 'nik': nik, 'avatar': avatar, 'username': username, 'first_name': firstName, @@ -61,6 +65,7 @@ class ProfileModel { ProfileModel copyWith({ String? id, String? userId, + String? nik, String? avatar, String? username, String? firstName, @@ -72,6 +77,7 @@ class ProfileModel { return ProfileModel( id: id ?? this.id, userId: userId ?? this.userId, + nik: nik ?? this.nik, avatar: avatar ?? this.avatar, username: username ?? this.username, firstName: firstName ?? this.firstName, @@ -88,6 +94,16 @@ class ProfileModel { return [firstName, lastName].where((x) => x != null).join(' '); } + // Get initials from first name and last name + String? get initials { + if (firstName == null && lastName == null) return null; + return [ + firstName?.substring(0, 1), + lastName?.substring(0, 1), + ].where((x) => x != null).join(''); + } + + @override String toString() { return 'ProfileModel(id: $id, username: $username, fullName: $fullName)'; diff --git a/sigap-mobile/lib/src/shared/widgets/text/custom_text_field.dart b/sigap-mobile/lib/src/shared/widgets/text/custom_text_field.dart index 8d0920f..5b3b14d 100644 --- a/sigap-mobile/lib/src/shared/widgets/text/custom_text_field.dart +++ b/sigap-mobile/lib/src/shared/widgets/text/custom_text_field.dart @@ -11,6 +11,7 @@ class CustomTextField extends StatelessWidget { final String? errorText; final bool autofocus; final int maxLines; + final String? hintText; final TextInputAction textInputAction; final Function(String)? onChanged; @@ -25,6 +26,7 @@ class CustomTextField extends StatelessWidget { this.errorText, this.autofocus = false, this.maxLines = 1, + this.hintText, this.textInputAction = TextInputAction.next, this.onChanged, }); @@ -54,6 +56,8 @@ class CustomTextField extends StatelessWidget { onChanged: onChanged, style: TextStyle(color: TColors.textPrimary, fontSize: 16), decoration: InputDecoration( + hintText: hintText, + hintStyle: TextStyle(color: TColors.textSecondary, fontSize: 16), errorText: errorText != null && errorText!.isNotEmpty ? errorText : null, contentPadding: const EdgeInsets.symmetric( diff --git a/sigap-mobile/lib/src/utils/constants/app_routes.dart b/sigap-mobile/lib/src/utils/constants/app_routes.dart index 8c912d1..f1df6a6 100644 --- a/sigap-mobile/lib/src/utils/constants/app_routes.dart +++ b/sigap-mobile/lib/src/utils/constants/app_routes.dart @@ -3,6 +3,7 @@ class AppRoutes { static const String welcome = '/welcome'; static const String signIn = '/sign-in'; static const String signUp = '/sign-up'; + static const String emailVerification = '/email-verification'; static const String forgotPassword = '/forgot-password'; static const String explore = '/explore'; static const String map = '/map'; @@ -11,6 +12,8 @@ class AppRoutes { static const String dailyOps = '/daily-ops'; static const String settings = '/settings'; static const String profile = '/profile'; - static const String chooseRole = '/choose-role'; + static const String roleSelection = '/role-selection'; static const String stateScreen = '/state-screen'; + static const String locationWarning = '/location-warning'; + static const String formRegistration = '/form-registration'; }