feat: Implement Statistics View and Emergency Features

- Added StatisticsViewController to manage crime statistics and safety indicators.
- Created CrimeStatsHeader widget for displaying district and date information.
- Developed EmergencyView with PanicButton and QuickActionButton for emergency actions.
- Introduced MainSafetyIndicator and RecoveryIndicator for visual safety metrics.
- Implemented StatIndicatorCard for displaying various statistics with progress indicators.
- Added functionality to change month and year for crime statistics.
- Integrated loading states and error handling in the UI.
- Created custom dropdowns for month and year selection.
- Enhanced user experience with responsive design and visual feedback.
This commit is contained in:
vergiLgood1 2025-05-27 15:28:25 +07:00
parent eb3008caf1
commit 860481a093
38 changed files with 2834 additions and 516 deletions

View File

@ -6,30 +6,24 @@ import 'package:get_storage/get_storage.dart';
import 'package:mapbox_maps_flutter/mapbox_maps_flutter.dart'; import 'package:mapbox_maps_flutter/mapbox_maps_flutter.dart';
import 'package:sigap/app.dart'; import 'package:sigap/app.dart';
import 'package:sigap/navigation_menu.dart'; import 'package:sigap/navigation_menu.dart';
import 'package:sigap/src/features/personalization/presentasion/bindings/personalization_bindings.dart'; import 'package:sigap/src/cores/services/supabase_service.dart';
import 'package:sigap/src/utils/theme/theme.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
Future<void> main() async { Future<void> main() async {
// Make sure to initialize bindings first // Make sure to initialize bindings first
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
// Register navigation controller early since it's needed for NavigationMenu
Get.put(NavigationController(), permanent: true);
// Make sure status bar is properly set // Make sure status bar is properly set
SystemChrome.setSystemUIOverlayStyle( SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(statusBarColor: Colors.transparent), const SystemUiOverlayStyle(statusBarColor: Colors.transparent),
); );
// FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding); // -- GetX Local Storage
await GetStorage.init();
// Load environment variables from the .env file // Load environment variables from the .env file
await dotenv.load(fileName: ".env"); await dotenv.load(fileName: ".env");
// -- GetX Local Storage
await GetStorage.init();
// Initialize the authentication repository with Supabase // Initialize the authentication repository with Supabase
await Supabase.initialize( await Supabase.initialize(
url: dotenv.env['SUPABASE_URL'] ?? '', url: dotenv.env['SUPABASE_URL'] ?? '',
@ -44,9 +38,16 @@ Future<void> main() async {
storageOptions: const StorageClientOptions(retryAttempts: 10), storageOptions: const StorageClientOptions(retryAttempts: 10),
); );
// Register services AFTER Supabase is initialized
final supabaseService =
await Get.put(SupabaseService(), permanent: true).init();
Get.put(
NavigationController(supabaseService: supabaseService),
permanent: true,
);
// Initialize the Mapbox // Initialize the Mapbox
String mapboxAccesToken = dotenv.env['MAPBOX_ACCESS_TOKEN'] ?? ''; String mapboxAccesToken = dotenv.env['MAPBOX_ACCESS_TOKEN'] ?? '';
MapboxOptions.setAccessToken(mapboxAccesToken); MapboxOptions.setAccessToken(mapboxAccesToken);
runApp(const App()); runApp(const App());

View File

@ -1,5 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:sigap/src/cores/services/supabase_service.dart';
import 'package:sigap/src/features/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';
@ -18,13 +23,7 @@ class NavigationMenu extends StatelessWidget {
body: Obx( body: Obx(
() => IndexedStack( () => IndexedStack(
index: controller.selectedIndex.value, index: controller.selectedIndex.value,
children: const [ children: controller.getScreens(),
// HomePage(),
// SearchPage(),
PanicButtonPage(),
// HistoryPage(),
SettingsScreen(),
],
), ),
), ),
bottomNavigationBar: const CustomBottomNavigationBar(), bottomNavigationBar: const CustomBottomNavigationBar(),
@ -38,8 +37,66 @@ class NavigationController extends GetxController {
// 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 = 2.obs; // Start with PanicButtonPage (index 2)
final SupabaseService supabaseService;
// Observable to track if user is an officer
final RxBool isOfficer = false.obs;
NavigationController({required this.supabaseService});
@override
void onInit() {
super.onInit();
_checkUserRole();
// Listen to auth state changes to update role when login/logout happens
supabaseService.client.auth.onAuthStateChange.listen((data) {
_checkUserRole();
});
}
// Check if the current user is an officer
void _checkUserRole() {
isOfficer.value = supabaseService.isOfficer;
}
// Get the appropriate screens based on user role
List<Widget> getScreens() {
final List<Widget> screens = [
const HomeScreen(),
const NotificationScreen(),
const PanicButtonPage(),
const MapScreen(),
];
// 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
void changeIndex(int index) { void changeIndex(int index) {
selectedIndex.value = index; // Ensure the index is valid for the current user role
if (index < getScreens().length) {
selectedIndex.value = index;
}
}
// Get the maximum index based on available screens
int get maxIndex => getScreens().length - 1;
// Check if a specific screen index should be visible
bool isScreenVisible(int index) {
// The PatrolUnit screen is at index 4
if (index == 4) {
return isOfficer.value;
}
return true;
} }
} }

View File

@ -1,6 +1,7 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:sigap/src/features/auth/presentasion/bindings/auth_bindings.dart'; import 'package:sigap/src/features/auth/presentasion/bindings/auth_bindings.dart';
import 'package:sigap/src/features/onboarding/presentasion/bindings/onboarding_binding.dart'; import 'package:sigap/src/features/onboarding/presentasion/bindings/onboarding_binding.dart';
import 'package:sigap/src/features/panic/presentation/bindings/panic_button_bindings.dart';
import 'package:sigap/src/features/personalization/presentasion/bindings/personalization_bindings.dart'; import 'package:sigap/src/features/personalization/presentasion/bindings/personalization_bindings.dart';
class ControllerBindings extends Bindings { class ControllerBindings extends Bindings {
@ -17,5 +18,8 @@ class ControllerBindings extends Bindings {
// Personalization Bindings // Personalization Bindings
PersonalizationBindings().dependencies(); PersonalizationBindings().dependencies();
// Panic Button Bindings
PanicButtonControllerBindings().dependencies();
} }
} }

View File

@ -18,5 +18,7 @@ class RepositoryBindings extends Bindings {
MapRepositoryBindings().dependencies(); MapRepositoryBindings().dependencies();
DailyOpsRepositoryBindings().dependencies(); DailyOpsRepositoryBindings().dependencies();
PanicButtonRepositoryBindings().dependencies();
} }
} }

View File

