From ffed8b8ede930976afb52e8dd47efad469260a92 Mon Sep 17 00:00:00 2001 From: vergiLgood1 Date: Fri, 16 May 2025 18:41:16 +0700 Subject: [PATCH] feat(panic-button): add models for events, evidences, incident logs, and sessions - Implement EventModel with JSON serialization and deserialization. - Create EvidenceModel with associated JSON handling. - Develop IncidentLogModel to manage incident logs and their evidences. - Introduce SessionsModel to track user sessions related to events. - Update index file to export new models. feat(personalization): introduce permissions and resources models - Add PermissionModel to manage user permissions with JSON support. - Create ResourceModel to define resources and their associated permissions. - Ensure proper serialization and deserialization for both models. chore(database): update Prisma schema for new models and relationships - Add new models: events, evidences, incident_logs, sessions, permissions, and resources. - Define relationships between users, roles, and permissions. - Update existing models to maintain referential integrity. - Introduce enums for session status, contact message status, crime rates, and crime status. --- sigap-mobile/auth_and_panic_button_flow.md | 157 ++++++ sigap-mobile/lib/main.dart | 11 +- .../repositories/auth/auth_repositories.dart | 154 +++++- .../panic/panic_button_repository.dart | 446 +++++++++++++++ .../unit/patrol_unit_repository.dart | 109 ++++ .../repositories/unit/unit_repository.dart | 99 ++++ .../unit/unit_statistics_repository.dart | 93 ++++ .../src/cores/services/biometric_service.dart | 138 +++++ .../src/cores/services/location_service.dart | 152 ++++++ .../controllers/step_form_controller.dart | 35 +- .../email_verification_screen.dart | 2 +- .../screens/step-form/step_form_screen.dart | 12 +- .../src/features/daily-ops/models/index.dart | 3 + .../daily-ops/models/patrol_units_model.dart | 112 ++++ .../models/unit_statistics_model.dart | 93 ++++ .../daily-ops/models/units_model.dart | 173 ++++++ .../features/explore/models/units_model.dart | 0 .../src/features/map/models/cities_model.dart | 82 +++ .../map/models/demographics_model.dart | 95 ++++ .../features/map/models/districts_model.dart | 132 +++++ .../map/models/geographics_model.dart | 113 ++++ .../lib/src/features/map/models/index.dart | 6 + .../map/models/location_logs_model.dart | 87 +++ .../features/map/models/locations_model.dart | 150 +++++ .../controllers/choose_role_controller.dart | 2 +- .../choose-role/widgets/role_card.dart | 2 +- .../models/crime_incidents_model.dart | 116 ++++ .../panic-button/models/crimes_model.dart | 156 ++++++ .../panic-button/models/events_model.dart | 77 +++ .../panic-button/models/evidences_model.dart | 92 ++++ .../models/incident_logs_model.dart | 107 ++++ .../features/panic-button/models/index.dart | 6 + .../panic-button/models/sessions_model.dart | 71 +++ .../personalization/models/index.dart | 4 +- .../models/permissions_model.dart | 84 +++ .../models/resources_model.dart | 97 ++++ .../widgets/text/custom_text_field.dart | 3 + .../lib/src/utils/validators/validation.dart | 2 +- .../flutter/generated_plugin_registrant.cc | 4 + .../linux/flutter/generated_plugins.cmake | 1 + .../Flutter/GeneratedPluginRegistrant.swift | 4 + sigap-mobile/pubspec.lock | 120 ++++ sigap-mobile/pubspec.yaml | 5 +- sigap-mobile/schema.prisma | 511 ++++++++++++++++++ .../flutter/generated_plugin_registrant.cc | 6 + .../windows/flutter/generated_plugins.cmake | 2 + sigap-website/prisma/backups/function.sql | 10 +- 47 files changed, 3893 insertions(+), 43 deletions(-) create mode 100644 sigap-mobile/auth_and_panic_button_flow.md create mode 100644 sigap-mobile/lib/src/cores/repositories/panic/panic_button_repository.dart create mode 100644 sigap-mobile/lib/src/cores/repositories/unit/patrol_unit_repository.dart create mode 100644 sigap-mobile/lib/src/cores/repositories/unit/unit_repository.dart create mode 100644 sigap-mobile/lib/src/cores/repositories/unit/unit_statistics_repository.dart create mode 100644 sigap-mobile/lib/src/cores/services/biometric_service.dart create mode 100644 sigap-mobile/lib/src/cores/services/location_service.dart create mode 100644 sigap-mobile/lib/src/features/daily-ops/models/index.dart create mode 100644 sigap-mobile/lib/src/features/daily-ops/models/patrol_units_model.dart create mode 100644 sigap-mobile/lib/src/features/daily-ops/models/unit_statistics_model.dart create mode 100644 sigap-mobile/lib/src/features/daily-ops/models/units_model.dart delete mode 100644 sigap-mobile/lib/src/features/explore/models/units_model.dart create mode 100644 sigap-mobile/lib/src/features/map/models/cities_model.dart create mode 100644 sigap-mobile/lib/src/features/map/models/demographics_model.dart create mode 100644 sigap-mobile/lib/src/features/map/models/districts_model.dart create mode 100644 sigap-mobile/lib/src/features/map/models/geographics_model.dart create mode 100644 sigap-mobile/lib/src/features/map/models/index.dart create mode 100644 sigap-mobile/lib/src/features/map/models/location_logs_model.dart create mode 100644 sigap-mobile/lib/src/features/map/models/locations_model.dart create mode 100644 sigap-mobile/lib/src/features/panic-button/models/crime_incidents_model.dart create mode 100644 sigap-mobile/lib/src/features/panic-button/models/crimes_model.dart create mode 100644 sigap-mobile/lib/src/features/panic-button/models/events_model.dart create mode 100644 sigap-mobile/lib/src/features/panic-button/models/evidences_model.dart create mode 100644 sigap-mobile/lib/src/features/panic-button/models/incident_logs_model.dart create mode 100644 sigap-mobile/lib/src/features/panic-button/models/index.dart create mode 100644 sigap-mobile/lib/src/features/panic-button/models/sessions_model.dart create mode 100644 sigap-mobile/lib/src/features/personalization/models/permissions_model.dart create mode 100644 sigap-mobile/lib/src/features/personalization/models/resources_model.dart create mode 100644 sigap-mobile/schema.prisma diff --git a/sigap-mobile/auth_and_panic_button_flow.md b/sigap-mobile/auth_and_panic_button_flow.md new file mode 100644 index 0000000..469be6a --- /dev/null +++ b/sigap-mobile/auth_and_panic_button_flow.md @@ -0,0 +1,157 @@ +# 🔐 Auth Verification Flow – GIS Crime App + +Dokumentasi ini merinci alur autentikasi aplikasi termasuk biometric login, verifikasi identitas (NIK/KTP untuk user & NRP/KTA untuk officer), serta sistem anti-spam panic button dan auto-ban. + +--- + +## 1. Biometric Login (Fingerprint / Face ID) + +### 📍 Tujuan: + +Mempercepat login pengguna dengan otentikasi biometrik. + +### 🔄 Alur: + +1. App diluncurkan. +2. Cek token atau NIK/NRP di `secure_storage`. +3. Jika tersedia → munculkan biometric prompt. +4. Jika autentikasi sukses → login otomatis ke homepage. +5. Jika gagal → redirect ke halaman login manual. + +### 🔧 Teknologi: + +- `local_auth` (Flutter) +- `flutter_secure_storage` + +--- + +## 2. Registrasi dengan Verifikasi NIK (User) atau NRP (Officer) + +### 📍 Tujuan: + +Mencegah spam akun dan memastikan pengguna adalah individu sah. + +### 🔄 Alur: + +1. User mengisi form registrasi: + - Email, password + - NIK (user) atau NRP (officer) + - Upload foto KTP/KTA (opsional) + - Validasi format NIK/NRP + - Cek apakah NIK/NRP sudah ada di database. + - Jika `is_banned = true` → tolak registrasi. + - Jika valid → simpan user baru. + +### 🔧 Teknologi: + +- Supabase/PostgreSQL (unique index + ban flag) +- Flutter form + validasi lokal + +--- + +## 3. Verifikasi KTP/KTA (Opsional) + +### 📍 Tujuan: + +Validasi lanjutan jika diperlukan (manual atau otomatis). + +### 🅱️ Otomatis (OCR): + +- Gunakan `google_mlkit_text_recognition` dan sejenisnya +- Ekstrak teks dari foto KTP atau KTA +- Cocokkan dengan data yang dimasukkan + +--- + +## 4. Anti-Spam Panic Button + Auto Ban + +### 📍 Tujuan: + +Menghindari penyalahgunaan fitur panic alert. + +### 🔄 Alur: + +1. User menekan panic button. +2. Backend: + + - Hitung jumlah panic alert dari user dalam 5 menit terakhir. + - Jika > 3 → tambahkan `panic_strike`. + - Jika `panic_strike >= 3` → set `is_banned = true`. + +3. Saat user login atau mendaftar: + - Cek `is_banned` berdasarkan NIK/NRP. + - Jika ya → tolak akses. + +# 📍 Location & Anti-Fake GPS Flow – GIS Crime App + +Dokumentasi ini merinci bagaimana sistem memverifikasi lokasi pengguna dan melindungi fitur penting seperti registrasi dan panic button dari penyalahgunaan melalui fake GPS (location spoofing). + +--- + +## teknologi +geolocator +geocoding + +## 🚦 1. REGISTRASI FLOW (NEW USER) + +### 🔄 Alur: +1. 📲 User membuka halaman registrasi +2. 📍 Aplikasi meminta akses lokasi +3. ✅ Jika lokasi berhasil didapat: + - Periksa apakah `position.isMocked == false` + - Konversi koordinat → nama kota + - Jika kota **"Jember"**: + - Lanjutkan proses registrasi + - Jika **bukan Jember**: + - Tampilkan alert: *"Registrasi hanya tersedia di wilayah Jember."* + - Blokir pendaftaran +4. ❌ Jika `position.isMocked == true`: + - Blokir pendaftaran + - Tambahkan flag `spoofing_attempt = true` (opsional) + +--- + +## 🚨 2. PANIC BUTTON FLOW + +### 🔄 Alur: +1. 📲 User membuka halaman Panic Button +2. 📍 Aplikasi: + - Ambil posisi GPS terkini + - Periksa: + - `isMocked == false` + - Lokasi berada di wilayah Jember +3. ✅ Jika valid: + - Panic button aktif +4. ❌ Jika: + - Lokasi palsu (`isMocked == true`) + - Atau lokasi di luar Jember + - → Panic button **dinonaktifkan** + - Tampilkan alert: *"Fitur Panic tidak dapat digunakan di luar Jember."* +5. ⚠️ Jika user mencoba spam dari lokasi palsu: + - Tambahkan `spoofing_strike += 1` + - Jika `spoofing_strike >= 2` → `is_banned = true` + +--- +### 🔧 Database Fields (liat schema.prisma untul lebih detail): + +```sql +users: + ..... + - is_banned BOOLEAN + - ban_reason TEXT + - panic_strike INT + - profile + ...... + - nik + +officer: + - nrp TEXT UNIQUE + - is_banned BOOLEAN + - ban_reason TEXT + - panic_strike INT + +panic_logs: + - user_id UUID + - timestamp TIMESTAMP + - location POINT +``` diff --git a/sigap-mobile/lib/main.dart b/sigap-mobile/lib/main.dart index fd37d7e..b6fab31 100644 --- a/sigap-mobile/lib/main.dart +++ b/sigap-mobile/lib/main.dart @@ -4,6 +4,9 @@ import 'package:flutter_native_splash/flutter_native_splash.dart'; import 'package:get/get.dart'; import 'package:get_storage/get_storage.dart'; import 'package:sigap/app.dart'; +import 'package:sigap/src/cores/repositories/panic/panic_button_repository.dart'; +import 'package:sigap/src/cores/services/biometric_service.dart'; +import 'package:sigap/src/cores/services/location_service.dart'; import 'package:sigap/src/cores/services/supabase_service.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; @@ -32,8 +35,14 @@ Future main() async { storageOptions: const StorageClientOptions(retryAttempts: 10), ); - // Add this to your dependencies initialization: + // Initialize services await Get.putAsync(() => SupabaseService().init()); + await Get.putAsync(() => BiometricService().init()); + await Get.putAsync(() => LocationService().init()); + + // Initialize repositories + Get.put(PanicButtonRepository()); + await Get.find().init(); runApp(const App()); } diff --git a/sigap-mobile/lib/src/cores/repositories/auth/auth_repositories.dart b/sigap-mobile/lib/src/cores/repositories/auth/auth_repositories.dart index 06f3f57..161c06b 100644 --- a/sigap-mobile/lib/src/cores/repositories/auth/auth_repositories.dart +++ b/sigap-mobile/lib/src/cores/repositories/auth/auth_repositories.dart @@ -2,6 +2,8 @@ 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'; @@ -16,17 +18,37 @@ class AuthenticationRepository extends GetxController { // 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; - // get isSessionExpired => authUser?.isExpired; @override void onReady() { FlutterNativeSplash.remove(); screenRedirect(); - // storage.remove('TEMP_ROLE'); + } + + // Check for biometric login on app start + Future attemptBiometricLogin() async { + if (!await _biometricService.isBiometricLoginEnabled()) { + return false; + } + + String? sessionString = await _biometricService.attemptBiometricLogin(); + if (sessionString == null) { + return false; + } + + try { + await _supabase.auth.recoverSession(sessionString); + Get.offAllNamed('/home'); + return true; + } catch (e) { + return false; + } } screenRedirect() async { @@ -38,6 +60,12 @@ class AuthenticationRepository extends GetxController { 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()); + } } } @@ -47,10 +75,29 @@ class AuthenticationRepository extends GetxController { 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); @@ -173,10 +220,7 @@ class AuthenticationRepository extends GetxController { ) async { try { // Reauthenticate user - await _supabase.auth.signInWithPassword( - email: email, - password: currentPassword, - ); + await _supabase.auth.reauthenticate(); // Update password await _supabase.auth.updateUser(UserAttributes(password: newPassword)); @@ -314,15 +358,67 @@ class AuthenticationRepository extends GetxController { } } - // [Email AUTH] - SIGN UP with role selection + // [Email AUTH] - SIGN UP with role selection and location verification Future signUpWithCredential( String email, - String password, { + String password, + String identifier, // NIK for users or NRP for officers + { Map? userMetadata, bool isOfficer = false, Map? officerData, }) async { try { + // Validate location for registration + bool isLocationValid = await _locationService.isLocationValidForFeature(); + if (!isLocationValid) { + throw TExceptions('Registration is only available within Jember area. Please ensure your location services are enabled and you are not using a mock location app.'); + } + + // Check if identifier (NIK/NRP) is already registered + final identifierField = isOfficer ? 'nrp' : 'nik'; + final identifierType = isOfficer ? 'NRP' : 'NIK'; + + if (isOfficer) { + final existingOfficer = await _supabase + .from('officers') + .select('id, is_banned') + .eq('nrp', identifier) + .maybeSingle(); + + if (existingOfficer != null) { + bool isBanned = existingOfficer['is_banned'] as bool? ?? false; + if (isBanned) { + throw TExceptions('This $identifierType is associated with a banned account.'); + } else { + throw TExceptions('This $identifierType is already registered.'); + } + } + } else { + // For regular users, check NIK in profiles + final existingUser = await _supabase + .from('profiles') + .select('id, user_id') + .eq('nik', identifier) + .maybeSingle(); + + if (existingUser != null) { + // Check if user is banned + final userId = existingUser['user_id']; + final userBanCheck = await _supabase + .from('users') + .select('is_banned') + .eq('id', userId) + .maybeSingle(); + + if (userBanCheck != null && (userBanCheck['is_banned'] as bool? ?? false)) { + throw TExceptions('This $identifierType is associated with a banned account.'); + } else { + throw TExceptions('This $identifierType is already registered.'); + } + } + } + // Prepare the complete user metadata final metadata = userMetadata ?? {}; @@ -332,6 +428,16 @@ class AuthenticationRepository extends GetxController { 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, @@ -345,6 +451,14 @@ class AuthenticationRepository extends GetxController { 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); @@ -355,10 +469,32 @@ class AuthenticationRepository extends GetxController { } on PostgrestException catch (error) { throw TExceptions.fromCode(error.code ?? 'unknown_error'); } catch (e) { + if (e is TExceptions) { + rethrow; + } throw TExceptions('Something went wrong. Please try again later.'); } } + // Enable or disable biometric login + Future toggleBiometricLogin(bool enable) async { + if (enable) { + await _biometricService.enableBiometricLogin(); + } else { + await _biometricService.disableBiometricLogin(); + } + } + + // Check if biometric login is enabled + Future isBiometricLoginEnabled() async { + return await _biometricService.isBiometricLoginEnabled(); + } + + // Check if biometrics are available on the device + Future isBiometricAvailable() async { + return _biometricService.isBiometricAvailable.value; + } + // ----------------- Logout ----------------- // [Sign Out] - SIGN OUT Future signOut() async { @@ -382,7 +518,7 @@ class AuthenticationRepository extends GetxController { Future deleteAccount() async { try { final userId = _supabase.auth.currentUser!.id; - await _supabase.rpc('delete_user', params: {'user_id': userId}); + await _supabase.rpc('delete_account', params: {'user_id': userId}); } on AuthException catch (e) { throw TExceptions(e.message); } on FormatException catch (_) { diff --git a/sigap-mobile/lib/src/cores/repositories/panic/panic_button_repository.dart b/sigap-mobile/lib/src/cores/repositories/panic/panic_button_repository.dart new file mode 100644 index 0000000..7b699ec --- /dev/null +++ b/sigap-mobile/lib/src/cores/repositories/panic/panic_button_repository.dart @@ -0,0 +1,446 @@ +import 'package:get/get.dart'; +import 'package:sigap/src/cores/services/location_service.dart'; +import 'package:sigap/src/cores/services/supabase_service.dart'; +import 'package:sigap/src/utils/exceptions/exceptions.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +class PanicButtonRepository extends GetxController { + static PanicButtonRepository get instance => Get.find(); + + final _supabase = SupabaseService.instance.client; + final _locationService = LocationService.instance; + + // Rate limiting + final RxInt recentPanicCount = 0.obs; + final RxBool isRateLimited = false.obs; + final RxInt panicStrikeCount = 0.obs; + + // Constants + static const int maxPanicAlertsIn5Minutes = 3; + static const int strikesTillBan = 3; + + // Initialize + Future init() async { + await _loadPanicStrikeCount(); + _startRateLimitCooldown(); + } + + // Send a panic alert + Future sendPanicAlert({ + required String description, + required String categoryId, + String? mediaUrl, + }) async { + try { + final userId = SupabaseService.instance.currentUserId; + if (userId == null) { + throw TExceptions('User not logged in.'); + } + + // Check if user is banned + bool isBanned = await _checkIfUserIsBanned(); + if (isBanned) { + throw TExceptions('Your account has been temporarily banned from using the panic button due to misuse.'); + } + + // Check location validity + if (!await _locationService.isLocationValidForFeature()) { + _incrementSpoofingAttempts(); + throw TExceptions('Location validation failed. Please ensure you are in Jember and not using a mock location app.'); + } + + // Check rate limit + if (isRateLimited.value || recentPanicCount.value >= maxPanicAlertsIn5Minutes) { + _incrementPanicStrike(); + throw TExceptions('You have reached the maximum number of panic alerts. Please try again later.'); + } + + // Get current location + final position = await _locationService.getCurrentPosition(); + if (position == null) { + throw TExceptions('Failed to get current location.'); + } + + // Create a new incident log + final incidentId = await _createIncidentLog( + userId: userId, + latitude: position.latitude, + longitude: position.longitude, + description: description, + categoryId: categoryId, + ); + + // Create panic button log + await _createPanicButtonLog( + userId: userId, + incidentId: incidentId, + ); + + // Add media if provided + if (mediaUrl != null && mediaUrl.isNotEmpty) { + await _addEvidenceToIncident(incidentId, mediaUrl); + } + + // Increment panic count for rate limiting + recentPanicCount.value++; + _checkRateLimit(); + + return true; + } on TExceptions { + rethrow; + } catch (e) { + throw TExceptions('Failed to send panic alert: ${e.toString()}'); + } + } + + // Create an incident log + Future _createIncidentLog({ + required String userId, + required double latitude, + required double longitude, + required String description, + required String categoryId, + }) async { + try { + // First create a location + final locationResponse = await _supabase.rpc('create_location', params: { + 'lat': latitude, + 'long': longitude, + 'district_id': null, // Will be filled by database function + 'event_id': null, // Will be filled by database function + }); + + final locationId = locationResponse as String; + + // Then create incident log + final incidentResponse = await _supabase.from('incident_logs').insert({ + 'user_id': userId, + 'location_id': locationId, + 'category_id': categoryId, + 'description': description, + 'source': 'panic_button', + 'time': DateTime.now().toIso8601String(), + 'verified': false, + }).select('id').single(); + + return incidentResponse['id'] as String; + } catch (e) { + throw TExceptions('Failed to create incident log: ${e.toString()}'); + } + } + + // Create panic button log + Future _createPanicButtonLog({ + required String userId, + required String incidentId, + }) async { + try { + // Get officer ID if the user is an officer + String? officerId; + final user = SupabaseService.instance.currentUser; + if (user != null && user.userMetadata?['is_officer'] == true) { + final officerQuery = await _supabase + .from('officers') + .select('id') + .eq('nrp', user.userMetadata?['officer_data']?['nrp']) + .maybeSingle(); + + if (officerQuery != null) { + officerId = officerQuery['id'] as String; + } + } + + await _supabase.from('panic_button_logs').insert({ + 'user_id': userId, + 'incident_id': incidentId, + 'officer_id': officerId, + 'timestamp': DateTime.now().toIso8601String(), + }); + } catch (e) { + throw TExceptions('Failed to create panic button log: ${e.toString()}'); + } + } + + // Add evidence to incident + Future _addEvidenceToIncident(String incidentId, String mediaUrl) async { + try { + await _supabase.from('evidence').insert({ + 'id': 'EV-${DateTime.now().millisecondsSinceEpoch}', + 'incident_id': incidentId, + 'type': 'photo', + 'url': mediaUrl, + 'uploaded_at': DateTime.now().toIso8601String(), + }); + } catch (e) { + // Non-critical error, just log it + print('Failed to add evidence: $e'); + } + } + + // Check rate limit + void _checkRateLimit() { + if (recentPanicCount.value >= maxPanicAlertsIn5Minutes) { + isRateLimited.value = true; + _incrementPanicStrike(); + } + } + + // Start cooldown for rate limiting + void _startRateLimitCooldown() { + // Reset panic count every 5 minutes + Future.delayed(const Duration(minutes: 5), () { + recentPanicCount.value = 0; + isRateLimited.value = false; + _startRateLimitCooldown(); + }); + } + + // Increment panic strike + Future _incrementPanicStrike() async { + try { + final userId = SupabaseService.instance.currentUserId; + if (userId == null) return; + + // Check if user is officer + final user = SupabaseService.instance.currentUser; + bool isOfficer = user?.userMetadata?['is_officer'] == true; + + // Increment strike in the appropriate table + if (isOfficer) { + final officerQuery = await _supabase + .from('officers') + .select('id, nrp, panic_strike') + .eq('nrp', user?.userMetadata?['officer_data']?['nrp']) + .single(); + + int currentStrikes = officerQuery['panic_strike'] as int; + panicStrikeCount.value = currentStrikes + 1; + + await _supabase + .from('officers') + .update({ + 'panic_strike': panicStrikeCount.value, + 'is_banned': panicStrikeCount.value >= strikesTillBan, + 'banned_reason': panicStrikeCount.value >= strikesTillBan + ? 'Exceeded panic button usage limit' + : null, + 'banned_until': panicStrikeCount.value >= strikesTillBan + ? DateTime.now().add(const Duration(days: 3)).toIso8601String() + : null, + }) + .eq('id', officerQuery['id']); + } else { + // Regular user + final userQuery = await _supabase + .from('users') + .select('panic_strike') + .eq('id', userId) + .single(); + + int currentStrikes = userQuery['panic_strike'] as int; + panicStrikeCount.value = currentStrikes + 1; + + await _supabase + .from('users') + .update({ + 'panic_strike': panicStrikeCount.value, + 'is_banned': panicStrikeCount.value >= strikesTillBan, + 'banned_reason': panicStrikeCount.value >= strikesTillBan + ? 'Exceeded panic button usage limit' + : null, + 'banned_until': panicStrikeCount.value >= strikesTillBan + ? DateTime.now().add(const Duration(days: 3)).toIso8601String() + : null, + }) + .eq('id', userId); + } + } catch (e) { + print('Failed to update panic strike: $e'); + } + } + + // Increment spoofing attempts + Future _incrementSpoofingAttempts() async { + try { + final userId = SupabaseService.instance.currentUserId; + if (userId == null) return; + + // Check if user is officer + final user = SupabaseService.instance.currentUser; + bool isOfficer = user?.userMetadata?['is_officer'] == true; + + // Increment spoofing attempts in the appropriate table + if (isOfficer) { + final officerQuery = await _supabase + .from('officers') + .select('id, nrp, spoofing_attempts') + .eq('nrp', user?.userMetadata?['officer_data']?['nrp']) + .single(); + + int currentAttempts = officerQuery['spoofing_attempts'] as int; + int newAttempts = currentAttempts + 1; + + await _supabase + .from('officers') + .update({ + 'spoofing_attempts': newAttempts, + 'is_banned': newAttempts >= 2, + 'banned_reason': newAttempts >= 2 + ? 'Suspected location spoofing' + : null, + 'banned_until': newAttempts >= 2 + ? DateTime.now().add(const Duration(days: 7)).toIso8601String() + : null, + }) + .eq('id', officerQuery['id']); + } else { + // Regular user + final userQuery = await _supabase + .from('users') + .select('spoofing_attempts') + .eq('id', userId) + .single(); + + int currentAttempts = userQuery['spoofing_attempts'] as int; + int newAttempts = currentAttempts + 1; + + await _supabase + .from('users') + .update({ + 'spoofing_attempts': newAttempts, + 'is_banned': newAttempts >= 2, + 'banned_reason': newAttempts >= 2 + ? 'Suspected location spoofing' + : null, + 'banned_until': newAttempts >= 2 + ? DateTime.now().add(const Duration(days: 7)).toIso8601String() + : null, + }) + .eq('id', userId); + } + } catch (e) { + print('Failed to update spoofing attempts: $e'); + } + } + + // Check if user is banned + Future _checkIfUserIsBanned() async { + try { + final userId = SupabaseService.instance.currentUserId; + if (userId == null) return false; + + // Check if user is officer + final user = SupabaseService.instance.currentUser; + bool isOfficer = user?.userMetadata?['is_officer'] == true; + + if (isOfficer) { + final officerQuery = await _supabase + .from('officers') + .select('is_banned, banned_until') + .eq('nrp', user?.userMetadata?['officer_data']?['nrp']) + .maybeSingle(); + + if (officerQuery == null) return false; + + bool isBanned = officerQuery['is_banned'] as bool? ?? false; + String? bannedUntil = officerQuery['banned_until'] as String?; + + // Check if ban has expired + if (isBanned && bannedUntil != null) { + DateTime banEnd = DateTime.parse(bannedUntil); + if (DateTime.now().isAfter(banEnd)) { + // Ban has expired, remove it + await _supabase + .from('officers') + .update({ + 'is_banned': false, + 'banned_until': null, + 'banned_reason': null, + }) + .eq('nrp', user?.userMetadata?['officer_data']?['nrp']); + return false; + } + } + + return isBanned; + } else { + // Regular user + final userQuery = await _supabase + .from('users') + .select('is_banned, banned_until') + .eq('id', userId) + .single(); + + bool isBanned = userQuery['is_banned'] as bool? ?? false; + String? bannedUntil = userQuery['banned_until'] as String?; + + // Check if ban has expired + if (isBanned && bannedUntil != null) { + DateTime banEnd = DateTime.parse(bannedUntil); + if (DateTime.now().isAfter(banEnd)) { + // Ban has expired, remove it + await _supabase + .from('users') + .update({ + 'is_banned': false, + 'banned_until': null, + 'banned_reason': null, + }) + .eq('id', userId); + return false; + } + } + + return isBanned; + } + } catch (e) { + print('Failed to check ban status: $e'); + return false; + } + } + + // Load panic strike count + Future _loadPanicStrikeCount() async { + try { + final userId = SupabaseService.instance.currentUserId; + if (userId == null) { + panicStrikeCount.value = 0; + return; + } + + // Check if user is officer + final user = SupabaseService.instance.currentUser; + bool isOfficer = user?.userMetadata?['is_officer'] == true; + + if (isOfficer) { + final officerQuery = await _supabase + .from('officers') + .select('panic_strike') + .eq('nrp', user?.userMetadata?['officer_data']?['nrp']) + .maybeSingle(); + + if (officerQuery != null) { + panicStrikeCount.value = officerQuery['panic_strike'] as int? ?? 0; + } else { + panicStrikeCount.value = 0; + } + } else { + // Regular user + final userQuery = await _supabase + .from('users') + .select('panic_strike') + .eq('id', userId) + .maybeSingle(); + + if (userQuery != null) { + panicStrikeCount.value = userQuery['panic_strike'] as int? ?? 0; + } else { + panicStrikeCount.value = 0; + } + } + } catch (e) { + panicStrikeCount.value = 0; + print('Failed to load panic strike count: $e'); + } + } +} diff --git a/sigap-mobile/lib/src/cores/repositories/unit/patrol_unit_repository.dart b/sigap-mobile/lib/src/cores/repositories/unit/patrol_unit_repository.dart new file mode 100644 index 0000000..36a2576 --- /dev/null +++ b/sigap-mobile/lib/src/cores/repositories/unit/patrol_unit_repository.dart @@ -0,0 +1,109 @@ +import 'package:get/get.dart'; +import 'package:sigap/src/cores/services/supabase_service.dart'; +import 'package:sigap/src/features/daily-ops/models/patrol_units_model.dart'; +import 'package:sigap/src/utils/exceptions/exceptions.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +class PatrolUnitRepository extends GetxController { + static PatrolUnitRepository get instance => Get.find(); + + final _supabase = SupabaseService.instance.client; + + // Get all patrol units + Future> getAllPatrolUnits() async { + try { + final patrolUnits = await _supabase + .from('patrol_units') + .select('*, members:officers(*)') + .order('name'); + + return patrolUnits.map((unit) => PatrolUnitModel.fromJson(unit)).toList(); + } on PostgrestException catch (error) { + throw TExceptions.fromCode(error.code ?? 'unknown_error'); + } catch (e) { + throw TExceptions('Failed to fetch patrol units: ${e.toString()}'); + } + } + + // Get patrol unit by ID + Future getPatrolUnitById(String id) async { + try { + final patrolUnit = await _supabase + .from('patrol_units') + .select('*, members:officers(*)') + .eq('id', id) + .single(); + + return PatrolUnitModel.fromJson(patrolUnit); + } on PostgrestException catch (error) { + throw TExceptions.fromCode(error.code ?? 'unknown_error'); + } catch (e) { + throw TExceptions('Failed to fetch patrol unit data: ${e.toString()}'); + } + } + + // Get patrol units by unit ID + Future> getPatrolUnitsByUnitId(String unitId) async { + try { + final patrolUnits = await _supabase + .from('patrol_units') + .select('*, members:officers(*)') + .eq('unit_id', unitId) + .order('name'); + + return patrolUnits.map((unit) => PatrolUnitModel.fromJson(unit)).toList(); + } on PostgrestException catch (error) { + throw TExceptions.fromCode(error.code ?? 'unknown_error'); + } catch (e) { + throw TExceptions('Failed to fetch patrol units: ${e.toString()}'); + } + } + + // Get patrol units by status + Future> getPatrolUnitsByStatus(String status) async { + try { + final patrolUnits = await _supabase + .from('patrol_units') + .select('*, members:officers(*)') + .eq('status', status) + .order('name'); + + return patrolUnits.map((unit) => PatrolUnitModel.fromJson(unit)).toList(); + } on PostgrestException catch (error) { + throw TExceptions.fromCode(error.code ?? 'unknown_error'); + } catch (e) { + throw TExceptions('Failed to fetch patrol units: ${e.toString()}'); + } + } + + // Get patrol units by type + Future> getPatrolUnitsByType(String type) async { + try { + final patrolUnits = await _supabase + .from('patrol_units') + .select('*, members:officers(*)') + .eq('type', type) + .order('name'); + + return patrolUnits.map((unit) => PatrolUnitModel.fromJson(unit)).toList(); + } on PostgrestException catch (error) { + throw TExceptions.fromCode(error.code ?? 'unknown_error'); + } catch (e) { + throw TExceptions('Failed to fetch patrol units: ${e.toString()}'); + } + } + + // Update patrol unit status + Future updatePatrolUnitStatus(String id, String status) async { + try { + await _supabase + .from('patrol_units') + .update({'status': status}) + .eq('id', id); + } on PostgrestException catch (error) { + throw TExceptions.fromCode(error.code ?? 'unknown_error'); + } catch (e) { + throw TExceptions('Failed to update patrol unit status: ${e.toString()}'); + } + } +} diff --git a/sigap-mobile/lib/src/cores/repositories/unit/unit_repository.dart b/sigap-mobile/lib/src/cores/repositories/unit/unit_repository.dart new file mode 100644 index 0000000..29cc03a --- /dev/null +++ b/sigap-mobile/lib/src/cores/repositories/unit/unit_repository.dart @@ -0,0 +1,99 @@ +import 'package:get/get.dart'; +import 'package:sigap/src/cores/services/supabase_service.dart'; +import 'package:sigap/src/features/daily-ops/models/units_model.dart'; +import 'package:sigap/src/features/daily-ops/models/patrol_units_model.dart'; +import 'package:sigap/src/features/daily-ops/models/unit_statistics_model.dart'; +import 'package:sigap/src/utils/exceptions/exceptions.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +class UnitRepository extends GetxController { + static UnitRepository get instance => Get.find(); + + final _supabase = SupabaseService.instance.client; + + // Get all units + Future> getAllUnits() async { + try { + final units = await _supabase + .from('units') + .select() + .order('name'); + + return units.map((unit) => UnitModel.fromJson(unit)).toList(); + } on PostgrestException catch (error) { + throw TExceptions.fromCode(error.code ?? 'unknown_error'); + } catch (e) { + throw TExceptions('Failed to fetch units: ${e.toString()}'); + } + } + + // Get unit by ID + Future getUnitById(String codeUnit) async { + try { + final unit = await _supabase + .from('units') + .select('*, officers(*), patrol_units(*), unit_statistics(*)') + .eq('code_unit', codeUnit) + .single(); + + return UnitModel.fromJson(unit); + } on PostgrestException catch (error) { + throw TExceptions.fromCode(error.code ?? 'unknown_error'); + } catch (e) { + throw TExceptions('Failed to fetch unit data: ${e.toString()}'); + } + } + + // Get units by type + Future> getUnitsByType(UnitType type) async { + try { + final units = await _supabase + .from('units') + .select() + .eq('type', type.name) + .order('name'); + + return units.map((unit) => UnitModel.fromJson(unit)).toList(); + } on PostgrestException catch (error) { + throw TExceptions.fromCode(error.code ?? 'unknown_error'); + } catch (e) { + throw TExceptions('Failed to fetch units by type: ${e.toString()}'); + } + } + + // Get units by city + Future> getUnitsByCity(String cityId) async { + try { + final units = await _supabase + .from('units') + .select() + .eq('city_id', cityId) + .order('name'); + + return units.map((unit) => UnitModel.fromJson(unit)).toList(); + } on PostgrestException catch (error) { + throw TExceptions.fromCode(error.code ?? 'unknown_error'); + } catch (e) { + throw TExceptions('Failed to fetch units by city: ${e.toString()}'); + } + } + + // Get units near a location + Future> getUnitsNearLocation(double latitude, double longitude, double radiusInKm) async { + try { + // Use PostGIS to find units within a radius + final units = await _supabase + .rpc('get_units_near_location', params: { + 'lat': latitude, + 'lng': longitude, + 'radius_km': radiusInKm, + }); + + return units.map((unit) => UnitModel.fromJson(unit)).toList(); + } on PostgrestException catch (error) { + throw TExceptions.fromCode(error.code ?? 'unknown_error'); + } catch (e) { + throw TExceptions('Failed to fetch nearby units: ${e.toString()}'); + } + } +} diff --git a/sigap-mobile/lib/src/cores/repositories/unit/unit_statistics_repository.dart b/sigap-mobile/lib/src/cores/repositories/unit/unit_statistics_repository.dart new file mode 100644 index 0000000..c5266bb --- /dev/null +++ b/sigap-mobile/lib/src/cores/repositories/unit/unit_statistics_repository.dart @@ -0,0 +1,93 @@ +import 'package:get/get.dart'; +import 'package:sigap/src/cores/services/supabase_service.dart'; +import 'package:sigap/src/features/daily-ops/models/unit_statistics_model.dart'; +import 'package:sigap/src/utils/exceptions/exceptions.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +class UnitStatisticsRepository extends GetxController { + static UnitStatisticsRepository get instance => Get.find(); + + final _supabase = SupabaseService.instance.client; + + // Get statistics for a specific unit + Future> getUnitStatistics(String codeUnit) async { + try { + final statistics = await _supabase + .from('unit_statistics') + .select() + .eq('code_unit', codeUnit) + .order('year', ascending: false) + .order('month', ascending: false); + + return statistics.map((stat) => UnitStatisticModel.fromJson(stat)).toList(); + } on PostgrestException catch (error) { + throw TExceptions.fromCode(error.code ?? 'unknown_error'); + } catch (e) { + throw TExceptions('Failed to fetch unit statistics: ${e.toString()}'); + } + } + + // Get statistics for a specific year + Future> getUnitStatisticsByYear(String codeUnit, int year) async { + try { + final statistics = await _supabase + .from('unit_statistics') + .select() + .eq('code_unit', codeUnit) + .eq('year', year) + .order('month'); + + return statistics.map((stat) => UnitStatisticModel.fromJson(stat)).toList(); + } on PostgrestException catch (error) { + throw TExceptions.fromCode(error.code ?? 'unknown_error'); + } catch (e) { + throw TExceptions('Failed to fetch unit statistics: ${e.toString()}'); + } + } + + // Get statistics for a specific month and year + Future getUnitStatisticByMonthYear(String codeUnit, int month, int year) async { + try { + final statistic = await _supabase + .from('unit_statistics') + .select() + .eq('code_unit', codeUnit) + .eq('month', month) + .eq('year', year) + .single(); + + return UnitStatisticModel.fromJson(statistic); + } on PostgrestException catch (error) { + throw TExceptions.fromCode(error.code ?? 'unknown_error'); + } catch (e) { + throw TExceptions('Failed to fetch unit statistic: ${e.toString()}'); + } + } + + // Get latest statistics for all units + Future> getLatestStatisticsForAllUnits() async { + try { + // This would need a custom RPC function in Supabase to get the latest statistic for each unit + final statistics = await _supabase.rpc('get_latest_unit_statistics'); + + return statistics.map((stat) => UnitStatisticModel.fromJson(stat)).toList(); + } on PostgrestException catch (error) { + throw TExceptions.fromCode(error.code ?? 'unknown_error'); + } catch (e) { + throw TExceptions('Failed to fetch latest statistics: ${e.toString()}'); + } + } + + // Get total statistics summarized by year + Future>> getTotalStatisticsByYear(int year) async { + try { + final statistics = await _supabase.rpc('summarize_statistics_by_year', params: {'target_year': year}); + + return List>.from(statistics); + } on PostgrestException catch (error) { + throw TExceptions.fromCode(error.code ?? 'unknown_error'); + } catch (e) { + throw TExceptions('Failed to fetch statistics summary: ${e.toString()}'); + } + } +} diff --git a/sigap-mobile/lib/src/cores/services/biometric_service.dart b/sigap-mobile/lib/src/cores/services/biometric_service.dart new file mode 100644 index 0000000..383b671 --- /dev/null +++ b/sigap-mobile/lib/src/cores/services/biometric_service.dart @@ -0,0 +1,138 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:get/get.dart'; +import 'package:local_auth/local_auth.dart'; +import 'package:sigap/src/cores/services/supabase_service.dart'; + +class BiometricService extends GetxService { + static BiometricService get instance => Get.find(); + + final LocalAuthentication _localAuth = LocalAuthentication(); + final FlutterSecureStorage _secureStorage = const FlutterSecureStorage(); + + final RxBool isBiometricAvailable = false.obs; + final RxBool isAuthenticated = false.obs; + + // Keys for secure storage + static const String _biometricEnabledKey = 'biometric_enabled'; + static const String _userIdKey = 'user_id'; + static const String _sessionKey = 'session'; + static const String _identifierKey = 'identifier'; // NIK or NRP + + // Initialize the biometric service + Future init() async { + await _checkBiometrics(); + return this; + } + + // Check if biometric authentication is available + Future _checkBiometrics() async { + try { + isBiometricAvailable.value = + await _localAuth.canCheckBiometrics && + await _localAuth.isDeviceSupported(); + return isBiometricAvailable.value; + } on PlatformException catch (_) { + isBiometricAvailable.value = false; + return false; + } + } + + // Get available biometric types + Future> getAvailableBiometrics() async { + try { + return await _localAuth.getAvailableBiometrics(); + } on PlatformException catch (_) { + return []; + } + } + + // Request biometric authentication + Future authenticate({ + String reason = 'Authenticate to continue', + }) async { + try { + if (!isBiometricAvailable.value) { + await _checkBiometrics(); + if (!isBiometricAvailable.value) return false; + } + + isAuthenticated.value = await _localAuth.authenticate( + localizedReason: reason, + options: const AuthenticationOptions( + stickyAuth: true, + biometricOnly: true, + ), + ); + + return isAuthenticated.value; + } on PlatformException catch (_) { + isAuthenticated.value = false; + return false; + } + } + + // Enable biometric login for the current user + Future enableBiometricLogin() async { + final user = SupabaseService.instance.currentUser; + if (user == null) return; + + await _secureStorage.write(key: _biometricEnabledKey, value: 'true'); + await _secureStorage.write(key: _userIdKey, value: user.id); + + // Store the session for auto-login + final session = SupabaseService.instance.client.auth.currentSession; + if (session != null) { + await _secureStorage.write( + key: _sessionKey, + value: session.toJson().toString(), + ); + } + + // Store identifier (NIK or NRP) based on user role + final userMetadata = user.userMetadata; + String? identifier; + + if (userMetadata != null) { + if (userMetadata['is_officer'] == true && + userMetadata['officer_data'] != null) { + identifier = userMetadata['officer_data']['nrp']; + } else if (userMetadata['nik'] != null) { + identifier = userMetadata['nik']; + } + } + + if (identifier != null) { + await _secureStorage.write(key: _identifierKey, value: identifier); + } + } + + // Disable biometric login + Future disableBiometricLogin() async { + await _secureStorage.delete(key: _biometricEnabledKey); + await _secureStorage.delete(key: _userIdKey); + await _secureStorage.delete(key: _sessionKey); + await _secureStorage.delete(key: _identifierKey); + } + + // Check if biometric login is enabled + Future isBiometricLoginEnabled() async { + final enabled = await _secureStorage.read(key: _biometricEnabledKey); + return enabled == 'true'; + } + + // Attempt to perform biometric login + Future attemptBiometricLogin() async { + if (!await isBiometricLoginEnabled()) return null; + + final success = await authenticate(reason: 'Log in to your account'); + if (!success) return null; + + return await _secureStorage.read(key: _sessionKey); + } + + // Get stored user identifier (NIK or NRP) + Future getStoredIdentifier() async { + return await _secureStorage.read(key: _identifierKey); + } +} diff --git a/sigap-mobile/lib/src/cores/services/location_service.dart b/sigap-mobile/lib/src/cores/services/location_service.dart new file mode 100644 index 0000000..bf8b273 --- /dev/null +++ b/sigap-mobile/lib/src/cores/services/location_service.dart @@ -0,0 +1,152 @@ +import 'package:get/get.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:geocoding/geocoding.dart'; +import 'package:sigap/src/utils/exceptions/exceptions.dart'; + +class LocationService extends GetxService { + static LocationService get instance => Get.find(); + + final RxBool isLocationServiceEnabled = false.obs; + final RxBool isPermissionGranted = false.obs; + final Rx currentPosition = Rx(null); + final RxString currentCity = ''.obs; + final RxBool isMockedLocation = false.obs; + + // Jember's center coordinate (approximate) + static const double jemberLatitude = -8.168333; + static const double jemberLongitude = 113.702778; + + // Max distance from Jember in meters (approximately 30km radius) + static const double maxDistanceFromJember = 30000; + + // Initialize the service + Future init() async { + await _checkLocationService(); + return this; + } + + // Check if location service is enabled and permission is granted + Future _checkLocationService() async { + try { + // Check if location service is enabled + isLocationServiceEnabled.value = await Geolocator.isLocationServiceEnabled(); + if (!isLocationServiceEnabled.value) { + return false; + } + + // Check location permission + var permission = await Geolocator.checkPermission(); + + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + if (permission == LocationPermission.denied) { + isPermissionGranted.value = false; + return false; + } + } + + if (permission == LocationPermission.deniedForever) { + isPermissionGranted.value = false; + return false; + } + + isPermissionGranted.value = true; + return true; + } catch (e) { + isLocationServiceEnabled.value = false; + isPermissionGranted.value = false; + return false; + } + } + + // Request location permission + Future requestLocationPermission() async { + try { + var permission = await Geolocator.requestPermission(); + isPermissionGranted.value = permission == LocationPermission.always || + permission == LocationPermission.whileInUse; + return isPermissionGranted.value; + } catch (e) { + isPermissionGranted.value = false; + return false; + } + } + + // Get current position + Future getCurrentPosition() async { + try { + if (!isPermissionGranted.value) { + bool hasPermission = await _checkLocationService(); + if (!hasPermission) return null; + } + + currentPosition.value = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.high, + timeLimit: const Duration(seconds: 10), + ); + + // Check if location is mocked + isMockedLocation.value = currentPosition.value?.isMocked ?? false; + + // Get city name from coordinates + if (currentPosition.value != null) { + await _updateCityName(); + } + + return currentPosition.value; + } catch (e) { + throw TExceptions('Failed to get location: ${e.toString()}'); + } + } + + // Update the city name based on current position + Future _updateCityName() async { + if (currentPosition.value == null) return; + + try { + List placemarks = await placemarkFromCoordinates( + currentPosition.value!.latitude, + currentPosition.value!.longitude, + ); + + if (placemarks.isNotEmpty) { + currentCity.value = placemarks.first.locality ?? ''; + } + } catch (e) { + currentCity.value = ''; + } + } + + // Check if the user is in Jember + bool isInJember() { + if (currentPosition.value == null) return false; + + // First check by city name if available + if (currentCity.value.toLowerCase().contains('jember')) { + return true; + } + + // Then check by distance from Jember's center + double distanceInMeters = Geolocator.distanceBetween( + currentPosition.value!.latitude, + currentPosition.value!.longitude, + jemberLatitude, + jemberLongitude, + ); + + return distanceInMeters <= maxDistanceFromJember; + } + + // Check if location is valid for registration or panic button + Future isLocationValidForFeature() async { + await getCurrentPosition(); + + if (currentPosition.value == null) return false; + + // Check if location is mocked + if (isMockedLocation.value) return false; + + // Check if in Jember + return isInJember(); + } +} diff --git a/sigap-mobile/lib/src/features/auth/controllers/step_form_controller.dart b/sigap-mobile/lib/src/features/auth/controllers/step_form_controller.dart index 52e95c5..8cdb2ee 100644 --- a/sigap-mobile/lib/src/features/auth/controllers/step_form_controller.dart +++ b/sigap-mobile/lib/src/features/auth/controllers/step_form_controller.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:sigap/src/cores/services/supabase_service.dart'; +import 'package:sigap/src/features/daily-ops/models/units_model.dart'; import 'package:sigap/src/features/personalization/models/index.dart'; import 'package:sigap/src/utils/constants/app_routes.dart'; +import 'package:sigap/src/utils/popups/loaders.dart'; import 'package:sigap/src/utils/validators/validation.dart'; class StepFormController extends GetxController { @@ -10,6 +12,7 @@ class StepFormController extends GetxController { // Role information final Rx selectedRole = Rx(null); + // Current step index final RxInt currentStep = 0.obs; @@ -23,7 +26,7 @@ class StepFormController extends GetxController { final addressController = TextEditingController(); // Viewer-specific fields - final emergencyNameController = TextEditingController(); + final nikController = TextEditingController(); final emergencyPhoneController = TextEditingController(); final relationshipController = TextEditingController(); @@ -39,7 +42,7 @@ class StepFormController extends GetxController { final RxString addressError = ''.obs; // Error states - Viewer - final RxString emergencyNameError = ''.obs; + final RxString nikError = ''.obs; final RxString emergencyPhoneError = ''.obs; final RxString relationshipError = ''.obs; @@ -53,8 +56,7 @@ class StepFormController extends GetxController { final RxBool isLoading = false.obs; // Available units for officer role - final RxList> availableUnits = - >[].obs; + final RxList availableUnits = [].obs; @override void onInit() { @@ -94,18 +96,11 @@ class StepFormController extends GetxController { // Here we would fetch units from repository // For now we'll use dummy data await Future.delayed(const Duration(seconds: 1)); - availableUnits.value = [ - {"id": "unit1", "name": "Polres Jember"}, - {"id": "unit2", "name": "Polsek Sumbersari"}, - {"id": "unit3", "name": "Polsek Kaliwates"}, - ]; + } catch (e) { - Get.snackbar( - 'Error', - 'Failed to load units: ${e.toString()}', - snackPosition: SnackPosition.BOTTOM, - backgroundColor: Colors.red, - colorText: Colors.white, + TLoaders.errorSnackBar( + title: 'Error', + message: 'Failed to fetch available units: ${e.toString()}', ); } finally { isLoading.value = false; @@ -119,7 +114,7 @@ class StepFormController extends GetxController { phoneController.dispose(); addressController.dispose(); - emergencyNameController.dispose(); + nikController.dispose(); emergencyPhoneController.dispose(); relationshipController.dispose(); @@ -165,7 +160,7 @@ class StepFormController extends GetxController { addressError.value = ''; // Clear viewer-specific errors - emergencyNameError.value = ''; + nikError.value = ''; emergencyPhoneError.value = ''; relationshipError.value = ''; @@ -215,11 +210,11 @@ class StepFormController extends GetxController { final nameValidation = TValidators.validateUserInput( 'Emergency contact name', - emergencyNameController.text, + nikController.text, 100, ); if (nameValidation != null) { - emergencyNameError.value = nameValidation; + nikError.value = nameValidation; isValid = false; } @@ -376,7 +371,7 @@ class StepFormController extends GetxController { } else { // Viewer role final emergencyContact = { - 'name': emergencyNameController.text, + 'name': nikController.text, 'phone': emergencyPhoneController.text, 'relationship': relationshipController.text, }; diff --git a/sigap-mobile/lib/src/features/auth/screens/email-verification/email_verification_screen.dart b/sigap-mobile/lib/src/features/auth/screens/email-verification/email_verification_screen.dart index b98b913..05d164f 100644 --- a/sigap-mobile/lib/src/features/auth/screens/email-verification/email_verification_screen.dart +++ b/sigap-mobile/lib/src/features/auth/screens/email-verification/email_verification_screen.dart @@ -116,7 +116,7 @@ class EmailVerificationScreen extends StatelessWidget { () => TextButton( onPressed: controller.isResendEnabled.value - ? controller.resendCode + ? () => controller.resendCode : null, child: Text( controller.isResendEnabled.value diff --git a/sigap-mobile/lib/src/features/auth/screens/step-form/step_form_screen.dart b/sigap-mobile/lib/src/features/auth/screens/step-form/step_form_screen.dart index fc47e3f..1bd7a1e 100644 --- a/sigap-mobile/lib/src/features/auth/screens/step-form/step_form_screen.dart +++ b/sigap-mobile/lib/src/features/auth/screens/step-form/step_form_screen.dart @@ -248,15 +248,15 @@ class StepFormScreen extends StatelessWidget { // Emergency contact name field Obx( () => CustomTextField( - label: 'Contact Name', - controller: controller.emergencyNameController, + label: 'NIK', + controller: controller.nikController, validator: (value) => TValidators.validateUserInput( 'Emergency contact name', value, 100, ), - errorText: controller.emergencyNameError.value, + errorText: controller.nikError.value, textInputAction: TextInputAction.next, ), ), @@ -377,8 +377,8 @@ class StepFormScreen extends StatelessWidget { controller.availableUnits .map( (unit) => DropdownMenuItem( - value: unit['id'], - child: Text(unit['name']), + value: unit.codeUnit, + child: Text(unit.name), ), ) .toList(), @@ -391,7 +391,7 @@ class StepFormScreen extends StatelessWidget { controller.unitIdController.text = value.toString(); } }, - validator: TValidators.validateUnitId, + validator: (value) => TValidators.validateUnitId(value), errorText: controller.unitIdError.value, ), ), diff --git a/sigap-mobile/lib/src/features/daily-ops/models/index.dart b/sigap-mobile/lib/src/features/daily-ops/models/index.dart new file mode 100644 index 0000000..d42354a --- /dev/null +++ b/sigap-mobile/lib/src/features/daily-ops/models/index.dart @@ -0,0 +1,3 @@ +export 'patrol_units_model.dart'; +export 'unit_statistics_model.dart'; +export 'units_model.dart'; diff --git a/sigap-mobile/lib/src/features/daily-ops/models/patrol_units_model.dart b/sigap-mobile/lib/src/features/daily-ops/models/patrol_units_model.dart new file mode 100644 index 0000000..cbf6a2e --- /dev/null +++ b/sigap-mobile/lib/src/features/daily-ops/models/patrol_units_model.dart @@ -0,0 +1,112 @@ +import 'package:sigap/src/features/daily-ops/models/units_model.dart'; +import 'package:sigap/src/features/map/models/locations_model.dart'; +import 'package:sigap/src/features/personalization/models/officers_model.dart'; + +class PatrolUnitModel { + final String id; + final String unitId; + final String locationId; + final String name; + final String type; + final String status; + final double radius; + final DateTime createdAt; + final List? members; + final LocationModel? location; + final UnitModel? unit; + + PatrolUnitModel({ + required this.id, + required this.unitId, + required this.locationId, + required this.name, + required this.type, + required this.status, + required this.radius, + required this.createdAt, + this.members, + this.location, + this.unit, + }); + + // Create a PatrolUnitModel instance from a JSON object + factory PatrolUnitModel.fromJson(Map json) { + return PatrolUnitModel( + id: json['id'] as String, + unitId: json['unit_id'] as String, + locationId: json['location_id'] as String, + name: json['name'] as String, + type: json['type'] as String, + status: json['status'] as String, + radius: json['radius'].toDouble(), + createdAt: DateTime.parse(json['created_at'] as String), + members: + json['members'] != null + ? (json['members'] as List) + .map((e) => OfficerModel.fromJson(e as Map)) + .toList() + : null, + location: + json['location'] != null + ? LocationModel.fromJson(json['location'] as Map) + : null, + unit: + json['unit'] != null + ? UnitModel.fromJson(json['unit'] as Map) + : null, + ); + } + + // Convert a PatrolUnitModel instance to a JSON object + Map toJson() { + return { + 'id': id, + 'unit_id': unitId, + 'location_id': locationId, + 'name': name, + 'type': type, + 'status': status, + 'radius': radius, + 'created_at': createdAt.toIso8601String(), + if (members != null) 'members': members!.map((e) => e.toJson()).toList(), + }; + } + + // Create a copy of the PatrolUnitModel with updated fields + PatrolUnitModel copyWith({ + String? id, + String? unitId, + String? locationId, + String? name, + String? type, + String? status, + double? radius, + DateTime? createdAt, + List? members, + LocationModel? location, + UnitModel? unit, + }) { + return PatrolUnitModel( + id: id ?? this.id, + unitId: unitId ?? this.unitId, + locationId: locationId ?? this.locationId, + name: name ?? this.name, + type: type ?? this.type, + status: status ?? this.status, + radius: radius ?? this.radius, + createdAt: createdAt ?? this.createdAt, + members: members ?? this.members, + location: location ?? this.location, + unit: unit ?? this.unit, + ); + } + + get coordinates { + return {'latitude': location?.latitude, 'longitude': location?.longitude}; + } + + @override + String toString() { + return 'PatrolUnitModel(id: $id, name: $name, status: $status)'; + } +} diff --git a/sigap-mobile/lib/src/features/daily-ops/models/unit_statistics_model.dart b/sigap-mobile/lib/src/features/daily-ops/models/unit_statistics_model.dart new file mode 100644 index 0000000..9e73e2b --- /dev/null +++ b/sigap-mobile/lib/src/features/daily-ops/models/unit_statistics_model.dart @@ -0,0 +1,93 @@ +class UnitStatisticModel { + final String id; + final int crimeTotal; + final int crimeCleared; + final double? percentage; + final int? pending; + final int month; + final int year; + final DateTime? createdAt; + final DateTime? updatedAt; + final String codeUnit; + + UnitStatisticModel({ + required this.id, + required this.crimeTotal, + required this.crimeCleared, + this.percentage, + this.pending, + required this.month, + required this.year, + this.createdAt, + this.updatedAt, + required this.codeUnit, + }); + + // Create a UnitStatisticModel instance from a JSON object + factory UnitStatisticModel.fromJson(Map json) { + return UnitStatisticModel( + id: json['id'] as String, + crimeTotal: json['crime_total'] as int, + crimeCleared: json['crime_cleared'] as int, + percentage: json['percentage']?.toDouble(), + pending: json['pending'] as int?, + month: json['month'] as int, + year: json['year'] as int, + 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, + codeUnit: json['code_unit'] as String, + ); + } + + // Convert a UnitStatisticModel instance to a JSON object + Map toJson() { + return { + 'id': id, + 'crime_total': crimeTotal, + 'crime_cleared': crimeCleared, + 'percentage': percentage, + 'pending': pending, + 'month': month, + 'year': year, + 'created_at': createdAt?.toIso8601String(), + 'updated_at': updatedAt?.toIso8601String(), + 'code_unit': codeUnit, + }; + } + + // Create a copy of the UnitStatisticModel with updated fields + UnitStatisticModel copyWith({ + String? id, + int? crimeTotal, + int? crimeCleared, + double? percentage, + int? pending, + int? month, + int? year, + DateTime? createdAt, + DateTime? updatedAt, + String? codeUnit, + }) { + return UnitStatisticModel( + id: id ?? this.id, + crimeTotal: crimeTotal ?? this.crimeTotal, + crimeCleared: crimeCleared ?? this.crimeCleared, + percentage: percentage ?? this.percentage, + pending: pending ?? this.pending, + month: month ?? this.month, + year: year ?? this.year, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + codeUnit: codeUnit ?? this.codeUnit, + ); + } + + @override + String toString() { + return 'UnitStatisticModel(id: $id, month: $month, year: $year, crimeTotal: $crimeTotal, crimeCleared: $crimeCleared)'; + } +} diff --git a/sigap-mobile/lib/src/features/daily-ops/models/units_model.dart b/sigap-mobile/lib/src/features/daily-ops/models/units_model.dart new file mode 100644 index 0000000..43e748f --- /dev/null +++ b/sigap-mobile/lib/src/features/daily-ops/models/units_model.dart @@ -0,0 +1,173 @@ +import 'package:sigap/src/features/daily-ops/models/patrol_units_model.dart'; +import 'package:sigap/src/features/daily-ops/models/unit_statistics_model.dart'; +import 'package:sigap/src/features/personalization/models/officers_model.dart'; + +enum UnitType { polda, polsek, polres, other } + +class UnitModel { + final String codeUnit; + final String? districtId; + final String name; + final String? description; + final UnitType type; + final DateTime? createdAt; + final DateTime? updatedAt; + final String? address; + final double? landArea; + final double latitude; + final double longitude; + final String cityId; + final String? phone; + final List? officers; + final List? patrolUnits; + final List? statistics; + + UnitModel({ + required this.codeUnit, + this.districtId, + required this.name, + this.description, + required this.type, + this.createdAt, + this.updatedAt, + this.address, + this.landArea, + required this.latitude, + required this.longitude, + required this.cityId, + this.phone, + this.officers, + this.patrolUnits, + this.statistics, + }); + + // Create a UnitModel instance from a JSON object + factory UnitModel.fromJson(Map json) { + return UnitModel( + codeUnit: json['code_unit'] as String, + districtId: json['district_id'] as String?, + name: json['name'] as String, + description: json['description'] as String?, + type: _parseUnitType(json['type'] as String), + createdAt: + json['created_at'] != null + ? DateTime.parse(json['created_at']) + : null, + updatedAt: + json['updated_at'] != null + ? DateTime.parse(json['updated_at']) + : null, + address: json['address'] as String?, + landArea: json['land_area']?.toDouble(), + latitude: json['latitude'].toDouble(), + longitude: json['longitude'].toDouble(), + cityId: json['city_id'] as String, + phone: json['phone'] as String?, + officers: + json['officers'] != null + ? (json['officers'] as List) + .map((e) => OfficerModel.fromJson(e as Map)) + .toList() + : null, + patrolUnits: + json['patrol_units'] != null + ? (json['patrol_units'] as List) + .map( + (e) => PatrolUnitModel.fromJson(e as Map), + ) + .toList() + : null, + statistics: + json['unit_statistics'] != null + ? (json['unit_statistics'] as List) + .map( + (e) => + UnitStatisticModel.fromJson(e as Map), + ) + .toList() + : null, + ); + } + + // Convert a UnitModel instance to a JSON object + Map toJson() { + return { + 'code_unit': codeUnit, + 'district_id': districtId, + 'name': name, + 'description': description, + 'type': type.name, + 'created_at': createdAt?.toIso8601String(), + 'updated_at': updatedAt?.toIso8601String(), + 'address': address, + 'land_area': landArea, + 'latitude': latitude, + 'longitude': longitude, + 'city_id': cityId, + 'phone': phone, + if (officers != null) + 'officers': officers!.map((e) => e.toJson()).toList(), + if (patrolUnits != null) + 'patrol_units': patrolUnits!.map((e) => e.toJson()).toList(), + if (statistics != null) + 'unit_statistics': statistics!.map((e) => e.toJson()).toList(), + }; + } + + // Create a copy of the UnitModel with updated fields + UnitModel copyWith({ + String? codeUnit, + String? districtId, + String? name, + String? description, + UnitType? type, + DateTime? createdAt, + DateTime? updatedAt, + String? address, + double? landArea, + double? latitude, + double? longitude, + String? cityId, + String? phone, + List? officers, + List? patrolUnits, + List? statistics, + }) { + return UnitModel( + codeUnit: codeUnit ?? this.codeUnit, + districtId: districtId ?? this.districtId, + name: name ?? this.name, + description: description ?? this.description, + type: type ?? this.type, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + address: address ?? this.address, + landArea: landArea ?? this.landArea, + latitude: latitude ?? this.latitude, + longitude: longitude ?? this.longitude, + cityId: cityId ?? this.cityId, + phone: phone ?? this.phone, + officers: officers ?? this.officers, + patrolUnits: patrolUnits ?? this.patrolUnits, + statistics: statistics ?? this.statistics, + ); + } + + static UnitType _parseUnitType(String type) { + switch (type.toLowerCase()) { + case 'polda': + return UnitType.polda; + case 'polsek': + return UnitType.polsek; + case 'polres': + return UnitType.polres; + default: + return UnitType.other; + } + } + + @override + String toString() { + return 'UnitModel(codeUnit: $codeUnit, name: $name, type: ${type.name})'; + } +} diff --git a/sigap-mobile/lib/src/features/explore/models/units_model.dart b/sigap-mobile/lib/src/features/explore/models/units_model.dart deleted file mode 100644 index e69de29..0000000 diff --git a/sigap-mobile/lib/src/features/map/models/cities_model.dart b/sigap-mobile/lib/src/features/map/models/cities_model.dart new file mode 100644 index 0000000..d416d31 --- /dev/null +++ b/sigap-mobile/lib/src/features/map/models/cities_model.dart @@ -0,0 +1,82 @@ +import 'package:sigap/src/features/daily-ops/models/units_model.dart'; +import 'package:sigap/src/features/map/models/districts_model.dart'; + +class CityModel { + final String id; + final String name; + final DateTime? createdAt; + final DateTime? updatedAt; + final List? districts; + final List? units; + + CityModel({ + required this.id, + required this.name, + this.createdAt, + this.updatedAt, + this.districts, + this.units, + }); + + // Create a CityModel instance from a JSON object + factory CityModel.fromJson(Map json) { + return CityModel( + id: json['id'] as String, + name: json['name'] as String, + createdAt: + json['created_at'] != null + ? DateTime.parse(json['created_at']) + : null, + updatedAt: + json['updated_at'] != null + ? DateTime.parse(json['updated_at']) + : null, + districts: + json['districts'] != null + ? (json['districts'] as List) + .map((e) => DistrictModel.fromJson(e as Map)) + .toList() + : null, + units: + json['units'] != null + ? (json['units'] as List) + .map((e) => UnitModel.fromJson(e as Map)) + .toList() + : null, + ); + } + + // Convert a CityModel instance to a JSON object + Map toJson() { + return { + 'id': id, + 'name': name, + 'created_at': createdAt?.toIso8601String(), + 'updated_at': updatedAt?.toIso8601String(), + }; + } + + // Create a copy of the CityModel with updated fields + CityModel copyWith({ + String? id, + String? name, + DateTime? createdAt, + DateTime? updatedAt, + List? districts, + List? units, + }) { + return CityModel( + id: id ?? this.id, + name: name ?? this.name, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + districts: districts ?? this.districts, + units: units ?? this.units, + ); + } + + @override + String toString() { + return 'CityModel(id: $id, name: $name)'; + } +} diff --git a/sigap-mobile/lib/src/features/map/models/demographics_model.dart b/sigap-mobile/lib/src/features/map/models/demographics_model.dart new file mode 100644 index 0000000..ad1e7a0 --- /dev/null +++ b/sigap-mobile/lib/src/features/map/models/demographics_model.dart @@ -0,0 +1,95 @@ +import 'package:sigap/src/features/map/models/districts_model.dart'; + +class DemographicModel { + final String id; + final String districtId; + final int population; + final int numberOfUnemployed; + final double populationDensity; + final int year; + final DateTime? createdAt; + final DateTime? updatedAt; + final DistrictModel? district; + + DemographicModel({ + required this.id, + required this.districtId, + required this.population, + required this.numberOfUnemployed, + required this.populationDensity, + required this.year, + this.createdAt, + this.updatedAt, + this.district, + }); + + // Create a DemographicModel instance from a JSON object + factory DemographicModel.fromJson(Map json) { + return DemographicModel( + id: json['id'] as String, + districtId: json['district_id'] as String, + population: json['population'] as int, + numberOfUnemployed: json['number_of_unemployed'] as int, + populationDensity: json['population_density'].toDouble(), + year: json['year'] as int, + createdAt: + json['created_at'] != null + ? DateTime.parse(json['created_at']) + : null, + updatedAt: + json['updated_at'] != null + ? DateTime.parse(json['updated_at']) + : null, + district: + json['districts'] != null + ? DistrictModel.fromJson( + json['districts'] as Map, + ) + : null, + ); + } + + // Convert a DemographicModel instance to a JSON object + Map toJson() { + return { + 'id': id, + 'district_id': districtId, + 'population': population, + 'number_of_unemployed': numberOfUnemployed, + 'population_density': populationDensity, + 'year': year, + 'created_at': createdAt?.toIso8601String(), + 'updated_at': updatedAt?.toIso8601String(), + }; + } + + // Create a copy of the DemographicModel with updated fields + DemographicModel copyWith({ + String? id, + String? districtId, + int? population, + int? numberOfUnemployed, + double? populationDensity, + int? year, + DateTime? createdAt, + DateTime? updatedAt, + DistrictModel? district, + }) { + return DemographicModel( + id: id ?? this.id, + districtId: districtId ?? this.districtId, + population: population ?? this.population, + numberOfUnemployed: numberOfUnemployed ?? this.numberOfUnemployed, + populationDensity: populationDensity ?? this.populationDensity, + year: year ?? this.year, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + district: district ?? this.district, + ); + } + + @override + String toString() { + return 'DemographicModel(id: $id, districtId: $districtId, year: $year)'; + } +} diff --git a/sigap-mobile/lib/src/features/map/models/districts_model.dart b/sigap-mobile/lib/src/features/map/models/districts_model.dart new file mode 100644 index 0000000..c59d36d --- /dev/null +++ b/sigap-mobile/lib/src/features/map/models/districts_model.dart @@ -0,0 +1,132 @@ +import 'package:sigap/src/features/daily-ops/models/units_model.dart'; +import 'package:sigap/src/features/map/models/cities_model.dart'; +import 'package:sigap/src/features/map/models/demographics_model.dart'; +import 'package:sigap/src/features/map/models/geographics_model.dart'; +import 'package:sigap/src/features/map/models/locations_model.dart'; +import 'package:sigap/src/features/panic-button/models/crimes_model.dart'; + +class DistrictModel { + final String id; + final String cityId; + final String name; + final DateTime? createdAt; + final DateTime? updatedAt; + final List? crimes; + final List? demographics; + final CityModel? city; + final List? geographics; + final List? locations; + final UnitModel? unit; + + DistrictModel({ + required this.id, + required this.cityId, + required this.name, + this.createdAt, + this.updatedAt, + this.crimes, + this.demographics, + this.city, + this.geographics, + this.locations, + this.unit, + }); + + // Create a DistrictModel instance from a JSON object + factory DistrictModel.fromJson(Map json) { + return DistrictModel( + id: json['id'] as String, + cityId: json['city_id'] as String, + name: json['name'] as String, + createdAt: + json['created_at'] != null + ? DateTime.parse(json['created_at']) + : null, + updatedAt: + json['updated_at'] != null + ? DateTime.parse(json['updated_at']) + : null, + crimes: + json['crimes'] != null + ? (json['crimes'] as List) + .map((e) => CrimeModel.fromJson(e as Map)) + .toList() + : null, + demographics: + json['demographics'] != null + ? (json['demographics'] as List) + .map( + (e) => DemographicModel.fromJson(e as Map), + ) + .toList() + : null, + city: + json['cities'] != null + ? CityModel.fromJson(json['cities'] as Map) + : null, + geographics: + json['geographics'] != null + ? (json['geographics'] as List) + .map( + (e) => GeographicModel.fromJson(e as Map), + ) + .toList() + : null, + locations: + json['locations'] != null + ? (json['locations'] as List) + .map((e) => LocationModel.fromJson(e as Map)) + .toList() + : null, + unit: + json['units'] != null + ? UnitModel.fromJson(json['units'] as Map) + : null, + ); + } + + // Convert a DistrictModel instance to a JSON object + Map toJson() { + return { + 'id': id, + 'city_id': cityId, + 'name': name, + 'created_at': createdAt?.toIso8601String(), + 'updated_at': updatedAt?.toIso8601String(), + }; + } + + // Create a copy of the DistrictModel with updated fields + DistrictModel copyWith({ + String? id, + String? cityId, + String? name, + DateTime? createdAt, + DateTime? updatedAt, + List? crimes, + List? demographics, + CityModel? city, + List? geographics, + List? locations, + UnitModel? unit, + }) { + return DistrictModel( + id: id ?? this.id, + cityId: cityId ?? this.cityId, + name: name ?? this.name, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + crimes: crimes ?? this.crimes, + demographics: demographics ?? this.demographics, + city: city ?? this.city, + geographics: geographics ?? this.geographics, + locations: locations ?? this.locations, + unit: unit ?? this.unit, + ); + } + + @override + String toString() { + return 'DistrictModel(id: $id, name: $name, cityId: $cityId)'; + } +} diff --git a/sigap-mobile/lib/src/features/map/models/geographics_model.dart b/sigap-mobile/lib/src/features/map/models/geographics_model.dart new file mode 100644 index 0000000..7eb5c92 --- /dev/null +++ b/sigap-mobile/lib/src/features/map/models/geographics_model.dart @@ -0,0 +1,113 @@ +import 'package:sigap/src/features/map/models/districts_model.dart'; + +class GeographicModel { + final String id; + final String districtId; + final String? address; + final double longitude; + final double latitude; + final double? landArea; + final DateTime? createdAt; + final DateTime? updatedAt; + final String? description; + final String? type; + final int? year; + final DistrictModel? district; + + GeographicModel({ + required this.id, + required this.districtId, + this.address, + required this.longitude, + required this.latitude, + this.landArea, + this.createdAt, + this.updatedAt, + this.description, + this.type, + this.year, + this.district, + }); + + // Create a GeographicModel instance from a JSON object + factory GeographicModel.fromJson(Map json) { + return GeographicModel( + id: json['id'] as String, + districtId: json['district_id'] as String, + address: json['address'] as String?, + longitude: json['longitude'].toDouble(), + latitude: json['latitude'].toDouble(), + landArea: json['land_area']?.toDouble(), + createdAt: + json['created_at'] != null + ? DateTime.parse(json['created_at']) + : null, + updatedAt: + json['updated_at'] != null + ? DateTime.parse(json['updated_at']) + : null, + description: json['description'] as String?, + type: json['type'] as String?, + year: json['year'] as int?, + district: + json['districts'] != null + ? DistrictModel.fromJson( + json['districts'] as Map, + ) + : null, + ); + } + + // Convert a GeographicModel instance to a JSON object + Map toJson() { + return { + 'id': id, + 'district_id': districtId, + 'address': address, + 'longitude': longitude, + 'latitude': latitude, + 'land_area': landArea, + 'created_at': createdAt?.toIso8601String(), + 'updated_at': updatedAt?.toIso8601String(), + 'description': description, + 'type': type, + 'year': year, + }; + } + + // Create a copy of the GeographicModel with updated fields + GeographicModel copyWith({ + String? id, + String? districtId, + String? address, + double? longitude, + double? latitude, + double? landArea, + DateTime? createdAt, + DateTime? updatedAt, + String? description, + String? type, + int? year, + DistrictModel? district, + }) { + return GeographicModel( + id: id ?? this.id, + districtId: districtId ?? this.districtId, + address: address ?? this.address, + longitude: longitude ?? this.longitude, + latitude: latitude ?? this.latitude, + landArea: landArea ?? this.landArea, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + description: description ?? this.description, + type: type ?? this.type, + year: year ?? this.year, + district: district ?? this.district, + ); + } + + @override + String toString() { + return 'GeographicModel(id: $id, districtId: $districtId, type: $type)'; + } +} diff --git a/sigap-mobile/lib/src/features/map/models/index.dart b/sigap-mobile/lib/src/features/map/models/index.dart new file mode 100644 index 0000000..e6731c5 --- /dev/null +++ b/sigap-mobile/lib/src/features/map/models/index.dart @@ -0,0 +1,6 @@ +export 'cities_model.dart'; +export 'demographics_model.dart'; +export 'districts_model.dart'; +export 'geographics_model.dart'; +export 'location_logs_model.dart'; +export 'locations_model.dart'; diff --git a/sigap-mobile/lib/src/features/map/models/location_logs_model.dart b/sigap-mobile/lib/src/features/map/models/location_logs_model.dart new file mode 100644 index 0000000..6a35b31 --- /dev/null +++ b/sigap-mobile/lib/src/features/map/models/location_logs_model.dart @@ -0,0 +1,87 @@ +import 'package:sigap/src/features/personalization/models/users_model.dart'; + +class LocationLogModel { + final String id; + final String userId; + final double latitude; + final double longitude; + final DateTime timestamp; + final String? description; + final DateTime createdAt; + final DateTime updatedAt; + final UserModel? user; + + LocationLogModel({ + required this.id, + required this.userId, + required this.latitude, + required this.longitude, + required this.timestamp, + this.description, + required this.createdAt, + required this.updatedAt, + this.user, + }); + + // Create a LocationLogModel instance from a JSON object + factory LocationLogModel.fromJson(Map json) { + return LocationLogModel( + id: json['id'] as String, + userId: json['user_id'] as String, + latitude: json['latitude'].toDouble(), + longitude: json['longitude'].toDouble(), + timestamp: DateTime.parse(json['timestamp'] as String), + description: json['description'] as String?, + createdAt: DateTime.parse(json['created_at'] as String), + updatedAt: DateTime.parse(json['updated_at'] as String), + user: + json['users'] != null + ? UserModel.fromJson(json['users'] as Map) + : null, + ); + } + + // Convert a LocationLogModel instance to a JSON object + Map toJson() { + return { + 'id': id, + 'user_id': userId, + 'latitude': latitude, + 'longitude': longitude, + 'timestamp': timestamp.toIso8601String(), + 'description': description, + 'created_at': createdAt.toIso8601String(), + 'updated_at': updatedAt.toIso8601String(), + }; + } + + // Create a copy of the LocationLogModel with updated fields + LocationLogModel copyWith({ + String? id, + String? userId, + double? latitude, + double? longitude, + DateTime? timestamp, + String? description, + DateTime? createdAt, + DateTime? updatedAt, + UserModel? user, + }) { + return LocationLogModel( + id: id ?? this.id, + userId: userId ?? this.userId, + latitude: latitude ?? this.latitude, + longitude: longitude ?? this.longitude, + timestamp: timestamp ?? this.timestamp, + description: description ?? this.description, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + user: user ?? this.user, + ); + } + + @override + String toString() { + return 'LocationLogModel(id: $id, userId: $userId, timestamp: $timestamp)'; + } +} diff --git a/sigap-mobile/lib/src/features/map/models/locations_model.dart b/sigap-mobile/lib/src/features/map/models/locations_model.dart new file mode 100644 index 0000000..e3cfc3d --- /dev/null +++ b/sigap-mobile/lib/src/features/map/models/locations_model.dart @@ -0,0 +1,150 @@ +import 'package:sigap/src/features/daily-ops/models/patrol_units_model.dart'; +import 'package:sigap/src/features/panic-button/models/crime_incidents_model.dart'; +import 'package:sigap/src/features/panic-button/models/events_model.dart'; +import 'package:sigap/src/features/panic-button/models/incident_logs_model.dart'; + +class LocationModel { + final String id; + final String districtId; + final String eventId; + final String? address; + final String? type; + final double latitude; + final double longitude; + final double? landArea; + final double? distanceToUnit; + final DateTime? createdAt; + final DateTime? updatedAt; + + // Relations + final EventModel event; + final List? crimeIncidents; + final List? incidentLogs; + final List? patrolUnits; + + LocationModel({ + required this.id, + required this.districtId, + required this.eventId, + this.address, + this.type, + required this.latitude, + required this.longitude, + this.landArea, + this.distanceToUnit, + this.createdAt, + this.updatedAt, + required this.event, + this.crimeIncidents, + this.incidentLogs, + this.patrolUnits, + }); + + // Create a LocationModel instance from a JSON object + factory LocationModel.fromJson(Map json) { + return LocationModel( + id: json['id'] as String, + districtId: json['district_id'] as String, + eventId: json['event_id'] as String, + address: json['address'] as String?, + type: json['type'] as String?, + latitude: json['latitude'].toDouble(), + longitude: json['longitude'].toDouble(), + landArea: json['land_area']?.toDouble(), + distanceToUnit: json['distance_to_unit']?.toDouble(), + createdAt: + json['created_at'] != null + ? DateTime.parse(json['created_at']) + : null, + updatedAt: + json['updated_at'] != null + ? DateTime.parse(json['updated_at']) + : null, + event: EventModel.fromJson(json['event'] as Map), + crimeIncidents: + json['crime_incidents'] != null + ? (json['crime_incidents'] as List) + .map( + (e) => + CrimeIncidentModel.fromJson(e as Map), + ) + .toList() + : null, + incidentLogs: + json['incident_logs'] != null + ? (json['incident_logs'] as List) + .map( + (e) => IncidentLogModel.fromJson(e as Map), + ) + .toList() + : null, + patrolUnits: + json['patrol_units'] != null + ? (json['patrol_units'] as List) + .map( + (e) => PatrolUnitModel.fromJson(e as Map), + ) + .toList() + : null, + ); + } + + // Convert a LocationModel instance to a JSON object + Map toJson() { + return { + 'id': id, + 'district_id': districtId, + 'event_id': eventId, + 'address': address, + 'type': type, + 'latitude': latitude, + 'longitude': longitude, + 'land_area': landArea, + 'distance_to_unit': distanceToUnit, + 'created_at': createdAt?.toIso8601String(), + 'updated_at': updatedAt?.toIso8601String(), + }; + } + + // Create a copy of the LocationModel with updated fields + LocationModel copyWith({ + String? id, + String? districtId, + String? eventId, + String? address, + String? type, + double? latitude, + double? longitude, + double? landArea, + double? distanceToUnit, + DateTime? createdAt, + DateTime? updatedAt, + EventModel? event, + List? crimeIncidents, + List? incidentLogs, + List? patrolUnits, + }) { + return LocationModel( + id: id ?? this.id, + districtId: districtId ?? this.districtId, + eventId: eventId ?? this.eventId, + address: address ?? this.address, + type: type ?? this.type, + latitude: latitude ?? this.latitude, + longitude: longitude ?? this.longitude, + landArea: landArea ?? this.landArea, + distanceToUnit: distanceToUnit ?? this.distanceToUnit, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + event: event ?? this.event, + crimeIncidents: crimeIncidents ?? this.crimeIncidents, + incidentLogs: incidentLogs ?? this.incidentLogs, + patrolUnits: patrolUnits ?? this.patrolUnits, + ); + } + + @override + String toString() { + return 'LocationModel(id: $id, latitude: $latitude, longitude: $longitude)'; + } +} diff --git a/sigap-mobile/lib/src/features/onboarding/controllers/choose_role_controller.dart b/sigap-mobile/lib/src/features/onboarding/controllers/choose_role_controller.dart index c5a68db..36251f6 100644 --- a/sigap-mobile/lib/src/features/onboarding/controllers/choose_role_controller.dart +++ b/sigap-mobile/lib/src/features/onboarding/controllers/choose_role_controller.dart @@ -1,6 +1,6 @@ import 'package:get/get.dart'; import 'package:sigap/src/cores/repositories/roles/roles_repository.dart'; -import 'package:sigap/src/features/personalization/models/index.dart'; +import 'package:sigap/src/features/personalization/models/roles_model.dart'; import 'package:sigap/src/utils/constants/app_routes.dart'; import 'package:sigap/src/utils/popups/loaders.dart'; diff --git a/sigap-mobile/lib/src/features/onboarding/screens/choose-role/widgets/role_card.dart b/sigap-mobile/lib/src/features/onboarding/screens/choose-role/widgets/role_card.dart index 7372ab8..f99e739 100644 --- a/sigap-mobile/lib/src/features/onboarding/screens/choose-role/widgets/role_card.dart +++ b/sigap-mobile/lib/src/features/onboarding/screens/choose-role/widgets/role_card.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:sigap/src/features/personalization/models/index.dart'; +import 'package:sigap/src/features/personalization/models/roles_model.dart'; import 'package:sigap/src/utils/constants/colors.dart'; class RoleCard extends StatelessWidget { diff --git a/sigap-mobile/lib/src/features/panic-button/models/crime_incidents_model.dart b/sigap-mobile/lib/src/features/panic-button/models/crime_incidents_model.dart new file mode 100644 index 0000000..3bdea32 --- /dev/null +++ b/sigap-mobile/lib/src/features/panic-button/models/crime_incidents_model.dart @@ -0,0 +1,116 @@ +enum CrimeStatus { open, closed, underInvestigation, resolved, unresolved } + +class CrimeIncidentModel { + final String id; + final String crimeId; + final String crimeCategoryId; + final String locationId; + final String description; + final int victimCount; + final CrimeStatus? status; + final DateTime? createdAt; + final DateTime? updatedAt; + final DateTime timestamp; + + CrimeIncidentModel({ + required this.id, + required this.crimeId, + required this.crimeCategoryId, + required this.locationId, + required this.description, + required this.victimCount, + this.status = CrimeStatus.open, + this.createdAt, + this.updatedAt, + required this.timestamp, + }); + + // Create a CrimeIncidentModel instance from a JSON object + factory CrimeIncidentModel.fromJson(Map json) { + return CrimeIncidentModel( + id: json['id'] as String, + crimeId: json['crime_id'] as String, + crimeCategoryId: json['crime_category_id'] as String, + locationId: json['location_id'] as String, + description: json['description'] as String, + victimCount: json['victim_count'] as int, + status: _parseStatus(json['status']?.toString()), + createdAt: + json['created_at'] != null + ? DateTime.parse(json['created_at']) + : null, + updatedAt: + json['updated_at'] != null + ? DateTime.parse(json['updated_at']) + : null, + timestamp: DateTime.parse(json['timestamp']), + ); + } + + // Convert a CrimeIncidentModel instance to a JSON object + Map toJson() { + return { + 'id': id, + 'crime_id': crimeId, + 'crime_category_id': crimeCategoryId, + 'location_id': locationId, + 'description': description, + 'victim_count': victimCount, + 'status': status?.toString().split('.').last ?? 'open', + 'timestamp': timestamp.toIso8601String(), + 'created_at': createdAt?.toIso8601String(), + 'updated_at': updatedAt?.toIso8601String(), + }; + } + + // Create a copy of the CrimeIncidentModel with updated fields + CrimeIncidentModel copyWith({ + String? id, + String? crimeId, + String? crimeCategoryId, + String? locationId, + String? description, + int? victimCount, + CrimeStatus? status, + DateTime? createdAt, + DateTime? updatedAt, + DateTime? timestamp, + }) { + return CrimeIncidentModel( + id: id ?? this.id, + crimeId: crimeId ?? this.crimeId, + crimeCategoryId: crimeCategoryId ?? this.crimeCategoryId, + locationId: locationId ?? this.locationId, + description: description ?? this.description, + victimCount: victimCount ?? this.victimCount, + status: status ?? this.status, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + timestamp: timestamp ?? this.timestamp, + ); + } + + static CrimeStatus? _parseStatus(String? status) { + if (status == null) return CrimeStatus.open; + + switch (status) { + case 'open': + return CrimeStatus.open; + case 'closed': + return CrimeStatus.closed; + case 'under_investigation': + return CrimeStatus.underInvestigation; + case 'resolved': + return CrimeStatus.resolved; + case 'unresolved': + return CrimeStatus.unresolved; + default: + return CrimeStatus.open; + } + } + + @override + String toString() { + return 'CrimeIncidentModel(id: $id, crimeId: $crimeId, status: ${status.toString()})'; + } +} diff --git a/sigap-mobile/lib/src/features/panic-button/models/crimes_model.dart b/sigap-mobile/lib/src/features/panic-button/models/crimes_model.dart new file mode 100644 index 0000000..de45029 --- /dev/null +++ b/sigap-mobile/lib/src/features/panic-button/models/crimes_model.dart @@ -0,0 +1,156 @@ +import 'package:sigap/src/features/map/models/districts_model.dart'; +import 'package:sigap/src/features/panic-button/models/crime_incidents_model.dart'; + +enum CrimeRates { low, medium, high, critical } + +class CrimeModel { + final String id; + final String districtId; + final DateTime? createdAt; + final CrimeRates level; + final String? method; + final int? month; + final int numberOfCrime; + final double score; + final DateTime? updatedAt; + final int? year; + final String? sourceType; + final int crimeCleared; + final double avgCrime; + final List? crimeIncidents; + final DistrictModel? district; + + CrimeModel({ + required this.id, + required this.districtId, + this.createdAt, + required this.level, + this.method, + this.month, + required this.numberOfCrime, + required this.score, + this.updatedAt, + this.year, + this.sourceType, + required this.crimeCleared, + required this.avgCrime, + this.crimeIncidents, + this.district, + }); + + // Create a CrimeModel instance from a JSON object + factory CrimeModel.fromJson(Map json) { + return CrimeModel( + id: json['id'] as String, + districtId: json['district_id'] as String, + createdAt: + json['created_at'] != null + ? DateTime.parse(json['created_at']) + : null, + level: _parseCrimeRate(json['level'] as String), + method: json['method'] as String?, + month: json['month'] as int?, + numberOfCrime: json['number_of_crime'] as int, + score: json['score'].toDouble(), + updatedAt: + json['updated_at'] != null + ? DateTime.parse(json['updated_at']) + : null, + year: json['year'] as int?, + sourceType: json['source_type'] as String?, + crimeCleared: json['crime_cleared'] as int, + avgCrime: json['avg_crime'].toDouble(), + crimeIncidents: + json['crime_incidents'] != null + ? (json['crime_incidents'] as List) + .map( + (e) => + CrimeIncidentModel.fromJson(e as Map), + ) + .toList() + : null, + district: + json['districts'] != null + ? DistrictModel.fromJson( + json['districts'] as Map, + ) + : null, + ); + } + + // Convert a CrimeModel instance to a JSON object + Map toJson() { + return { + 'id': id, + 'district_id': districtId, + 'created_at': createdAt?.toIso8601String(), + 'level': level.name, + 'method': method, + 'month': month, + 'number_of_crime': numberOfCrime, + 'score': score, + 'updated_at': updatedAt?.toIso8601String(), + 'year': year, + 'source_type': sourceType, + 'crime_cleared': crimeCleared, + 'avg_crime': avgCrime, + }; + } + + // Create a copy of the CrimeModel with updated fields + CrimeModel copyWith({ + String? id, + String? districtId, + DateTime? createdAt, + CrimeRates? level, + String? method, + int? month, + int? numberOfCrime, + double? score, + DateTime? updatedAt, + int? year, + String? sourceType, + int? crimeCleared, + double? avgCrime, + List? crimeIncidents, + DistrictModel? district, + }) { + return CrimeModel( + id: id ?? this.id, + districtId: districtId ?? this.districtId, + createdAt: createdAt ?? this.createdAt, + level: level ?? this.level, + method: method ?? this.method, + month: month ?? this.month, + numberOfCrime: numberOfCrime ?? this.numberOfCrime, + score: score ?? this.score, + updatedAt: updatedAt ?? this.updatedAt, + year: year ?? this.year, + sourceType: sourceType ?? this.sourceType, + crimeCleared: crimeCleared ?? this.crimeCleared, + avgCrime: avgCrime ?? this.avgCrime, + crimeIncidents: crimeIncidents ?? this.crimeIncidents, + district: district ?? this.district, + ); + } + + static CrimeRates _parseCrimeRate(String rate) { + switch (rate.toLowerCase()) { + case 'low': + return CrimeRates.low; + case 'medium': + return CrimeRates.medium; + case 'high': + return CrimeRates.high; + case 'critical': + return CrimeRates.critical; + default: + return CrimeRates.low; + } + } + + @override + String toString() { + return 'CrimeModel(id: $id, districtId: $districtId, level: ${level.name}, year: $year, month: $month)'; + } +} diff --git a/sigap-mobile/lib/src/features/panic-button/models/events_model.dart b/sigap-mobile/lib/src/features/panic-button/models/events_model.dart new file mode 100644 index 0000000..680d5d1 --- /dev/null +++ b/sigap-mobile/lib/src/features/panic-button/models/events_model.dart @@ -0,0 +1,77 @@ +import 'package:sigap/src/features/map/models/locations_model.dart'; + +class EventModel { + final String id; + final String name; + final String? description; + final String code; + final DateTime createdAt; + final String userId; + final List? locations; + + EventModel({ + required this.id, + required this.name, + this.description, + required this.code, + required this.createdAt, + required this.userId, + this.locations, + }); + + // Create an EventModel instance from a JSON object + factory EventModel.fromJson(Map json) { + return EventModel( + id: json['id'] as String, + name: json['name'] as String, + description: json['description'] as String?, + code: json['code'] as String, + createdAt: DateTime.parse(json['created_at'] as String), + userId: json['user_id'] as String, + locations: + json['locations'] != null + ? (json['locations'] as List) + .map((e) => LocationModel.fromJson(e as Map)) + .toList() + : null, + ); + } + + // Convert an EventModel instance to a JSON object + Map toJson() { + return { + 'id': id, + 'name': name, + 'description': description, + 'code': code, + 'created_at': createdAt.toIso8601String(), + 'user_id': userId, + }; + } + + // Create a copy of the EventModel with updated fields + EventModel copyWith({ + String? id, + String? name, + String? description, + String? code, + DateTime? createdAt, + String? userId, + List? locations, + }) { + return EventModel( + id: id ?? this.id, + name: name ?? this.name, + description: description ?? this.description, + code: code ?? this.code, + createdAt: createdAt ?? this.createdAt, + userId: userId ?? this.userId, + locations: locations ?? this.locations, + ); + } + + @override + String toString() { + return 'EventModel(id: $id, name: $name, code: $code)'; + } +} diff --git a/sigap-mobile/lib/src/features/panic-button/models/evidences_model.dart b/sigap-mobile/lib/src/features/panic-button/models/evidences_model.dart new file mode 100644 index 0000000..085f161 --- /dev/null +++ b/sigap-mobile/lib/src/features/panic-button/models/evidences_model.dart @@ -0,0 +1,92 @@ +import 'package:sigap/src/features/panic-button/models/incident_logs_model.dart'; + +class EvidenceModel { + final String id; + final String incidentId; + final String type; + final String url; + final String? description; + final String? caption; + final Map? metadata; + final DateTime? uploadedAt; + final IncidentLogModel? incident; + + EvidenceModel({ + required this.id, + required this.incidentId, + required this.type, + required this.url, + this.description, + this.caption, + this.metadata, + this.uploadedAt, + this.incident, + }); + + // Create an EvidenceModel instance from a JSON object + factory EvidenceModel.fromJson(Map json) { + return EvidenceModel( + id: json['id'] as String, + incidentId: json['incident_id'] as String, + type: json['type'] as String, + url: json['url'] as String, + description: json['description'] as String?, + caption: json['caption'] as String?, + metadata: json['metadata'] as Map?, + uploadedAt: + json['uploaded_at'] != null + ? DateTime.parse(json['uploaded_at'] as String) + : null, + incident: + json['incident'] != null + ? IncidentLogModel.fromJson( + json['incident'] as Map, + ) + : null, + ); + } + + // Convert an EvidenceModel instance to a JSON object + Map toJson() { + return { + 'id': id, + 'incident_id': incidentId, + 'type': type, + 'url': url, + 'description': description, + 'caption': caption, + 'metadata': metadata, + 'uploaded_at': uploadedAt?.toIso8601String(), + }; + } + + // Create a copy of the EvidenceModel with updated fields + EvidenceModel copyWith({ + String? id, + String? incidentId, + String? type, + String? url, + String? description, + String? caption, + Map? metadata, + DateTime? uploadedAt, + IncidentLogModel? incident, + }) { + return EvidenceModel( + id: id ?? this.id, + incidentId: incidentId ?? this.incidentId, + type: type ?? this.type, + url: url ?? this.url, + description: description ?? this.description, + caption: caption ?? this.caption, + metadata: metadata ?? this.metadata, + uploadedAt: uploadedAt ?? this.uploadedAt, + incident: incident ?? this.incident, + ); + } + + @override + String toString() { + return 'EvidenceModel(id: $id, type: $type)'; + } +} diff --git a/sigap-mobile/lib/src/features/panic-button/models/incident_logs_model.dart b/sigap-mobile/lib/src/features/panic-button/models/incident_logs_model.dart new file mode 100644 index 0000000..d5f8024 --- /dev/null +++ b/sigap-mobile/lib/src/features/panic-button/models/incident_logs_model.dart @@ -0,0 +1,107 @@ +import 'package:sigap/src/features/panic-button/models/evidences_model.dart'; + +class IncidentLogModel { + final String id; + final String userId; + final String locationId; + final String categoryId; + final String? description; + final String? source; + final DateTime time; + final bool? verified; + final DateTime? createdAt; + final DateTime? updatedAt; + final List? evidence; + + IncidentLogModel({ + required this.id, + required this.userId, + required this.locationId, + required this.categoryId, + this.description, + this.source = 'manual', + required this.time, + this.verified = false, + this.createdAt, + this.updatedAt, + this.evidence, + }); + + // Create an IncidentLogModel instance from a JSON object + factory IncidentLogModel.fromJson(Map json) { + return IncidentLogModel( + id: json['id'] as String, + userId: json['user_id'] as String, + locationId: json['location_id'] as String, + categoryId: json['category_id'] as String, + description: json['description'] as String?, + source: json['source'] as String? ?? 'manual', + time: DateTime.parse(json['time'] as String), + verified: json['verified'] as bool? ?? false, + createdAt: + json['created_at'] != null + ? DateTime.parse(json['created_at']) + : null, + updatedAt: + json['updated_at'] != null + ? DateTime.parse(json['updated_at']) + : null, + evidence: + json['evidence'] != null + ? (json['evidence'] as List) + .map((e) => EvidenceModel.fromJson(e as Map)) + .toList() + : null, + ); + } + + // Convert an IncidentLogModel instance to a JSON object + Map toJson() { + return { + 'id': id, + 'user_id': userId, + 'location_id': locationId, + 'category_id': categoryId, + 'description': description, + 'source': source, + 'time': time.toIso8601String(), + 'verified': verified, + 'created_at': createdAt?.toIso8601String(), + 'updated_at': updatedAt?.toIso8601String(), + }; + } + + // Create a copy of the IncidentLogModel with updated fields + IncidentLogModel copyWith({ + String? id, + String? userId, + String? locationId, + String? categoryId, + String? description, + String? source, + DateTime? time, + bool? verified, + DateTime? createdAt, + DateTime? updatedAt, + List? evidence, + }) { + return IncidentLogModel( + id: id ?? this.id, + userId: userId ?? this.userId, + locationId: locationId ?? this.locationId, + categoryId: categoryId ?? this.categoryId, + description: description ?? this.description, + source: source ?? this.source, + time: time ?? this.time, + verified: verified ?? this.verified, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + evidence: evidence ?? this.evidence, + ); + } + + @override + String toString() { + return 'IncidentLogModel(id: $id, locationId: $locationId, time: $time)'; + } +} diff --git a/sigap-mobile/lib/src/features/panic-button/models/index.dart b/sigap-mobile/lib/src/features/panic-button/models/index.dart new file mode 100644 index 0000000..81a00e2 --- /dev/null +++ b/sigap-mobile/lib/src/features/panic-button/models/index.dart @@ -0,0 +1,6 @@ +export 'crime_incidents_model.dart'; +export 'crimes_model.dart'; +export 'events_model.dart'; +export 'evidences_model.dart'; +export 'incident_logs_model.dart'; +export 'sessions_model.dart'; diff --git a/sigap-mobile/lib/src/features/panic-button/models/sessions_model.dart b/sigap-mobile/lib/src/features/panic-button/models/sessions_model.dart new file mode 100644 index 0000000..8757a94 --- /dev/null +++ b/sigap-mobile/lib/src/features/panic-button/models/sessions_model.dart @@ -0,0 +1,71 @@ +import 'package:sigap/src/features/panic-button/models/events_model.dart'; + +enum SessionStatus { active, inactive } + +class SessionsModel { + final String id; + final String userId; + final String eventId; + final SessionStatus status; + final DateTime createdAt; + final EventModel event; + + SessionsModel({ + required this.id, + required this.userId, + required this.eventId, + required this.status, + required this.createdAt, + required this.event, + }); + + // Create a SessionsModel instance from a JSON object + factory SessionsModel.fromJson(Map json) { + return SessionsModel( + id: json['id'] as String, + userId: json['user_id'] as String, + eventId: json['event_id'] as String, + status: SessionStatus.values.firstWhere( + (e) => e.toString() == 'SessionStatus.${json['status']}', + ), + createdAt: DateTime.parse(json['created_at'] as String), + event: EventModel.fromJson(json['event'] as Map), + ); + } + + // Convert a SessionsModel instance to a JSON object + Map toJson() { + return { + 'id': id, + 'user_id': userId, + 'event_id': eventId, + 'status': status.toString().split('.').last, + 'created_at': createdAt.toIso8601String(), + 'event': event.toJson(), + }; + } + + // Create a copy of the SessionsModel with updated fields + SessionsModel copyWith({ + String? id, + String? userId, + String? eventId, + SessionStatus? status, + DateTime? createdAt, + EventModel? event, + }) { + return SessionsModel( + id: id ?? this.id, + userId: userId ?? this.userId, + eventId: eventId ?? this.eventId, + status: status ?? this.status, + createdAt: createdAt ?? this.createdAt, + event: event ?? this.event, + ); + } + + @override + String toString() { + return 'SessionsModel(id: $id, userId: $userId, eventId: $eventId, status: $status)'; + } +} diff --git a/sigap-mobile/lib/src/features/personalization/models/index.dart b/sigap-mobile/lib/src/features/personalization/models/index.dart index 36b0a8a..fe4b3fb 100644 --- a/sigap-mobile/lib/src/features/personalization/models/index.dart +++ b/sigap-mobile/lib/src/features/personalization/models/index.dart @@ -1,4 +1,6 @@ -export 'users_model.dart'; export 'officers_model.dart'; +export 'permissions_model.dart'; export 'profile_model.dart'; +export 'resources_model.dart'; export 'roles_model.dart'; +export 'users_model.dart'; diff --git a/sigap-mobile/lib/src/features/personalization/models/permissions_model.dart b/sigap-mobile/lib/src/features/personalization/models/permissions_model.dart new file mode 100644 index 0000000..04a11a9 --- /dev/null +++ b/sigap-mobile/lib/src/features/personalization/models/permissions_model.dart @@ -0,0 +1,84 @@ +import 'package:sigap/src/features/personalization/models/resources_model.dart'; +import 'package:sigap/src/features/personalization/models/roles_model.dart'; + +class PermissionModel { + final String id; + final String action; + final String resourceId; + final String roleId; + final DateTime createdAt; + final DateTime updatedAt; + final ResourceModel? resource; + final RoleModel? role; + + PermissionModel({ + required this.id, + required this.action, + required this.resourceId, + required this.roleId, + required this.createdAt, + required this.updatedAt, + this.resource, + this.role, + }); + + // Create a PermissionModel instance from a JSON object + factory PermissionModel.fromJson(Map json) { + return PermissionModel( + id: json['id'] as String, + action: json['action'] as String, + resourceId: json['resource_id'] as String, + roleId: json['role_id'] as String, + createdAt: DateTime.parse(json['created_at'] as String), + updatedAt: DateTime.parse(json['updated_at'] as String), + resource: + json['resource'] != null + ? ResourceModel.fromJson(json['resource'] as Map) + : null, + role: + json['role'] != null + ? RoleModel.fromJson(json['role'] as Map) + : null, + ); + } + + // Convert a PermissionModel instance to a JSON object + Map toJson() { + return { + 'id': id, + 'action': action, + 'resource_id': resourceId, + 'role_id': roleId, + 'created_at': createdAt.toIso8601String(), + 'updated_at': updatedAt.toIso8601String(), + }; + } + + // Create a copy of the PermissionModel with updated fields + PermissionModel copyWith({ + String? id, + String? action, + String? resourceId, + String? roleId, + DateTime? createdAt, + DateTime? updatedAt, + ResourceModel? resource, + RoleModel? role, + }) { + return PermissionModel( + id: id ?? this.id, + action: action ?? this.action, + resourceId: resourceId ?? this.resourceId, + roleId: roleId ?? this.roleId, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + resource: resource ?? this.resource, + role: role ?? this.role, + ); + } + + @override + String toString() { + return 'PermissionModel(id: $id, action: $action)'; + } +} diff --git a/sigap-mobile/lib/src/features/personalization/models/resources_model.dart b/sigap-mobile/lib/src/features/personalization/models/resources_model.dart new file mode 100644 index 0000000..a64afcc --- /dev/null +++ b/sigap-mobile/lib/src/features/personalization/models/resources_model.dart @@ -0,0 +1,97 @@ +import 'package:sigap/src/features/personalization/models/permissions_model.dart'; + +class ResourceModel { + final String id; + final String name; + final String? type; + final String? description; + final String? instanceRole; + final String? relations; + final Map? attributes; + final DateTime createdAt; + final DateTime updatedAt; + final List? permissions; + + ResourceModel({ + required this.id, + required this.name, + this.type, + this.description, + this.instanceRole, + this.relations, + this.attributes, + required this.createdAt, + required this.updatedAt, + this.permissions, + }); + + // Create a ResourceModel instance from a JSON object + factory ResourceModel.fromJson(Map json) { + return ResourceModel( + id: json['id'] as String, + name: json['name'] as String, + type: json['type'] as String?, + description: json['description'] as String?, + instanceRole: json['instance_role'] as String?, + relations: json['relations'] as String?, + attributes: json['attributes'] as Map?, + createdAt: DateTime.parse(json['created_at'] as String), + updatedAt: DateTime.parse(json['updated_at'] as String), + permissions: + json['permissions'] != null + ? (json['permissions'] as List) + .map( + (e) => PermissionModel.fromJson(e as Map), + ) + .toList() + : null, + ); + } + + // Convert a ResourceModel instance to a JSON object + Map toJson() { + return { + 'id': id, + 'name': name, + 'type': type, + 'description': description, + 'instance_role': instanceRole, + 'relations': relations, + 'attributes': attributes, + 'created_at': createdAt.toIso8601String(), + 'updated_at': updatedAt.toIso8601String(), + }; + } + + // Create a copy of the ResourceModel with updated fields + ResourceModel copyWith({ + String? id, + String? name, + String? type, + String? description, + String? instanceRole, + String? relations, + Map? attributes, + DateTime? createdAt, + DateTime? updatedAt, + List? permissions, + }) { + return ResourceModel( + id: id ?? this.id, + name: name ?? this.name, + type: type ?? this.type, + description: description ?? this.description, + instanceRole: instanceRole ?? this.instanceRole, + relations: relations ?? this.relations, + attributes: attributes ?? this.attributes, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + permissions: permissions ?? this.permissions, + ); + } + + @override + String toString() { + return 'ResourceModel(id: $id, name: $name)'; + } +} diff --git a/sigap-mobile/lib/src/shared/widgets/text/custom_text_field.dart b/sigap-mobile/lib/src/shared/widgets/text/custom_text_field.dart index 31ce417..8d0920f 100644 --- a/sigap-mobile/lib/src/shared/widgets/text/custom_text_field.dart +++ b/sigap-mobile/lib/src/shared/widgets/text/custom_text_field.dart @@ -10,6 +10,7 @@ class CustomTextField extends StatelessWidget { final TextInputType keyboardType; final String? errorText; final bool autofocus; + final int maxLines; final TextInputAction textInputAction; final Function(String)? onChanged; @@ -23,6 +24,7 @@ class CustomTextField extends StatelessWidget { this.keyboardType = TextInputType.text, this.errorText, this.autofocus = false, + this.maxLines = 1, this.textInputAction = TextInputAction.next, this.onChanged, }); @@ -48,6 +50,7 @@ class CustomTextField extends StatelessWidget { keyboardType: keyboardType, autofocus: autofocus, textInputAction: textInputAction, + maxLines: maxLines, onChanged: onChanged, style: TextStyle(color: TColors.textPrimary, fontSize: 16), decoration: InputDecoration( diff --git a/sigap-mobile/lib/src/utils/validators/validation.dart b/sigap-mobile/lib/src/utils/validators/validation.dart index 80c4b79..bc76c61 100644 --- a/sigap-mobile/lib/src/utils/validators/validation.dart +++ b/sigap-mobile/lib/src/utils/validators/validation.dart @@ -196,7 +196,7 @@ class TValidators { return null; } - + static String? validateNRP(String? value) { return validateUserInput('NRP', value, 50, minLength: 5); } diff --git a/sigap-mobile/linux/flutter/generated_plugin_registrant.cc b/sigap-mobile/linux/flutter/generated_plugin_registrant.cc index e12c657..ea3bde6 100644 --- a/sigap-mobile/linux/flutter/generated_plugin_registrant.cc +++ b/sigap-mobile/linux/flutter/generated_plugin_registrant.cc @@ -7,6 +7,7 @@ #include "generated_plugin_registrant.h" #include +#include #include #include @@ -14,6 +15,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); file_selector_plugin_register_with_registrar(file_selector_linux_registrar); + g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); + flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); g_autoptr(FlPluginRegistrar) gtk_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin"); gtk_plugin_register_with_registrar(gtk_registrar); diff --git a/sigap-mobile/linux/flutter/generated_plugins.cmake b/sigap-mobile/linux/flutter/generated_plugins.cmake index 4453582..0420466 100644 --- a/sigap-mobile/linux/flutter/generated_plugins.cmake +++ b/sigap-mobile/linux/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_linux + flutter_secure_storage_linux gtk url_launcher_linux ) diff --git a/sigap-mobile/macos/Flutter/GeneratedPluginRegistrant.swift b/sigap-mobile/macos/Flutter/GeneratedPluginRegistrant.swift index a9d1fa6..ba93e15 100644 --- a/sigap-mobile/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/sigap-mobile/macos/Flutter/GeneratedPluginRegistrant.swift @@ -10,8 +10,10 @@ import connectivity_plus import file_picker import file_selector_macos import flutter_local_notifications +import flutter_secure_storage_macos import geolocator_apple import google_sign_in_ios +import local_auth_darwin import path_provider_foundation import shared_preferences_foundation import url_launcher_macos @@ -22,8 +24,10 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) + FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin")) + FLALocalAuthPlugin.register(with: registry.registrar(forPlugin: "FLALocalAuthPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) diff --git a/sigap-mobile/pubspec.lock b/sigap-mobile/pubspec.lock index 5bc39dd..95b1a2e 100644 --- a/sigap-mobile/pubspec.lock +++ b/sigap-mobile/pubspec.lock @@ -427,6 +427,54 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.1" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.dev" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" flutter_test: dependency: "direct dev" description: flutter @@ -453,6 +501,38 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.2" + geocoding: + dependency: "direct main" + description: + name: geocoding + sha256: d580c801cba9386b4fac5047c4c785a4e19554f46be42f4f5e5b7deacd088a66 + url: "https://pub.dev" + source: hosted + version: "3.0.0" + geocoding_android: + dependency: transitive + description: + name: geocoding_android + sha256: "1b13eca79b11c497c434678fed109c2be020b158cec7512c848c102bc7232603" + url: "https://pub.dev" + source: hosted + version: "3.3.1" + geocoding_ios: + dependency: transitive + description: + name: geocoding_ios + sha256: "94ddba60387501bd1c11e18dca7c5a9e8c645d6e3da9c38b9762434941870c24" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + geocoding_platform_interface: + dependency: transitive + description: + name: geocoding_platform_interface + sha256: "8c2c8226e5c276594c2e18bfe88b19110ed770aeb7c1ab50ede570be8b92229b" + url: "https://pub.dev" + source: hosted + version: "3.2.0" geolocator: dependency: "direct main" description: @@ -781,6 +861,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.1" + local_auth: + dependency: "direct main" + description: + name: local_auth + sha256: "434d854cf478f17f12ab29a76a02b3067f86a63a6d6c4eb8fbfdcfe4879c1b7b" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + local_auth_android: + dependency: transitive + description: + name: local_auth_android + sha256: "63ad7ca6396290626dc0cb34725a939e4cfe965d80d36112f08d49cf13a8136e" + url: "https://pub.dev" + source: hosted + version: "1.0.49" + local_auth_darwin: + dependency: transitive + description: + name: local_auth_darwin + sha256: "630996cd7b7f28f5ab92432c4b35d055dd03a747bc319e5ffbb3c4806a3e50d2" + url: "https://pub.dev" + source: hosted + version: "1.4.3" + local_auth_platform_interface: + dependency: transitive + description: + name: local_auth_platform_interface + sha256: "1b842ff177a7068442eae093b64abe3592f816afd2a533c0ebcdbe40f9d2075a" + url: "https://pub.dev" + source: hosted + version: "1.0.10" + local_auth_windows: + dependency: transitive + description: + name: local_auth_windows + sha256: bc4e66a29b0fdf751aafbec923b5bed7ad6ed3614875d8151afe2578520b2ab5 + url: "https://pub.dev" + source: hosted + version: "1.0.11" logger: dependency: "direct main" description: diff --git a/sigap-mobile/pubspec.yaml b/sigap-mobile/pubspec.yaml index 184c83e..b573220 100644 --- a/sigap-mobile/pubspec.yaml +++ b/sigap-mobile/pubspec.yaml @@ -68,6 +68,7 @@ dependencies: flutter_map: latlong2: geolocator: + geocoding: # Icons iconsax: @@ -82,7 +83,9 @@ dependencies: supabase_flutter: # Authentication - google_sign_in: + google_sign_in: + local_auth: + flutter_secure_storage: # API Services dio: diff --git a/sigap-mobile/schema.prisma b/sigap-mobile/schema.prisma new file mode 100644 index 0000000..a94adb8 --- /dev/null +++ b/sigap-mobile/schema.prisma @@ -0,0 +1,511 @@ +generator client { + provider = "prisma-client-js" + previewFeatures = ["postgresqlExtensions"] +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") + directUrl = env("DIRECT_URL") + extensions = [pgcrypto, postgis, uuid_ossp(map: "uuid-ossp", schema: "extensions")] +} + +model profiles { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + user_id String @unique @db.Uuid + nik String @unique @default("") @db.VarChar(100) + avatar String? @db.VarChar(355) + username String? @unique @db.VarChar(255) + first_name String? @db.VarChar(255) + last_name String? @db.VarChar(255) + bio String? @db.VarChar + address Json? @db.Json + birth_date DateTime? + users users @relation(fields: [user_id], references: [id]) + + @@index([nik], map: "idx_profiles_nik") + @@index([user_id]) + @@index([username]) +} + +model users { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + roles_id String @db.Uuid + email String @unique @db.VarChar(255) + phone String? @unique @db.VarChar(20) + encrypted_password String? @db.VarChar(255) + invited_at DateTime? @db.Timestamptz(6) + confirmed_at DateTime? @db.Timestamptz(6) + email_confirmed_at DateTime? @db.Timestamptz(6) + recovery_sent_at DateTime? @db.Timestamptz(6) + last_sign_in_at DateTime? @db.Timestamptz(6) + app_metadata Json? + user_metadata Json? + created_at DateTime @default(now()) @db.Timestamptz(6) + updated_at DateTime @default(now()) @db.Timestamptz(6) + is_banned Boolean @default(false) + spoofing_attempts Int @default(0) + panic_strike Int @default(0) + banned_reason String? @db.VarChar(255) + banned_until DateTime? @db.Timestamptz(6) + is_anonymous Boolean @default(false) + events events[] + incident_logs incident_logs[] + location_logs location_logs[] + profile profiles? + sessions sessions[] + role roles @relation(fields: [roles_id], references: [id]) + panic_button_logs panic_button_logs[] + + @@index([is_anonymous]) + @@index([created_at]) + @@index([updated_at]) +} + +model roles { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + name String @unique @db.VarChar(255) + description String? + created_at DateTime @default(now()) @db.Timestamptz(6) + updated_at DateTime @default(now()) @db.Timestamptz(6) + permissions permissions[] + users users[] + officers officers[] +} + +model sessions { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + user_id String @db.Uuid + event_id String @db.Uuid + status session_status @default(active) + created_at DateTime @default(now()) @db.Timestamptz(6) + event events @relation(fields: [event_id], references: [id]) + user users @relation(fields: [user_id], references: [id]) + + @@index([user_id], map: "idx_sessions_user_id") + @@index([event_id], map: "idx_sessions_event_id") + @@index([status], map: "idx_sessions_status") +} + +model events { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + name String @db.VarChar(255) + description String? @db.VarChar(255) + code String @unique @default(nanoid(10)) + created_at DateTime @default(now()) @db.Timestamptz(6) + user_id String @db.Uuid + users users @relation(fields: [user_id], references: [id]) + locations locations[] + sessions sessions[] + + @@index([name], map: "idx_events_name") + @@index([code], map: "idx_events_code") + @@index([id], map: "idx_events_id") +} + +model resources { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + name String @unique @db.VarChar(255) + type String? + description String? + instance_role String? + relations String? + attributes Json? + created_at DateTime @default(now()) @db.Timestamptz(6) + updated_at DateTime @default(now()) @db.Timestamptz(6) + permissions permissions[] +} + +model permissions { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + action String + resource_id String @db.Uuid + role_id String @db.Uuid + created_at DateTime @default(now()) @db.Timestamptz(6) + updated_at DateTime @updatedAt @db.Timestamptz(6) + resource resources @relation(fields: [resource_id], references: [id]) + role roles @relation(fields: [role_id], references: [id]) +} + +model cities { + id String @id @db.VarChar(20) + name String @db.VarChar(100) + created_at DateTime? @default(now()) @db.Timestamptz(6) + updated_at DateTime? @default(now()) @db.Timestamptz(6) + districts districts[] + units units[] + + @@index([name], map: "idx_cities_name") +} + +model crime_incidents { + id String @id @db.VarChar(20) + crime_id String @db.VarChar(20) + crime_category_id String @db.VarChar(20) + location_id String @db.Uuid + description String + victim_count Int + status crime_status? @default(open) + created_at DateTime? @default(now()) @db.Timestamptz(6) + updated_at DateTime? @default(now()) @db.Timestamptz(6) + timestamp DateTime @db.Timestamptz(6) + crime_categories crime_categories @relation(fields: [crime_category_id], references: [id], onUpdate: NoAction) + crimes crimes @relation(fields: [crime_id], references: [id], onDelete: NoAction, onUpdate: NoAction) + locations locations @relation(fields: [location_id], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@index([crime_category_id], map: "idx_crime_incidents_crime_category_id") + @@index([timestamp], map: "idx_crime_incidents_date") + @@index([location_id], map: "idx_crime_incidents_location_id") + @@index([crime_id], map: "idx_crime_incidents_crime_id") + @@index([status], map: "idx_crime_incidents_status") +} + +model crime_categories { + id String @id @db.VarChar(20) + name String @db.VarChar(255) + description String + created_at DateTime? @default(now()) @db.Timestamptz(6) + updated_at DateTime? @default(now()) @db.Timestamptz(6) + type String? @db.VarChar(100) + crime_incidents crime_incidents[] + incident_logs incident_logs[] + + @@index([name], map: "idx_crime_categories_name") +} + +model crimes { + id String @id @db.VarChar(20) + district_id String @db.VarChar(20) + created_at DateTime? @default(now()) @db.Timestamptz(6) + level crime_rates @default(low) + method String? @db.VarChar(100) + month Int? + number_of_crime Int @default(0) + score Float @default(0) + updated_at DateTime? @default(now()) @db.Timestamptz(6) + year Int? + source_type String? @db.VarChar(100) + crime_cleared Int @default(0) + avg_crime Float @default(0) + crime_incidents crime_incidents[] + districts districts @relation(fields: [district_id], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@index([district_id, year, month], map: "idx_crimes_district_id_year_month") + @@index([month, year], map: "idx_crimes_month_year") + @@index([month], map: "idx_crimes_month") + @@index([year], map: "idx_crimes_year") + @@index([district_id, month], map: "idx_crimes_district_id_month") +} + +model demographics { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + district_id String @db.VarChar(20) + population Int + number_of_unemployed Int + population_density Float + year Int + created_at DateTime? @default(now()) @db.Timestamptz(6) + updated_at DateTime? @default(now()) @db.Timestamptz(6) + districts districts @relation(fields: [district_id], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@unique([district_id, year]) + @@index([year], map: "idx_demographics_year") +} + +model districts { + id String @id @db.VarChar(20) + city_id String @db.VarChar(20) + name String @db.VarChar(100) + created_at DateTime? @default(now()) @db.Timestamptz(6) + updated_at DateTime? @default(now()) @db.Timestamptz(6) + crimes crimes[] + demographics demographics[] + cities cities @relation(fields: [city_id], references: [id], onDelete: Cascade, onUpdate: NoAction) + geographics geographics[] + locations locations[] + units units? + + @@index([city_id], map: "idx_districts_city_id") + @@index([name], map: "idx_districts_name") +} + +model locations { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + district_id String @db.VarChar(20) + event_id String @db.Uuid + address String? @db.VarChar(255) + type String? @db.VarChar(100) + latitude Float + longitude Float + land_area Float? + polygon Unsupported("geometry")? + geometry Unsupported("geometry")? + created_at DateTime? @default(now()) @db.Timestamptz(6) + updated_at DateTime? @default(now()) @db.Timestamptz(6) + location Unsupported("geography") + distance_to_unit Float? + crime_incidents crime_incidents[] + incident_logs incident_logs[] + districts districts @relation(fields: [district_id], references: [id], onDelete: Cascade, onUpdate: NoAction) + event events @relation(fields: [event_id], references: [id]) + patrol_units patrol_units[] + + @@index([district_id], map: "idx_locations_district_id") + @@index([type], map: "idx_locations_type") + @@index([location], map: "idx_locations_geography", type: Gist) + @@index([location], map: "idx_locations_location_gist", type: Gist) +} + +model incident_logs { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + user_id String @db.Uuid + location_id String @db.Uuid + category_id String @db.VarChar(20) + description String? + source String? @default("manual") + time DateTime @db.Timestamptz(6) + verified Boolean? @default(false) + created_at DateTime? @default(now()) @db.Timestamptz(6) + updated_at DateTime? @default(now()) @db.Timestamptz(6) + crime_categories crime_categories @relation(fields: [category_id], references: [id], map: "fk_incident_category") + locations locations @relation(fields: [location_id], references: [id], onDelete: Cascade, onUpdate: NoAction) + user users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction) + evidence evidence[] + panic_button_logs panic_button_logs[] + + @@index([category_id], map: "idx_incident_logs_category_id") + @@index([time], map: "idx_incident_logs_time") +} + +model evidence { + id String @id @unique @db.VarChar(20) + incident_id String @db.Uuid + type String @db.VarChar(50) // contoh: photo, video, document, images + url String @db.Text + description String? @db.VarChar(255) + caption String? @db.VarChar(255) + metadata Json? + uploaded_at DateTime? @default(now()) @db.Timestamptz(6) + + incident incident_logs @relation(fields: [incident_id], references: [id], onDelete: Cascade) + + @@index([incident_id], map: "idx_evidence_incident_id") +} + +model units { + code_unit String @id @unique @db.VarChar(20) + district_id String? @unique @db.VarChar(20) + name String @db.VarChar(100) + description String? + type unit_type + created_at DateTime? @default(now()) @db.Timestamptz(6) + updated_at DateTime? @default(now()) @db.Timestamptz(6) + address String? + land_area Float? + latitude Float + longitude Float + location Unsupported("geography") + city_id String @db.VarChar(20) + phone String? + unit_statistics unit_statistics[] + cities cities @relation(fields: [city_id], references: [id], onDelete: Cascade, onUpdate: NoAction) + districts districts? @relation(fields: [district_id], references: [id], onDelete: Cascade, onUpdate: NoAction) + officers officers[] + patrol_units patrol_units[] + + @@index([name], map: "idx_units_name") + @@index([type], map: "idx_units_type") + @@index([code_unit], map: "idx_units_code_unit") + @@index([district_id], map: "idx_units_district_id") + @@index([location], map: "idx_unit_location", type: Gist) + @@index([district_id, location], map: "idx_units_location_district") + @@index([location], map: "idx_units_location_gist", type: Gist) + @@index([location], type: Gist) + @@index([location], map: "units_location_idx1", type: Gist) + @@index([location], map: "units_location_idx2", type: Gist) +} + +model patrol_units { + id String @id @unique @db.VarChar(100) + unit_id String @db.VarChar(20) + location_id String @db.Uuid + name String @db.VarChar(100) + type String @db.VarChar(50) + status String @db.VarChar(50) + radius Float + created_at DateTime @default(now()) @db.Timestamptz(6) + + members officers[] + location locations @relation(fields: [location_id], references: [id], onDelete: Cascade, onUpdate: NoAction) + unit units @relation(fields: [unit_id], references: [code_unit], onDelete: Cascade, onUpdate: NoAction) + + @@index([unit_id], map: "idx_patrol_units_unit_id") + @@index([location_id], map: "idx_patrol_units_location_id") + @@index([name], map: "idx_patrol_units_name") + @@index([type], map: "idx_patrol_units_type") + @@index([status], map: "idx_patrol_units_status") +} + +model officers { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + unit_id String @db.VarChar(20) + role_id String @db.Uuid + patrol_unit_id String @db.VarChar(100) + nrp String @unique @db.VarChar(100) + name String @db.VarChar(100) + rank String? @db.VarChar(100) + position String? @db.VarChar(100) + phone String? @db.VarChar(100) + email String? @db.VarChar(255) + avatar String? + valid_until DateTime? + qr_code String? + is_banned Boolean @default(false) + panic_strike Int @default(0) + spoofing_attempts Int @default(0) + banned_reason String? @db.VarChar(255) + banned_until DateTime? + created_at DateTime? @default(now()) @db.Timestamptz(6) + updated_at DateTime? @default(now()) @db.Timestamptz(6) + units units @relation(fields: [unit_id], references: [code_unit], onDelete: Cascade, onUpdate: NoAction) + roles roles @relation(fields: [role_id], references: [id], onDelete: Cascade, onUpdate: NoAction) + patrol_units patrol_units? @relation(fields: [patrol_unit_id], references: [id]) + panic_button_logs panic_button_logs[] + + @@index([unit_id], map: "idx_officers_unit_id") + @@index([nrp], map: "idx_officers_nrp") + @@index([name], map: "idx_officers_name") + @@index([rank], map: "idx_officers_rank") + @@index([position], map: "idx_officers_position") +} + +model unit_statistics { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + crime_total Int + crime_cleared Int + percentage Float? + pending Int? + month Int + year Int + created_at DateTime? @default(now()) @db.Timestamptz(6) + updated_at DateTime? @default(now()) @db.Timestamptz(6) + code_unit String @db.VarChar(20) + units units @relation(fields: [code_unit], references: [code_unit], onDelete: Cascade, onUpdate: NoAction) + + @@unique([code_unit, month, year]) + @@index([year, month], map: "idx_unit_statistics_year_month") +} + +model geographics { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + district_id String @db.VarChar(20) + address String? + longitude Float + latitude Float + land_area Float? + polygon Unsupported("geometry")? + geometry Unsupported("geometry")? + created_at DateTime? @default(now()) @db.Timestamptz(6) + updated_at DateTime? @default(now()) @db.Timestamptz(6) + description String? + type String? @db.VarChar(100) + location Unsupported("geography") + year Int? + districts districts @relation(fields: [district_id], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@index([district_id], map: "idx_geographics_district_id") + @@index([type], map: "idx_geographics_type") + @@index([district_id, year], map: "idx_geographics_district_id_year") + @@index([location], map: "idx_geographics_location", type: Gist) +} + +model contact_messages { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + name String? @db.VarChar(255) + email String? @db.VarChar(255) + phone String? @db.VarChar(20) + message_type String? @db.VarChar(50) + message_type_label String? @db.VarChar(50) + message String? + status status_contact_messages @default(new) + created_at DateTime @default(now()) @db.Timestamptz(6) + updated_at DateTime @db.Timestamptz(6) +} + +model logs { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + action String @db.VarChar(100) + entity String @db.VarChar(100) + entity_id String? @db.VarChar(100) + changes Json? + user_id String? @db.VarChar(100) + ip_address String? @db.VarChar(100) + user_agent String? @db.VarChar(255) + created_at DateTime @default(now()) @db.Timestamptz(6) + + @@index([entity]) + @@index([user_id]) +} + +model panic_button_logs { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + user_id String @db.Uuid + officer_id String? @db.Uuid + incident_id String @db.Uuid + timestamp DateTime @db.Timestamptz(6) + users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction) + officers officers? @relation(fields: [officer_id], references: [id], onDelete: Cascade, onUpdate: NoAction) + incidents incident_logs @relation(fields: [incident_id], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@index([user_id], map: "idx_panic_buttons_user_id") +} + +model location_logs { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + user_id String @db.Uuid + latitude Float + longitude Float + location Unsupported("geography") + timestamp DateTime @db.Timestamptz(6) + description String? @db.VarChar(255) + created_at DateTime @default(now()) @db.Timestamptz(6) + updated_at DateTime @default(now()) @db.Timestamptz(6) + users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@index([timestamp], map: "idx_location_logs_timestamp") + @@index([user_id], map: "idx_location_logs_user_id") +} + +enum session_status { + active + completed +} + +enum status_contact_messages { + new + read + replied + closed +} + +enum crime_rates { + low + medium + high + critical +} + +enum crime_status { + open + closed + under_investigation + resolved + unresolved +} + +enum unit_type { + polda + polsek + polres + other +} diff --git a/sigap-mobile/windows/flutter/generated_plugin_registrant.cc b/sigap-mobile/windows/flutter/generated_plugin_registrant.cc index 534bd12..b483282 100644 --- a/sigap-mobile/windows/flutter/generated_plugin_registrant.cc +++ b/sigap-mobile/windows/flutter/generated_plugin_registrant.cc @@ -9,7 +9,9 @@ #include #include #include +#include #include +#include #include #include @@ -20,8 +22,12 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); + FlutterSecureStorageWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); GeolocatorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("GeolocatorWindows")); + LocalAuthPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("LocalAuthPlugin")); PermissionHandlerWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/sigap-mobile/windows/flutter/generated_plugins.cmake b/sigap-mobile/windows/flutter/generated_plugins.cmake index 587c1f3..d4437dc 100644 --- a/sigap-mobile/windows/flutter/generated_plugins.cmake +++ b/sigap-mobile/windows/flutter/generated_plugins.cmake @@ -6,7 +6,9 @@ list(APPEND FLUTTER_PLUGIN_LIST app_links connectivity_plus file_selector_windows + flutter_secure_storage_windows geolocator_windows + local_auth_windows permission_handler_windows url_launcher_windows ) diff --git a/sigap-website/prisma/backups/function.sql b/sigap-website/prisma/backups/function.sql index 2e66206..e8436ff 100644 --- a/sigap-website/prisma/backups/function.sql +++ b/sigap-website/prisma/backups/function.sql @@ -98,4 +98,12 @@ CREATE INDEX IF NOT EXISTS idx_units_location_district ON units (district_id, lo -- Analisis tabel setelah membuat index ANALYZE units; -ANALYZE locations; \ No newline at end of file +ANALYZE locations; + +create or replace function delete_user() +returns void as +$$ +begin +delete from auth.users where id = (select auth.uid()); +end; +$$ language plpgsql security definer set search_path = ''; \ No newline at end of file