feat(statistics): enhance safety indicators with animations and improve UI components
This commit is contained in:
parent
8bafa19e31
commit
7c44fa86bd
|
@ -10,7 +10,7 @@ class MainSafetyIndicatorController extends GetxController {
|
|||
// Observable variables
|
||||
final RxDouble progress = 0.0.obs;
|
||||
final RxString title = "".obs;
|
||||
final RxString label = "Level".obs;
|
||||
final RxString label = "Level of crime".obs;
|
||||
final Rx<Color> color = Colors.purple.obs;
|
||||
|
||||
@override
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class StatIndicatorCardAnimated extends StatefulWidget {
|
||||
final double progress;
|
||||
final String value;
|
||||
|
@ -8,12 +9,12 @@ class StatIndicatorCardAnimated extends StatefulWidget {
|
|||
final Color color;
|
||||
|
||||
const StatIndicatorCardAnimated({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.progress,
|
||||
required this.value,
|
||||
required this.label,
|
||||
required this.color,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
State<StatIndicatorCardAnimated> createState() => _StatIndicatorCardAnimatedState();
|
||||
|
@ -23,13 +24,15 @@ class _StatIndicatorCardAnimatedState extends State<StatIndicatorCardAnimated>
|
|||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _progressAnimation;
|
||||
String _prevValue = '';
|
||||
late AnimatedNumber _numberAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1200),
|
||||
duration: const Duration(milliseconds: 1000),
|
||||
);
|
||||
|
||||
_progressAnimation = Tween<double>(
|
||||
|
@ -40,14 +43,48 @@ class _StatIndicatorCardAnimatedState extends State<StatIndicatorCardAnimated>
|
|||
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
|
||||
_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
|
||||
void didUpdateWidget(StatIndicatorCardAnimated 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) {
|
||||
_progressAnimation = Tween<double>(
|
||||
begin: oldWidget.progress,
|
||||
|
@ -56,9 +93,27 @@ class _StatIndicatorCardAnimatedState extends State<StatIndicatorCardAnimated>
|
|||
parent: _controller,
|
||||
curve: Curves.easeOutCubic,
|
||||
));
|
||||
|
||||
// Reset and run the animation
|
||||
_controller.reset();
|
||||
_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
|
||||
|
@ -90,6 +145,8 @@ class _StatIndicatorCardAnimatedState extends State<StatIndicatorCardAnimated>
|
|||
AnimatedBuilder(
|
||||
animation: _progressAnimation,
|
||||
builder: (context, child) {
|
||||
final displayValue = _formatNumber(_numberAnimation.value);
|
||||
|
||||
return SizedBox(
|
||||
height: 60,
|
||||
width: 60,
|
||||
|
@ -98,19 +155,21 @@ class _StatIndicatorCardAnimatedState extends State<StatIndicatorCardAnimated>
|
|||
progress: _progressAnimation.value,
|
||||
color: widget.color,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
displayValue,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: widget.color,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
// Value text
|
||||
Text(
|
||||
widget.value,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Label text
|
||||
Text(
|
||||
widget.label,
|
||||
|
@ -118,6 +177,7 @@ class _StatIndicatorCardAnimatedState extends State<StatIndicatorCardAnimated>
|
|||
fontSize: 12,
|
||||
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 {
|
||||
final double progress;
|
||||
final Color color;
|
||||
|
|
|
@ -5,9 +5,9 @@ import 'package:sigap/src/features/panic/presentation/controllers/statistics_vie
|
|||
import 'package:sigap/src/utils/loaders/shimmer.dart';
|
||||
|
||||
import 'crime_stats_header.dart';
|
||||
import 'main_safety_indicator.dart';
|
||||
import 'main_safety_indicator_animated.dart';
|
||||
import 'recovery_indicator.dart';
|
||||
import 'stat_indicator_card.dart';
|
||||
import 'stat_indicator_card_animated.dart';
|
||||
|
||||
class StatisticsView extends StatelessWidget {
|
||||
const StatisticsView({super.key});
|
||||
|
@ -46,8 +46,8 @@ class StatisticsView extends StatelessWidget {
|
|||
|
||||
const SizedBox(height: 15),
|
||||
|
||||
// Main indicator - Area Safety Level
|
||||
MainSafetyIndicator(
|
||||
// Main indicator - Area Safety Level with animation - Full Width
|
||||
MainSafetyIndicatorAnimated(
|
||||
progress: safetyController.progress.value,
|
||||
title: safetyController.title.value,
|
||||
label: safetyController.label.value,
|
||||
|
@ -58,11 +58,11 @@ class StatisticsView extends StatelessWidget {
|
|||
|
||||
const SizedBox(height: 15),
|
||||
|
||||
// Secondary indicators row with donut charts
|
||||
// Secondary indicators row with animated donut charts
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: StatIndicatorCard(
|
||||
child: StatIndicatorCardAnimated(
|
||||
progress: statsController.reportsProgress.value,
|
||||
value: statsController.reportsValue.value,
|
||||
label: 'Reports',
|
||||
|
@ -71,7 +71,7 @@ class StatisticsView extends StatelessWidget {
|
|||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: StatIndicatorCard(
|
||||
child: StatIndicatorCardAnimated(
|
||||
progress: statsController.zoneMinProgress.value,
|
||||
value: statsController.zoneMinValue.value,
|
||||
label: 'Safety Score',
|
||||
|
@ -80,7 +80,7 @@ class StatisticsView extends StatelessWidget {
|
|||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: StatIndicatorCard(
|
||||
child: StatIndicatorCardAnimated(
|
||||
progress: statsController.mindfulProgress.value,
|
||||
value: statsController.mindfulValue.value,
|
||||
label: 'Solved Rate',
|
||||
|
|
Loading…
Reference in New Issue