@ -12,6 +12,11 @@ class LocationService extends GetxService {
final RxBool isPermissionGranted = false.obs; final RxBool isPermissionGranted = false.obs;
final Rx<Position?> currentPosition = Rx<Position?>(null); final Rx<Position?> currentPosition = Rx<Position?>(null);
final RxString currentCity = ''.obs; final RxString currentCity = ''.obs;
// Add currentDistrict to store the district/subdistrict name
final RxString currentDistrict = ''.obs;
// Add lastAddress to store the full address for reference
final RxString lastAddress = ''.obs;
final RxBool isMockedLocation = false.obs; final RxBool isMockedLocation = false.obs;
// Jember's center coordinate (approximate) // Jember's center coordinate (approximate)
@ -144,7 +149,6 @@ class LocationService extends GetxService {
// Get city name from coordinates // Get city name from coordinates
if (currentPosition.value != null) { if (currentPosition.value != null) {
await _updateCityName(); await _updateCityName();
} }
@ -203,12 +207,33 @@ class LocationService extends GetxService {
); );
if (placemarks.isNotEmpty) { if (placemarks.isNotEmpty) {
currentCity.value = placemarks.first.locality ?? ''; final placemark = placemarks.first;
}
Logger().i('Current city: ${currentCity.value}'); // Store city name
currentCity.value = placemark.subAdministrativeArea ?? '';
// Store district/subdistrict name
currentDistrict.value =
placemark.locality ?? placemark.subLocality ?? '';
// Create and store full address for reference
final List<String> addressComponents =
[
placemark.street ?? '',
placemark.subLocality ?? '',
placemark.locality ?? '',
placemark.subAdministrativeArea ?? '',
placemark.administrativeArea ?? '',
placemark.postalCode ?? '',
].where((component) => component.isNotEmpty).toList();
lastAddress.value = addressComponents.join(', ');
}
} catch (e) { } catch (e) {
currentCity.value = ''; currentCity.value = '';
currentDistrict.value = '';
lastAddress.value = '';
Logger().e('Error updating location info: $e');
} }
} }
@ -353,6 +378,11 @@ class LocationService extends GetxService {
} }
try { try {
// If we already have a cached address from a recent geocoding call, use it
if (lastAddress.value.isNotEmpty) {
return lastAddress.value;
}
List<Placemark> placemarks = await placemarkFromCoordinates( List<Placemark> placemarks = await placemarkFromCoordinates(
currentPosition.value!.latitude, currentPosition.value!.latitude,
currentPosition.value!.longitude, currentPosition.value!.longitude,
@ -372,15 +402,19 @@ class LocationService extends GetxService {
placemark.postalCode ?? '', placemark.postalCode ?? '',
].where((component) => component.isNotEmpty).toList(); ].where((component) => component.isNotEmpty).toList();
return addressComponents.join(', '); lastAddress.value = addressComponents.join(', ');
return lastAddress.value;
} }
return ''; return '';
} catch (e) { } catch (e) {
return ''; return lastAddress.value.isNotEmpty ? lastAddress.value : '';
} }
} }
// Get current district and city names
// Calculate distance between two points in kilometers // Calculate distance between two points in kilometers
double calculateDistance(double lat1, double lon1, double lat2, double lon2) { double calculateDistance(double lat1, double lon1, double lat2, double lon2) {
return Geolocator.distanceBetween(lat1, lon1, lat2, lon2) / 1000; return Geolocator.distanceBetween(lat1, lon1, lat2, lon2) / 1000;

View File

@ -7,30 +7,33 @@ class SupabaseService extends GetxService {
final _client = Supabase.instance.client; final _client = Supabase.instance.client;
// Observable for auth state changes
final Rx<User?> _currentUser = Rx<User?>(null);
/// Get Supabase client instance /// Get Supabase client instance
SupabaseClient get client => _client; SupabaseClient get client => _client;
/// Get current authenticated user /// Get current authenticated user
User? get currentUser => _client.auth.currentUser; User? get currentUser => _currentUser.value;
/// Get current user ID, if authenticated /// Get current user ID, if authenticated
String? get currentUserId => _client.auth.currentUser?.id; String? get currentUserId => _currentUser.value?.id;
/// Get type-safe user metadata /// Get type-safe user metadata
UserMetadataModel get userMetadata { UserMetadataModel get userMetadata {
if (currentUser == null) return UserMetadataModel(); if (_currentUser.value == null) return UserMetadataModel();
return UserMetadataModel.fromJson(currentUser!.userMetadata); return UserMetadataModel.fromJson(_currentUser.value!.userMetadata ?? {});
} }
/// Check if user is authenticated /// Check if user is authenticated
bool get isAuthenticated => currentUser != null; bool get isAuthenticated => _currentUser.value != null;
/// Check if current user is an officer based on metadata /// Check if current user is an officer based on metadata
bool get isOfficer => userMetadata.isOfficer; bool get isOfficer => userMetadata.isOfficer;
/// Get the stored identifier (NIK or NRP) of the current user /// Get the stored identifier (NIK or NRP) of the current user
String? get userIdentifier { String? get userIdentifier {
if (currentUser == null) return null; if (_currentUser.value == null) return null;
final metadata = userMetadata; final metadata = userMetadata;
if (metadata.isOfficer == true && metadata.officerData != null) { if (metadata.isOfficer == true && metadata.officerData != null) {
@ -42,6 +45,14 @@ class SupabaseService extends GetxService {
/// Initialize Supabase service /// Initialize Supabase service
Future<SupabaseService> init() async { Future<SupabaseService> init() async {
// Set the initial user
_currentUser.value = _client.auth.currentUser;
// Listen for auth state changes
_client.auth.onAuthStateChange.listen((data) {
_currentUser.value = data.session?.user;
});
return this; return this;
} }
@ -51,6 +62,7 @@ class SupabaseService extends GetxService {
final response = await client.auth.updateUser( final response = await client.auth.updateUser(
UserAttributes(data: metadata), UserAttributes(data: metadata),
); );
_currentUser.value = response.user;
return response.user; return response.user;
} catch (e) { } catch (e) {
throw Exception('Failed to update user metadata: $e'); throw Exception('Failed to update user metadata: $e');
@ -63,6 +75,7 @@ class SupabaseService extends GetxService {
final response = await client.auth.updateUser( final response = await client.auth.updateUser(
UserAttributes(data: metadata.toJson()), UserAttributes(data: metadata.toJson()),
); );
_currentUser.value = response.user;
return response.user; return response.user;
} catch (e) { } catch (e) {
throw Exception('Failed to update user metadata: $e'); throw Exception('Failed to update user metadata: $e');
@ -71,6 +84,4 @@ class SupabaseService extends GetxService {
/// Check if current user is an officer /// Check if current user is an officer
bool get isUserOfficer => userMetadata.isOfficer; bool get isUserOfficer => userMetadata.isOfficer;
} }

View File

@ -172,6 +172,7 @@ class AuthenticationRepository extends GetxController {
} }
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// EMAIL & PASSWORD AUTHENTICATION // EMAIL & PASSWORD AUTHENTICATION
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -0,0 +1,60 @@
class CrimeCategory {
final String id;
final String name;
final String description;
final DateTime createdAt;
final DateTime updatedAt;
final String type;
CrimeCategory({
required this.id,
required this.name,
required this.description,
required this.createdAt,
required this.updatedAt,
required this.type,
});
factory CrimeCategory.fromJson(Map<String, dynamic> json) {
return CrimeCategory(
id: json['id'] as String,
name: json['name'] as String,
description: json['description'] as String,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
type: json['type'] as String,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'description': description,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'type': type,
};
}
// copyWith method to create a new instance with modified properties
CrimeCategory copyWith({
String? id,
String? name,
String? description,
DateTime? createdAt,
DateTime? updatedAt,
String? type,
}) {
return CrimeCategory(
id: id ?? this.id,
name: name ?? this.name,
description: description ?? this.description,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
type: type ?? this.type,
);
}
}

View File

@ -0,0 +1,86 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:sigap/src/cores/services/supabase_service.dart';
class PatrolUnitScreen extends StatelessWidget {
const PatrolUnitScreen({super.key});
@override
Widget build(BuildContext context) {
// Get the current user
final user = SupabaseService.instance.currentUser;
// Check if user is an officer
final isOfficer = user?.userMetadata?['is_officer'] == true;
// If not an officer, show access denied screen
if (!isOfficer) {
return _buildAccessDeniedScreen(context);
}
// Officer view
return Scaffold(
appBar: AppBar(title: const Text('Patrol Unit'), elevation: 0),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.local_police_outlined,
size: 100,
color: Theme.of(context).primaryColor.withOpacity(0.5),
),
const SizedBox(height: 24),
Text(
'Patrol Unit Dashboard',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 16),
Text(
'Officer-only functionality will be implemented here',
style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.center,
),
],
),
),
);
}
// Access denied screen for non-officers
Widget _buildAccessDeniedScreen(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Patrol Unit'), elevation: 0),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.no_accounts_outlined,
size: 100,
color: Colors.red.withOpacity(0.5),
),
const SizedBox(height: 24),
Text(
'Access Restricted',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32.0),
child: Text(
'This feature is only available for officers. If you believe you should have access, please contact support.',
style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.center,
),
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () => Get.back(),
child: const Text('Go Back'),
),
],
),
),
);
}
}

View File

@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Home'), elevation: 0),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.home_outlined,
size: 100,
color: Theme.of(context).primaryColor.withOpacity(0.5),
),
const SizedBox(height: 24),
Text(
'Home Screen',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 16),
Text(
'This is a placeholder for the Home screen',
style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.center,
),
],
),
),
);
}
}

View File

@ -46,42 +46,42 @@ class DistrictModel {
json['updated_at'] != null json['updated_at'] != null
? DateTime.parse(json['updated_at']) ? DateTime.parse(json['updated_at'])
: null, : null,
crimes: // crimes:
json['crimes'] != null // json['crimes'] != null
? (json['crimes'] as List) // ? (json['crimes'] as List)
.map((e) => CrimeModel.fromJson(e as Map<String, dynamic>)) // .map((e) => CrimeModel.fromJson(e as Map<String, dynamic>))
.toList() // .toList()
: null, // : null,
demographics: // demographics:
json['demographics'] != null // json['demographics'] != null
? (json['demographics'] as List) // ? (json['demographics'] as List)
.map( // .map(
(e) => DemographicModel.fromJson(e as Map<String, dynamic>), // (e) => DemographicModel.fromJson(e as Map<String, dynamic>),
) // )
.toList() // .toList()
: null, // : null,
city: // city:
json['cities'] != null // json['cities'] != null
? CityModel.fromJson(json['cities'] as Map<String, dynamic>) // ? CityModel.fromJson(json['cities'] as Map<String, dynamic>)
: null, // : null,
geographics: // geographics:
json['geographics'] != null // json['geographics'] != null
? (json['geographics'] as List) // ? (json['geographics'] as List)
.map( // .map(
(e) => GeographicModel.fromJson(e as Map<String, dynamic>), // (e) => GeographicModel.fromJson(e as Map<String, dynamic>),
) // )
.toList() // .toList()
: null, // : null,
locations: // locations:
json['locations'] != null // json['locations'] != null
? (json['locations'] as List) // ? (json['locations'] as List)
.map((e) => LocationModel.fromJson(e as Map<String, dynamic>)) // .map((e) => LocationModel.fromJson(e as Map<String, dynamic>))
.toList() // .toList()
: null, // : null,
unit: // unit:
json['units'] != null // json['units'] != null
? UnitModel.fromJson(json['units'] as Map<String, dynamic>) // ? UnitModel.fromJson(json['units'] as Map<String, dynamic>)
: null, // : null,
); );
} }

View File

@ -96,6 +96,23 @@ class DistrictsRepository extends GetxController {
} }
} }
// Get districts by district name
Future<DistrictModel> getDistrictsByName(String name) async {
try {
final response =
await _supabase
.from('districts')
.select('*, cities(*), units(*)')
.eq('name', name)
.single();
return DistrictModel.fromJson(response);
} catch (e) {
_log.e('Error fetching districts by name $name: $e');
throw Exception('Failed to load districts by name: $e');
}
}
// Clear cache // Clear cache
void clearCache() { void clearCache() {
_districtsByCity.clear(); _districtsByCity.clear();

View File

@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
class MapScreen extends StatelessWidget {
const MapScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Map'), elevation: 0),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.map_outlined,
size: 100,
color: Theme.of(context).primaryColor.withOpacity(0.5),
),
const SizedBox(height: 24),
Text(
'Map Screen',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 16),
Text(
'This is a placeholder for the Map screen',
style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.center,
),
],
),
),
);
}
}

View File

@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
class NotificationScreen extends StatelessWidget {
const NotificationScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Notifications'), elevation: 0),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.notifications_outlined,
size: 100,
color: Theme.of(context).primaryColor.withOpacity(0.5),
),
const SizedBox(height: 24),
Text(
'Notification Screen',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 16),
Text(
'This is a placeholder for the Notification screen',
style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.center,
),
],
),
),
);
}
}

View File

@ -34,4 +34,43 @@ class CrimesRepository extends GetxController {
throw TExceptions('Failed to fetch crime category: ${e.toString()}'); throw TExceptions('Failed to fetch crime category: ${e.toString()}');
} }
} }
// Get crime statistics for a specific district, year and month
Future<List<Map<String, dynamic>>> getCrimeStatisticsByDistrict(
String districtId,
int year,
int month,
) async {
try {
final response = await _supabase
.from('crimes')
.select('*, district:districts(*), crime_incidents(*)')
.eq('district_id', districtId)
.eq('year', year)
.eq('month', month)
.order('created_at', ascending: false);
return List<Map<String, dynamic>>.from(response);
} catch (e) {
throw TExceptions('Failed to fetch crime statistics: ${e.toString()}');
}
}
// Get crime statistics summary by district
Future<Map<String, dynamic>> getCrimeStatisticsSummary(
String districtId,
) async {
try {
final response = await _supabase.rpc(
'get_district_crime_summary',
params: {'p_district_id': districtId},
);
return response as Map<String, dynamic>;
} catch (e) {
throw TExceptions(
'Failed to fetch crime statistics summary: ${e.toString()}',
);
}
}
} }

View File

