import 'dart:async'; import 'package:flutter/material.dart'; import 'package:firebase_database/firebase_database.dart'; import 'package:firebase_auth/firebase_auth.dart'; import 'package:fl_chart/fl_chart.dart'; import 'package:intl/intl.dart'; import 'history.dart'; import 'notifikasi.dart'; import '../widgets/fade_in_up.dart'; import '../widgets/whatsapp_notification.dart'; import 'akun.dart'; import '../utils/knn_classifier.dart'; class DashboardPage extends StatefulWidget { const DashboardPage({super.key}); @override State createState() => _DashboardPageState(); } class _DashboardPageState extends State { final db = FirebaseDatabase.instance.ref(); double ppm = 0.0; double displayPpm = 0.0; double lastTestedPpm = 0.0; Timer? _interpolationTimer; int fanStatus = 0; String selectedFood = "Monitoring"; bool isTesting = false; bool isFoodMenuExpanded = false; // 🔥 REALTIME GRAPH List realtimeData = []; int timeIndex = 0; StreamSubscription? startListener; StreamSubscription? _ppmSubscription; StreamSubscription? _historySubscription; StreamSubscription? _fanSubscription; bool _isInteractingWithChart = false; @override void initState() { super.initState(); WhatsAppNotificationService().startListening(); readData(); // Inisialisasi awal Firebase ke mode monitoring db.child("kontrol").update({"makanan": "Monitoring", "start": 0}); } @override void dispose() { startListener?.cancel(); _ppmSubscription?.cancel(); _historySubscription?.cancel(); _fanSubscription?.cancel(); _interpolationTimer?.cancel(); super.dispose(); } void readData() { // ================= SENSOR REALTIME ================= _ppmSubscription?.cancel(); _ppmSubscription = db.child("sensor/formalin_ppm").onValue.listen((event) { print("REALTIME PPM: ${event.snapshot.value}"); final data = event.snapshot.value; if (data != null && mounted) { double val = double.tryParse(data.toString()) ?? 0.0; setState(() { ppm = val; if (!isTesting) { displayPpm = val; // Tambahkan data ke grafik secara realtime saat standby realtimeData.add(FlSpot(timeIndex.toDouble(), val)); timeIndex++; if (realtimeData.length > 30) { realtimeData.removeAt(0); } } }); } }); // ================= LAST TEST RESULT ================= _historySubscription?.cancel(); _historySubscription = db.child("sensor/history").limitToLast(1).onValue.listen((event) { final data = event.snapshot.value as Map?; if (data != null && data.isNotEmpty && mounted) { final lastEntry = data.values.first as Map?; double lastPpm = double.tryParse(lastEntry?["ppm"]?.toString() ?? "") ?? 0.0; setState(() { lastTestedPpm = lastPpm; }); } }); // ================= FAN REALTIME ================= _fanSubscription?.cancel(); _fanSubscription = db.child("sensor/fan").onValue.listen((event) { print("REALTIME FAN: ${event.snapshot.value}"); final data = event.snapshot.value; if (data != null && mounted) { int newFanStatus = int.tryParse(data.toString()) ?? 0; setState(() { fanStatus = newFanStatus; }); } }); // ================= START REALTIME ================= startListener?.cancel(); startListener = db.child("kontrol/start").onValue.listen((event) { print("REALTIME START: ${event.snapshot.value}"); final val = event.snapshot.value; final startValue = int.tryParse(val.toString()) ?? 0; // TEST SELESAI if (startValue == 0 && isTesting && mounted) { final testedFood = selectedFood; _interpolationTimer?.cancel(); // Delay 1 detik untuk menghindari race condition agar Firebase selesai memproses data baru dari ESP32 Future.delayed(const Duration(milliseconds: 1000), () async { if (!mounted) return; double finalPpm = ppm; try { final snapshot = await db.child("sensor/hasil_akhir").get(); if (snapshot.value != null) { finalPpm = double.tryParse(snapshot.value.toString()) ?? ppm; } } catch (e) { print("Error fetching hasil_akhir: $e"); } if (mounted) { setState(() { isTesting = false; selectedFood = "Monitoring"; // Kembalikan ke mode monitoring di UI lastTestedPpm = finalPpm; displayPpm = finalPpm; realtimeData.clear(); timeIndex = 0; }); tampilkanHasil(testedFood); // Update Firebase ke mode monitoring db.child("kontrol").update({"makanan": "Monitoring", "start": 0}); // 🔥 SINKRONISASI: Simpan hasil uji ke history Firebase final historyRef = db.child("sensor/history").push(); final nowSeconds = DateTime.now().millisecondsSinceEpoch ~/ 1000; String statusKnn = klasifikasiKNN(finalPpm); historyRef.set({ "makanan": testedFood, "ppm": finalPpm, "timestamp": nowSeconds, "keterangan": statusKnn, }); // 🔥 SINKRONISASI: Kirim notifikasi / alert ke path notifikasi Firebase final notifRef = db.child("notifikasi").push(); final timeString = DateFormat('HH:mm').format(DateTime.now()); notifRef.set({ "title": statusKnn == "Bahaya" ? "Peringatan Bahaya!" : (statusKnn == "Sedang" ? "Peringatan Sedang!" : "Pemberitahuan Aman"), "message": statusKnn == "Bahaya" ? "Terdeteksi kandungan formalin tinggi pada $testedFood (${finalPpm.toStringAsFixed(2)} PPM)!" : (statusKnn == "Sedang" ? "Terdeteksi kandungan formalin sedang pada $testedFood (${finalPpm.toStringAsFixed(2)} PPM)." : "$testedFood aman dari kandungan formalin (${finalPpm.toStringAsFixed(2)} PPM)."), "timestamp": DateTime.now().millisecondsSinceEpoch, "time": timeString, }); } }); } }); } // ================= DETEKSI ================= void mulaiPengujian(String makanan) async { if (isTesting) return; setState(() { isTesting = true; selectedFood = makanan; displayPpm = 0.0; // reset grafik realtime realtimeData.clear(); timeIndex = 0; }); _startInterpolation(); await db.child("kontrol").update({"makanan": makanan, "start": 1}); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text("Mendeteksi $makanan selama 10 detik...")), ); } void batalkanPengujian() async { _interpolationTimer?.cancel(); setState(() { isTesting = false; selectedFood = "Monitoring"; displayPpm = ppm; realtimeData.clear(); timeIndex = 0; }); await db.child("kontrol").update({"makanan": "Monitoring", "start": 0, "reset": 1}); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( duration: Duration(milliseconds: 1500), content: Text("Pengujian dibatalkan. Kembali ke mode Monitoring."), ), ); } void _startInterpolation() { _interpolationTimer?.cancel(); _interpolationTimer = Timer.periodic(const Duration(milliseconds: 100), (timer) { if (!mounted || !isTesting) { timer.cancel(); return; } setState(() { if (displayPpm < ppm) { double diff = ppm - displayPpm; if (diff > 0.02) { displayPpm += diff * 0.12; // Asymptotical rise } else { displayPpm = ppm; } } else if (displayPpm > ppm) { double diff = displayPpm - ppm; if (diff > 0.02) { displayPpm -= diff * 0.12; } else { displayPpm = ppm; } } double roundedDisplay = double.parse((displayPpm ?? 0.0).toStringAsFixed(2)); realtimeData.add(FlSpot(timeIndex.toDouble(), roundedDisplay)); timeIndex++; if (realtimeData.length > 30) { realtimeData.removeAt(0); } }); }); } void tampilkanHasil(String makanan) { String status; String message; double resultPpm = lastTestedPpm ?? 0.0; String statusKnn = klasifikasiKNN(resultPpm); if (statusKnn == "Bahaya") { status = "Hasil Uji: BERBAHAYA!"; message = "Peringatan! Terdeteksi kandungan FORMALIN TINGGI pada $makanan (Rata-rata: ${resultPpm.toStringAsFixed(2)} PPM). JANGAN DIKONSUMSI!"; } else if (statusKnn == "Sedang") { status = "Hasil Uji: SEDANG"; message = "Perhatian. Terdeteksi kandungan formalin tingkat SEDANG pada $makanan (Rata-rata: ${resultPpm.toStringAsFixed(2)} PPM). Disarankan untuk lebih waspada."; } else { status = "Hasil Uji: AMAN"; message = "Makanan $makanan AMAN dari formalin. Silakan konsumsi!"; } showDialog( context: context, builder: (context) => AlertDialog( title: Text( status, style: TextStyle( color: statusKnn == "Bahaya" ? Colors.redAccent : (statusKnn == "Sedang" ? Colors.orangeAccent : const Color(0xFF1565C0)), fontWeight: FontWeight.bold, ), ), content: Text( "Kadar HCHO pada $makanan adalah ${resultPpm.toStringAsFixed(2)} PPM\n\n$message", ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text("OK"), ), ], ), ); } @override Widget build(BuildContext context) { final statusKnn = klasifikasiKNN(displayPpm); bool isBahaya = statusKnn == "Bahaya"; return Scaffold( body: Container( width: double.infinity, height: double.infinity, decoration: const BoxDecoration( gradient: LinearGradient( colors: [ Colors.white, Color(0xFFE3F2FD), // Light blue Color(0xFF42A5F5), // Medium blue Color(0xFF1565C0), // Dark blue ], begin: Alignment.topCenter, end: Alignment.bottomCenter, ), ), child: SafeArea( child: SingleChildScrollView( physics: _isInteractingWithChart ? const NeverScrollableScrollPhysics() : const BouncingScrollPhysics(), padding: const EdgeInsets.all(20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 0. GREETING SECTION FadeInUp( delay: 50, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( "Hello!", style: TextStyle( fontSize: 24, fontWeight: FontWeight.bold, color: Color(0xFF1565C0), ), ), Text( "Welcome to SafeBite", style: TextStyle( fontSize: 14, fontWeight: FontWeight.w500, color: const Color(0xFF1565C0).withOpacity(0.7), ), ), ], ), ), const SizedBox(height: 15), // 1. FORMALIN PPM CARD (Replacing Status Aman/Berbahaya) FadeInUp( delay: 100, child: AnimatedContainer( duration: const Duration(milliseconds: 600), curve: Curves.easeInOut, width: double.infinity, padding: const EdgeInsets.symmetric( vertical: 25, horizontal: 20, ), decoration: BoxDecoration( color: statusKnn == "Bahaya" ? Colors.redAccent : (statusKnn == "Sedang" ? Colors.orangeAccent : const Color(0xFF1565C0)), borderRadius: BorderRadius.circular(18), boxShadow: [ BoxShadow( color: (statusKnn == "Bahaya" ? Colors.redAccent : (statusKnn == "Sedang" ? Colors.orangeAccent : const Color(0xFF1565C0))) .withOpacity(statusKnn == "Bahaya" ? 0.45 : 0.25), blurRadius: statusKnn == "Bahaya" ? 24 : 12, spreadRadius: statusKnn == "Bahaya" ? 3 : 0, offset: const Offset(0, 8), ), ], ), child: Column( children: [ Text( "FORMALIN PPM", style: TextStyle( fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white.withOpacity(0.8), letterSpacing: 1.2, ), ), const SizedBox(height: 10), TweenAnimationBuilder( tween: Tween(begin: 0.0, end: displayPpm ?? 0.0), duration: const Duration(milliseconds: 600), curve: Curves.easeOutCubic, builder: (context, val, child) { return Text( (val ?? 0.0).toStringAsFixed(2), style: const TextStyle( fontSize: 48, fontWeight: FontWeight.bold, color: Colors.white, ), ); }, ), const SizedBox(height: 5), AnimatedSwitcher( duration: const Duration(milliseconds: 300), transitionBuilder: (child, animation) { return FadeTransition( opacity: animation, child: ScaleTransition(scale: animation, child: child), ); }, child: Text( statusKnn == "Bahaya" ? "BAHAYA" : (statusKnn == "Sedang" ? "SEDANG" : "AMAN"), key: ValueKey(statusKnn), style: const TextStyle( fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white, letterSpacing: 1.0, ), ), ), ], ), ), ), const SizedBox(height: 25), // 2. PILIHAN MAKANAN FadeInUp( delay: 200, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ GestureDetector( onTap: () { if (!isTesting) { setState(() { isFoodMenuExpanded = !isFoodMenuExpanded; }); } }, child: AnimatedContainer( duration: const Duration(milliseconds: 300), padding: const EdgeInsets.symmetric( horizontal: 20, vertical: 16, ), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(18), border: Border.all( color: const Color(0xFF1565C0).withOpacity(0.12), width: 1.5, ), boxShadow: [ BoxShadow( color: const Color(0xFF1565C0).withOpacity(0.06), blurRadius: 10, offset: const Offset(0, 4), ), ], ), child: Row( children: [ Container( padding: const EdgeInsets.all(10), decoration: const BoxDecoration( color: Color(0xFFE3F2FD), shape: BoxShape.circle, ), child: const Icon( Icons.restaurant_menu_rounded, color: Color(0xFF1565C0), size: 24, ), ), const SizedBox(width: 15), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( "Pilih Menu Makanan", style: TextStyle( fontSize: 16, fontWeight: FontWeight.bold, color: Color(0xFF1565C0), letterSpacing: 0.3, ), ), const SizedBox(height: 3), Text( isTesting ? "Sedang Menguji: $selectedFood" : (selectedFood == "Monitoring" ? "Mode: Monitoring (Ketuk opsi di bawah)" : "Terpilih: $selectedFood (Ketuk untuk ganti)"), style: TextStyle( fontSize: 12, fontWeight: FontWeight.w500, color: (selectedFood == "Monitoring" || isTesting) ? Colors.grey.shade600 : const Color(0xFF1565C0), ), ), ], ), ), const SizedBox(width: 5), AnimatedRotation( turns: isFoodMenuExpanded ? 0.5 : 0.0, duration: const Duration(milliseconds: 300), child: Icon( Icons.keyboard_arrow_down_rounded, color: const Color(0xFF1565C0).withOpacity(0.7), size: 26, ), ), ], ), ), ), AnimatedSize( duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, child: Container( child: isFoodMenuExpanded ? Padding( padding: const EdgeInsets.only(top: 15.0), child: Column( children: [ Row( children: [ Expanded( child: _foodButton( "Mie Ayam", Icons.ramen_dining, ), ), const SizedBox(width: 15), Expanded( child: _foodButton( "Bakso", Icons.soup_kitchen, ), ), ], ), const SizedBox(height: 15), Row( children: [ Expanded( child: _foodButton( "Air Mineral", Icons.local_drink_rounded, ), ), const SizedBox(width: 15), const Expanded( child: SizedBox.shrink(), ), ], ), ], ), ) : const SizedBox.shrink(), ), ), ], ), ), const SizedBox(height: 25), // 3. STATUS KIPAS FadeInUp( delay: 300, child: FanControlCard( isFanActive: fanStatus == 1, ), ), const SizedBox(height: 25), // 4. GRAFIK FadeInUp( delay: 400, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( "Grafik Realtime", style: TextStyle( fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white, ), ), const SizedBox(height: 10), Container( height: 200, padding: const EdgeInsets.all(15), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(15), ), child: Listener( onPointerDown: (_) { setState(() { _isInteractingWithChart = true; }); }, onPointerUp: (_) { setState(() { _isInteractingWithChart = false; }); }, onPointerCancel: (_) { setState(() { _isInteractingWithChart = false; }); }, child: InteractiveViewer( panEnabled: true, scaleEnabled: true, minScale: 0.8, maxScale: 5.0, child: LineChart( LineChartData( gridData: FlGridData( show: true, drawVerticalLine: true, horizontalInterval: 0.5, verticalInterval: 5, getDrawingHorizontalLine: (value) { return FlLine( color: Colors.blue.shade50, strokeWidth: 1, ); }, getDrawingVerticalLine: (value) { return FlLine( color: Colors.blue.shade50, strokeWidth: 1, ); }, ), titlesData: const FlTitlesData( show: true, rightTitles: AxisTitles( sideTitles: SideTitles(showTitles: false), ), topTitles: AxisTitles( sideTitles: SideTitles(showTitles: false), ), leftTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 30, ), ), bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 22, ), ), ), borderData: FlBorderData( show: true, border: Border.all(color: Colors.blue.shade100, width: 1), ), lineTouchData: LineTouchData( handleBuiltInTouches: true, touchTooltipData: LineTouchTooltipData( tooltipBgColor: const Color(0xFF1565C0).withOpacity(0.8), getTooltipItems: (List touchedBarSpots) { return touchedBarSpots.map((barSpot) { return LineTooltipItem( '${barSpot.y.toStringAsFixed(2)} PPM', const TextStyle(color: Colors.white, fontWeight: FontWeight.bold), ); }).toList(); }, ), ), lineBarsData: [ LineChartBarData( spots: realtimeData.length >= 2 ? realtimeData : [FlSpot(0, displayPpm), FlSpot(1, displayPpm)], isCurved: true, preventCurveOverShooting: true, barWidth: 4, color: isTesting ? Colors.orange : const Color(0xFF1565C0), isStrokeCapRound: true, dotData: FlDotData( show: true, getDotPainter: (spot, percent, barData, index) => FlDotCirclePainter( radius: 3, color: isTesting ? Colors.orange : const Color(0xFF1565C0), strokeWidth: 1, strokeColor: Colors.white, ), ), belowBarData: BarAreaData( show: true, gradient: LinearGradient( colors: [ (isTesting ? Colors.orange : const Color(0xFF1565C0)) .withOpacity(0.2), (isTesting ? Colors.orange : const Color(0xFF1565C0)) .withOpacity(0.0), ], begin: Alignment.topCenter, end: Alignment.bottomCenter, ), ), ), ], ), duration: const Duration(milliseconds: 350), curve: Curves.easeOutCubic, ), ), ), ), ], ), ), ], ), ), ), ), bottomNavigationBar: BottomNavigationBar( currentIndex: 0, selectedItemColor: const Color(0xFF1565C0), unselectedItemColor: Colors.grey, type: BottomNavigationBarType.fixed, onTap: (index) { if (index == 1) { Navigator.pushReplacement( context, MaterialPageRoute(builder: (context) => const RiwayatPage()), ); } else if (index == 2) { Navigator.pushReplacement( context, MaterialPageRoute(builder: (context) => const NotifikasiPage()), ); } else if (index == 3) { Navigator.pushReplacement( context, MaterialPageRoute(builder: (context) => const AkunPage()), ); } }, items: const [ BottomNavigationBarItem(icon: Icon(Icons.home), label: "Home"), BottomNavigationBarItem(icon: Icon(Icons.history), label: "History"), BottomNavigationBarItem( icon: Icon(Icons.notifications), label: "Notif", ), BottomNavigationBarItem(icon: Icon(Icons.person), label: "Akun"), ], ), ); } Widget _foodButton(String title, IconData icon) { bool isSelected = selectedFood == title; bool loading = isTesting && isSelected; return FoodSelectionCard( title: title, icon: icon, isSelected: isSelected, isLoading: loading, onTap: () { if (selectedFood == title) { batalkanPengujian(); } else { mulaiPengujian(title); } }, ); } } class FoodSelectionCard extends StatefulWidget { final String title; final IconData icon; final bool isSelected; final bool isLoading; final VoidCallback onTap; const FoodSelectionCard({ super.key, required this.title, required this.icon, required this.isSelected, required this.isLoading, required this.onTap, }); @override State createState() => _FoodSelectionCardState(); } class _FoodSelectionCardState extends State with SingleTickerProviderStateMixin { bool _isPressed = false; late AnimationController _rotationController; @override void initState() { super.initState(); _rotationController = AnimationController( duration: const Duration(seconds: 2), vsync: this, ); if (widget.isLoading) { _rotationController.repeat(); } } @override void didUpdateWidget(covariant FoodSelectionCard oldWidget) { super.didUpdateWidget(oldWidget); if (widget.isLoading && !_rotationController.isAnimating) { _rotationController.repeat(); } else if (!widget.isLoading && _rotationController.isAnimating) { _rotationController.stop(); } } @override void dispose() { _rotationController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { double scale = 1.0; if (_isPressed) { scale = 0.94; } else if (widget.isSelected) { scale = 1.04; } Gradient gradient; List shadows; Color iconColor; Color textColor; Border border; if (widget.isLoading) { gradient = const LinearGradient( colors: [Color(0xFFFF9100), Color(0xFFFF3D00)], begin: Alignment.topLeft, end: Alignment.bottomRight, ); shadows = [ BoxShadow( color: Colors.deepOrange.withOpacity(0.4), blurRadius: 15, offset: const Offset(0, 8), ), ]; iconColor = Colors.white; textColor = Colors.white; border = Border.all(color: Colors.transparent); } else if (widget.isSelected) { gradient = const LinearGradient( colors: [Color(0xFF2196F3), Color(0xFF0D47A1)], begin: Alignment.topLeft, end: Alignment.bottomRight, ); shadows = [ BoxShadow( color: const Color(0xFF0D47A1).withOpacity(0.4), blurRadius: 15, offset: const Offset(0, 8), ), ]; iconColor = Colors.white; textColor = Colors.white; border = Border.all(color: Colors.transparent); } else { gradient = const LinearGradient( colors: [Colors.white, Color(0xFFF5F7FA)], begin: Alignment.topLeft, end: Alignment.bottomRight, ); shadows = [ BoxShadow( color: Colors.black.withOpacity(0.04), blurRadius: 10, offset: const Offset(0, 4), ), ]; iconColor = const Color(0xFF1565C0); textColor = Colors.black87; border = Border.all( color: const Color(0xFF1565C0).withOpacity(0.12), width: 1.5, ); } return GestureDetector( onTapDown: (_) => setState(() => _isPressed = true), onTapUp: (_) => setState(() => _isPressed = false), onTapCancel: () => setState(() => _isPressed = false), onTap: widget.onTap, child: AnimatedScale( scale: scale, duration: const Duration(milliseconds: 150), curve: Curves.easeOutBack, child: AnimatedContainer( duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 16), decoration: BoxDecoration( gradient: gradient, borderRadius: BorderRadius.circular(20), border: border, boxShadow: shadows, ), child: Column( mainAxisSize: MainAxisSize.min, children: [ RotationTransition( turns: _rotationController, child: AnimatedContainer( duration: const Duration(milliseconds: 300), padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: widget.isLoading ? Colors.white.withOpacity(0.25) : (widget.isSelected ? Colors.white.withOpacity(0.15) : const Color(0xFFE3F2FD)), shape: BoxShape.circle, ), child: Icon(widget.icon, size: 28, color: iconColor), ), ), const SizedBox(height: 12), AnimatedDefaultTextStyle( duration: const Duration(milliseconds: 200), style: TextStyle( fontSize: 15, fontWeight: FontWeight.bold, color: textColor, letterSpacing: 0.3, ), child: Text(widget.isLoading ? "Menguji..." : widget.title), ), const SizedBox(height: 4), AnimatedDefaultTextStyle( duration: const Duration(milliseconds: 200), style: TextStyle( fontSize: 11, fontWeight: FontWeight.w500, color: widget.isLoading ? Colors.white.withOpacity(0.8) : (widget.isSelected ? Colors.white.withOpacity(0.8) : Colors.grey.shade600), ), child: Text( widget.isLoading ? "Harap Tunggu" : (widget.isSelected ? "Ketuk utk Batal" : "Uji Sekarang"), ), ), ], ), ), ), ); } } class FanControlCard extends StatefulWidget { final bool isFanActive; const FanControlCard({ super.key, required this.isFanActive, }); @override State createState() => _FanControlCardState(); } class _FanControlCardState extends State with SingleTickerProviderStateMixin { late AnimationController _spinController; @override void initState() { super.initState(); _spinController = AnimationController( duration: const Duration(seconds: 2), vsync: this, ); if (widget.isFanActive) { _spinController.repeat(); } } @override void didUpdateWidget(covariant FanControlCard oldWidget) { super.didUpdateWidget(oldWidget); if (widget.isFanActive && !_spinController.isAnimating) { _spinController.repeat(); } else if (!widget.isFanActive && _spinController.isAnimating) { _spinController.stop(); } } @override void dispose() { _spinController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { Gradient gradient; Border border; if (widget.isFanActive) { gradient = const LinearGradient( colors: [Color(0xFFE0F7FA), Color(0xFFE3F2FD)], begin: Alignment.topLeft, end: Alignment.bottomRight, ); border = Border.all( color: const Color(0xFF00E5FF).withOpacity(0.3), width: 1.5, ); } else { gradient = const LinearGradient( colors: [Colors.white, Color(0xFFF8F9FA)], begin: Alignment.topLeft, end: Alignment.bottomRight, ); border = Border.all( color: const Color(0xFF1565C0).withOpacity(0.12), width: 1.5, ); } return AnimatedContainer( duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), decoration: BoxDecoration( gradient: gradient, borderRadius: BorderRadius.circular(20), border: border, boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.04), blurRadius: 10, offset: const Offset(0, 4), ), ], ), child: Column( children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row( children: [ RotationTransition( turns: _spinController, child: Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: widget.isFanActive ? const Color(0xFF00E5FF).withOpacity(0.2) : const Color(0xFFE3F2FD), shape: BoxShape.circle, ), child: Icon( Icons.toys_outlined, color: widget.isFanActive ? const Color(0xFF00E5FF) : const Color(0xFF1565C0), size: 24, ), ), ), const SizedBox(width: 12), const Text( "Kipas Sirkulasi Otomatis", style: TextStyle( fontSize: 16, fontWeight: FontWeight.bold, color: Color(0xFF1565C0), ), ), ], ), Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( color: widget.isFanActive ? Colors.green.shade50 : Colors.grey.shade100, borderRadius: BorderRadius.circular(8), border: Border.all( color: widget.isFanActive ? Colors.green.shade300 : Colors.grey.shade300, ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Container( width: 8, height: 8, decoration: BoxDecoration( color: widget.isFanActive ? Colors.green : Colors.grey, shape: BoxShape.circle, ), ), const SizedBox(width: 6), Text( widget.isFanActive ? "AKTIF" : "MATI", style: TextStyle( fontSize: 10, fontWeight: FontWeight.bold, color: widget.isFanActive ? Colors.green.shade700 : Colors.grey.shade700, ), ), ], ), ), ], ), const Divider(height: 24, thickness: 1), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( widget.isFanActive ? "Kipas Sedang Menyala" : "Kipas Sedang Mati", style: const TextStyle( fontSize: 14, fontWeight: FontWeight.bold, color: Colors.black87, ), ), const SizedBox(height: 4), Text( widget.isFanActive ? "Terdeteksi formalin, kipas aktif secara otomatis untuk menetralisir udara sekitar." : "Sensor dalam kondisi aman. Kipas akan otomatis menyala jika kadar formalin meningkat.", style: TextStyle( fontSize: 11, color: Colors.grey.shade600, ), ), ], ), ), ], ), ], ), ); } }