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.
This commit is contained in:
vergiLgood1 2025-05-17 07:10:00 +07:00
parent 8da86d10d2
commit 803a28494d
62 changed files with 2600 additions and 1284 deletions

View File

@ -1,6 +1,10 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Required to fetch data from the internet. --> <!-- Required to fetch data from the internet. -->
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<!-- Geolocator permissions -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<!-- ... --> <!-- ... -->
<application android:label="sigap" <application android:label="sigap"
android:name="${applicationName}" android:name="${applicationName}"
@ -32,23 +36,23 @@
<data android:scheme="io.supabase.flutterquickstart" <data android:scheme="io.supabase.flutterquickstart"
android:host="signin" /> android:host="signin" />
<data android:scheme="io.supabase.flutterquickstart" <data android:scheme="io.supabase.flutterquickstart"
android:host="signup" /> android:host="signup" />
<data android:scheme="io.supabase.flutterquickstart" <data android:scheme="io.supabase.flutterquickstart"
android:host="forgotpassword" /> android:host="forgotpassword" />
<data android:scheme="io.supabase.flutterquickstart" <data android:scheme="io.supabase.flutterquickstart"
android:host="resetpassword" /> android:host="resetpassword" />
<data android:scheme="io.supabase.flutterquickstart" <data android:scheme="io.supabase.flutterquickstart"
android:host="verifyemail" /> android:host="verifyemail" />
<data android:scheme="io.supabase.flutterquickstart" <data android:scheme="io.supabase.flutterquickstart"
android:host="verifyphone" /> android:host="verifyphone" />
<data android:scheme="io.supabase.flutterquickstart" <data android:scheme="io.supabase.flutterquickstart"
android:host="verifyemailotp" /> android:host="verifyemailotp" />
<data android:scheme="io.supabase.flutterquickstart" <data android:scheme="io.supabase.flutterquickstart"
android:host="verifyphoneotp" /> android:host="verifyphoneotp" />
<data android:scheme="io.supabase.flutterquickstart" <data android:scheme="io.supabase.flutterquickstart"
android:host="verifyemailchange" /> android:host="verifyemailchange" />
<data android:scheme="io.supabase.flutterquickstart" <data android:scheme="io.supabase.flutterquickstart"
android:host="verifyphonechange" /> android:host="verifyphonechange" />
</intent-filter> </intent-filter>
</activity> </activity>
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.

View File

@ -155,3 +155,37 @@ panic_logs:
- timestamp TIMESTAMP - timestamp TIMESTAMP
- location POINT - location POINT
``` ```
Splash Screen
Token Valid?
┌─────────────┐
│ Ya │ → Biometric Prompt
│ Tidak │
└─────┬───────┘
Onboarding Completed?
┌─────────────┐
│ Ya │ → geolocator verification
│ Tidak │ → Tampilkan Onboarding
└─────┬───────┘
Location Valid?
┌─────────────┐
│ Ya │ → Role Selection
│ Tidak │ → Tampilkan Sreen warning geolocator
└─────┬───────┘
Role Selection
Registrasi Step Form (NIK/NRP + Upload KTP/KTA + OCR AI untuk validasi KTP/KTA)
Submit & Buat Akun
Login Manual
Login Success?
┌─────────────┐
│ Ya │ → Home sesuai Role
│ Tidak │ → Tampilkan Error
└─────────────┘

View File

@ -58,5 +58,7 @@
</dict> </dict>
</array> </array>
<!-- ... other tags --> <!-- ... other tags -->
<key>NSLocationWhenInUseUsageDescription</key>
<string>This app needs access to location when open.</string>
</dict> </dict>
</plist> </plist>

View File

@ -4,10 +4,7 @@ import 'package:flutter_native_splash/flutter_native_splash.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart'; import 'package:get_storage/get_storage.dart';
import 'package:sigap/app.dart'; import 'package:sigap/app.dart';
import 'package:sigap/src/cores/repositories/panic/panic_button_repository.dart'; import 'package:sigap/src/cores/repositories/panic-button/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:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
Future<void> main() async { Future<void> main() async {
@ -35,11 +32,6 @@ Future<void> main() async {
storageOptions: const StorageClientOptions(retryAttempts: 10), storageOptions: const StorageClientOptions(retryAttempts: 10),
); );
// Initialize services
await Get.putAsync(() => SupabaseService().init());
await Get.putAsync(() => BiometricService().init());
await Get.putAsync(() => LocationService().init());
// Initialize repositories // Initialize repositories
Get.put(PanicButtonRepository()); Get.put(PanicButtonRepository());
await Get.find<PanicButtonRepository>().init(); await Get.find<PanicButtonRepository>().init();

View File

@ -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/forgot_password_controller.dart';
import 'package:sigap/src/features/auth/controllers/signin_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/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/onboarding_controller.dart';
import 'package:sigap/src/features/onboarding/controllers/role_selection_controller.dart';
// Onboarding controller bindings // Onboarding controller bindings
class OnboardingControllerBinding extends Bindings { class OnboardingControllerBinding extends Bindings {
@ -13,10 +13,10 @@ class OnboardingControllerBinding extends Bindings {
} }
} }
class ChooseRoleControllerBinding extends Bindings { class RoleSelectionControllerBinding extends Bindings {
@override @override
void dependencies() { void dependencies() {
Get.lazyPut<ChooseRoleController>(() => ChooseRoleController()); Get.lazyPut<RoleSelectionController>(() => RoleSelectionController());
} }
} }

View File

@ -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>(
() => AuthenticationRepository(),
fenix: true,
);
// User Repository
Get.lazyPut<UserRepository>(() => UserRepository(), fenix: true);
// Officer Repository
Get.lazyPut<OfficerRepository>(() => OfficerRepository(), fenix: true);
// Unit Repository
Get.lazyPut<UnitRepository>(() => UnitRepository(), fenix: true);
// Profile Repository
Get.lazyPut<ProfileRepository>(() => ProfileRepository(), fenix: true);
// Role Repository
Get.lazyPut<RolesRepository>(() => RolesRepository(), fenix: true);
}
}

View File

@ -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<void> dependencies() async {
// Initialize services
await Get.putAsync(() => SupabaseService().init());
await Get.putAsync(() => BiometricService().init());
await Get.putAsync(() => LocationService().init());
}
}

View File

