TKK_E32230502/lib/screen/dashboard.dart

1233 lines
43 KiB
Dart

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<DashboardPage> createState() => _DashboardPageState();
}
class _DashboardPageState extends State<DashboardPage> {
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<FlSpot> 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<double>(
tween: Tween<double>(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<String>(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<LineBarSpot> 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<FoodSelectionCard> createState() => _FoodSelectionCardState();
}
class _FoodSelectionCardState extends State<FoodSelectionCard>
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<BoxShadow> 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<FanControlCard> createState() => _FanControlCardState();
}
class _FanControlCardState extends State<FanControlCard>
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,
),
),
],
),
),
],
),
],
),
);
}
}