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(
|
DeviceControlSection(
|
||||||
isPumpOn: control.isPumpOn,
|
isPumpOn: control.isPumpOn,
|
||||||
isOnline: isOnline,
|
isOnline: isOnline,
|
||||||
suhuLimit: control.suhuLimit,
|
suhuMinLimit: control.suhuMinLimit,
|
||||||
|
suhuMaxLimit: control.suhuMaxLimit,
|
||||||
kelembapanLimit: control.kelembapanLimit,
|
kelembapanLimit: control.kelembapanLimit,
|
||||||
activePumpDuration: control.activePumpDuration,
|
activePumpDuration: control.activePumpDuration,
|
||||||
pumpTurnedOnAt: control.pumpTurnedOnAt,
|
pumpTurnedOnAt: control.pumpTurnedOnAt,
|
||||||
|
|
@ -286,8 +287,8 @@ class ControlPage extends ConsumerWidget {
|
||||||
_showManualDurationDialog(context, ref);
|
_showManualDurationDialog(context, ref);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSaveThreshold: (suhu, kelembapan) =>
|
onSaveThreshold: (suhuMin, suhuMax, kelembapan) =>
|
||||||
notifier.saveThreshold(suhu, kelembapan),
|
notifier.saveThreshold(suhuMin, suhuMax, kelembapan),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../../core/app_theme.dart';
|
import '../../../core/app_theme.dart';
|
||||||
|
import '../../../models/monitoring_status.dart';
|
||||||
import '../../auth/auth_notifier.dart';
|
import '../../auth/auth_notifier.dart';
|
||||||
import '../../auth/auth_state.dart';
|
import '../../auth/auth_state.dart';
|
||||||
import '../providers/monitoring_provider.dart';
|
import '../providers/monitoring_provider.dart';
|
||||||
|
|
@ -185,7 +186,7 @@ class MonitoringPage extends ConsumerWidget {
|
||||||
// ── Belum ada data sensor ─────────────────────
|
// ── Belum ada data sensor ─────────────────────
|
||||||
_NoDataBanner(),
|
_NoDataBanner(),
|
||||||
] else ...[
|
] else ...[
|
||||||
// ── Sensor cards ──────────────────────────────
|
// ── Sensor cards Row 1 ────────────────────────
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|
@ -193,7 +194,7 @@ class MonitoringPage extends ConsumerWidget {
|
||||||
value: monitoring.temperature.toStringAsFixed(1),
|
value: monitoring.temperature.toStringAsFixed(1),
|
||||||
unit: '°C',
|
unit: '°C',
|
||||||
subtitle: 'Suhu Real Time',
|
subtitle: 'Suhu Real Time',
|
||||||
status: monitoring.tempStatus(control.suhuLimit),
|
status: monitoring.tempStatus(control.suhuMinLimit, control.suhuMaxLimit),
|
||||||
gradient: AppTheme.suhuGradient,
|
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),
|
const SizedBox(height: 30),
|
||||||
|
|
||||||
// ── Chart filter ──────────────────────────────
|
// ── 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 '../../../features/auth/auth_notifier.dart';
|
||||||
import '../../../core/routes.dart';
|
import '../../../core/routes.dart';
|
||||||
import 'home_page.dart';
|
import 'home_page.dart';
|
||||||
|
import 'kumbung_map_page.dart';
|
||||||
import 'control_page.dart';
|
import 'control_page.dart';
|
||||||
|
|
||||||
class MainPage extends ConsumerStatefulWidget {
|
class MainPage extends ConsumerStatefulWidget {
|
||||||
|
|
@ -19,6 +20,7 @@ class _MainPageState extends ConsumerState<MainPage> {
|
||||||
|
|
||||||
final List<Widget> _pages = const [
|
final List<Widget> _pages = const [
|
||||||
MonitoringPage(),
|
MonitoringPage(),
|
||||||
|
KumbungMapPage(),
|
||||||
ControlPage(),
|
ControlPage(),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -69,6 +71,11 @@ class _MainPageState extends ConsumerState<MainPage> {
|
||||||
activeIcon: Icon(Icons.dashboard),
|
activeIcon: Icon(Icons.dashboard),
|
||||||
label: 'Monitoring',
|
label: 'Monitoring',
|
||||||
),
|
),
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: Icon(Icons.map_outlined),
|
||||||
|
activeIcon: Icon(Icons.map),
|
||||||
|
label: 'Peta',
|
||||||
|
),
|
||||||
BottomNavigationBarItem(
|
BottomNavigationBarItem(
|
||||||
icon: Icon(Icons.tune_outlined),
|
icon: Icon(Icons.tune_outlined),
|
||||||
activeIcon: Icon(Icons.tune),
|
activeIcon: Icon(Icons.tune),
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,8 @@ class ControlNotifier extends Notifier<ControlState> {
|
||||||
|
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
isLoading: false,
|
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,
|
kelembapanLimit: (threshold['hum_max'] as num?)?.toDouble() ?? 75.0,
|
||||||
schedules: parsedSchedules,
|
schedules: parsedSchedules,
|
||||||
deviceMode: DeviceModeX.fromString(currentMode),
|
deviceMode: DeviceModeX.fromString(currentMode),
|
||||||
|
|
@ -189,20 +190,26 @@ class ControlNotifier extends Notifier<ControlState> {
|
||||||
void setPump(bool value) => state = state.copyWith(isPumpOn: value);
|
void setPump(bool value) => state = state.copyWith(isPumpOn: value);
|
||||||
|
|
||||||
// ── Batasan Sensor ───────────────────────────────────────────
|
// ── Batasan Sensor ───────────────────────────────────────────
|
||||||
/// Menyimpan batasan suhu maks ([suhu]) dan kelembapan min ([kelembapan]) ke server.
|
/// Menyimpan batasan suhu min-maks ([suhuMin], [suhuMax]) dan kelembapan min ([kelembapan]) ke server.
|
||||||
Future<void> saveThreshold(double suhu, double kelembapan) async {
|
Future<void> saveThreshold(double suhuMin, double suhuMax, double kelembapan) async {
|
||||||
final oldSuhu = state.suhuLimit;
|
final oldSuhuMin = state.suhuMinLimit;
|
||||||
|
final oldSuhuMax = state.suhuMaxLimit;
|
||||||
final oldKelembapan = state.kelembapanLimit;
|
final oldKelembapan = state.kelembapanLimit;
|
||||||
|
|
||||||
// Optimistic Update
|
// Optimistic Update
|
||||||
state = state.copyWith(suhuLimit: suhu, kelembapanLimit: kelembapan);
|
state = state.copyWith(
|
||||||
|
suhuMinLimit: suhuMin,
|
||||||
|
suhuMaxLimit: suhuMax,
|
||||||
|
kelembapanLimit: kelembapan,
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
await _api.updateThreshold(suhu, kelembapan);
|
await _api.updateThreshold(suhuMin, suhuMax, kelembapan);
|
||||||
state = state.copyWith(successMessage: 'Batasan sensor diperbarui');
|
state = state.copyWith(successMessage: 'Batasan sensor diperbarui');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Revert ke nilai lama jika gagal menyimpan
|
// Revert ke nilai lama jika gagal menyimpan
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
suhuLimit: oldSuhu,
|
suhuMinLimit: oldSuhuMin,
|
||||||
|
suhuMaxLimit: oldSuhuMax,
|
||||||
kelembapanLimit: oldKelembapan,
|
kelembapanLimit: oldKelembapan,
|
||||||
errorMessage: 'Gagal menyimpan batasan sensor',
|
errorMessage: 'Gagal menyimpan batasan sensor',
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -116,8 +116,11 @@ class ControlState {
|
||||||
/// Status relay pompa saat ini (true = menyala, false = mati).
|
/// Status relay pompa saat ini (true = menyala, false = mati).
|
||||||
final bool isPumpOn;
|
final bool isPumpOn;
|
||||||
|
|
||||||
|
/// Nilai threshold suhu minimal (°C).
|
||||||
|
final double suhuMinLimit;
|
||||||
|
|
||||||
/// Nilai threshold suhu maksimal (°C).
|
/// Nilai threshold suhu maksimal (°C).
|
||||||
final double suhuLimit;
|
final double suhuMaxLimit;
|
||||||
|
|
||||||
/// Nilai threshold kelembapan minimal (%).
|
/// Nilai threshold kelembapan minimal (%).
|
||||||
final double kelembapanLimit;
|
final double kelembapanLimit;
|
||||||
|
|
@ -146,7 +149,8 @@ class ControlState {
|
||||||
const ControlState({
|
const ControlState({
|
||||||
this.isLoading = false,
|
this.isLoading = false,
|
||||||
this.isPumpOn = false,
|
this.isPumpOn = false,
|
||||||
this.suhuLimit = 30,
|
this.suhuMinLimit = 22,
|
||||||
|
this.suhuMaxLimit = 30,
|
||||||
this.kelembapanLimit = 75,
|
this.kelembapanLimit = 75,
|
||||||
this.schedules = const [],
|
this.schedules = const [],
|
||||||
this.errorMessage,
|
this.errorMessage,
|
||||||
|
|
@ -168,7 +172,8 @@ class ControlState {
|
||||||
ControlState copyWith({
|
ControlState copyWith({
|
||||||
bool? isLoading,
|
bool? isLoading,
|
||||||
bool? isPumpOn,
|
bool? isPumpOn,
|
||||||
double? suhuLimit,
|
double? suhuMinLimit,
|
||||||
|
double? suhuMaxLimit,
|
||||||
double? kelembapanLimit,
|
double? kelembapanLimit,
|
||||||
List<WateringSchedule>? schedules,
|
List<WateringSchedule>? schedules,
|
||||||
String? errorMessage,
|
String? errorMessage,
|
||||||
|
|
@ -184,7 +189,8 @@ class ControlState {
|
||||||
return ControlState(
|
return ControlState(
|
||||||
isLoading: isLoading ?? this.isLoading,
|
isLoading: isLoading ?? this.isLoading,
|
||||||
isPumpOn: isPumpOn ?? this.isPumpOn,
|
isPumpOn: isPumpOn ?? this.isPumpOn,
|
||||||
suhuLimit: suhuLimit ?? this.suhuLimit,
|
suhuMinLimit: suhuMinLimit ?? this.suhuMinLimit,
|
||||||
|
suhuMaxLimit: suhuMaxLimit ?? this.suhuMaxLimit,
|
||||||
kelembapanLimit: kelembapanLimit ?? this.kelembapanLimit,
|
kelembapanLimit: kelembapanLimit ?? this.kelembapanLimit,
|
||||||
schedules: schedules ?? this.schedules,
|
schedules: schedules ?? this.schedules,
|
||||||
errorMessage: clearError ? null : (errorMessage ?? this.errorMessage),
|
errorMessage: clearError ? null : (errorMessage ?? this.errorMessage),
|
||||||
|
|
@ -202,7 +208,8 @@ class ControlState {
|
||||||
(other is ControlState &&
|
(other is ControlState &&
|
||||||
isLoading == other.isLoading &&
|
isLoading == other.isLoading &&
|
||||||
isPumpOn == other.isPumpOn &&
|
isPumpOn == other.isPumpOn &&
|
||||||
suhuLimit == other.suhuLimit &&
|
suhuMinLimit == other.suhuMinLimit &&
|
||||||
|
suhuMaxLimit == other.suhuMaxLimit &&
|
||||||
kelembapanLimit == other.kelembapanLimit &&
|
kelembapanLimit == other.kelembapanLimit &&
|
||||||
errorMessage == other.errorMessage &&
|
errorMessage == other.errorMessage &&
|
||||||
successMessage == other.successMessage &&
|
successMessage == other.successMessage &&
|
||||||
|
|
@ -214,7 +221,8 @@ class ControlState {
|
||||||
int get hashCode =>
|
int get hashCode =>
|
||||||
isLoading.hashCode ^
|
isLoading.hashCode ^
|
||||||
isPumpOn.hashCode ^
|
isPumpOn.hashCode ^
|
||||||
suhuLimit.hashCode ^
|
suhuMinLimit.hashCode ^
|
||||||
|
suhuMaxLimit.hashCode ^
|
||||||
kelembapanLimit.hashCode ^
|
kelembapanLimit.hashCode ^
|
||||||
errorMessage.hashCode ^
|
errorMessage.hashCode ^
|
||||||
successMessage.hashCode ^
|
successMessage.hashCode ^
|
||||||
|
|
|
||||||
|
|
@ -91,9 +91,13 @@ class MonitoringState {
|
||||||
/// - Danger: jika suhu melebihi batas maks + 3°C.
|
/// - Danger: jika suhu melebihi batas maks + 3°C.
|
||||||
/// - Warning: jika suhu melebihi batas maks.
|
/// - Warning: jika suhu melebihi batas maks.
|
||||||
/// - Normal: jika suhu berada di bawah batas maks.
|
/// - Normal: jika suhu berada di bawah batas maks.
|
||||||
MonitoringStatus tempStatus(double maxSuhu) {
|
/// Menentukan status suhu berdasarkan batas minimum ([minSuhu]) dan maksimum ([maxSuhu]).
|
||||||
if (temperature >= maxSuhu + 3) return MonitoringStatus.danger;
|
/// - Danger: jika suhu melebihi batas maks + 3°C atau di bawah batas min - 3°C.
|
||||||
if (temperature > maxSuhu) return MonitoringStatus.warning;
|
/// - 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;
|
return MonitoringStatus.normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -109,8 +113,8 @@ class MonitoringState {
|
||||||
|
|
||||||
/// Menentukan status keseluruhan kumbung jamur berdasarkan status suhu dan kelembapan.
|
/// Menentukan status keseluruhan kumbung jamur berdasarkan status suhu dan kelembapan.
|
||||||
/// Menghasilkan string 'Danger', 'Warning', atau 'Normal'.
|
/// Menghasilkan string 'Danger', 'Warning', atau 'Normal'.
|
||||||
String overallStatus(double maxSuhu, double minHum) {
|
String overallStatus(double minSuhu, double maxSuhu, double minHum) {
|
||||||
final statuses = [tempStatus(maxSuhu), humidStatus(minHum)];
|
final statuses = [tempStatus(minSuhu, maxSuhu), humidStatus(minHum)];
|
||||||
if (statuses.contains(MonitoringStatus.danger)) return 'Danger';
|
if (statuses.contains(MonitoringStatus.danger)) return 'Danger';
|
||||||
if (statuses.contains(MonitoringStatus.warning)) return 'Warning';
|
if (statuses.contains(MonitoringStatus.warning)) return 'Warning';
|
||||||
return 'Normal';
|
return 'Normal';
|
||||||
|
|
|
||||||
|
|
@ -5,18 +5,20 @@ import 'sensor_limit_card.dart';
|
||||||
class DeviceControlSection extends StatelessWidget {
|
class DeviceControlSection extends StatelessWidget {
|
||||||
final bool isPumpOn;
|
final bool isPumpOn;
|
||||||
final bool isOnline;
|
final bool isOnline;
|
||||||
final double suhuLimit;
|
final double suhuMinLimit;
|
||||||
|
final double suhuMaxLimit;
|
||||||
final double kelembapanLimit;
|
final double kelembapanLimit;
|
||||||
final int? activePumpDuration;
|
final int? activePumpDuration;
|
||||||
final DateTime? pumpTurnedOnAt;
|
final DateTime? pumpTurnedOnAt;
|
||||||
final VoidCallback onPumpToggle;
|
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({
|
const DeviceControlSection({
|
||||||
super.key,
|
super.key,
|
||||||
required this.isPumpOn,
|
required this.isPumpOn,
|
||||||
required this.isOnline,
|
required this.isOnline,
|
||||||
required this.suhuLimit,
|
required this.suhuMinLimit,
|
||||||
|
required this.suhuMaxLimit,
|
||||||
required this.kelembapanLimit,
|
required this.kelembapanLimit,
|
||||||
this.activePumpDuration,
|
this.activePumpDuration,
|
||||||
this.pumpTurnedOnAt,
|
this.pumpTurnedOnAt,
|
||||||
|
|
@ -30,7 +32,8 @@ class DeviceControlSection extends StatelessWidget {
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
SensorLimitCard(
|
SensorLimitCard(
|
||||||
suhuLimit: suhuLimit,
|
suhuMinLimit: suhuMinLimit,
|
||||||
|
suhuMaxLimit: suhuMaxLimit,
|
||||||
kelembapanLimit: kelembapanLimit,
|
kelembapanLimit: kelembapanLimit,
|
||||||
onSave: onSaveThreshold,
|
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 {
|
IconData get _bgIcon {
|
||||||
return subtitle.toLowerCase().contains('suhu')
|
final sub = subtitle.toLowerCase();
|
||||||
? Icons.thermostat
|
if (sub.contains('suhu')) {
|
||||||
: Icons.water_drop;
|
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
|
@override
|
||||||
|
|
|
||||||
|
|
@ -2,19 +2,22 @@ import 'package:flutter/material.dart';
|
||||||
import '../../../core/app_theme.dart';
|
import '../../../core/app_theme.dart';
|
||||||
|
|
||||||
class SensorLimitCard extends StatefulWidget {
|
class SensorLimitCard extends StatefulWidget {
|
||||||
final double suhuLimit;
|
final double suhuMinLimit;
|
||||||
|
final double suhuMaxLimit;
|
||||||
final double kelembapanLimit;
|
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({
|
const SensorLimitCard({
|
||||||
super.key,
|
super.key,
|
||||||
required this.suhuLimit,
|
required this.suhuMinLimit,
|
||||||
|
required this.suhuMaxLimit,
|
||||||
required this.kelembapanLimit,
|
required this.kelembapanLimit,
|
||||||
required this.onSave,
|
required this.onSave,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Nilai default untuk tanaman jamur
|
// 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;
|
static const double defaultKelembapan = 90.0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -22,58 +25,67 @@ class SensorLimitCard extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SensorLimitCardState extends State<SensorLimitCard> {
|
class _SensorLimitCardState extends State<SensorLimitCard> {
|
||||||
late double _draftSuhu;
|
late double _draftSuhuMin;
|
||||||
|
late double _draftSuhuMax;
|
||||||
late double _draftKelembapan;
|
late double _draftKelembapan;
|
||||||
bool _isSaving = false;
|
bool _isSaving = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_draftSuhu = widget.suhuLimit;
|
_draftSuhuMin = widget.suhuMinLimit;
|
||||||
|
_draftSuhuMax = widget.suhuMaxLimit;
|
||||||
_draftKelembapan = widget.kelembapanLimit;
|
_draftKelembapan = widget.kelembapanLimit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sinkronisasi draft jika nilai dari provider berubah dari luar
|
// Sinkronisasi draft jika nilai dari provider berubah dari luar
|
||||||
// (misal setelah reload awal / restore dari server)
|
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(SensorLimitCard oldWidget) {
|
void didUpdateWidget(SensorLimitCard oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
final serverChanged = oldWidget.suhuLimit != widget.suhuLimit ||
|
final serverChanged = oldWidget.suhuMinLimit != widget.suhuMinLimit ||
|
||||||
|
oldWidget.suhuMaxLimit != widget.suhuMaxLimit ||
|
||||||
oldWidget.kelembapanLimit != widget.kelembapanLimit;
|
oldWidget.kelembapanLimit != widget.kelembapanLimit;
|
||||||
final draftUnchanged = _draftSuhu == oldWidget.suhuLimit &&
|
final draftUnchanged = _draftSuhuMin == oldWidget.suhuMinLimit &&
|
||||||
|
_draftSuhuMax == oldWidget.suhuMaxLimit &&
|
||||||
_draftKelembapan == oldWidget.kelembapanLimit;
|
_draftKelembapan == oldWidget.kelembapanLimit;
|
||||||
|
|
||||||
// Update draft hanya jika user belum mengedit
|
// Update draft hanya jika user belum mengedit
|
||||||
if (serverChanged && draftUnchanged) {
|
if (serverChanged && draftUnchanged) {
|
||||||
_draftSuhu = widget.suhuLimit;
|
_draftSuhuMin = widget.suhuMinLimit;
|
||||||
|
_draftSuhuMax = widget.suhuMaxLimit;
|
||||||
_draftKelembapan = widget.kelembapanLimit;
|
_draftKelembapan = widget.kelembapanLimit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool get _hasChanges =>
|
bool get _hasChanges =>
|
||||||
_draftSuhu != widget.suhuLimit ||
|
_draftSuhuMin != widget.suhuMinLimit ||
|
||||||
|
_draftSuhuMax != widget.suhuMaxLimit ||
|
||||||
_draftKelembapan != widget.kelembapanLimit;
|
_draftKelembapan != widget.kelembapanLimit;
|
||||||
|
|
||||||
bool get _isDefault =>
|
bool get _isDefault =>
|
||||||
_draftSuhu == SensorLimitCard.defaultSuhu &&
|
_draftSuhuMin == SensorLimitCard.defaultSuhuMin &&
|
||||||
|
_draftSuhuMax == SensorLimitCard.defaultSuhuMax &&
|
||||||
_draftKelembapan == SensorLimitCard.defaultKelembapan;
|
_draftKelembapan == SensorLimitCard.defaultKelembapan;
|
||||||
|
|
||||||
void _applyDefault() {
|
void _applyDefault() {
|
||||||
setState(() {
|
setState(() {
|
||||||
_draftSuhu = SensorLimitCard.defaultSuhu;
|
_draftSuhuMin = SensorLimitCard.defaultSuhuMin;
|
||||||
|
_draftSuhuMax = SensorLimitCard.defaultSuhuMax;
|
||||||
_draftKelembapan = SensorLimitCard.defaultKelembapan;
|
_draftKelembapan = SensorLimitCard.defaultKelembapan;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _cancel() {
|
void _cancel() {
|
||||||
setState(() {
|
setState(() {
|
||||||
_draftSuhu = widget.suhuLimit;
|
_draftSuhuMin = widget.suhuMinLimit;
|
||||||
|
_draftSuhuMax = widget.suhuMaxLimit;
|
||||||
_draftKelembapan = widget.kelembapanLimit;
|
_draftKelembapan = widget.kelembapanLimit;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _save() async {
|
Future<void> _save() async {
|
||||||
setState(() => _isSaving = true);
|
setState(() => _isSaving = true);
|
||||||
await widget.onSave(_draftSuhu, _draftKelembapan);
|
await widget.onSave(_draftSuhuMin, _draftSuhuMax, _draftKelembapan);
|
||||||
if (mounted) setState(() => _isSaving = false);
|
if (mounted) setState(() => _isSaving = false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -134,16 +146,22 @@ class _SensorLimitCardState extends State<SensorLimitCard> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// ── Slider Suhu ────────────────────────────────────────
|
// ── Slider Rentang Suhu (Min - Maks) ───────────────────
|
||||||
_SliderRow(
|
_RangeSliderRow(
|
||||||
icon: Icons.thermostat,
|
icon: Icons.thermostat,
|
||||||
label: 'Suhu Maks',
|
label: 'Batas Rentang Suhu',
|
||||||
value: _draftSuhu,
|
startValue: _draftSuhuMin,
|
||||||
displayValue: '${_draftSuhu.round()}°C',
|
endValue: _draftSuhuMax,
|
||||||
|
displayValue: '${_draftSuhuMin.round()}°C - ${_draftSuhuMax.round()}°C',
|
||||||
min: 15,
|
min: 15,
|
||||||
max: 50,
|
max: 50,
|
||||||
divisions: 35,
|
divisions: 35,
|
||||||
onChanged: (v) => setState(() => _draftSuhu = v),
|
onChanged: (values) {
|
||||||
|
setState(() {
|
||||||
|
_draftSuhuMin = values.start;
|
||||||
|
_draftSuhuMax = values.end;
|
||||||
|
});
|
||||||
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
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 ────────────────────────────────────────────────────
|
// ── Slider Row ────────────────────────────────────────────────────
|
||||||
class _SliderRow extends StatelessWidget {
|
class _SliderRow extends StatelessWidget {
|
||||||
final IconData icon;
|
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 {
|
class SensorModel {
|
||||||
final double temperature;
|
final double temperature;
|
||||||
final double humidity;
|
final double humidity;
|
||||||
|
final double waterFlow;
|
||||||
|
final double waterVolume;
|
||||||
final bool relayOn;
|
final bool relayOn;
|
||||||
final String mode;
|
final String mode;
|
||||||
final DateTime timestamp;
|
final DateTime timestamp;
|
||||||
|
|
@ -9,6 +11,8 @@ class SensorModel {
|
||||||
const SensorModel({
|
const SensorModel({
|
||||||
required this.temperature,
|
required this.temperature,
|
||||||
required this.humidity,
|
required this.humidity,
|
||||||
|
this.waterFlow = 0.0,
|
||||||
|
this.waterVolume = 0.0,
|
||||||
this.relayOn = false,
|
this.relayOn = false,
|
||||||
this.mode = 'auto',
|
this.mode = 'auto',
|
||||||
required this.timestamp,
|
required this.timestamp,
|
||||||
|
|
@ -18,6 +22,8 @@ class SensorModel {
|
||||||
return SensorModel(
|
return SensorModel(
|
||||||
temperature: (json['temperature'] as num?)?.toDouble() ?? 0.0,
|
temperature: (json['temperature'] as num?)?.toDouble() ?? 0.0,
|
||||||
humidity: (json['humidity'] 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,
|
relayOn: json['relay_state'] as bool? ?? false,
|
||||||
mode: json['mode'] as String? ?? 'auto',
|
mode: json['mode'] as String? ?? 'auto',
|
||||||
timestamp: json['timestamp'] != null
|
timestamp: json['timestamp'] != null
|
||||||
|
|
@ -31,6 +37,8 @@ class SensorModel {
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
'temperature': temperature,
|
'temperature': temperature,
|
||||||
'humidity': humidity,
|
'humidity': humidity,
|
||||||
|
'water_flow': waterFlow,
|
||||||
|
'water_volume': waterVolume,
|
||||||
'relay_state': relayOn,
|
'relay_state': relayOn,
|
||||||
'mode': mode,
|
'mode': mode,
|
||||||
'timestamp': timestamp.toIso8601String(),
|
'timestamp': timestamp.toIso8601String(),
|
||||||
|
|
@ -39,6 +47,8 @@ class SensorModel {
|
||||||
SensorModel copyWith({
|
SensorModel copyWith({
|
||||||
double? temperature,
|
double? temperature,
|
||||||
double? humidity,
|
double? humidity,
|
||||||
|
double? waterFlow,
|
||||||
|
double? waterVolume,
|
||||||
bool? relayOn,
|
bool? relayOn,
|
||||||
String? mode,
|
String? mode,
|
||||||
DateTime? timestamp,
|
DateTime? timestamp,
|
||||||
|
|
@ -46,6 +56,8 @@ class SensorModel {
|
||||||
return SensorModel(
|
return SensorModel(
|
||||||
temperature: temperature ?? this.temperature,
|
temperature: temperature ?? this.temperature,
|
||||||
humidity: humidity ?? this.humidity,
|
humidity: humidity ?? this.humidity,
|
||||||
|
waterFlow: waterFlow ?? this.waterFlow,
|
||||||
|
waterVolume: waterVolume ?? this.waterVolume,
|
||||||
relayOn: relayOn ?? this.relayOn,
|
relayOn: relayOn ?? this.relayOn,
|
||||||
mode: mode ?? this.mode,
|
mode: mode ?? this.mode,
|
||||||
timestamp: timestamp ?? this.timestamp,
|
timestamp: timestamp ?? this.timestamp,
|
||||||
|
|
@ -54,5 +66,5 @@ class SensorModel {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() =>
|
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.
|
/// Memperbarui nilai batas threshold suhu ([tempMin], [tempMax]) dan kelembapan ([humMax]) di server.
|
||||||
Future<void> updateThreshold(double tempMax, double humMax) async {
|
Future<void> updateThreshold(double tempMin, double tempMax, double humMax) async {
|
||||||
try {
|
try {
|
||||||
await _dio.post(
|
await _dio.post(
|
||||||
'${ApiConfig.baseUrl}/threshold/$deviceId',
|
'${ApiConfig.baseUrl}/threshold/$deviceId',
|
||||||
data: {
|
data: {
|
||||||
|
'temp_min': tempMin,
|
||||||
'temp_max': tempMax,
|
'temp_max': tempMax,
|
||||||
'hum_max': humMax,
|
'hum_max': humMax,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue