1233 lines
43 KiB
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|