feat: implementasikan visualisasi isometrik 2.5D, heatmap sensor, range slider suhu, debit air, dan panduan budidaya
This commit is contained in:
parent
e73750213e
commit
ce118a806b
|
|
@ -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),
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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<KumbungMapPage> createState() => _KumbungMapPageState();
|
||||
}
|
||||
|
||||
class _KumbungMapPageState extends ConsumerState<KumbungMapPage>
|
||||
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<void> _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<void> _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<double> 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<MainPage> {
|
|||
|
||||
final List<Widget> _pages = const [
|
||||
MonitoringPage(),
|
||||
KumbungMapPage(),
|
||||
ControlPage(),
|
||||
];
|
||||
|
||||
|
|
@ -69,6 +71,11 @@ class _MainPageState extends ConsumerState<MainPage> {
|
|||
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),
|
||||
|
|
|
|||
|
|
@ -102,7 +102,8 @@ class ControlNotifier extends Notifier<ControlState> {
|
|||
|
||||
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<ControlState> {
|
|||
void setPump(bool value) => state = state.copyWith(isPumpOn: value);
|
||||
|
||||
// ── Batasan Sensor ───────────────────────────────────────────
|
||||
/// Menyimpan batasan suhu maks ([suhu]) dan kelembapan min ([kelembapan]) ke server.
|
||||
Future<void> saveThreshold(double suhu, double kelembapan) async {
|
||||
final oldSuhu = state.suhuLimit;
|
||||
/// Menyimpan batasan suhu min-maks ([suhuMin], [suhuMax]) dan kelembapan min ([kelembapan]) ke server.
|
||||
Future<void> 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',
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<WateringSchedule>? 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 ^
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<void> Function(double suhu, double kelembapan) onSaveThreshold;
|
||||
final Future<void> 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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<void> Function(double suhu, double kelembapan) onSave;
|
||||
final Future<void> 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<SensorLimitCard> {
|
||||
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<void> _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<SensorLimitCard> {
|
|||
),
|
||||
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<SensorLimitCard> {
|
|||
}
|
||||
}
|
||||
|
||||
// ── 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<RangeValues> 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;
|
||||
|
|
|
|||
|
|
@ -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<String, dynamic> 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)';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,12 +48,13 @@ class ApiControlService {
|
|||
}
|
||||
}
|
||||
|
||||
/// Memperbarui nilai batas threshold suhu ([tempMax]) dan kelembapan ([humMax]) di server.
|
||||
Future<void> updateThreshold(double tempMax, double humMax) async {
|
||||
/// Memperbarui nilai batas threshold suhu ([tempMin], [tempMax]) dan kelembapan ([humMax]) di server.
|
||||
Future<void> 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,
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue