feat(statistics): add crime statistics fetching methods and enhance statistics view with animated indicators

This commit is contained in:
vergiLgood1 2025-05-27 17:42:31 +07:00
parent c116e5d229
commit b54c204963
9 changed files with 724 additions and 47 deletions

View File

@ -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,

View File

@ -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];
}

View File

@ -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;
}
}
}

View File

@ -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);
},
);

View File

@ -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),
],
),
],

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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