From 860481a0936f73b4076dcb7fd337a68c274519da Mon Sep 17 00:00:00 2001 From: vergiLgood1 Date: Tue, 27 May 2025 15:28:25 +0700 Subject: [PATCH] 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. --- sigap-mobile/lib/main.dart | 21 +- sigap-mobile/lib/navigation_menu.dart | 73 +++- .../cores/bindings/controller_bindings.dart | 4 + .../cores/bindings/repository_bindings.dart | 2 + .../src/cores/services/location_service.dart | 46 ++- .../src/cores/services/supabase_service.dart | 27 +- .../authentication_repository.dart | 1 + .../models/models/crime_categories_model.dart | 60 +++ .../crime_categorie_repository.dart | 0 .../pages/patrol-unit/patrol_unit_screen.dart | 86 +++++ .../pages/home-screen/home_screen.dart | 35 ++ .../data/models/models/districts_model.dart | 72 ++-- .../repositories/districts_repository.dart | 17 + .../map/presentasion/pages/map_screen.dart | 35 ++ .../pages/notification_screen.dart | 35 ++ .../data/repositories/crimes_repository.dart | 39 ++ .../controllers/panic_button_controller.dart | 145 -------- .../panic_button_repository_bindings.dart | 13 + .../bindings/panic_button_bindings.dart | 46 +++ .../emergency_view_controller.dart | 94 +++++ .../main_safety_indicator_controller.dart | 71 ++++ .../controllers/panic_button_controller.dart | 284 ++++++++++++++ .../recovery_indicator_controller.dart | 164 +++++++++ .../statistics_view_controller.dart | 307 +++++++++++++++ .../presentation/pages/panic_button_page.dart | 348 ++++++------------ .../widgets/crime_stats_header.dart | 138 +++++++ .../presentation/widgets/emergency_view.dart | 191 ++++++++++ .../widgets/main_safety_indicator.dart | 82 +++++ .../presentation/widgets/panic_button.dart | 101 +++++ .../widgets/progress_arc_painter.dart | 57 +++ .../widgets/quick_action_button.dart | 45 +++ .../widgets/recovery_indicator.dart | 274 ++++++++++++++ .../widgets/stat_indicator_card.dart | 85 +++++ .../presentation/widgets/statistics_view.dart | 154 ++++++++ .../presentation/widgets/tab_button.dart | 46 +++ .../data/repositories/users_repository.dart | 22 +- .../custom_bottom_navigation_bar.dart | 8 +- sigap-mobile/schema.prisma | 122 +++--- 38 files changed, 2834 insertions(+), 516 deletions(-) create mode 100644 sigap-mobile/lib/src/features/daily-ops/data/models/models/crime_categories_model.dart create mode 100644 sigap-mobile/lib/src/features/daily-ops/data/repositories/crime_categorie_repository.dart create mode 100644 sigap-mobile/lib/src/features/daily-ops/presentasion/pages/patrol-unit/patrol_unit_screen.dart create mode 100644 sigap-mobile/lib/src/features/explore/presentasion/pages/home-screen/home_screen.dart create mode 100644 sigap-mobile/lib/src/features/map/presentasion/pages/map_screen.dart create mode 100644 sigap-mobile/lib/src/features/notification/presentation/pages/notification_screen.dart delete mode 100644 sigap-mobile/lib/src/features/panic/controllers/panic_button_controller.dart create mode 100644 sigap-mobile/lib/src/features/panic/datas/bindings/panic_button_repository_bindings.dart create mode 100644 sigap-mobile/lib/src/features/panic/presentation/bindings/panic_button_bindings.dart create mode 100644 sigap-mobile/lib/src/features/panic/presentation/controllers/emergency_view_controller.dart create mode 100644 sigap-mobile/lib/src/features/panic/presentation/controllers/main_safety_indicator_controller.dart create mode 100644 sigap-mobile/lib/src/features/panic/presentation/controllers/panic_button_controller.dart create mode 100644 sigap-mobile/lib/src/features/panic/presentation/controllers/recovery_indicator_controller.dart create mode 100644 sigap-mobile/lib/src/features/panic/presentation/controllers/statistics_view_controller.dart create mode 100644 sigap-mobile/lib/src/features/panic/presentation/widgets/crime_stats_header.dart create mode 100644 sigap-mobile/lib/src/features/panic/presentation/widgets/emergency_view.dart create mode 100644 sigap-mobile/lib/src/features/panic/presentation/widgets/main_safety_indicator.dart create mode 100644 sigap-mobile/lib/src/features/panic/presentation/widgets/panic_button.dart create mode 100644 sigap-mobile/lib/src/features/panic/presentation/widgets/progress_arc_painter.dart create mode 100644 sigap-mobile/lib/src/features/panic/presentation/widgets/quick_action_button.dart create mode 100644 sigap-mobile/lib/src/features/panic/presentation/widgets/recovery_indicator.dart create mode 100644 sigap-mobile/lib/src/features/panic/presentation/widgets/stat_indicator_card.dart create mode 100644 sigap-mobile/lib/src/features/panic/presentation/widgets/statistics_view.dart create mode 100644 sigap-mobile/lib/src/features/panic/presentation/widgets/tab_button.dart diff --git a/sigap-mobile/lib/main.dart b/sigap-mobile/lib/main.dart index 80c294c..a7e8b50 100644 --- a/sigap-mobile/lib/main.dart +++ b/sigap-mobile/lib/main.dart @@ -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 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 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()); diff --git a/sigap-mobile/lib/navigation_menu.dart b/sigap-mobile/lib/navigation_menu.dart index ee78d01..4d6ba62 100644 --- a/sigap-mobile/lib/navigation_menu.dart +++ b/sigap-mobile/lib/navigation_menu.dart @@ -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 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 getScreens() { + final List 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) { - selectedIndex.value = index; + // Ensure the index is valid for the current user role + if (index < getScreens().length) { + selectedIndex.value = index; + } + } + + // Get the maximum index based on available screens + int get maxIndex => getScreens().length - 1; + + // Check if a specific screen index should be visible + bool isScreenVisible(int index) { + // The PatrolUnit screen is at index 4 + if (index == 4) { + return isOfficer.value; + } + return true; } } diff --git a/sigap-mobile/lib/src/cores/bindings/controller_bindings.dart b/sigap-mobile/lib/src/cores/bindings/controller_bindings.dart index e4eab58..ee79364 100644 --- a/sigap-mobile/lib/src/cores/bindings/controller_bindings.dart +++ b/sigap-mobile/lib/src/cores/bindings/controller_bindings.dart @@ -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(); } } diff --git a/sigap-mobile/lib/src/cores/bindings/repository_bindings.dart b/sigap-mobile/lib/src/cores/bindings/repository_bindings.dart index 51a8988..a6efeef 100644 --- a/sigap-mobile/lib/src/cores/bindings/repository_bindings.dart +++ b/sigap-mobile/lib/src/cores/bindings/repository_bindings.dart @@ -18,5 +18,7 @@ class RepositoryBindings extends Bindings { MapRepositoryBindings().dependencies(); DailyOpsRepositoryBindings().dependencies(); + + PanicButtonRepositoryBindings().dependencies(); } } diff --git a/sigap-mobile/lib/src/cores/services/location_service.dart b/sigap-mobile/lib/src/cores/services/location_service.dart index ad74dfa..01469ec 100644 --- a/sigap-mobile/lib/src/cores/services/location_service.dart +++ b/sigap-mobile/lib/src/cores/services/location_service.dart @@ -12,6 +12,11 @@ class LocationService extends GetxService { final RxBool isPermissionGranted = false.obs; final Rx currentPosition = Rx(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 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 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; diff --git a/sigap-mobile/lib/src/cores/services/supabase_service.dart b/sigap-mobile/lib/src/cores/services/supabase_service.dart index 7b1c2a4..ff99994 100644 --- a/sigap-mobile/lib/src/cores/services/supabase_service.dart +++ b/sigap-mobile/lib/src/cores/services/supabase_service.dart @@ -6,31 +6,34 @@ class SupabaseService extends GetxService { static SupabaseService get instance => Get.find(); final _client = Supabase.instance.client; + + // Observable for auth state changes + final Rx _currentUser = Rx(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 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; - - } diff --git a/sigap-mobile/lib/src/features/auth/data/repositories/authentication_repository.dart b/sigap-mobile/lib/src/features/auth/data/repositories/authentication_repository.dart index f790a43..184371c 100644 --- a/sigap-mobile/lib/src/features/auth/data/repositories/authentication_repository.dart +++ b/sigap-mobile/lib/src/features/auth/data/repositories/authentication_repository.dart @@ -172,6 +172,7 @@ class AuthenticationRepository extends GetxController { } } + // --------------------------------------------------------------------------- // EMAIL & PASSWORD AUTHENTICATION // --------------------------------------------------------------------------- diff --git a/sigap-mobile/lib/src/features/daily-ops/data/models/models/crime_categories_model.dart b/sigap-mobile/lib/src/features/daily-ops/data/models/models/crime_categories_model.dart new file mode 100644 index 0000000..81d7fce --- /dev/null +++ b/sigap-mobile/lib/src/features/daily-ops/data/models/models/crime_categories_model.dart @@ -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 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 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, + ); + } + + +} \ No newline at end of file diff --git a/sigap-mobile/lib/src/features/daily-ops/data/repositories/crime_categorie_repository.dart b/sigap-mobile/lib/src/features/daily-ops/data/repositories/crime_categorie_repository.dart new file mode 100644 index 0000000..e69de29 diff --git a/sigap-mobile/lib/src/features/daily-ops/presentasion/pages/patrol-unit/patrol_unit_screen.dart b/sigap-mobile/lib/src/features/daily-ops/presentasion/pages/patrol-unit/patrol_unit_screen.dart new file mode 100644 index 0000000..581d144 --- /dev/null +++ b/sigap-mobile/lib/src/features/daily-ops/presentasion/pages/patrol-unit/patrol_unit_screen.dart @@ -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'), + ), + ], + ), + ), + ); + } +} diff --git a/sigap-mobile/lib/src/features/explore/presentasion/pages/home-screen/home_screen.dart b/sigap-mobile/lib/src/features/explore/presentasion/pages/home-screen/home_screen.dart new file mode 100644 index 0000000..a8a37b7 --- /dev/null +++ b/sigap-mobile/lib/src/features/explore/presentasion/pages/home-screen/home_screen.dart @@ -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, + ), + ], + ), + ), + ); + } +} diff --git a/sigap-mobile/lib/src/features/map/data/models/models/districts_model.dart b/sigap-mobile/lib/src/features/map/data/models/models/districts_model.dart index ba3bd31..c4f3025 100644 --- a/sigap-mobile/lib/src/features/map/data/models/models/districts_model.dart +++ b/sigap-mobile/lib/src/features/map/data/models/models/districts_model.dart @@ -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)) - .toList() - : null, - demographics: - json['demographics'] != null - ? (json['demographics'] as List) - .map( - (e) => DemographicModel.fromJson(e as Map), - ) - .toList() - : null, - city: - json['cities'] != null - ? CityModel.fromJson(json['cities'] as Map) - : null, - geographics: - json['geographics'] != null - ? (json['geographics'] as List) - .map( - (e) => GeographicModel.fromJson(e as Map), - ) - .toList() - : null, - locations: - json['locations'] != null - ? (json['locations'] as List) - .map((e) => LocationModel.fromJson(e as Map)) - .toList() - : null, - unit: - json['units'] != null - ? UnitModel.fromJson(json['units'] as Map) - : null, + // crimes: + // json['crimes'] != null + // ? (json['crimes'] as List) + // .map((e) => CrimeModel.fromJson(e as Map)) + // .toList() + // : null, + // demographics: + // json['demographics'] != null + // ? (json['demographics'] as List) + // .map( + // (e) => DemographicModel.fromJson(e as Map), + // ) + // .toList() + // : null, + // city: + // json['cities'] != null + // ? CityModel.fromJson(json['cities'] as Map) + // : null, + // geographics: + // json['geographics'] != null + // ? (json['geographics'] as List) + // .map( + // (e) => GeographicModel.fromJson(e as Map), + // ) + // .toList() + // : null, + // locations: + // json['locations'] != null + // ? (json['locations'] as List) + // .map((e) => LocationModel.fromJson(e as Map)) + // .toList() + // : null, + // unit: + // json['units'] != null + // ? UnitModel.fromJson(json['units'] as Map) + // : null, ); } diff --git a/sigap-mobile/lib/src/features/map/data/repositories/districts_repository.dart b/sigap-mobile/lib/src/features/map/data/repositories/districts_repository.dart index 4bf3f28..e1b3b65 100644 --- a/sigap-mobile/lib/src/features/map/data/repositories/districts_repository.dart +++ b/sigap-mobile/lib/src/features/map/data/repositories/districts_repository.dart @@ -96,6 +96,23 @@ class DistrictsRepository extends GetxController { } } + // Get districts by district name + Future 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(); diff --git a/sigap-mobile/lib/src/features/map/presentasion/pages/map_screen.dart b/sigap-mobile/lib/src/features/map/presentasion/pages/map_screen.dart new file mode 100644 index 0000000..8a37cd1 --- /dev/null +++ b/sigap-mobile/lib/src/features/map/presentasion/pages/map_screen.dart @@ -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, + ), + ], + ), + ), + ); + } +} diff --git a/sigap-mobile/lib/src/features/notification/presentation/pages/notification_screen.dart b/sigap-mobile/lib/src/features/notification/presentation/pages/notification_screen.dart new file mode 100644 index 0000000..19332e6 --- /dev/null +++ b/sigap-mobile/lib/src/features/notification/presentation/pages/notification_screen.dart @@ -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, + ), + ], + ), + ), + ); + } +} diff --git a/sigap-mobile/lib/src/features/panic-button/data/repositories/crimes_repository.dart b/sigap-mobile/lib/src/features/panic-button/data/repositories/crimes_repository.dart index de1f28a..bfdcdad 100644 --- a/sigap-mobile/lib/src/features/panic-button/data/repositories/crimes_repository.dart +++ b/sigap-mobile/lib/src/features/panic-button/data/repositories/crimes_repository.dart @@ -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>> 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>.from(response); + } catch (e) { + throw TExceptions('Failed to fetch crime statistics: ${e.toString()}'); + } + } + + // Get crime statistics summary by district + Future> getCrimeStatisticsSummary( + String districtId, + ) async { + try { + final response = await _supabase.rpc( + 'get_district_crime_summary', + params: {'p_district_id': districtId}, + ); + + return response as Map; + } catch (e) { + throw TExceptions( + 'Failed to fetch crime statistics summary: ${e.toString()}', + ); + } + } } diff --git a/sigap-mobile/lib/src/features/panic/controllers/panic_button_controller.dart b/sigap-mobile/lib/src/features/panic/controllers/panic_button_controller.dart deleted file mode 100644 index fcadbcb..0000000 --- a/sigap-mobile/lib/src/features/panic/controllers/panic_button_controller.dart +++ /dev/null @@ -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'; - } -} diff --git a/sigap-mobile/lib/src/features/panic/datas/bindings/panic_button_repository_bindings.dart b/sigap-mobile/lib/src/features/panic/datas/bindings/panic_button_repository_bindings.dart new file mode 100644 index 0000000..e06cc53 --- /dev/null +++ b/sigap-mobile/lib/src/features/panic/datas/bindings/panic_button_repository_bindings.dart @@ -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()); + // Register repositories + Get.lazyPut(() => IncidentLogsRepository()); + } +} diff --git a/sigap-mobile/lib/src/features/panic/presentation/bindings/panic_button_bindings.dart b/sigap-mobile/lib/src/features/panic/presentation/bindings/panic_button_bindings.dart new file mode 100644 index 0000000..b553e31 --- /dev/null +++ b/sigap-mobile/lib/src/features/panic/presentation/bindings/panic_button_bindings.dart @@ -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()) { + Get.put(Logger()); + } + + // Register repositories + Get.lazyPut(() => IncidentLogsRepository()); + Get.lazyPut(() => CrimesRepository()); + Get.lazyPut(() => CrimeIncidentsRepository()); + Get.lazyPut(() => LocationsRepository()); + + // Register services if not already registered + if (!Get.isRegistered()) { + Get.put(LocationService().init()); + } + + // Register main controller + Get.lazyPut(() => PanicButtonController()); + + // Register sub-controllers + Get.lazyPut(() => StatisticsViewController()); + Get.lazyPut(() => EmergencyViewController()); + Get.lazyPut( + () => MainSafetyIndicatorController(), + ); + Get.lazyPut( + () => RecoveryIndicatorController(), + ); + } +} diff --git a/sigap-mobile/lib/src/features/panic/presentation/controllers/emergency_view_controller.dart b/sigap-mobile/lib/src/features/panic/presentation/controllers/emergency_view_controller.dart new file mode 100644 index 0000000..d9bdd2f --- /dev/null +++ b/sigap-mobile/lib/src/features/panic/presentation/controllers/emergency_view_controller.dart @@ -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(); + + // Animation controllers + late AnimationController pulseController; + late AnimationController rippleController; + late Animation pulseAnimation; + late Animation 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(begin: 1.0, end: 1.1).animate( + CurvedAnimation(parent: pulseController, curve: Curves.easeInOut), + ); + + rippleAnimation = Tween( + 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); + } +} diff --git a/sigap-mobile/lib/src/features/panic/presentation/controllers/main_safety_indicator_controller.dart b/sigap-mobile/lib/src/features/panic/presentation/controllers/main_safety_indicator_controller.dart new file mode 100644 index 0000000..d43ff4b --- /dev/null +++ b/sigap-mobile/lib/src/features/panic/presentation/controllers/main_safety_indicator_controller.dart @@ -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(); + + // Observable variables + final RxDouble progress = 0.0.obs; + final RxString title = "".obs; + final RxString label = "Level".obs; + final Rx 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; + } + } +} diff --git a/sigap-mobile/lib/src/features/panic/presentation/controllers/panic_button_controller.dart b/sigap-mobile/lib/src/features/panic/presentation/controllers/panic_button_controller.dart new file mode 100644 index 0000000..74f1424 --- /dev/null +++ b/sigap-mobile/lib/src/features/panic/presentation/controllers/panic_button_controller.dart @@ -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(); + + 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"; + } + } +} diff --git a/sigap-mobile/lib/src/features/panic/presentation/controllers/recovery_indicator_controller.dart b/sigap-mobile/lib/src/features/panic/presentation/controllers/recovery_indicator_controller.dart new file mode 100644 index 0000000..396f123 --- /dev/null +++ b/sigap-mobile/lib/src/features/panic/presentation/controllers/recovery_indicator_controller.dart @@ -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(); + final IncidentLogsRepository _incidentLogsRepository = + Get.find(); + + // Observable variables + final RxString duration = "".obs; + final RxString timeLabel = "Today".obs; + final RxDouble progress = 0.0.obs; + + // Incident logs variables + final RxList unverifiedIncidentLogs = + [].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 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 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 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"; + } +} diff --git a/sigap-mobile/lib/src/features/panic/presentation/controllers/statistics_view_controller.dart b/sigap-mobile/lib/src/features/panic/presentation/controllers/statistics_view_controller.dart new file mode 100644 index 0000000..622e474 --- /dev/null +++ b/sigap-mobile/lib/src/features/panic/presentation/controllers/statistics_view_controller.dart @@ -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(); + final CrimeIncidentsRepository _crimeIncidentsRepo = + Get.find(); + final CrimesRepository _crimesRepo = Get.find(); + final DistrictsRepository _districtsRepo = Get.find(); + final Logger _logger = Get.find(); + + // 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 districtCrimes = [].obs; + final RxList recentIncidents = [].obs; + final RxList crimeCategories = [].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 currentDistrict = Rx(null); + + // Available years for selection (from 2020 to current year) + RxList get availableYears { + final int currentYearInt = DateTime.now().year; + return List.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 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 _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 _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 _loadCrimeStatistics() async { + try { + if (currentDistrictId.value.isEmpty) return; + + // Fetch crime statistics for the current district + final response = await Get.find() + .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 _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 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"; + } +} diff --git a/sigap-mobile/lib/src/features/panic/presentation/pages/panic_button_page.dart b/sigap-mobile/lib/src/features/panic/presentation/pages/panic_button_page.dart index a2237f4..9774ed3 100644 --- a/sigap-mobile/lib/src/features/panic/presentation/pages/panic_button_page.dart +++ b/sigap-mobile/lib/src/features/panic/presentation/pages/panic_button_page.dart @@ -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 { - final controller = Get.put(PanicButtonController()); + // Use GetX controller + late PanicButtonController _panicController; + late EmergencyViewController _emergencyController; + + @override + void initState() { + super.initState(); + + // Initialize controllers + _panicController = Get.find(); + _emergencyController = Get.find(); + } @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, - children: [ - Obx( - () => Text( - controller.isPanicActive.value ? "SOS ACTIVE" : "Emergency Alert", - style: const TextStyle( - color: Colors.white, - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - ), - Container( - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.1), - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.all(8), - child: const Icon(Icons.more_vert, color: Colors.white), - ), - ], - ), - ); - } - - Widget _buildStatusIndicator() { - return Obx(() { - if (controller.isPanicActive.value) { - // Active state - show pulsating effect - return Column( + title: 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: 2), + // Location indicator moved to app bar - Now using real location + Obx( + () => Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 4, + ), + decoration: BoxDecoration( + color: Colors.blue.shade50, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + 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, ), + overflow: TextOverflow.ellipsis, ), ), - ), + ], ), ), ), - const SizedBox(height: 24), - const Text( - "Help is on the way", - style: TextStyle( - color: Colors.white, - fontSize: 24, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Text( - "Signal sent ${controller.elapsedTimeString}", - style: TextStyle( - color: Colors.white.withOpacity(0.8), - fontSize: 16, - ), - ), ], - ); - } else { - // Inactive state - ready to activate - return Column( - children: [ - Container( - width: 120, - height: 120, - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Colors.teal.shade300, Colors.teal.shade600], - ), - ), - child: const Center( - child: Icon( - Icons.shield_outlined, - color: Colors.white, - size: 60, - ), - ), - ), - const SizedBox(height: 24), - const Text( - "You are unprotected", - style: TextStyle( - color: Colors.white, - fontSize: 24, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Text( - "Tap the button below in case of emergency", - style: TextStyle( - color: Colors.white.withOpacity(0.8), - fontSize: 16, - ), - textAlign: TextAlign.center, - ), - ], - ); - } - }); - } - - Widget _buildPanicButton() { - return Obx(() { - final isPanicActive = controller.isPanicActive.value; - return GestureDetector( - onTap: () => controller.togglePanicMode(), - child: Container( - width: 200, - height: 60, - decoration: BoxDecoration( - color: isPanicActive ? Colors.white : Colors.red, - borderRadius: BorderRadius.circular(30), - boxShadow: [ - BoxShadow( - color: (isPanicActive ? Colors.white : Colors.red).withOpacity( - 0.5, - ), - blurRadius: 20, - spreadRadius: 5, - ), - ], - ), - child: Center( - child: Text( - isPanicActive ? "CANCEL SOS" : "SOS", - style: TextStyle( - color: isPanicActive ? Colors.red : Colors.white, - fontSize: 24, - fontWeight: FontWeight.bold, - ), - ), - ), ), - ); - }); - } - - Widget _buildLocationInfo() { - return Container( - margin: const EdgeInsets.symmetric(horizontal: 24), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.1), - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.1), - shape: BoxShape.circle, - ), - child: const Icon(Icons.location_on, color: Colors.white, size: 24), + centerTitle: true, + actions: [ + IconButton( + icon: const Icon(Icons.location_on, color: Colors.blue), + onPressed: () { + // Refresh location when icon is tapped + _panicController.getCurrentDistrict(); + }, ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - "Your current location", - style: TextStyle( - color: Colors.white, - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 4), - Obx( - () => Text( - controller.locationString.value, - style: TextStyle( - color: Colors.white.withOpacity(0.7), - fontSize: 14, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ), - const SizedBox(width: 8), - Icon(Icons.chevron_right, color: Colors.white.withOpacity(0.7)), ], ), + body: SafeArea( + child: Column( + children: [ + // Add mode switcher tabs + Container( + margin: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(25), + ), + child: Obx( + () => Row( + mainAxisSize: MainAxisSize.min, + children: [ + TabButton( + title: 'Emergency', + isSelected: !_panicController.isStatisticsMode.value, + onTap: () => _panicController.setViewMode(false), + ), + TabButton( + title: 'Statistics', + isSelected: _panicController.isStatisticsMode.value, + onTap: () => _panicController.setViewMode(true), + ), + ], + ), + ), + ), + + // Main Content based on mode + Expanded( + child: Obx( + () => + _panicController.isStatisticsMode.value + ? const StatisticsView() + : EmergencyView( + pulseAnimation: _emergencyController.pulseAnimation, + rippleAnimation: _emergencyController.rippleAnimation, + isEmergencyActive: + _emergencyController.isCountingDown.value, + countdown: _emergencyController.countdown.value, + onActivatePanicButton: + _emergencyController.startCountdown, + onCancelEmergency: + _emergencyController.cancelCountdown, + showReportDialog: _panicController.showReportDialog, + ), + ), + ), + ], + ), + ), ); } } diff --git a/sigap-mobile/lib/src/features/panic/presentation/widgets/crime_stats_header.dart b/sigap-mobile/lib/src/features/panic/presentation/widgets/crime_stats_header.dart new file mode 100644 index 0000000..8ae26cd --- /dev/null +++ b/sigap-mobile/lib/src/features/panic/presentation/widgets/crime_stats_header.dart @@ -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(); + + 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( + 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( + value: monthNum, + child: Text(monthName, style: const TextStyle(fontSize: 14)), + ); + }), + ); + } + + // Custom year dropdown using the shared custom dropdown widget + Widget _buildYearDropdownCustom(List availableYears) { + final int currentYear = int.parse(year); + final selectedYear = + availableYears.contains(currentYear) + ? currentYear + : availableYears.last; + + return CustomDropdown( + label: '', + value: selectedYear, + onChanged: (value) { + if (value != null) onYearChanged(value); + }, + items: + availableYears.map((year) { + return DropdownMenuItem( + value: year, + child: Text( + year.toString(), + style: const TextStyle(fontSize: 14), + ), + ); + }).toList(), + ); + } +} diff --git a/sigap-mobile/lib/src/features/panic/presentation/widgets/emergency_view.dart b/sigap-mobile/lib/src/features/panic/presentation/widgets/emergency_view.dart new file mode 100644 index 0000000..0699726 --- /dev/null +++ b/sigap-mobile/lib/src/features/panic/presentation/widgets/emergency_view.dart @@ -0,0 +1,191 @@ +import 'package:flutter/material.dart'; + +import 'panic_button.dart'; +import 'quick_action_button.dart'; + +class EmergencyView extends StatelessWidget { + final Animation pulseAnimation; + final Animation 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), + ), + ], + ); + } +} diff --git a/sigap-mobile/lib/src/features/panic/presentation/widgets/main_safety_indicator.dart b/sigap-mobile/lib/src/features/panic/presentation/widgets/main_safety_indicator.dart new file mode 100644 index 0000000..0bac166 --- /dev/null +++ b/sigap-mobile/lib/src/features/panic/presentation/widgets/main_safety_indicator.dart @@ -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), + ), + ], + ), + ], + ), + ], + ), + ); + } +} diff --git a/sigap-mobile/lib/src/features/panic/presentation/widgets/panic_button.dart b/sigap-mobile/lib/src/features/panic/presentation/widgets/panic_button.dart new file mode 100644 index 0000000..eb52ee2 --- /dev/null +++ b/sigap-mobile/lib/src/features/panic/presentation/widgets/panic_button.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; + +class PanicButton extends StatelessWidget { + final Animation pulseAnimation; + final Animation 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, + ), + ), + ], + ), + ), + ), + ); + }, + ), + ], + ); + } +} diff --git a/sigap-mobile/lib/src/features/panic/presentation/widgets/progress_arc_painter.dart b/sigap-mobile/lib/src/features/panic/presentation/widgets/progress_arc_painter.dart new file mode 100644 index 0000000..0e36837 --- /dev/null +++ b/sigap-mobile/lib/src/features/panic/presentation/widgets/progress_arc_painter.dart @@ -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; +} diff --git a/sigap-mobile/lib/src/features/panic/presentation/widgets/quick_action_button.dart b/sigap-mobile/lib/src/features/panic/presentation/widgets/quick_action_button.dart new file mode 100644 index 0000000..48e7aba --- /dev/null +++ b/sigap-mobile/lib/src/features/panic/presentation/widgets/quick_action_button.dart @@ -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, + ), + ), + ], + ), + ); + } +} diff --git a/sigap-mobile/lib/src/features/panic/presentation/widgets/recovery_indicator.dart b/sigap-mobile/lib/src/features/panic/presentation/widgets/recovery_indicator.dart new file mode 100644 index 0000000..1c69b6a --- /dev/null +++ b/sigap-mobile/lib/src/features/panic/presentation/widgets/recovery_indicator.dart @@ -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(); + + 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), + ), + ), + ], + ), + ); + } +} diff --git a/sigap-mobile/lib/src/features/panic/presentation/widgets/stat_indicator_card.dart b/sigap-mobile/lib/src/features/panic/presentation/widgets/stat_indicator_card.dart new file mode 100644 index 0000000..74be7ee --- /dev/null +++ b/sigap-mobile/lib/src/features/panic/presentation/widgets/stat_indicator_card.dart @@ -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), + ), + ], + ), + ); + } +} diff --git a/sigap-mobile/lib/src/features/panic/presentation/widgets/statistics_view.dart b/sigap-mobile/lib/src/features/panic/presentation/widgets/statistics_view.dart new file mode 100644 index 0000000..17b27e8 --- /dev/null +++ b/sigap-mobile/lib/src/features/panic/presentation/widgets/statistics_view.dart @@ -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(); + final safetyController = Get.find(); + + 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(); + 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(); + 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; + } +} diff --git a/sigap-mobile/lib/src/features/panic/presentation/widgets/tab_button.dart b/sigap-mobile/lib/src/features/panic/presentation/widgets/tab_button.dart new file mode 100644 index 0000000..f9d36a8 --- /dev/null +++ b/sigap-mobile/lib/src/features/panic/presentation/widgets/tab_button.dart @@ -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, + ), + ), + ), + ); + } +} diff --git a/sigap-mobile/lib/src/features/personalization/data/repositories/users_repository.dart b/sigap-mobile/lib/src/features/personalization/data/repositories/users_repository.dart index 34c6700..0c16015 100644 --- a/sigap-mobile/lib/src/features/personalization/data/repositories/users_repository.dart +++ b/sigap-mobile/lib/src/features/personalization/data/repositories/users_repository.dart @@ -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 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 + } + } } diff --git a/sigap-mobile/lib/src/shared/widgets/navigation/custom_bottom_navigation_bar.dart b/sigap-mobile/lib/src/shared/widgets/navigation/custom_bottom_navigation_bar.dart index 3fb8bf9..47809c5 100644 --- a/sigap-mobile/lib/src/shared/widgets/navigation/custom_bottom_navigation_bar.dart +++ b/sigap-mobile/lib/src/shared/widgets/navigation/custom_bottom_navigation_bar.dart @@ -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, ), diff --git a/sigap-mobile/schema.prisma b/sigap-mobile/schema.prisma index a94adb8..18720e6 100644 --- a/sigap-mobile/schema.prisma +++ b/sigap-mobile/schema.prisma @@ -11,19 +11,19 @@ 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) - last_name String? @db.VarChar(255) - bio String? @db.VarChar - address Json? @db.Json - birth_date DateTime? - users users @relation(fields: [user_id], references: [id]) + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + user_id String @unique @db.Uuid + avatar String? @db.VarChar(355) + username String? @unique @db.VarChar(255) + first_name String? @db.VarChar(255) + last_name String? @db.VarChar(255) + bio String? @db.VarChar + address Json? @db.Json + birth_date DateTime? + 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,16 +278,15 @@ 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) + incident_id String @db.Uuid + 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? - uploaded_at DateTime? @default(now()) @db.Timestamptz(6) - - incident incident_logs @relation(fields: [incident_id], references: [id], onDelete: Cascade) + 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,24 +319,22 @@ 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) - type String @db.VarChar(50) - status String @db.VarChar(50) - radius Float - created_at DateTime @default(now()) @db.Timestamptz(6) - - members officers[] - location locations @relation(fields: [location_id], references: [id], onDelete: Cascade, onUpdate: NoAction) - unit units @relation(fields: [unit_id], references: [code_unit], onDelete: Cascade, onUpdate: NoAction) + unit_id String @db.VarChar(20) + location_id String @db.Uuid + name String @db.VarChar(100) + type String @db.VarChar(50) + status String @db.VarChar(50) + radius Float + created_at DateTime @default(now()) @db.Timestamptz(6) + 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) @@index([unit_id], map: "idx_patrol_units_unit_id") @@index([location_id], map: "idx_patrol_units_location_id") @@ -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