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 createState() => _HomePageState(); } class _HomePageState extends State 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.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 sendAuto(String value) async { await dbRef.update({"auto": value}); } Future sendExhaust(String value) async { await dbRef.update({"exhaust_manual": value}); } Future 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; }