@ -1,145 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class PanicButtonController extends GetxController {
static PanicButtonController get instance => Get.find();
// Observable variables
final RxBool isPanicActive = false.obs;
final RxString locationString = "Indonesia · 125.161.172.145".obs;
final RxInt elapsedSeconds = 0.obs;
Timer? _timer;
@override
void onInit() {
super.onInit();
// Simulate fetching location
_fetchLocation();
}
@override
void onClose() {
_timer?.cancel();
super.onClose();
}
// Toggle panic mode on/off
void togglePanicMode() {
if (isPanicActive.value) {
// If currently active, show confirmation dialog
Get.dialog(
AlertDialog(
title: const Text('Cancel Emergency Alert?'),
content: const Text(
'Are you sure you want to cancel the emergency alert?',
),
actions: [
TextButton(onPressed: () => Get.back(), child: const Text('No')),
TextButton(
onPressed: () {
Get.back();
_deactivatePanicMode();
},
child: const Text('Yes'),
),
],
),
);
} else {
// If not active, show confirmation to activate
Get.dialog(
AlertDialog(
title: const Text('Confirm Emergency Alert'),
content: const Text(
'Are you sure you want to send an emergency alert?',
),
actions: [
TextButton(
onPressed: () => Get.back(),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
Get.back();
_activatePanicMode();
},
child: const Text(
'Send Alert',
style: TextStyle(color: Colors.red),
),
),
],
),
);
}
}
void _activatePanicMode() {
isPanicActive.value = true;
elapsedSeconds.value = 0;
_startTimer();
// Show toast notification
Get.snackbar(
'Emergency Alert Active',
'Help has been notified and is on the way',
backgroundColor: Colors.red,
colorText: Colors.white,
icon: const Icon(Icons.warning_amber_rounded, color: Colors.white),
snackPosition: SnackPosition.TOP,
duration: const Duration(seconds: 3),
);
// TODO: Implement actual emergency services notification
}
void _deactivatePanicMode() {
isPanicActive.value = false;
_timer?.cancel();
// Show toast notification
Get.snackbar(
'Emergency Alert Cancelled',
'Your emergency alert has been cancelled',
backgroundColor: Colors.green,
colorText: Colors.white,
icon: const Icon(Icons.check_circle, color: Colors.white),
snackPosition: SnackPosition.TOP,
duration: const Duration(seconds: 3),
);
// TODO: Implement actual emergency services cancellation
}
void _startTimer() {
_timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
elapsedSeconds.value++;
});
}
void _fetchLocation() {
// TODO: Implement actual location fetching
// This is just a placeholder that simulates location fetch
Future.delayed(const Duration(seconds: 2), () {
locationString.value = "Jakarta, Indonesia · 125.161.172.145";
});
}
// Format elapsed time as string
String get elapsedTimeString {
final seconds = elapsedSeconds.value;
if (seconds < 60) {
return '$seconds seconds ago';
}
final minutes = seconds ~/ 60;
if (minutes < 60) {
return '$minutes minutes ago';
}
final hours = minutes ~/ 60;
return '$hours hours ago';
}
}

View File

@ -0,0 +1,13 @@
import 'package:get/get.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';
class PanicButtonRepositoryBindings extends Bindings {
@override
void dependencies() {
// Register the PanicButtonRepository as a singleton
Get.lazyPut<PanicButtonRepository>(() => PanicButtonRepository());
// Register repositories
Get.lazyPut<IncidentLogsRepository>(() => IncidentLogsRepository());
}
}

View File

@ -0,0 +1,46 @@
import 'package:get/get.dart';
import 'package:logger/logger.dart';
import 'package:sigap/src/cores/services/location_service.dart';
import 'package:sigap/src/features/map/data/repositories/locations_repository.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/incident_logs_repository.dart';
import 'package:sigap/src/features/panic/presentation/controllers/emergency_view_controller.dart';
import 'package:sigap/src/features/panic/presentation/controllers/main_safety_indicator_controller.dart';
import 'package:sigap/src/features/panic/presentation/controllers/panic_button_controller.dart';
import 'package:sigap/src/features/panic/presentation/controllers/recovery_indicator_controller.dart';
import 'package:sigap/src/features/panic/presentation/controllers/statistics_view_controller.dart';
class PanicButtonControllerBindings extends Bindings {
@override
void dependencies() {
// Register the logger if not already registered
if (!Get.isRegistered<Logger>()) {
Get.put(Logger());
}
// Register repositories
Get.lazyPut<IncidentLogsRepository>(() => IncidentLogsRepository());
Get.lazyPut<CrimesRepository>(() => CrimesRepository());
Get.lazyPut<CrimeIncidentsRepository>(() => CrimeIncidentsRepository());
Get.lazyPut<LocationsRepository>(() => LocationsRepository());
// Register services if not already registered
if (!Get.isRegistered<LocationService>()) {
Get.put(LocationService().init());
}
// Register main controller
Get.lazyPut<PanicButtonController>(() => PanicButtonController());
// Register sub-controllers
Get.lazyPut<StatisticsViewController>(() => StatisticsViewController());
Get.lazyPut<EmergencyViewController>(() => EmergencyViewController());
Get.lazyPut<MainSafetyIndicatorController>(
() => MainSafetyIndicatorController(),
);
Get.lazyPut<RecoveryIndicatorController>(
() => RecoveryIndicatorController(),
);
}
}

View File

@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:sigap/src/features/panic/presentation/controllers/panic_button_controller.dart';
class EmergencyViewController extends GetxController
with GetTickerProviderStateMixin {
final PanicButtonController panicController =
Get.find<PanicButtonController>();
// Animation controllers
late AnimationController pulseController;
late AnimationController rippleController;
late Animation<double> pulseAnimation;
late Animation<double> rippleAnimation;
// Observable variables
final RxBool isCountingDown = false.obs;
final RxInt countdown = 5.obs;
@override
void onInit() {
super.onInit();
// Initialize animation controllers
pulseController = AnimationController(
duration: const Duration(seconds: 2),
vsync: this,
);
rippleController = AnimationController(
duration: const Duration(milliseconds: 1500),
vsync: this,
);
// Set up animations
pulseAnimation = Tween<double>(begin: 1.0, end: 1.1).animate(
CurvedAnimation(parent: pulseController, curve: Curves.easeInOut),
);
rippleAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(parent: rippleController, curve: Curves.easeOut));
// Start pulse animation
pulseController.repeat(reverse: true);
}
@override
void onClose() {
pulseController.dispose();
rippleController.dispose();
super.onClose();
}
// Start emergency countdown
void startCountdown() {
isCountingDown.value = true;
countdown.value = 5;
rippleController.forward();
_countDown();
}
// Cancel emergency countdown
void cancelCountdown() {
isCountingDown.value = false;
rippleController.reset();
}
// Countdown timer implementation
void _countDown() {
Future.delayed(const Duration(seconds: 1), () {
if (countdown.value > 1 && isCountingDown.value) {
countdown.value--;
_countDown();
} else if (isCountingDown.value) {
activateEmergency();
}
});
}
// Activate the emergency alert
void activateEmergency() {
isCountingDown.value = false;
panicController.togglePanicMode();
}
// Show report dialog for quick actions
void showReportDialog(String type) {
panicController.showReportDialog(type);
}
}

View File

@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:sigap/src/features/panic-button/data/models/models/crimes_model.dart';
import 'package:sigap/src/features/panic/presentation/controllers/statistics_view_controller.dart';
class MainSafetyIndicatorController extends GetxController {
final StatisticsViewController statisticsController =
Get.find<StatisticsViewController>();
// Observable variables
final RxDouble progress = 0.0.obs;
final RxString title = "".obs;
final RxString label = "Level".obs;
final Rx<Color> color = Colors.purple.obs;
@override
void onInit() {
super.onInit();
// Initialize with values from StatisticsViewController
progress.value = statisticsController.safetylevel.value;
title.value = statisticsController.safetyTitle.value;
// Set up listeners to keep synchronized with StatisticsViewController
ever(
statisticsController.safetylevel,
(_) => progress.value = statisticsController.safetylevel.value,
);
ever(
statisticsController.safetyTitle,
(_) => title.value = statisticsController.safetyTitle.value,
);
// Update color based on safety level
ever(progress, (_) => color.value = getSafetyColor(progress.value));
}
// Update safety level manually if needed
void updateSafetyLevel(double value, String newTitle) {
progress.value = value;
title.value = newTitle;
// Also update the statistics controller
statisticsController.safetylevel.value = value;
statisticsController.safetyTitle.value = newTitle;
}
// Calculate color based on safety level
Color getSafetyColor(double level) {
if (level >= 0.8) return Colors.green; // Low risk
if (level >= 0.6) return Colors.blue; // Medium-low risk
if (level >= 0.3) return Colors.orange; // Medium-high risk
return Colors.red; // High/Critical risk
}
// Get color based on crime risk level
Color getColorForCrimeLevel(CrimeRates level) {
switch (level) {
case CrimeRates.low:
return Colors.green;
case CrimeRates.medium:
return Colors.orange;
case CrimeRates.high:
return Colors.red;
case CrimeRates.critical:
return Colors.deepPurple;
default:
return Colors.blue;
}
}
}

View File