@ -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/location_service.dart';
import 'package:sigap/src/cores/services/supabase_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/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/constants/app_routes.dart';
import 'package:sigap/src/utils/exceptions/exceptions.dart'; import 'package:sigap/src/utils/exceptions/exceptions.dart';
import 'package:sigap/src/utils/exceptions/format_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<bool> isOnboardingComplete() async {
return storage.read('ONBOARDING_COMPLETED') ?? false;
}
// Screen redirect based on auth status
void screenRedirect() async {
final user = _supabase.auth.currentUser; final user = _supabase.auth.currentUser;
if (user != null) { if (user != null) {
// local storage // User is authenticated
storage.writeIfNull('isFirstTime', true); Get.offAllNamed(AppRoutes.panicButton);
// check if user is already logged in
storage.read('isFirstTime') != true
? Get.offAll(() => const SignInScreen())
: Get.offAll(() => const OnboardingScreen());
} else { } else {
// Try biometric login first // Check if onboarding is completed
bool biometricSuccess = await attemptBiometricLogin(); final onboardingCompleted = await isOnboardingComplete();
if (!biometricSuccess) {
Get.offAll(() => const SignInScreen()); 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 // [Email Reset Password ] - RESET PASSWORD
Future<void> sendOtpResetPassword(String email) async { Future<void> sendResetPasswordForEmail(String email) async {
try { try {
await _supabase.auth.resetPasswordForEmail(email); await _supabase.auth.resetPasswordForEmail(email);
} on AuthException catch (e) { } on AuthException catch (e) {
@ -365,15 +381,11 @@ class AuthenticationRepository extends GetxController {
} }
} }
// [Email AUTH] - SIGN UP with role selection and location verification // [Email AUTH] - SIGN UP
Future<AuthResponse> signUpWithCredential( Future<AuthResponse> signUpWithEmailPassword({
String email, required String email,
String password, required String password,
String identifier, // NIK for users or NRP for officers
{
Map<String, dynamic>? userMetadata, Map<String, dynamic>? userMetadata,
bool isOfficer = false,
Map<String, dynamic>? officerData,
}) async { }) async {
try { try {
// Validate location for registration // Validate location for registration
@ -381,104 +393,23 @@ class AuthenticationRepository extends GetxController {
if (!isLocationValid) { 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.'); 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 // Create user with email and password
final identifierField = isOfficer ? 'nrp' : 'nik'; final response = await _supabase.auth.signUp(
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, email: email,
password: password, password: password,
data: metadata, data: userMetadata,
); );
final Session? session = authResponse.session; return response;
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) { } on AuthException catch (e) {
throw TExceptions(e.message); throw TExceptions(e.message);
} on FormatException catch (_) { } on FormatException catch (_) {
throw const TFormatException(); throw const TFormatException();
} on PlatformException catch (e) { } on PlatformException catch (e) {
throw TPlatformException(e.code).message; throw TPlatformException(e.code).message;
} on PostgrestException catch (error) {
throw TExceptions.fromCode(error.code ?? 'unknown_error');
} catch (e) { } catch (e) {
if (e is TExceptions) { if (e is TExceptions) rethrow;
rethrow;
}
throw TExceptions('Something went wrong. Please try again later.'); throw TExceptions('Something went wrong. Please try again later.');
} }
} }

View File

@ -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<bool> 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<AuthResponse> 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<Map<String, dynamic>?> 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<void> 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<void> 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<AuthResponse> 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<UserResponse> 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<String> 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<void> 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<bool> 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<bool> 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<bool> 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<bool> 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<bool> 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<AuthResponse> signUpWithCredential(
String email,
String password,
String identifier, { // NIK for users or NRP for officers
Map<String, dynamic>? userMetadata,
bool isOfficer = false,
Map<String, dynamic>? 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<void> toggleBiometricLogin(bool enable) async {
if (enable) {
await _biometricService.enableBiometricLogin();
} else {
await _biometricService.disableBiometricLogin();
}
}
// Check if biometric login is enabled
Future<bool> isBiometricLoginEnabled() async {
return await _biometricService.isBiometricLoginEnabled();
}
// Check if biometrics are available on the device
Future<bool> isBiometricAvailable() async {
return _biometricService.isBiometricAvailable.value;
}
// ----------------- Logout -----------------
// [Sign Out] - SIGN OUT
Future<void> 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<void> 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<UserResponse> updateUserRole({
required bool isOfficer,
Map<String, dynamic>? officerData,
}) async {
try {
// Prepare metadata with the officer flag
final userMetadata = <String, dynamic>{'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.');
}
}
}

View File

@ -1,7 +1,7 @@
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:get/get.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/features/personalization/models/index.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/exceptions.dart';
import 'package:sigap/src/utils/exceptions/format_exceptions.dart'; import 'package:sigap/src/utils/exceptions/format_exceptions.dart';
import 'package:sigap/src/utils/exceptions/platform_exceptions.dart'; import 'package:sigap/src/utils/exceptions/platform_exceptions.dart';

View File

@ -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<LocationService>();
// Rate limiting
final RxInt panicAttemptsInWindow = 0.obs;
final RxBool isRateLimited = false.obs;
final RxInt panicStrikeCount = 0.obs;
final Rx<DateTime?> lastPanicTime = Rx<DateTime?>(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<Map<String, dynamic>> 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<Map<String, dynamic>> 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<Map<String, dynamic>> 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<String> _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<String> _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<void> _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<void> _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<void> _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<bool> _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<void> _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');
}
}
}

View File

@ -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<void> init() async {
await _loadPanicStrikeCount();
_startRateLimitCooldown();
}
// Send a panic alert
Future<bool> 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<String> _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<void> _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<void> _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<void> _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<void> _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<bool> _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<void> _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');
}
}
}

View File

@ -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<ProfileModel> 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<String> 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<ProfileModel> 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<ProfileModel> 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()}';
}
}
}

View File

@ -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<ProfileModel> 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<String> 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<ProfileModel> 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<ProfileModel> 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()}';
}
}
}

View File

@ -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<List<UnitModel>> 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()}';
}
}
}

View File

