feat(statistics): enhance safety indicators with animations and improve UI components

This commit is contained in:
vergiLgood1 2025-05-27 18:09:30 +07:00
parent 8bafa19e31
commit 7c44fa86bd
5 changed files with 415 additions and 24 deletions

View File

@ -10,7 +10,7 @@ class MainSafetyIndicatorController extends GetxController {
// Observable variables // Observable variables
final RxDouble progress = 0.0.obs; final RxDouble progress = 0.0.obs;
final RxString title = "".obs; final RxString title = "".obs;
final RxString label = "Level".obs; final RxString label = "Level of crime".obs;
final Rx<Color> color = Colors.purple.obs; final Rx<Color> color = Colors.purple.obs;
@override @override

View File

@ -0,0 +1,190 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
class MainSafetyIndicatorAnimated extends StatefulWidget {
final double progress;
final String title;
final String label;
final Color color;
const MainSafetyIndicatorAnimated({
super.key,
required this.progress,
required this.title,
required this.label,
required this.color,
});
@override
State<MainSafetyIndicatorAnimated> createState() =>
_MainSafetyIndicatorAnimatedState();
}
class _MainSafetyIndicatorAnimatedState
extends State<MainSafetyIndicatorAnimated>
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(MainSafetyIndicatorAnimated oldWidget) {
super.didUpdateWidget(oldWidget);
// If 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),
);
// Reset and run the animation
_controller.reset();
_controller.forward();
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
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: 160,
width: 160,
child: Stack(
alignment: Alignment.center,
children: [
// Animated progress circle
CustomPaint(
painter: SafetyIndicatorPainter(
progress: _progressAnimation.value,
color: widget.color,
),
size: const Size(160, 160),
),
// Center text
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
widget.title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'${(_progressAnimation.value * 100).toInt()}%',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: widget.color,
),
),
],
),
],
),
);
},
),
const SizedBox(height: 10),
Text(
widget.label,
style: const TextStyle(fontSize: 14, color: Colors.grey),
),
],
),
);
}
}
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 = 14.0;
canvas.drawCircle(center, radius - 7, backgroundPaint);
// Progress arc
final progressPaint =
Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 14.0
..strokeCap = StrokeCap.round;
final progressAngle = 2 * math.pi * progress;
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius - 7),
-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