@ -0,0 +1,284 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:sigap/src/cores/services/location_service.dart';
import 'package:sigap/src/utils/popups/loaders.dart';
class PanicButtonController extends GetxController {
static PanicButtonController get instance => Get.find();
// Observable variables
final RxBool isPanicActive = false.obs;
final RxString locationString = "Indonesia · 125.161.172.145".obs;
final RxInt elapsedSeconds = 0.obs;
final RxBool isStatisticsMode = false.obs;
// Location service
final LocationService _locationService = Get.find<LocationService>();
Timer? _timer;
@override
void onInit() {
super.onInit();
// Fetch real location
// _fetchLocation();
// Setup location updates listener
ever(_locationService.currentPosition, (_) => _updateLocationString());
}
@override
void onClose() {
_timer?.cancel();
super.onClose();
}
// Toggle view mode between emergency and statistics
void toggleViewMode() {
isStatisticsMode.value = !isStatisticsMode.value;
}
// Set specific view mode
void setViewMode(bool statisticsMode) {
isStatisticsMode.value = statisticsMode;
}
// Toggle panic mode on/off
void togglePanicMode() {
if (isPanicActive.value) {
// If currently active, show confirmation dialog
Get.dialog(
AlertDialog(
title: const Text('Cancel Emergency Alert?'),
content: const Text(
'Are you sure you want to cancel the emergency alert?',
),
actions: [
TextButton(onPressed: () => Get.back(), child: const Text('No')),
TextButton(
onPressed: () {
Get.back();
_deactivatePanicMode();
},
child: const Text('Yes'),
),
],
),
);
} else {
// If not active, show confirmation to activate
Get.dialog(
AlertDialog(
title: const Text('Confirm Emergency Alert'),
content: const Text(
'Are you sure you want to send an emergency alert?',
),
actions: [
TextButton(
onPressed: () => Get.back(),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
Get.back();
_activatePanicMode();
},
child: const Text(
'Send Alert',
style: TextStyle(color: Colors.red),
),
),
],
),
);
}
}
void _activatePanicMode() {
isPanicActive.value = true;
elapsedSeconds.value = 0;
_startTimer();
TLoaders.errorSnackBar(
title: 'Emergency Alert',
message: 'Help is on the way!',
);
// TODO: Implement actual emergency services notification
}
void _deactivatePanicMode() {
isPanicActive.value = false;
_timer?.cancel();
// Show toast notification
TLoaders.infoSnackBar(
title: 'Emergency Alert Cancelled',
message: 'Emergency services have been notified of cancellation.',
);
// TODO: Implement actual emergency services cancellation
}
void _startTimer() {
_timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
elapsedSeconds.value++;
});
}
void _fetchLocation() async {
try {
// Get current position from location service
await _locationService.getCurrentPosition();
_updateLocationString();
} catch (e) {
print('Error fetching location: $e');
// Fallback to default location string
locationString.value = "Unknown Location";
}
}
// Update location string with district/subdistrict information
void _updateLocationString() async {
if (_locationService.currentPosition.value == null) return;
try {
// Get current district and city from location service
await _locationService.getCurrentPosition();
String currentDistrict = _locationService.currentDistrict.value;
String currentCity = _locationService.currentCity.value;
// Use currentDistrict if available, otherwise use currentCity
String locationDisplay = "";
// Remove "Kabupaten" or "Kecamatan" prefixes if present
if (currentDistrict.isNotEmpty) {
locationDisplay = currentDistrict.replaceAll(
RegExp(r'(Kabupaten|Kecamatan)\s*'),
'',
);
}
if (currentCity.isNotEmpty) {
String cleanedCity = currentCity.replaceAll(
RegExp(r'(Kabupaten|Kecamatan)\s*'),
'',
);
locationDisplay =
locationDisplay.isEmpty
? cleanedCity
: "$cleanedCity, $locationDisplay";
}
if (locationDisplay.isEmpty) {
locationDisplay = "Unknown Area";
}
// Add coordinates for additional precision
final lat = _locationService.currentPosition.value!.latitude
.toStringAsFixed(4);
final lng = _locationService.currentPosition.value!.longitude
.toStringAsFixed(4);
locationString.value = "$locationDisplay · $lat, $lng";
} catch (e) {
print('Error updating location string: $e');
// Use district or city name as fallback
if (_locationService.currentDistrict.value.isNotEmpty) {
locationString.value =
"${_locationService.currentDistrict.value} · Location";
} else if (_locationService.currentCity.value.isNotEmpty) {
locationString.value =
"${_locationService.currentCity.value} · Location";
}
}
}
// Format elapsed time as string
String get elapsedTimeString {
final seconds = elapsedSeconds.value;
if (seconds < 60) {
return '$seconds seconds ago';
}
final minutes = seconds ~/ 60;
if (minutes < 60) {
return '$minutes minutes ago';
}
final hours = minutes ~/ 60;
return '$hours hours ago';
}
// Show report dialog for quick actions
void showReportDialog(String type) {
// Determine the location name to show - prefer district, fall back to city
final locationName =
_locationService.currentDistrict.value.isNotEmpty
? _locationService.currentDistrict.value
: (_locationService.currentCity.value.isNotEmpty
? _locationService.currentCity.value
: "your area");
Get.dialog(
AlertDialog(
title: Text('Contact $type'),
content: Text(
'Would you like to contact $type services in $locationName?',
),
actions: [
TextButton(onPressed: () => Get.back(), child: const Text('Cancel')),
ElevatedButton(
onPressed: () {
Get.back();
Get.snackbar(
'Service Contact',
'Contacting $type services...',
backgroundColor: Colors.blue,
colorText: Colors.white,
);
// TODO: Implement actual service contact
},
child: const Text('Contact'),
),
],
),
);
}
// Get user's current district
String getCurrentDistrict() {
_fetchLocation(); // Refresh location data
if (_locationService.currentDistrict.value.isEmpty) {
return "Unknown Area";
}
String locationDisplay = "";
// Remove "Kabupaten" or "Kecamatan" prefixes if present
final currenDistrict = _locationService.currentDistrict.value.replaceAll(
RegExp(r'(Kabupaten|Kecamatan)\s*'),
'',
);
if (locationDisplay.isEmpty) {
return "Unknown Area";
}
final currentCity = _locationService.currentCity.value;
if (currentCity.isEmpty) {
return currenDistrict;
} else {
String cleanedCity = currentCity.replaceAll(
RegExp(r'(Kabupaten|Kecamatan)\s*'),
'',
);
return "$cleanedCity, $currenDistrict";
}
}
}

View File

@ -0,0 +1,164 @@
import 'package:get/get.dart';
import 'package:sigap/src/cores/services/supabase_service.dart';
import 'package:sigap/src/features/panic-button/data/models/models/incident_logs_model.dart';
import 'package:sigap/src/features/panic-button/data/repositories/incident_logs_repository.dart';
import 'statistics_view_controller.dart';
class RecoveryIndicatorController extends GetxController {
final StatisticsViewController statisticsController =
Get.find<StatisticsViewController>();
final IncidentLogsRepository _incidentLogsRepository =
Get.find<IncidentLogsRepository>();
// Observable variables
final RxString duration = "".obs;
final RxString timeLabel = "Today".obs;
final RxDouble progress = 0.0.obs;
// Incident logs variables
final RxList<IncidentLogModel> unverifiedIncidentLogs =
<IncidentLogModel>[].obs;
final RxBool isLoading = false.obs;
final RxString errorMessage = ''.obs;
// User ID
final String? _currentUserId = SupabaseService.instance.currentUserId;
@override
void onInit() {
super.onInit();
// Initialize with values from StatisticsViewController
duration.value = statisticsController.recoveryTime.value;
progress.value = statisticsController.recoveryProgress.value;
// Set up listeners to keep synchronized with StatisticsViewController
ever(
statisticsController.recoveryTime,
(_) => duration.value = statisticsController.recoveryTime.value,
);
ever(
statisticsController.recoveryProgress,
(_) => progress.value = statisticsController.recoveryProgress.value,
);
// Fetch unverified incident logs
fetchUnverifiedIncidentLogs();
}
// Fetch all unverified incident logs for the current user
Future<void> fetchUnverifiedIncidentLogs() async {
if (_currentUserId == null) return;
try {
isLoading.value = true;
errorMessage.value = '';
final logs = await _incidentLogsRepository.getIncidentLogs();
// Filter for current user and unverified logs
final filteredLogs =
logs
.where(
(log) =>
log['user_id'] == _currentUserId &&
(log['verified'] == null || log['verified'] == false),
)
.map((log) => IncidentLogModel.fromJson(log))
.toList();
unverifiedIncidentLogs.value = filteredLogs;
// Update statistics
_updateRecoveryStats();
} catch (e) {
errorMessage.value = 'Failed to load incident logs: ${e.toString()}';
} finally {
isLoading.value = false;
}
}
// Verify an incident log
Future<bool> verifyIncidentLog(String logId) async {
try {
isLoading.value = true;
// Call the Supabase client directly since we don't have a specific method in the repository
await SupabaseService.instance.client
.from('incident_logs')
.update({'verified': true})
.eq('id', logId);
// Remove from the local list
unverifiedIncidentLogs.removeWhere((log) => log.id == logId);
// Update recovery statistics
_updateRecoveryStats();
return true;
} catch (e) {
errorMessage.value = 'Failed to verify log: ${e.toString()}';
return false;
} finally {
isLoading.value = false;
}
}
// Delete an incident log
Future<bool> deleteIncidentLog(String logId) async {
try {
isLoading.value = true;
// Call the Supabase client directly since we don't have a specific method in the repository
await SupabaseService.instance.client
.from('incident_logs')
.delete()
.eq('id', logId);
// Remove from the local list
unverifiedIncidentLogs.removeWhere((log) => log.id == logId);
// Update recovery statistics
_updateRecoveryStats();
return true;
} catch (e) {
errorMessage.value = 'Failed to delete log: ${e.toString()}';
return false;
} finally {
isLoading.value = false;
}
}
// Update recovery data manually if needed
void updateRecoveryData(String newDuration, double newProgress) {
duration.value = newDuration;
progress.value = newProgress;
// Also update the statistics controller
statisticsController.recoveryTime.value = newDuration;
statisticsController.recoveryProgress.value = newProgress;
}
// Calculate and update recovery statistics based on incident logs
void _updateRecoveryStats() {
final int count = unverifiedIncidentLogs.length;
// Calculate progress (more items = less progress)
final double newProgress = count > 0 ? 1.0 / (count + 1) : 1.0;
progress.value = newProgress;
statisticsController.recoveryProgress.value = newProgress;
// Set duration text based on number of unverified logs
if (count == 0) {
duration.value = "All Clear";
} else if (count == 1) {
duration.value = "1 Report";
} else {
duration.value = "$count Reports";
}
statisticsController.recoveryTime.value = duration.value;
// Update time label
timeLabel.value = "Pending";
}
}

View File

