TKK_E32231533/lib/page/home_page.dart

1204 lines
39 KiB
Dart

import 'dart:math';
import 'package:flutter/material.dart';
import 'package:firebase_database/firebase_database.dart';
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
final dbRef = FirebaseDatabase.instance.ref("pengering");
double temperature = 0;
double humidity = 0;
String fanIn = "OFF";
String fanOut = "OFF";
String heater = "OFF";
bool stepperLeft = false;
bool stepperRight = false;
String mode = "-";
bool exhaustManual = false;
// Controller animasi untuk putaran kipas 3D
late final AnimationController _fanInController;
late final AnimationController _fanOutController;
@override
void initState() {
super.initState();
_fanInController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
);
_fanOutController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 650),
);
dbRef.onValue.listen((event) {
final data = event.snapshot.value;
if (data != null) {
try {
final map = Map<String, dynamic>.from(data as Map);
setState(() {
if (map["suhu"] != null) {
temperature = (map["suhu"] as num).toDouble();
}
if (map["kelembapan"] != null) {
humidity = (map["kelembapan"] as num).toDouble();
}
fanIn = map["fan_in"]?.toString() ?? "OFF";
fanOut = map["fan_out"]?.toString() ?? "OFF";
heater = map["heater"]?.toString() ?? "OFF";
mode = map["status"]?.toString() ?? "-";
// ===== Sinkron tombol Exhaust =====
exhaustManual = map["exhaust_manual"] == "ON";
// ===== Sinkron tombol Stepper =====
stepperLeft = map["stepper"] == "LEFT";
stepperRight = map["stepper"] == "RIGHT";
});
// ===== Sinkron animasi kipas 3D dengan status ON/OFF =====
if (isOn(fanIn)) {
if (!_fanInController.isAnimating) _fanInController.repeat();
} else {
_fanInController.stop();
}
if (isOn(fanOut)) {
if (!_fanOutController.isAnimating) _fanOutController.repeat();
} else {
_fanOutController.stop();
}
} catch (e) {
debugPrint("ERROR PARSE: $e");
}
}
});
}
@override
void dispose() {
_fanInController.dispose();
_fanOutController.dispose();
super.dispose();
}
bool isOn(String value) => value == "ON";
/// =========================
/// CONTROL BUTTON
/// =========================
Future<void> sendAuto(String value) async {
await dbRef.update({"auto": value});
}
Future<void> sendExhaust(String value) async {
await dbRef.update({"exhaust_manual": value});
}
Future<void> sendStepper(String value) async {
await dbRef.update({"stepper": value});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF6F1EE),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
children: [
/// HEADER
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF6F4E37), Color(0xFF8D6E63)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(30),
boxShadow: [
BoxShadow(
color: Colors.brown.withOpacity(0.2),
blurRadius: 15,
offset: const Offset(0, 8),
),
],
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
shape: BoxShape.circle,
),
child: const Icon(
Icons.coffee,
color: Colors.white,
size: 30,
),
),
const SizedBox(width: 15),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Coffee Dryer",
style: TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 5),
Text(
"Monitoring Pengering Kopi",
style: TextStyle(
color: Colors.white70,
fontSize: 14,
),
),
],
),
),
],
),
),
const SizedBox(height: 25),
/// STATUS MODE
Container(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 15,
),
decoration: BoxDecoration(
color: mode == "AUTO" ? Colors.green : Colors.orange,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.autorenew, color: Colors.white),
const SizedBox(width: 10),
Text(
"MODE : $mode",
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
letterSpacing: 1,
),
),
],
),
),
const SizedBox(height: 25),
/// SENSOR CARD
Row(
children: [
Expanded(
child: sensorCard(
title: "Suhu",
value: "${temperature.toStringAsFixed(1)}°C",
icon: Icons.thermostat,
color: Colors.redAccent,
),
),
const SizedBox(width: 15),
Expanded(
child: sensorCard(
title: "Kelembapan",
value: "${humidity.toStringAsFixed(1)}%",
icon: Icons.water_drop,
color: Colors.blue,
),
),
],
),
const SizedBox(height: 30),
/// CONTROL BUTTON
Align(
alignment: Alignment.centerLeft,
child: Text(
"Kontrol Mesin",
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.brown.shade800,
),
),
),
const SizedBox(height: 15),
Row(
children: [
Expanded(
child: controlButton(
title: "Mulai",
icon: Icons.play_arrow,
color: Colors.green,
onTap: () => sendAuto("START"),
),
),
const SizedBox(width: 12),
Expanded(
child: controlButton(
title: "Berhenti",
icon: Icons.stop,
color: Colors.red,
onTap: () => sendAuto("STOP"),
),
),
const SizedBox(width: 12),
Expanded(
child: controlButton(
title: exhaustManual ? "Exhaust ON" : "Exhaust OFF",
icon: Icons.wind_power,
color: exhaustManual ? Colors.green : Colors.blueGrey,
onTap: () async {
// Tidak boleh dimatikan saat suhu masih tinggi
if (temperature > 55 && exhaustManual) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
"Exhaust tidak bisa dimatikan saat suhu di atas 55°C",
),
),
);
return;
}
if (exhaustManual) {
await sendExhaust("OFF");
} else {
await sendExhaust("ON");
}
},
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: controlButton(
title: stepperLeft ? "Mundur ON" : "Mundur OFF",
icon: Icons.rotate_left,
color: stepperLeft ? Colors.green : Colors.grey,
onTap: () async {
if (mode != "STOP") {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
"Stepper hanya bisa dijalankan setelah mesin berhenti",
),
),
);
return;
}
stepperLeft = !stepperLeft;
if (stepperLeft) {
stepperRight = false;
await sendStepper("LEFT");
} else {
await sendStepper("STOP");
}
setState(() {});
},
),
),
const SizedBox(width: 12),
Expanded(
child: controlButton(
title: stepperRight ? "Maju ON" : "Maju OFF",
icon: Icons.rotate_right,
color: stepperRight ? Colors.green : Colors.grey,
onTap: () async {
if (mode != "STOP") {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
"Stepper hanya bisa dijalankan setelah mesin berhenti",
),
),
);
return;
}
stepperRight = !stepperRight;
if (stepperRight) {
stepperLeft = false;
await sendStepper("RIGHT");
} else {
await sendStepper("STOP");
}
setState(() {});
},
),
),
],
),
const SizedBox(height: 12),
/// PANDUAN ISTILAH - penjelasan awam soal tombol
/// Maju & Mundur, gaya sama dengan kartu
/// Heater/Intake/Exhaust di bawah
Align(
alignment: Alignment.centerLeft,
child: Text(
"Keterangan Tombol Maju & Mundur",
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.brown.shade800,
),
),
),
const SizedBox(height: 15),
stepperGuideCard(),
const SizedBox(height: 30),
/// INFORMASI ALAT - VISUALISASI 3D
Align(
alignment: Alignment.centerLeft,
child: Text(
"Informasi Alat",
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.brown.shade800,
),
),
),
const SizedBox(height: 15),
machineVisualization(),
const SizedBox(height: 20),
/// PANDUAN ISTILAH - penjelasan awam soal
/// Heater, Fan Intake, dan Fan Exhaust
Align(
alignment: Alignment.centerLeft,
child: Text(
"Apa Arti Status Ini?",
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.brown.shade800,
),
),
),
const SizedBox(height: 15),
termGuideCard(),
const SizedBox(height: 20),
],
),
),
),
);
}
/// SENSOR CARD
Widget sensorCard({
required String title,
required String value,
required IconData icon,
required Color color,
}) {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(25),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 15,
offset: const Offset(0, 8),
),
],
),
child: Column(
children: [
Container(
padding: const EdgeInsets.all(15),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(icon, size: 35, color: color),
),
const SizedBox(height: 15),
Text(
title,
style: TextStyle(color: Colors.grey.shade700, fontSize: 16),
),
const SizedBox(height: 10),
Text(
value,
style: TextStyle(
color: color,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
],
),
);
}
/// CONTROL BUTTON
Widget controlButton({
required String title,
required IconData icon,
required Color color,
required VoidCallback onTap,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 18),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: color.withOpacity(0.3),
blurRadius: 10,
offset: const Offset(0, 5),
),
],
),
child: Column(
children: [
Icon(icon, color: Colors.white, size: 30),
const SizedBox(height: 8),
Text(
title,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
],
),
),
);
}
/// =========================================================
/// VISUALISASI MESIN PENGERING
///
/// Badan mesin digambar sebagai KUBUS/KOTAK, dibuat dari 2 layer
/// Container yang saling offset (bukan proyeksi isometrik trig)
/// supaya kesan "3D"-nya tetap ada tapi bentuknya tidak gampang
/// berantakan di berbagai ukuran layar.
///
/// - Kipas EXHAUST kiri & kanan menempel PERSIS di sisi kiri/kanan
/// kubus lewat bracket kecil.
/// - Kipas INTAKE disambungkan ke bagian ATAS kubus lewat duct -
/// mewakili posisi "belakang" (karena tampilan depan datar tidak
/// bisa menunjukkan sisi belakang secara literal).
/// - HEATER digambar sebagai bola cahaya + ikon api PERSIS DI
/// TENGAH kubus, menyala saat status ON.
/// =========================================================
Widget machineVisualization() {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(25),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 15,
offset: const Offset(0, 8),
),
],
),
child: Column(
children: [
Center(
child: SizedBox(
width: 300,
height: 300,
child: Stack(
clipBehavior: Clip.none,
children: [
// ===== Duct Intake: dari kubus ke kipas intake =====
Positioned(
left: 143,
top: 69,
child: Container(
width: 14,
height: 50,
decoration: BoxDecoration(
color: isOn(fanIn)
? Colors.blue.shade200
: Colors.grey.shade300,
borderRadius: BorderRadius.circular(6),
),
),
),
// ===== Lapisan belakang kubus (kesan kedalaman 3D) =====
Positioned(
left: 80,
top: 105,
child: Container(
width: 160,
height: 140,
decoration: BoxDecoration(
color: isOn(heater)
? Colors.deepOrange.shade700
: Colors.brown.shade600,
borderRadius: BorderRadius.circular(22),
),
),
),
// ===== KUBUS PENGERING (badan mesin, layer depan) =====
Positioned(
left: 70,
top: 115,
child: Container(
width: 160,
height: 140,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(22),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isOn(heater)
? [
Colors.orange.shade100,
Colors.deepOrange.shade200,
]
: [Colors.brown.shade100, Colors.brown.shade300],
),
border: Border.all(
color: Colors.black.withOpacity(0.08),
),
),
),
),
// ===== Kilau tipis di sisi atas kubus =====
Positioned(
left: 82,
top: 123,
child: Container(
width: 136,
height: 10,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.3),
borderRadius: BorderRadius.circular(6),
),
),
),
// ===== Bracket KIRI (dudukan kipas exhaust) =====
Positioned(
left: 62,
top: 135,
child: Container(
width: 16,
height: 100,
decoration: BoxDecoration(
color: isOn(heater)
? Colors.deepOrange.shade400
: Colors.brown.shade500,
borderRadius: BorderRadius.circular(6),
),
),
),
// ===== Bracket KANAN (dudukan kipas exhaust) =====
Positioned(
left: 222,
top: 135,
child: Container(
width: 16,
height: 100,
decoration: BoxDecoration(
color: isOn(heater)
? Colors.deepOrange.shade400
: Colors.brown.shade500,
borderRadius: BorderRadius.circular(6),
),
),
),
// ===== HEATER - persis di tengah kubus =====
Positioned(
left: 112,
top: 147,
child: Container(
width: 76,
height: 76,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: isOn(heater)
? [Colors.orange.shade300, Colors.red.shade600]
: [Colors.grey.shade300, Colors.grey.shade400],
),
boxShadow: isOn(heater)
? [
BoxShadow(
color: Colors.red.withOpacity(0.5),
blurRadius: 20,
spreadRadius: 4,
),
]
: [],
),
child: const Icon(
Icons.local_fire_department,
color: Colors.white,
size: 34,
),
),
),
// ===== Kipas EXHAUST KIRI (nempel di bracket kiri) =====
Positioned(
left: 44,
top: 159,
child: fanCircle(
isOnStatus: isOn(fanOut),
controller: _fanOutController,
color: Colors.green,
size: 52,
),
),
Positioned(
left: 22,
top: 215,
child: SizedBox(
width: 96,
child: Text(
"Exhaust",
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Colors.grey.shade600,
),
),
),
),
// ===== Kipas EXHAUST KANAN (nempel di bracket kanan) =====
Positioned(
left: 204,
top: 159,
child: fanCircle(
isOnStatus: isOn(fanOut),
controller: _fanOutController,
color: Colors.green,
size: 52,
),
),
Positioned(
left: 182,
top: 215,
child: SizedBox(
width: 96,
child: Text(
"Exhaust",
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Colors.grey.shade600,
),
),
),
),
// ===== Kipas INTAKE (belakang, nyambung ke duct atas) =====
Positioned(
left: 127,
top: 23,
child: fanCircle(
isOnStatus: isOn(fanIn),
controller: _fanInController,
color: Colors.blue,
size: 46,
),
),
Positioned(
left: 90,
top: 5,
child: SizedBox(
width: 120,
child: Text(
"Intake (Belakang)",
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Colors.grey.shade600,
),
),
),
),
],
),
),
),
const SizedBox(height: 12),
// ===== Ringkasan status ON/OFF =====
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
statusChip("Heater", isOn(heater), Colors.red),
statusChip("Fan In", isOn(fanIn), Colors.blue),
statusChip("Fan Out", isOn(fanOut), Colors.green),
],
),
],
),
);
}
/// =========================================================
/// KARTU PANDUAN TOMBOL MAJU & MUNDUR
///
/// Gaya kartu & tile-nya sengaja dibuat identik dengan
/// termGuideCard() (Heater/Intake/Exhaust) di bawah, supaya
/// konsisten secara visual di seluruh halaman - pakai ulang
/// termGuideTile() yang sudah ada.
/// =========================================================
Widget stepperGuideCard() {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(25),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 15,
offset: const Offset(0, 8),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
termGuideTile(
icon: Icons.rotate_right,
color: Colors.green,
title: "Tombol Maju",
description:
"Ketika ditekan hingga muncul ON, tabung akan bergerak ke depan. "
"Tekan lagi tombolnya agar perputaran berhenti.",
onMeaning: "Tabung sedang bergerak ke depan.",
offMeaning: "Perputaran berhenti.",
onColor: Colors.green,
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 14),
child: Divider(height: 1),
),
termGuideTile(
icon: Icons.rotate_left,
color: Colors.green,
title: "Tombol Mundur",
description:
"Ketika ditekan hingga muncul ON, tabung akan bergerak ke belakang. "
"Tekan lagi tombolnya agar perputaran berhenti.",
onMeaning: "Tabung sedang bergerak ke belakang.",
offMeaning: "Perputaran berhenti.",
onColor: Colors.green,
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 14),
child: Divider(height: 1),
),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.orange.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(
Icons.warning_amber_rounded,
color: Colors.orange.shade800,
size: 22,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Perhatian",
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: Colors.orange.shade800,
),
),
const SizedBox(height: 4),
Text(
"Tombol ini hanya bisa ditekan ketika alat sedang diam "
"atau tidak sedang melakukan pengeringan.",
style: TextStyle(
fontSize: 12.5,
color: Colors.grey.shade700,
),
),
],
),
),
],
),
],
),
);
}
/// =========================================================
/// KARTU PANDUAN ISTILAH
///
/// Menjelaskan dengan bahasa awam apa itu Heater, Fan Intake, dan
/// Fan Exhaust, serta apa artinya ketika masing-masing ON / OFF.
/// Diletakkan sebagai kartu statis (bukan dialog) supaya pengguna
/// baru langsung bisa membaca tanpa perlu tap apa pun.
/// =========================================================
Widget termGuideCard() {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(25),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 15,
offset: const Offset(0, 8),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
termGuideTile(
icon: Icons.local_fire_department,
color: Colors.red,
title: "Heater (Pemanas)",
description:
"Elemen pemanas yang menghasilkan panas untuk mengeringkan biji kopi di dalam mesin.",
onMeaning: "Sedang memanaskan udara di dalam mesin.",
offMeaning: "Mati, suhu di dalam mesin akan turun perlahan.",
onColor: Colors.red,
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 14),
child: Divider(height: 1),
),
termGuideTile(
icon: Icons.air,
color: Colors.blue,
title: "Fan Intake (Kipas Masuk)",
description:
"Kipas yang mengalirkan udara panas dari heater masuk ke ruang pengering, tempat biji kopi berada.",
onMeaning: "Sedang mengalirkan udara panas masuk ke mesin.",
offMeaning: "Berhenti, tidak ada udara panas baru yang masuk.",
onColor: Colors.blue,
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 14),
child: Divider(height: 1),
),
termGuideTile(
icon: Icons.wind_power,
color: Colors.green,
title: "Fan Exhaust (Kipas Buang)",
description:
"Kipas yang membuang udara lembap sisa proses pengeringan ke luar mesin.",
onMeaning: "Sedang membuang udara lembap keluar mesin.",
offMeaning: "Berhenti, udara lembap tidak dikeluarkan.",
onColor: Colors.green,
),
],
),
);
}
/// Satu baris penjelasan istilah: ikon + judul + deskripsi singkat,
/// lalu dua baris kecil "Saat ON" dan "Saat OFF" biar orang awam
/// langsung paham konsekuensi status yang mereka lihat di layar.
Widget termGuideTile({
required IconData icon,
required Color color,
required String title,
required String description,
required String onMeaning,
required String offMeaning,
required Color onColor,
}) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(icon, color: color, size: 22),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: Colors.brown.shade800,
),
),
const SizedBox(height: 4),
Text(
description,
style: TextStyle(fontSize: 12.5, color: Colors.grey.shade700),
),
const SizedBox(height: 8),
Row(
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: onColor,
),
),
const SizedBox(width: 6),
Expanded(
child: Text(
"ON : $onMeaning",
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: onColor,
),
),
),
],
),
const SizedBox(height: 4),
Row(
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.grey.shade400,
),
),
const SizedBox(width: 6),
Expanded(
child: Text(
"OFF : $offMeaning",
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Colors.grey.shade600,
),
),
),
],
),
],
),
),
],
);
}
/// Housing kipas bulat + baling-baling berputar (RotationTransition).
/// Murni lingkaran (tanpa label di dalamnya) supaya bisa dipasang
/// presisi di titik mana pun lewat Positioned dari luar.
Widget fanCircle({
required bool isOnStatus,
required AnimationController controller,
required Color color,
double size = 54,
}) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white,
border: Border.all(
color: isOnStatus ? color : Colors.grey.shade400,
width: 3,
),
boxShadow: isOnStatus
? [
BoxShadow(
color: color.withOpacity(0.45),
blurRadius: 12,
spreadRadius: 1,
),
]
: [],
),
padding: EdgeInsets.all(size * 0.14),
child: RotationTransition(
turns: controller,
child: CustomPaint(
painter: _FanBladesPainter(
color: isOnStatus ? color : Colors.grey.shade400,
),
),
),
);
}
/// Chip status ON/OFF ringkas
Widget statusChip(String label, bool status, Color color) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: status ? color.withOpacity(0.15) : Colors.grey.withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: status ? color : Colors.grey,
),
),
const SizedBox(width: 6),
Text(
"$label ${status ? 'ON' : 'OFF'}",
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: status ? color : Colors.grey.shade600,
),
),
],
),
);
}
}
/// =========================================================
/// PAINTER: baling-baling kipas (4 blade), diputar via
/// RotationTransition dari luar.
/// =========================================================
class _FanBladesPainter extends CustomPainter {
final Color color;
_FanBladesPainter({required this.color});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final r = size.width / 2;
final paint = Paint()..color = color;
for (int i = 0; i < 4; i++) {
final angle = (pi / 2) * i;
canvas.save();
canvas.translate(center.dx, center.dy);
canvas.rotate(angle);
final blade = Path()
..moveTo(0, 0)
..quadraticBezierTo(r * 0.6, -r * 0.35, r * 0.9, 0)
..quadraticBezierTo(r * 0.6, r * 0.35, 0, 0)
..close();
canvas.drawPath(blade, paint);
canvas.restore();
}
canvas.drawCircle(center, r * 0.18, Paint()..color = Colors.white);
canvas.drawCircle(
center,
r * 0.18,
Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 1.5,
);
}
@override
bool shouldRepaint(covariant _FanBladesPainter oldDelegate) =>
oldDelegate.color != color;
}