@ -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/forgot-password/forgot_password.dart';
import 'package:sigap/src/features/auth/screens/signin/signin_screen.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/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/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'; import 'package:sigap/src/utils/constants/app_routes.dart';
class AppPages { class AppPages {
@ -19,9 +19,9 @@ class AppPages {
), ),
GetPage( GetPage(
name: AppRoutes.chooseRole, name: AppRoutes.roleSelection,
page: () => const ChooseRoleScreen(), page: () => const RoleSelectionScreen(),
binding: ChooseRoleControllerBinding(), binding: RoleSelectionControllerBinding(),
), ),
// Auth // Auth

View File

@ -83,10 +83,10 @@ class BiometricService extends GetxService {
// Store user email and hashed password for session recovery // Store user email and hashed password for session recovery
final userMetadata = UserMetadataModel.fromJson(user.userMetadata); final userMetadata = UserMetadataModel.fromJson(user.userMetadata);
if (userMetadata.officer?.email != null) { if (userMetadata.officerData?.email != null) {
await _secureStorage.write( await _secureStorage.write(
key: 'user_email', 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 // Parse user metadata with our type-safe model
String? identifier; String? identifier;
if (userMetadata.isOfficer && userMetadata.officer != null) { if (userMetadata.isOfficer && userMetadata.officerData != null) {
identifier = userMetadata.officer!.nrp; identifier = userMetadata.officerData!.nrp;
} else if (userMetadata.nik != null) { } else if (userMetadata.nik != null) {
identifier = userMetadata.nik; identifier = userMetadata.nik;
} }

View File

@ -1,24 +1,76 @@
import 'package:get/get.dart'; import 'package:flutter/foundation.dart';
import 'package:geolocator/geolocator.dart';
import 'package:geocoding/geocoding.dart'; import 'package:geocoding/geocoding.dart';
import 'package:geolocator/geolocator.dart';
import 'package:get/get.dart';
import 'package:sigap/src/utils/exceptions/exceptions.dart'; import 'package:sigap/src/utils/exceptions/exceptions.dart';
class LocationService extends GetxService { class LocationService extends GetxService {
static LocationService get instance => Get.find<LocationService>(); static LocationService get instance => Get.find<LocationService>();
final RxBool isLocationServiceEnabled = false.obs; final RxBool isLocationServiceEnabled = false.obs;
final RxBool isPermissionGranted = false.obs; final RxBool isPermissionGranted = false.obs;
final Rx<Position?> currentPosition = Rx<Position?>(null); final Rx<Position?> currentPosition = Rx<Position?>(null);
final RxString currentCity = ''.obs; final RxString currentCity = ''.obs;
final RxBool isMockedLocation = false.obs; final RxBool isMockedLocation = false.obs;
// Jember's center coordinate (approximate) // Jember's center coordinate (approximate)
static const double jemberLatitude = -8.168333; static const double jemberLatitude = -8.168333;
static const double jemberLongitude = 113.702778; static const double jemberLongitude = 113.702778;
// Max distance from Jember in meters (approximately 30km radius) // Max distance from Jember in meters (approximately 30km radius)
static const double maxDistanceFromJember = 30000; 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<DateTime?> lastLocationCheckTime = Rx<DateTime?>(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 // Initialize the service
Future<LocationService> init() async { Future<LocationService> init() async {
await _checkLocationService(); await _checkLocationService();
@ -29,14 +81,15 @@ class LocationService extends GetxService {
Future<bool> _checkLocationService() async { Future<bool> _checkLocationService() async {
try { try {
// Check if location service is enabled // Check if location service is enabled
isLocationServiceEnabled.value = await Geolocator.isLocationServiceEnabled(); isLocationServiceEnabled.value =
await Geolocator.isLocationServiceEnabled();
if (!isLocationServiceEnabled.value) { if (!isLocationServiceEnabled.value) {
return false; return false;
} }
// Check location permission // Check location permission
var permission = await Geolocator.checkPermission(); var permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) { if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission(); permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) { if (permission == LocationPermission.denied) {
@ -44,12 +97,12 @@ class LocationService extends GetxService {
return false; return false;
} }
} }
if (permission == LocationPermission.deniedForever) { if (permission == LocationPermission.deniedForever) {
isPermissionGranted.value = false; isPermissionGranted.value = false;
return false; return false;
} }
isPermissionGranted.value = true; isPermissionGranted.value = true;
return true; return true;
} catch (e) { } catch (e) {
@ -63,8 +116,9 @@ class LocationService extends GetxService {
Future<bool> requestLocationPermission() async { Future<bool> requestLocationPermission() async {
try { try {
var permission = await Geolocator.requestPermission(); var permission = await Geolocator.requestPermission();
isPermissionGranted.value = permission == LocationPermission.always || isPermissionGranted.value =
permission == LocationPermission.whileInUse; permission == LocationPermission.always ||
permission == LocationPermission.whileInUse;
return isPermissionGranted.value; return isPermissionGranted.value;
} catch (e) { } catch (e) {
isPermissionGranted.value = false; isPermissionGranted.value = false;
@ -81,34 +135,71 @@ class LocationService extends GetxService {
} }
currentPosition.value = await Geolocator.getCurrentPosition( currentPosition.value = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high, locationSettings: locationSettings,
timeLimit: const Duration(seconds: 10),
); );
// Check if location is mocked // Check if location is mocked
isMockedLocation.value = currentPosition.value?.isMocked ?? false; isMockedLocation.value = currentPosition.value?.isMocked ?? false;
// Get city name from coordinates // Get city name from coordinates
if (currentPosition.value != null) { if (currentPosition.value != null) {
await _updateCityName(); await _updateCityName();
} }
return currentPosition.value; return currentPosition.value;
} catch (e) { } catch (e) {
throw TExceptions('Failed to get location: ${e.toString()}'); throw TExceptions('Failed to get location: ${e.toString()}');
} }
} }
Future<Position?> 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 // Update the city name based on current position
Future<void> _updateCityName() async { Future<void> _updateCityName() async {
if (currentPosition.value == null) return; if (currentPosition.value == null) return;
try { try {
List<Placemark> placemarks = await placemarkFromCoordinates( List<Placemark> placemarks = await placemarkFromCoordinates(
currentPosition.value!.latitude, currentPosition.value!.latitude,
currentPosition.value!.longitude, currentPosition.value!.longitude,
); );
if (placemarks.isNotEmpty) { if (placemarks.isNotEmpty) {
currentCity.value = placemarks.first.locality ?? ''; currentCity.value = placemarks.first.locality ?? '';
} }
@ -116,16 +207,16 @@ class LocationService extends GetxService {
currentCity.value = ''; currentCity.value = '';
} }
} }
// Check if the user is in Jember // Check if the user is in Jember
bool isInJember() { bool isInJember() {
if (currentPosition.value == null) return false; if (currentPosition.value == null) return false;
// First check by city name if available // First check by city name if available
if (currentCity.value.toLowerCase().contains('jember')) { if (currentCity.value.toLowerCase().contains('jember')) {
return true; return true;
} }
// Then check by distance from Jember's center // Then check by distance from Jember's center
double distanceInMeters = Geolocator.distanceBetween( double distanceInMeters = Geolocator.distanceBetween(
currentPosition.value!.latitude, currentPosition.value!.latitude,
@ -133,20 +224,171 @@ class LocationService extends GetxService {
jemberLatitude, jemberLatitude,
jemberLongitude, jemberLongitude,
); );
return distanceInMeters <= maxDistanceFromJember; return distanceInMeters <= maxDistanceFromJember;
} }
// Check if location is valid for registration or panic button // Check if location is valid for registration or panic button
Future<bool> isLocationValidForFeature() async { Future<bool> isLocationValidForFeature() async {
await getCurrentPosition(); await getCurrentPosition();
if (currentPosition.value == null) return false; if (currentPosition.value == null) return false;
// Check if location is mocked // Check if location is mocked
if (isMockedLocation.value) return false; if (isMockedLocation.value) return false;
// Check if in Jember // Check if in Jember
return isInJember(); return isInJember();
} }
// Enhanced location validation for panic button
Future<Map<String, dynamic>> 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<String> getAddressFromCoordinates() async {
if (currentPosition.value == null) {
await getCurrentPosition();
if (currentPosition.value == null) return '';
}
try {
List<Placemark> placemarks = await placemarkFromCoordinates(
currentPosition.value!.latitude,
currentPosition.value!.longitude,
);
if (placemarks.isNotEmpty) {
final placemark = placemarks.first;
// Create a formatted address
final List<String> 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;
}
} }

View File

@ -17,8 +17,8 @@ class SupabaseService extends GetxService {
String? get currentUserId => _client.auth.currentUser?.id; String? get currentUserId => _client.auth.currentUser?.id;
/// Get type-safe user metadata /// Get type-safe user metadata
UserMetadataModel? get userMetadata { UserMetadataModel get userMetadata {
if (currentUser == null) return null; if (currentUser == null) return UserMetadataModel();
return UserMetadataModel.fromJson(currentUser!.userMetadata); return UserMetadataModel.fromJson(currentUser!.userMetadata);
} }
@ -26,17 +26,17 @@ class SupabaseService extends GetxService {
bool get isAuthenticated => currentUser != null; bool get isAuthenticated => currentUser != null;
/// Check if current user is an officer based on metadata /// 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 /// Get the stored identifier (NIK or NRP) of the current user
String? get userIdentifier { String? get userIdentifier {
if (currentUser == null) return null; if (currentUser == null) return null;
final metadata = userMetadata; final metadata = userMetadata;
if (metadata?.isOfficer == true && metadata?.officer != null) { if (metadata.isOfficer == true && metadata.officerData != null) {
return metadata!.officer!.nrp; return metadata.officerData?.nrp;
} else { } else {
return metadata?.nik; return metadata.profileData?.nik;
} }
} }
@ -45,8 +45,20 @@ class SupabaseService extends GetxService {
return this; return this;
} }
/// Update user metadata with type safety /// Update user metadata with raw Map
Future<User?> updateUserMetadata(UserMetadataModel metadata) async { Future<User?> updateUserMetadata(Map<String, dynamic> 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<User?> updateUserMetadataModel(UserMetadataModel metadata) async {
try { try {
final response = await client.auth.updateUser( final response = await client.auth.updateUser(
UserAttributes(data: metadata.toJson()), UserAttributes(data: metadata.toJson()),
@ -56,4 +68,9 @@ class SupabaseService extends GetxService {
throw Exception('Failed to update user metadata: $e'); throw Exception('Failed to update user metadata: $e');
} }
} }
/// Check if current user is an officer
bool get isUserOfficer => userMetadata.isOfficer;
} }

View File

@ -2,14 +2,14 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.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/constants/app_routes.dart';
import 'package:sigap/src/utils/popups/loaders.dart'; import 'package:sigap/src/utils/popups/loaders.dart';
class EmailVerificationController extends GetxController { class EmailVerificationController extends GetxController {
// OTP text controllers // OTP text controllers
final List<TextEditingController> otpControllers = List.generate( final List<TextEditingController> otpControllers = List.generate(
4, 6,
(_) => TextEditingController(), (_) => TextEditingController(),
); );
@ -94,21 +94,21 @@ class EmailVerificationController extends GetxController {
isLoading.value = true; isLoading.value = true;
verificationError.value = ''; verificationError.value = '';
// Simulate API call final authuser = await AuthenticationRepository.instance.verifyOtp(otp);
await Future.delayed(const Duration(seconds: 2));
// TODO: Implement actual OTP verification if (authuser.session == null || authuser.user == null) {
// For demo, we'll consider "1234" as valid OTP verificationError.value = 'Invalid OTP. Please try again.';
if (otp == "1234") { return;
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.';
} }
isVerified.value = true;
TLoaders.successSnackBar(
title: 'Verification Successful',
message: 'Your email has been verified successfully.',
);
Get.offNamed(AppRoutes.roleSelection);
} catch (e) { } catch (e) {
verificationError.value = 'Verification failed: ${e.toString()}'; verificationError.value = 'Verification failed: ${e.toString()}';
} finally { } finally {

View File

@ -1,5 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.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'; import 'package:sigap/src/utils/validators/validation.dart';
class ForgotPasswordController extends GetxController { class ForgotPasswordController extends GetxController {
@ -43,11 +45,16 @@ class ForgotPasswordController extends GetxController {
// Simulate API call // Simulate API call
await Future.delayed(const Duration(seconds: 2)); await Future.delayed(const Duration(seconds: 2));
// TODO: Implement actual password reset logic await AuthenticationRepository.instance.sendResetPasswordForEmail(
// This would typically involve calling an authentication service emailController.text,
);
// Show success message // Show success message
isEmailSent.value = true; isEmailSent.value = true;
TLoaders.successSnackBar(
title: 'Success',
message: 'Reset password email sent successfully.',
);
} catch (e) { } catch (e) {
Get.snackbar( Get.snackbar(
'Error', 'Error',

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart'; import 'package:get_storage/get_storage.dart';
import 'package: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/constants/app_routes.dart';
import 'package:sigap/src/utils/helpers/network_manager.dart'; import 'package:sigap/src/utils/helpers/network_manager.dart';
import 'package:sigap/src/utils/popups/loaders.dart'; import 'package:sigap/src/utils/popups/loaders.dart';

View File

@ -1,81 +1,120 @@
import 'package:flutter/widgets.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart'; import 'package:get_storage/get_storage.dart';
import 'package:logger/logger.dart'; import 'package:logger/logger.dart';
import 'package:sigap/src/cores/repositories/auth/auth_repositories.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/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/popups/loaders.dart';
import 'package:sigap/src/utils/validators/validation.dart';
class SignUpController extends GetxController { class SignUpController extends GetxController {
static SignUpController get instance => Get.find(); static SignUpController get instance => Get.find();
// Variable // Variable
final storage = GetStorage(); final storage = GetStorage();
final formKey = GlobalKey<FormState>();
final hidePassword = true.obs; // Privacy policy
final hideConfirmPassword = true.obs;
final privacyPolicy = false.obs; final privacyPolicy = false.obs;
final isOfficer = false.obs; // Add flag for officer registration
final email = TextEditingController(); // Controllers for form fields
final firstName = TextEditingController(); final emailController = TextEditingController();
final lastName = TextEditingController(); final passwordController = TextEditingController();
final username = TextEditingController(); final confirmPasswordController = TextEditingController();
final phoneNumber = TextEditingController(); // Observable error messages
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
final emailError = ''.obs; final emailError = ''.obs;
final firstNameError = ''.obs;
final lastNameError = ''.obs;
final usernameError = ''.obs;
final phoneNumberError = ''.obs;
final passwordError = ''.obs; final passwordError = ''.obs;
final confirmPasswordError = ''.obs; final confirmPasswordError = ''.obs;
// Observable states
final isPasswordVisible = false.obs; final isPasswordVisible = false.obs;
final isConfirmPasswordVisible = 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 isLoading = false.obs;
final userMetadata = Rx<UserMetadataModel?>(null);
final selectedRole = Rx<dynamic>(null);
GlobalKey<FormState> signupFormKey = GlobalKey<FormState>(); @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 // Sign Up Function
void signUp() async { void signUp() async {
Logger().i('SignUp process started'); Logger().i('SignUp process started');
try { 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 // Start loading
Logger().i('Opening loading dialog'); isLoading.value = true;
TFullScreenLoader.openLoadingDialog( Logger().i('Starting signup process');
'Processing your information....',
TImages.amongUsLoading,
);
// Check connection // Check connection
Logger().i('Checking network connection'); Logger().i('Checking network connection');
final isConnected = await NetworkManager.instance.isConnected(); final isConnected = await NetworkManager.instance.isConnected();
if (!isConnected) { if (!isConnected) {
Logger().w('No internet connection'); Logger().w('No internet connection');
TFullScreenLoader.stopLoading(); isLoading.value = false;
TLoaders.errorSnackBar( TLoaders.errorSnackBar(
title: 'No Internet Connection', title: 'No Internet Connection',
message: 'Please check your internet connection and try again.', message: 'Please check your internet connection and try again.',
@ -83,88 +122,54 @@ class SignUpController extends GetxController {
return; return;
} }
// Form validation // Make sure user metadata is available
Logger().i('Validating form'); if (userMetadata.value == null) {
if (!signupFormKey.currentState!.validate()) { Logger().w('User metadata is missing');
Logger().w('Form validation failed'); isLoading.value = false;
TFullScreenLoader.stopLoading(); TLoaders.errorSnackBar(
return; title: 'Missing Information',
} message: 'Please complete your profile information first.',
// 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.',
); );
return; return;
} }
// Prepare user metadata based on user type (officer or viewer) // Add email to user metadata
Map<String, dynamic> userMetadata = { final updatedMetadata = userMetadata.value!.copyWith(
'first_name': firstName.text.trim(), email: emailController.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(),
};
}
// Register user with Supabase Auth // Register user with Supabase Auth
Logger().i('Registering user with Supabase Auth'); Logger().i('Registering user with Supabase Auth');
final authResponse = await AuthenticationRepository.instance final authResponse = await AuthenticationRepository.instance
.signUpWithCredential( .signUpWithEmailPassword(
email.text.trim(), email: emailController.text.trim(),
password.text.trim(), password: passwordController.text.trim(),
userMetadata: userMetadata, userMetadata: updatedMetadata.toJson(),
); );
// Store email for verification screen // Store email for verification or next steps
storage.write('CURRENT_USER_EMAIL', email.text.trim()); storage.write('CURRENT_USER_EMAIL', emailController.text.trim());
// Remove loading // Remove loading
Logger().i('Stopping loading dialog'); Logger().i('Signup process completed');
TFullScreenLoader.stopLoading(); isLoading.value = false;
// Show success message // Show success message
Logger().i('Showing success message'); Logger().i('Showing success message');
TLoaders.successSnackBar( TLoaders.successSnackBar(
title: 'Congratulations', title: 'Account Created',
message: 'Your account has been created! Verify email to continue.', message: 'Please check your email to verify your account!',
); );
// Move to verification screen // Navigate to email verification
Logger().i('Navigating to VerifyEmailScreen'); Get.offNamed(
// Get.to(() => VerifyEmailScreen(email: email.text.trim())); AppRoutes.emailVerification,
arguments: {'email': authResponse.user?.email},
);
} catch (e) { } catch (e) {
// Remove loading // Handle error
Logger().e('Error occurred: $e'); Logger().e('Error occurred: $e');
TFullScreenLoader.stopLoading(); isLoading.value = false;
// Show error to the user // Show error to the user
TLoaders.errorSnackBar( 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 // Navigate to sign in screen
void goToSignIn() { void goToSignIn() {
Get.back(); Get.offNamed(AppRoutes.signIn);
} }
} }

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.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/features/daily-ops/models/units_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/features/personalization/models/index.dart';
import 'package:sigap/src/utils/constants/app_routes.dart'; import 'package:sigap/src/utils/constants/app_routes.dart';
import 'package:sigap/src/utils/popups/loaders.dart'; import 'package:sigap/src/utils/popups/loaders.dart';
@ -12,7 +12,6 @@ class StepFormController extends GetxController {
// Role information // Role information
final Rx<RoleModel?> selectedRole = Rx<RoleModel?>(null); final Rx<RoleModel?> selectedRole = Rx<RoleModel?>(null);
// Current step index // Current step index
final RxInt currentStep = 0.obs; final RxInt currentStep = 0.obs;
@ -21,14 +20,16 @@ class StepFormController extends GetxController {
late List<GlobalKey<FormState>> stepFormKeys; late List<GlobalKey<FormState>> stepFormKeys;
// Common information (for all roles) // Common information (for all roles)
final nameController = TextEditingController(); final firstNameController = TextEditingController();
final lastNameController = TextEditingController();
final nameController = TextEditingController(); // For combined name
final phoneController = TextEditingController(); final phoneController = TextEditingController();
final addressController = TextEditingController(); final addressController = TextEditingController();
// Viewer-specific fields // Viewer-specific fields
final nikController = TextEditingController(); final nikController = TextEditingController();
final emergencyPhoneController = TextEditingController(); final bioController = TextEditingController();
final relationshipController = TextEditingController(); final birthDateController = TextEditingController();
// Officer-specific fields // Officer-specific fields
final nrpController = TextEditingController(); final nrpController = TextEditingController();
@ -36,15 +37,20 @@ class StepFormController extends GetxController {
final positionController = TextEditingController(); final positionController = TextEditingController();
final unitIdController = TextEditingController(); final unitIdController = TextEditingController();
// User metadata model
final Rx<UserMetadataModel> userMetadata = UserMetadataModel().obs;
// Error states - Common // Error states - Common
final RxString firstNameError = ''.obs;
final RxString lastNameError = ''.obs;
final RxString nameError = ''.obs; final RxString nameError = ''.obs;
final RxString phoneError = ''.obs; final RxString phoneError = ''.obs;
final RxString addressError = ''.obs; final RxString addressError = ''.obs;
// Error states - Viewer // Error states - Viewer
final RxString nikError = ''.obs; final RxString nikError = ''.obs;
final RxString emergencyPhoneError = ''.obs; final RxString bioError = ''.obs;
final RxString relationshipError = ''.obs; final RxString birthDateError = ''.obs;
// Error states - Officer // Error states - Officer
final RxString nrpError = ''.obs; final RxString nrpError = ''.obs;
@ -61,10 +67,21 @@ class StepFormController extends GetxController {
@override @override
void onInit() { void onInit() {
super.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 // Get role from arguments
final arguments = Get.arguments; final arguments = Get.arguments;
if (arguments != null && arguments['role'] != null) { if (arguments != null && arguments['role'] != null) {
selectedRole.value = arguments['role'] as RoleModel; selectedRole.value = arguments['role'] as RoleModel;
// Initialize userMetadata with the selected role information
userMetadata.value = UserMetadataModel(
isOfficer: selectedRole.value?.isOfficer ?? false,
);
_initializeBasedOnRole(); _initializeBasedOnRole();
} else { } else {
Get.snackbar( 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() { void _initializeBasedOnRole() {
if (selectedRole.value?.isOfficer == true) { if (selectedRole.value?.isOfficer == true) {
stepFormKeys = List.generate(3, (_) => GlobalKey<FormState>()); stepFormKeys = List.generate(3, (_) => GlobalKey<FormState>());
@ -96,7 +129,6 @@ class StepFormController extends GetxController {
// Here we would fetch units from repository // Here we would fetch units from repository
// For now we'll use dummy data // For now we'll use dummy data
await Future.delayed(const Duration(seconds: 1)); await Future.delayed(const Duration(seconds: 1));
} catch (e) { } catch (e) {
TLoaders.errorSnackBar( TLoaders.errorSnackBar(
title: 'Error', title: 'Error',
@ -109,14 +141,20 @@ class StepFormController extends GetxController {
@override @override
void onClose() { void onClose() {
// Remove listeners
firstNameController.removeListener(_updateCombinedName);
lastNameController.removeListener(_updateCombinedName);
// Dispose all controllers // Dispose all controllers
firstNameController.dispose();
lastNameController.dispose();
nameController.dispose(); nameController.dispose();
phoneController.dispose(); phoneController.dispose();
addressController.dispose(); addressController.dispose();
nikController.dispose(); nikController.dispose();
emergencyPhoneController.dispose(); bioController.dispose();
relationshipController.dispose(); birthDateController.dispose();
nrpController.dispose(); nrpController.dispose();
rankController.dispose(); rankController.dispose();
@ -155,14 +193,16 @@ class StepFormController extends GetxController {
void clearErrors() { void clearErrors() {
// Clear common errors // Clear common errors
firstNameError.value = '';
lastNameError.value = '';
nameError.value = ''; nameError.value = '';
phoneError.value = ''; phoneError.value = '';
addressError.value = ''; addressError.value = '';
// Clear viewer-specific errors // Clear viewer-specific errors
nikError.value = ''; nikError.value = '';
emergencyPhoneError.value = ''; bioError.value = '';
relationshipError.value = ''; birthDateError.value = '';
// Clear officer-specific errors // Clear officer-specific errors
nrpError.value = ''; nrpError.value = '';
@ -174,13 +214,24 @@ class StepFormController extends GetxController {
bool validatePersonalInfo() { bool validatePersonalInfo() {
bool isValid = true; bool isValid = true;
final nameValidation = TValidators.validateUserInput( final firstNameValidation = TValidators.validateUserInput(
'Full name', 'First name',
nameController.text, firstNameController.text,
100, 50,
); );
if (nameValidation != null) { if (firstNameValidation != null) {
nameError.value = nameValidation; 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; isValid = false;
} }
@ -208,31 +259,36 @@ class StepFormController extends GetxController {
bool validateEmergencyContact() { bool validateEmergencyContact() {
bool isValid = true; bool isValid = true;
final nameValidation = TValidators.validateUserInput( final nikValidation = TValidators.validateUserInput(
'Emergency contact name', 'NIK',
nikController.text, nikController.text,
100, 16,
); );
if (nameValidation != null) { if (nikValidation != null) {
nikError.value = nameValidation; nikError.value = nikValidation;
isValid = false; isValid = false;
} }
final phoneValidation = TValidators.validatePhoneNumber( // Bio can be optional, so we validate with required: false
emergencyPhoneController.text, final bioValidation = TValidators.validateUserInput(
'Bio',
bioController.text,
255,
required: false,
); );
if (phoneValidation != null) { if (bioValidation != null) {
emergencyPhoneError.value = phoneValidation; bioError.value = bioValidation;
isValid = false; isValid = false;
} }
final relationshipValidation = TValidators.validateUserInput( // Birth date validation
'Relationship', final birthDateValidation = TValidators.validateUserInput(
relationshipController.text, 'Birth Date',
50, birthDateController.text,
10,
); );
if (relationshipValidation != null) { if (birthDateValidation != null) {
relationshipError.value = relationshipValidation; birthDateError.value = birthDateValidation;
isValid = false; isValid = false;
} }
@ -343,56 +399,55 @@ class StepFormController extends GetxController {
try { try {
isLoading.value = true; isLoading.value = true;
// Prepare data based on role // Prepare UserMetadataModel based on role
final Map<String, dynamic> userData = {
'name': nameController.text,
'phone': phoneController.text,
'address': addressController.text,
};
if (selectedRole.value?.isOfficer == true) { if (selectedRole.value?.isOfficer == true) {
// Officer role // Officer role - create OfficerModel with the data
final officerData = { final officerData = OfficerModel(
'nrp': nrpController.text, id: '', // Will be assigned by backend
'rank': rankController.text, unitId: unitIdController.text,
'position': positionController.text, roleId: selectedRole.value!.id,
'unit_id': unitIdController.text, 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 userMetadata.value = UserMetadataModel(
await SupabaseService.instance.updateUserMetadata({ isOfficer: true,
'is_officer': true, name: nameController.text, // Use the combined name
'officer_data': { phone: phoneController.text,
...officerData, officerData: officerData,
'name': userData['name'], additionalData: {'address': addressController.text},
'phone': userData['phone'], );
},
});
} else { } else {
// Viewer role // Regular user - create profile-related data
final emergencyContact = { userMetadata.value = UserMetadataModel(
'name': nikController.text, isOfficer: false,
'phone': emergencyPhoneController.text, nik: nikController.text,
'relationship': relationshipController.text, name: nameController.text, // Use the combined name
}; phone: phoneController.text,
profileData: ProfileModel(
// Update auth user with viewer role id: '', // Will be assigned by backend
await SupabaseService.instance.updateUserMetadata({ userId: '', // Will be assigned by backend
'is_officer': false, nik: nikController.text,
'emergency_contact': emergencyContact, firstName: firstNameController.text.trim(),
'address': userData['address'], 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( Get.toNamed(
AppRoutes.stateScreen, AppRoutes.signUp,
arguments: { arguments: {
'type': 'success', 'userMetadata': userMetadata.value,
'title': 'Profile Completed', 'role': selectedRole.value,
'message': 'Your profile information has been successfully saved.',
'buttonText': 'Continue',
'onButtonPressed': () => Get.offAllNamed(AppRoutes.explore),
}, },
); );
} catch (e) { } catch (e) {
@ -400,8 +455,9 @@ class StepFormController extends GetxController {
AppRoutes.stateScreen, AppRoutes.stateScreen,
arguments: { arguments: {
'type': 'error', 'type': 'error',
'title': 'Submission Failed', 'title': 'Data Preparation Failed',
'message': 'There was an error saving your profile: ${e.toString()}', 'message':
'There was an error preparing your profile: ${e.toString()}',
'buttonText': 'Try Again', 'buttonText': 'Try Again',
'onButtonPressed': () => Get.back(), 'onButtonPressed': () => Get.back(),
}, },
@ -410,4 +466,32 @@ class StepFormController extends GetxController {
isLoading.value = false; 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;
}
}
} }

View File

@ -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 { class UserMetadataModel {
final bool isOfficer; final bool isOfficer;
final String? nik; final String? nik;
final OfficerModel? officer; final String? email;
final String? phone;
final String? name;
final OfficerModel? officerData;
final ProfileModel? profileData;
final Map<String, dynamic>? additionalData; final Map<String, dynamic>? additionalData;
// Emergency contact data frequently used in the app
final Map<String, dynamic>? emergencyContact;
UserMetadataModel({ UserMetadataModel({
this.isOfficer = false, this.isOfficer = false,
this.nik, this.nik,
this.officer, this.email,
this.phone,
this.name,
this.officerData,
this.profileData,
this.emergencyContact,
this.additionalData, this.additionalData,
}); });
/// Create a UserMetadataModel from raw Map data (from Supabase Auth) /// Create a UserMetadataModel from raw Map data (from Supabase Auth)
factory UserMetadataModel.fromJson(Map<String, dynamic>? json) { factory UserMetadataModel.fromJson(Map<String, dynamic>? json) {
if (json == null) return UserMetadataModel(); 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<String, dynamic>.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<String, dynamic>.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( return UserMetadataModel(
isOfficer: json['is_officer'] == true, isOfficer: json['is_officer'] == true,
nik: json['nik'] as String?, nik: json['nik'] as String?,
officer: email: json['email'] as String?,
json['officer_data'] != null phone: json['phone'] as String?,
? OfficerModel.fromJson( name: json['name'] as String?,
json['officer_data'] as Map<String, dynamic>, officerData: officerData,
) profileData: profileData,
emergencyContact:
json['emergency_contact'] != null
? Map<String, dynamic>.from(json['emergency_contact'])
: null, : null,
additionalData: Map<String, dynamic>.from(json)..removeWhere( additionalData: Map<String, dynamic>.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<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final Map<String, dynamic> data = {'is_officer': isOfficer}; final Map<String, dynamic> data = {'is_officer': isOfficer};
if (nik != null) { if (nik != null) data['nik'] = nik;
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) { if (emergencyContact != null) {
data['officer_data'] = officer!.toJson(); data['emergency_contact'] = emergencyContact;
} }
if (additionalData != null) { if (additionalData != null) {
@ -50,77 +144,41 @@ class UserMetadataModel {
return data; return data;
} }
/// Create a copy with updated fields /// Create a copy with updated fields
UserMetadataModel copyWith({ UserMetadataModel copyWith({
bool? isOfficer, bool? isOfficer,
String? nik, String? nik,
OfficerModel? officer, String? email,
String? phone,
String? name,
OfficerModel? officerData,
ProfileModel? profileData,
Map<String, dynamic>? emergencyContact,
Map<String, dynamic>? additionalData, Map<String, dynamic>? additionalData,
}) { }) {
return UserMetadataModel( return UserMetadataModel(
isOfficer: isOfficer ?? this.isOfficer, isOfficer: isOfficer ?? this.isOfficer,
nik: nik ?? this.nik, 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, 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<String, dynamic> 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<String, dynamic> 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,
// );
// }
// }

View File

@ -15,6 +15,7 @@ class EmailVerificationScreen extends StatelessWidget {
// Get the controller // Get the controller
final controller = Get.find<EmailVerificationController>(); final controller = Get.find<EmailVerificationController>();
// Set system overlay style // Set system overlay style
SystemChrome.setSystemUIOverlayStyle( SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle( const SystemUiOverlayStyle(
@ -50,13 +51,15 @@ class EmailVerificationScreen extends StatelessWidget {
} }
Widget _buildVerificationForm(EmailVerificationController controller) { Widget _buildVerificationForm(EmailVerificationController controller) {
final isResendEnabled = controller.isResendEnabled.value;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
// Header // Header
const AuthHeader( const AuthHeader(
title: 'Email Verification', 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), const SizedBox(height: 32),
@ -65,7 +68,7 @@ class EmailVerificationScreen extends StatelessWidget {
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: List.generate( children: List.generate(
4, 6,
(index) => OtpInputField( (index) => OtpInputField(
controller: controller.otpControllers[index], controller: controller.otpControllers[index],
focusNode: controller.focusNodes[index], focusNode: controller.focusNodes[index],
@ -115,16 +118,16 @@ class EmailVerificationScreen extends StatelessWidget {
Obx( Obx(
() => TextButton( () => TextButton(
onPressed: onPressed:
controller.isResendEnabled.value isResendEnabled
? () => controller.resendCode ? () => controller.resendCode
: null, : null,
child: Text( child: Text(
controller.isResendEnabled.value isResendEnabled
? 'Resend' ? 'Resend'
: 'Resend in ${controller.resendCountdown.value}s', : 'Resend in ${controller.resendCountdown.value}s',
style: TextStyle( style: TextStyle(
color: color:
controller.isResendEnabled.value isResendEnabled
? TColors.primary ? TColors.primary
: TColors.textSecondary, : TColors.textSecondary,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,

View File

@ -3,10 +3,7 @@ import 'package:flutter/services.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:sigap/src/features/auth/controllers/signup_controller.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_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/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/shared/widgets/text/custom_text_field.dart';
import 'package:sigap/src/utils/constants/colors.dart'; import 'package:sigap/src/utils/constants/colors.dart';
@ -31,6 +28,14 @@ class SignUpScreen extends StatelessWidget {
appBar: AppBar( appBar: AppBar(
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
elevation: 0, elevation: 0,
title: Text(
'Create Account',
style: TextStyle(
color: TColors.textPrimary,
fontWeight: FontWeight.bold,
),
),
centerTitle: true,
leading: IconButton( leading: IconButton(
icon: Icon(Icons.arrow_back, color: TColors.textPrimary), icon: Icon(Icons.arrow_back, color: TColors.textPrimary),
onPressed: () => Get.back(), onPressed: () => Get.back(),
@ -45,22 +50,28 @@ class SignUpScreen extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Header // Header with profile summary
const AuthHeader( Obx(() => _buildProfileSummary(controller)),
title: 'Create Account',
subtitle: 'Sign up to get started with the app',
),
// Name field // Credentials section header
Obx( const SizedBox(height: 24),
() => CustomTextField( Text(
label: 'Full Name', 'Set Your Login Credentials',
controller: controller.nameController, style: TextStyle(
validator: controller.validateName, fontSize: 18,
errorText: controller.nameError.value, fontWeight: FontWeight.bold,
textInputAction: TextInputAction.next, 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 // Email field
Obx( 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), const SizedBox(height: 24),
// Sign up button // Sign up button
Obx( Obx(
() => AuthButton( () => AuthButton(
text: 'Sign Up', text: 'Create Account',
onPressed: controller.signUp, onPressed: controller.signUp,
isLoading: controller.isLoading.value, 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), const SizedBox(height: 16),
// Already have an account // 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,
),
),
],
),
),
],
),
],
),
);
}
} }

View File

@ -181,15 +181,32 @@ class StepFormScreen extends StatelessWidget {
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
// Name field // First Name field
Obx( Obx(
() => CustomTextField( () => CustomTextField(
label: 'Full Name', label: 'First Name',
controller: controller.nameController, controller: controller.firstNameController,
validator: validator:
(value) => (value) =>
TValidators.validateUserInput('Full name', value, 100), TValidators.validateUserInput('First name', value, 50),
errorText: controller.nameError.value, 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, textInputAction: TextInputAction.next,
), ),
), ),
@ -231,7 +248,7 @@ class StepFormScreen extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'Emergency Contact', 'Additional Information',
style: TextStyle( style: TextStyle(
fontSize: 20, fontSize: 20,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@ -240,49 +257,55 @@ class StepFormScreen extends StatelessWidget {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Please provide emergency contact details', 'Please provide additional personal details',
style: TextStyle(fontSize: 14, color: TColors.textSecondary), style: TextStyle(fontSize: 14, color: TColors.textSecondary),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
// Emergency contact name field // NIK field
Obx( Obx(
() => CustomTextField( () => CustomTextField(
label: 'NIK', label: 'NIK (Identity Number)',
controller: controller.nikController, controller: controller.nikController,
validator: validator:
(value) => TValidators.validateUserInput( (value) => TValidators.validateUserInput('NIK', value, 16),
'Emergency contact name',
value,
100,
),
errorText: controller.nikError.value, errorText: controller.nikError.value,
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
keyboardType: TextInputType.number,
), ),
), ),
// Emergency contact phone field // Bio field
Obx( Obx(
() => CustomTextField( () => CustomTextField(
label: 'Contact Phone', label: 'Bio',
controller: controller.emergencyPhoneController, controller: controller.bioController,
validator: TValidators.validatePhoneNumber, validator:
errorText: controller.emergencyPhoneError.value, (value) => TValidators.validateUserInput(
keyboardType: TextInputType.phone, 'Bio',
value,
255,
required: false,
),
errorText: controller.bioError.value,
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
maxLines: 3,
hintText: 'Tell us a little about yourself (optional)',
), ),
), ),
// Relationship field // Birth Date field
Obx( Obx(
() => CustomTextField( () => CustomTextField(
label: 'Relationship', label: 'Birth Date (YYYY-MM-DD)',
controller: controller.relationshipController, controller: controller.birthDateController,
validator: validator:
(value) => (value) =>
TValidators.validateUserInput('Relationship', value, 50), TValidators.validateUserInput('Birth date', value, 10),
errorText: controller.relationshipError.value, errorText: controller.birthDateError.value,
textInputAction: TextInputAction.done, textInputAction: TextInputAction.done,
keyboardType: TextInputType.datetime,
hintText: 'e.g., 1990-01-31',
), ),
), ),
], ],

View File

@ -1,3 +1,4 @@
export 'officers_model.dart';
export 'patrol_units_model.dart'; export 'patrol_units_model.dart';
export 'unit_statistics_model.dart'; export 'unit_statistics_model.dart';
export 'units_model.dart'; export 'units_model.dart';

View File

@ -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<String, dynamic> 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<String, dynamic>)
: null,
);
}
// Convert an OfficerModel instance to a JSON object
Map<String, dynamic> 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<String, dynamic> 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)';
}
}

View File

@ -1,6 +1,6 @@
import 'package:sigap/src/features/daily-ops/models/units_model.dart'; 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/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 { class PatrolUnitModel {
final String id; final String id;

View File

@ -1,6 +1,6 @@
import 'package:sigap/src/features/daily-ops/models/patrol_units_model.dart'; 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/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 } enum UnitType { polda, polsek, polres, other }

View File

@ -1,5 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.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/features/onboarding/datas/onboarding_data.dart';
import 'package:sigap/src/utils/constants/app_routes.dart'; import 'package:sigap/src/utils/constants/app_routes.dart';
@ -8,9 +10,16 @@ class OnboardingController extends GetxController
// Singleton instance // Singleton instance
static OnboardingController get instance => Get.find(); static OnboardingController get instance => Get.find();
// Storage for onboarding state
final _storage = GetStorage();
// Location service
final _locationService = Get.find<LocationService>();
// Observable variables // Observable variables
final RxInt currentIndex = 0.obs; final RxInt currentIndex = 0.obs;
final PageController pageController = PageController(initialPage: 0); final PageController pageController = PageController(initialPage: 0);
final RxBool isLocationChecking = false.obs;
// Animation controllers // Animation controllers
late AnimationController animationController; late AnimationController animationController;
@ -74,18 +83,36 @@ class OnboardingController extends GetxController
// Method to navigate to welcome screen // Method to navigate to welcome screen
void navigateToWelcomeScreen() { void navigateToWelcomeScreen() {
// Mark onboarding as completed in storage
_storage.write('ONBOARDING_COMPLETED', true);
Get.offAllNamed(AppRoutes.welcome); Get.offAllNamed(AppRoutes.welcome);
} }
void getStarted() { // Method to check location validity and proceed with auth flow
Get.offAllNamed(AppRoutes.chooseRole); Future<void> 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() { void goToSignIn() {
Get.offAllNamed(AppRoutes.signIn); Get.offAllNamed(AppRoutes.signIn);
} }
void goToSignUp() {
Get.offAllNamed(AppRoutes.signUp);
}
} }

View File

@ -1,11 +1,11 @@
import 'package:get/get.dart'; 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/features/personalization/models/roles_model.dart';
import 'package:sigap/src/utils/constants/app_routes.dart'; import 'package:sigap/src/utils/constants/app_routes.dart';
import 'package:sigap/src/utils/popups/loaders.dart'; import 'package:sigap/src/utils/popups/loaders.dart';
class ChooseRoleController extends GetxController { class RoleSelectionController extends GetxController {
static ChooseRoleController get instance => Get.find(); static RoleSelectionController get instance => Get.find();
// Repositories // Repositories
final _rolesRepository = Get.find<RolesRepository>(); final _rolesRepository = Get.find<RolesRepository>();
@ -78,10 +78,10 @@ class ChooseRoleController extends GetxController {
isOfficer.value = false; isOfficer.value = false;
} }
// Navigate to sign up screen with selected role // Navigate directly to step form with selected role
Get.offNamed( Get.toNamed(
AppRoutes.signUp, AppRoutes.formRegistration,
arguments: {'role': selectedRole.value, 'isOfficer': isOfficer.value}, arguments: {'role': selectedRole.value},
); );
} catch (e) { } catch (e) {
TLoaders.errorSnackBar( TLoaders.errorSnackBar(

View File

@ -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<LocationService>();
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,
),
),
),
],
),
),
),
);
}
}

View File

@ -3,17 +3,17 @@ import 'package:flutter/services.dart';
import 'package:get/get.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_button.dart';
import 'package:sigap/src/features/auth/screens/widgets/auth_header.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/controllers/role_selection_controller.dart';
import 'package:sigap/src/features/onboarding/screens/choose-role/widgets/role_card.dart'; import 'package:sigap/src/features/onboarding/screens/role-selection/widgets/role_card.dart';
import 'package:sigap/src/utils/constants/colors.dart'; import 'package:sigap/src/utils/constants/colors.dart';
class ChooseRoleScreen extends StatelessWidget { class RoleSelectionScreen extends StatelessWidget {
const ChooseRoleScreen({super.key}); const RoleSelectionScreen({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Get the controller // Get the controller
final controller = Get.find<ChooseRoleController>(); final controller = Get.find<RoleSelectionController>();
// Set system overlay style // Set system overlay style
SystemChrome.setSystemUIOverlayStyle( SystemChrome.setSystemUIOverlayStyle(

View File

@ -1,4 +1,3 @@
export 'officers_model.dart';
export 'permissions_model.dart'; export 'permissions_model.dart';
export 'profile_model.dart'; export 'profile_model.dart';
export 'resources_model.dart'; export 'resources_model.dart';

View File

@ -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<String, dynamic> 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<String, dynamic>)
: null,
);
}
// Convert an OfficerModel instance to a JSON object
Map<String, dynamic> 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<String, dynamic> 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)';
}
}

View File

@ -1,6 +1,7 @@
class ProfileModel { class ProfileModel {
final String id; final String id;
final String userId; final String userId;
final String nik;
final String? avatar; final String? avatar;
final String? username; final String? username;
final String? firstName; final String? firstName;
@ -12,6 +13,7 @@ class ProfileModel {
ProfileModel({ ProfileModel({
required this.id, required this.id,
required this.userId, required this.userId,
required this.nik,
this.avatar, this.avatar,
this.username, this.username,
this.firstName, this.firstName,
@ -26,6 +28,7 @@ class ProfileModel {
return ProfileModel( return ProfileModel(
id: json['id'] as String, id: json['id'] as String,
userId: json['user_id'] as String, userId: json['user_id'] as String,
nik: json['nik'] as String,
avatar: json['avatar'] as String?, avatar: json['avatar'] as String?,
username: json['username'] as String?, username: json['username'] as String?,
firstName: json['first_name'] as String?, firstName: json['first_name'] as String?,
@ -47,6 +50,7 @@ class ProfileModel {
return { return {
'id': id, 'id': id,
'user_id': userId, 'user_id': userId,
'nik': nik,
'avatar': avatar, 'avatar': avatar,
'username': username, 'username': username,
'first_name': firstName, 'first_name': firstName,
@ -61,6 +65,7 @@ class ProfileModel {
ProfileModel copyWith({ ProfileModel copyWith({
String? id, String? id,
String? userId, String? userId,
String? nik,
String? avatar, String? avatar,
String? username, String? username,
String? firstName, String? firstName,
@ -72,6 +77,7 @@ class ProfileModel {
return ProfileModel( return ProfileModel(
id: id ?? this.id, id: id ?? this.id,
userId: userId ?? this.userId, userId: userId ?? this.userId,
nik: nik ?? this.nik,
avatar: avatar ?? this.avatar, avatar: avatar ?? this.avatar,
username: username ?? this.username, username: username ?? this.username,
firstName: firstName ?? this.firstName, firstName: firstName ?? this.firstName,
@ -88,6 +94,16 @@ class ProfileModel {
return [firstName, lastName].where((x) => x != null).join(' '); 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 @override
String toString() { String toString() {
return 'ProfileModel(id: $id, username: $username, fullName: $fullName)'; return 'ProfileModel(id: $id, username: $username, fullName: $fullName)';

View File

@ -11,6 +11,7 @@ class CustomTextField extends StatelessWidget {
final String? errorText; final String? errorText;
final bool autofocus; final bool autofocus;
final int maxLines; final int maxLines;
final String? hintText;
final TextInputAction textInputAction; final TextInputAction textInputAction;
final Function(String)? onChanged; final Function(String)? onChanged;
@ -25,6 +26,7 @@ class CustomTextField extends StatelessWidget {
this.errorText, this.errorText,
this.autofocus = false, this.autofocus = false,
this.maxLines = 1, this.maxLines = 1,
this.hintText,
this.textInputAction = TextInputAction.next, this.textInputAction = TextInputAction.next,
this.onChanged, this.onChanged,
}); });
@ -54,6 +56,8 @@ class CustomTextField extends StatelessWidget {
onChanged: onChanged, onChanged: onChanged,
style: TextStyle(color: TColors.textPrimary, fontSize: 16), style: TextStyle(color: TColors.textPrimary, fontSize: 16),
decoration: InputDecoration( decoration: InputDecoration(
hintText: hintText,
hintStyle: TextStyle(color: TColors.textSecondary, fontSize: 16),
errorText: errorText:
errorText != null && errorText!.isNotEmpty ? errorText : null, errorText != null && errorText!.isNotEmpty ? errorText : null,
contentPadding: const EdgeInsets.symmetric( contentPadding: const EdgeInsets.symmetric(

View File

@ -3,6 +3,7 @@ class AppRoutes {
static const String welcome = '/welcome'; static const String welcome = '/welcome';
static const String signIn = '/sign-in'; static const String signIn = '/sign-in';
static const String signUp = '/sign-up'; static const String signUp = '/sign-up';
static const String emailVerification = '/email-verification';
static const String forgotPassword = '/forgot-password'; static const String forgotPassword = '/forgot-password';
static const String explore = '/explore'; static const String explore = '/explore';
static const String map = '/map'; static const String map = '/map';
@ -11,6 +12,8 @@ class AppRoutes {
static const String dailyOps = '/daily-ops'; static const String dailyOps = '/daily-ops';
static const String settings = '/settings'; static const String settings = '/settings';
static const String profile = '/profile'; 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 stateScreen = '/state-screen';
static const String locationWarning = '/location-warning';
static const String formRegistration = '/form-registration';
} }