@ -0,0 +1,307 @@
import 'package:get/get.dart';
import 'package:logger/logger.dart';
import 'package:sigap/src/cores/services/location_service.dart';
import 'package:sigap/src/features/daily-ops/data/models/models/crime_categories_model.dart';
import 'package:sigap/src/features/map/data/models/models/districts_model.dart';
import 'package:sigap/src/features/map/data/repositories/districts_repository.dart';
import 'package:sigap/src/features/panic-button/data/models/models/crime_incidents_model.dart';
import 'package:sigap/src/features/panic-button/data/models/models/crimes_model.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';
class StatisticsViewController extends GetxController {
// Dependencies
final LocationService _locationService = Get.find<LocationService>();
final CrimeIncidentsRepository _crimeIncidentsRepo =
Get.find<CrimeIncidentsRepository>();
final CrimesRepository _crimesRepo = Get.find<CrimesRepository>();
final DistrictsRepository _districtsRepo = Get.find<DistrictsRepository>();
final Logger _logger = Get.find<Logger>();
// Observable variables for statistics
final RxDouble safetylevel = 0.7.obs;
final RxString safetyTitle = "Medium Risk".obs;
final RxDouble reportsProgress = 0.65.obs;
final RxString reportsValue = "13".obs;
final RxDouble zoneMinProgress = 0.3.obs;
final RxString zoneMinValue = "15".obs;
final RxDouble mindfulProgress = 0.8.obs;
final RxString mindfulValue = "4 of 5".obs;
final RxString recoveryTime = "All Clear".obs;
final RxDouble recoveryProgress = 0.25.obs;
// Crime statistics
final RxList<CrimeModel> districtCrimes = <CrimeModel>[].obs;
final RxList<CrimeIncidentModel> recentIncidents = <CrimeIncidentModel>[].obs;
final RxList<CrimeCategory> crimeCategories = <CrimeCategory>[].obs;
final RxString currentDistrictId = ''.obs;
final RxString currentMonth =
DateTime.now().month.toString().padLeft(2, '0').obs;
final RxString currentYear = DateTime.now().year.toString().obs;
// Add district model
final Rx<DistrictModel?> currentDistrict = Rx<DistrictModel?>(null);
// Available years for selection (from 2020 to current year)
RxList<int> get availableYears {
final int currentYearInt = DateTime.now().year;
return List<int>.generate(currentYearInt - 2019, (i) => 2020 + i).obs;
}
// Loading state
final RxBool isLoading = false.obs;
final RxString errorMessage = ''.obs;
@override
void onInit() {
super.onInit();
// Load statistics data
loadStatisticsData();
// Setup listener for location changes
ever(_locationService.currentDistrict, (_) => _onLocationChanged());
}
void _onLocationChanged() {
if (_locationService.currentDistrict.value.isNotEmpty) {
loadStatisticsData();
}
}
// Load statistics data from Supabase
Future<void> loadStatisticsData() async {
isLoading.value = true;
errorMessage.value = '';
try {
// First, fetch crime categories for reference
await _loadCrimeCategories();
// Get current location information to find the district
if (_locationService.currentPosition.value != null) {
await _loadDistrictData();
}
// Load crime statistics
await _loadCrimeStatistics();
// Load recent incidents
await _loadRecentIncidents();
// Update UI components with fetched data
_updateStatisticsUI();
} catch (e) {
_logger.e('Error loading statistics: $e');
errorMessage.value = 'Failed to load crime statistics data.';
} finally {
isLoading.value = false;
}
}
Future<void> _loadCrimeCategories() async {
try {
final categories = await _crimesRepo.getCrimeCategories();
final categoriesList =
categories
.map(
(cat) => CrimeCategory(
id: cat['id'],
name: cat['name'],
description: cat['description'] ?? '',
createdAt: DateTime.parse(cat['created_at']),
updatedAt: DateTime.parse(cat['updated_at']),
type: cat['type'] ?? 'general',
),
)
.toList();
crimeCategories.value = categoriesList;
} catch (e) {
_logger.e('Error loading crime categories: $e');
}
}
Future<void> _loadDistrictData() async {
try {
// Get district name from location service
final districtName = _locationService.currentDistrict.value;
final cityName = _locationService.currentCity.value;
if (districtName.isEmpty && cityName.isEmpty) {
_logger.w('No district or city information available');
return;
}
// Clean the district name by removing the "Kecamatan" prefix if present
final cleanedDistrictName =
districtName
.replaceFirst(RegExp(r'^Kecamatan\s+', caseSensitive: false), '')
.trim();
// Fetch district data based on the cleaned district name
final district = await _districtsRepo.getDistrictsByName(
cleanedDistrictName,
);
currentDistrictId.value = district.id;
} catch (e) {
_logger.e('Error loading district data: $e');
}
}
Future<void> _loadCrimeStatistics() async {
try {
if (currentDistrictId.value.isEmpty) return;
// Fetch crime statistics for the current district
final response = await Get.find<CrimesRepository>()
.getCrimeStatisticsByDistrict(
currentDistrictId.value,
int.parse(currentYear.value),
int.parse(currentMonth.value),
);
// Convert to model objects
final crimesList =
response.map((crime) => CrimeModel.fromJson(crime)).toList();
districtCrimes.value = crimesList;
} catch (e) {
_logger.e('Error loading crime statistics: $e');
}
}
Future<void> _loadRecentIncidents() async {
try {
final incidents = await _crimeIncidentsRepo.getRecentIncidents();
// Filter to show only incidents from current district if district ID is available
final filteredIncidents =
currentDistrictId.value.isNotEmpty
? incidents.where((incident) {
final locationId =
incident['location_id'] is String
? incident['location_id']
: incident['location_id']?['id'];
// Logic to filter by district would go here, but we'd need to join with locations table
// For now, just return all incidents
return true;
}).toList()
: incidents;
recentIncidents.value =
filteredIncidents
.map((incident) => CrimeIncidentModel.fromJson(incident))
.toList();
} catch (e) {
_logger.e('Error loading recent incidents: $e');
}
}
void _updateStatisticsUI() {
if (districtCrimes.isEmpty) return;
// Get the most recent crime data
final latestCrime = districtCrimes.first;
// Update safety level based directly on crime level from response
switch (latestCrime.level) {
case CrimeRates.low:
safetylevel.value = 0.85; // High safety
safetyTitle.value = "Low Risk";
break;
case CrimeRates.medium:
safetylevel.value = 0.6; // Medium safety
safetyTitle.value = "Medium Risk";
break;
case CrimeRates.high:
safetylevel.value = 0.3; // Low safety
safetyTitle.value = "High Risk";
break;
case CrimeRates.critical:
safetylevel.value = 0.15; // Very low safety
safetyTitle.value = "Critical Risk";
break;
}
// Update reports data
final totalIncidents = latestCrime.numberOfCrime;
reportsValue.value = totalIncidents.toString();
// Progress is inverse of number of reports - more reports means lower safety
reportsProgress.value = _calculateProgressFromIncidents(totalIncidents);
// Update zone minutes data (using crime score as proxy)
final safetyScore = latestCrime.score;
zoneMinProgress.value = safetyScore / 100;
zoneMinValue.value = "${safetyScore.toInt()}%";
// Update mindful days data (using crime cleared ratio)
final clearRate =
latestCrime.numberOfCrime > 0
? latestCrime.crimeCleared / latestCrime.numberOfCrime
: 1.0;
mindfulProgress.value = clearRate;
mindfulValue.value = "${(clearRate * 100).toInt()}%";
// Update recovery stats based on crime level
switch (latestCrime.level) {
case CrimeRates.low:
recoveryProgress.value = 1.0; // Fully recovered
recoveryTime.value = "All Clear";
break;
case CrimeRates.medium:
recoveryProgress.value = 0.7;
recoveryTime.value = "${latestCrime.numberOfCrime} Reports";
break;
case CrimeRates.high:
recoveryProgress.value = 0.4;
recoveryTime.value = "High Risk Area";
break;
case CrimeRates.critical:
recoveryProgress.value = 0.2;
recoveryTime.value = "Critical Zone";
break;
}
}
// Calculate progress value based on number of incidents
double _calculateProgressFromIncidents(int incidents) {
if (incidents == 0) return 1.0;
if (incidents >= 20) return 0.1;
return 1.0 - (incidents / 20.0);
}
// Refresh statistics data
void refreshStatistics() {
loadStatisticsData();
}
// Change the month for crime statistics
void changeMonth(int month) {
currentMonth.value = month.toString().padLeft(2, '0');
loadStatisticsData();
}
// Change the year for crime statistics
void changeYear(int year) {
currentYear.value = year.toString();
loadStatisticsData();
}
// Get crime categories by type
List<CrimeCategory> getCategoriesByType(String type) {
return crimeCategories.where((cat) => cat.type == type).toList();
}
// Get district name with fallback
String getDistrictName() {
if (currentDistrict.value != null) {
return currentDistrict.value!.name;
} else if (_locationService.currentDistrict.value.isNotEmpty) {
return _locationService.currentDistrict.value;
} else if (_locationService.currentCity.value.isNotEmpty) {
return _locationService.currentCity.value;
}
return "Your Area";
}
}

View File

@ -1,6 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:sigap/src/features/panic/controllers/panic_button_controller.dart'; import 'package:sigap/src/features/panic/presentation/controllers/emergency_view_controller.dart';
import 'package:sigap/src/features/panic/presentation/controllers/panic_button_controller.dart';
import 'package:sigap/src/features/panic/presentation/widgets/emergency_view.dart';
import 'package:sigap/src/features/panic/presentation/widgets/statistics_view.dart';
import 'package:sigap/src/features/panic/presentation/widgets/tab_button.dart';
class PanicButtonPage extends StatefulWidget { class PanicButtonPage extends StatefulWidget {
const PanicButtonPage({super.key}); const PanicButtonPage({super.key});
@ -10,259 +14,129 @@ class PanicButtonPage extends StatefulWidget {
} }
class _PanicButtonPageState extends State<PanicButtonPage> { class _PanicButtonPageState extends State<PanicButtonPage> {
final controller = Get.put(PanicButtonController()); // Use GetX controller
late PanicButtonController _panicController;
late EmergencyViewController _emergencyController;
@override
void initState() {
super.initState();
// Initialize controllers
_panicController = Get.find<PanicButtonController>();
_emergencyController = Get.find<EmergencyViewController>();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
body: Container( backgroundColor: const Color(0xFFF8F9FA),
decoration: const BoxDecoration( appBar: AppBar(
gradient: LinearGradient( backgroundColor: Colors.transparent,
begin: Alignment.topCenter, elevation: 0,
end: Alignment.bottomCenter, leading: IconButton(
colors: [Color(0xFF0C1323), Color(0xFF223142)], icon: const Icon(Icons.arrow_back_ios, color: Colors.black87),
), onPressed: () => Navigator.pop(context),
), ),
child: SafeArea( title: Column(
child: Column(
children: [
_buildAppBar(),
const Spacer(),
_buildStatusIndicator(),
const SizedBox(height: 100),
_buildPanicButton(),
const Spacer(),
_buildLocationInfo(),
const SizedBox(height: 20),
],
),
),
),
);
}
Widget _buildAppBar() {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Obx(
() => Text(
controller.isPanicActive.value ? "SOS ACTIVE" : "Emergency Alert",
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
Container(
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.all(8),
child: const Icon(Icons.more_vert, color: Colors.white),
),
],
),
);
}
Widget _buildStatusIndicator() {
return Obx(() {
if (controller.isPanicActive.value) {
// Active state - show pulsating effect
return Column(
children: [ children: [
Container( const SizedBox(height: 2),
width: 180, // Location indicator moved to app bar - Now using real location
height: 180, Obx(
decoration: BoxDecoration( () => Container(
shape: BoxShape.circle, padding: const EdgeInsets.symmetric(
color: Colors.red.withOpacity(0.2), horizontal: 12,
), vertical: 4,
child: Center( ),
child: Container( decoration: BoxDecoration(
width: 140, color: Colors.blue.shade50,
height: 140, borderRadius: BorderRadius.circular(12),
decoration: BoxDecoration( ),
shape: BoxShape.circle, child: Row(
color: Colors.red.withOpacity(0.4), mainAxisSize: MainAxisSize.min,
), children: [
child: Center( const Icon(Icons.location_on, color: Colors.blue, size: 12),
child: Container( const SizedBox(width: 2),
width: 100, Flexible(
height: 100, child: Text(
decoration: const BoxDecoration( _panicController.locationString.value.split(' · ')[0],
shape: BoxShape.circle, style: const TextStyle(
color: Colors.red, color: Colors.blue,
), fontWeight: FontWeight.w500,
child: const Center( fontSize: 12,
child: Icon(
Icons.warning_amber_rounded,
color: Colors.white,
size: 50,
), ),
overflow: TextOverflow.ellipsis,
), ),
), ),
), ],
), ),
), ),
), ),
const SizedBox(height: 24),
const Text(
"Help is on the way",
style: TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
"Signal sent ${controller.elapsedTimeString}",
style: TextStyle(
color: Colors.white.withOpacity(0.8),
fontSize: 16,
),
),
], ],
);
} else {
// Inactive state - ready to activate
return Column(
children: [
Container(
width: 120,
height: 120,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.teal.shade300, Colors.teal.shade600],
),
),
child: const Center(
child: Icon(
Icons.shield_outlined,
color: Colors.white,
size: 60,
),
),
),
const SizedBox(height: 24),
const Text(
"You are unprotected",
style: TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
"Tap the button below in case of emergency",
style: TextStyle(
color: Colors.white.withOpacity(0.8),
fontSize: 16,
),
textAlign: TextAlign.center,
),
],
);
}
});
}
Widget _buildPanicButton() {
return Obx(() {
final isPanicActive = controller.isPanicActive.value;
return GestureDetector(
onTap: () => controller.togglePanicMode(),
child: Container(
width: 200,
height: 60,
decoration: BoxDecoration(
color: isPanicActive ? Colors.white : Colors.red,
borderRadius: BorderRadius.circular(30),
boxShadow: [
BoxShadow(
color: (isPanicActive ? Colors.white : Colors.red).withOpacity(
0.5,
),
blurRadius: 20,
spreadRadius: 5,
),
],
),
child: Center(
child: Text(
isPanicActive ? "CANCEL SOS" : "SOS",
style: TextStyle(
color: isPanicActive ? Colors.red : Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
),
), ),
); centerTitle: true,
}); actions: [
} IconButton(
icon: const Icon(Icons.location_on, color: Colors.blue),
Widget _buildLocationInfo() { onPressed: () {
return Container( // Refresh location when icon is tapped
margin: const EdgeInsets.symmetric(horizontal: 24), _panicController.getCurrentDistrict();
padding: const EdgeInsets.all(16), },
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1),
shape: BoxShape.circle,
),
child: const Icon(Icons.location_on, color: Colors.white, size: 24),
), ),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
"Your current location",
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Obx(
() => Text(
controller.locationString.value,
style: TextStyle(
color: Colors.white.withOpacity(0.7),
fontSize: 14,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
const SizedBox(width: 8),
Icon(Icons.chevron_right, color: Colors.white.withOpacity(0.7)),
], ],
), ),
body: SafeArea(
child: Column(
children: [
// Add mode switcher tabs
Container(
margin: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(25),
),
child: Obx(
() => Row(
mainAxisSize: MainAxisSize.min,
children: [
TabButton(
title: 'Emergency',
isSelected: !_panicController.isStatisticsMode.value,
onTap: () => _panicController.setViewMode(false),
),
TabButton(
title: 'Statistics',
isSelected: _panicController.isStatisticsMode.value,
onTap: () => _panicController.setViewMode(true),
),
],
),
),
),
// Main Content based on mode
Expanded(
child: Obx(
() =>
_panicController.isStatisticsMode.value
? const StatisticsView()
: EmergencyView(
pulseAnimation: _emergencyController.pulseAnimation,
rippleAnimation: _emergencyController.rippleAnimation,
isEmergencyActive:
_emergencyController.isCountingDown.value,
countdown: _emergencyController.countdown.value,
onActivatePanicButton:
_emergencyController.startCountdown,
onCancelEmergency:
_emergencyController.cancelCountdown,
showReportDialog: _panicController.showReportDialog,
),
),
),
],
),
),
); );
} }
} }

