diff --git a/sigap-mobile/lib/src/features/panic/presentation/controllers/main_safety_indicator_controller.dart b/sigap-mobile/lib/src/features/panic/presentation/controllers/main_safety_indicator_controller.dart index d43ff4b..ba1bbe5 100644 --- a/sigap-mobile/lib/src/features/panic/presentation/controllers/main_safety_indicator_controller.dart +++ b/sigap-mobile/lib/src/features/panic/presentation/controllers/main_safety_indicator_controller.dart @@ -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 = Colors.purple.obs; @override diff --git a/sigap-mobile/lib/src/features/panic/presentation/widgets/main_safety_indicator_animated.dart b/sigap-mobile/lib/src/features/panic/presentation/widgets/main_safety_indicator_animated.dart new file mode 100644 index 0000000..f1b1ab8 --- /dev/null +++ b/sigap-mobile/lib/src/features/panic/presentation/widgets/main_safety_indicator_animated.dart @@ -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 createState() => + _MainSafetyIndicatorAnimatedState(); +} + +class _MainSafetyIndicatorAnimatedState + 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(MainSafetyIndicatorAnimated oldWidget) { + super.didUpdateWidget(oldWidget); + + // If 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), + ); + + // 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; + } +} diff --git a/sigap-mobile/lib/src/features/panic/presentation/widgets/recovery_indicator_animated.dart b/sigap-mobile/lib/src/features/panic/presentation/widgets/recovery_indicator_animated.dart new file mode 100644 index 0000000..b58ece5 --- /dev/null +++ b/sigap-mobile/lib/src/features/panic/presentation/widgets/recovery_indicator_animated.dart @@ -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 createState() => + _RecoveryIndicatorAnimatedState(); +} + +class _RecoveryIndicatorAnimatedState 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 (_controller.isCompleted && + _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 Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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 index edae520..a82e707 100644 --- 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 @@ -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,28 +9,30 @@ 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 createState() => _StatIndicatorCardAnimatedState(); } -class _StatIndicatorCardAnimatedState extends State +class _StatIndicatorCardAnimatedState extends State with SingleTickerProviderStateMixin { late AnimationController _controller; late Animation _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( @@ -40,14 +43,48 @@ class _StatIndicatorCardAnimatedState extends State 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( begin: oldWidget.progress, @@ -56,9 +93,27 @@ class _StatIndicatorCardAnimatedState extends State 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 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 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 fontSize: 12, color: Colors.grey[600], ), + textAlign: TextAlign.center, ), ], ), @@ -125,6 +185,27 @@ class _StatIndicatorCardAnimatedState extends State } } +// Animation helper for smooth number transitions +class AnimatedNumber { + final double from; + final double to; + final AnimationController controller; + late Animation animation; + + AnimatedNumber({ + required this.from, + required this.to, + required this.controller, + }) { + animation = Tween( + 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; 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 9937ed8..09b7641 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 @@ -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',