feat(statistics): add crime statistics fetching methods and enhance statistics view with animated indicators
This commit is contained in:
parent
c116e5d229
commit
b54c204963
|
@ -56,6 +56,46 @@ class CrimesRepository extends GetxController {
|
|||
}
|
||||
}
|
||||
|
||||
// Get crime statistics for a specific year
|
||||
Future<List<Map<String, dynamic>>> 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<Map<String, dynamic>>.from(response);
|
||||
} catch (e) {
|
||||
throw TExceptions(
|
||||
'Failed to fetch crime statistics by year: ${e.toString()}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Get all crime statistics for a specific district
|
||||
Future<List<Map<String, dynamic>>> 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<Map<String, dynamic>>.from(response);
|
||||
} catch (e) {
|
||||
throw TExceptions(
|
||||
'Failed to fetch all crime statistics: ${e.toString()}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Get crime statistics summary by district
|
||||
Future<Map<String, dynamic>> getCrimeStatisticsSummary(
|
||||
String districtId,
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
class CrimeStatistics {
|
||||
final Map<String, dynamic> data;
|
||||
|
||||
CrimeStatistics({required this.data});
|
||||
|
||||
factory CrimeStatistics.fromJson(Map<String, dynamic> json) {
|
||||
return CrimeStatistics(data: json);
|
||||
}
|
||||
|
||||
// Add getters or methods to access specific parts of the data
|
||||
dynamic getStatFor(String category) => data[category];
|
||||
}
|
|
@ -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<StatisticsViewController>();
|
||||
|
||||
// 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<Map<String, dynamic>> completeStatistics = Rx<Map<String, dynamic>>(
|
||||
{},
|
||||
);
|
||||
|
||||
// Filtered statistics based on current selections
|
||||
final Rx<CrimeStatistics?> filteredStatistics = Rx<CrimeStatistics?>(null);
|
||||
|
||||
// Available years from the API
|
||||
// final RxList<int> availableYears = <int>[DateTime.now().year].obs;
|
||||
|
||||
// Dependencies
|
||||
final LocationService _locationService = Get.find<LocationService>();
|
||||
final CrimeIncidentsRepository _crimeIncidentsRepo =
|
||||
|
@ -31,6 +53,7 @@ class StatisticsViewController extends GetxController {
|
|||
final RxDouble recoveryProgress = 0.25.obs;
|
||||
|
||||
// Crime statistics
|
||||
final RxList<CrimeModel> allCrimeData = <CrimeModel>[].obs;
|
||||
final RxList<CrimeModel> districtCrimes = <CrimeModel>[].obs;
|
||||
final RxList<CrimeIncidentModel> recentIncidents = <CrimeIncidentModel>[].obs;
|
||||
final RxList<CrimeCategory> crimeCategories = <CrimeCategory>[].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<void> 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<void> _loadAllCrimeStatistics() async {
|
||||
try {
|
||||
if (currentDistrictId.value.isEmpty) return;
|
||||
|
||||
// Get all available years
|
||||
final years = availableYears;
|
||||
final List<CrimeModel> 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<CrimesRepository>()
|
||||
.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<void> _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<CrimesRepository>()
|
||||
.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<void> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
@ -97,11 +95,9 @@ 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<int>(
|
||||
initialValue: currentMonth,
|
||||
|
@ -203,18 +199,21 @@ 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<int> 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
|
||||
final availableYears = controller.availableYears;
|
||||
final selectedYear = availableYears.contains(currentYear)
|
||||
? currentYear
|
||||
: availableYears.isNotEmpty
|
||||
? availableYears.last
|
||||
: DateTime.now().year;
|
||||
|
||||
|
@ -273,8 +272,7 @@ class CrimeStatsHeader extends StatelessWidget {
|
|||
},
|
||||
itemBuilder: (BuildContext context) {
|
||||
// Sort the years in descending order (newest first)
|
||||
final sortedYears = List<int>.from(availableYears)
|
||||
..sort((a, b) => b.compareTo(a));
|
||||
final sortedYears = List<int>.from(availableYears)..sort((a, b) => b.compareTo(a));
|
||||
|
||||
return sortedYears.map((year) {
|
||||
final isSelected = year == selectedYear;
|
||||
|
@ -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);
|
||||
},
|
||||
);
|
||||
|
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
|
|
@ -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<AnimatedMainSafetyIndicator> createState() => _AnimatedMainSafetyIndicatorState();
|
||||
}
|
||||
|
||||
class _AnimatedMainSafetyIndicatorState extends State<AnimatedMainSafetyIndicator>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _progressAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1500),
|
||||
);
|
||||
|
||||
_progressAnimation = Tween<double>(
|
||||
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<double>(
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<AnimatedRecoveryIndicator> createState() => _AnimatedRecoveryIndicatorState();
|
||||
}
|
||||
|
||||
class _AnimatedRecoveryIndicatorState extends State<AnimatedRecoveryIndicator>
|
||||
with SingleTickerProviderStateMixin {
|
||||
final statsController = Get.find<StatisticsViewController>();
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _progressAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1200),
|
||||
);
|
||||
|
||||
_progressAnimation = Tween<double>(
|
||||
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<double>(
|
||||
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<Color>(_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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<StatIndicatorCardAnimated> createState() => _StatIndicatorCardAnimatedState();
|
||||
}
|
||||
|
||||
class _StatIndicatorCardAnimatedState extends State<StatIndicatorCardAnimated>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _progressAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1200),
|
||||
);
|
||||
|
||||
_progressAnimation = Tween<double>(
|
||||
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<double>(
|
||||
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;
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue