From ce118a806b1296fc7cd6f4b1d6fd4b41c6f92cfe Mon Sep 17 00:00:00 2001 From: bimabuana Date: Mon, 6 Jul 2026 15:51:30 +0700 Subject: [PATCH] feat: implementasikan visualisasi isometrik 2.5D, heatmap sensor, range slider suhu, debit air, dan panduan budidaya --- lib/features/iot/page/control_page.dart | 7 +- lib/features/iot/page/home_page.dart | 91 ++- lib/features/iot/page/kumbung_map_page.dart | 586 ++++++++++++++++++ lib/features/iot/page/main_page.dart | 7 + .../iot/providers/control_notifier.dart | 21 +- lib/features/iot/providers/control_state.dart | 20 +- .../iot/providers/monitoring_provider.dart | 14 +- .../iot/widgets/device_control_section.dart | 11 +- .../widgets/isometric_kumbung_painter.dart | 407 ++++++++++++ .../iot/widgets/isometric_transform.dart | 53 ++ lib/features/iot/widgets/monitoring_card.dart | 13 +- .../iot/widgets/sensor_limit_card.dart | 130 +++- lib/models/sensor_model.dart | 16 +- lib/services/api_control_service.dart | 5 +- 14 files changed, 1326 insertions(+), 55 deletions(-) create mode 100644 lib/features/iot/page/kumbung_map_page.dart create mode 100644 lib/features/iot/widgets/isometric_kumbung_painter.dart create mode 100644 lib/features/iot/widgets/isometric_transform.dart diff --git a/lib/features/iot/page/control_page.dart b/lib/features/iot/page/control_page.dart index 50f0ca9..bf9e3f0 100644 --- a/lib/features/iot/page/control_page.dart +++ b/lib/features/iot/page/control_page.dart @@ -275,7 +275,8 @@ class ControlPage extends ConsumerWidget { DeviceControlSection( isPumpOn: control.isPumpOn, isOnline: isOnline, - suhuLimit: control.suhuLimit, + suhuMinLimit: control.suhuMinLimit, + suhuMaxLimit: control.suhuMaxLimit, kelembapanLimit: control.kelembapanLimit, activePumpDuration: control.activePumpDuration, pumpTurnedOnAt: control.pumpTurnedOnAt, @@ -286,8 +287,8 @@ class ControlPage extends ConsumerWidget { _showManualDurationDialog(context, ref); } }, - onSaveThreshold: (suhu, kelembapan) => - notifier.saveThreshold(suhu, kelembapan), + onSaveThreshold: (suhuMin, suhuMax, kelembapan) => + notifier.saveThreshold(suhuMin, suhuMax, kelembapan), ), const SizedBox(height: 20), diff --git a/lib/features/iot/page/home_page.dart b/lib/features/iot/page/home_page.dart index 2e0713c..e7083fe 100644 --- a/lib/features/iot/page/home_page.dart +++ b/lib/features/iot/page/home_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/app_theme.dart'; +import '../../../models/monitoring_status.dart'; import '../../auth/auth_notifier.dart'; import '../../auth/auth_state.dart'; import '../providers/monitoring_provider.dart'; @@ -185,7 +186,7 @@ class MonitoringPage extends ConsumerWidget { // ── Belum ada data sensor ───────────────────── _NoDataBanner(), ] else ...[ - // ── Sensor cards ────────────────────────────── + // ── Sensor cards Row 1 ──────────────────────── Row( children: [ Expanded( @@ -193,7 +194,7 @@ class MonitoringPage extends ConsumerWidget { value: monitoring.temperature.toStringAsFixed(1), unit: '°C', subtitle: 'Suhu Real Time', - status: monitoring.tempStatus(control.suhuLimit), + status: monitoring.tempStatus(control.suhuMinLimit, control.suhuMaxLimit), gradient: AppTheme.suhuGradient, ), ), @@ -210,6 +211,92 @@ class MonitoringPage extends ConsumerWidget { ], ), + const SizedBox(height: 16), + + // ── Sensor cards Row 2 (Debit & Volume Air) ─── + Row( + children: [ + Expanded( + child: MonitoringCard( + value: (monitoring.latest?.waterFlow ?? 0.0).toStringAsFixed(1), + unit: ' L/m', + subtitle: 'Debit Aliran Air', + status: MonitoringStatus.normal, + gradient: const LinearGradient( + colors: [Color(0xFF0288D1), Color(0xFF005691)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: MonitoringCard( + value: (monitoring.latest?.waterVolume ?? 0.0).toStringAsFixed(1), + unit: ' L', + subtitle: 'Total Volume Air', + status: MonitoringStatus.normal, + gradient: const LinearGradient( + colors: [Color(0xFF00B0FF), Color(0xFF0091EA)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + ), + ], + ), + + const SizedBox(height: 16), + + // ── Info Waktu Penyiraman Terbaik & Rentang Aman ── + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.grey.shade200), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.02), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.info_outline, color: AppTheme.primary, size: 20), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Info Rekomendasi Budidaya', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: AppTheme.textPrimary, + ), + ), + const SizedBox(height: 4), + Text( + 'Batas Suhu Aman: ${control.suhuMinLimit.round()}°C s.d ${control.suhuMaxLimit.round()}°C.\n' + 'Waktu penyiraman terbaik untuk menjaga kelembapan kumbung adalah Pagi hari (06:00-08:00) dan Sore hari (16:00-18:00).', + style: const TextStyle( + fontSize: 11, + color: AppTheme.textSecondary, + height: 1.4, + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 30), // ── Chart filter ────────────────────────────── diff --git a/lib/features/iot/page/kumbung_map_page.dart b/lib/features/iot/page/kumbung_map_page.dart new file mode 100644 index 0000000..9617c1e --- /dev/null +++ b/lib/features/iot/page/kumbung_map_page.dart @@ -0,0 +1,586 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../../core/app_theme.dart'; +import '../providers/monitoring_provider.dart'; +import '../widgets/isometric_transform.dart'; +import '../widgets/isometric_kumbung_painter.dart'; + +class KumbungMapPage extends ConsumerStatefulWidget { + const KumbungMapPage({super.key}); + + @override + ConsumerState createState() => _KumbungMapPageState(); +} + +class _KumbungMapPageState extends ConsumerState + with SingleTickerProviderStateMixin { + late AnimationController _animationController; + + // Koordinat Sensor (X, Y, Z) dalam meter, dan Radius Jangkauan (meter) + double _sensorX = 4.0; + double _sensorY = 3.0; + final double _sensorZ = 1.2; // Tinggi melayang sensor di rak + double _sensorRadius = 3.0; + + // Mode Heatmap: true = Suhu, false = Kelembapan + bool _isSuhuMode = true; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + vsync: this, + duration: const Duration(seconds: 2), + )..repeat(); + + _loadSensorSettings(); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + // Memuat setelan koordinat lokal dari SharedPreferences + Future _loadSensorSettings() async { + final prefs = await SharedPreferences.getInstance(); + setState(() { + _sensorX = prefs.getDouble('sensor_x') ?? 4.0; + _sensorY = prefs.getDouble('sensor_y') ?? 3.0; + _sensorRadius = prefs.getDouble('sensor_radius') ?? 3.0; + }); + } + + // Menyimpan setelan koordinat lokal ke SharedPreferences + Future _saveSensorSettings() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setDouble('sensor_x', _sensorX); + await prefs.setDouble('sensor_y', _sensorY); + await prefs.setDouble('sensor_radius', _sensorRadius); + } + + // Mengubah posisi sensor saat canvas diketuk oleh pengguna + void _handleCanvasTap(Offset localPosition, Size canvasSize, IsometricTransform transformer) { + final worldPt = transformer.toWorldGround(localPosition); + + // Batasi agar koordinat berada di dalam rentang ruangan (8m x 6m) + final double clampedX = worldPt.dx.clamp(0.0, 8.0); + final double clampedY = worldPt.dy.clamp(0.0, 6.0); + + setState(() { + _sensorX = clampedX; + _sensorY = clampedY; + }); + _saveSensorSettings(); + } + + @override + Widget build(BuildContext context) { + final monitoring = ref.watch(monitoringProvider); + final hasDevice = monitoring.latest != null; + + // Nilai telemetri sensor real-time + final double tempVal = monitoring.temperature; + final double humidVal = monitoring.humidity; + + // Tentukan warna status untuk sensor berdasarkan nilainya + final Color pinColor = _isSuhuMode + ? (tempVal > 30 ? Colors.redAccent : Colors.teal) + : (humidVal < 75 ? Colors.orangeAccent : Colors.cyan); + + return Scaffold( + backgroundColor: AppTheme.bgPage, + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Header Section ───────────────────────────────── + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Peta Kumbung', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: AppTheme.textPrimary, + ), + ), + Text( + 'Denah & Cakupan Jangkauan Sensor', + style: TextStyle( + fontSize: 13, + color: AppTheme.textSecondary, + ), + ), + ], + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: hasDevice ? Colors.green.shade50 : Colors.grey.shade100, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + CircleAvatar( + radius: 4, + backgroundColor: hasDevice ? Colors.green : Colors.grey, + ), + const SizedBox(width: 6), + Text( + hasDevice ? 'Sensor Aktif' : 'Sensor Offline', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: hasDevice ? Colors.green.shade700 : Colors.grey.shade700, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + + // ── Visualizer Canvas 3D ─────────────────────────── + Container( + width: double.infinity, + height: 280, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.03), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(24), + child: Stack( + children: [ + // Canvas Utama Rendering Isometrik + LayoutBuilder( + builder: (context, constraints) { + // Menghitung origin tengah bawah untuk visualisasi isometrik + final centerOrigin = Offset( + constraints.maxWidth / 2, + constraints.maxHeight / 2 + 30, + ); + // Skala dinamis: 1 meter = 24 piksel + final transformer = IsometricTransform( + scale: 24.0, + origin: centerOrigin, + ); + + return GestureDetector( + onTapUp: (details) => _handleCanvasTap( + details.localPosition, + Size(constraints.maxWidth, constraints.maxHeight), + transformer, + ), + child: AnimatedBuilder( + animation: _animationController, + builder: (context, child) { + return CustomPaint( + size: Size(constraints.maxWidth, constraints.maxHeight), + painter: StaticKumbungPainter( + transformer: transformer, + roomWidth: 6.0, + roomLength: 8.0, + roomHeight: 2.5, + ), + foregroundPainter: PinKumbungPainter( + transformer: transformer, + sensorPos: Offset(_sensorX, _sensorY), + sensorHeight: _sensorZ, + value: _isSuhuMode ? tempVal : humidVal, + unit: _isSuhuMode ? '°C' : '%', + animationValue: _animationController.value, + pinColor: pinColor, + ), + child: CustomPaint( + size: Size(constraints.maxWidth, constraints.maxHeight), + painter: HeatmapKumbungPainter( + transformer: transformer, + sensorPos: Offset(_sensorX, _sensorY), + radius: _sensorRadius, + value: _isSuhuMode ? tempVal : humidVal, + isSuhuMode: _isSuhuMode, + ), + ), + ); + }, + ), + ); + }, + ), + + // Toggle Switch Peta Suhu / Kelembapan + Positioned( + top: 12, + right: 12, + child: Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(16), + ), + child: Row( + children: [ + _buildToggleButton( + label: 'Suhu', + isActive: _isSuhuMode, + color: Colors.teal, + onTap: () => setState(() => _isSuhuMode = true), + ), + _buildToggleButton( + label: 'Kelembapan', + isActive: !_isSuhuMode, + color: Colors.cyan, + onTap: () => setState(() => _isSuhuMode = false), + ), + ], + ), + ), + ), + + // Instruksi Interaksi + const Positioned( + bottom: 12, + left: 12, + child: Row( + children: [ + Icon(Icons.touch_app, size: 14, color: AppTheme.textSecondary), + SizedBox(width: 4), + Text( + 'Ketuk lantai grid untuk memindah sensor', + style: TextStyle( + fontSize: 10, + color: AppTheme.textSecondary, + ), + ), + ], + ), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + + // ── Control Settings Panel ───────────────────────── + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.02), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.settings_input_hdmi, color: AppTheme.primary, size: 18), + const SizedBox(width: 8), + Text( + 'Konfigurasi Sensor Terpasang', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Colors.grey.shade800, + ), + ), + ], + ), + const SizedBox(height: 12), + _buildSlider( + label: 'Posisi X (Panjang)', + value: _sensorX, + min: 0.5, + max: 7.5, + displayValue: '${_sensorX.toStringAsFixed(1)}m', + onChanged: (v) { + setState(() => _sensorX = v); + _saveSensorSettings(); + }, + ), + _buildSlider( + label: 'Posisi Y (Lebar)', + value: _sensorY, + min: 0.5, + max: 5.5, + displayValue: '${_sensorY.toStringAsFixed(1)}m', + onChanged: (v) { + setState(() => _sensorY = v); + _saveSensorSettings(); + }, + ), + _buildSlider( + label: 'Radius Jangkauan', + value: _sensorRadius, + min: 1.0, + max: 5.0, + displayValue: '${_sensorRadius.toStringAsFixed(1)}m', + onChanged: (v) { + setState(() => _sensorRadius = v); + _saveSensorSettings(); + }, + ), + ], + ), + ), + const SizedBox(height: 16), + + // ── Dashboard Referensi Budidaya ─────────────────── + _buildReferenceCard(), + ], + ), + ), + ), + ); + } + + // Widget Button Toggle + Widget _buildToggleButton({ + required String label, + required bool isActive, + required Color color, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: isActive ? color : Colors.transparent, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + label, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: isActive ? Colors.white : AppTheme.textSecondary, + ), + ), + ), + ); + } + + // Widget Row Slider Pengaturan + Widget _buildSlider({ + required String label, + required double value, + required double min, + required double max, + required String displayValue, + required ValueChanged onChanged, + }) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Expanded( + flex: 3, + child: Text( + label, + style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary), + ), + ), + Expanded( + flex: 6, + child: SliderTheme( + data: SliderTheme.of(context).copyWith( + activeTrackColor: AppTheme.primary, + inactiveTrackColor: Colors.grey.shade200, + thumbColor: AppTheme.primary, + trackHeight: 3, + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6), + ), + child: Slider( + value: value, + min: min, + max: max, + onChanged: onChanged, + ), + ), + ), + Expanded( + flex: 2, + child: Text( + displayValue, + textAlign: TextAlign.right, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: AppTheme.textPrimary, + ), + ), + ), + ], + ), + ); + } + + // Widget Kartu Referensi Budidaya Jamur + Widget _buildReferenceCard() { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.02), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: AppTheme.primary.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: const Icon(Icons.menu_book, color: AppTheme.primary, size: 16), + ), + const SizedBox(width: 10), + const Text( + 'Panduan Referensi Budidaya', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: AppTheme.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 12), + const Text( + 'Untuk budidaya Jamur Tiram yang optimal, berikut adalah parameter referensi kondisi kumbung:', + style: TextStyle(fontSize: 12, color: AppTheme.textSecondary, height: 1.4), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _buildRefIndicator( + icon: Icons.thermostat, + iconColor: Colors.teal, + label: 'Suhu Ideal', + value: '22°C - 28°C', + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildRefIndicator( + icon: Icons.water_drop, + iconColor: Colors.cyan, + label: 'Kelembapan Ideal', + value: '80% - 90%', + ), + ), + ], + ), + const SizedBox(height: 12), + Divider(color: Colors.grey.shade100), + const SizedBox(height: 8), + const Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.wb_sunny_outlined, size: 16, color: Colors.orange), + SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Waktu Penyiraman Terbaik:', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: AppTheme.textPrimary, + ), + ), + SizedBox(height: 4), + Text( + '• Pagi Hari (06.00 - 08.00 WIB): Mempersiapkan kelembapan sebelum suhu udara mulai meningkat.\n• Sore Hari (16.00 - 18.00 WIB): Mengembalikan kelembapan setelah penguapan tinggi di siang hari.', + style: TextStyle(fontSize: 11, color: AppTheme.textSecondary, height: 1.5), + ), + ], + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildRefIndicator({ + required IconData icon, + required Color iconColor, + required String label, + required String value, + }) { + return Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: iconColor.withOpacity(0.05), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: iconColor.withOpacity(0.1)), + ), + child: Row( + children: [ + Icon(icon, color: iconColor, size: 20), + const SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle(fontSize: 9, color: AppTheme.textSecondary), + ), + const SizedBox(height: 2), + Text( + value, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: iconColor.withOpacity(0.9), + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/features/iot/page/main_page.dart b/lib/features/iot/page/main_page.dart index de1871d..2552ab3 100644 --- a/lib/features/iot/page/main_page.dart +++ b/lib/features/iot/page/main_page.dart @@ -5,6 +5,7 @@ import '../../../core/app_theme.dart'; import '../../../features/auth/auth_notifier.dart'; import '../../../core/routes.dart'; import 'home_page.dart'; +import 'kumbung_map_page.dart'; import 'control_page.dart'; class MainPage extends ConsumerStatefulWidget { @@ -19,6 +20,7 @@ class _MainPageState extends ConsumerState { final List _pages = const [ MonitoringPage(), + KumbungMapPage(), ControlPage(), ]; @@ -69,6 +71,11 @@ class _MainPageState extends ConsumerState { activeIcon: Icon(Icons.dashboard), label: 'Monitoring', ), + BottomNavigationBarItem( + icon: Icon(Icons.map_outlined), + activeIcon: Icon(Icons.map), + label: 'Peta', + ), BottomNavigationBarItem( icon: Icon(Icons.tune_outlined), activeIcon: Icon(Icons.tune), diff --git a/lib/features/iot/providers/control_notifier.dart b/lib/features/iot/providers/control_notifier.dart index ca351ff..d529b12 100644 --- a/lib/features/iot/providers/control_notifier.dart +++ b/lib/features/iot/providers/control_notifier.dart @@ -102,7 +102,8 @@ class ControlNotifier extends Notifier { state = state.copyWith( isLoading: false, - suhuLimit: (threshold['temp_max'] as num?)?.toDouble() ?? 30.0, + suhuMinLimit: (threshold['temp_min'] as num?)?.toDouble() ?? 22.0, + suhuMaxLimit: (threshold['temp_max'] as num?)?.toDouble() ?? 30.0, kelembapanLimit: (threshold['hum_max'] as num?)?.toDouble() ?? 75.0, schedules: parsedSchedules, deviceMode: DeviceModeX.fromString(currentMode), @@ -189,20 +190,26 @@ class ControlNotifier extends Notifier { void setPump(bool value) => state = state.copyWith(isPumpOn: value); // ── Batasan Sensor ─────────────────────────────────────────── - /// Menyimpan batasan suhu maks ([suhu]) dan kelembapan min ([kelembapan]) ke server. - Future saveThreshold(double suhu, double kelembapan) async { - final oldSuhu = state.suhuLimit; + /// Menyimpan batasan suhu min-maks ([suhuMin], [suhuMax]) dan kelembapan min ([kelembapan]) ke server. + Future saveThreshold(double suhuMin, double suhuMax, double kelembapan) async { + final oldSuhuMin = state.suhuMinLimit; + final oldSuhuMax = state.suhuMaxLimit; final oldKelembapan = state.kelembapanLimit; // Optimistic Update - state = state.copyWith(suhuLimit: suhu, kelembapanLimit: kelembapan); + state = state.copyWith( + suhuMinLimit: suhuMin, + suhuMaxLimit: suhuMax, + kelembapanLimit: kelembapan, + ); try { - await _api.updateThreshold(suhu, kelembapan); + await _api.updateThreshold(suhuMin, suhuMax, kelembapan); state = state.copyWith(successMessage: 'Batasan sensor diperbarui'); } catch (e) { // Revert ke nilai lama jika gagal menyimpan state = state.copyWith( - suhuLimit: oldSuhu, + suhuMinLimit: oldSuhuMin, + suhuMaxLimit: oldSuhuMax, kelembapanLimit: oldKelembapan, errorMessage: 'Gagal menyimpan batasan sensor', ); diff --git a/lib/features/iot/providers/control_state.dart b/lib/features/iot/providers/control_state.dart index e2a1f8b..0d6dedc 100644 --- a/lib/features/iot/providers/control_state.dart +++ b/lib/features/iot/providers/control_state.dart @@ -116,8 +116,11 @@ class ControlState { /// Status relay pompa saat ini (true = menyala, false = mati). final bool isPumpOn; + /// Nilai threshold suhu minimal (°C). + final double suhuMinLimit; + /// Nilai threshold suhu maksimal (°C). - final double suhuLimit; + final double suhuMaxLimit; /// Nilai threshold kelembapan minimal (%). final double kelembapanLimit; @@ -146,7 +149,8 @@ class ControlState { const ControlState({ this.isLoading = false, this.isPumpOn = false, - this.suhuLimit = 30, + this.suhuMinLimit = 22, + this.suhuMaxLimit = 30, this.kelembapanLimit = 75, this.schedules = const [], this.errorMessage, @@ -168,7 +172,8 @@ class ControlState { ControlState copyWith({ bool? isLoading, bool? isPumpOn, - double? suhuLimit, + double? suhuMinLimit, + double? suhuMaxLimit, double? kelembapanLimit, List? schedules, String? errorMessage, @@ -184,7 +189,8 @@ class ControlState { return ControlState( isLoading: isLoading ?? this.isLoading, isPumpOn: isPumpOn ?? this.isPumpOn, - suhuLimit: suhuLimit ?? this.suhuLimit, + suhuMinLimit: suhuMinLimit ?? this.suhuMinLimit, + suhuMaxLimit: suhuMaxLimit ?? this.suhuMaxLimit, kelembapanLimit: kelembapanLimit ?? this.kelembapanLimit, schedules: schedules ?? this.schedules, errorMessage: clearError ? null : (errorMessage ?? this.errorMessage), @@ -202,7 +208,8 @@ class ControlState { (other is ControlState && isLoading == other.isLoading && isPumpOn == other.isPumpOn && - suhuLimit == other.suhuLimit && + suhuMinLimit == other.suhuMinLimit && + suhuMaxLimit == other.suhuMaxLimit && kelembapanLimit == other.kelembapanLimit && errorMessage == other.errorMessage && successMessage == other.successMessage && @@ -214,7 +221,8 @@ class ControlState { int get hashCode => isLoading.hashCode ^ isPumpOn.hashCode ^ - suhuLimit.hashCode ^ + suhuMinLimit.hashCode ^ + suhuMaxLimit.hashCode ^ kelembapanLimit.hashCode ^ errorMessage.hashCode ^ successMessage.hashCode ^ diff --git a/lib/features/iot/providers/monitoring_provider.dart b/lib/features/iot/providers/monitoring_provider.dart index 1ee7b70..d5c95ab 100644 --- a/lib/features/iot/providers/monitoring_provider.dart +++ b/lib/features/iot/providers/monitoring_provider.dart @@ -91,9 +91,13 @@ class MonitoringState { /// - Danger: jika suhu melebihi batas maks + 3°C. /// - Warning: jika suhu melebihi batas maks. /// - Normal: jika suhu berada di bawah batas maks. - MonitoringStatus tempStatus(double maxSuhu) { - if (temperature >= maxSuhu + 3) return MonitoringStatus.danger; - if (temperature > maxSuhu) return MonitoringStatus.warning; + /// Menentukan status suhu berdasarkan batas minimum ([minSuhu]) dan maksimum ([maxSuhu]). + /// - Danger: jika suhu melebihi batas maks + 3°C atau di bawah batas min - 3°C. + /// - Warning: jika suhu melebihi batas maks atau di bawah batas min. + /// - Normal: jika suhu berada di dalam rentang. + MonitoringStatus tempStatus(double minSuhu, double maxSuhu) { + if (temperature >= maxSuhu + 3 || temperature <= minSuhu - 3) return MonitoringStatus.danger; + if (temperature > maxSuhu || temperature < minSuhu) return MonitoringStatus.warning; return MonitoringStatus.normal; } @@ -109,8 +113,8 @@ class MonitoringState { /// Menentukan status keseluruhan kumbung jamur berdasarkan status suhu dan kelembapan. /// Menghasilkan string 'Danger', 'Warning', atau 'Normal'. - String overallStatus(double maxSuhu, double minHum) { - final statuses = [tempStatus(maxSuhu), humidStatus(minHum)]; + String overallStatus(double minSuhu, double maxSuhu, double minHum) { + final statuses = [tempStatus(minSuhu, maxSuhu), humidStatus(minHum)]; if (statuses.contains(MonitoringStatus.danger)) return 'Danger'; if (statuses.contains(MonitoringStatus.warning)) return 'Warning'; return 'Normal'; diff --git a/lib/features/iot/widgets/device_control_section.dart b/lib/features/iot/widgets/device_control_section.dart index 198eb29..8cf09b3 100644 --- a/lib/features/iot/widgets/device_control_section.dart +++ b/lib/features/iot/widgets/device_control_section.dart @@ -5,18 +5,20 @@ import 'sensor_limit_card.dart'; class DeviceControlSection extends StatelessWidget { final bool isPumpOn; final bool isOnline; - final double suhuLimit; + final double suhuMinLimit; + final double suhuMaxLimit; final double kelembapanLimit; final int? activePumpDuration; final DateTime? pumpTurnedOnAt; final VoidCallback onPumpToggle; - final Future Function(double suhu, double kelembapan) onSaveThreshold; + final Future Function(double suhuMin, double suhuMax, double kelembapan) onSaveThreshold; const DeviceControlSection({ super.key, required this.isPumpOn, required this.isOnline, - required this.suhuLimit, + required this.suhuMinLimit, + required this.suhuMaxLimit, required this.kelembapanLimit, this.activePumpDuration, this.pumpTurnedOnAt, @@ -30,7 +32,8 @@ class DeviceControlSection extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SensorLimitCard( - suhuLimit: suhuLimit, + suhuMinLimit: suhuMinLimit, + suhuMaxLimit: suhuMaxLimit, kelembapanLimit: kelembapanLimit, onSave: onSaveThreshold, ), diff --git a/lib/features/iot/widgets/isometric_kumbung_painter.dart b/lib/features/iot/widgets/isometric_kumbung_painter.dart new file mode 100644 index 0000000..fc9fcdf --- /dev/null +++ b/lib/features/iot/widgets/isometric_kumbung_painter.dart @@ -0,0 +1,407 @@ +import 'dart:math' as math; +import 'package:flutter/material.dart'; +import 'isometric_transform.dart'; + +// ============================================================================= +// LAYER 1: STATIC STRUCTURE PAINTER +// ============================================================================= +/// Menggambar grid lantai, dinding transparan, dan barisan rak baglog jamur (balok 3D). +class StaticKumbungPainter extends CustomPainter { + final IsometricTransform transformer; + final double roomWidth; // Dimensi Y (lebar) dalam meter + final double roomLength; // Dimensi X (panjang) dalam meter + final double roomHeight; // Dimensi Z (tinggi) dalam meter + + const StaticKumbungPainter({ + required this.transformer, + this.roomWidth = 6.0, + this.roomLength = 8.0, + this.roomHeight = 2.5, + }); + + @override + void paint(Canvas canvas, Size size) { + final gridPaint = Paint() + ..color = Colors.grey.shade300 + ..strokeWidth = 1.0 + ..style = PaintingStyle.stroke; + + final wallPaint = Paint() + ..color = Colors.blueGrey.withOpacity(0.15) + ..strokeWidth = 1.5 + ..style = PaintingStyle.stroke; + + // 1. Menggambar Grid Lantai (Z = 0) + // Garis sepanjang X (Panjang) + for (double y = 0; y <= roomWidth; y += 1.0) { + final pStart = transformer.toScreen(0, y, 0); + final pEnd = transformer.toScreen(roomLength, y, 0); + canvas.drawLine(pStart, pEnd, gridPaint); + } + // Garis sepanjang Y (Lebar) + for (double x = 0; x <= roomLength; x += 1.0) { + final pStart = transformer.toScreen(x, 0, 0); + final pEnd = transformer.toScreen(x, roomWidth, 0); + canvas.drawLine(pStart, pEnd, gridPaint); + } + + // 2. Menggambar Rak Jamur (3D Blocks) + // Kita buat 4 baris rak jamur vertikal (sejajar sumbu X/Panjang) + // Format rak: { xStart, xEnd, yStart, yEnd, zHeight } + final raks = [ + _Rak3D(xStart: 1.0, xEnd: 7.0, yStart: 0.6, yEnd: 1.2, height: 1.8), + _Rak3D(xStart: 1.0, xEnd: 7.0, yStart: 1.9, yEnd: 2.5, height: 1.8), + _Rak3D(xStart: 1.0, xEnd: 7.0, yStart: 3.5, yEnd: 4.1, height: 1.8), + _Rak3D(xStart: 1.0, xEnd: 7.0, yStart: 4.8, yEnd: 5.4, height: 1.8), + ]; + + for (final rak in raks) { + _drawRak(canvas, rak); + } + + // 3. Menggambar Outline Dinding Kumbung (Wireframe Box) + // Sudut-sudut dasar lantai + final p000 = transformer.toScreen(0, 0, 0); + final pL00 = transformer.toScreen(roomLength, 0, 0); + final p0W0 = transformer.toScreen(0, roomWidth, 0); + final pLW0 = transformer.toScreen(roomLength, roomWidth, 0); + + // Sudut-sudut atap (Z = roomHeight) + final p00H = transformer.toScreen(0, 0, roomHeight); + final pL0H = transformer.toScreen(roomLength, 0, roomHeight); + final p0WH = transformer.toScreen(0, roomWidth, roomHeight); + final pLWH = transformer.toScreen(roomLength, roomWidth, roomHeight); + + // Gambar tiang vertikal sudut + canvas.drawLine(p000, p00H, wallPaint); + canvas.drawLine(pL00, pL0H, wallPaint); + canvas.drawLine(p0W0, p0WH, wallPaint); + canvas.drawLine(pLW0, pLWH, wallPaint); + + // Gambar garis atap + final roofPath = Path() + ..moveTo(p00H.dx, p00H.dy) + ..lineTo(pL0H.dx, pL0H.dy) + ..lineTo(pLWH.dx, pLWH.dy) + ..lineTo(p0WH.dx, p0WH.dy) + ..close(); + canvas.drawPath(roofPath, wallPaint); + + // Gambar garis dasar (jika tertutup) + final floorPath = Path() + ..moveTo(p000.dx, p000.dy) + ..lineTo(pL00.dx, pL00.dy) + ..lineTo(pLW0.dx, pLW0.dy) + ..lineTo(p0W0.dx, p0W0.dy) + ..close(); + canvas.drawPath(floorPath, wallPaint); + } + + /// Menggambar balok 3D isometrik untuk rak jamur + void _drawRak(Canvas canvas, _Rak3D rak) { + final fillPaint = Paint() + ..color = Colors.teal.withOpacity(0.12) + ..style = PaintingStyle.fill; + + final borderPaint = Paint() + ..color = Colors.teal.withOpacity(0.4) + ..strokeWidth = 1.0 + ..style = PaintingStyle.stroke; + + // Titik-titik sudut bawah (Z = 0) + final p10 = transformer.toScreen(rak.xEnd, rak.yStart, 0); + final p01 = transformer.toScreen(rak.xStart, rak.yEnd, 0); + final p11 = transformer.toScreen(rak.xEnd, rak.yEnd, 0); + + // Titik-titik sudut atas (Z = height) + final p00H = transformer.toScreen(rak.xStart, rak.yStart, rak.height); + final p10H = transformer.toScreen(rak.xEnd, rak.yStart, rak.height); + final p01H = transformer.toScreen(rak.xStart, rak.yEnd, rak.height); + final p11H = transformer.toScreen(rak.xEnd, rak.yEnd, rak.height); + + // 1. Gambar sisi kanan (X-Right Face) + final rightFace = Path() + ..moveTo(p10.dx, p10.dy) + ..lineTo(p11.dx, p11.dy) + ..lineTo(p11H.dx, p11H.dy) + ..lineTo(p10H.dx, p10H.dy) + ..close(); + canvas.drawPath(rightFace, fillPaint); + canvas.drawPath(rightFace, borderPaint); + + // 2. Gambar sisi kiri (Y-Left Face) + final leftFace = Path() + ..moveTo(p01.dx, p01.dy) + ..lineTo(p11.dx, p11.dy) + ..lineTo(p11H.dx, p11H.dy) + ..lineTo(p01H.dx, p01H.dy) + ..close(); + canvas.drawPath(leftFace, fillPaint); + canvas.drawPath(leftFace, borderPaint); + + // 3. Gambar sisi atas (Top Face) + final topFace = Path() + ..moveTo(p00H.dx, p00H.dy) + ..lineTo(p10H.dx, p10H.dy) + ..lineTo(p11H.dx, p11H.dy) + ..lineTo(p01H.dx, p01H.dy) + ..close(); + canvas.drawPath(topFace, fillPaint..color = Colors.teal.withOpacity(0.18)); + canvas.drawPath(topFace, borderPaint); + } + + @override + bool shouldRepaint(covariant StaticKumbungPainter oldDelegate) { + return oldDelegate.roomWidth != roomWidth || + oldDelegate.roomLength != roomLength || + oldDelegate.roomHeight != roomHeight || + oldDelegate.transformer.scale != transformer.scale || + oldDelegate.transformer.origin != transformer.origin; + } +} + +class _Rak3D { + final double xStart; + final double xEnd; + final double yStart; + final double yEnd; + final double height; + _Rak3D({required this.xStart, required this.xEnd, required this.yStart, required this.yEnd, required this.height}); +} + + +// ============================================================================= +// LAYER 2: HEATMAP PAINTER +// ============================================================================= +/// Menggambar lingkaran gradien heatmap isometrik (elips) di sekitar posisi sensor di atas lantai. +class HeatmapKumbungPainter extends CustomPainter { + final IsometricTransform transformer; + final Offset sensorPos; // Koordinat X dan Y sensor (meter) + final double radius; // Radius cakupan dalam meter + final double value; // Nilai aktual sensor (suhu atau kelembapan) + final bool isSuhuMode; // true untuk Suhu, false untuk Kelembapan + + const HeatmapKumbungPainter({ + required this.transformer, + required this.sensorPos, + required this.radius, + required this.value, + required this.isSuhuMode, + }); + + @override + void paint(Canvas canvas, Size size) { + if (radius <= 0) return; + + // Titik pusat lingkaran di ground (Z = 0) dalam koordinat piksel + final center = transformer.toScreen(sensorPos.dx, sensorPos.dy, 0); + + // Radius dalam proyeksi isometrik (elips) + // Sumbu horizontal (Major Axis): radius * scale + // Sumbu vertikal (Minor Axis): radius * scale * sin(30) -> sin(30) = 0.5 + final double radiusX = radius * transformer.scale * math.cos(30.0 * math.pi / 180.0); + final double radiusY = radius * transformer.scale * math.sin(30.0 * math.pi / 180.0); + + // Tentukan warna heatmap berdasarkan nilai aktual sensor + Color centerColor; + if (isSuhuMode) { + // Peta Suhu: Dingin (< 22°C) = Biru, Optimal (22 - 28°C) = Hijau, Panas (> 28°C) = Merah/Oranye + if (value < 22) { + centerColor = Colors.blue.withOpacity(0.5); + } else if (value <= 28) { + centerColor = Colors.green.withOpacity(0.45); + } else { + // Semakin panas semakin merah menyala + final factor = ((value - 28) / 10).clamp(0.0, 1.0); + centerColor = Color.lerp(Colors.orange, Colors.redAccent, factor)!.withOpacity(0.55); + } + } else { + // Peta Kelembapan: Kering (< 60%) = Kuning/Oranye, Cukup (60 - 80%) = Hijau, Basah/Optimal (> 80%) = Biru/Sian + if (value < 60) { + centerColor = Colors.orange.withOpacity(0.5); + } else if (value < 80) { + centerColor = Colors.green.withOpacity(0.45); + } else { + centerColor = Colors.cyan.withOpacity(0.5); + } + } + + final paint = Paint() + ..shader = RadialGradient( + colors: [ + centerColor, + centerColor.withOpacity(0.15), + Colors.transparent, + ], + stops: const [0.0, 0.6, 1.0], + ).createShader(Rect.fromLTRB( + center.dx - radiusX, + center.dy - radiusY, + center.dx + radiusX, + center.dy + radiusY, + )); + + // Gambar elips heatmap isometrik + canvas.drawOval( + Rect.fromCenter(center: center, width: radiusX * 2, height: radiusY * 2), + paint, + ); + } + + @override + bool shouldRepaint(covariant HeatmapKumbungPainter oldDelegate) { + return oldDelegate.sensorPos != sensorPos || + oldDelegate.radius != radius || + oldDelegate.value != value || + oldDelegate.isSuhuMode != isSuhuMode || + oldDelegate.transformer.scale != transformer.scale || + oldDelegate.transformer.origin != transformer.origin; + } +} + + +// ============================================================================= +// LAYER 3: DYNAMIC SENSOR PIN & TAG PAINTER +// ============================================================================= +/// Menggambar penanda pin sensor melayang (Z = height), garis bantu vertikal ke ground, +/// lingkaran pulsing glow, serta tag nilai real-time di atas pin. +class PinKumbungPainter extends CustomPainter { + final IsometricTransform transformer; + final Offset sensorPos; // X, Y koordinat sensor (meter) + final double sensorHeight; // Z (tinggi sensor) melayang dalam meter + final double value; // Nilai sensor yang ditampilkan + final String unit; // Satuan nilai (contoh: "°C" atau "%") + final double animationValue; // Nilai animasi 0.0 s.d 1.0 untuk denyut pulsing + final Color pinColor; // Warna pin (menyesuaikan status) + + const PinKumbungPainter({ + required this.transformer, + required this.sensorPos, + required this.sensorHeight, + required this.value, + required this.unit, + required this.animationValue, + required this.pinColor, + }); + + @override + void paint(Canvas canvas, Size size) { + // 1. Koordinat Piksel Ground (Z = 0) dan Koordinat Floating (Z = sensorHeight) + final pGround = transformer.toScreen(sensorPos.dx, sensorPos.dy, 0); + final pFloat = transformer.toScreen(sensorPos.dx, sensorPos.dy, sensorHeight); + + // 2. Menggambar Garis Bantu Vertikal (Dotted/Dashed Line) + final linePaint = Paint() + ..color = pinColor.withOpacity(0.5) + ..strokeWidth = 1.2 + ..style = PaintingStyle.stroke; + + _drawDashedLine(canvas, pGround, pFloat, linePaint); + + // 3. Menggambar Lingkaran Pulsing Glow di Lantai (Z = 0) + final double pulseMaxRadius = 24.0; + final double pulseRadius = pulseMaxRadius * animationValue; + final double pulseOpacity = (1.0 - animationValue).clamp(0.0, 1.0); + final pulsePaint = Paint() + ..color = pinColor.withOpacity(pulseOpacity * 0.4) + ..style = PaintingStyle.fill; + + // Proyeksi isometrik elips untuk pulsing + final double prX = pulseRadius; + final double prY = pulseRadius * math.sin(30.0 * math.pi / 180.0); + canvas.drawOval( + Rect.fromCenter(center: pGround, width: prX * 2, height: prY * 2), + pulsePaint, + ); + + // 4. Menggambar Pin Sensor Melayang (Floating Pin) + // Efek mengambang naik-turun menggunakan nilai animasi + final double floatOffset = 6.0 * math.sin(animationValue * 2 * math.pi); + final pFloatingPin = Offset(pFloat.dx, pFloat.dy + floatOffset); + + // Lingkaran pin inti + final corePaint = Paint() + ..color = pinColor + ..style = PaintingStyle.fill; + final borderPaint = Paint() + ..color = Colors.white + ..strokeWidth = 2.0 + ..style = PaintingStyle.stroke; + + canvas.drawCircle(pFloatingPin, 8.0, corePaint); + canvas.drawCircle(pFloatingPin, 8.0, borderPaint); + + // 5. Menggambar Tag Pembacaan Nilai (Tooltip Tag) di atas Pin + final tagText = '${value.toStringAsFixed(1)}$unit'; + _drawTooltipTag(canvas, pFloatingPin - const Offset(0, 14), tagText); + } + + /// Helper untuk menggambar dashed line + void _drawDashedLine(Canvas canvas, Offset p1, Offset p2, Paint paint) { + const double dashHeight = 4.0; + const double dashGap = 3.0; + double startY = p1.dy; + double endY = p2.dy; + + // Arah vertical naik (dy mengecil) + if (startY < endY) { + final temp = startY; + startY = endY; + endY = temp; + } + + double currentY = startY; + while (currentY > endY) { + final double nextY = math.max(currentY - dashHeight, endY); + canvas.drawLine(Offset(p1.dx, currentY), Offset(p1.dx, nextY), paint); + currentY = nextY - dashGap; + } + } + + /// Helper untuk menggambar text tag box (tooltip) di atas pin sensor + void _drawTooltipTag(Canvas canvas, Offset position, String text) { + final textPainter = TextPainter( + text: TextSpan( + text: text, + style: const TextStyle( + color: Colors.white, + fontSize: 10.0, + fontWeight: FontWeight.bold, + ), + ), + textDirection: TextDirection.ltr, + )..layout(); + + final double paddingX = 8.0; + final double paddingY = 4.0; + final double width = textPainter.width + paddingX * 2; + final double height = textPainter.height + paddingY * 2; + + final rect = Rect.fromCenter(center: position, width: width, height: height); + final rrect = RRect.fromRectAndRadius(rect, const Radius.circular(6.0)); + + // Paint background tag box (semacam pill box gelap) + final bgPaint = Paint() + ..color = Colors.black.withOpacity(0.7) + ..style = PaintingStyle.fill; + canvas.drawRRect(rrect, bgPaint); + + // Gambar teks di tengah box + textPainter.paint( + canvas, + Offset(position.dx - textPainter.width / 2, position.dy - textPainter.height / 2), + ); + } + + @override + bool shouldRepaint(covariant PinKumbungPainter oldDelegate) { + return oldDelegate.sensorPos != sensorPos || + oldDelegate.sensorHeight != sensorHeight || + oldDelegate.value != value || + oldDelegate.unit != unit || + oldDelegate.animationValue != animationValue || + oldDelegate.pinColor != pinColor || + oldDelegate.transformer.scale != transformer.scale || + oldDelegate.transformer.origin != transformer.origin; + } +} diff --git a/lib/features/iot/widgets/isometric_transform.dart b/lib/features/iot/widgets/isometric_transform.dart new file mode 100644 index 0000000..693f3d8 --- /dev/null +++ b/lib/features/iot/widgets/isometric_transform.dart @@ -0,0 +1,53 @@ +import 'dart:math' as math; +import 'package:flutter/material.dart'; + +/// Class utility untuk transformasi koordinat 3D World (meter) ke 2D Screen (piksel) +/// dengan proyeksi Isometrik. +class IsometricTransform { + /// Skala piksel per meter (misalnya 1 meter = 40 piksel) + final double scale; + + /// Titik origin (0,0,0) di layar + final Offset origin; + + const IsometricTransform({ + required this.scale, + required this.origin, + }); + + /// Mengonversi koordinat 3D World (x, y, z) dalam satuan meter ke Offset 2D Canvas (piksel). + /// * [x] : Arah kedalaman kumbung (dari depan ke belakang) - e.g. 0 s.d 8 meter. + /// * [y] : Arah lebar kumbung (dari kiri ke kanan) - e.g. 0 s.d 6 meter. + /// * [z] : Arah tinggi kumbung (dari lantai ke atap) - e.g. 0 s.d 3 meter. + Offset toScreen(double x, double y, double z) { + const double angle = 30.0 * math.pi / 180.0; // Sudut proyeksi isometrik 30 derajat + + // dx = (x - y) * cos(30) * scale + // dy = ((x + y) * sin(30) - z) * scale + final double dx = (x - y) * math.cos(angle) * scale; + final double dy = ((x + y) * math.sin(angle) - z) * scale; + + return Offset(origin.dx + dx, origin.dy + dy); + } + + /// Mengonversi koordinat 2D Screen (tap) kembali ke koordinat 2D World Ground (x, y) pada z = 0. + /// Berguna untuk mendeteksi penempatan/interaksi ketukan jari di lantai kumbung. + Offset toWorldGround(Offset screenPt) { + const double angle = 30.0 * math.pi / 180.0; + final double cos30 = math.cos(angle); + final double sin30 = math.sin(angle); + + final double rx = screenPt.dx - origin.dx; + final double ry = screenPt.dy - origin.dy; + + // x - y = rx / (cos(30) * scale) + // x + y = ry / (sin(30) * scale) + final double diffVal = rx / (cos30 * scale); + final double sumVal = ry / (sin30 * scale); + + final double x = (sumVal + diffVal) / 2; + final double y = (sumVal - diffVal) / 2; + + return Offset(x, y); + } +} diff --git a/lib/features/iot/widgets/monitoring_card.dart b/lib/features/iot/widgets/monitoring_card.dart index bf1a82a..25e52ad 100644 --- a/lib/features/iot/widgets/monitoring_card.dart +++ b/lib/features/iot/widgets/monitoring_card.dart @@ -35,9 +35,16 @@ class MonitoringCard extends StatelessWidget { } IconData get _bgIcon { - return subtitle.toLowerCase().contains('suhu') - ? Icons.thermostat - : Icons.water_drop; + final sub = subtitle.toLowerCase(); + if (sub.contains('suhu')) { + return Icons.thermostat; + } else if (sub.contains('debit') || sub.contains('alir')) { + return Icons.waves; + } else if (sub.contains('volume') || sub.contains('total')) { + return Icons.opacity; + } else { + return Icons.water_drop; + } } @override diff --git a/lib/features/iot/widgets/sensor_limit_card.dart b/lib/features/iot/widgets/sensor_limit_card.dart index 6c216cf..a3dae98 100644 --- a/lib/features/iot/widgets/sensor_limit_card.dart +++ b/lib/features/iot/widgets/sensor_limit_card.dart @@ -2,19 +2,22 @@ import 'package:flutter/material.dart'; import '../../../core/app_theme.dart'; class SensorLimitCard extends StatefulWidget { - final double suhuLimit; + final double suhuMinLimit; + final double suhuMaxLimit; final double kelembapanLimit; - final Future Function(double suhu, double kelembapan) onSave; + final Future Function(double suhuMin, double suhuMax, double kelembapan) onSave; const SensorLimitCard({ super.key, - required this.suhuLimit, + required this.suhuMinLimit, + required this.suhuMaxLimit, required this.kelembapanLimit, required this.onSave, }); // Nilai default untuk tanaman jamur - static const double defaultSuhu = 28.0; + static const double defaultSuhuMin = 22.0; + static const double defaultSuhuMax = 28.0; static const double defaultKelembapan = 90.0; @override @@ -22,58 +25,67 @@ class SensorLimitCard extends StatefulWidget { } class _SensorLimitCardState extends State { - late double _draftSuhu; + late double _draftSuhuMin; + late double _draftSuhuMax; late double _draftKelembapan; bool _isSaving = false; @override void initState() { super.initState(); - _draftSuhu = widget.suhuLimit; + _draftSuhuMin = widget.suhuMinLimit; + _draftSuhuMax = widget.suhuMaxLimit; _draftKelembapan = widget.kelembapanLimit; } // Sinkronisasi draft jika nilai dari provider berubah dari luar - // (misal setelah reload awal / restore dari server) @override void didUpdateWidget(SensorLimitCard oldWidget) { super.didUpdateWidget(oldWidget); - final serverChanged = oldWidget.suhuLimit != widget.suhuLimit || + final serverChanged = oldWidget.suhuMinLimit != widget.suhuMinLimit || + oldWidget.suhuMaxLimit != widget.suhuMaxLimit || oldWidget.kelembapanLimit != widget.kelembapanLimit; - final draftUnchanged = _draftSuhu == oldWidget.suhuLimit && + final draftUnchanged = _draftSuhuMin == oldWidget.suhuMinLimit && + _draftSuhuMax == oldWidget.suhuMaxLimit && _draftKelembapan == oldWidget.kelembapanLimit; + // Update draft hanya jika user belum mengedit if (serverChanged && draftUnchanged) { - _draftSuhu = widget.suhuLimit; + _draftSuhuMin = widget.suhuMinLimit; + _draftSuhuMax = widget.suhuMaxLimit; _draftKelembapan = widget.kelembapanLimit; } } bool get _hasChanges => - _draftSuhu != widget.suhuLimit || + _draftSuhuMin != widget.suhuMinLimit || + _draftSuhuMax != widget.suhuMaxLimit || _draftKelembapan != widget.kelembapanLimit; bool get _isDefault => - _draftSuhu == SensorLimitCard.defaultSuhu && + _draftSuhuMin == SensorLimitCard.defaultSuhuMin && + _draftSuhuMax == SensorLimitCard.defaultSuhuMax && _draftKelembapan == SensorLimitCard.defaultKelembapan; void _applyDefault() { setState(() { - _draftSuhu = SensorLimitCard.defaultSuhu; + _draftSuhuMin = SensorLimitCard.defaultSuhuMin; + _draftSuhuMax = SensorLimitCard.defaultSuhuMax; _draftKelembapan = SensorLimitCard.defaultKelembapan; }); } void _cancel() { setState(() { - _draftSuhu = widget.suhuLimit; + _draftSuhuMin = widget.suhuMinLimit; + _draftSuhuMax = widget.suhuMaxLimit; _draftKelembapan = widget.kelembapanLimit; }); } Future _save() async { setState(() => _isSaving = true); - await widget.onSave(_draftSuhu, _draftKelembapan); + await widget.onSave(_draftSuhuMin, _draftSuhuMax, _draftKelembapan); if (mounted) setState(() => _isSaving = false); } @@ -134,16 +146,22 @@ class _SensorLimitCardState extends State { ), const SizedBox(height: 16), - // ── Slider Suhu ──────────────────────────────────────── - _SliderRow( + // ── Slider Rentang Suhu (Min - Maks) ─────────────────── + _RangeSliderRow( icon: Icons.thermostat, - label: 'Suhu Maks', - value: _draftSuhu, - displayValue: '${_draftSuhu.round()}°C', + label: 'Batas Rentang Suhu', + startValue: _draftSuhuMin, + endValue: _draftSuhuMax, + displayValue: '${_draftSuhuMin.round()}°C - ${_draftSuhuMax.round()}°C', min: 15, max: 50, divisions: 35, - onChanged: (v) => setState(() => _draftSuhu = v), + onChanged: (values) { + setState(() { + _draftSuhuMin = values.start; + _draftSuhuMax = values.end; + }); + }, ), const SizedBox(height: 8), @@ -232,6 +250,76 @@ class _SensorLimitCardState extends State { } } +// ── Range Slider Row ────────────────────────────────────────────── +class _RangeSliderRow extends StatelessWidget { + final IconData icon; + final String label; + final double startValue; + final double endValue; + final String displayValue; + final double min; + final double max; + final int divisions; + final ValueChanged onChanged; + + const _RangeSliderRow({ + required this.icon, + required this.label, + required this.startValue, + required this.endValue, + required this.displayValue, + required this.min, + required this.max, + required this.divisions, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, color: Colors.white70, size: 14), + const SizedBox(width: 4), + Expanded( + child: Text( + label, + style: const TextStyle(color: Colors.white70, fontSize: 12), + ), + ), + Text( + displayValue, + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + SliderTheme( + data: SliderTheme.of(context).copyWith( + activeTrackColor: Colors.white, + inactiveTrackColor: Colors.white24, + thumbColor: Colors.white, + overlayColor: Colors.white24, + trackHeight: 3, + ), + child: RangeSlider( + values: RangeValues(startValue, endValue), + min: min, + max: max, + divisions: divisions, + onChanged: onChanged, + ), + ), + ], + ); + } +} + // ── Slider Row ──────────────────────────────────────────────────── class _SliderRow extends StatelessWidget { final IconData icon; diff --git a/lib/models/sensor_model.dart b/lib/models/sensor_model.dart index 09a112f..00549ee 100644 --- a/lib/models/sensor_model.dart +++ b/lib/models/sensor_model.dart @@ -1,7 +1,9 @@ -/// Model untuk data sensor IoT (suhu & kelembapan). +/// Model untuk data sensor IoT (suhu, kelembapan, & debit air). class SensorModel { final double temperature; final double humidity; + final double waterFlow; + final double waterVolume; final bool relayOn; final String mode; final DateTime timestamp; @@ -9,6 +11,8 @@ class SensorModel { const SensorModel({ required this.temperature, required this.humidity, + this.waterFlow = 0.0, + this.waterVolume = 0.0, this.relayOn = false, this.mode = 'auto', required this.timestamp, @@ -18,6 +22,8 @@ class SensorModel { return SensorModel( temperature: (json['temperature'] as num?)?.toDouble() ?? 0.0, humidity: (json['humidity'] as num?)?.toDouble() ?? 0.0, + waterFlow: (json['water_flow'] as num?)?.toDouble() ?? 0.0, + waterVolume: (json['water_volume'] as num?)?.toDouble() ?? 0.0, relayOn: json['relay_state'] as bool? ?? false, mode: json['mode'] as String? ?? 'auto', timestamp: json['timestamp'] != null @@ -31,6 +37,8 @@ class SensorModel { Map toJson() => { 'temperature': temperature, 'humidity': humidity, + 'water_flow': waterFlow, + 'water_volume': waterVolume, 'relay_state': relayOn, 'mode': mode, 'timestamp': timestamp.toIso8601String(), @@ -39,6 +47,8 @@ class SensorModel { SensorModel copyWith({ double? temperature, double? humidity, + double? waterFlow, + double? waterVolume, bool? relayOn, String? mode, DateTime? timestamp, @@ -46,6 +56,8 @@ class SensorModel { return SensorModel( temperature: temperature ?? this.temperature, humidity: humidity ?? this.humidity, + waterFlow: waterFlow ?? this.waterFlow, + waterVolume: waterVolume ?? this.waterVolume, relayOn: relayOn ?? this.relayOn, mode: mode ?? this.mode, timestamp: timestamp ?? this.timestamp, @@ -54,5 +66,5 @@ class SensorModel { @override String toString() => - 'SensorModel(temp: $temperature°C, hum: $humidity%, relay: $relayOn, mode: $mode, ts: $timestamp)'; + 'SensorModel(temp: $temperature°C, hum: $humidity%, flow: $waterFlow L/min, vol: $waterVolume L, relay: $relayOn, mode: $mode, ts: $timestamp)'; } diff --git a/lib/services/api_control_service.dart b/lib/services/api_control_service.dart index 48968be..1ca145c 100644 --- a/lib/services/api_control_service.dart +++ b/lib/services/api_control_service.dart @@ -48,12 +48,13 @@ class ApiControlService { } } - /// Memperbarui nilai batas threshold suhu ([tempMax]) dan kelembapan ([humMax]) di server. - Future updateThreshold(double tempMax, double humMax) async { + /// Memperbarui nilai batas threshold suhu ([tempMin], [tempMax]) dan kelembapan ([humMax]) di server. + Future updateThreshold(double tempMin, double tempMax, double humMax) async { try { await _dio.post( '${ApiConfig.baseUrl}/threshold/$deviceId', data: { + 'temp_min': tempMin, 'temp_max': tempMax, 'hum_max': humMax, },