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:
parent
8da86d10d2
commit
803a28494d
|
@ -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.
|
||||||
|
|
|
@ -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
|
||||||
|
└─────────────┘
|
|
@ -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>
|
|
@ -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();
|
||||||
|
|
|
@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
|
@ -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.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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';
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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()}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()}';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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()}';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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)';
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
|
|
@ -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 }
|
||||||
|
|
||||||
|
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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(
|
|
@ -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';
|
||||||
|
|
|
@ -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)';
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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)';
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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';
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue