first commit

This commit is contained in:
vergiLgood1 2025-08-05 14:30:36 +07:00
parent 7c44fa86bd
commit aa675f04f9
1698 changed files with 658456 additions and 92782 deletions

View File

@ -2,26 +2,27 @@
# https://app.supabase.com/project/_/settings/api # https://app.supabase.com/project/_/settings/api
# # Supabase Production URL # # Supabase Production URL
# SUPABASE_URL=https://bhfzrlgxqkbkjepvqeva.supabase.co SUPABASE_URL=https://htwoxhaigocfxxrmdivp.supabase.co
# SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImJoZnpybGd4cWtia2plcHZxZXZhIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDY1MTU2ODUsImV4cCI6MjA2MjA5MTY4NX0.qDe8QNOON5ra6-JSQ-mhBEXdRFxoQGPPifBpB_-5FrU SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imh0d294aGFpZ29jZnh4cm1kaXZwIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDk5ODgyNjQsImV4cCI6MjA2NTU2NDI2NH0.CGjBAdeYfGfazPXkefsxtil_c13cgj3PlbPCRNE2xAU
# SERVICE_ROLE_SECRET="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImNwcGVqcm9leW9uc3F4dWxpbmFqIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTczOTM2MTEyNiwiZXhwIjoyMDU0OTM3MTI2fQ.iYIVeUChLIcC7NRaeJ6dViI9JiUZSMUKufFsDTfAkjA" SERVICE_ROLE_SECRET=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imh0d294aGFpZ29jZnh4cm1kaXZwIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc0OTk4ODI2NCwiZXhwIjoyMDY1NTY0MjY0fQ.k5VeuT-63O8GqKQkO8LbIDkuAkRZyKT1MO_s9Bv0rwo
# SUPABASE_STORAGE_URL="https://cppejroeyonsqxulinaj.supabase.co/storage/v1/object/public"
# # Connect to Supabase via connection pooling SUPABASE_STORAGE_URL="https://cppejroeyonsqxulinaj.supabase.co/storage/v1/object/public"
# DATABASE_URL="postgresql://postgres.bhfzrlgxqkbkjepvqeva:TA-SIGAP2024@aws-0-ap-southeast-1.pooler.supabase.com:6543/postgres?pgbouncer=true"
# # Direct connection to the database. Used for migrations # Connect to Supabase via connection pooling
# DIRECT_URL="postgresql://postgres.bhfzrlgxqkbkjepvqeva:TA-SIGAP2024@aws-0-ap-southeast-1.pooler.supabase.com:5432/postgres" DATABASE_URL="postgresql://postgres.bhfzrlgxqkbkjepvqeva:TA-SIGAP2024@aws-0-ap-southeast-1.pooler.supabase.com:6543/postgres?pgbouncer=true"
# Supabase Local URL # Direct connection to the database. Used for migrations
SUPABASE_URL=http://192.168.1.8:54321 DIRECT_URL="postgresql://postgres.bhfzrlgxqkbkjepvqeva:TA-SIGAP2024@aws-0-ap-southeast-1.pooler.supabase.com:5432/postgres"
SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
SERVICE_ROLE_SECRET=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU # Supabase Locapl URL
# SUPABASE_URL=http://192.168.1.8:54321
# SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:54322/postgres" # SERVICE_ROLE_SECRET=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU
DIRECT_URL="postgresql://postgres:postgres@127.0.0.1:54322/postgres"
# DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:54322/postgres"
# DIRECT_URL="postgresql://postgres:postgres@127.0.0.1:54322/postgres"
# RESEND_API_KEY_TES="re_WtXdegYe_Ey3yiShKfZZtjCyY1agkEaSi" # RESEND_API_KEY_TES="re_WtXdegYe_Ey3yiShKfZZtjCyY1agkEaSi"
RESEND_API_KEY="re_4EyTgztQ_3istGdHQFeSTQsLBtH6oAdub" RESEND_API_KEY="re_4EyTgztQ_3istGdHQFeSTQsLBtH6oAdub"
@ -46,7 +47,7 @@ AZURE_SUBSCRIPTION_KEY="ANeYAEr78MF7HzCEDg53DEHfKZJg19raPeJCubNEZP2tXGD6xREgJQQJ
AZURE_FACE_SUBSCRIPTION_KEY="6pBJKuYEFWHkrCBaZh8hErDci6ZwYnG0tEaE3VA34P8XPAYj4ZvOJQQJ99BEACqBBLyXJ3w3AAAKACOGYqeW" AZURE_FACE_SUBSCRIPTION_KEY="6pBJKuYEFWHkrCBaZh8hErDci6ZwYnG0tEaE3VA34P8XPAYj4ZvOJQQJ99BEACqBBLyXJ3w3AAAKACOGYqeW"
; Aws rekognition ; Aws rekognition
AWS_REGION=ap-southeast-1 AWS_RK_REGION=ap-southeast-1
AWS_ACCESS_KEY=AKIAQCK3TTCVDWT7HK4N AWS_RK_ACCESS_KEY=AKIAQCK3TTCVDWT7HK4N
AWS_SECRET_KEY=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 AWS_RK_SECRET_KEY=hLjsFn1bcxpxpPV2oamYn/INSEgZSaAgdp+A0Mt6

View File

@ -0,0 +1,10 @@
<!-- ---
applyTo: '**'
---
Coding standards, domain knowledge, and preferences that AI should follow.
# Always Uses Constant
I want you to always use constants for values that are used multiple times in the code. This includes colors, strings, and any other values that are reused. Define these constants in a separate file in folder lib\src\utils\constants, such as `lib\src\utils\constants\app_routes.dart, lib\src\utils\constants\enums.dart, lib\src\utils\constants\api_urls.dart,lib\src\utils\constants\colors.dart, lib\src\utils\constants\num_int.dart, lib\src\utils\constants\sizes.dart, lib\src\utils\constants\num_int.dart`, and import them where necessary. if the constant is not defined, create a new constant in the appropriate file. Use descriptive names for constants to make the code more readable and maintainable. Avoid using hardcoded values directly in the code.
Make sure to use the constants consistently across the application, and avoid duplicating values that can be defined as constants. This will help in maintaining the code and making it easier to update values in the future. -->

View File

@ -0,0 +1,9 @@
<!-- ---
applyTo: '**'
---
# Use Theme Color
I want you to use the theme color in the code. The theme color is defined in `lib\src\utils\theme\theme.dart` as `color`. Use this color for all UI elements that require a primary color, such as buttons, headers, and backgrounds.
Make sure to import the colors file where necessary and apply `primaryColor` consistently across the application
make it a flexible for dark and light themes, ensuring that the color adapts to the current theme mode. -->

View File

@ -0,0 +1,13 @@
<!-- ---
applyTo: '**'
---
Coding standards, domain knowledge, and preferences that AI should follow.
# Uses utils if applicable
I want you to use utility functions and classes from the `lib\src\utils` directory whenever applicable. This includes using helper functions for common tasks, constants for repeated values, and any other utility that can simplify the code.
Make sure to import the necessary utility files where required and apply them consistently across the application. This will help in maintaining clean, readable, and efficient code.
Avoid duplicating logic that can be encapsulated in utility functions or classes. If a utility function does not exist for a specific task, consider creating one in the appropriate utility file.
Make sure to follow the naming conventions and structure of the existing utility files to maintain consistency across the codebase.
These are recently edited files. Do not suggest code that has been deleted. -->

View File

@ -0,0 +1,114 @@
# Panic Button Feature Improvements
## Overview
Fixed and optimized the panic button feature to properly handle the emergency session flow and user interactions, particularly the "Stop" button functionality.
## Key Improvements Made
### 1. Enhanced Stop Button Functionality
- **Location**: `lib/src/features/panic/presentation/widgets/emergency_view.dart`
- **Changes**:
- Added confirmation dialog before stopping emergency session
- Improved button styling with better visual feedback
- Added icon and clearer text ("Stop Emergency Session")
- Increased button size and prominence
### 2. Improved Session Status Indicator
- **Location**: `lib/src/features/panic/presentation/widgets/emergency_view.dart`
- **Changes**:
- Enhanced visual design with gradient background
- Added more informative text layout
- Clearer instructions for user actions
- Better responsive design for small screens
### 3. Fixed Session Management Flow
- **Location**: `lib/src/features/panic/presentation/controllers/panic_button_controller.dart`
- **Changes**:
- Added `endSessionAndGoToUpdateIncident()` method with proper state management
- Added `startCooldownAfterIncidentUpdate()` method for post-update cooldown
- Fixed navigation flow to use proper route constants
- Improved error handling and user feedback
### 4. Enhanced Incident Update Process
- **Location**: `lib/src/features/panic/presentation/controllers/incident_update_controller.dart`
- **Changes**:
- Integration with panic button controller for cooldown management
- Automatic cooldown start after successful incident update
- Better success messaging and flow control
### 5. Improved Incident Update Screen UI
- **Location**: `lib/src/features/panic/presentation/screens/incident_update_screen.dart`
- **Changes**:
- Added back button confirmation dialog
- Enhanced header information with clearer instructions
- Added informational note about cooldown period
- Better user guidance throughout the process
### 6. Enhanced Panic Button Visual States
- **Location**: `lib/src/features/panic/presentation/widgets/panic_button.dart`
- **Changes**:
- Improved session content display with emergency icon
- Better text hierarchy and visual feedback
- Enhanced session timer display
## User Flow Improvements
### Before:
1. User activates panic button
2. Session runs for 60 seconds
3. Automatic end → cooldown starts immediately
4. User may miss opportunity to provide details
### After:
1. User activates panic button
2. Session runs with prominent "Stop" button visible
3. User can press "Stop" → confirmation dialog
4. User gets redirected to incident update form
5. After completing form → cooldown starts
6. If user goes back without completing → cooldown starts with warning
## Technical Improvements
### Error Handling
- Added proper error handling for missing controllers
- Graceful degradation when components are not found
- Better user feedback for error scenarios
### State Management
- Proper cleanup of timers and resources
- Clear state transitions between session, update, and cooldown phases
- Synchronized state between related controllers
### Navigation
- Used proper route constants instead of hardcoded strings
- Implemented proper navigation stack management
- Added confirmation dialogs for critical actions
## Benefits
1. **Better User Experience**: Clear visual feedback and instructions
2. **Proper Emergency Flow**: Users are guided to complete incident reports
3. **Reduced Accidental Usage**: Confirmation dialogs prevent mistakes
4. **Improved Data Collection**: Users more likely to provide incident details
5. **System Stability**: Better error handling and state management
## Testing Recommendations
1. Test the complete flow: activate → stop → update → cooldown
2. Test automatic session timeout behavior
3. Test back button behavior in incident update screen
4. Test error scenarios (missing data, network issues)
5. Test on different screen sizes for responsive design
6. Test rapid button pressing and edge cases

View File

@ -11,6 +11,8 @@
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<!-- Phone direct caller -->
<uses-permission android:name="android.permission.CALL_PHONE"/>
<!-- ... --> <!-- ... -->
<application android:label="sigap" <application android:label="sigap"
android:name="${applicationName}" android:name="${applicationName}"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 538 B

After

Width:  |  Height:  |  Size: 556 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 538 B

After

Width:  |  Height:  |  Size: 556 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 538 B

After

Width:  |  Height:  |  Size: 556 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 392 B

After

Width:  |  Height:  |  Size: 353 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 392 B

After

Width:  |  Height:  |  Size: 353 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 392 B

After

Width:  |  Height:  |  Size: 353 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 528 B

After

Width:  |  Height:  |  Size: 558 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 528 B

After

Width:  |  Height:  |  Size: 558 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 528 B

After

Width:  |  Height:  |  Size: 558 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 412 B

After

Width:  |  Height:  |  Size: 366 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 412 B

After

Width:  |  Height:  |  Size: 366 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 412 B

After

Width:  |  Height:  |  Size: 366 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 587 B

After

Width:  |  Height:  |  Size: 674 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 587 B

After

Width:  |  Height:  |  Size: 674 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 587 B

After

Width:  |  Height:  |  Size: 674 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 754 B

After

Width:  |  Height:  |  Size: 909 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 754 B

After

Width:  |  Height:  |  Size: 909 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 754 B

After

Width:  |  Height:  |  Size: 909 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 708 B

After

Width:  |  Height:  |  Size: 952 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 708 B

After

Width:  |  Height:  |  Size: 952 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 708 B

After

Width:  |  Height:  |  Size: 952 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 669 B

After

Width:  |  Height:  |  Size: 677 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 669 B

After

Width:  |  Height:  |  Size: 677 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 669 B

After

Width:  |  Height:  |  Size: 677 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 871 B

After

Width:  |  Height:  |  Size: 913 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 871 B

After

Width:  |  Height:  |  Size: 913 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 871 B

After

Width:  |  Height:  |  Size: 913 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 836 B

After

Width:  |  Height:  |  Size: 965 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 836 B

After

Width:  |  Height:  |  Size: 965 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 836 B

After

Width:  |  Height:  |  Size: 965 B

View File

@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:

View File

@ -0,0 +1,135 @@
// Example implementation of persistent cooldown in panic button controller
import 'dart:async';
import 'package:get/get.dart';
import 'package:shared_preferences/shared_preferences.dart';
class PanicButtonCooldownExample {
// SharedPreferences keys for cooldown persistence
static const String _cooldownEndTimeKey = 'panic_cooldown_end_time';
static const String _cooldownActiveKey = 'panic_cooldown_active';
// Cooldown variables
final RxBool isCooldownActive = false.obs;
final RxInt cooldownSeconds = 0.obs;
Timer? _cooldownTimer;
// Load cooldown state from SharedPreferences
Future<void> _loadCooldownState() async {
try {
final prefs = await SharedPreferences.getInstance();
final cooldownEndTime = prefs.getString(_cooldownEndTimeKey);
if (cooldownEndTime != null) {
final endTime = DateTime.parse(cooldownEndTime);
final now = DateTime.now();
if (now.isBefore(endTime)) {
// Cooldown is still active
isCooldownActive.value = true;
cooldownSeconds.value = endTime.difference(now).inSeconds;
_startCooldownTimer();
} else {
// Cooldown has expired, clean up
await _clearCooldownState();
}
}
} catch (e) {
print('Error loading cooldown state: $e');
}
}
// Save cooldown state to SharedPreferences
Future<void> _saveCooldownState(DateTime endTime) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_cooldownEndTimeKey, endTime.toIso8601String());
await prefs.setBool(_cooldownActiveKey, true);
} catch (e) {
print('Error saving cooldown state: $e');
}
}
// Clear cooldown state from SharedPreferences
Future<void> _clearCooldownState() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_cooldownEndTimeKey);
await prefs.remove(_cooldownActiveKey);
isCooldownActive.value = false;
cooldownSeconds.value = 0;
} catch (e) {
print('Error clearing cooldown state: $e');
}
}
// Start cooldown timer
void _startCooldown() {
int cooldownDuration = 300; // 5 minutes
isCooldownActive.value = true;
cooldownSeconds.value = cooldownDuration;
// Save cooldown end time to persistent storage
final endTime = DateTime.now().add(Duration(seconds: cooldownDuration));
_saveCooldownState(endTime);
_startCooldownTimer();
}
// Start cooldown timer (separated for reuse)
void _startCooldownTimer() {
_cooldownTimer?.cancel();
_cooldownTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
cooldownSeconds.value--;
if (cooldownSeconds.value <= 0) {
_endCooldown();
}
});
}
// End cooldown period
void _endCooldown() {
_cooldownTimer?.cancel();
isCooldownActive.value = false;
cooldownSeconds.value = 0;
// Clear persistent storage
_clearCooldownState();
}
// Force clear cooldown - useful for testing or admin override
Future<void> forceClearCooldown() async {
_cooldownTimer?.cancel();
await _clearCooldownState();
}
// Check if cooldown should still be active
Future<bool> shouldCooldownBeActive() async {
try {
final prefs = await SharedPreferences.getInstance();
final cooldownEndTime = prefs.getString(_cooldownEndTimeKey);
if (cooldownEndTime != null) {
final endTime = DateTime.parse(cooldownEndTime);
return DateTime.now().isBefore(endTime);
}
return false;
} catch (e) {
print('Error checking cooldown state: $e');
return false;
}
}
// Initialize - call this in onInit()
Future<void> initializeCooldown() async {
await _loadCooldownState();
}
// Dispose - call this in onClose()
void disposeCooldown() {
_cooldownTimer?.cancel();
}
}

View File

@ -0,0 +1,179 @@
# Persistent Cooldown Implementation
Implementasi cooldown persisten untuk panic button yang akan tetap berlaku meskipun user keluar dan masuk kembali dari aplikasi.
## Features
1. **Persistent Storage**: Cooldown state disimpan menggunakan SharedPreferences
2. **Auto-restore**: Cooldown akan otomatis dipulihkan saat aplikasi dibuka kembali
3. **Expiration Check**: Otomatis membersihkan cooldown yang sudah expired
4. **Force Clear**: Method untuk membersihkan cooldown secara paksa (untuk testing/admin)
## Implementation Details
### 1. SharedPreferences Keys
```dart
static const String _cooldownEndTimeKey = 'panic_cooldown_end_time';
static const String _cooldownActiveKey = 'panic_cooldown_active';
```
### 2. Core Methods
#### Load Cooldown State
```dart
Future<void> _loadCooldownState() async {
final prefs = await SharedPreferences.getInstance();
final cooldownEndTime = prefs.getString(_cooldownEndTimeKey);
if (cooldownEndTime != null) {
final endTime = DateTime.parse(cooldownEndTime);
final now = DateTime.now();
if (now.isBefore(endTime)) {
// Cooldown is still active
isCooldownActive.value = true;
cooldownSeconds.value = endTime.difference(now).inSeconds;
_startCooldownTimer();
} else {
// Cooldown has expired, clean up
await _clearCooldownState();
}
}
}
```
#### Save Cooldown State
```dart
Future<void> _saveCooldownState(DateTime endTime) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_cooldownEndTimeKey, endTime.toIso8601String());
await prefs.setBool(_cooldownActiveKey, true);
}
```
#### Clear Cooldown State
```dart
Future<void> _clearCooldownState() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_cooldownEndTimeKey);
await prefs.remove(_cooldownActiveKey);
isCooldownActive.value = false;
cooldownSeconds.value = 0;
}
```
### 3. Integration with Existing Code
#### Modified \_startCooldown()
```dart
void _startCooldown() {
int cooldownDuration = _calculateCooldownDuration();
isCooldownActive.value = true;
cooldownSeconds.value = cooldownDuration;
_updateCooldownTimeString();
// Save cooldown end time to persistent storage
final endTime = DateTime.now().add(Duration(seconds: cooldownDuration));
_saveCooldownState(endTime);
_startCooldownTimer();
}
```
#### Modified \_endCooldown()
```dart
void _endCooldown() {
_cooldownTimer?.cancel();
isCooldownActive.value = false;
cooldownSeconds.value = 0;
cooldownTimeString.value = '';
// Clear persistent storage
_clearCooldownState();
}
```
### 4. Controller Initialization
#### In onInit()
```dart
@override
void onInit() {
super.onInit();
// Load cooldown state from persistent storage
_loadCooldownState();
// ... other initialization code
}
```
## Usage Scenarios
### 1. App Launch with Active Cooldown
- User mengaktifkan panic button
- Cooldown dimulai (5 menit)
- User keluar dari aplikasi
- User membuka aplikasi kembali setelah 2 menit
- Cooldown akan otomatis dipulihkan dengan 3 menit tersisa
### 2. App Launch with Expired Cooldown
- User mengaktifkan panic button
- Cooldown dimulai (5 menit)
- User keluar dari aplikasi
- User membuka aplikasi kembali setelah 6 menit
- Cooldown akan otomatis dibersihkan karena sudah expired
### 3. Force Clear Cooldown
- Admin atau developer dapat menggunakan method `forceClearCooldown()`
- Berguna untuk testing atau situasi darurat
## Testing
### Manual Testing Steps
1. Aktivasi panic button hingga cooldown aktif
2. Tutup aplikasi sepenuhnya
3. Buka aplikasi kembali
4. Verify cooldown masih aktif dengan waktu yang tepat
5. Tunggu hingga cooldown habis
6. Verify panic button dapat digunakan lagi
### Edge Cases
- Perubahan timezone
- Perubahan waktu sistem
- Aplikasi di-kill oleh sistem
- Storage permission issues
## Security Considerations
1. **No sensitive data**: Hanya menyimpan timestamp, tidak ada data sensitif
2. **Local storage only**: Menggunakan SharedPreferences yang bersifat lokal
3. **Validation**: Selalu validasi timestamp sebelum menggunakan
4. **Fallback**: Jika ada error, default ke state aman (no cooldown)
## Performance Considerations
1. **Minimal storage**: Hanya menyimpan 2 key-value pairs
2. **Efficient loading**: Loading dilakukan sekali saat app start
3. **Cleanup**: Otomatis membersihkan expired data
4. **Timer optimization**: Reuse existing timer mechanism
## Future Enhancements
1. **Server sync**: Sinkronisasi dengan server untuk multi-device
2. **Usage analytics**: Track cooldown patterns
3. **Dynamic duration**: Cooldown duration berdasarkan usage pattern
4. **Notification**: Notify user saat cooldown berakhir

View File

@ -1,15 +1,15 @@
flutter_native_splash: flutter_native_splash:
color: "#ffffff" color: "#ffffff"
image: assets/logos/logo-light.png image: assets/logos/logo-bg-light.png
branding: assets/logos/logo-light.png branding: assets/logos/logo-bg-light.png
color_dark: "#121212" color_dark: "#121212"
image_dark: assets/logos/logo-dark.png image_dark: assets/logos/logo-bg-dark.png
branding_dark: assets/logos/logo-dark.png branding_dark: assets/logos/logo-bg-dark.png
android_12: android_12:
image: assets/logos/logo-light.png image: assets/logos/logo-bg-light.png
icon_background_color: "#ffffff" icon_background_color: "#ffffff"
image_dark: assets/logos/logo-dark.png image_dark: assets/logos/logo-bg-dark.png
icon_background_color_dark: "#121212" icon_background_color_dark: "#121212"
web: false web: false

Binary file not shown.

Before

Width:  |  Height:  |  Size: 392 B

After

Width:  |  Height:  |  Size: 353 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 669 B

After

Width:  |  Height:  |  Size: 677 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 871 B

After

Width:  |  Height:  |  Size: 913 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 412 B

After

Width:  |  Height:  |  Size: 366 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 587 B

After

Width:  |  Height:  |  Size: 674 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 754 B

After

Width:  |  Height:  |  Size: 909 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 392 B

After

Width:  |  Height:  |  Size: 353 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 669 B

After

Width:  |  Height:  |  Size: 677 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 871 B

After

Width:  |  Height:  |  Size: 913 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 412 B

After

Width:  |  Height:  |  Size: 366 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 587 B

After

Width:  |  Height:  |  Size: 674 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 754 B

After

Width:  |  Height:  |  Size: 909 B

View File

@ -41,7 +41,7 @@
</scene> </scene>
</scenes> </scenes>
<resources> <resources>
<image name="LaunchImage" width="50" height="50"/> <image name="LaunchImage" width="48" height="48"/>
<image name="LaunchBackground" width="1" height="1"/> <image name="LaunchBackground" width="1" height="1"/>
<image name="BrandingImage" width="1" height="1"/> <image name="BrandingImage" width="1" height="1"/>
</resources> </resources>

View File

@ -70,8 +70,12 @@
<key>MGLMapboxAccessToken</key> <key>MGLMapboxAccessToken</key>
<string>$(MAPBOX_ACCESS_TOKEN)</string> <string>$(MAPBOX_ACCESS_TOKEN)</string>
<key>UIStatusBarHidden</key> <key>UIStatusBarHidden</key>
<key>NSCameraUsageDescription</key> <false />
<string>We need access to your camera to detect faces.</string> <string>We need access to your camera to detect faces.</string>
<false /> <false />
<key>LSApplicationQueriesSchemes</key>
<array>
<string>tel</string>
</array>
</dict> </dict>
</plist> </plist>

View File

@ -12,13 +12,18 @@ class App extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Initialize bindings explicitly first
final bindings = AppBindings();
bindings.dependencies();
return GetMaterialApp( return GetMaterialApp(
title: TTexts.appName, title: TTexts.appName,
themeMode: ThemeMode.system, // This will follow system theme settings themeMode: ThemeMode.system, // This will follow system theme settings
theme: TAppTheme.lightTheme, theme: TAppTheme.lightTheme,
darkTheme: TAppTheme.darkTheme, darkTheme: TAppTheme.darkTheme,
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
initialBinding: AppBindings(), // Still keep initialBinding for any controllers that rely on it
initialBinding: bindings,
localizationsDelegates: GlobalMaterialLocalizations.delegates, localizationsDelegates: GlobalMaterialLocalizations.delegates,
supportedLocales: const [Locale('id', '')], supportedLocales: const [Locale('id', '')],
getPages: AppPages.routes, getPages: AppPages.routes,

View File

@ -1,10 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:sigap/src/cores/services/supabase_service.dart'; import 'package:sigap/src/cores/services/supabase_service.dart';
import 'package:sigap/src/features/daily-ops/presentasion/pages/patrol-unit/patrol_unit_screen.dart';
import 'package:sigap/src/features/explore/presentasion/pages/home-screen/home_screen.dart';
import 'package:sigap/src/features/map/presentasion/pages/map_screen.dart';
import 'package:sigap/src/features/notification/presentation/pages/notification_screen.dart';
import 'package:sigap/src/features/panic/presentation/pages/panic_button_page.dart'; import 'package:sigap/src/features/panic/presentation/pages/panic_button_page.dart';
import 'package:sigap/src/features/personalization/presentasion/pages/settings/setting_screen.dart'; import 'package:sigap/src/features/personalization/presentasion/pages/settings/setting_screen.dart';
import 'package:sigap/src/shared/widgets/navigation/custom_bottom_navigation_bar.dart'; import 'package:sigap/src/shared/widgets/navigation/custom_bottom_navigation_bar.dart';
@ -35,7 +31,7 @@ class NavigationController extends GetxController {
static NavigationController get instance => Get.find(); static NavigationController get instance => Get.find();
// Observable variable to track the current selected index // Observable variable to track the current selected index
final Rx<int> selectedIndex = 2.obs; // Start with PanicButtonPage (index 2) final Rx<int> selectedIndex = 0.obs; // Start with PanicButtonPage (index 0)
final SupabaseService supabaseService; final SupabaseService supabaseService;
@ -62,22 +58,10 @@ class NavigationController extends GetxController {
// Get the appropriate screens based on user role // Get the appropriate screens based on user role
List<Widget> getScreens() { List<Widget> getScreens() {
final List<Widget> screens = [ return [
const HomeScreen(),
const NotificationScreen(),
const PanicButtonPage(), const PanicButtonPage(),
const MapScreen(), const SettingsScreen(),
]; ];
// Add PatrolUnitScreen only if user is an officer
if (isOfficer.value) {
screens.add(const PatrolUnitScreen());
}
// Always add the Settings screen at the end
screens.add(const SettingsScreen());
return screens;
} }
// Method to change selected index // Method to change selected index

View File

@ -1,8 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart'; import 'package:get_storage/get_storage.dart';
import 'package:logger/logger.dart'; import 'package:logger/logger.dart';
import 'package:lottie/lottie.dart'; import 'package:lottie/lottie.dart';
import 'package:sigap/src/features/auth/data/repositories/authentication_repository.dart'; import 'package:sigap/src/features/auth/data/repositories/authentication_repository.dart';
import 'package:sigap/src/utils/constants/app_routes.dart';
import 'package:sigap/src/utils/constants/colors.dart'; import 'package:sigap/src/utils/constants/colors.dart';
import 'package:sigap/src/utils/constants/image_strings.dart'; import 'package:sigap/src/utils/constants/image_strings.dart';
import 'package:sigap/src/utils/helpers/helper_functions.dart'; import 'package:sigap/src/utils/helpers/helper_functions.dart';
@ -50,7 +52,22 @@ class _AnimatedSplashScreenWidgetState extends State<AnimatedSplashScreenWidget>
} }
Future<void> _handleNavigation() async { Future<void> _handleNavigation() async {
AuthenticationRepository.instance.screenRedirect(); try {
// Check if AuthenticationRepository is registered
if (Get.isRegistered<AuthenticationRepository>()) {
AuthenticationRepository.instance.screenRedirect();
} else {
Logger().e(
'AuthenticationRepository not registered, manually registering',
);
final authRepo = Get.put(AuthenticationRepository(), permanent: true);
authRepo.screenRedirect();
}
} catch (e) {
Logger().e('Error during navigation: $e');
// Fallback to onboarding screen if there's an error
Get.offAllNamed(AppRoutes.onboarding);
}
} }
@override @override

View File

@ -2,21 +2,31 @@ import 'package:get/get.dart';
import 'package:sigap/src/cores/bindings/controller_bindings.dart'; import 'package:sigap/src/cores/bindings/controller_bindings.dart';
import 'package:sigap/src/cores/bindings/general_bindings.dart'; import 'package:sigap/src/cores/bindings/general_bindings.dart';
import 'package:sigap/src/cores/bindings/repository_bindings.dart'; import 'package:sigap/src/cores/bindings/repository_bindings.dart';
import 'package:sigap/src/cores/bindings/service_bindings.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/data/repositories/authentication_repository.dart';
class AppBindings implements Bindings { class AppBindings implements Bindings {
@override @override
Future<void> dependencies() async { void dependencies() {
// Register general helpers and utilities // Register general helpers and utilities first
UtilityBindings().dependencies(); UtilityBindings().dependencies();
// Register all services // Register services
await ServiceBindings().dependencies(); Get.put(SupabaseService(), permanent: true);
Get.put(BiometricService(), permanent: true);
Get.put(LocationService(), permanent: true);
// Register all repositories // Always explicitly register AuthenticationRepository early to avoid the splash screen issue
Get.put(AuthenticationRepository(), permanent: true);
// Register remaining repositories
RepositoryBindings().dependencies(); RepositoryBindings().dependencies();
// Register all feature controllers // Register feature controllers after repositories
ControllerBindings().dependencies(); ControllerBindings().dependencies();
} }
} }

View File

@ -2,7 +2,7 @@ import 'package:get/get.dart';
import 'package:sigap/src/features/auth/data/bindings/authentication_bindings_repository.dart'; import 'package:sigap/src/features/auth/data/bindings/authentication_bindings_repository.dart';
import 'package:sigap/src/features/daily-ops/data/bindings/daily_ops_repository_bindings.dart'; import 'package:sigap/src/features/daily-ops/data/bindings/daily_ops_repository_bindings.dart';
import 'package:sigap/src/features/map/data/bindings/map_repository_bindings.dart'; import 'package:sigap/src/features/map/data/bindings/map_repository_bindings.dart';
import 'package:sigap/src/features/panic-button/data/bindings/panic_button_repository_bindings.dart'; import 'package:sigap/src/features/panic/datas/bindings/panic_button_repository_bindings.dart';
import 'package:sigap/src/features/personalization/data/bindings/personalization_repository_bindings.dart'; import 'package:sigap/src/features/personalization/data/bindings/personalization_repository_bindings.dart';
class RepositoryBindings extends Bindings { class RepositoryBindings extends Bindings {
@ -13,8 +13,6 @@ class RepositoryBindings extends Bindings {
PersonalizationRepositoryBindings().dependencies(); PersonalizationRepositoryBindings().dependencies();
PanicButtonRepositoryBindings().dependencies();
MapRepositoryBindings().dependencies(); MapRepositoryBindings().dependencies();
DailyOpsRepositoryBindings().dependencies(); DailyOpsRepositoryBindings().dependencies();

View File

@ -11,6 +11,7 @@ import 'package:sigap/src/features/onboarding/presentasion/pages/location-warnin
import 'package:sigap/src/features/onboarding/presentasion/pages/onboarding/onboarding_screen.dart'; import 'package:sigap/src/features/onboarding/presentasion/pages/onboarding/onboarding_screen.dart';
import 'package:sigap/src/features/onboarding/presentasion/pages/role-selection/role_signup_pageview.dart'; import 'package:sigap/src/features/onboarding/presentasion/pages/role-selection/role_signup_pageview.dart';
import 'package:sigap/src/features/onboarding/presentasion/pages/welcome/welcome_screen.dart'; import 'package:sigap/src/features/onboarding/presentasion/pages/welcome/welcome_screen.dart';
import 'package:sigap/src/features/panic/presentation/screens/incident_update_screen.dart';
import 'package:sigap/src/features/personalization/presentasion/pages/profile/profile_screen.dart'; import 'package:sigap/src/features/personalization/presentasion/pages/profile/profile_screen.dart';
import 'package:sigap/src/features/personalization/presentasion/pages/settings/setting_screen.dart'; import 'package:sigap/src/features/personalization/presentasion/pages/settings/setting_screen.dart';
import 'package:sigap/src/utils/constants/app_routes.dart'; import 'package:sigap/src/utils/constants/app_routes.dart';
@ -82,7 +83,13 @@ class AppPages {
page: () => const ProfileScreen(isCurrentUser: true), page: () => const ProfileScreen(isCurrentUser: true),
), ),
// Panic Button Related Pages
GetPage(
name: AppRoutes.panicUpdateIncident,
page: () => const IncidentUpdateScreen(),
transition: Transition.rightToLeft,
transitionDuration: const Duration(milliseconds: 300),
),
]; ];
} }

