From b54c204963a60983af4a2215fb9a935b42ff00a3 Mon Sep 17 00:00:00 2001 From: vergiLgood1 Date: Tue, 27 May 2025 17:42:31 +0700 Subject: [PATCH] feat(statistics): add crime statistics fetching methods and enhance statistics view with animated indicators --- .../data/repositories/crimes_repository.dart | 40 ++++ .../panic/domain/models/crime_statistics.dart | 12 ++ .../statistics_view_controller.dart | 193 +++++++++++++++++- .../widgets/crime_stats_header.dart | 37 ++-- .../presentation/widgets/emergency_view.dart | 18 +- .../widgets/main_safety_indicator.dart | 179 ++++++++++++++++ .../widgets/recovery_indicator.dart | 117 +++++++++++ .../widgets/stat_indicator_card_animated.dart | 169 +++++++++++++++ .../presentation/widgets/statistics_view.dart | 6 +- 9 files changed, 724 insertions(+), 47 deletions(-) create mode 100644 sigap-mobile/lib/src/features/panic/domain/models/crime_statistics.dart create mode 100644 sigap-mobile/lib/src/features/panic/presentation/widgets/stat_indicator_card_animated.dart 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 bfdcdad..b8a09b5 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 @@ -56,6 +56,46 @@ class CrimesRepository extends GetxController { } } + // Get crime statistics for a specific year + Future>> getCrimeStatisticsByYear( + String districtId, + int year, + ) async { + try { + final response = await _supabase + .from('crimes') + .select('*, district:districts(*), crime_incidents(*)') + .eq('district_id', districtId) + .eq('year', year) + .order('created_at', ascending: false); + + return List>.from(response); + } catch (e) { + throw TExceptions( + 'Failed to fetch crime statistics by year: ${e.toString()}', + ); + } + } + + // Get all crime statistics for a specific district + Future>> getAllCrimeStatisticsByDistrict( + String districtId, + ) async { + try { + final response = await _supabase + .from('crimes') + .select('*, district:districts(*), crime_incidents(*)') + .eq('district_id', districtId) + .order('created_at', ascending: false); + + return List>.from(response); + } catch (e) { + throw TExceptions( + 'Failed to fetch all crime statistics: ${e.toString()}', + ); + } + } + // Get crime statistics summary by district Future> getCrimeStatisticsSummary( String districtId, diff --git a/sigap-mobile/lib/src/features/panic/domain/models/crime_statistics.dart b/sigap-mobile/lib/src/features/panic/domain/models/crime_statistics.dart new file mode 100644 index 0000000..e49c05a --- /dev/null +++ b/sigap-mobile/lib/src/features/panic/domain/models/crime_statistics.dart @@ -0,0 +1,12 @@ +class CrimeStatistics { + final Map data; + + CrimeStatistics({required this.data}); + + factory CrimeStatistics.fromJson(Map json) { + return CrimeStatistics(data: json); + } + + // Add getters or methods to access specific parts of the data + dynamic getStatFor(String category) => data[category]; +} 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 820d2ff..9415024 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 @@ -8,8 +8,30 @@ import 'package:sigap/src/features/panic-button/data/models/models/crime_inciden 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'; +import 'package:sigap/src/features/panic/domain/models/crime_statistics.dart'; class StatisticsViewController extends GetxController { + // Singelton instance of this controller + static StatisticsViewController get instance => + Get.find(); + + // Observable variables + final RxBool isLoading = false.obs; + final RxString selectedMonth = DateTime.now().month.toString().obs; + final RxString selectedYear = DateTime.now().year.toString().obs; + final RxString selectedDistrict = 'All Districts'.obs; + + // Complete dataset - stored once and filtered locally + final Rx> completeStatistics = Rx>( + {}, + ); + + // Filtered statistics based on current selections + final Rx filteredStatistics = Rx(null); + + // Available years from the API + // final RxList availableYears = [DateTime.now().year].obs; + // Dependencies final LocationService _locationService = Get.find(); final CrimeIncidentsRepository _crimeIncidentsRepo = @@ -31,6 +53,7 @@ class StatisticsViewController extends GetxController { final RxDouble recoveryProgress = 0.25.obs; // Crime statistics + final RxList allCrimeData = [].obs; final RxList districtCrimes = [].obs; final RxList recentIncidents = [].obs; final RxList crimeCategories = [].obs; @@ -49,7 +72,7 @@ class StatisticsViewController extends GetxController { } // Loading state - final RxBool isLoading = false.obs; + // final RxBool isLoading = false.obs; final RxString errorMessage = ''.obs; @override @@ -64,14 +87,58 @@ class StatisticsViewController extends GetxController { // Setup listener for location changes ever(_locationService.currentDistrict, (_) => _onLocationChanged()); + + // Setup listeners for month and year changes + ever(selectedMonth, (_) => _onMonthYearChanged()); + ever(selectedYear, (_) => _onMonthYearChanged()); + ever(selectedDistrict, (_) => _onDistrictChanged()); + + // Initialize the currently selected month/year to match what we use for API + selectedMonth.value = currentMonth.value; + selectedYear.value = currentYear.value; } + // Handle location changes void _onLocationChanged() { if (_locationService.currentDistrict.value.isNotEmpty) { loadStatisticsData(); } } + // Handle month or year changes + void _onMonthYearChanged() { + // Update the current month/year used for reference + currentMonth.value = selectedMonth.value.padLeft(2, '0'); + currentYear.value = selectedYear.value; + + // Check if we already have this data locally + if (_hasLocalDataFor( + int.parse(currentYear.value), + int.parse(currentMonth.value), + )) { + // Filter locally without API call + _filterLocalCrimeData(); + // Update UI with filtered data + _updateStatisticsUI(); + _logger.d( + 'Filtered crime data locally for ${currentYear.value}-${currentMonth.value}', + ); + } else { + // We don't have this data locally, fetch from API + _loadCrimeStatistics().then((_) { + _updateStatisticsUI(); + }); + } + } + + // Handle district changes + void _onDistrictChanged() { + // This would require additional implementation if we want to + // change districts within the app. For now, we'll just make a note. + _logger.d('District changed to: ${selectedDistrict.value}'); + // Future implementation could fetch data for different districts + } + // Load statistics data from Supabase Future loadStatisticsData() async { isLoading.value = true; @@ -86,8 +153,14 @@ class StatisticsViewController extends GetxController { await _loadDistrictData(); } - // Load crime statistics - await _loadCrimeStatistics(); + // Clear the local data store since this is a full reload + allCrimeData.clear(); + + // Fetch data for all available years and months for the current district + await _loadAllCrimeStatistics(); + + // Filter for the currently selected month and year + _filterLocalCrimeData(); // Update UI components with fetched data _updateStatisticsUI(); @@ -146,16 +219,61 @@ class StatisticsViewController extends GetxController { currentDistrictId.value = district.id; currentDistrict.value = district; + + Logger().i('Districts : ${district.toJson()}'); } catch (e) { _logger.e('Error loading district data: $e'); } } + // Load ALL available crime statistics for current district + Future _loadAllCrimeStatistics() async { + try { + if (currentDistrictId.value.isEmpty) return; + + // Get all available years + final years = availableYears; + final List allData = []; + + // For each year, fetch data for all 12 months + for (final year in years) { + for (int month = 1; month <= 12; month++) { + // Only fetch past months up to current month for current year + if (year == DateTime.now().year && month > DateTime.now().month) { + continue; + } + + try { + final response = await Get.find() + .getCrimeStatisticsByYear(currentDistrictId.value, year); + + // final monthData = + // response.map((crime) => CrimeModel.fromJson(crime)).toList(); + allData.addAll( + response.map((crime) => CrimeModel.fromJson(crime)).toList(), + ); + } catch (e) { + // Just log errors but continue fetching other months + _logger.w('Could not fetch data for $year-$month: $e'); + } + } + } + + // Update the local data store + allCrimeData.value = allData; + + _logger.i('Loaded ${allData.length} crime statistics records'); + } catch (e) { + _logger.e('Error loading all crime statistics: $e'); + } + } + + // This method now focuses only on fetching specific month/year data Future _loadCrimeStatistics() async { try { if (currentDistrictId.value.isEmpty) return; - // Fetch crime statistics for the current district + // Fetch crime statistics for the current district with the selected month/year final response = await Get.find() .getCrimeStatisticsByDistrict( currentDistrictId.value, @@ -166,7 +284,23 @@ class StatisticsViewController extends GetxController { // Convert to model objects final crimesList = response.map((crime) => CrimeModel.fromJson(crime)).toList(); + + // Update displayed crimes districtCrimes.value = crimesList; + + // Add to our local data store if not already there + final year = int.parse(currentYear.value); + final month = int.parse(currentMonth.value); + + // Remove any existing data for this year/month + allCrimeData.removeWhere( + (crime) => crime.year == year && crime.month == month, + ); + + // Add the new data + allCrimeData.addAll(crimesList); + + return; } catch (e) { _logger.e('Error loading crime statistics: $e'); } @@ -246,20 +380,33 @@ class StatisticsViewController extends GetxController { } // Refresh statistics data - void refreshStatistics() { - loadStatisticsData(); - } + // void refreshStatistics() { + // loadStatisticsData(); + // } // Change the month for crime statistics void changeMonth(int month) { - currentMonth.value = month.toString().padLeft(2, '0'); - loadStatisticsData(); + // Only update if it's a new value + if (selectedMonth.value != month.toString()) { + selectedMonth.value = month.toString(); + // The ever() listener will call _onMonthYearChanged + } } // Change the year for crime statistics void changeYear(int year) { - currentYear.value = year.toString(); - loadStatisticsData(); + // Only update if it's a new value + if (selectedYear.value != year.toString()) { + selectedYear.value = year.toString(); + // The ever() listener will call _onMonthYearChanged + } + } + + // Manual refresh - reloads all data + Future refreshStatistics() async { + // Clear local data to force a complete reload + allCrimeData.clear(); + await loadStatisticsData(); } // Get crime categories by type @@ -278,4 +425,28 @@ class StatisticsViewController extends GetxController { } return "Your Area"; } + + // Check if we have local data for the specified year and month + bool _hasLocalDataFor(int year, int month) { + return allCrimeData.any( + (crime) => crime.year == year && crime.month == month, + ); + } + + // Filter crime data locally based on selected year and month + void _filterLocalCrimeData() { + final int year = int.parse(currentYear.value); + final int month = int.parse(currentMonth.value); + + // Filter the all crime data list based on year and month + final filteredData = + allCrimeData + .where((crime) => crime.year == year && crime.month == month) + .toList(); + + // Update the district crimes with filtered data + if (filteredData.isNotEmpty) { + districtCrimes.value = filteredData; + } + } } 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 130af52..cc1ced4 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 @@ -84,11 +84,9 @@ class CrimeStatsHeader extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 0), child: Row( children: [ - Expanded(child: _buildMonthDropdown()), + Expanded(child: _buildMonthDropdown(statsController)), const SizedBox(width: TSizes.spaceBtwInputFields / 2), - Expanded( - child: _buildYearDropdown(statsController.availableYears), - ), + Expanded(child: _buildYearDropdown(statsController)), ], ), ), @@ -96,12 +94,10 @@ class CrimeStatsHeader extends StatelessWidget { ), ); } - - Widget _buildMonthDropdown() { + + Widget _buildMonthDropdown(StatisticsViewController controller) { final int currentMonth = int.parse(month); - final monthName = DateFormat( - 'MMMM', - ).format(DateTime(int.parse(year), currentMonth)); + final monthName = DateFormat('MMMM').format(DateTime(int.parse(year), currentMonth)); return CustomPopupMenuButton( initialValue: currentMonth, @@ -203,19 +199,22 @@ class CrimeStatsHeader extends StatelessWidget { }); }, onSelected: (value) { + // Use the controller's method instead of the callback + controller.changeMonth(value); + // Still keep the original callback for backward compatibility onMonthChanged(value); }, ); } - Widget _buildYearDropdown(List availableYears) { + Widget _buildYearDropdown(StatisticsViewController controller) { final int currentYear = int.parse(year); // Use the available years from stats controller - final selectedYear = - availableYears.contains(currentYear) - ? currentYear - : availableYears.isNotEmpty - ? availableYears.last + final availableYears = controller.availableYears; + final selectedYear = availableYears.contains(currentYear) + ? currentYear + : availableYears.isNotEmpty + ? availableYears.last : DateTime.now().year; return CustomPopupMenuButton( @@ -273,9 +272,8 @@ class CrimeStatsHeader extends StatelessWidget { }, itemBuilder: (BuildContext context) { // Sort the years in descending order (newest first) - final sortedYears = List.from(availableYears) - ..sort((a, b) => b.compareTo(a)); - + final sortedYears = List.from(availableYears)..sort((a, b) => b.compareTo(a)); + return sortedYears.map((year) { final isSelected = year == selectedYear; return CustomPopupMenuItem( @@ -316,6 +314,9 @@ class CrimeStatsHeader extends StatelessWidget { }).toList(); }, onSelected: (value) { + // Use the controller's method instead of the callback + controller.changeYear(value); + // Still keep the original callback for backward compatibility onYearChanged(value); }, ); 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 index 0699726..aec0b7a 100644 --- a/sigap-mobile/lib/src/features/panic/presentation/widgets/emergency_view.dart +++ b/sigap-mobile/lib/src/features/panic/presentation/widgets/emergency_view.dart @@ -131,21 +131,9 @@ class EmergencyView extends StatelessWidget { 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, - ), + _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), ], ), ], 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 index 0bac166..06b9f78 100644 --- 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 @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'dart:math' as math; import '../widgets/progress_arc_painter.dart'; @@ -80,3 +81,181 @@ class MainSafetyIndicator extends StatelessWidget { ); } } + +// Add this animated version of the main safety indicator +class AnimatedMainSafetyIndicator extends StatefulWidget { + final double progress; + final String title; + final String label; + final Color color; + + const AnimatedMainSafetyIndicator({ + Key? key, + required this.progress, + required this.title, + required this.label, + required this.color, + }) : super(key: key); + + @override + State createState() => _AnimatedMainSafetyIndicatorState(); +} + +class _AnimatedMainSafetyIndicatorState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _progressAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1500), + ); + + _progressAnimation = Tween( + begin: 0.0, + end: widget.progress, + ).animate(CurvedAnimation( + parent: _controller, + curve: Curves.easeOutCubic, + )); + + // Start the animation when the widget is first built + _controller.forward(); + } + + @override + void didUpdateWidget(AnimatedMainSafetyIndicator oldWidget) { + super.didUpdateWidget(oldWidget); + // If the progress value changes, animate to the new value + if (oldWidget.progress != widget.progress) { + _progressAnimation = Tween( + begin: oldWidget.progress, + end: widget.progress, + ).animate(CurvedAnimation( + parent: _controller, + curve: Curves.easeOutCubic, + )); + _controller.reset(); + _controller.forward(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 15), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.1), + spreadRadius: 1, + blurRadius: 5, + offset: const Offset(0, 1), + ), + ], + ), + child: Column( + children: [ + AnimatedBuilder( + animation: _progressAnimation, + builder: (context, child) { + return SizedBox( + height: 120, + width: 120, + child: CustomPaint( + painter: SafetyIndicatorPainter( + progress: _progressAnimation.value, + color: widget.color, + ), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + widget.title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + Text( + '${(_progressAnimation.value * 100).toInt()}%', + style: TextStyle( + fontSize: 14, + color: Colors.grey[600], + ), + ), + ], + ), + ), + ), + ); + }, + ), + const SizedBox(height: 10), + Text( + widget.label, + style: TextStyle( + fontSize: 14, + color: Colors.grey[600], + ), + ), + ], + ), + ); + } +} + +class SafetyIndicatorPainter extends CustomPainter { + final double progress; + final Color color; + + SafetyIndicatorPainter({required this.progress, required this.color}); + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final radius = math.min(size.width, size.height) / 2; + + // Background circle + final backgroundPaint = Paint() + ..color = Colors.grey.withOpacity(0.15) + ..style = PaintingStyle.stroke + ..strokeWidth = 12.0; + + canvas.drawCircle(center, radius - 6, backgroundPaint); + + // Progress arc + final progressPaint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = 12.0 + ..strokeCap = StrokeCap.round; + + final progressAngle = 2 * math.pi * progress; + + canvas.drawArc( + Rect.fromCircle(center: center, radius: radius - 6), + -math.pi / 2, // Start from the top + progressAngle, + false, + progressPaint, + ); + } + + @override + bool shouldRepaint(covariant SafetyIndicatorPainter oldDelegate) { + return oldDelegate.progress != progress || oldDelegate.color != color; + } +} 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 index 091dc54..49b1a66 100644 --- a/sigap-mobile/lib/src/features/panic/presentation/widgets/recovery_indicator.dart +++ b/sigap-mobile/lib/src/features/panic/presentation/widgets/recovery_indicator.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.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 RecoveryIndicator extends StatelessWidget { const RecoveryIndicator({super.key}); @@ -213,3 +214,119 @@ class RecoveryIndicator extends StatelessWidget { ); } } + +// Add this animated version of the recovery indicator +class AnimatedRecoveryIndicator extends StatefulWidget { + const AnimatedRecoveryIndicator({super.key}); + + @override + State createState() => _AnimatedRecoveryIndicatorState(); +} + +class _AnimatedRecoveryIndicatorState extends State + with SingleTickerProviderStateMixin { + final statsController = Get.find(); + late AnimationController _controller; + late Animation _progressAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1200), + ); + + _progressAnimation = Tween( + begin: 0.0, + end: statsController.recoveryProgress.value, + ).animate(CurvedAnimation( + parent: _controller, + curve: Curves.easeOutCubic, + )); + + // Start the animation when the widget is first built + _controller.forward(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Obx(() { + // Update the animation if the recovery progress changes + if (_progressAnimation.value != statsController.recoveryProgress.value) { + _progressAnimation = Tween( + begin: _progressAnimation.value, + end: statsController.recoveryProgress.value, + ).animate(CurvedAnimation( + parent: _controller, + curve: Curves.easeOutCubic, + )); + _controller.reset(); + _controller.forward(); + } + + return Container( + padding: const EdgeInsets.all(15), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.1), + spreadRadius: 1, + blurRadius: 5, + offset: const Offset(0, 1), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Area Recovery Status', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 10), + AnimatedBuilder( + animation: _progressAnimation, + builder: (context, child) { + return LinearProgressIndicator( + value: _progressAnimation.value, + backgroundColor: Colors.grey[200], + valueColor: AlwaysStoppedAnimation(_getStatusColor()), + minHeight: 10, + borderRadius: BorderRadius.circular(5), + ); + }, + ), + const SizedBox(height: 5), + Text( + statsController.recoveryTime.value, + style: TextStyle( + color: _getStatusColor(), + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + }); + } + + Color _getStatusColor() { + final progress = statsController.recoveryProgress.value; + if (progress >= 0.8) return Colors.green; + if (progress >= 0.5) return Colors.orange; + if (progress >= 0.3) return Colors.deepOrange; + return Colors.red; + } +} diff --git a/sigap-mobile/lib/src/features/panic/presentation/widgets/stat_indicator_card_animated.dart b/sigap-mobile/lib/src/features/panic/presentation/widgets/stat_indicator_card_animated.dart new file mode 100644 index 0000000..edae520 --- /dev/null +++ b/sigap-mobile/lib/src/features/panic/presentation/widgets/stat_indicator_card_animated.dart @@ -0,0 +1,169 @@ +import 'package:flutter/material.dart'; +import 'dart:math' as math; + +class StatIndicatorCardAnimated extends StatefulWidget { + final double progress; + final String value; + final String label; + final Color color; + + const StatIndicatorCardAnimated({ + Key? key, + required this.progress, + required this.value, + required this.label, + required this.color, + }) : super(key: key); + + @override + State createState() => _StatIndicatorCardAnimatedState(); +} + +class _StatIndicatorCardAnimatedState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _progressAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1200), + ); + + _progressAnimation = Tween( + begin: 0.0, + end: widget.progress, + ).animate(CurvedAnimation( + parent: _controller, + curve: Curves.easeOutCubic, + )); + + // Start the animation when the widget is first built + _controller.forward(); + } + + @override + void didUpdateWidget(StatIndicatorCardAnimated oldWidget) { + super.didUpdateWidget(oldWidget); + // If the progress value changes, animate to the new value + if (oldWidget.progress != widget.progress) { + _progressAnimation = Tween( + begin: oldWidget.progress, + end: widget.progress, + ).animate(CurvedAnimation( + parent: _controller, + curve: Curves.easeOutCubic, + )); + _controller.reset(); + _controller.forward(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 15, horizontal: 10), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.1), + spreadRadius: 1, + blurRadius: 5, + offset: const Offset(0, 1), + ), + ], + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Animated donut chart + AnimatedBuilder( + animation: _progressAnimation, + builder: (context, child) { + return SizedBox( + height: 60, + width: 60, + child: CustomPaint( + painter: DonutChartPainter( + progress: _progressAnimation.value, + color: widget.color, + ), + ), + ); + }, + ), + const SizedBox(height: 10), + // Value text + Text( + widget.value, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + // Label text + Text( + widget.label, + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + ), + ), + ], + ), + ); + } +} + +class DonutChartPainter extends CustomPainter { + final double progress; + final Color color; + + DonutChartPainter({required this.progress, required this.color}); + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final radius = math.min(size.width, size.height) / 2; + + // Background circle + final backgroundPaint = Paint() + ..color = Colors.grey.withOpacity(0.15) + ..style = PaintingStyle.stroke + ..strokeWidth = 8.0; + + canvas.drawCircle(center, radius - 4, backgroundPaint); + + // Progress arc + final progressPaint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = 8.0 + ..strokeCap = StrokeCap.round; + + final progressAngle = 2 * math.pi * progress; + + canvas.drawArc( + Rect.fromCircle(center: center, radius: radius - 4), + -math.pi / 2, // Start from the top + progressAngle, + false, + progressPaint, + ); + } + + @override + bool shouldRepaint(covariant DonutChartPainter oldDelegate) { + return oldDelegate.progress != progress || oldDelegate.color != color; + } +} 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 index 377b365..4cd3672 100644 --- a/sigap-mobile/lib/src/features/panic/presentation/widgets/statistics_view.dart +++ b/sigap-mobile/lib/src/features/panic/presentation/widgets/statistics_view.dart @@ -2,6 +2,8 @@ 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 'package:sigap/src/shared/widgets/loaders/custom_circular_loader.dart'; +import 'package:sigap/src/utils/constants/colors.dart'; import 'crime_stats_header.dart'; import 'main_safety_indicator.dart'; @@ -94,9 +96,7 @@ class StatisticsView extends StatelessWidget { // Loading indicator if (statsController.isLoading.value) - Container( - color: Colors.black.withOpacity(0.1), - child: const Center(child: CircularProgressIndicator()), + Center(child: TLoader.centeredLoader(color: TColors.primary), ), // Error message