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:
parent
eb3008caf1
commit
860481a093
|
@ -6,30 +6,24 @@ import 'package:get_storage/get_storage.dart';
|
|||
import 'package:mapbox_maps_flutter/mapbox_maps_flutter.dart';
|
||||
import 'package:sigap/app.dart';
|
||||
import 'package:sigap/navigation_menu.dart';
|
||||
import 'package:sigap/src/features/personalization/presentasion/bindings/personalization_bindings.dart';
|
||||
import 'package:sigap/src/utils/theme/theme.dart';
|
||||
import 'package:sigap/src/cores/services/supabase_service.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
// Make sure to initialize bindings first
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Register navigation controller early since it's needed for NavigationMenu
|
||||
Get.put(NavigationController(), permanent: true);
|
||||
|
||||
// Make sure status bar is properly set
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
const SystemUiOverlayStyle(statusBarColor: Colors.transparent),
|
||||
);
|
||||
|
||||
// FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding);
|
||||
// -- GetX Local Storage
|
||||
await GetStorage.init();
|
||||
|
||||
// Load environment variables from the .env file
|
||||
await dotenv.load(fileName: ".env");
|
||||
|
||||
// -- GetX Local Storage
|
||||
await GetStorage.init();
|
||||
|
||||
// Initialize the authentication repository with Supabase
|
||||
await Supabase.initialize(
|
||||
url: dotenv.env['SUPABASE_URL'] ?? '',
|
||||
|
@ -44,9 +38,16 @@ Future<void> main() async {
|
|||
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
|
||||
String mapboxAccesToken = dotenv.env['MAPBOX_ACCESS_TOKEN'] ?? '';
|
||||
|
||||
MapboxOptions.setAccessToken(mapboxAccesToken);
|
||||
|
||||
runApp(const App());
|
||||
|
|
|
@ -1,5 +1,10 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:sigap/src/cores/services/supabase_service.dart';
|
||||
import 'package:sigap/src/features/daily-ops/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/personalization/presentasion/pages/settings/setting_screen.dart';
|
||||
import 'package:sigap/src/shared/widgets/navigation/custom_bottom_navigation_bar.dart';
|
||||
|
@ -18,13 +23,7 @@ class NavigationMenu extends StatelessWidget {
|
|||
body: Obx(
|
||||
() => IndexedStack(
|
||||
index: controller.selectedIndex.value,
|
||||
children: const [
|
||||
// HomePage(),
|
||||
// SearchPage(),
|
||||
PanicButtonPage(),
|
||||
// HistoryPage(),
|
||||
SettingsScreen(),
|
||||
],
|
||||
children: controller.getScreens(),
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: const CustomBottomNavigationBar(),
|
||||
|
@ -38,8 +37,66 @@ class NavigationController extends GetxController {
|
|||
// Observable variable to track the current selected index
|
||||
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
|
||||
void changeIndex(int 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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import 'package:get/get.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/panic/presentation/bindings/panic_button_bindings.dart';
|
||||
import 'package:sigap/src/features/personalization/presentasion/bindings/personalization_bindings.dart';
|
||||
|
||||
class ControllerBindings extends Bindings {
|
||||
|
@ -17,5 +18,8 @@ class ControllerBindings extends Bindings {
|
|||
// Personalization Bindings
|
||||
PersonalizationBindings().dependencies();
|
||||
|
||||
|
||||
// Panic Button Bindings
|
||||
PanicButtonControllerBindings().dependencies();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,5 +18,7 @@ class RepositoryBindings extends Bindings {
|
|||
MapRepositoryBindings().dependencies();
|
||||
|
||||
DailyOpsRepositoryBindings().dependencies();
|
||||
|
||||
PanicButtonRepositoryBindings().dependencies();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,6 +12,11 @@ class LocationService extends GetxService {
|
|||
final RxBool isPermissionGranted = false.obs;
|
||||
final Rx<Position?> currentPosition = Rx<Position?>(null);
|
||||
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;
|
||||
|
||||
// Jember's center coordinate (approximate)
|
||||
|
@ -144,7 +149,6 @@ class LocationService extends GetxService {
|
|||
|
||||
// Get city name from coordinates
|
||||
if (currentPosition.value != null) {
|
||||
|
||||
await _updateCityName();
|
||||
}
|
||||
|
||||
|
@ -203,12 +207,33 @@ class LocationService extends GetxService {
|
|||
);
|
||||
|
||||
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) {
|
||||
currentCity.value = '';
|
||||
currentDistrict.value = '';
|
||||
lastAddress.value = '';
|
||||
Logger().e('Error updating location info: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -353,6 +378,11 @@ class LocationService extends GetxService {
|
|||
}
|
||||
|
||||
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(
|
||||
currentPosition.value!.latitude,
|
||||
currentPosition.value!.longitude,
|
||||
|
@ -372,15 +402,19 @@ class LocationService extends GetxService {
|
|||
placemark.postalCode ?? '',
|
||||
].where((component) => component.isNotEmpty).toList();
|
||||
|
||||
return addressComponents.join(', ');
|
||||
lastAddress.value = addressComponents.join(', ');
|
||||
return lastAddress.value;
|
||||
}
|
||||
|
||||
return '';
|
||||
} catch (e) {
|
||||
return '';
|
||||
return lastAddress.value.isNotEmpty ? lastAddress.value : '';
|
||||
}
|
||||
}
|
||||
|
||||
// Get current district and city names
|
||||
|
||||
|
||||
// Calculate distance between two points in kilometers
|
||||
double calculateDistance(double lat1, double lon1, double lat2, double lon2) {
|
||||
return Geolocator.distanceBetween(lat1, lon1, lat2, lon2) / 1000;
|
||||
|
|
|
@ -7,30 +7,33 @@ class SupabaseService extends GetxService {
|
|||
|
||||
final _client = Supabase.instance.client;
|
||||
|
||||
// Observable for auth state changes
|
||||
final Rx<User?> _currentUser = Rx<User?>(null);
|
||||
|
||||
/// Get Supabase client instance
|
||||
SupabaseClient get client => _client;
|
||||
|
||||
/// Get current authenticated user
|
||||
User? get currentUser => _client.auth.currentUser;
|
||||
User? get currentUser => _currentUser.value;
|
||||
|
||||
/// Get current user ID, if authenticated
|
||||
String? get currentUserId => _client.auth.currentUser?.id;
|
||||
String? get currentUserId => _currentUser.value?.id;
|
||||
|
||||
/// Get type-safe user metadata
|
||||
UserMetadataModel get userMetadata {
|
||||
if (currentUser == null) return UserMetadataModel();
|
||||
return UserMetadataModel.fromJson(currentUser!.userMetadata);
|
||||
if (_currentUser.value == null) return UserMetadataModel();
|
||||
return UserMetadataModel.fromJson(_currentUser.value!.userMetadata ?? {});
|
||||
}
|
||||
|
||||
/// 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
|
||||
bool get isOfficer => userMetadata.isOfficer;
|
||||
|
||||
/// Get the stored identifier (NIK or NRP) of the current user
|
||||
String? get userIdentifier {
|
||||
if (currentUser == null) return null;
|
||||
if (_currentUser.value == null) return null;
|
||||
final metadata = userMetadata;
|
||||
|
||||
if (metadata.isOfficer == true && metadata.officerData != null) {
|
||||
|
@ -42,6 +45,14 @@ class SupabaseService extends GetxService {
|
|||
|
||||
/// Initialize Supabase service
|
||||
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;
|
||||
}
|
||||
|
||||
|
@ -51,6 +62,7 @@ class SupabaseService extends GetxService {
|
|||
final response = await client.auth.updateUser(
|
||||
UserAttributes(data: metadata),
|
||||
);
|
||||
_currentUser.value = response.user;
|
||||
return response.user;
|
||||
} catch (e) {
|
||||
throw Exception('Failed to update user metadata: $e');
|
||||
|
@ -63,6 +75,7 @@ class SupabaseService extends GetxService {
|
|||
final response = await client.auth.updateUser(
|
||||
UserAttributes(data: metadata.toJson()),
|
||||
);
|
||||
_currentUser.value = response.user;
|
||||
return response.user;
|
||||
} catch (e) {
|
||||
throw Exception('Failed to update user metadata: $e');
|
||||
|
@ -71,6 +84,4 @@ class SupabaseService extends GetxService {
|
|||
|
||||
/// Check if current user is an officer
|
||||
bool get isUserOfficer => userMetadata.isOfficer;
|
||||
|
||||
|
||||
}
|
||||
|
|
|
@ -172,6 +172,7 @@ class AuthenticationRepository extends GetxController {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EMAIL & PASSWORD AUTHENTICATION
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -46,42 +46,42 @@ class DistrictModel {
|
|||
json['updated_at'] != null
|
||||
? DateTime.parse(json['updated_at'])
|
||||
: null,
|
||||
crimes:
|
||||
json['crimes'] != null
|
||||
? (json['crimes'] as List)
|
||||
.map((e) => CrimeModel.fromJson(e as Map<String, dynamic>))
|
||||
.toList()
|
||||
: null,
|
||||
demographics:
|
||||
json['demographics'] != null
|
||||
? (json['demographics'] as List)
|
||||
.map(
|
||||
(e) => DemographicModel.fromJson(e as Map<String, dynamic>),
|
||||
)
|
||||
.toList()
|
||||
: null,
|
||||
city:
|
||||
json['cities'] != null
|
||||
? CityModel.fromJson(json['cities'] as Map<String, dynamic>)
|
||||
: null,
|
||||
geographics:
|
||||
json['geographics'] != null
|
||||
? (json['geographics'] as List)
|
||||
.map(
|
||||
(e) => GeographicModel.fromJson(e as Map<String, dynamic>),
|
||||
)
|
||||
.toList()
|
||||
: null,
|
||||
locations:
|
||||
json['locations'] != null
|
||||
? (json['locations'] as List)
|
||||
.map((e) => LocationModel.fromJson(e as Map<String, dynamic>))
|
||||
.toList()
|
||||
: null,
|
||||
unit:
|
||||
json['units'] != null
|
||||
? UnitModel.fromJson(json['units'] as Map<String, dynamic>)
|
||||
: null,
|
||||
// crimes:
|
||||
// json['crimes'] != null
|
||||
// ? (json['crimes'] as List)
|
||||
// .map((e) => CrimeModel.fromJson(e as Map<String, dynamic>))
|
||||
// .toList()
|
||||
// : null,
|
||||
// demographics:
|
||||
// json['demographics'] != null
|
||||
// ? (json['demographics'] as List)
|
||||
// .map(
|
||||
// (e) => DemographicModel.fromJson(e as Map<String, dynamic>),
|
||||
// )
|
||||
// .toList()
|
||||
// : null,
|
||||
// city:
|
||||
// json['cities'] != null
|
||||
// ? CityModel.fromJson(json['cities'] as Map<String, dynamic>)
|
||||
// : null,
|
||||
// geographics:
|
||||
// json['geographics'] != null
|
||||
// ? (json['geographics'] as List)
|
||||
// .map(
|
||||
// (e) => GeographicModel.fromJson(e as Map<String, dynamic>),
|
||||
// )
|
||||
// .toList()
|
||||
// : null,
|
||||
// locations:
|
||||
// json['locations'] != null
|
||||
// ? (json['locations'] as List)
|
||||
// .map((e) => LocationModel.fromJson(e as Map<String, dynamic>))
|
||||
// .toList()
|
||||
// : null,
|
||||
// unit:
|
||||
// json['units'] != null
|
||||
// ? UnitModel.fromJson(json['units'] as Map<String, dynamic>)
|
||||
// : null,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
void clearCache() {
|
||||
_districtsByCity.clear();
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -34,4 +34,43 @@ class CrimesRepository extends GetxController {
|
|||
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()}',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
}
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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";
|
||||
}
|
||||
}
|
|
@ -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";
|
||||
}
|
||||
}
|
|
@ -1,6 +1,10 @@
|
|||
import 'package:flutter/material.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 {
|
||||
const PanicButtonPage({super.key});
|
||||
|
@ -10,259 +14,129 @@ class PanicButtonPage extends StatefulWidget {
|
|||
}
|
||||
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xFF0C1323), Color(0xFF223142)],
|
||||
backgroundColor: const Color(0xFFF8F9FA),
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios, color: Colors.black87),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
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,
|
||||
title: Column(
|
||||
children: [
|
||||
const SizedBox(height: 2),
|
||||
// Location indicator moved to app bar - Now using real location
|
||||
Obx(
|
||||
() => Text(
|
||||
controller.isPanicActive.value ? "SOS ACTIVE" : "Emergency Alert",
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
() => Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 4,
|
||||
),
|
||||
),
|
||||
),
|
||||
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: [
|
||||
Container(
|
||||
width: 180,
|
||||
height: 180,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.red.withOpacity(0.2),
|
||||
),
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 140,
|
||||
height: 140,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.red.withOpacity(0.4),
|
||||
),
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.red,
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
color: Colors.white,
|
||||
size: 50,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildLocationInfo() {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 24),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.1),
|
||||
color: Colors.blue.shade50,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.1),
|
||||
shape: BoxShape.circle,
|
||||
const Icon(Icons.location_on, color: Colors.blue, size: 12),
|
||||
const SizedBox(width: 2),
|
||||
Flexible(
|
||||
child: Text(
|
||||
_panicController.locationString.value.split(' · ')[0],
|
||||
style: const TextStyle(
|
||||
color: Colors.blue,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 12,
|
||||
),
|
||||
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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.location_on, color: Colors.blue),
|
||||
onPressed: () {
|
||||
// Refresh location when icon is tapped
|
||||
_panicController.getCurrentDistrict();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -139,9 +139,7 @@ class UserRepository extends GetxController {
|
|||
throw 'User not authenticated';
|
||||
}
|
||||
|
||||
final metadata = {
|
||||
'profile_status': status,
|
||||
};
|
||||
final metadata = {'profile_status': status};
|
||||
|
||||
await updateUserMetadata(metadata);
|
||||
} on AuthException catch (e) {
|
||||
|
@ -537,4 +535,22 @@ class UserRepository extends GetxController {
|
|||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -40,16 +40,16 @@ class CustomBottomNavigationBar extends StatelessWidget {
|
|||
),
|
||||
_buildNavItem(
|
||||
context,
|
||||
"Search",
|
||||
Icons.search,
|
||||
"Notify",
|
||||
Icons.notifications_none,
|
||||
1,
|
||||
controller.selectedIndex.value == 1,
|
||||
),
|
||||
_buildPanicButton(context),
|
||||
_buildNavItem(
|
||||
context,
|
||||
"History",
|
||||
Icons.history,
|
||||
"Map",
|
||||
Icons.map_outlined,
|
||||
3,
|
||||
controller.selectedIndex.value == 3,
|
||||
),
|
||||
|
|
|
@ -13,7 +13,6 @@ datasource db {
|
|||
model profiles {
|
||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||
user_id String @unique @db.Uuid
|
||||
nik String @unique @default("") @db.VarChar(100)
|
||||
avatar String? @db.VarChar(355)
|
||||
username String? @unique @db.VarChar(255)
|
||||
first_name String? @db.VarChar(255)
|
||||
|
@ -21,9 +20,10 @@ model profiles {
|
|||
bio String? @db.VarChar
|
||||
address Json? @db.Json
|
||||
birth_date DateTime?
|
||||
nik String? @db.VarChar(100)
|
||||
birth_place String?
|
||||
users users @relation(fields: [user_id], references: [id])
|
||||
|
||||
@@index([nik], map: "idx_profiles_nik")
|
||||
@@index([user_id])
|
||||
@@index([username])
|
||||
}
|
||||
|
@ -43,19 +43,19 @@ model users {
|
|||
user_metadata Json?
|
||||
created_at DateTime @default(now()) @db.Timestamptz(6)
|
||||
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
||||
is_banned Boolean @default(false)
|
||||
spoofing_attempts Int @default(0)
|
||||
panic_strike Int @default(0)
|
||||
banned_reason String? @db.VarChar(255)
|
||||
banned_until DateTime? @db.Timestamptz(6)
|
||||
is_anonymous Boolean @default(false)
|
||||
banned_reason String? @db.VarChar(255)
|
||||
is_banned Boolean @default(false)
|
||||
panic_strike Int @default(0)
|
||||
spoofing_attempts Int @default(0)
|
||||
events events[]
|
||||
incident_logs incident_logs[]
|
||||
location_logs location_logs[]
|
||||
panic_button_logs panic_button_logs[]
|
||||
profile profiles?
|
||||
sessions sessions[]
|
||||
role roles @relation(fields: [roles_id], references: [id])
|
||||
panic_button_logs panic_button_logs[]
|
||||
|
||||
@@index([is_anonymous])
|
||||
@@index([created_at])
|
||||
|
@ -68,9 +68,9 @@ model roles {
|
|||
description String?
|
||||
created_at DateTime @default(now()) @db.Timestamptz(6)
|
||||
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
||||
officers officers[]
|
||||
permissions permissions[]
|
||||
users users[]
|
||||
officers officers[]
|
||||
}
|
||||
|
||||
model sessions {
|
||||
|
@ -267,10 +267,10 @@ model incident_logs {
|
|||
verified Boolean? @default(false)
|
||||
created_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")
|
||||
locations locations @relation(fields: [location_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
user users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
evidence evidence[]
|
||||
panic_button_logs panic_button_logs[]
|
||||
|
||||
@@index([category_id], map: "idx_incident_logs_category_id")
|
||||
|
@ -278,15 +278,14 @@ model incident_logs {
|
|||
}
|
||||
|
||||
model evidence {
|
||||
id String @id @unique @db.VarChar(20)
|
||||
incident_id String @db.Uuid
|
||||
type String @db.VarChar(50) // contoh: photo, video, document, images
|
||||
url String @db.Text
|
||||
description String? @db.VarChar(255)
|
||||
caption String? @db.VarChar(255)
|
||||
metadata Json?
|
||||
type String @db.VarChar(50)
|
||||
url String
|
||||
uploaded_at DateTime? @default(now()) @db.Timestamptz(6)
|
||||
|
||||
caption String? @db.VarChar(255)
|
||||
description String? @db.VarChar(255)
|
||||
metadata Json?
|
||||
id String @id @unique @db.VarChar(20)
|
||||
incident incident_logs @relation(fields: [incident_id], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([incident_id], map: "idx_evidence_incident_id")
|
||||
|
@ -306,12 +305,12 @@ model units {
|
|||
longitude Float
|
||||
location Unsupported("geography")
|
||||
city_id String @db.VarChar(20)
|
||||
phone String?
|
||||
phone String? @db.VarChar(20)
|
||||
officers officers[]
|
||||
patrol_units patrol_units[]
|
||||
unit_statistics unit_statistics[]
|
||||
cities cities @relation(fields: [city_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
districts districts? @relation(fields: [district_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
officers officers[]
|
||||
patrol_units patrol_units[]
|
||||
|
||||
@@index([name], map: "idx_units_name")
|
||||
@@index([type], map: "idx_units_type")
|
||||
|
@ -320,13 +319,9 @@ model units {
|
|||
@@index([location], map: "idx_unit_location", type: Gist)
|
||||
@@index([district_id, location], map: "idx_units_location_district")
|
||||
@@index([location], map: "idx_units_location_gist", type: Gist)
|
||||
@@index([location], type: Gist)
|
||||
@@index([location], map: "units_location_idx1", type: Gist)
|
||||
@@index([location], map: "units_location_idx2", type: Gist)
|
||||
}
|
||||
|
||||
model patrol_units {
|
||||
id String @id @unique @db.VarChar(100)
|
||||
unit_id String @db.VarChar(20)
|
||||
location_id String @db.Uuid
|
||||
name String @db.VarChar(100)
|
||||
|
@ -334,7 +329,9 @@ model patrol_units {
|
|||
status String @db.VarChar(50)
|
||||
radius Float
|
||||
created_at DateTime @default(now()) @db.Timestamptz(6)
|
||||
|
||||
id String @id @unique @db.VarChar(100)
|
||||
category patrol_unit_category? @default(group)
|
||||
member_count Int? @default(0)
|
||||
members officers[]
|
||||
location locations @relation(fields: [location_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
unit units @relation(fields: [unit_id], references: [code_unit], onDelete: Cascade, onUpdate: NoAction)
|
||||
|
@ -347,29 +344,31 @@ model patrol_units {
|
|||
}
|
||||
|
||||
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
|
||||
patrol_unit_id String @db.VarChar(100)
|
||||
nrp String @unique @db.VarChar(100)
|
||||
nrp String? @unique @db.VarChar(100)
|
||||
name String @db.VarChar(100)
|
||||
rank String? @db.VarChar(100)
|
||||
position String? @db.VarChar(100)
|
||||
phone String? @db.VarChar(100)
|
||||
phone String? @db.VarChar(20)
|
||||
email String? @db.VarChar(255)
|
||||
avatar String?
|
||||
valid_until DateTime?
|
||||
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)
|
||||
panic_strike Int @default(0)
|
||||
spoofing_attempts Int @default(0)
|
||||
banned_reason String? @db.VarChar(255)
|
||||
banned_until DateTime?
|
||||
created_at DateTime? @default(now()) @db.Timestamptz(6)
|
||||
updated_at DateTime? @default(now()) @db.Timestamptz(6)
|
||||
units units @relation(fields: [unit_id], references: [code_unit], onDelete: Cascade, onUpdate: NoAction)
|
||||
place_of_birth String?
|
||||
date_of_birth DateTime? @db.Timestamptz(6)
|
||||
patrol_units patrol_units? @relation(fields: [patrol_unit_id], references: [id], onDelete: Restrict)
|
||||
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[]
|
||||
|
||||
@@index([unit_id], map: "idx_officers_unit_id")
|
||||
|
@ -453,9 +452,9 @@ model panic_button_logs {
|
|||
officer_id String? @db.Uuid
|
||||
incident_id String @db.Uuid
|
||||
timestamp DateTime @db.Timestamptz(6)
|
||||
users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
officers officers? @relation(fields: [officer_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
incidents incident_logs @relation(fields: [incident_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
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")
|
||||
}
|
||||
|
@ -476,6 +475,11 @@ model location_logs {
|
|||
@@index([user_id], map: "idx_location_logs_user_id")
|
||||
}
|
||||
|
||||
enum patrol_unit_category {
|
||||
individual
|
||||
group
|
||||
}
|
||||
|
||||
enum session_status {
|
||||
active
|
||||
completed
|
||||
|
|
Loading…
Reference in New Issue