feat: implementasikan visualisasi isometrik 2.5D, heatmap sensor, range slider suhu, debit air, dan panduan budidaya

This commit is contained in:
bimabuana 2026-07-06 15:51:30 +07:00
parent e73750213e
commit ce118a806b
14 changed files with 1326 additions and 55 deletions

View File

@ -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),

View File

@ -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

View File

@ -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),
),
),
],
),
],
),
);
}
}

View File

@ -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),

View File

@ -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',
);

View File

@ -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 ^

View File

@ -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';

View File

@ -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,
),

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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

View File

@ -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;

View File

@ -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)';
}

View File

@ -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,
},