From c116e5d229bc902e5a421335e33834f34a590097 Mon Sep 17 00:00:00 2001 From: vergiLgood1 Date: Tue, 27 May 2025 17:00:40 +0700 Subject: [PATCH] feat(panic): enhance incident logs model and repository, add recent incidents fetching, and improve statistics view UI --- .../models/models/incident_logs_model.dart | 12 +- .../incident_logs_repository.dart | 18 + .../controllers/panic_button_controller.dart | 25 +- .../statistics_view_controller.dart | 33 +- .../widgets/crime_stats_header.dart | 500 ++++++++++++++++-- 5 files changed, 493 insertions(+), 95 deletions(-) diff --git a/sigap-mobile/lib/src/features/panic-button/data/models/models/incident_logs_model.dart b/sigap-mobile/lib/src/features/panic-button/data/models/models/incident_logs_model.dart index 612183c..4ee65a7 100644 --- a/sigap-mobile/lib/src/features/panic-button/data/models/models/incident_logs_model.dart +++ b/sigap-mobile/lib/src/features/panic-button/data/models/models/incident_logs_model.dart @@ -46,12 +46,12 @@ class IncidentLogModel { json['updated_at'] != null ? DateTime.parse(json['updated_at']) : null, - evidence: - json['evidence'] != null - ? (json['evidence'] as List) - .map((e) => EvidenceModel.fromJson(e as Map)) - .toList() - : null, + // evidence: + // json['evidence'] != null + // ? (json['evidence'] as List) + // .map((e) => EvidenceModel.fromJson(e as Map)) + // .toList() + // : null, ); } diff --git a/sigap-mobile/lib/src/features/panic-button/data/repositories/incident_logs_repository.dart b/sigap-mobile/lib/src/features/panic-button/data/repositories/incident_logs_repository.dart index aeb46a8..8d4b67a 100644 --- a/sigap-mobile/lib/src/features/panic-button/data/repositories/incident_logs_repository.dart +++ b/sigap-mobile/lib/src/features/panic-button/data/repositories/incident_logs_repository.dart @@ -1,5 +1,6 @@ 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/utils/exceptions/exceptions.dart'; class IncidentLogsRepository extends GetxController { @@ -20,6 +21,23 @@ class IncidentLogsRepository extends GetxController { } } + // Get recent incidents + Future> getRecentIncidents() async { + try { + final response = await _supabase + .from('incident_logs') + .select('*, location_id(*), category_id(*)') + .order('time', ascending: false) + .limit(10); + + return List>.from( + response, + ).map((e) => IncidentLogModel.fromJson(e)).toList(); + } catch (e) { + throw TExceptions('Failed to fetch recent incidents: ${e.toString()}'); + } + } + Future> getIncidentById(String id) async { try { final response = 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 index 74f1424..4bfb76e 100644 --- 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 @@ -17,13 +17,17 @@ class PanicButtonController extends GetxController { // Location service final LocationService _locationService = Get.find(); + final RxString currentDistrict = ''.obs; + final RxString currentCity = ''.obs; + Timer? _timer; @override void onInit() { super.onInit(); - // Fetch real location - // _fetchLocation(); + + // Get current district and city + getCurrentDistrict(); // Setup location updates listener ever(_locationService.currentPosition, (_) => _updateLocationString()); @@ -249,8 +253,23 @@ class PanicButtonController extends GetxController { ); } - // Get user's current district String getCurrentDistrict() { + // Fetch real location + currentCity.value = _locationService.currentCity.value.replaceAll( + RegExp(r'(Kabupaten|Kecamatan)\s*'), + '', + ); + currentDistrict.value = _locationService.currentDistrict.value.replaceAll( + RegExp(r'(Kabupaten|Kecamatan)\s*'), + '', + ); + + return locationString.value = + "${currentCity.value}, ${currentDistrict.value}"; + } + + // Get user's current district + String updateCurrentDistrict() { _fetchLocation(); // Refresh location data if (_locationService.currentDistrict.value.isEmpty) { 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 index 7a0266b..820d2ff 100644 --- 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 @@ -56,6 +56,9 @@ class StatisticsViewController extends GetxController { void onInit() { super.onInit(); + // init district and city names + getDistrictName(); + // Load statistics data loadStatisticsData(); @@ -86,9 +89,6 @@ class StatisticsViewController extends GetxController { // Load crime statistics await _loadCrimeStatistics(); - // Load recent incidents - await _loadRecentIncidents(); - // Update UI components with fetched data _updateStatisticsUI(); } catch (e) { @@ -172,33 +172,6 @@ class StatisticsViewController extends GetxController { } } - 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; 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 index 772b9ea..130af52 100644 --- 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 @@ -2,6 +2,7 @@ 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/utils/constants/sizes.dart'; class CrimeStatsHeader extends StatelessWidget { final String district; @@ -25,18 +26,19 @@ class CrimeStatsHeader extends StatelessWidget { 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), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(12), boxShadow: [ BoxShadow( - color: Colors.grey.withOpacity(0.1), - blurRadius: 5, - spreadRadius: 1, + color: Colors.grey.withOpacity(0.08), + blurRadius: 8, + spreadRadius: 0, + offset: const Offset(0, 2), ), ], ), @@ -50,32 +52,45 @@ class CrimeStatsHeader extends StatelessWidget { child: Text( district, style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, + fontSize: 18, + fontWeight: FontWeight.w600, color: Colors.black87, ), overflow: TextOverflow.ellipsis, ), ), - IconButton( - icon: const Icon(Icons.refresh, size: 20), - onPressed: onRefresh, - tooltip: 'Refresh statistics', + Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: onRefresh, + child: Container( + padding: const EdgeInsets.all(8), + child: const Icon( + Icons.refresh_rounded, + size: 20, + color: Colors.blue, + ), + ), + ), ), ], ), - const SizedBox(height: 5), + const SizedBox(height: 12), - // Date picker row - Row( - children: [ - const Icon(Icons.calendar_month, size: 16, color: Colors.blue), - const SizedBox(width: 5), - _buildMonthDropdown(), - const SizedBox(width: 5), - _buildYearDropdown(statsController.availableYears), - ], + // Date picker section + Container( + padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 0), + child: Row( + children: [ + Expanded(child: _buildMonthDropdown()), + const SizedBox(width: TSizes.spaceBtwInputFields / 2), + Expanded( + child: _buildYearDropdown(statsController.availableYears), + ), + ], + ), ), ], ), @@ -88,48 +103,421 @@ class CrimeStatsHeader extends StatelessWidget { 'MMMM', ).format(DateTime(int.parse(year), currentMonth)); - return DropdownButton( - value: currentMonth, - underline: Container(), // Remove underline - 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)), + return CustomPopupMenuButton( + initialValue: currentMonth, + buttonBuilder: (context, openMenu, isOpen) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isOpen ? Colors.blue.shade300 : Colors.grey.shade300, + width: isOpen ? 2 : 1, + ), + boxShadow: + isOpen + ? [ + BoxShadow( + color: Colors.blue.withOpacity(0.1), + blurRadius: 4, + spreadRadius: 0, + ), + ] + : null, + ), + child: InkWell( + onTap: openMenu, + borderRadius: BorderRadius.circular(8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + monthName, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Colors.black87, + ), + overflow: TextOverflow.ellipsis, + ), + ), + AnimatedRotation( + turns: isOpen ? 0.5 : 0, + duration: const Duration(milliseconds: 200), + child: Icon( + Icons.keyboard_arrow_down_rounded, + color: isOpen ? Colors.blue.shade600 : Colors.grey.shade600, + size: 20, + ), + ), + ], + ), + ), ); - }), + }, + itemBuilder: (BuildContext context) { + return List.generate(12, (index) { + final monthNum = index + 1; + final monthName = DateFormat( + 'MMMM', + ).format(DateTime(int.parse(year), monthNum)); + final isSelected = monthNum == currentMonth; + + return CustomPopupMenuItem( + value: monthNum, + isSelected: isSelected, + child: Row( + children: [ + Container( + width: 6, + height: 6, + decoration: BoxDecoration( + color: + isSelected ? Colors.blue.shade600 : Colors.transparent, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + monthName, + style: TextStyle( + fontSize: 14, + fontWeight: + isSelected ? FontWeight.w600 : FontWeight.w400, + color: isSelected ? Colors.blue.shade700 : Colors.black87, + ), + ), + ), + if (isSelected) + Icon( + Icons.check_rounded, + size: 16, + color: Colors.blue.shade600, + ), + ], + ), + ); + }); + }, + onSelected: (value) { + onMonthChanged(value); + }, ); } Widget _buildYearDropdown(List availableYears) { final int currentYear = int.parse(year); + // Use the available years from stats controller + final selectedYear = + availableYears.contains(currentYear) + ? currentYear + : availableYears.isNotEmpty + ? availableYears.last + : DateTime.now().year; - return DropdownButton( - value: - availableYears.contains(currentYear) - ? currentYear - : availableYears.last, - underline: Container(), // Remove underline - onChanged: (value) { - if (value != null) onYearChanged(value); + return CustomPopupMenuButton( + initialValue: selectedYear, + buttonBuilder: (context, openMenu, isOpen) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isOpen ? Colors.blue.shade300 : Colors.grey.shade300, + width: isOpen ? 2 : 1, + ), + boxShadow: + isOpen + ? [ + BoxShadow( + color: Colors.blue.withOpacity(0.1), + blurRadius: 4, + spreadRadius: 0, + ), + ] + : null, + ), + child: InkWell( + onTap: openMenu, + borderRadius: BorderRadius.circular(8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + selectedYear.toString(), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Colors.black87, + ), + ), + ), + AnimatedRotation( + turns: isOpen ? 0.5 : 0, + duration: const Duration(milliseconds: 200), + child: Icon( + Icons.keyboard_arrow_down_rounded, + color: isOpen ? Colors.blue.shade600 : Colors.grey.shade600, + size: 20, + ), + ), + ], + ), + ), + ); + }, + itemBuilder: (BuildContext context) { + // Sort the years in descending order (newest first) + final sortedYears = List.from(availableYears) + ..sort((a, b) => b.compareTo(a)); + + return sortedYears.map((year) { + final isSelected = year == selectedYear; + return CustomPopupMenuItem( + value: year, + isSelected: isSelected, + child: Row( + children: [ + Container( + width: 6, + height: 6, + decoration: BoxDecoration( + color: + isSelected ? Colors.blue.shade600 : Colors.transparent, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + year.toString(), + style: TextStyle( + fontSize: 14, + fontWeight: + isSelected ? FontWeight.w600 : FontWeight.w400, + color: isSelected ? Colors.blue.shade700 : Colors.black87, + ), + ), + ), + if (isSelected) + Icon( + Icons.check_rounded, + size: 16, + color: Colors.blue.shade600, + ), + ], + ), + ); + }).toList(); + }, + onSelected: (value) { + onYearChanged(value); }, - items: - availableYears.map((year) { - return DropdownMenuItem( - value: year, - child: Text( - year.toString(), - style: const TextStyle(fontSize: 14), - ), - ); - }).toList(), + ); + } +} + +// Custom popup menu item with selection state +class CustomPopupMenuItem { + final T value; + final Widget child; + final bool isSelected; + + const CustomPopupMenuItem({ + required this.value, + required this.child, + this.isSelected = false, + }); +} + +// Enhanced custom popup menu button +class CustomPopupMenuButton extends StatefulWidget { + final T initialValue; + final List> Function(BuildContext) itemBuilder; + final Widget Function(BuildContext, VoidCallback, bool) buttonBuilder; + final void Function(T) onSelected; + + const CustomPopupMenuButton({ + super.key, + required this.initialValue, + required this.itemBuilder, + required this.buttonBuilder, + required this.onSelected, + }); + + @override + State> createState() => + _CustomPopupMenuButtonState(); +} + +class _CustomPopupMenuButtonState extends State> + with TickerProviderStateMixin { + late T selectedValue; + final LayerLink _layerLink = LayerLink(); + OverlayEntry? _overlayEntry; + bool _isOpen = false; + late AnimationController _animationController; + late Animation _scaleAnimation; + late Animation _opacityAnimation; + + @override + void initState() { + super.initState(); + selectedValue = widget.initialValue; + _animationController = AnimationController( + duration: const Duration(milliseconds: 200), + vsync: this, + ); + _scaleAnimation = Tween(begin: 0.8, end: 1.0).animate( + CurvedAnimation(parent: _animationController, curve: Curves.easeOutBack), + ); + _opacityAnimation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation(parent: _animationController, curve: Curves.easeOut), + ); + } + + void _showOverlay() { + if (_isOpen) return; + + setState(() { + _isOpen = true; + }); + + _overlayEntry = _createOverlayEntry(); + Overlay.of(context).insert(_overlayEntry!); + _animationController.forward(); + } + + void _hideOverlay() { + if (!_isOpen) return; + + setState(() { + _isOpen = false; + }); + + _animationController.reverse().then((_) { + _overlayEntry?.remove(); + _overlayEntry = null; + }); + } + + OverlayEntry _createOverlayEntry() { + final RenderBox renderBox = context.findRenderObject() as RenderBox; + final size = renderBox.size; + final offset = renderBox.localToGlobal(Offset.zero); + + return OverlayEntry( + builder: + (context) => GestureDetector( + onTap: _hideOverlay, + behavior: HitTestBehavior.translucent, + child: Stack( + children: [ + // Invisible overlay to detect taps outside + Positioned.fill(child: Container(color: Colors.transparent)), + // Dropdown menu + Positioned( + left: offset.dx, + top: offset.dy + size.height + 4, + width: size.width, + child: AnimatedBuilder( + animation: _animationController, + builder: (context, child) { + return Transform.scale( + scale: _scaleAnimation.value, + alignment: Alignment.topCenter, + child: Opacity( + opacity: _opacityAnimation.value, + child: Material( + elevation: 8, + borderRadius: BorderRadius.circular(12), + color: Colors.white, + shadowColor: Colors.black.withOpacity(0.1), + child: Container( + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: Colors.grey.shade200), + borderRadius: BorderRadius.circular(12), + ), + constraints: const BoxConstraints(maxHeight: 250), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric( + vertical: 8, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: + widget.itemBuilder(context).map((item) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + _hideOverlay(); + widget.onSelected(item.value); + setState(() { + selectedValue = item.value; + }); + }, + child: Container( + width: double.infinity, + padding: + const EdgeInsets.symmetric( + vertical: 12, + horizontal: 16, + ), + decoration: BoxDecoration( + color: + item.isSelected + ? Colors.blue + .withOpacity(0.05) + : Colors.transparent, + ), + child: item.child, + ), + ), + ); + }).toList(), + ), + ), + ), + ), + ), + ), + ); + }, + ), + ), + ], + ), + ), + ); + } + + @override + void dispose() { + _hideOverlay(); + _animationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return CompositedTransformTarget( + link: _layerLink, + child: widget.buttonBuilder(context, () { + if (_isOpen) { + _hideOverlay(); + } else { + _showOverlay(); + } + }, _isOpen), ); } }