View File

@ -34,7 +34,7 @@ class EdgeFunctionService {
String get _verifyFaceUrl => '$supabaseUrl/functions/v1/$_verifyFaceFunction'; String get _verifyFaceUrl => '$supabaseUrl/functions/v1/$_verifyFaceFunction';
// Max retries // Max retries
final int _maxRetries = 0; final int _maxRetries = 10;
/// Detects faces in an image using the Supabase Edge Function with retries /// Detects faces in an image using the Supabase Edge Function with retries
Future<List<FaceModel>> detectFaces(XFile imageFile) async { Future<List<FaceModel>> detectFaces(XFile imageFile) async {

View File

@ -3,6 +3,7 @@ import 'package:geocoding/geocoding.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:logger/logger.dart'; import 'package:logger/logger.dart';
import 'package:sigap/src/utils/constants/app_routes.dart';
import 'package:sigap/src/utils/exceptions/exceptions.dart'; import 'package:sigap/src/utils/exceptions/exceptions.dart';
class LocationService extends GetxService { class LocationService extends GetxService {
@ -37,6 +38,8 @@ class LocationService extends GetxService {
// Add last check timestamp // Add last check timestamp
final Rx<DateTime?> lastLocationCheckTime = Rx<DateTime?>(null); final Rx<DateTime?> lastLocationCheckTime = Rx<DateTime?>(null);
final RxBool isLocationValid = true.obs;
LocationService() { LocationService() {
if (defaultTargetPlatform == TargetPlatform.android) { if (defaultTargetPlatform == TargetPlatform.android) {
locationSettings = AndroidSettings( locationSettings = AndroidSettings(
@ -80,6 +83,7 @@ class LocationService extends GetxService {
// Initialize the service // Initialize the service
Future<LocationService> init() async { Future<LocationService> init() async {
await _checkLocationService(); await _checkLocationService();
// startListeningToLocationUpdates();
return this; return this;
} }
@ -185,14 +189,19 @@ class LocationService extends GetxService {
void startListeningToLocationUpdates() { void startListeningToLocationUpdates() {
Geolocator.getPositionStream(locationSettings: locationSettings).listen(( Geolocator.getPositionStream(locationSettings: locationSettings).listen((
Position position, Position position,
) { ) async {
currentPosition.value = position; currentPosition.value = position;
// Check if location is mocked
isMockedLocation.value = position.isMocked; isMockedLocation.value = position.isMocked;
await _updateCityName();
// Update city name based on new position // Cek validitas lokasi setiap update
_updateCityName(); bool valid = !isMockedLocation.value && isInJember();
isLocationValid.value = valid;
// Redirect jika tidak valid dan bukan di halaman warning
if (!valid && Get.currentRoute != AppRoutes.locationWarning) {
Get.offAllNamed(AppRoutes.locationWarning);
}
}); });
} }
@ -229,6 +238,8 @@ class LocationService extends GetxService {
lastAddress.value = addressComponents.join(', '); lastAddress.value = addressComponents.join(', ');
} }
Logger().d('Current city: ${currentCity.value}');
} catch (e) { } catch (e) {
currentCity.value = ''; currentCity.value = '';
currentDistrict.value = ''; currentDistrict.value = '';

View File

@ -4,6 +4,7 @@ import 'package:sigap/src/features/auth/data/repositories/authentication_reposit
class AuthRepositoryBindings extends Bindings { class AuthRepositoryBindings extends Bindings {
@override @override
void dependencies() { void dependencies() {
Get.lazyPut(() => AuthenticationRepository(), fenix: true); // Changed from lazyPut to put to ensure immediate instantiation
Get.put(AuthenticationRepository(), permanent: true);
} }
} }

View File

@ -1,4 +1,3 @@
import 'package:flutter/material.dart';
/// Types of administrative divisions in Indonesia /// Types of administrative divisions in Indonesia
enum DivisionType { enum DivisionType {

View File

@ -1,3 +1,4 @@
import 'package:dio/dio.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart'; import 'package:get_storage/get_storage.dart';
@ -7,7 +8,9 @@ import 'package:sigap/src/cores/services/location_service.dart';
import 'package:sigap/src/cores/services/supabase_service.dart'; import 'package:sigap/src/cores/services/supabase_service.dart';
import 'package:sigap/src/features/auth/presentasion/pages/signin/signin_screen.dart'; import 'package:sigap/src/features/auth/presentasion/pages/signin/signin_screen.dart';
import 'package:sigap/src/features/personalization/data/models/models/user_metadata_model.dart'; import 'package:sigap/src/features/personalization/data/models/models/user_metadata_model.dart';
import 'package:sigap/src/utils/constants/api_urls.dart';
import 'package:sigap/src/utils/constants/app_routes.dart'; import 'package:sigap/src/utils/constants/app_routes.dart';
import 'package:sigap/src/utils/dio.client/dio_client.dart';
import 'package:sigap/src/utils/exceptions/exceptions.dart'; import 'package:sigap/src/utils/exceptions/exceptions.dart';
import 'package:sigap/src/utils/exceptions/format_exceptions.dart'; import 'package:sigap/src/utils/exceptions/format_exceptions.dart';
import 'package:sigap/src/utils/exceptions/platform_exceptions.dart'; import 'package:sigap/src/utils/exceptions/platform_exceptions.dart';
@ -30,6 +33,7 @@ class AuthenticationRepository extends GetxController {
User? get authUser => SupabaseService.instance.currentUser; User? get authUser => SupabaseService.instance.currentUser;
String? get currentUserId => SupabaseService.instance.currentUserId; String? get currentUserId => SupabaseService.instance.currentUserId;
Session? get currentSession => _supabase.auth.currentSession; Session? get currentSession => _supabase.auth.currentSession;
LocationService get locationService => _locationService;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// LIFECYCLE & REDIRECT // LIFECYCLE & REDIRECT
@ -94,57 +98,88 @@ class AuthenticationRepository extends GetxController {
static bool _isRedirecting = false; static bool _isRedirecting = false;
/// Updated screenRedirect method to handle onboarding preferences /// Improved screenRedirect method with better flow control and error handling
void screenRedirect({UserMetadataModel? arguments}) async { Future<void> screenRedirect({UserMetadataModel? arguments}) async {
// Prevent recursive calls with a static guard // Prevent recursive calls with a static guard
if (_isRedirecting) { if (_isRedirecting) {
Logger().w('Screen redirect already in progress, ignoring this call'); _logger.w('Screen redirect already in progress, ignoring this call');
return; return;
} }
try { try {
_isRedirecting = true; _isRedirecting = true;
Logger().d('Starting screen redirect'); _logger.d('Starting screen redirect');
final session = _supabase.auth.currentSession; // Get current session and user preferences
final user = _supabase.auth.currentUser;
final bool isFirstTime = storage.read('isFirstTime') ?? true; final bool isFirstTime = storage.read('isFirstTime') ?? true;
final isEmailVerified = session?.user.emailConfirmedAt != null;
final isProfileComplete = // First handle authentication state
session?.user.userMetadata?['profile_status'] == 'completed'; if (user == null) {
_logger.d('No active session found');
await _handleUnauthenticatedUser(isFirstTime);
return;
}
// Cek lokasi terlebih dahulu // User is authenticated, check email verification and profile status
final isEmailVerified = user.emailConfirmedAt != null;
final isProfileComplete =
user.userMetadata?['profile_status'] == 'completed';
_logger.d(
'Auth state: emailVerified=$isEmailVerified, profileComplete=$isProfileComplete',
);
// Email verification is a priority check
if (!isEmailVerified) {
_logger.d('Email not verified, redirecting to verification screen');
_navigateToRoute(AppRoutes.emailVerification);
return;
}
// Now check location if not first time and email is verified
if (!isFirstTime) { if (!isFirstTime) {
bool isLocationValid = _logger.d('Checking location validity');
await _locationService.isLocationValidForFeature(); bool isLocationValid = false;
try {
isLocationValid = await _locationService.isLocationValidForFeature();
} catch (locError) {
_logger.e('Error checking location: $locError');
// Default to invalid if there's an error
isLocationValid = false;
}
if (!isLocationValid) { if (!isLocationValid) {
Logger().w('Location is invalid, redirecting to location warning'); _logger.w('Location is invalid, redirecting to location warning');
_navigateToRoute(AppRoutes.locationWarning); _navigateToRoute(AppRoutes.locationWarning);
return; return;
} }
} }
if (session != null) { // Check profile completion status
if (!isEmailVerified) { if (!isProfileComplete) {
_navigateToRoute(AppRoutes.emailVerification); // If already on registration form, don't redirect again
} else if (!isProfileComplete && isEmailVerified) { if (Get.currentRoute == AppRoutes.registrationForm) {
// If already on registration form, don't redirect again _logger.d('Already on registration form, not redirecting');
if (Get.currentRoute == AppRoutes.registrationForm) { return;
Logger().d('Already on registration form, not redirecting');
return;
}
_navigateToRoute(AppRoutes.registrationForm);
} else {
_navigateToRoute(AppRoutes.navigationMenu);
} }
} else { _logger.d('Profile not complete, redirecting to registration form');
await _handleUnauthenticatedUser(isFirstTime); _navigateToRoute(AppRoutes.registrationForm);
return;
} }
// All checks passed, go to main app
_logger.d('All checks passed, redirecting to main app');
_navigateToRoute(AppRoutes.navigationMenu);
} catch (e) { } catch (e) {
Logger().e('Error in screenRedirect: $e'); _logger.e('Error in screenRedirect: $e');
_navigateToRoute(AppRoutes.signIn); _navigateToRoute(AppRoutes.signIn);
} finally { } finally {
_isRedirecting = false; _isRedirecting = false;
Logger().d('Screen redirect completed'); _logger.d('Screen redirect completed');
} }
} }
@ -166,7 +201,11 @@ class AuthenticationRepository extends GetxController {
if (isFirstTime) { if (isFirstTime) {
_navigateToRoute(AppRoutes.onboarding); _navigateToRoute(AppRoutes.onboarding);
} else { } else {
_navigateToRoute(AppRoutes.signIn); if (await _locationService.isLocationValidForFeature()) {
_navigateToRoute(AppRoutes.signIn);
} else {
_navigateToRoute(AppRoutes.locationWarning);
}
} }
} }
} }
@ -328,7 +367,55 @@ class AuthenticationRepository extends GetxController {
// Send reset password email // Send reset password email
Future<void> sendResetPasswordForEmail(String email) async { Future<void> sendResetPasswordForEmail(String email) async {
try { try {
await _supabase.auth.resetPasswordForEmail(email); await _supabase.auth.resetPasswordForEmail(
email,
redirectTo: 'https://sigap.backspacex.tech/auth/reset-password',
);
} on AuthException catch (e) {
throw TExceptions(e.message);
} on FormatException catch (_) {
throw const TFormatException();
} on PlatformException catch (e) {
throw TPlatformException(e.code).message;
} on PostgrestException catch (error) {
throw TExceptions.fromCode(error.code ?? 'unknown_error');
} catch (e) {
throw TExceptions('Something went wrong. Please try again later.');
}
}
Future<void> sendCustomResetPasswordEmail(String email) async {
try {
await DioClient().post(
'${Endpoints.baseUrl}/send',
options: Options(headers: {'Content-Type': 'application/json'}),
data: {
'type': 'email-token',
'to': email,
'firstName': email.split('@')[0],
'token': await generateTokenForEmail(
email,
GenerateLinkType.recovery,
),
},
);
} catch (e) {
throw TExceptions(
e.toString() ?? 'Something went wrong. Please try again later. ',
);
}
}
Future<GenerateLinkResponse> generateTokenForEmail(
String email,
GenerateLinkType type,
) async {
try {
final res = await _supabase.auth.admin.generateLink(
type: type,
email: email,
);
return res;
} on AuthException catch (e) { } on AuthException catch (e) {
throw TExceptions(e.message); throw TExceptions(e.message);
} on FormatException catch (_) { } on FormatException catch (_) {
@ -343,11 +430,12 @@ class AuthenticationRepository extends GetxController {
} }
// Verify OTP // Verify OTP
Future<AuthResponse> verifyOtp(String otp) async { Future<AuthResponse> verifyOtp(String otp, OtpType type, String email) async {
try { try {
final AuthResponse res = await _supabase.auth.verifyOTP( final AuthResponse res = await _supabase.auth.verifyOTP(
type: OtpType.signup, type: type,
token: otp, token: otp,
email: email,
); );
final Session? session = res.session; final Session? session = res.session;
@ -371,6 +459,8 @@ class AuthenticationRepository extends GetxController {
} }
} }
// Update password after reset // Update password after reset
Future<UserResponse> updatePasswordAfterReset(String newPassword) async { Future<UserResponse> updatePasswordAfterReset(String newPassword) async {
try { try {

View File

@ -5,6 +5,7 @@ import 'package:get/get.dart';
import 'package:sigap/src/features/auth/data/repositories/authentication_repository.dart'; import 'package:sigap/src/features/auth/data/repositories/authentication_repository.dart';
import 'package:sigap/src/utils/constants/app_routes.dart'; import 'package:sigap/src/utils/constants/app_routes.dart';
import 'package:sigap/src/utils/popups/loaders.dart'; import 'package:sigap/src/utils/popups/loaders.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
class EmailVerificationController extends GetxController { class EmailVerificationController extends GetxController {
// Singleton instance // Singleton instance
@ -64,7 +65,7 @@ class EmailVerificationController extends GetxController {
} }
// Handle OTP input change // Handle OTP input change
void onOtpChanged(String value, int index) { void onOtpChanged(String value, int index, String email) {
if (value.length == 1) { if (value.length == 1) {
// Move to next field // Move to next field
if (index < otpControllers.length - 1) { if (index < otpControllers.length - 1) {
@ -73,7 +74,7 @@ class EmailVerificationController extends GetxController {
// Last field filled, hide keyboard // Last field filled, hide keyboard
focusNodes[index].unfocus(); focusNodes[index].unfocus();
// Verify OTP automatically when all fields are filled // Verify OTP automatically when all fields are filled
verifyOtp(); verifyOtp(email);
} }
} }
} }
@ -84,7 +85,7 @@ class EmailVerificationController extends GetxController {
} }
// Verify OTP // Verify OTP
Future<void> verifyOtp() async { Future<void> verifyOtp(String email) async {
final otp = getOtp(); final otp = getOtp();
// Check if OTP is complete // Check if OTP is complete
@ -97,7 +98,11 @@ class EmailVerificationController extends GetxController {
isLoading.value = true; isLoading.value = true;
verificationError.value = ''; verificationError.value = '';
final authuser = await AuthenticationRepository.instance.verifyOtp(otp); final authuser = await AuthenticationRepository.instance.verifyOtp(
otp,
OtpType.signup,
email,
);
if (authuser.session == null || authuser.user == null) { if (authuser.session == null || authuser.user == null) {
verificationError.value = 'Invalid OTP. Please try again.'; verificationError.value = 'Invalid OTP. Please try again.';

View File

@ -1,10 +1,15 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:logger/Logger.dart';
import 'package:sigap/src/features/auth/data/repositories/authentication_repository.dart'; import 'package:sigap/src/features/auth/data/repositories/authentication_repository.dart';
import 'package:sigap/src/shared/widgets/state_screeen/state_screen.dart'; import 'package:sigap/src/features/auth/presentasion/pages/forgot-password/otp_reset_password.dart';
import 'package:sigap/src/utils/constants/image_strings.dart'; import 'package:sigap/src/utils/constants/app_routes.dart';
import 'package:sigap/src/utils/exceptions/exceptions.dart';
import 'package:sigap/src/utils/popups/loaders.dart'; import 'package:sigap/src/utils/popups/loaders.dart';
import 'package:sigap/src/utils/validators/validation.dart'; import 'package:sigap/src/utils/validators/validation.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
class ForgotPasswordController extends GetxController { class ForgotPasswordController extends GetxController {
// Singleton instance // Singleton instance
@ -16,14 +21,73 @@ class ForgotPasswordController extends GetxController {
// Text controllers // Text controllers
final emailController = TextEditingController(); final emailController = TextEditingController();
// OTP controllers and state
final otpController = TextEditingController();
final RxString otpError = ''.obs;
final RxBool isOtpLoading = false.obs;
final RxBool isOtpVerified = false.obs;
// Password reset controllers and state
final newPasswordController = TextEditingController();
final confirmPasswordController = TextEditingController();
final RxString passwordError = ''.obs;
final RxBool isResetLoading = false.obs;
final RxBool isResetSuccess = false.obs;
// Observable variables // Observable variables
final RxBool isLoading = false.obs; final RxBool isLoading = false.obs;
final RxString emailError = ''.obs; final RxString emailError = ''.obs;
final RxBool isEmailSent = false.obs; final RxBool isEmailSent = false.obs;
final RxString currentOtpEmail = ''.obs;
// Resend OTP cooldown state
final RxInt resendCooldown = 60.obs;
final int resendCooldownDuration = 60;
final RxBool isResendAvailable = false.obs;
final RxBool isResending = false.obs;
Timer? _resendTimer;
void startResendCooldown() {
isResendAvailable.value = false;
resendCooldown.value = resendCooldownDuration;
_resendTimer?.cancel();
_resendTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (resendCooldown.value > 0) {
resendCooldown.value--;
} else {
isResendAvailable.value = true;
_resendTimer?.cancel();
}
});
}
Future<void> resendOtpWithEmail(String email) async {
if (!isResendAvailable.value || isResending.value) return;
isResending.value = true;
try {
await forgotPasswordWithEmail(email);
TLoaders.infoSnackBar(
title: 'Success',
message: 'OTP has been resent to your email',
);
startResendCooldown();
} catch (e) {
TLoaders.errorSnackBar(
title: 'Error',
message: 'Failed to resend OTP. Please try again.',
);
} finally {
isResending.value = false;
}
}
@override @override
void onClose() { void onClose() {
emailController.dispose(); emailController.dispose();
otpController.dispose();
newPasswordController.dispose();
confirmPasswordController.dispose();
_resendTimer?.cancel();
super.onClose(); super.onClose();
} }
@ -34,8 +98,28 @@ class ForgotPasswordController extends GetxController {
return error; return error;
} }
// Reset password method // OTP validation
Future<void> resetPassword() async { String? validateOtp(String? value) {
if (value == null || value.length != 6) {
otpError.value = 'OTP must be 6 digits';
return 'OTP must be 6 digits';
}
otpError.value = '';
return null;
}
// Password validation
String? validatePassword(String? value) {
if (value == null || value.length < 6) {
passwordError.value = 'Password must be at least 6 characters';
return 'Password must be at least 6 characters';
}
passwordError.value = '';
return null;
}
// Send reset password email
Future<void> forgotPassword() async {
// Clear previous errors // Clear previous errors
emailError.value = ''; emailError.value = '';
@ -53,38 +137,100 @@ class ForgotPasswordController extends GetxController {
// Show success message // Show success message
isEmailSent.value = true; isEmailSent.value = true;
TLoaders.successSnackBar( currentOtpEmail.value = emailController.text;
TLoaders.infoSnackBar(
title: 'Success', title: 'Success',
message: 'Reset password email sent successfully.', message: 'Reset password email sent successfully.',
); );
Get.off( Get.to(
() => StateScreen( () => OTPResetPasswordPage(),
title: 'Check Your Email', arguments: {'email': emailController.text},
subtitle: 'Please check your email for the reset link.',
image: TImages.customerSupport,
isSvg: true,
primaryButtonTitle: 'Go Back',
showButton: true,
onPressed: () => Get.back(),
),
); );
} catch (e) { } catch (e) {
Get.snackbar( TLoaders.errorSnackBar(title: 'Error', message: e.toString());
'Error',
'Failed to send reset email: ${e.toString()}',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
} finally { } finally {
isLoading.value = false; isLoading.value = false;
} }
} }
// Resend reset password email with specific email (for OTP resend)
Future<void> forgotPasswordWithEmail(String email) async {
try {
await AuthenticationRepository.instance.sendResetPasswordForEmail(email);
currentOtpEmail.value = email;
TLoaders.infoSnackBar(
title: 'Success',
message: 'Reset password email sent successfully.',
);
} catch (e) {
TLoaders.errorSnackBar(title: 'Error', message: e.toString());
}
}
// OTP verification logic
Future<void> verifyOtp() async {
otpError.value = '';
if (validateOtp(otpController.text) != null) return;
try {
isOtpLoading.value = true;
// Call Supabase OTP verification API
await AuthenticationRepository.instance.verifyOtp(
otpController.text,
OtpType.recovery,
currentOtpEmail.value,
);
isOtpVerified.value = true;
TLoaders.infoSnackBar(title: 'Success', message: 'OTP verified.');
// Navigation to reset password page can be handled in the UI after success
} catch (e) {
String errorMsg = 'Invalid OTP or an error occurred.';
if (e is TExceptions) {
errorMsg = e.message;
} else {
errorMsg = e.toString();
}
otpError.value = errorMsg;
TLoaders.errorSnackBar(title: 'Error', message: errorMsg);
Logger().e(e);
} finally {
isOtpLoading.value = false;
}
}
// Reset password logic
Future<void> resetPassword() async {
passwordError.value = '';
if (validatePassword(newPasswordController.text) != null ||
newPasswordController.text != confirmPasswordController.text) {
passwordError.value = 'Passwords do not match or are invalid';
return;
}
try {
isResetLoading.value = true;
// Call Supabase reset password API
await AuthenticationRepository.instance.updatePasswordAfterReset(
newPasswordController.text,
);
isResetSuccess.value = true;
TLoaders.infoSnackBar(
title: 'Success',
message: 'Password has been reset successfully.',
);
FocusManager.instance.primaryFocus?.unfocus();
Get.offAllNamed(AppRoutes.signIn);
// Navigation to login page can be handled in the UI after success
} catch (e) {
passwordError.value = 'Failed to reset password.';
TLoaders.errorSnackBar(title: 'Error', message: e.toString());
} finally {
isResetLoading.value = false;
}
}
// Go back to sign in // Go back to sign in
void goBack() { void goBack() {
Get.back(); Get.back();
} }
} }

View File

@ -75,8 +75,6 @@ class FormRegistrationController extends GetxController {
final RxString submitMessage = RxString(''); final RxString submitMessage = RxString('');
final RxBool isSubmitSuccess = RxBool(false); final RxBool isSubmitSuccess = RxBool(false);
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
@ -740,8 +738,20 @@ class FormRegistrationController extends GetxController {
return; return;
} }
final authUser = AuthenticationRepository.instance.authUser;
if (authUser == null) {
TLoaders.errorSnackBar(
title: 'Authentication Error',
message: 'User is not authenticated. Please sign in again.',
);
isSubmitting.value = false;
submitMessage.value =
'Failed to complete registration: User not authenticated';
return;
}
final currentUser = await UserRepository.instance.getUserById( final currentUser = await UserRepository.instance.getUserById(
AuthenticationRepository.instance.authUser!.id, authUser.id,
); );
final updateUser = currentUser.copyWith( final updateUser = currentUser.copyWith(
@ -750,19 +760,63 @@ class FormRegistrationController extends GetxController {
); );
final updateProfile = currentUser.profile?.copyWith( final updateProfile = currentUser.profile?.copyWith(
nik: identityController.nikController.text, // Access NIK safely with a fallback to extractedInfo
nik:
idCardVerificationController.ktpModel.value?.nik ??
idCardVerificationController.extractedInfo['nik'] ??
'',
firstName: personalInfoController.firstNameController.text, firstName: personalInfoController.firstNameController.text,
lastName: personalInfoController.lastNameController.text, lastName: personalInfoController.lastNameController.text,
address: { address: {
'full_address': personalInfoController.addressController.text, 'full_address': personalInfoController.addressController.text,
}, },
placeOfBirth: identityController.placeOfBirthController.text, placeOfBirth: identityController.placeOfBirthController.text,
birthDate: _parseBirthDate(identityController.birthDateController.text), birthDate: parseBirthDate(identityController.birthDateController.text),
); );
// Add debug logs to trace the NIK value
Logger().d(
'Updating user profile with NIK from KTP model: ${idCardVerificationController.ktpModel.value?.nik}',
);
Logger().d(
'NIK from extractedInfo: ${idCardVerificationController.extractedInfo['nik']}',
);
Logger().d('Final NIK used: ${updateProfile?.nik}');
Logger().d('Updating user profile with NIK: ${updateProfile?.nik}');
if (updateProfile == null) {
TLoaders.errorSnackBar(
title: 'Error',
message: 'Failed to create profile data. Please try again.',
);
isSubmitting.value = false;
submitMessage.value =
'Failed to create profile data. Please try again.';
return;
}
if (updateProfile.nik == null) {
TLoaders.errorSnackBar(
title: 'Validation Error',
message: 'NIK cannot be empty.',
);
return;
}
if (updateProfile.nik!.isEmpty) {
TLoaders.errorSnackBar(
title: 'Validation Error',
message: 'NIK cannot be empty.',
);
isSubmitting.value = false;
submitMessage.value = 'NIK cannot be empty.';
return;
}
// Check if NIK is already registered (but ignore if it's the current user's NIK) // Check if NIK is already registered (but ignore if it's the current user's NIK)
final isNikTaken = await UserRepository.instance.isNikExists( final isNikTaken = await UserRepository.instance.isNikExists(
updateProfile!.nik!, updateProfile.nik!,
); );
if (isNikTaken) { if (isNikTaken) {
@ -871,7 +925,7 @@ class FormRegistrationController extends GetxController {
} }
} }
DateTime? _parseBirthDate(String dateStr) { DateTime? parseBirthDate(String dateStr) {
try { try {
if (dateStr.isEmpty) return null; if (dateStr.isEmpty) return null;

View File

@ -178,16 +178,17 @@ class SignupWithRoleController extends GetxController {
); );
// Validate response // Validate response
if (authResponse.session == null || authResponse.user == null) { if (authResponse.user == null) {
TLoaders.errorSnackBar( TLoaders.errorSnackBar(
title: 'Registration Failed', title: 'Registration Failed',
message: 'Failed to create account. Please try again.', message: 'Failed to create account. Please try again.',
); );
return; return;
} }
final user = authResponse.user!; final user = authResponse.user!;
Logger().d('Account created successfully for user: ${user.id}'); // Logger().d('Account created successfully for user: ${authResponse.user}');
AuthenticationRepository.instance.screenRedirect(); AuthenticationRepository.instance.screenRedirect();
} catch (e) { } catch (e) {

View File

@ -268,6 +268,15 @@ class IdCardVerificationController extends GetxController {
// Store the extraction results // Store the extraction results
extractedInfo.assignAll(result); extractedInfo.assignAll(result);
// Create KTP model from the result if not an officer
if (!isOfficer) {
ktpModel.value = _ocrService.createKtpModel(result);
// Add debug logs to check NIK value
Logger().i('KTP model created with NIK: ${ktpModel.value?.nik}');
Logger().i('Raw NIK in extractedInfo: ${extractedInfo['nik']}');
}
hasExtractedInfo.value = result.isNotEmpty; hasExtractedInfo.value = result.isNotEmpty;
// Save the OCR results to local storage // Save the OCR results to local storage
@ -418,6 +427,16 @@ class IdCardVerificationController extends GetxController {
return isOfficer ? ktaModel.value : ktpModel.value; return isOfficer ? ktaModel.value : ktpModel.value;
} }
// Add a getter for NIK to easily access it
String? get nikValue {
if (!isOfficer && ktpModel.value != null) {
return ktpModel.value?.nik;
} else if (extractedInfo.containsKey('nik')) {
return extractedInfo['nik'];
}
return null;
}
// Debug helper method - can be called from UI for troubleshooting // Debug helper method - can be called from UI for troubleshooting
void debugControllerState() { void debugControllerState() {
Logger().i('======= ID CARD VERIFICATION CONTROLLER STATE ======='); Logger().i('======= ID CARD VERIFICATION CONTROLLER STATE =======');
@ -428,7 +447,9 @@ class IdCardVerificationController extends GetxController {
Logger().i('Has confirmed ID card: ${hasConfirmedIdCard.value}'); Logger().i('Has confirmed ID card: ${hasConfirmedIdCard.value}');
Logger().i('Has extracted info: ${hasExtractedInfo.value}'); Logger().i('Has extracted info: ${hasExtractedInfo.value}');
Logger().i('Extracted info fields: ${extractedInfo.length}'); Logger().i('Extracted info fields: ${extractedInfo.length}');
Logger().i('NIK in extractedInfo: ${extractedInfo['nik']}');
Logger().i('KTP model null: ${ktpModel.value == null}'); Logger().i('KTP model null: ${ktpModel.value == null}');
Logger().i('NIK in KTP model: ${ktpModel.value?.nik}');
Logger().i('KTA model null: ${ktaModel.value == null}'); Logger().i('KTA model null: ${ktaModel.value == null}');
Logger().i('================================================'); Logger().i('================================================');
} }

View File

@ -1,9 +1,20 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:logger/logger.dart'; import 'package:logger/logger.dart';
import 'package:sigap/src/features/auth/data/models/face_model.dart'; import 'package:sigap/src/features/auth/data/models/face_model.dart';
import 'package:sigap/src/features/auth/data/repositories/authentication_repository.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/signup/main/registration_form_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/main/registration_form_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/signup/step/selfie-verification/facial_verification_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/step/selfie-verification/facial_verification_controller.dart';
import 'package:sigap/src/features/personalization/data/models/models/profile_model.dart';
import 'package:sigap/src/features/personalization/data/repositories/profile_repository.dart';
import 'package:sigap/src/features/personalization/data/repositories/users_repository.dart';
import 'package:sigap/src/shared/widgets/state_screeen/state_screen.dart';
import 'package:sigap/src/utils/constants/image_strings.dart';
import 'package:sigap/src/utils/helpers/network_manager.dart';
import 'package:sigap/src/utils/popups/loaders.dart';
class IdentityVerificationController extends GetxController { class IdentityVerificationController extends GetxController {
// Singleton instance // Singleton instance
@ -170,16 +181,21 @@ class IdentityVerificationController extends GetxController {
final identityData = registrationData.identityVerification; final identityData = registrationData.identityVerification;
if (identityData != null) { if (identityData != null) {
if (nikController.text.isEmpty) nikController.text = identityData.nik; if (nikController.text.isEmpty) nikController.text = identityData.nik;
if (fullNameController.text.isEmpty) if (fullNameController.text.isEmpty) {
fullNameController.text = identityData.fullName; fullNameController.text = identityData.fullName;
if (placeOfBirthController.text.isEmpty) }
if (placeOfBirthController.text.isEmpty) {
placeOfBirthController.text = identityData.placeOfBirth; placeOfBirthController.text = identityData.placeOfBirth;
if (birthDateController.text.isEmpty) }
if (birthDateController.text.isEmpty) {
birthDateController.text = identityData.birthDate; birthDateController.text = identityData.birthDate;
if (addressController.text.isEmpty) }
if (addressController.text.isEmpty) {
addressController.text = identityData.address; addressController.text = identityData.address;
if (identityData.gender.isNotEmpty) }
if (identityData.gender.isNotEmpty) {
selectedGender.value = identityData.gender; selectedGender.value = identityData.gender;
}
} }
} }
@ -461,8 +477,9 @@ class IdentityVerificationController extends GetxController {
if (normalizedName1 == normalizedName2) return true; if (normalizedName1 == normalizedName2) return true;
if (normalizedName1.contains(normalizedName2) || if (normalizedName1.contains(normalizedName2) ||
normalizedName2.contains(normalizedName1)) normalizedName2.contains(normalizedName1)) {
return true; return true;
}
var parts1 = normalizedName1.split(' '); var parts1 = normalizedName1.split(' ');
var parts2 = normalizedName2.split(' '); var parts2 = normalizedName2.split(' ');
@ -546,26 +563,246 @@ class IdentityVerificationController extends GetxController {
// Save registration data using the centralized model // Save registration data using the centralized model
void saveRegistrationData() async { void saveRegistrationData() async {
try { try {
// For regular users, call the three required updates isSavingData.value = true;
mainController.updateUserRegistrationData(); dataSaveMessage.value = 'Processing registration data...';
// Check for internet connection
final isConnected = await NetworkManager.instance.isConnected();
if (!isConnected) {
TLoaders.errorSnackBar(
title: 'No Internet Connection',
message: 'Please check your internet connection and try again.',
);
isSavingData.value = false;
dataSaveMessage.value = 'No internet connection';
return;
}
// Get the current user data
final authUser = AuthenticationRepository.instance.authUser;
if (authUser == null) {
TLoaders.errorSnackBar(
title: 'Authentication Error',
message: 'User is not authenticated. Please sign in again.',
);
isSavingData.value = false;
dataSaveMessage.value = 'User not authenticated';
return;
}
// Fetch the current user from repository
final currentUser = await UserRepository.instance.getUserById(
authUser.id,
);
final currentProfile = await ProfileRepository.instance
.getProfileByUserId(authUser.id);
// Update user with phone
final updatedUser = currentUser.copyWith(
phone: mainController.personalInfoController.phoneController.text,
updatedAt: DateTime.now(),
);
// Get NIK from either KTP model or extracted info
String? nikValue;
if (mainController.idCardVerificationController.ktpModel.value != null) {
nikValue =
mainController.idCardVerificationController.ktpModel.value?.nik;
Logger().d('Got NIK from KTP model: $nikValue');
} else if (mainController.idCardVerificationController.extractedInfo
.containsKey('nik')) {
nikValue =
mainController.idCardVerificationController.extractedInfo['nik'];
Logger().d('Got NIK from extracted info: $nikValue');
} else {
nikValue = nikController.text;
Logger().d('Using NIK from form input: $nikValue');
}
// Hash NIK before saving or checking
final hashedNik = nikValue != null ? hashNik(nikValue) : null;
Logger().d('Hashed NIK: $hashedNik');
// Update profile dengan field baru (NIK sudah di-hash)
final updatedProfile =
currentUser.profile != null
? currentUser.profile!.copyWith(
userId: currentUser.profile!.userId,
nik: hashedNik ?? currentUser.profile!.nik,
firstName:
mainController
.personalInfoController
.firstNameController
.text,
lastName:
mainController
.personalInfoController
.lastNameController
.text,
address: {
'full_address':
mainController
.personalInfoController
.addressController
.text,
},
placeOfBirth: placeOfBirthController.text,
birthDate: _parseBirthDate(birthDateController.text),
)
: ProfileModel(
id: currentProfile.id ?? '',
userId: authUser.id,
nik: hashedNik ?? '',
firstName:
mainController
.personalInfoController
.firstNameController
.text,
lastName:
mainController
.personalInfoController
.lastNameController
.text,
address: {
'full_address':
mainController
.personalInfoController
.addressController
.text,
},
placeOfBirth: placeOfBirthController.text,
birthDate: _parseBirthDate(birthDateController.text),
);
// Validate NIK
if (updatedProfile.nik == null || updatedProfile.nik!.isEmpty) {
TLoaders.errorSnackBar(
title: 'Validation Error',
message: 'NIK cannot be empty.',
);
isSavingData.value = false;
dataSaveMessage.value = 'NIK cannot be empty';
return;
}
// Check if NIK is already registered (but ignore if it's the current user's NIK)
final isNikTaken = await UserRepository.instance.isNikExists(
updatedProfile.nik!,
);
if (isNikTaken) {
TLoaders.errorSnackBar(
title: 'Error',
message: 'NIK already registered to another user!',
);
isSavingData.value = false;
dataSaveMessage.value = 'NIK already registered to another user';
return;
}
// Perform the updates
Logger().d('Saving user profile changes...');
dataSaveMessage.value = 'Saving user profile changes...';
// Get repositories
final userRepository = UserRepository.instance;
final profileRepository = ProfileRepository.instance;
Logger().d(
'Updating user with ID: ${updatedUser.id}, NIK: ${updatedProfile.nik}',
);
// Update user data in database
final userUpdated = await userRepository.updateUser(updatedUser);
if (userUpdated == null) {
TLoaders.errorSnackBar(
title: 'Error',
message: 'Failed to update user data. Please try again.',
);
isSavingData.value = false;
dataSaveMessage.value = 'Failed to update user data';
return;
}
// Update profile data
await profileRepository.updateProfile(updatedProfile);
// Update metadata to mark profile as completed
await userRepository.updateProfileStatus('completed');
Logger().d('Registration completed successfully');
isDataSaved.value = true;
dataSaveMessage.value = 'Registration completed successfully!';
// Show success screen
Get.off(
() => StateScreen(
title: 'Registration Completed',
subtitle: 'Your registration has been successfully completed.',
image: TImages.womanHuggingEarth,
isSvg: true,
showButton: true,
primaryButtonTitle: 'Continue',
onPressed: () => AuthenticationRepository.instance.screenRedirect(),
),
);
} catch (e) { } catch (e) {
isDataSaved.value = false; isDataSaved.value = false;
dataSaveMessage.value = 'Error saving registration data: $e'; dataSaveMessage.value = 'Error saving registration data: $e';
Logger().e('Error saving registration data: $e'); Logger().e('Error saving registration data: $e');
TLoaders.errorSnackBar(
title: 'Error',
message: 'Failed to complete registration: ${e.toString()}',
);
} finally { } finally {
isSavingData.value = false; isSavingData.value = false;
} }
} }
// Helper method to parse birth date
DateTime? _parseBirthDate(String dateStr) {
try {
if (dateStr.isEmpty) return null;
if (dateStr.contains('-')) {
return DateTime.parse(dateStr);
}
if (dateStr.contains('/')) {
final parts = dateStr.split('/');
if (parts.length == 3) {
final day = int.parse(parts[0]);
final month = int.parse(parts[1]);
final year = int.parse(parts[2]);
return DateTime(year, month, day);
}
}
return null;
} catch (e) {
Logger().e('Error parsing birth date: $e');
return null;
}
}
// Helper to hash NIK
String hashNik(String nik) {
final bytes = utf8.encode(nik);
final digest = sha256.convert(bytes);
return digest.toString();
}
@override @override
void onClose() { void onClose() {
// Dispose form controllers // Dispose form controllers
nikController.dispose(); // nikController.dispose();
nrpController.dispose(); // nrpController.dispose();
fullNameController.dispose(); // fullNameController.dispose();
placeOfBirthController.dispose(); // placeOfBirthController.dispose();
birthDateController.dispose(); // birthDateController.dispose();
addressController.dispose(); // addressController.dispose();
super.onClose(); super.onClose();
} }
} }

View File

@ -5,7 +5,6 @@ import 'package:sigap/src/features/auth/data/models/kta_model.dart';
import 'package:sigap/src/features/auth/data/repositories/authentication_repository.dart'; import 'package:sigap/src/features/auth/data/repositories/authentication_repository.dart';
import 'package:sigap/src/features/daily-ops/data/models/index.dart'; import 'package:sigap/src/features/daily-ops/data/models/index.dart';
import 'package:sigap/src/features/daily-ops/data/repositories/units_repository.dart'; import 'package:sigap/src/features/daily-ops/data/repositories/units_repository.dart';
import 'package:sigap/src/features/personalization/data/models/models/user_metadata_model.dart';
import 'package:sigap/src/features/personalization/data/repositories/officers_repository.dart'; import 'package:sigap/src/features/personalization/data/repositories/officers_repository.dart';
import 'package:sigap/src/features/personalization/data/repositories/users_repository.dart'; import 'package:sigap/src/features/personalization/data/repositories/users_repository.dart';
import 'package:sigap/src/shared/widgets/state_screeen/state_screen.dart'; import 'package:sigap/src/shared/widgets/state_screeen/state_screen.dart';

View File

@ -52,7 +52,7 @@ class EmailVerificationScreen extends StatelessWidget {
Widget _buildVerificationForm(EmailVerificationController controller) { Widget _buildVerificationForm(EmailVerificationController controller) {
final isResendEnabled = controller.isResendEnabled.value; final isResendEnabled = controller.isResendEnabled.value;
final email = Get.arguments['email'];
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
@ -73,7 +73,8 @@ class EmailVerificationScreen extends StatelessWidget {
controller: controller.otpControllers[index], controller: controller.otpControllers[index],
focusNode: controller.focusNodes[index], focusNode: controller.focusNodes[index],
autoFocus: index == 0, autoFocus: index == 0,
onChanged: (value) => controller.onOtpChanged(value, index), onChanged:
(value) => controller.onOtpChanged(value, index, email),
), ),
), ),
), ),
@ -100,7 +101,7 @@ class EmailVerificationScreen extends StatelessWidget {
Obx( Obx(
() => AuthButton( () => AuthButton(
text: 'Verify', text: 'Verify',
onPressed: controller.verifyOtp, onPressed: () => controller.verifyOtp(email),
isLoading: controller.isLoading.value, isLoading: controller.isLoading.value,
), ),
), ),

View File

@ -41,18 +41,13 @@ class ForgotPasswordScreen extends StatelessWidget {
), ),
), ),
body: SafeArea( body: SafeArea(
child: Center(
child: SingleChildScrollView( child: SingleChildScrollView(
child: Padding( child: Padding(
padding: const EdgeInsets.all(TSizes.defaultSpace), padding: const EdgeInsets.all(TSizes.defaultSpace),
child: Obx( child: Obx(
() => Form( () => Form(
key: controller.formKey, key: controller.formKey,
child: child: _buildFormView(controller, context),
controller.isEmailSent.value
? _buildSuccessView(controller)
: _buildFormView(controller, context),
),
), ),
), ),
), ),
@ -103,54 +98,14 @@ class ForgotPasswordScreen extends StatelessWidget {
const SizedBox(height: 24), const SizedBox(height: 24),
// Reset password button // Reset password button
Obx( AuthButton(
() => AuthButton( text: 'Reset Password',
text: 'Reset Password', onPressed: () async {
onPressed: controller.resetPassword, await controller.forgotPassword();
isLoading: controller.isLoading.value, },
), isLoading: controller.isLoading.value,
), ),
], ],
); );
} }
Widget _buildSuccessView(ForgotPasswordController controller) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Logo centered
SvgPicture.asset(TImages.lightAppBgLogo, height: 100, width: 100),
const SizedBox(height: 32),
// Success icon
Icon(Icons.check_circle_outline, size: 80, color: TColors.success),
const SizedBox(height: 24),
// Success message
Text(
'Email Sent',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: TColors.textPrimary,
),
),
const SizedBox(height: 16),
Text(
'We have sent a password recovery instructions to your email.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 16, color: TColors.textSecondary),
),
const SizedBox(height: 32),
// Back to sign in button
AuthButton(text: 'Back to Sign In', onPressed: controller.goBack),
],
);
}
} }

View File

@ -0,0 +1,277 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:get/get.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/forgot-password/forgot_password_controller.dart';
import 'package:sigap/src/features/auth/presentasion/pages/forgot-password/reset_password.dart';
import 'package:sigap/src/features/auth/presentasion/widgets/auth_button.dart';
import 'package:sigap/src/features/auth/presentasion/widgets/otp_input_field.dart';
import 'package:sigap/src/utils/constants/image_strings.dart';
import 'package:sigap/src/utils/constants/sizes.dart';
class OTPResetPasswordPage extends StatefulWidget {
const OTPResetPasswordPage({super.key});
@override
State<OTPResetPasswordPage> createState() => _OTPResetPasswordPageState();
}
class _OTPResetPasswordPageState extends State<OTPResetPasswordPage> {
final List<TextEditingController> _otpControllers = List.generate(
6,
(_) => TextEditingController(),
);
final List<FocusNode> _focusNodes = List.generate(6, (_) => FocusNode());
ForgotPasswordController get controller => ForgotPasswordController.instance;
String? _email;
@override
void initState() {
super.initState();
final args = Get.arguments;
_email =
args != null && args['email'] != null ? args['email'] as String : null;
// Start cooldown if not already running
if (!controller.isResendAvailable.value &&
controller.resendCooldown.value == controller.resendCooldownDuration) {
controller.startResendCooldown();
}
// Add listeners for better navigation
for (int i = 0; i < 6; i++) {
_focusNodes[i].addListener(() {
if (_focusNodes[i].hasFocus) {
_otpControllers[i].selection = TextSelection(
baseOffset: 0,
extentOffset: _otpControllers[i].text.length,
);
}
});
}
}
@override
void dispose() {
for (final c in _otpControllers) {
c.dispose();
}
for (final f in _focusNodes) {
f.dispose();
}
super.dispose();
}
void _onOtpChanged(int index, String value) {
// Handle paste - if user pastes 6 digits in any field
if (value.length == 6 && RegExp(r'^\d{6}$').hasMatch(value)) {
_handlePaste(value);
return;
}
// Handle single digit input
if (value.length == 1 && RegExp(r'^\d$').hasMatch(value)) {
_otpControllers[index].text = value;
_moveToNext(index);
} else if (value.isEmpty) {
_otpControllers[index].text = '';
_moveToPrevious(index);
} else {
// If user types more than 1 digit, take only the last digit
if (value.length > 1) {
final lastDigit = value.substring(value.length - 1);
if (RegExp(r'^\d$').hasMatch(lastDigit)) {
_otpControllers[index].text = lastDigit;
_moveToNext(index);
}
}
}
setState(() {});
}
void _handlePaste(String pastedValue) {
// Fill all fields with pasted value
for (int i = 0; i < 6 && i < pastedValue.length; i++) {
_otpControllers[i].text = pastedValue[i];
}
// Move focus to the last filled field or next empty field
int lastFilledIndex = pastedValue.length - 1;
if (lastFilledIndex >= 5) {
_focusNodes[5].requestFocus();
} else {
_focusNodes[lastFilledIndex + 1].requestFocus();
}
setState(() {});
}
void _moveToNext(int currentIndex) {
if (currentIndex < 5) {
_focusNodes[currentIndex + 1].requestFocus();
} else {
// If it's the last field, remove focus to hide keyboard
_focusNodes[currentIndex].unfocus();
}
}
void _moveToPrevious(int currentIndex) {
if (currentIndex > 0) {
_focusNodes[currentIndex - 1].requestFocus();
}
}
bool _onKeyEvent(KeyEvent event, int index) {
// Handle backspace key
if (event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.backspace) {
if (_otpControllers[index].text.isEmpty && index > 0) {
_focusNodes[index - 1].requestFocus();
_otpControllers[index - 1].text = '';
setState(() {});
return true;
}
}
return false;
}
String get _otpValue => _otpControllers.map((c) => c.text).join();
bool get _isOtpComplete => _otpValue.length == 6;
void _submitOtp() async {
if (!_isOtpComplete) {
// Show error if OTP is not complete
controller.otpError.value = 'Please enter complete 6-digit OTP';
return;
}
controller.otpController.text = _otpValue;
await controller.verifyOtp();
if (controller.isOtpVerified.value) {
Get.to(() => ResetPasswordFormPage());
}
}
void _clearOtp() {
for (final controller in _otpControllers) {
controller.clear();
}
_focusNodes[0].requestFocus();
setState(() {});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Verify OTP'),
centerTitle: true,
actions: [
if (_otpValue.isNotEmpty)
IconButton(
icon: const Icon(Icons.clear),
onPressed: _clearOtp,
tooltip: 'Clear OTP',
),
],
),
body: Padding(
padding: const EdgeInsets.all(TSizes.defaultSpace),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
Align(
alignment: Alignment.center,
child: SvgPicture.asset(
TImages.lightAppBgLogo,
height: 100,
width: 100,
),
),
const SizedBox(height: 16),
const Text(
'Enter the 6-digit OTP sent to your email',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
),
const SizedBox(height: TSizes.spaceBtwSections),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(
6,
(i) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: KeyboardListener(
focusNode: FocusNode(),
onKeyEvent: (event) => _onKeyEvent(event, i),
child: OtpInputField(
controller: _otpControllers[i],
focusNode: _focusNodes[i],
autoFocus: i == 0,
maxLength: 6, // Allow paste in any field
onChanged: (val) => _onOtpChanged(i, val),
),
),
),
),
),
const SizedBox(height: TSizes.spaceBtwSections),
Obx(
() => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (controller.otpError.value.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
controller.otpError.value,
style: const TextStyle(color: Colors.red, fontSize: 14),
),
),
AuthButton(
text: 'Verify',
isLoading: controller.isOtpLoading.value,
onPressed:
controller.isOtpLoading.value ? () {} : _submitOtp,
),
],
),
),
const SizedBox(height: 16),
// Resend OTP option
Center(
child: Obx(
() => TextButton(
onPressed:
(controller.isResendAvailable.value &&
!controller.isResending.value &&
_email != null)
? () async {
_clearOtp();
await controller.resendOtpWithEmail(_email!);
}
: null,
child:
controller.isResending.value
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: controller.isResendAvailable.value
? const Text('Resend OTP')
: Text(
'Resend OTP in ${controller.resendCooldown.value}s',
),
),
),
),
],
),
),
);
}
}

View File

@ -0,0 +1,92 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:get/get.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/forgot-password/forgot_password_controller.dart';
import 'package:sigap/src/features/auth/presentasion/widgets/auth_button.dart';
import 'package:sigap/src/shared/widgets/text/custom_text_field.dart';
import 'package:sigap/src/utils/constants/image_strings.dart';
import 'package:sigap/src/utils/constants/sizes.dart';
class ResetPasswordFormPage extends StatelessWidget {
ResetPasswordFormPage({super.key});
final ForgotPasswordController controller = ForgotPasswordController.instance;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Reset Password'), centerTitle: true),
body: Padding(
padding: const EdgeInsets.all(TSizes.defaultSpace),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
Align(
alignment: Alignment.center,
child: SvgPicture.asset(
TImages.lightAppBgLogo,
height: 100,
width: 100,
),
),
const SizedBox(height: 16),
const Text(
'Enter your new password',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
),
const SizedBox(height: TSizes.spaceBtwSections),
Obx(
() => CustomTextField(
label: 'New Password',
controller: controller.newPasswordController,
keyboardType: TextInputType.visiblePassword,
obscureText: true,
hintText: 'New password',
validator: controller.validatePassword,
errorText:
controller.passwordError.value.isNotEmpty
? controller.passwordError.value
: null,
),
),
Obx(
() => CustomTextField(
label: 'Confirm Password',
controller: controller.confirmPasswordController,
keyboardType: TextInputType.visiblePassword,
obscureText: true,
hintText: 'Confirm password',
validator: (val) {
if (val != controller.newPasswordController.text) {
return 'Passwords do not match';
}
return null;
},
errorText:
controller.passwordError.value.isNotEmpty
? controller.passwordError.value
: null,
),
),
const SizedBox(height: TSizes.spaceBtwSections),
Obx(
() => AuthButton(
text: 'Reset Password',
isLoading: controller.isResetLoading.value,
onPressed:
controller.isResetLoading.value
? () {}
: () async {
await controller.resetPassword();
// TODO: Navigate to login or show success if needed
},
),
),
],
),
),
);
}
}

View File

@ -27,27 +27,25 @@ class SignupWithRoleScreen extends StatelessWidget {
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.all(TSizes.defaultSpace), padding: const EdgeInsets.all(TSizes.defaultSpace),
child: Center( child: Center(
child: Container( child: Column(
child: Column( mainAxisSize: MainAxisSize.min,
mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center, children: [
children: [ // Logo di dalam container form
// Logo di dalam container form Padding(
Padding( padding: const EdgeInsets.only(bottom: TSizes.lg),
padding: const EdgeInsets.only(bottom: TSizes.lg), child: Hero(
child: Hero( tag: 'app_logo',
tag: 'app_logo', child: SvgPicture.asset(
child: SvgPicture.asset( isDark ? TImages.darkAppBgLogo : TImages.lightAppBgLogo,
isDark ? TImages.darkAppBgLogo : TImages.lightAppBgLogo, width: 100,
width: 100, height: 100,
height: 100, fit: BoxFit.contain,
fit: BoxFit.contain,
),
), ),
), ),
_buildSignupForm(context, controller), ),
], _buildSignupForm(context, controller),
), ],
), ),
), ),
), ),

View File

@ -52,43 +52,43 @@ class IdentityVerificationStep extends StatelessWidget {
// Data Summary Section // Data Summary Section
_buildDataSummaryCards(context, isOfficer), _buildDataSummaryCards(context, isOfficer),
GetBuilder<IdentityVerificationController>( // GetBuilder<IdentityVerificationController>(
id: 'saveButton', // id: 'saveButton',
builder: // builder:
(ctrl) => ElevatedButton( // (ctrl) => ElevatedButton(
onPressed: // onPressed:
ctrl.isSavingData.value // ctrl.isSavingData.value
? null // ? null
: () => _submitRegistrationData(controller), // : () => _submitRegistrationData(controller),
style: ElevatedButton.styleFrom( // style: ElevatedButton.styleFrom(
backgroundColor: TColors.primary, // backgroundColor: TColors.primary,
foregroundColor: Colors.white, // foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 50), // minimumSize: const Size(double.infinity, 50),
shape: RoundedRectangleBorder( // shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(TSizes.buttonRadius), // borderRadius: BorderRadius.circular(TSizes.buttonRadius),
), // ),
disabledBackgroundColor: TColors.primary.withOpacity(0.3), // disabledBackgroundColor: TColors.primary.withOpacity(0.3),
), // ),
child: // child:
ctrl.isSavingData.value // ctrl.isSavingData.value
? const Row( // ? const Row(
mainAxisAlignment: MainAxisAlignment.center, // mainAxisAlignment: MainAxisAlignment.center,
children: [ // children: [
SizedBox( // SizedBox(
width: 20, // width: 20,
height: 20, // height: 20,
child: CircularProgressIndicator( // child: CircularProgressIndicator(
color: Colors.white, // color: Colors.white,
strokeWidth: 2, // strokeWidth: 2,
), // ),
), // ),
SizedBox(width: TSizes.sm), // SizedBox(width: TSizes.sm),
Text('Submitting...'), // Text('Submitting...'),
], // ],
) // )
: const Text('Submit Registration'), // : const Text('Submit Registration'),
), // ),
), // ),
Obx( Obx(
() => StepNavigationButtons( () => StepNavigationButtons(

View File

@ -59,7 +59,7 @@ class SelfieVerificationStep extends StatelessWidget {
_buildHeader(), _buildHeader(),
// Development mode indicator // Development mode indicator
_buildDevelopmentModeIndicator(facialVerificationService), // _buildDevelopmentModeIndicator(facialVerificationService),
// Main verification flow // Main verification flow
_buildVerificationFlow(controller), _buildVerificationFlow(controller),

View File

@ -22,9 +22,8 @@ class AuthButton extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isDark = THelperFunctions.isDarkMode(context); final isDark = THelperFunctions.isDarkMode(context);
return SizedBox( return ConstrainedBox(
width: double.infinity, constraints: const BoxConstraints(minWidth: double.infinity),
height: 55,
child: ElevatedButton( child: ElevatedButton(
onPressed: isLoading ? null : onPressed, onPressed: isLoading ? null : onPressed,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
@ -32,6 +31,9 @@ class AuthButton extends StatelessWidget {
foregroundColor: foregroundColor:
textColor ?? (isDark ? TColors.primary : TColors.accent), textColor ?? (isDark ? TColors.primary : TColors.accent),
elevation: 1, elevation: 1,
padding: const EdgeInsets.symmetric(
vertical: 16,
), // padding agar tombol tidak terlalu tipis
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(TSizes.buttonRadius), borderRadius: BorderRadius.circular(TSizes.buttonRadius),
), ),

View File

@ -7,9 +7,9 @@ class AuthDivider extends StatelessWidget {
final String text; final String text;
const AuthDivider({ const AuthDivider({
Key? key, super.key,
required this.text, required this.text,
}) : super(key: key); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View File

@ -9,6 +9,7 @@ class OtpInputField extends StatelessWidget {
final FocusNode focusNode; final FocusNode focusNode;
final Function(String) onChanged; final Function(String) onChanged;
final bool autoFocus; final bool autoFocus;
final int maxLength;
const OtpInputField({ const OtpInputField({
super.key, super.key,
@ -16,6 +17,7 @@ class OtpInputField extends StatelessWidget {
required this.focusNode, required this.focusNode,
required this.onChanged, required this.onChanged,
this.autoFocus = false, this.autoFocus = false,
this.maxLength = 1,
}); });
@override @override
@ -31,11 +33,14 @@ class OtpInputField extends StatelessWidget {
controller: controller, controller: controller,
focusNode: focusNode, focusNode: focusNode,
autofocus: autoFocus, autofocus: autoFocus,
onChanged: onChanged, onChanged: (value) {
// If user pastes multiple digits, let parent handle
onChanged(value);
},
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
textAlign: TextAlign.center, textAlign: TextAlign.center,
inputFormatters: [ inputFormatters: [
LengthLimitingTextInputFormatter(1), LengthLimitingTextInputFormatter(maxLength),
FilteringTextInputFormatter.digitsOnly, FilteringTextInputFormatter.digitsOnly,
], ],
style: Theme.of(context).textTheme.headlineSmall?.copyWith( style: Theme.of(context).textTheme.headlineSmall?.copyWith(

View File

@ -282,29 +282,35 @@ class PatrolUnitController extends GetxController {
String? validateRadiusRange(String patrolType, double radius) { String? validateRadiusRange(String patrolType, double radius) {
switch (patrolType) { switch (patrolType) {
case 'car': case 'car':
if (radius < 5000) if (radius < 5000) {
return 'Car patrols should have at least 5000m radius'; return 'Car patrols should have at least 5000m radius';
}
if (radius > 8000) return 'Car patrol radius should not exceed 8000m'; if (radius > 8000) return 'Car patrol radius should not exceed 8000m';
break; break;
case 'motorcycle': case 'motorcycle':
if (radius < 3000) if (radius < 3000) {
return 'Motorcycle patrols should have at least 3000m radius'; return 'Motorcycle patrols should have at least 3000m radius';
if (radius > 5000) }
if (radius > 5000) {
return 'Motorcycle patrol radius should not exceed 5000m'; return 'Motorcycle patrol radius should not exceed 5000m';
}
break; break;
case 'foot': case 'foot':
if (radius < 500) if (radius < 500) {
return 'Foot patrols should have at least 500m radius'; return 'Foot patrols should have at least 500m radius';
}
if (radius > 1500) return 'Foot patrol radius should not exceed 1500m'; if (radius > 1500) return 'Foot patrol radius should not exceed 1500m';
break; break;
case 'drone': case 'drone':
if (radius < 2000) if (radius < 2000) {
return 'Drone patrols should have at least 2000m radius'; return 'Drone patrols should have at least 2000m radius';
}
if (radius > 4000) return 'Drone patrol radius should not exceed 4000m'; if (radius > 4000) return 'Drone patrol radius should not exceed 4000m';
break; break;
case 'mixed': case 'mixed':
if (radius < 2000) if (radius < 2000) {
return 'Mixed patrols should have at least 2000m radius'; return 'Mixed patrols should have at least 2000m radius';
}
if (radius > 6000) return 'Mixed patrol radius should not exceed 6000m'; if (radius > 6000) return 'Mixed patrol radius should not exceed 6000m';
break; break;
} }

View File

@ -140,16 +140,18 @@ class OfficerModel {
if (email != null) json['email'] = email; if (email != null) json['email'] = email;
if (avatar != null) json['avatar'] = avatar; if (avatar != null) json['avatar'] = avatar;
if (placeOfBirth != null) json['place_of_birth'] = placeOfBirth; if (placeOfBirth != null) json['place_of_birth'] = placeOfBirth;
if (dateOfBirth != null) if (dateOfBirth != null) {
json['date_of_birth'] = dateOfBirth!.toIso8601String(); json['date_of_birth'] = dateOfBirth!.toIso8601String();
}
if (validUntil != null) json['valid_until'] = validUntil!.toIso8601String(); if (validUntil != null) json['valid_until'] = validUntil!.toIso8601String();
if (qrCode != null) json['qr_code'] = qrCode; if (qrCode != null) json['qr_code'] = qrCode;
if (createdAt != null) json['created_at'] = createdAt!.toIso8601String(); if (createdAt != null) json['created_at'] = createdAt!.toIso8601String();
if (updatedAt != null) json['updated_at'] = updatedAt!.toIso8601String(); if (updatedAt != null) json['updated_at'] = updatedAt!.toIso8601String();
// New fields // New fields
if (bannedReason != null) json['banned_reason'] = bannedReason; if (bannedReason != null) json['banned_reason'] = bannedReason;
if (bannedUntil != null) if (bannedUntil != null) {
json['banned_until'] = bannedUntil!.toIso8601String(); json['banned_until'] = bannedUntil!.toIso8601String();
}
json['is_banned'] = isBanned; json['is_banned'] = isBanned;
json['panic_strike'] = panicStrike; json['panic_strike'] = panicStrike;
json['spoofing_attempts'] = spoofingAttempts; json['spoofing_attempts'] = spoofingAttempts;

View File

@ -3,7 +3,7 @@ import 'package:sigap/src/features/map/data/models/models/cities_model.dart';
import 'package:sigap/src/features/map/data/models/models/demographics_model.dart'; import 'package:sigap/src/features/map/data/models/models/demographics_model.dart';
import 'package:sigap/src/features/map/data/models/models/geographics_model.dart'; import 'package:sigap/src/features/map/data/models/models/geographics_model.dart';
import 'package:sigap/src/features/map/data/models/models/locations_model.dart'; import 'package:sigap/src/features/map/data/models/models/locations_model.dart';
import 'package:sigap/src/features/panic-button/data/models/models/crimes_model.dart'; import 'package:sigap/src/features/panic/datas/models/models/crimes_model.dart';
class DistrictModel { class DistrictModel {
final String id; final String id;

View File

@ -1,7 +1,7 @@
import 'package:sigap/src/features/daily-ops/data/models/models/patrol_units_model.dart'; import 'package:sigap/src/features/daily-ops/data/models/models/patrol_units_model.dart';
import 'package:sigap/src/features/panic-button/data/models/models/crime_incidents_model.dart'; import 'package:sigap/src/features/panic/datas/models/models/crime_incidents_model.dart';
import 'package:sigap/src/features/panic-button/data/models/models/events_model.dart'; import 'package:sigap/src/features/panic/datas/models/models/events_model.dart';
import 'package:sigap/src/features/panic-button/data/models/models/incident_logs_model.dart'; import 'package:sigap/src/features/panic/datas/models/models/incident_logs_model.dart';
class LocationModel { class LocationModel {
final String id; final String id;

View File

@ -21,6 +21,7 @@ class CheckLocationController extends GetxController
final RxString errorMessage = ''.obs; final RxString errorMessage = ''.obs;
final RxBool showUIElements = true.obs; final RxBool showUIElements = true.obs;
final RxBool bottomSheetShown = false.obs; final RxBool bottomSheetShown = false.obs;
final RxBool isCarouselLocked = false.obs;
// Controllers // Controllers
late PageController pageController; late PageController pageController;
@ -137,6 +138,20 @@ class CheckLocationController extends GetxController
currentPage.value = index; currentPage.value = index;
} }
/// Pindahkan ke page index tertentu, kunci carousel, lalu jalankan validasi lokasi
Future<void> moveToPageAndCheckLocation(int pageIndex) async {
isCarouselLocked.value = true;
if (currentPage.value != 1 && pageController.hasClients) {
await pageController.animateToPage(
1,
duration: const Duration(milliseconds: 400),
curve: Curves.easeInOut,
);
onPageChanged(pageIndex);
}
await checkLocation();
}
Future<void> checkLocation() async { Future<void> checkLocation() async {
isLoading.value = true; isLoading.value = true;
try { try {
@ -145,12 +160,7 @@ class CheckLocationController extends GetxController
if (result['valid']) { if (result['valid']) {
isLoading.value = false; isLoading.value = false;
isLocationValid.value = true; isLocationValid.value = true;
// Navigate to communication slide before zooming
_navigateToCommunicationSlide();
storage.write('isFirstTime', false); storage.write('isFirstTime', false);
// Give time to see the success message before animating // Give time to see the success message before animating
await Future.delayed(Duration(milliseconds: 800)); await Future.delayed(Duration(milliseconds: 800));
animController animController
@ -159,14 +169,15 @@ class CheckLocationController extends GetxController
isLoading.value = false; isLoading.value = false;
isLocationValid.value = false; isLocationValid.value = false;
errorMessage.value = result['message'] ?? 'Location is invalid'; errorMessage.value = result['message'] ?? 'Location is invalid';
// Force page to show error slide // Force page to show error slide
pageController.jumpToPage(slides.length - 1); pageController.jumpToPage(slides.length - 1);
isCarouselLocked.value = false; // unlock carousel jika gagal
} }
} catch (e) { } catch (e) {
isLoading.value = false; isLoading.value = false;
isLocationValid.value = false; isLocationValid.value = false;
errorMessage.value = 'Failed to check location. Please try again.'; errorMessage.value = 'Failed to check location. Please try again.';
isCarouselLocked.value = false; // unlock carousel jika error
} }
} }
@ -181,8 +192,8 @@ class CheckLocationController extends GetxController
.forward(); // This will trigger the navigation when animation completes .forward(); // This will trigger the navigation when animation completes
}); });
} else { } else {
isLoading.value = true; // Ubah: kunci carousel dan pindah ke page 1 sebelum validasi lokasi
checkLocation(); moveToPageAndCheckLocation(1);
} }
} }

View File

@ -1,4 +1,5 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:sigap/src/features/personalization/data/models/models/roles_model.dart'; import 'package:sigap/src/features/personalization/data/models/models/roles_model.dart';
import 'package:sigap/src/features/personalization/data/repositories/roles_repository.dart'; import 'package:sigap/src/features/personalization/data/repositories/roles_repository.dart';
import 'package:sigap/src/utils/popups/loaders.dart'; import 'package:sigap/src/utils/popups/loaders.dart';
@ -23,10 +24,13 @@ class RoleSelectionController extends GetxController {
// Loading state // Loading state
final RxBool isLoading = false.obs; final RxBool isLoading = false.obs;
final RxBool isOfficer = false.obs; final RxBool isOfficer = false.obs;
// Flag to track role loading state // Flag to track role loading state
final isRolesLoading = true.obs; final isRolesLoading = true.obs;
// Local storage
final storage = GetStorage();
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:sigap/src/features/onboarding/presentasion/controllers/check_location_controller.dart'; import 'package:sigap/src/features/onboarding/presentasion/controllers/check_location_controller.dart';
import 'package:sigap/src/utils/constants/colors.dart';
class CheckLocationScreen extends StatefulWidget { class CheckLocationScreen extends StatefulWidget {
final VoidCallback? onSuccess; final VoidCallback? onSuccess;
@ -16,6 +17,9 @@ class _CheckLocationScreenState extends State<CheckLocationScreen> {
// Controller // Controller
late CheckLocationController controller; late CheckLocationController controller;
// Tambahkan flag untuk memastikan zoom hanya berjalan sekali
bool _zoomStarted = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -33,14 +37,25 @@ class _CheckLocationScreenState extends State<CheckLocationScreen> {
isScrollControlled: true, isScrollControlled: true,
builder: (context) { builder: (context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
return Container( return Container(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.cardColor, color: isDark ? const Color(0xFF23272F) : Colors.white,
borderRadius: const BorderRadius.only( borderRadius: const BorderRadius.only(
topLeft: Radius.circular(24), topLeft: Radius.circular(24),
topRight: Radius.circular(24), topRight: Radius.circular(24),
), ),
boxShadow: [
BoxShadow(
color:
isDark
? Colors.black.withOpacity(0.7)
: Colors.grey.withOpacity(0.15),
blurRadius: 16,
offset: const Offset(0, -4),
),
],
), ),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@ -50,10 +65,7 @@ class _CheckLocationScreenState extends State<CheckLocationScreen> {
height: 5, height: 5,
margin: const EdgeInsets.only(bottom: 20), margin: const EdgeInsets.only(bottom: 20),
decoration: BoxDecoration( decoration: BoxDecoration(
color: color: isDark ? Colors.grey[700] : Colors.grey[300],
theme.brightness == Brightness.light
? Colors.grey[300]
: Colors.grey[700],
borderRadius: BorderRadius.circular(3), borderRadius: BorderRadius.circular(3),
), ),
), ),
@ -61,6 +73,7 @@ class _CheckLocationScreenState extends State<CheckLocationScreen> {
'Location Validation Required', 'Location Validation Required',
style: theme.textTheme.titleLarge?.copyWith( style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: isDark ? Colors.white : Colors.black,
), ),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
@ -68,7 +81,9 @@ class _CheckLocationScreenState extends State<CheckLocationScreen> {
Text( Text(
'This application is designed specifically for users in the Jember region. We need to validate your location to ensure all services function properly.', 'This application is designed specifically for users in the Jember region. We need to validate your location to ensure all services function properly.',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium, style: theme.textTheme.bodyMedium?.copyWith(
color: isDark ? Colors.grey[300] : Colors.grey[700],
),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
Row( Row(
@ -80,6 +95,7 @@ class _CheckLocationScreenState extends State<CheckLocationScreen> {
'Press "Check Location" button to proceed', 'Press "Check Location" button to proceed',
style: theme.textTheme.bodyMedium?.copyWith( style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: isDark ? Colors.white : Colors.black,
), ),
), ),
], ],
@ -88,14 +104,20 @@ class _CheckLocationScreenState extends State<CheckLocationScreen> {
ElevatedButton( ElevatedButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
minimumSize: Size(200, 50), minimumSize: const Size(200, 50),
backgroundColor: theme.primaryColor, backgroundColor: theme.primaryColor,
foregroundColor: Colors.white, foregroundColor: isDark ? Colors.white : Colors.white,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(30), borderRadius: BorderRadius.circular(30),
), ),
elevation: isDark ? 0 : 2,
),
child: Text(
'Got it',
style: TextStyle(
color: isDark ? TColors.primary : TColors.white,
),
), ),
child: Text('Got it'),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
], ],
@ -105,6 +127,20 @@ class _CheckLocationScreenState extends State<CheckLocationScreen> {
); );
} }
// Method untuk memindahkan PageView ke tengah
Future<void> _moveToCenterPage() async {
final middlePage = (controller.slides.length / 2).floor();
if (controller.currentPage.value != middlePage &&
controller.pageController.hasClients) {
await controller.pageController.animateToPage(
middlePage,
duration: const Duration(milliseconds: 400),
curve: Curves.easeInOut,
);
controller.onPageChanged(middlePage);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
@ -118,7 +154,10 @@ class _CheckLocationScreenState extends State<CheckLocationScreen> {
}); });
return Scaffold( return Scaffold(
backgroundColor: theme.scaffoldBackgroundColor, backgroundColor:
theme.brightness == Brightness.dark
? const Color(0xFF181A20)
: theme.scaffoldBackgroundColor,
body: SafeArea( body: SafeArea(
child: Column( child: Column(
children: [ children: [
@ -136,6 +175,13 @@ class _CheckLocationScreenState extends State<CheckLocationScreen> {
Icons.info_outline, Icons.info_outline,
color: theme.primaryColor, color: theme.primaryColor,
size: 24, size: 24,
shadows: [
if (theme.brightness == Brightness.dark)
const Shadow(
color: Colors.black54,
blurRadius: 4,
),
],
), ),
), ),
), ),
@ -148,16 +194,38 @@ class _CheckLocationScreenState extends State<CheckLocationScreen> {
child: AnimatedBuilder( child: AnimatedBuilder(
animation: controller.scaleAnimation, animation: controller.scaleAnimation,
builder: (context, child) { builder: (context, child) {
// Calculate current page as an integer for clean zooming on the correct card
final currentPageIndex = controller.currentPage.value; final currentPageIndex = controller.currentPage.value;
final middlePage = (controller.slides.length / 2).floor();
// Ensure we're centered on current page during animation // Jika lokasi valid dan belum zoom, pindahkan ke tengah dulu
if (controller.isLocationValid.value && if (controller.isLocationValid.value &&
controller.scaleAnimation.value > 1.0 && !_zoomStarted) {
controller.pageController.hasClients && if (currentPageIndex != middlePage &&
controller.pageController.page?.round() != controller.pageController.hasClients) {
currentPageIndex) { // Pindahkan ke tengah, lalu setelah selesai mulai zoom
controller.pageController.jumpToPage(currentPageIndex); _moveToCenterPage().then((_) {
if (!_zoomStarted && mounted) {
setState(() {
_zoomStarted = true;
});
controller.animController.forward();
}
});
// Jangan zoom sebelum pindah ke tengah
return child!;
} else {
// Sudah di tengah, mulai zoom jika belum
if (!_zoomStarted) {
_zoomStarted = true;
controller.animController.forward();
}
}
}
// Reset zoom flag jika validasi lokasi diulang
if (!controller.isLocationValid.value && _zoomStarted) {
_zoomStarted = false;
controller.animController.reset();
} }
return Transform.scale( return Transform.scale(
@ -173,8 +241,8 @@ class _CheckLocationScreenState extends State<CheckLocationScreen> {
onPageChanged: controller.onPageChanged, onPageChanged: controller.onPageChanged,
itemCount: controller.slides.length, itemCount: controller.slides.length,
physics: physics:
controller.isLocationValid.value controller.isCarouselLocked.value
? const NeverScrollableScrollPhysics() // Lock scrolling during animation ? const NeverScrollableScrollPhysics()
: null, : null,
itemBuilder: (context, index) { itemBuilder: (context, index) {
return _buildCarouselCard(index, theme); return _buildCarouselCard(index, theme);
@ -361,6 +429,7 @@ class _CheckLocationScreenState extends State<CheckLocationScreen> {
Widget _buildCarouselCard(int index, ThemeData theme) { Widget _buildCarouselCard(int index, ThemeData theme) {
final slide = controller.slides[index]; final slide = controller.slides[index];
final isDark = theme.brightness == Brightness.dark;
// Fix: Removed nested Obx and directly use controller values // Fix: Removed nested Obx and directly use controller values
return AnimatedContainer( return AnimatedContainer(
@ -371,25 +440,39 @@ class _CheckLocationScreenState extends State<CheckLocationScreen> {
vertical: index == controller.currentPage.value ? 10 : 30, vertical: index == controller.currentPage.value ? 10 : 30,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: color: isDark ? TColors.primary : TColors.secondary,
theme.brightness == Brightness.light
? Colors.grey[200]
: Colors.grey[800],
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(24),
boxShadow: boxShadow:
index == controller.currentPage.value index == controller.currentPage.value
? [ ? [
BoxShadow( BoxShadow(
color: Colors.black.withOpacity(0.1), color:
blurRadius: 10, isDark
offset: const Offset(0, 5), ? Colors.black.withOpacity(0.5)
: Colors.black.withOpacity(0.1),
blurRadius: 14,
offset: const Offset(0, 6),
), ),
] ]
: [], : [],
border: Border.all(
color:
isDark
? Colors.white.withOpacity(0.05)
: Colors.grey.withOpacity(0.08),
width: 1,
),
), ),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(24),
child: SvgPicture.asset(slide['image'], fit: BoxFit.contain), child: SvgPicture.asset(
slide['image'],
fit: BoxFit.contain,
colorFilter:
isDark
? const ColorFilter.mode(Colors.white, BlendMode.srcIn)
: null,
),
), ),
); );
} }

View File

@ -1,24 +1,37 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:lottie/lottie.dart'; import 'package:lottie/lottie.dart';
import 'package:sigap/src/cores/services/location_service.dart'; import 'package:sigap/src/cores/services/location_service.dart';
import 'package:sigap/src/utils/constants/app_routes.dart'; import 'package:sigap/src/features/auth/data/repositories/authentication_repository.dart';
import 'package:sigap/src/utils/constants/colors.dart';
import 'package:sigap/src/utils/constants/image_strings.dart'; import 'package:sigap/src/utils/constants/image_strings.dart';
import 'package:sigap/src/utils/constants/sizes.dart'; import 'package:sigap/src/utils/constants/sizes.dart';
import 'package:sigap/src/utils/helpers/helper_functions.dart';
import 'package:sigap/src/utils/popups/loaders.dart'; import 'package:sigap/src/utils/popups/loaders.dart';
class LocationWarningScreen extends StatelessWidget { class LocationWarningScreen extends StatefulWidget {
const LocationWarningScreen({super.key}); const LocationWarningScreen({super.key});
@override
State<LocationWarningScreen> createState() => _LocationWarningScreenState();
}
class _LocationWarningScreenState extends State<LocationWarningScreen> {
bool _isLoading = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final locationService = Get.find<LocationService>(); final locationService = Get.find<LocationService>();
final isMocked = locationService.isMockedLocation.value; final isMocked = locationService.isMockedLocation.value;
final isOutsideJember = !locationService.isInJember(); final isOutsideJember = !locationService.isInJember();
final authRepository = Get.find<AuthenticationRepository>();
final storage = GetStorage();
// Get theme data for consistent styling // Get theme data for consistent styling
final theme = Theme.of(context); final theme = Theme.of(context);
final isDark = THelperFunctions.isDarkMode(context);
return Scaffold( return Scaffold(
// Use theme background color instead of hardcoded color // Use theme background color instead of hardcoded color
backgroundColor: theme.scaffoldBackgroundColor, backgroundColor: theme.scaffoldBackgroundColor,
@ -36,7 +49,11 @@ class LocationWarningScreen extends StatelessWidget {
height: 200, height: 200,
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) { errorBuilder: (context, error, stackTrace) {
return const Icon(Icons.error, size: 100, color: Colors.red); return const Icon(
Icons.error,
size: 100,
color: TColors.error,
);
}, },
), ),
@ -69,34 +86,58 @@ class LocationWarningScreen extends StatelessWidget {
// Try Again Button - full width // Try Again Button - full width
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
height: 56,
child: ElevatedButton( child: ElevatedButton(
onPressed: () async { onPressed:
final isValid = _isLoading
await locationService.isLocationValidForFeature(); ? null
if (isValid) { : () async {
Get.offAllNamed(AppRoutes.roleSelection); setState(() {
} else { _isLoading = true;
TLoaders.errorSnackBar( });
title: 'Location Verification Failed', final isValid =
message: await locationService
isMocked .isLocationValidForFeature();
? 'Please disable mock location apps to continue.' setState(() {
: 'Ensure you are within Jember region and location services are enabled.', _isLoading = false;
); });
} if (isValid) {
}, authRepository.screenRedirect();
} else {
TLoaders.errorSnackBar(
title: 'Location Verification Failed',
message:
isMocked
? 'Please disable mock location apps to continue.'
: 'Ensure you are within Jember region and location services are enabled.',
);
}
},
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
minimumSize: const Size.fromHeight(56),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(TSizes.buttonRadius), borderRadius: BorderRadius.circular(TSizes.buttonRadius),
), ),
backgroundColor:
isDark ? TColors.secondary : TColors.primary,
elevation: 0, elevation: 0,
), ),
child: Text( child:
'Try Again', _isLoading
style: Theme.of(context).textTheme.titleMedium, ? SizedBox(
), height: 22,
width: 22,
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(
isDark ? TColors.primary : TColors.white,
),
strokeWidth: 2.5,
),
)
: Text(
'Try Again',
style: theme.textTheme.titleMedium?.copyWith(
color: isDark ? TColors.primary : TColors.white,
),
),
), ),
), ),
@ -106,20 +147,21 @@ class LocationWarningScreen extends StatelessWidget {
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: TextButton( child: TextButton(
onPressed: () { onPressed: () {
// This will close the app // Exit the app
Get.back(); Get.close(0);
}, },
style: TextButton.styleFrom( style: TextButton.styleFrom(
foregroundColor: theme.colorScheme.secondary, foregroundColor:
isDark ? TColors.secondary : TColors.primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(TSizes.buttonRadius),
),
), ),
child: Text( child: Text('Exit App', style: theme.textTheme.bodyLarge),
'Exit App',
style: theme.textTheme.bodyLarge),
), ),
), ),
], ],
), ),
), ),
), ),

View File

@ -67,17 +67,17 @@ class RoleSelectionScreen extends StatelessWidget {
), ),
), ),
const SizedBox(height: 40), const SizedBox(height: 40),
Text( // Text(
'Choose your role', // 'Choose your role',
style: TextStyle( // style: TextStyle(
fontSize: 32, // fontSize: 32,
fontWeight: FontWeight.w700, // fontWeight: FontWeight.w700,
color: // color:
isDark ? Colors.white : const Color(0xFF2F2F2F), // isDark ? Colors.white : const Color(0xFF2F2F2F),
letterSpacing: -0.5, // letterSpacing: -0.5,
), // ),
textAlign: TextAlign.center, // textAlign: TextAlign.center,
), // ),
const SizedBox(height: 12), const SizedBox(height: 12),
Obx( Obx(
() => Text( () => Text(
@ -245,26 +245,26 @@ class RoleSelectionScreen extends StatelessWidget {
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
// Second role card // Second role card
Expanded( // Expanded(
child: Obx( // child: Obx(
() => _buildRoleCard( // () => _buildRoleCard(
title: // title:
controller.roles.length > 1 // controller.roles.length > 1
? controller.roles[1].name // ? controller.roles[1].name
: 'Viewer', // : 'Viewer',
isSelected: // isSelected:
controller.selectedRole.value?.id == // controller.selectedRole.value?.id ==
(controller.roles.length > 1 ? controller.roles[1].id : null), // (controller.roles.length > 1 ? controller.roles[1].id : null),
onTap: // onTap:
() => // () =>
controller.roles.length > 1 // controller.roles.length > 1
? controller.selectRole(controller.roles[1]) // ? controller.selectRole(controller.roles[1])
: null, // : null,
illustration: _buildCardViewerIllustration(isDark), // illustration: _buildCardViewerIllustration(isDark),
isDark: isDark, // isDark: isDark,
), // ),
), // ),
), // ),
], ],
); );
} }

View File

@ -47,15 +47,14 @@ class _RoleSignupPageViewState extends State<RoleSignupPageView> {
), ),
Positioned( Positioned(
top: MediaQuery.of(context).padding.top + 16, top: MediaQuery.of(context).padding.top + 16,
left: 0, right: 16,
right: 0,
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.end,
children: List.generate( children: List.generate(
2, 2,
(index) => AnimatedContainer( (index) => AnimatedContainer(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.symmetric(horizontal: 4), margin: const EdgeInsets.symmetric(horizontal: 2),
width: _currentPage == index ? 12 : 8, width: _currentPage == index ? 12 : 8,
height: _currentPage == index ? 12 : 8, height: _currentPage == index ? 12 : 8,
decoration: BoxDecoration( decoration: BoxDecoration(

View File

@ -1,38 +0,0 @@
import 'package:get/get.dart';
import 'package:sigap/src/features/panic-button/data/repositories/crime_incidents_repository.dart';
import 'package:sigap/src/features/panic-button/data/repositories/crimes_repository.dart';
import 'package:sigap/src/features/panic-button/data/repositories/events_repository.dart';
import 'package:sigap/src/features/panic-button/data/repositories/evidences_repository.dart';
import 'package:sigap/src/features/panic-button/data/repositories/incident_logs_repository.dart';
import 'package:sigap/src/features/panic-button/data/repositories/panic_button_repository.dart';
import 'package:sigap/src/features/panic-button/data/repositories/sessions_repository.dart';
class PanicButtonRepositoryBindings implements Bindings {
@override
void dependencies() {
// Register the main panic button repository
Get.lazyPut<PanicButtonRepository>(
() => PanicButtonRepository(),
fenix: true,
);
// Register other related repositories
Get.lazyPut<IncidentLogsRepository>(
() => IncidentLogsRepository(),
fenix: true,
);
Get.lazyPut<EvidencesRepository>(() => EvidencesRepository(), fenix: true);
Get.lazyPut<EventsRepository>(() => EventsRepository(), fenix: true);
Get.lazyPut<CrimesRepository>(() => CrimesRepository(), fenix: true);
Get.lazyPut<CrimeIncidentsRepository>(
() => CrimeIncidentsRepository(),
fenix: true,
);
Get.lazyPut<SessionsRepository>(() => SessionsRepository(), fenix: true);
}
}

View File

@ -1,14 +0,0 @@
export 'models/crime_incidents_model.dart';
export 'models/crimes_model.dart';
export 'models/events_model.dart';
export 'models/evidences_model.dart';
export 'models/incident_logs_model.dart';
export 'models/sessions_model.dart';
export 'supadart-models/crime_categories_model_supadart.dart';
export 'supadart-models/crime_incidents_model_supadart.dart';
export 'supadart-models/crimes_model_supadart.dart';
export 'supadart-models/evidence_model_supadart.dart';
export 'supadart-models/incident_logs_model_supadart.dart';
export 'supadart-models/panic_button_logs_model_supadart.dart';
export 'supadart-models/sessions_model_supadart.dart';

View File

@ -1,13 +1,38 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:sigap/src/features/panic-button/data/repositories/incident_logs_repository.dart'; import 'package:sigap/src/features/panic/datas/repositories/crime_incidents_repository.dart';
import 'package:sigap/src/features/panic-button/data/repositories/panic_button_repository.dart'; import 'package:sigap/src/features/panic/datas/repositories/crimes_repository.dart';
import 'package:sigap/src/features/panic/datas/repositories/events_repository.dart';
import 'package:sigap/src/features/panic/datas/repositories/evidences_repository.dart';
import 'package:sigap/src/features/panic/datas/repositories/incident_logs_repository.dart';
import 'package:sigap/src/features/panic/datas/repositories/panic_button_repository.dart';
import 'package:sigap/src/features/panic/datas/repositories/sessions_repository.dart';
class PanicButtonRepositoryBindings extends Bindings { class PanicButtonRepositoryBindings implements Bindings {
@override @override
void dependencies() { void dependencies() {
// Register the PanicButtonRepository as a singleton // Register the main panic button repository
Get.lazyPut<PanicButtonRepository>(() => PanicButtonRepository()); Get.lazyPut<PanicButtonRepository>(
// Register repositories () => PanicButtonRepository(),
Get.lazyPut<IncidentLogsRepository>(() => IncidentLogsRepository()); fenix: true,
);
// Register other related repositories
Get.lazyPut<IncidentLogsRepository>(
() => IncidentLogsRepository(),
fenix: true,
);
Get.lazyPut<EvidencesRepository>(() => EvidencesRepository(), fenix: true);
Get.lazyPut<EventsRepository>(() => EventsRepository(), fenix: true);
Get.lazyPut<CrimesRepository>(() => CrimesRepository(), fenix: true);
Get.lazyPut<CrimeIncidentsRepository>(
() => CrimeIncidentsRepository(),
fenix: true,
);
Get.lazyPut<SessionsRepository>(() => SessionsRepository(), fenix: true);
} }
} }

View File

@ -1,12 +1,12 @@
class CrimeStatistics { class CrimeStatistics {
final Map<String, dynamic> data; final Map<String, dynamic> data;
CrimeStatistics({required this.data}); CrimeStatistics({required this.data});
factory CrimeStatistics.fromJson(Map<String, dynamic> json) { factory CrimeStatistics.fromJson(Map<String, dynamic> json) {
return CrimeStatistics(data: json); return CrimeStatistics(data: json);
} }
// Add getters or methods to access specific parts of the data // Add getters or methods to access specific parts of the data
dynamic getStatFor(String category) => data[category]; dynamic getStatFor(String category) => data[category];
} }

View File

@ -1,5 +1,5 @@
import 'package:sigap/src/features/map/data/models/models/districts_model.dart'; import 'package:sigap/src/features/map/data/models/models/districts_model.dart';
import 'package:sigap/src/features/panic-button/data/models/models/crime_incidents_model.dart'; import 'package:sigap/src/features/panic/datas/models/models/crime_incidents_model.dart';
enum CrimeRates { low, medium, high, critical } enum CrimeRates { low, medium, high, critical }

View File

@ -1,4 +1,4 @@
import 'package:sigap/src/features/panic-button/data/models/models/incident_logs_model.dart'; import 'package:sigap/src/features/panic/datas/models/models/incident_logs_model.dart';
class EvidenceModel { class EvidenceModel {
final String id; final String id;

Some files were not shown because too many files have changed in this diff Show More