@ -0,0 +1,120 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:sigap/src/features/panic/presentation/controllers/statistics_view_controller.dart';
class RecoveryIndicatorAnimated extends StatefulWidget {
const RecoveryIndicatorAnimated({super.key});
@override
State<RecoveryIndicatorAnimated> createState() =>
_RecoveryIndicatorAnimatedState();
}
class _RecoveryIndicatorAnimatedState extends State<RecoveryIndicatorAnimated>
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 (_controller.isCompleted &&
_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 Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/material.dart';
class StatIndicatorCardAnimated extends StatefulWidget { class StatIndicatorCardAnimated extends StatefulWidget {
final double progress; final double progress;
final String value; final String value;
@ -8,28 +9,30 @@ class StatIndicatorCardAnimated extends StatefulWidget {
final Color color; final Color color;
const StatIndicatorCardAnimated({ const StatIndicatorCardAnimated({
Key? key, super.key,
required this.progress, required this.progress,
required this.value, required this.value,
required this.label, required this.label,
required this.color, required this.color,
}) : super(key: key); });
@override @override
State<StatIndicatorCardAnimated> createState() => _StatIndicatorCardAnimatedState(); State<StatIndicatorCardAnimated> createState() => _StatIndicatorCardAnimatedState();
} }
class _StatIndicatorCardAnimatedState extends State<StatIndicatorCardAnimated> class _StatIndicatorCardAnimatedState extends State<StatIndicatorCardAnimated>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
late AnimationController _controller; late AnimationController _controller;
late Animation<double> _progressAnimation; late Animation<double> _progressAnimation;
String _prevValue = '';
late AnimatedNumber _numberAnimation;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_controller = AnimationController( _controller = AnimationController(
vsync: this, vsync: this,
duration: const Duration(milliseconds: 1200), duration: const Duration(milliseconds: 1000),
); );
_progressAnimation = Tween<double>( _progressAnimation = Tween<double>(
@ -40,14 +43,48 @@ class _StatIndicatorCardAnimatedState extends State<StatIndicatorCardAnimated>
curve: Curves.easeOutCubic, curve: Curves.easeOutCubic,
)); ));
_prevValue = widget.value;
_numberAnimation = AnimatedNumber(
from: _parseValue(widget.value),
to: _parseValue(widget.value),
controller: _controller,
);
// Start the animation when the widget is first built // Start the animation when the widget is first built
_controller.forward(); _controller.forward();
} }
double _parseValue(String value) {
// Try to extract a number from the value string
if (value.contains('%')) {
// If it's a percentage, extract the number
return double.tryParse(value.replaceAll(RegExp(r'[^0-9.]'), '')) ?? 0;
} else {
// Otherwise, try to parse the entire string
return double.tryParse(value) ?? 0;
}
}
String _formatNumber(double value) {
// If original value contained a percentage sign, add it back
if (widget.value.contains('%')) {
return '${value.toInt()}%';
}
// Otherwise, if it's an integer, show it without decimals
if (value == value.truncateToDouble()) {
return value.toInt().toString();
}
// For other values, format with appropriate decimals
return value.toStringAsFixed(1);
}
@override @override
void didUpdateWidget(StatIndicatorCardAnimated oldWidget) { void didUpdateWidget(StatIndicatorCardAnimated oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
// If the progress value changes, animate to the new value
// If progress value changes, animate to the new value
if (oldWidget.progress != widget.progress) { if (oldWidget.progress != widget.progress) {
_progressAnimation = Tween<double>( _progressAnimation = Tween<double>(
begin: oldWidget.progress, begin: oldWidget.progress,
@ -56,9 +93,27 @@ class _StatIndicatorCardAnimatedState extends State<StatIndicatorCardAnimated>
parent: _controller, parent: _controller,
curve: Curves.easeOutCubic, curve: Curves.easeOutCubic,
)); ));
// Reset and run the animation
_controller.reset(); _controller.reset();
_controller.forward(); _controller.forward();
} }
// If value changes, animate the number
if (oldWidget.value != widget.value) {
_numberAnimation = AnimatedNumber(
from: _parseValue(_prevValue),
to: _parseValue(widget.value),
controller: _controller,
);
_prevValue = widget.value;
// If the animation wasn't triggered by progress change
if (oldWidget.progress == widget.progress) {
_controller.reset();
_controller.forward();
}
}
} }
@override @override
@ -90,6 +145,8 @@ class _StatIndicatorCardAnimatedState extends State<StatIndicatorCardAnimated>
AnimatedBuilder( AnimatedBuilder(
animation: _progressAnimation, animation: _progressAnimation,
builder: (context, child) { builder: (context, child) {
final displayValue = _formatNumber(_numberAnimation.value);
return SizedBox( return SizedBox(
height: 60, height: 60,
width: 60, width: 60,
@ -98,19 +155,21 @@ class _StatIndicatorCardAnimatedState extends State<StatIndicatorCardAnimated>
progress: _progressAnimation.value, progress: _progressAnimation.value,
color: widget.color, color: widget.color,
), ),
child: Center(
child: Text(
displayValue,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: widget.color,
),
),
),
), ),
); );
}, },
), ),
const SizedBox(height: 10), const SizedBox(height: 8),
// Value text
Text(
widget.value,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
// Label text // Label text
Text( Text(
widget.label, widget.label,
@ -118,6 +177,7 @@ class _StatIndicatorCardAnimatedState extends State<StatIndicatorCardAnimated>
fontSize: 12, fontSize: 12,
color: Colors.grey[600], color: Colors.grey[600],
), ),
textAlign: TextAlign.center,
), ),
], ],
), ),
@ -125,6 +185,27 @@ class _StatIndicatorCardAnimatedState extends State<StatIndicatorCardAnimated>
} }
} }
// Animation helper for smooth number transitions
class AnimatedNumber {
final double from;
final double to;
final AnimationController controller;
late Animation<double> animation;
AnimatedNumber({
required this.from,
required this.to,
required this.controller,
}) {
animation = Tween<double>(
begin: from,
end: to,
).animate(CurvedAnimation(parent: controller, curve: Curves.easeOutCubic));
}
double get value => animation.value;
}
class DonutChartPainter extends CustomPainter { class DonutChartPainter extends CustomPainter {
final double progress; final double progress;
final Color color; final Color color;

View File

@ -5,9 +5,9 @@ import 'package:sigap/src/features/panic/presentation/controllers/statistics_vie
import 'package:sigap/src/utils/loaders/shimmer.dart'; import 'package:sigap/src/utils/loaders/shimmer.dart';
import 'crime_stats_header.dart'; import 'crime_stats_header.dart';
import 'main_safety_indicator.dart'; import 'main_safety_indicator_animated.dart';
import 'recovery_indicator.dart'; import 'recovery_indicator.dart';
import 'stat_indicator_card.dart'; import 'stat_indicator_card_animated.dart';
class StatisticsView extends StatelessWidget { class StatisticsView extends StatelessWidget {
const StatisticsView({super.key}); const StatisticsView({super.key});
@ -46,8 +46,8 @@ class StatisticsView extends StatelessWidget {
const SizedBox(height: 15), const SizedBox(height: 15),
// Main indicator - Area Safety Level // Main indicator - Area Safety Level with animation - Full Width
MainSafetyIndicator( MainSafetyIndicatorAnimated(
progress: safetyController.progress.value, progress: safetyController.progress.value,
title: safetyController.title.value, title: safetyController.title.value,
label: safetyController.label.value, label: safetyController.label.value,
@ -58,11 +58,11 @@ class StatisticsView extends StatelessWidget {
const SizedBox(height: 15), const SizedBox(height: 15),
// Secondary indicators row with donut charts // Secondary indicators row with animated donut charts
Row( Row(
children: [ children: [
Expanded( Expanded(
child: StatIndicatorCard( child: StatIndicatorCardAnimated(
progress: statsController.reportsProgress.value, progress: statsController.reportsProgress.value,
value: statsController.reportsValue.value, value: statsController.reportsValue.value,
label: 'Reports', label: 'Reports',
@ -71,7 +71,7 @@ class StatisticsView extends StatelessWidget {
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(
child: StatIndicatorCard( child: StatIndicatorCardAnimated(
progress: statsController.zoneMinProgress.value, progress: statsController.zoneMinProgress.value,
value: statsController.zoneMinValue.value, value: statsController.zoneMinValue.value,
label: 'Safety Score', label: 'Safety Score',
@ -80,7 +80,7 @@ class StatisticsView extends StatelessWidget {
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(
child: StatIndicatorCard( child: StatIndicatorCardAnimated(
progress: statsController.mindfulProgress.value, progress: statsController.mindfulProgress.value,
value: statsController.mindfulValue.value, value: statsController.mindfulValue.value,
label: 'Solved Rate', label: 'Solved Rate',