View File

@ -0,0 +1,138 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:sigap/src/features/panic/presentation/controllers/statistics_view_controller.dart';
import 'package:sigap/src/shared/widgets/dropdown/custom_dropdown.dart';
class CrimeStatsHeader extends StatelessWidget {
final String district;
final String month;
final String year;
final Function(int) onMonthChanged;
final Function(int) onYearChanged;
final VoidCallback onRefresh;
const CrimeStatsHeader({
super.key,
required this.district,
required this.month,
required this.year,
required this.onMonthChanged,
required this.onYearChanged,
required this.onRefresh,
});
@override
Widget build(BuildContext context) {
// Get the statistics controller to access available years
final statsController = Get.find<StatisticsViewController>();
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 10),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.1),
blurRadius: 5,
spreadRadius: 1,
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Text(
district,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: const Icon(Icons.refresh, size: 20),
onPressed: onRefresh,
tooltip: 'Refresh statistics',
),
],
),
const SizedBox(height: 5),
// Date picker row with custom dropdowns
Row(
children: [
const Icon(Icons.calendar_month, size: 16, color: Colors.blue),
const SizedBox(width: 10),
Expanded(child: _buildMonthDropdownCustom()),
const SizedBox(width: 10),
Expanded(
child: _buildYearDropdownCustom(statsController.availableYears),
),
],
),
],
),
);
}
// Custom month dropdown using the shared custom dropdown widget
Widget _buildMonthDropdownCustom() {
final int currentMonth = int.parse(month);
return CustomDropdown<int>(
label: '',
value: currentMonth,
onChanged: (value) {
if (value != null) onMonthChanged(value);
},
items: List.generate(12, (index) {
final monthNum = index + 1;
final monthName = DateFormat(
'MMMM',
).format(DateTime(int.parse(year), monthNum));
return DropdownMenuItem<int>(
value: monthNum,
child: Text(monthName, style: const TextStyle(fontSize: 14)),
);
}),
);
}
// Custom year dropdown using the shared custom dropdown widget
Widget _buildYearDropdownCustom(List<int> availableYears) {
final int currentYear = int.parse(year);
final selectedYear =
availableYears.contains(currentYear)
? currentYear
: availableYears.last;
return CustomDropdown<int>(
label: '',
value: selectedYear,
onChanged: (value) {
if (value != null) onYearChanged(value);
},
items:
availableYears.map((year) {
return DropdownMenuItem<int>(
value: year,
child: Text(
year.toString(),
style: const TextStyle(fontSize: 14),
),
);
}).toList(),
);
}
}

View File

@ -0,0 +1,191 @@
import 'package:flutter/material.dart';
import 'panic_button.dart';
import 'quick_action_button.dart';
class EmergencyView extends StatelessWidget {
final Animation<double> pulseAnimation;
final Animation<double> rippleAnimation;
final bool isEmergencyActive;
final int countdown;
final VoidCallback onActivatePanicButton;
final VoidCallback onCancelEmergency;
final Function(String) showReportDialog;
const EmergencyView({
super.key,
required this.pulseAnimation,
required this.rippleAnimation,
required this.isEmergencyActive,
required this.countdown,
required this.onActivatePanicButton,
required this.onCancelEmergency,
required this.showReportDialog,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
// Main Panic Button
Expanded(
child: Center(
child: PanicButton(
pulseAnimation: pulseAnimation,
rippleAnimation: rippleAnimation,
isEmergencyActive: isEmergencyActive,
countdown: countdown,
onTap: onActivatePanicButton,
),
),
),
// Cancel button (only show when emergency is active)
if (isEmergencyActive)
Padding(
padding: const EdgeInsets.only(bottom: 20),
child: ElevatedButton(
onPressed: onCancelEmergency,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.grey.shade600,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 40,
vertical: 15,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(25),
),
),
child: const Text(
'CANCEL',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
),
),
// Quick Action Buttons
if (!isEmergencyActive) ...[
const Text(
'Quick Actions',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
QuickActionButton(
icon: Icons.local_police,
label: 'Police',
color: Colors.blue,
onTap: () => showReportDialog('Police'),
),
QuickActionButton(
icon: Icons.medical_services,
label: 'Medical',
color: Colors.green,
onTap: () => showReportDialog('Medical'),
),
QuickActionButton(
icon: Icons.fire_truck,
label: 'Fire Dept',
color: Colors.orange,
onTap: () => showReportDialog('Fire Department'),
),
],
),
const SizedBox(height: 30),
// Crime Statistics
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(15),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.1),
blurRadius: 10,
spreadRadius: 2,
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Today\'s Safety Status',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
const SizedBox(height: 15),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: const [
_StatItem(
value: '3',
label: 'Reports',
color: Colors.orange,
),
_StatItem(
value: '12m',
label: 'Avg Response',
color: Colors.blue,
),
_StatItem(
value: 'Safe',
label: 'Area Status',
color: Colors.green,
),
],
),
],
),
),
],
],
);
}
}
class _StatItem extends StatelessWidget {
final String value;
final String label;
final Color color;
const _StatItem({
super.key,
required this.value,
required this.label,
required this.color,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
Text(
value,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: color,
),
),
Text(
label,
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
),
],
);
}
}

View File

@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import '../widgets/progress_arc_painter.dart';
class MainSafetyIndicator extends StatelessWidget {
final double progress;
final String title;
final String label;
final Color color;
const MainSafetyIndicator({
super.key,
required this.progress,
required this.title,
required this.label,
required this.color,
});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.1),
blurRadius: 10,
spreadRadius: 2,
),
],
),
child: Column(
children: [
const Text(
'Area Safety Level',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.black87,
),
),
const SizedBox(height: 15),
Stack(
alignment: Alignment.center,
children: [
SizedBox(
width: 180,
height: 180,
child: CustomPaint(
painter: ProgressArcPainter(
progress: progress,
color: color,
backgroundColor: Colors.grey.shade200,
),
),
),
Column(
children: [
Text(
title,
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: color,
),
),
Text(
label,
style: TextStyle(fontSize: 14, color: Colors.grey.shade600),
),
],
),
],
),
],
),
);
}
}

View File

@ -0,0 +1,101 @@
import 'package:flutter/material.dart';
class PanicButton extends StatelessWidget {
final Animation<double> pulseAnimation;
final Animation<double> rippleAnimation;
final bool isEmergencyActive;
final int countdown;
final VoidCallback? onTap;
const PanicButton({
super.key,
required this.pulseAnimation,
required this.rippleAnimation,
required this.isEmergencyActive,
required this.countdown,
this.onTap,
});
@override
Widget build(BuildContext context) {
return Stack(
alignment: Alignment.center,
children: [
// Ripple effect
if (isEmergencyActive)
AnimatedBuilder(
animation: rippleAnimation,
builder: (context, child) {
return Container(
width: 300 * rippleAnimation.value,
height: 300 * rippleAnimation.value,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: Colors.red.withOpacity(1.0 - rippleAnimation.value),
width: 2,
),
),
);
},
),
// Main button
AnimatedBuilder(
animation: pulseAnimation,
builder: (context, child) {
return Transform.scale(
scale: isEmergencyActive ? 1.0 : pulseAnimation.value,
child: GestureDetector(
onTap: isEmergencyActive ? null : onTap,
child: Container(
width: 200,
height: 200,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isEmergencyActive ? Colors.red : Colors.red.shade400,
boxShadow: [
BoxShadow(
color: Colors.red.withOpacity(0.3),
blurRadius: 20,
spreadRadius: 5,
),
],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
isEmergencyActive ? Icons.warning : Icons.emergency,
color: Colors.white,
size: 50,
),
const SizedBox(height: 8),
Text(
isEmergencyActive ? '$countdown' : 'PANIC',
style: const TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
if (!isEmergencyActive)
const Text(
'BUTTON',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
);
},
),
],
);
}
}

View File

@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
/// Custom painter for circular progress indicators (donut charts)
class ProgressArcPainter extends CustomPainter {
final double progress;
final Color color;
final Color backgroundColor;
final double strokeWidth;
ProgressArcPainter({
required this.progress,
required this.color,
required this.backgroundColor,
this.strokeWidth = 15,
});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = size.width / 2;
// Background circle
final backgroundPaint =
Paint()
..color = backgroundColor
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round;
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
-0.5 * 3.14159, // Start at -90 degrees
2 * 3.14159, // Full circle
false,
backgroundPaint,
);
// Progress arc
final progressPaint =
Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round;
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
-0.5 * 3.14159, // Start at -90 degrees
progress * 2 * 3.14159, // Draw based on progress
false,
progressPaint,
);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

View File

@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
class QuickActionButton extends StatelessWidget {
final IconData icon;
final String label;
final Color color;
final VoidCallback onTap;
const QuickActionButton({
Key? key,
required this.icon,
required this.label,
required this.color,
required this.onTap,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Column(
children: [
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: color.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(icon, color: color, size: 30),
),
const SizedBox(height: 8),
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Colors.grey.shade700,
),
),
],
),
);
}
}

View File

@ -0,0 +1,274 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:sigap/src/features/panic/presentation/controllers/recovery_indicator_controller.dart';
import '../widgets/progress_arc_painter.dart';
class RecoveryIndicator extends StatelessWidget {
const RecoveryIndicator({super.key});
@override
Widget build(BuildContext context) {
// Use GetX to access the controller
final controller = Get.find<RecoveryIndicatorController>();
return Obx(
() => Container(
width: double.infinity,
padding: const EdgeInsets.all(15),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.1),
blurRadius: 10,
spreadRadius: 2,
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Unverified Reports',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
// Refresh button
IconButton(
icon: const Icon(Icons.refresh, size: 20),
onPressed: controller.fetchUnverifiedIncidentLogs,
),
],
),
const SizedBox(height: 10),
Row(
children: [
Text(
controller.duration.value,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.black,
),
),
const SizedBox(width: 8),
Text(
controller.timeLabel.value,
style: TextStyle(fontSize: 14, color: Colors.grey.shade600),
),
const Spacer(),
Stack(
alignment: Alignment.center,
children: [
Container(
width: 50,
height: 50,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.grey.shade100,
),
),
SizedBox(
width: 45,
height: 45,
child: CustomPaint(
painter: ProgressArcPainter(
progress: controller.progress.value,
color: Colors.purple,
backgroundColor: Colors.grey.shade200,
strokeWidth: 4,
),
),
),
Container(
width: 25,
height: 25,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Colors.white,
),
child: const Icon(
Icons.security,
color: Colors.teal,
size: 16,
),
),
],
),
],
),
const SizedBox(height: 16),
// Show loading indicator if loading
if (controller.isLoading.value)
const Center(child: CircularProgressIndicator()),
// Show error message if any
if (controller.errorMessage.value.isNotEmpty)
Text(
controller.errorMessage.value,
style: const TextStyle(color: Colors.red),
),
// Show empty state if no logs
if (!controller.isLoading.value &&
controller.unverifiedIncidentLogs.isEmpty &&
controller.errorMessage.value.isEmpty)
const Center(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 16.0),
child: Text(
'No unverified reports',
style: TextStyle(color: Colors.grey),
),
),
),
// List of unverified incident logs
if (!controller.isLoading.value &&
controller.unverifiedIncidentLogs.isNotEmpty)
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: controller.unverifiedIncidentLogs.length,
itemBuilder: (context, index) {
final log = controller.unverifiedIncidentLogs[index];
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
title: Text(
log.description ?? 'Unnamed incident',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
'Reported on ${_formatDate(log.time)}',
style: const TextStyle(fontSize: 12),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Verify button
IconButton(
icon: const Icon(Icons.check, color: Colors.green),
onPressed:
() => controller.verifyIncidentLog(log.id),
tooltip: 'Verify',
),
// Delete button
IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed:
() =>
_confirmDelete(context, controller, log.id),
tooltip: 'Delete',
),
],
),
onTap: () => _showIncidentDetails(context, log),
),
);
},
),
if (controller.unverifiedIncidentLogs.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Center(
child: Text(
'Verify or delete reports to improve your safety score',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
fontStyle: FontStyle.italic,
),
),
),
),
],
),
),
);
}
// Format date for display
String _formatDate(DateTime date) {
return '${date.day}/${date.month}/${date.year} ${date.hour}:${date.minute.toString().padLeft(2, '0')}';
}
// Show incident details dialog
void _showIncidentDetails(BuildContext context, dynamic log) {
showDialog(
context: context,
builder:
(context) => AlertDialog(
title: const Text('Incident Details'),
content: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('Description: ${log.description ?? 'No description'}'),
const SizedBox(height: 8),
Text('Time: ${_formatDate(log.time)}'),
const SizedBox(height: 8),
Text('Source: ${log.source ?? 'Unknown'}'),
const SizedBox(height: 8),
Text('Location ID: ${log.locationId}'),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
],
),
);
}
// Show confirmation dialog before deleting
void _confirmDelete(
BuildContext context,
RecoveryIndicatorController controller,
String logId,
) {
showDialog(
context: context,
builder:
(context) => AlertDialog(
title: const Text('Delete Report?'),
content: const Text(
'Are you sure you want to delete this report? This action cannot be undone.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
controller.deleteIncidentLog(logId);
Navigator.pop(context);
},
child: const Text(
'Delete',
style: TextStyle(color: Colors.red),
),
),
],
),
);
}
}

View File

@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import '../widgets/progress_arc_painter.dart';
class StatIndicatorCard extends StatelessWidget {
final double progress;
final String value;
final String label;
final Color color;
const StatIndicatorCard({
super.key,
required this.progress,
required this.value,
required this.label,
required this.color,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 15, horizontal: 5),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.1),
blurRadius: 5,
spreadRadius: 1,
),
],
),
child: Column(
children: [
// Donut chart
Stack(
alignment: Alignment.center,
children: [
SizedBox(
width: 50,
height: 50,
child: CustomPaint(
painter: ProgressArcPainter(
progress: progress,
color: color,
backgroundColor: Colors.grey.shade200,
strokeWidth: 6,
),
),
),
Text(
'${(progress * 100).toInt()}%',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: color,
),
),
],
),
const SizedBox(height: 10),
Text(
value,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
Text(
label,
style: TextStyle(fontSize: 11, color: Colors.grey.shade600),
),
const SizedBox(height: 4),
Container(
width: 6,
height: 6,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
],
),
);
}
}

View File

@ -0,0 +1,154 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:sigap/src/features/panic/presentation/controllers/main_safety_indicator_controller.dart';
import 'package:sigap/src/features/panic/presentation/controllers/statistics_view_controller.dart';
import 'crime_stats_header.dart';
import 'main_safety_indicator.dart';
import 'recovery_indicator.dart';
import 'stat_indicator_card.dart';
class StatisticsView extends StatelessWidget {
const StatisticsView({super.key});
@override
Widget build(BuildContext context) {
final statsController = Get.find<StatisticsViewController>();
final safetyController = Get.find<MainSafetyIndicatorController>();
return Obx(
() => Stack(
children: [
// Main content
SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// District and date information
CrimeStatsHeader(
district:
statsController.currentDistrictId.value.isNotEmpty
? _getDistrictName(
statsController.currentDistrictId.value,
)
: _getLocalityName(),
month: statsController.currentMonth.value,
year: statsController.currentYear.value,
onMonthChanged: statsController.changeMonth,
onYearChanged: statsController.changeYear,
onRefresh: statsController.refreshStatistics,
),
const SizedBox(height: 15),
// Main indicator - Area Safety Level
MainSafetyIndicator(
progress: safetyController.progress.value,
title: safetyController.title.value,
label: safetyController.label.value,
color: _getSafetyColor(safetyController.progress.value),
),
const SizedBox(height: 15),
// Secondary indicators row with donut charts
Row(
children: [
Expanded(
child: StatIndicatorCard(
progress: statsController.reportsProgress.value,
value: statsController.reportsValue.value,
label: 'Reports',
color: Colors.teal,
),
),
const SizedBox(width: 10),
Expanded(
child: StatIndicatorCard(
progress: statsController.zoneMinProgress.value,
value: statsController.zoneMinValue.value,
label: 'Safety Score',
color: Colors.green,
),
),
const SizedBox(width: 10),
Expanded(
child: StatIndicatorCard(
progress: statsController.mindfulProgress.value,
value: statsController.mindfulValue.value,
label: 'Solved Rate',
color: Colors.indigo,
),
),
],
),
const SizedBox(height: 15),
// Recovery indicator with unverified incidents
const RecoveryIndicator(),
],
),
),
// Loading indicator
if (statsController.isLoading.value)
Container(
color: Colors.black.withOpacity(0.1),
child: const Center(child: CircularProgressIndicator()),
),
// Error message
if (statsController.errorMessage.value.isNotEmpty)
Positioned(
top: 10,
left: 0,
right: 0,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 20),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.red.shade100,
borderRadius: BorderRadius.circular(10),
),
child: Text(
statsController.errorMessage.value,
style: TextStyle(color: Colors.red.shade800),
textAlign: TextAlign.center,
),
),
),
],
),
);
}
// Helper to get district name from district ID
String _getDistrictName(String districtId) {
final statsController = Get.find<StatisticsViewController>();
if (statsController.currentDistrict.value != null) {
return statsController.currentDistrict.value!.name;
}
// Fallback to ID-based name if no district model available
if (districtId.length >= 6) {
return "District ${districtId.substring(districtId.length - 6)}";
}
return "Current District";
}
// Helper to get locality name from location service
String _getLocalityName() {
final statsController = Get.find<StatisticsViewController>();
return statsController.getDistrictName();
}
// Helper to get color based on safety level
Color _getSafetyColor(double safety) {
if (safety >= 0.8) return Colors.green;
if (safety >= 0.6) return Colors.blue;
if (safety >= 0.3) return Colors.orange;
return Colors.red;
}
}

View File

@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
class TabButton extends StatelessWidget {
final String title;
final bool isSelected;
final VoidCallback onTap;
const TabButton({
super.key,
required this.title,
required this.isSelected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 24),
decoration: BoxDecoration(
color: isSelected ? Colors.white : Colors.transparent,
borderRadius: BorderRadius.circular(25),
boxShadow:
isSelected
? [
BoxShadow(
color: Colors.grey.withOpacity(0.2),
blurRadius: 4,
offset: const Offset(0, 1),
),
]
: null,
),
child: Text(
title,
style: TextStyle(
color: isSelected ? Colors.blue : Colors.grey.shade700,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
fontSize: 14,
),
),
),
);
}
}

View File

@ -139,9 +139,7 @@ class UserRepository extends GetxController {
throw 'User not authenticated'; throw 'User not authenticated';
} }
final metadata = { final metadata = {'profile_status': status};
'profile_status': status,
};
await updateUserMetadata(metadata); await updateUserMetadata(metadata);
} on AuthException catch (e) { } on AuthException catch (e) {
@ -537,4 +535,22 @@ class UserRepository extends GetxController {
return false; return false;
} }
} }
// Check if the current user is an officer
Future<bool> isOfficer() async {
try {
final user = SupabaseService.instance.currentUser;
if (user == null) {
return false; // User is not authenticated
}
// Check if the user is an officer from the user metadata
final isUserOfficer = user.userMetadata?['is_officer'] == true;
return isUserOfficer;
} catch (e) {
print('Error checking user role: $e');
return false; // Default to not an officer if an error occurs
}
}
} }

View File

