first commit
|
@ -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=hLjsFn1bcxpxpPV2oamYn/INSEgZSaAgdp+A0Mt6
|
AWS_RK_SECRET_KEY=hLjsFn1bcxpxpPV2oamYn/INSEgZSaAgdp+A0Mt6
|
||||||
|
|
|
@ -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. -->
|
|
@ -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. -->
|
|
@ -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. -->
|
|
@ -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
|
|
@ -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}"
|
||||||
|
|
Before Width: | Height: | Size: 538 B After Width: | Height: | Size: 556 B |
Before Width: | Height: | Size: 538 B After Width: | Height: | Size: 556 B |
Before Width: | Height: | Size: 538 B After Width: | Height: | Size: 556 B |
Before Width: | Height: | Size: 392 B After Width: | Height: | Size: 353 B |
Before Width: | Height: | Size: 392 B After Width: | Height: | Size: 353 B |
Before Width: | Height: | Size: 392 B After Width: | Height: | Size: 353 B |
Before Width: | Height: | Size: 528 B After Width: | Height: | Size: 558 B |
Before Width: | Height: | Size: 528 B After Width: | Height: | Size: 558 B |
Before Width: | Height: | Size: 528 B After Width: | Height: | Size: 558 B |
Before Width: | Height: | Size: 412 B After Width: | Height: | Size: 366 B |
Before Width: | Height: | Size: 412 B After Width: | Height: | Size: 366 B |
Before Width: | Height: | Size: 412 B After Width: | Height: | Size: 366 B |
Before Width: | Height: | Size: 587 B After Width: | Height: | Size: 674 B |
Before Width: | Height: | Size: 587 B After Width: | Height: | Size: 674 B |
Before Width: | Height: | Size: 587 B After Width: | Height: | Size: 674 B |
Before Width: | Height: | Size: 754 B After Width: | Height: | Size: 909 B |
Before Width: | Height: | Size: 754 B After Width: | Height: | Size: 909 B |
Before Width: | Height: | Size: 754 B After Width: | Height: | Size: 909 B |
Before Width: | Height: | Size: 708 B After Width: | Height: | Size: 952 B |
Before Width: | Height: | Size: 708 B After Width: | Height: | Size: 952 B |
Before Width: | Height: | Size: 708 B After Width: | Height: | Size: 952 B |
Before Width: | Height: | Size: 669 B After Width: | Height: | Size: 677 B |
Before Width: | Height: | Size: 669 B After Width: | Height: | Size: 677 B |
Before Width: | Height: | Size: 669 B After Width: | Height: | Size: 677 B |
Before Width: | Height: | Size: 871 B After Width: | Height: | Size: 913 B |
Before Width: | Height: | Size: 871 B After Width: | Height: | Size: 913 B |
Before Width: | Height: | Size: 871 B After Width: | Height: | Size: 913 B |
Before Width: | Height: | Size: 836 B After Width: | Height: | Size: 965 B |
Before Width: | Height: | Size: 836 B After Width: | Height: | Size: 965 B |
Before Width: | Height: | Size: 836 B After Width: | Height: | Size: 965 B |
|
@ -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:
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
|
@ -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
|
||||||
|
|
Before Width: | Height: | Size: 392 B After Width: | Height: | Size: 353 B |
Before Width: | Height: | Size: 669 B After Width: | Height: | Size: 677 B |
Before Width: | Height: | Size: 871 B After Width: | Height: | Size: 913 B |
Before Width: | Height: | Size: 412 B After Width: | Height: | Size: 366 B |
Before Width: | Height: | Size: 587 B After Width: | Height: | Size: 674 B |
Before Width: | Height: | Size: 754 B After Width: | Height: | Size: 909 B |
Before Width: | Height: | Size: 392 B After Width: | Height: | Size: 353 B |
Before Width: | Height: | Size: 669 B After Width: | Height: | Size: 677 B |
Before Width: | Height: | Size: 871 B After Width: | Height: | Size: 913 B |
Before Width: | Height: | Size: 412 B After Width: | Height: | Size: 366 B |
Before Width: | Height: | Size: 587 B After Width: | Height: | Size: 674 B |
Before Width: | Height: | Size: 754 B After Width: | Height: | Size: 909 B |
|
@ -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>
|
||||||
|
|
|
@ -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>
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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),
|
||||||
|
),
|
||||||
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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 = '';
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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.';
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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('================================================');
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
@ -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),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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),
|
||||||
),
|
),
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
@ -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,
|
||||||
),
|
// ),
|
||||||
),
|
// ),
|
||||||
),
|
// ),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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';
|
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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];
|
||||||
}
|
}
|
|
@ -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 }
|
||||||
|
|
|
@ -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;
|