@ -40,16 +40,16 @@ class CustomBottomNavigationBar extends StatelessWidget {
), ),
_buildNavItem( _buildNavItem(
context, context,
"Search", "Notify",
Icons.search, Icons.notifications_none,
1, 1,
controller.selectedIndex.value == 1, controller.selectedIndex.value == 1,
), ),
_buildPanicButton(context), _buildPanicButton(context),
_buildNavItem( _buildNavItem(
context, context,
"History", "Map",
Icons.history, Icons.map_outlined,
3, 3,
controller.selectedIndex.value == 3, controller.selectedIndex.value == 3,
), ),

View File

@ -11,19 +11,19 @@ datasource db {
} }
model profiles { model profiles {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
user_id String @unique @db.Uuid user_id String @unique @db.Uuid
nik String @unique @default("") @db.VarChar(100) avatar String? @db.VarChar(355)
avatar String? @db.VarChar(355) username String? @unique @db.VarChar(255)
username String? @unique @db.VarChar(255) first_name String? @db.VarChar(255)
first_name String? @db.VarChar(255) last_name String? @db.VarChar(255)
last_name String? @db.VarChar(255) bio String? @db.VarChar
bio String? @db.VarChar address Json? @db.Json
address Json? @db.Json birth_date DateTime?
birth_date DateTime? nik String? @db.VarChar(100)
users users @relation(fields: [user_id], references: [id]) birth_place String?
users users @relation(fields: [user_id], references: [id])
@@index([nik], map: "idx_profiles_nik")
@@index([user_id]) @@index([user_id])
@@index([username]) @@index([username])
} }
@ -43,19 +43,19 @@ model users {
user_metadata Json? user_metadata Json?
created_at DateTime @default(now()) @db.Timestamptz(6) created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6) updated_at DateTime @default(now()) @db.Timestamptz(6)
is_banned Boolean @default(false)
spoofing_attempts Int @default(0)
panic_strike Int @default(0)
banned_reason String? @db.VarChar(255)
banned_until DateTime? @db.Timestamptz(6) banned_until DateTime? @db.Timestamptz(6)
is_anonymous Boolean @default(false) is_anonymous Boolean @default(false)
banned_reason String? @db.VarChar(255)
is_banned Boolean @default(false)
panic_strike Int @default(0)
spoofing_attempts Int @default(0)
events events[] events events[]
incident_logs incident_logs[] incident_logs incident_logs[]
location_logs location_logs[] location_logs location_logs[]
panic_button_logs panic_button_logs[]
profile profiles? profile profiles?
sessions sessions[] sessions sessions[]
role roles @relation(fields: [roles_id], references: [id]) role roles @relation(fields: [roles_id], references: [id])
panic_button_logs panic_button_logs[]
@@index([is_anonymous]) @@index([is_anonymous])
@@index([created_at]) @@index([created_at])
@ -68,9 +68,9 @@ model roles {
description String? description String?
created_at DateTime @default(now()) @db.Timestamptz(6) created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6) updated_at DateTime @default(now()) @db.Timestamptz(6)
officers officers[]
permissions permissions[] permissions permissions[]
users users[] users users[]
officers officers[]
} }
model sessions { model sessions {
@ -267,10 +267,10 @@ model incident_logs {
verified Boolean? @default(false) verified Boolean? @default(false)
created_at DateTime? @default(now()) @db.Timestamptz(6) created_at DateTime? @default(now()) @db.Timestamptz(6)
updated_at DateTime? @default(now()) @db.Timestamptz(6) updated_at DateTime? @default(now()) @db.Timestamptz(6)
evidence evidence[]
crime_categories crime_categories @relation(fields: [category_id], references: [id], map: "fk_incident_category") crime_categories crime_categories @relation(fields: [category_id], references: [id], map: "fk_incident_category")
locations locations @relation(fields: [location_id], references: [id], onDelete: Cascade, onUpdate: NoAction) locations locations @relation(fields: [location_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
user users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction) user users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
evidence evidence[]
panic_button_logs panic_button_logs[] panic_button_logs panic_button_logs[]
@@index([category_id], map: "idx_incident_logs_category_id") @@index([category_id], map: "idx_incident_logs_category_id")
@ -278,16 +278,15 @@ model incident_logs {
} }
model evidence { model evidence {
id String @id @unique @db.VarChar(20) incident_id String @db.Uuid
incident_id String @db.Uuid type String @db.VarChar(50)
type String @db.VarChar(50) // contoh: photo, video, document, images url String
url String @db.Text uploaded_at DateTime? @default(now()) @db.Timestamptz(6)
description String? @db.VarChar(255) caption String? @db.VarChar(255)
caption String? @db.VarChar(255) description String? @db.VarChar(255)
metadata Json? metadata Json?
uploaded_at DateTime? @default(now()) @db.Timestamptz(6) id String @id @unique @db.VarChar(20)
incident incident_logs @relation(fields: [incident_id], references: [id], onDelete: Cascade)
incident incident_logs @relation(fields: [incident_id], references: [id], onDelete: Cascade)
@@index([incident_id], map: "idx_evidence_incident_id") @@index([incident_id], map: "idx_evidence_incident_id")
} }
@ -306,12 +305,12 @@ model units {
longitude Float longitude Float
location Unsupported("geography") location Unsupported("geography")
city_id String @db.VarChar(20) city_id String @db.VarChar(20)
phone String? phone String? @db.VarChar(20)
officers officers[]
patrol_units patrol_units[]
unit_statistics unit_statistics[] unit_statistics unit_statistics[]
cities cities @relation(fields: [city_id], references: [id], onDelete: Cascade, onUpdate: NoAction) cities cities @relation(fields: [city_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
districts districts? @relation(fields: [district_id], references: [id], onDelete: Cascade, onUpdate: NoAction) districts districts? @relation(fields: [district_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
officers officers[]
patrol_units patrol_units[]
@@index([name], map: "idx_units_name") @@index([name], map: "idx_units_name")
@@index([type], map: "idx_units_type") @@index([type], map: "idx_units_type")
@ -320,24 +319,22 @@ model units {
@@index([location], map: "idx_unit_location", type: Gist) @@index([location], map: "idx_unit_location", type: Gist)
@@index([district_id, location], map: "idx_units_location_district") @@index([district_id, location], map: "idx_units_location_district")
@@index([location], map: "idx_units_location_gist", type: Gist) @@index([location], map: "idx_units_location_gist", type: Gist)
@@index([location], type: Gist)
@@index([location], map: "units_location_idx1", type: Gist)
@@index([location], map: "units_location_idx2", type: Gist)
} }
model patrol_units { model patrol_units {
id String @id @unique @db.VarChar(100) unit_id String @db.VarChar(20)
unit_id String @db.VarChar(20) location_id String @db.Uuid
location_id String @db.Uuid name String @db.VarChar(100)
name String @db.VarChar(100) type String @db.VarChar(50)
type String @db.VarChar(50) status String @db.VarChar(50)
status String @db.VarChar(50) radius Float
radius Float created_at DateTime @default(now()) @db.Timestamptz(6)
created_at DateTime @default(now()) @db.Timestamptz(6) id String @id @unique @db.VarChar(100)
category patrol_unit_category? @default(group)
members officers[] member_count Int? @default(0)
location locations @relation(fields: [location_id], references: [id], onDelete: Cascade, onUpdate: NoAction) members officers[]
unit units @relation(fields: [unit_id], references: [code_unit], onDelete: Cascade, onUpdate: NoAction) location locations @relation(fields: [location_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
unit units @relation(fields: [unit_id], references: [code_unit], onDelete: Cascade, onUpdate: NoAction)
@@index([unit_id], map: "idx_patrol_units_unit_id") @@index([unit_id], map: "idx_patrol_units_unit_id")
@@index([location_id], map: "idx_patrol_units_location_id") @@index([location_id], map: "idx_patrol_units_location_id")
@ -347,29 +344,31 @@ model patrol_units {
} }
model officers { model officers {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid unit_id String? @db.VarChar(20)
unit_id String @db.VarChar(20)
role_id String @db.Uuid role_id String @db.Uuid
patrol_unit_id String @db.VarChar(100) nrp String? @unique @db.VarChar(100)
nrp String @unique @db.VarChar(100)
name String @db.VarChar(100) name String @db.VarChar(100)
rank String? @db.VarChar(100) rank String? @db.VarChar(100)
position String? @db.VarChar(100) position String? @db.VarChar(100)
phone String? @db.VarChar(100) phone String? @db.VarChar(20)
email String? @db.VarChar(255) email String? @db.VarChar(255)
avatar String? avatar String?
valid_until DateTime? valid_until DateTime?
qr_code String? qr_code String?
created_at DateTime? @default(now()) @db.Timestamptz(6)
updated_at DateTime? @default(now()) @db.Timestamptz(6)
patrol_unit_id String? @db.VarChar(100)
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
banned_reason String? @db.VarChar(255)
banned_until DateTime?
is_banned Boolean @default(false) is_banned Boolean @default(false)
panic_strike Int @default(0) panic_strike Int @default(0)
spoofing_attempts Int @default(0) spoofing_attempts Int @default(0)
banned_reason String? @db.VarChar(255) place_of_birth String?
banned_until DateTime? date_of_birth DateTime? @db.Timestamptz(6)
created_at DateTime? @default(now()) @db.Timestamptz(6) patrol_units patrol_units? @relation(fields: [patrol_unit_id], references: [id], onDelete: Restrict)
updated_at DateTime? @default(now()) @db.Timestamptz(6)
units units @relation(fields: [unit_id], references: [code_unit], onDelete: Cascade, onUpdate: NoAction)
roles roles @relation(fields: [role_id], references: [id], onDelete: Cascade, onUpdate: NoAction) roles roles @relation(fields: [role_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
patrol_units patrol_units? @relation(fields: [patrol_unit_id], references: [id]) units units? @relation(fields: [unit_id], references: [code_unit], onDelete: Cascade, onUpdate: NoAction)
panic_button_logs panic_button_logs[] panic_button_logs panic_button_logs[]
@@index([unit_id], map: "idx_officers_unit_id") @@index([unit_id], map: "idx_officers_unit_id")
@ -453,9 +452,9 @@ model panic_button_logs {
officer_id String? @db.Uuid officer_id String? @db.Uuid
incident_id String @db.Uuid incident_id String @db.Uuid
timestamp DateTime @db.Timestamptz(6) timestamp DateTime @db.Timestamptz(6)
users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
officers officers? @relation(fields: [officer_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
incidents incident_logs @relation(fields: [incident_id], references: [id], onDelete: Cascade, onUpdate: NoAction) incidents incident_logs @relation(fields: [incident_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
officers officers? @relation(fields: [officer_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@index([user_id], map: "idx_panic_buttons_user_id") @@index([user_id], map: "idx_panic_buttons_user_id")
} }
@ -476,6 +475,11 @@ model location_logs {
@@index([user_id], map: "idx_location_logs_user_id") @@index([user_id], map: "idx_location_logs_user_id")
} }
enum patrol_unit_category {
individual
group
}
enum session_status { enum session_status {
active active
completed completed