feat: Threshold-based sensor system with unlimited threshold configs
- NEW: Flexible threshold system (threshold_1, threshold_2, ... unlimited) - Each threshold: batas_bawah, batas_atas, durasi, smart_mode, pot_aktif[] - Smart mode: siram sampai batas_atas tercapai - Fixed mode: siram sesuai durasi tetap - ThresholdModel: Complete model with validation - ThresholdService: CRUD operations for threshold management - ThresholdManagementPage: UI untuk list dan manage threshold - ThresholdFormPage: Add/edit form dengan slider dan pot selection - Worker.js: Dynamic threshold_* detection and processing - Navigation: Sensor button now goes to ThresholdManagementPage - Firebase: Auto-setup script with example threshold data (threshold_1, threshold_2, threshold_3) - Path: All threshold data stored in /kontrol_1
This commit is contained in:
parent
abef985a11
commit
1224c8e867
|
|
@ -0,0 +1,116 @@
|
|||
class ThresholdModel {
|
||||
final String id; // threshold_1, threshold_2, ...
|
||||
final bool aktif;
|
||||
final int batasBawah; // 0-100 persentase kelembaban
|
||||
final int batasAtas; // 0-100 persentase kelembaban
|
||||
final int durasi; // detik
|
||||
final bool smartMode; // true = smart, false = fixed duration
|
||||
final List<int> potAktif; // [1,2,3] atau [4,5]
|
||||
final bool pompaAir;
|
||||
final bool pompaPupuk;
|
||||
|
||||
ThresholdModel({
|
||||
required this.id,
|
||||
required this.aktif,
|
||||
required this.batasBawah,
|
||||
required this.batasAtas,
|
||||
required this.durasi,
|
||||
required this.smartMode,
|
||||
required this.potAktif,
|
||||
required this.pompaAir,
|
||||
required this.pompaPupuk,
|
||||
});
|
||||
|
||||
// Parse dari Firebase
|
||||
factory ThresholdModel.fromJson(String id, Map<dynamic, dynamic> json) {
|
||||
List<int> parsePotAktif(dynamic potData) {
|
||||
if (potData == null) return [];
|
||||
if (potData is List) {
|
||||
return potData.map((e) => e is int ? e : int.tryParse(e.toString()) ?? 0).toList();
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
return ThresholdModel(
|
||||
id: id,
|
||||
aktif: json['aktif'] == true,
|
||||
batasBawah: json['batas_bawah'] is int ? json['batas_bawah'] : 30,
|
||||
batasAtas: json['batas_atas'] is int ? json['batas_atas'] : 70,
|
||||
durasi: json['durasi'] is int ? json['durasi'] : 600,
|
||||
smartMode: json['smart_mode'] == true,
|
||||
potAktif: parsePotAktif(json['pot_aktif']),
|
||||
pompaAir: json['pompa_air'] == true,
|
||||
pompaPupuk: json['pompa_pupuk'] == true,
|
||||
);
|
||||
}
|
||||
|
||||
// Convert ke format Firebase
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'aktif': aktif,
|
||||
'batas_bawah': batasBawah,
|
||||
'batas_atas': batasAtas,
|
||||
'durasi': durasi,
|
||||
'smart_mode': smartMode,
|
||||
'pot_aktif': potAktif,
|
||||
'pompa_air': pompaAir,
|
||||
'pompa_pupuk': pompaPupuk,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper: durasi dalam menit
|
||||
int get durasiMenit => (durasi / 60).round();
|
||||
|
||||
// Helper: cek apakah pot tertentu aktif
|
||||
bool isPotAktif(int potNumber) {
|
||||
return potAktif.contains(potNumber);
|
||||
}
|
||||
|
||||
// Helper: string pot aktif (1,2,3)
|
||||
String get potAktifString {
|
||||
if (potAktif.isEmpty) return 'Tidak ada pot';
|
||||
return potAktif.join(', ');
|
||||
}
|
||||
|
||||
// Helper: range string (30-70%)
|
||||
String get rangeString => '$batasBawah%-$batasAtas%';
|
||||
|
||||
// Helper: mode string
|
||||
String get modeString => smartMode ? 'Smart Mode' : 'Fixed Duration';
|
||||
|
||||
// Validation
|
||||
bool get isValid {
|
||||
return batasBawah >= 0 &&
|
||||
batasBawah <= 100 &&
|
||||
batasAtas >= 0 &&
|
||||
batasAtas <= 100 &&
|
||||
batasBawah < batasAtas &&
|
||||
durasi > 0 &&
|
||||
potAktif.isNotEmpty;
|
||||
}
|
||||
|
||||
// Copy with
|
||||
ThresholdModel copyWith({
|
||||
String? id,
|
||||
bool? aktif,
|
||||
int? batasBawah,
|
||||
int? batasAtas,
|
||||
int? durasi,
|
||||
bool? smartMode,
|
||||
List<int>? potAktif,
|
||||
bool? pompaAir,
|
||||
bool? pompaPupuk,
|
||||
}) {
|
||||
return ThresholdModel(
|
||||
id: id ?? this.id,
|
||||
aktif: aktif ?? this.aktif,
|
||||
batasBawah: batasBawah ?? this.batasBawah,
|
||||
batasAtas: batasAtas ?? this.batasAtas,
|
||||
durasi: durasi ?? this.durasi,
|
||||
smartMode: smartMode ?? this.smartMode,
|
||||
potAktif: potAktif ?? this.potAktif,
|
||||
pompaAir: pompaAir ?? this.pompaAir,
|
||||
pompaPupuk: pompaPupuk ?? this.pompaPupuk,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import '../theme/app_color.dart';
|
|||
import '../widgets/kontrol_widgets.dart';
|
||||
import 'pot_selection_page.dart';
|
||||
import 'jadwal_management_page.dart';
|
||||
import 'threshold_management_page.dart';
|
||||
import '../services/kontrol_storage.dart';
|
||||
import '../services/firebase_database_service.dart';
|
||||
|
||||
|
|
@ -82,9 +83,7 @@ class _KontrolPageState extends State<KontrolPage> {
|
|||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder:
|
||||
(context) =>
|
||||
const PotSelectionPage(mode: 'Sensor'),
|
||||
builder: (context) => const ThresholdManagementPage(),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import '../widgets/kontrol_widgets.dart';
|
|||
import '../services/kontrol_storage.dart';
|
||||
import 'waktu_config_page.dart';
|
||||
import 'sensor_config_page.dart';
|
||||
import 'jadwal_management_page.dart';
|
||||
import 'threshold_management_page.dart';
|
||||
|
||||
class PotSelectionPage extends StatefulWidget {
|
||||
final String mode;
|
||||
|
|
@ -141,30 +143,22 @@ class _PotSelectionPageState extends State<PotSelectionPage> {
|
|||
mode: 'Waktu',
|
||||
isSelected: widget.mode == 'Waktu',
|
||||
onPressed: () {
|
||||
if (widget.mode != 'Waktu') {
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(
|
||||
builder:
|
||||
(context) =>
|
||||
const PotSelectionPage(mode: 'Waktu'),
|
||||
),
|
||||
);
|
||||
}
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const JadwalManagementPage(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
ModeButton(
|
||||
mode: 'Sensor',
|
||||
isSelected: widget.mode == 'Sensor',
|
||||
onPressed: () {
|
||||
if (widget.mode != 'Sensor') {
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(
|
||||
builder:
|
||||
(context) =>
|
||||
const PotSelectionPage(mode: 'Sensor'),
|
||||
),
|
||||
);
|
||||
}
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const ThresholdManagementPage(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,593 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../models/threshold_model.dart';
|
||||
import '../services/threshold_service.dart';
|
||||
import '../theme/app_color.dart';
|
||||
|
||||
/// Form untuk tambah/edit threshold sensor
|
||||
class ThresholdFormPage extends StatefulWidget {
|
||||
final ThresholdModel? threshold; // null = create new, not null = edit
|
||||
|
||||
const ThresholdFormPage({super.key, this.threshold});
|
||||
|
||||
@override
|
||||
State<ThresholdFormPage> createState() => _ThresholdFormPageState();
|
||||
}
|
||||
|
||||
class _ThresholdFormPageState extends State<ThresholdFormPage> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
late String _thresholdId;
|
||||
late double _batasBawah; // 0-100
|
||||
late double _batasAtas; // 0-100
|
||||
late int _durasi; // dalam detik
|
||||
late bool _smartMode;
|
||||
late List<bool> _potSelection; // [pot1, pot2, pot3, pot4, pot5]
|
||||
late bool _pompaAir;
|
||||
late bool _pompaPupuk;
|
||||
late bool _aktif;
|
||||
|
||||
bool _isLoading = false;
|
||||
bool _isEditMode = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeValues();
|
||||
}
|
||||
|
||||
void _initializeValues() {
|
||||
_isEditMode = widget.threshold != null;
|
||||
|
||||
if (_isEditMode) {
|
||||
final threshold = widget.threshold!;
|
||||
_thresholdId = threshold.id;
|
||||
_batasBawah = threshold.batasBawah.toDouble();
|
||||
_batasAtas = threshold.batasAtas.toDouble();
|
||||
_durasi = threshold.durasi;
|
||||
_smartMode = threshold.smartMode;
|
||||
|
||||
// Initialize pot selection
|
||||
_potSelection = List.generate(5, (index) {
|
||||
return threshold.potAktif.contains(index + 1);
|
||||
});
|
||||
|
||||
_pompaAir = threshold.pompaAir;
|
||||
_pompaPupuk = threshold.pompaPupuk;
|
||||
_aktif = threshold.aktif;
|
||||
} else {
|
||||
// Default values for new threshold
|
||||
_thresholdId = ''; // Will be set when saving
|
||||
_batasBawah = 30.0;
|
||||
_batasAtas = 70.0;
|
||||
_durasi = 600; // 10 minutes
|
||||
_smartMode = true;
|
||||
_potSelection = [false, false, false, false, false];
|
||||
_pompaAir = true;
|
||||
_pompaPupuk = false;
|
||||
_aktif = true;
|
||||
}
|
||||
}
|
||||
|
||||
int get _durasiMenit => (_durasi / 60).round();
|
||||
|
||||
set _durasiMenit(int value) {
|
||||
_durasi = value * 60;
|
||||
}
|
||||
|
||||
List<int> get _selectedPots {
|
||||
final List<int> selected = [];
|
||||
for (int i = 0; i < _potSelection.length; i++) {
|
||||
if (_potSelection[i]) {
|
||||
selected.add(i + 1);
|
||||
}
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
bool _validate() {
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_selectedPots.isEmpty) {
|
||||
_showError('Pilih minimal 1 pot');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_batasBawah >= _batasAtas) {
|
||||
_showError('Batas bawah harus lebih kecil dari batas atas');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!_smartMode && _durasi <= 0) {
|
||||
_showError('Durasi harus lebih dari 0');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (!_validate()) return;
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
// Get or create ID
|
||||
if (!_isEditMode) {
|
||||
_thresholdId = await ThresholdService.getNextThresholdId();
|
||||
}
|
||||
|
||||
// Create threshold model
|
||||
final threshold = ThresholdModel(
|
||||
id: _thresholdId,
|
||||
aktif: _aktif,
|
||||
batasBawah: _batasBawah.toInt(),
|
||||
batasAtas: _batasAtas.toInt(),
|
||||
durasi: _durasi,
|
||||
smartMode: _smartMode,
|
||||
potAktif: _selectedPots,
|
||||
pompaAir: _pompaAir,
|
||||
pompaPupuk: _pompaPupuk,
|
||||
);
|
||||
|
||||
// Save to Firebase
|
||||
final success = await ThresholdService.saveThreshold(threshold);
|
||||
|
||||
setState(() => _isLoading = false);
|
||||
|
||||
if (success) {
|
||||
if (mounted) {
|
||||
_showSuccess(
|
||||
_isEditMode
|
||||
? 'Threshold berhasil diupdate'
|
||||
: 'Threshold berhasil ditambahkan',
|
||||
);
|
||||
Navigator.pop(context, true);
|
||||
}
|
||||
} else {
|
||||
_showError('Gagal menyimpan threshold');
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() => _isLoading = false);
|
||||
_showError('Error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _showSuccess(String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: Colors.green,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showError(String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: Colors.red,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColor.background,
|
||||
appBar: AppBar(
|
||||
backgroundColor: AppColor.primary,
|
||||
foregroundColor: Colors.white,
|
||||
title: Text(_isEditMode ? 'Edit Threshold' : 'Tambah Threshold'),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: Form(
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_buildRangeSection(),
|
||||
const SizedBox(height: 24),
|
||||
_buildModeSection(),
|
||||
const SizedBox(height: 24),
|
||||
_buildPotSelection(),
|
||||
const SizedBox(height: 24),
|
||||
_buildPumpSection(),
|
||||
const SizedBox(height: 24),
|
||||
_buildStatusSection(),
|
||||
const SizedBox(height: 32),
|
||||
_buildSaveButton(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRangeSection() {
|
||||
return Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.water_drop, color: Colors.blue[700]),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Range Kelembaban',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColor.textDark,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Batas Bawah
|
||||
Text(
|
||||
'Batas Bawah: ${_batasBawah.toInt()}%',
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
|
||||
),
|
||||
Slider(
|
||||
value: _batasBawah,
|
||||
min: 0,
|
||||
max: 100,
|
||||
divisions: 100,
|
||||
label: '${_batasBawah.toInt()}%',
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_batasBawah = value;
|
||||
if (_batasBawah >= _batasAtas) {
|
||||
_batasAtas = (_batasBawah + 1).clamp(0, 100);
|
||||
}
|
||||
});
|
||||
},
|
||||
activeColor: AppColor.primary,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Batas Atas
|
||||
Text(
|
||||
'Batas Atas: ${_batasAtas.toInt()}%',
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
|
||||
),
|
||||
Slider(
|
||||
value: _batasAtas,
|
||||
min: 0,
|
||||
max: 100,
|
||||
divisions: 100,
|
||||
label: '${_batasAtas.toInt()}%',
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_batasAtas = value;
|
||||
if (_batasAtas <= _batasBawah) {
|
||||
_batasBawah = (_batasAtas - 1).clamp(0, 100);
|
||||
}
|
||||
});
|
||||
},
|
||||
activeColor: AppColor.primary,
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue[50],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.blue[200]!),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, size: 18, color: Colors.blue[700]),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Siram jika kelembaban < ${_batasBawah.toInt()}%',
|
||||
style: TextStyle(fontSize: 12, color: Colors.blue[900]),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildModeSection() {
|
||||
return Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.psychology, color: Colors.orange[700]),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Mode Penyiraman',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColor.textDark,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Smart Mode Toggle
|
||||
SwitchListTile(
|
||||
value: _smartMode,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_smartMode = value;
|
||||
});
|
||||
},
|
||||
title: Text(
|
||||
_smartMode ? 'Smart Mode' : 'Fixed Duration',
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
subtitle: Text(
|
||||
_smartMode
|
||||
? 'Siram sampai kelembaban ${_batasAtas.toInt()}%'
|
||||
: 'Siram dengan durasi tetap',
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
activeColor: AppColor.primary,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
|
||||
// Duration input (hanya jika Fixed Duration)
|
||||
if (!_smartMode) ...[
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
initialValue: _durasiMenit.toString(),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Durasi (menit)',
|
||||
hintText: 'Masukkan durasi',
|
||||
prefixIcon: const Icon(Icons.timer),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(color: AppColor.primary, width: 2),
|
||||
),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Durasi tidak boleh kosong';
|
||||
}
|
||||
final durasi = int.tryParse(value);
|
||||
if (durasi == null || durasi <= 0) {
|
||||
return 'Durasi harus lebih dari 0';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onChanged: (value) {
|
||||
final menit = int.tryParse(value);
|
||||
if (menit != null) {
|
||||
setState(() {
|
||||
_durasiMenit = menit;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPotSelection() {
|
||||
return Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.local_florist, color: Colors.green[700]),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Pilih Pot',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColor.textDark,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Pot selection buttons
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: List.generate(5, (index) {
|
||||
final potNumber = index + 1;
|
||||
final isSelected = _potSelection[index];
|
||||
|
||||
return FilterChip(
|
||||
label: Text('Pot $potNumber'),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
setState(() {
|
||||
_potSelection[index] = selected;
|
||||
});
|
||||
},
|
||||
selectedColor: AppColor.primary.withOpacity(0.2),
|
||||
checkmarkColor: AppColor.primary,
|
||||
backgroundColor: Colors.grey[100],
|
||||
side: BorderSide(
|
||||
color: isSelected ? AppColor.primary : Colors.grey[300]!,
|
||||
width: isSelected ? 2 : 1,
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Quick actions
|
||||
Row(
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_potSelection = [true, true, true, true, true];
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.select_all, size: 18),
|
||||
label: const Text('Pilih Semua'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_potSelection = [false, false, false, false, false];
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.clear, size: 18),
|
||||
label: const Text('Hapus Semua'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPumpSection() {
|
||||
return Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.water, color: Colors.blue[700]),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Pompa',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColor.textDark,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
SwitchListTile(
|
||||
value: _pompaAir,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_pompaAir = value;
|
||||
});
|
||||
},
|
||||
title: const Text('Pompa Air'),
|
||||
subtitle: const Text('Aktifkan pompa air saat penyiraman'),
|
||||
activeColor: AppColor.primary,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
|
||||
SwitchListTile(
|
||||
value: _pompaPupuk,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_pompaPupuk = value;
|
||||
});
|
||||
},
|
||||
title: const Text('Pompa Pupuk'),
|
||||
subtitle: const Text('Aktifkan pompa pupuk saat penyiraman'),
|
||||
activeColor: AppColor.primary,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusSection() {
|
||||
return Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: SwitchListTile(
|
||||
value: _aktif,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_aktif = value;
|
||||
});
|
||||
},
|
||||
title: const Text(
|
||||
'Status Threshold',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text(_aktif ? 'Aktif' : 'Nonaktif'),
|
||||
activeColor: AppColor.primary,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSaveButton() {
|
||||
return ElevatedButton(
|
||||
onPressed: _isLoading ? null : _save,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColor.primary,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
elevation: 2,
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
_isEditMode ? 'Update Threshold' : 'Simpan Threshold',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,438 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../models/threshold_model.dart';
|
||||
import '../services/threshold_service.dart';
|
||||
import '../theme/app_color.dart';
|
||||
import 'threshold_form_page.dart';
|
||||
|
||||
/// Screen untuk manage multiple threshold sensor
|
||||
class ThresholdManagementPage extends StatefulWidget {
|
||||
const ThresholdManagementPage({super.key});
|
||||
|
||||
@override
|
||||
State<ThresholdManagementPage> createState() =>
|
||||
_ThresholdManagementPageState();
|
||||
}
|
||||
|
||||
class _ThresholdManagementPageState extends State<ThresholdManagementPage> {
|
||||
List<ThresholdModel> _thresholdList = [];
|
||||
bool _isLoading = true;
|
||||
bool _sensorModeEnabled = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadThreshold();
|
||||
}
|
||||
|
||||
Future<void> _loadThreshold() async {
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
final thresholdList = await ThresholdService.getAllThreshold();
|
||||
setState(() {
|
||||
_thresholdList = thresholdList;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() => _isLoading = false);
|
||||
_showError('Gagal memuat threshold: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleThresholdAktif(ThresholdModel threshold) async {
|
||||
final newStatus = !threshold.aktif;
|
||||
final success = await ThresholdService.toggleThresholdAktif(
|
||||
threshold.id,
|
||||
newStatus,
|
||||
);
|
||||
|
||||
if (success) {
|
||||
_loadThreshold();
|
||||
_showSuccess(
|
||||
newStatus ? 'Threshold diaktifkan' : 'Threshold dinonaktifkan',
|
||||
);
|
||||
} else {
|
||||
_showError('Gagal mengubah status threshold');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteThreshold(ThresholdModel threshold) async {
|
||||
final confirmed = await _showConfirmDialog(
|
||||
'Hapus Threshold',
|
||||
'Yakin ingin menghapus ${threshold.id}?',
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
final success = await ThresholdService.deleteThreshold(threshold.id);
|
||||
if (success) {
|
||||
_loadThreshold();
|
||||
_showSuccess('Threshold berhasil dihapus');
|
||||
} else {
|
||||
_showError('Gagal menghapus threshold');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _duplicateThreshold(ThresholdModel threshold) async {
|
||||
final newId = await ThresholdService.duplicateThreshold(threshold);
|
||||
if (newId != null) {
|
||||
_loadThreshold();
|
||||
_showSuccess('Threshold berhasil diduplikasi ke $newId');
|
||||
} else {
|
||||
_showError('Gagal menduplikasi threshold');
|
||||
}
|
||||
}
|
||||
|
||||
void _navigateToForm({ThresholdModel? threshold}) async {
|
||||
final result = await Navigator.push<bool>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ThresholdFormPage(threshold: threshold),
|
||||
),
|
||||
);
|
||||
|
||||
if (result == true) {
|
||||
_loadThreshold();
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool?> _showConfirmDialog(String title, String message) {
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(title),
|
||||
content: Text(message),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Batal'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Hapus', style: TextStyle(color: Colors.red)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showSuccess(String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: Colors.green,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showError(String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: Colors.red,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColor.background,
|
||||
appBar: AppBar(
|
||||
backgroundColor: AppColor.primary,
|
||||
foregroundColor: Colors.white,
|
||||
title: const Text('Kontrol Sensor'),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _loadThreshold,
|
||||
tooltip: 'Refresh',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: RefreshIndicator(
|
||||
onRefresh: _loadThreshold,
|
||||
child: _thresholdList.isEmpty
|
||||
? _buildEmptyState()
|
||||
: _buildThresholdList(),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () => _navigateToForm(),
|
||||
backgroundColor: AppColor.primary,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Tambah Threshold'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.sensors_off, size: 80, color: Colors.grey[400]),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Belum ada threshold',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
color: Colors.grey[600],
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Tap tombol + untuk menambah',
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildThresholdList() {
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: _thresholdList.length,
|
||||
itemBuilder: (context, index) {
|
||||
final threshold = _thresholdList[index];
|
||||
return _buildThresholdCard(threshold);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildThresholdCard(ThresholdModel threshold) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: InkWell(
|
||||
onTap: () => _navigateToForm(threshold: threshold),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header: ID + Toggle
|
||||
Row(
|
||||
children: [
|
||||
// Icon
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
threshold.aktif
|
||||
? AppColors.primaryGreen.withOpacity(0.1)
|
||||
: Colors.grey.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.sensors,
|
||||
color: threshold.aktif ? AppColors.primaryGreen : Colors.grey,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// ID + Status
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
threshold.id.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColor.textDark,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
threshold.aktif ? 'Aktif' : 'Nonaktif',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: threshold.aktif ? Colors.green : Colors.grey,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Toggle Switch
|
||||
Switch(
|
||||
value: threshold.aktif,
|
||||
onChanged: (_) => _toggleThresholdAktif(threshold),
|
||||
activeColor: AppColor.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const Divider(height: 24),
|
||||
|
||||
// Range kelembaban
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.water_drop, size: 18, color: Colors.blue[700]),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Range: ${threshold.rangeString}',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColor.textDark,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Mode
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
threshold.smartMode ? Icons.psychology : Icons.timer,
|
||||
size: 18,
|
||||
color: Colors.orange[700],
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
threshold.modeString,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
if (!threshold.smartMode) ...[
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'(${threshold.durasiMenit} menit)',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Pot aktif
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.local_florist, size: 18, color: Colors.green[700]),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Pot: ${threshold.potAktifString}',
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Pompa
|
||||
Row(
|
||||
children: [
|
||||
if (threshold.pompaAir)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue[50],
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: Colors.blue[300]!),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.water, size: 14, color: Colors.blue[700]),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'Air',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.blue[900],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (threshold.pompaAir && threshold.pompaPupuk)
|
||||
const SizedBox(width: 8),
|
||||
if (threshold.pompaPupuk)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green[50],
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: Colors.green[300]!),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.eco, size: 14, color: Colors.green[700]),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'Pupuk',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.green[900],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Action buttons
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: () => _navigateToForm(threshold: threshold),
|
||||
icon: const Icon(Icons.edit, size: 18),
|
||||
label: const Text('Edit'),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.blue,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
TextButton.icon(
|
||||
onPressed: () => _duplicateThreshold(threshold),
|
||||
icon: const Icon(Icons.copy, size: 18),
|
||||
label: const Text('Duplikat'),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.orange,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
TextButton.icon(
|
||||
onPressed: () => _deleteThreshold(threshold),
|
||||
icon: const Icon(Icons.delete, size: 18),
|
||||
label: const Text('Hapus'),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.red,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
import 'package:firebase_database/firebase_database.dart';
|
||||
import '../models/threshold_model.dart';
|
||||
|
||||
class ThresholdService {
|
||||
static const String kontrolPath = 'kontrol_1';
|
||||
static final DatabaseReference _dbRef = FirebaseDatabase.instance.ref();
|
||||
|
||||
// Get semua threshold dari Firebase
|
||||
static Future<List<ThresholdModel>> getAllThreshold() async {
|
||||
try {
|
||||
final snapshot = await _dbRef.child(kontrolPath).get();
|
||||
|
||||
if (!snapshot.exists) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final data = snapshot.value as Map<dynamic, dynamic>?;
|
||||
if (data == null) return [];
|
||||
|
||||
// Filter hanya yang key-nya dimulai dengan 'threshold_'
|
||||
final thresholdList = <ThresholdModel>[];
|
||||
data.forEach((key, value) {
|
||||
if (key.toString().startsWith('threshold_') && value is Map) {
|
||||
try {
|
||||
final threshold = ThresholdModel.fromJson(
|
||||
key.toString(),
|
||||
Map<dynamic, dynamic>.from(value),
|
||||
);
|
||||
thresholdList.add(threshold);
|
||||
} catch (e) {
|
||||
print('Error parsing threshold $key: $e');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Sort by ID (threshold_1, threshold_2, ...)
|
||||
thresholdList.sort((a, b) {
|
||||
final numA = int.tryParse(a.id.replaceAll('threshold_', '')) ?? 0;
|
||||
final numB = int.tryParse(b.id.replaceAll('threshold_', '')) ?? 0;
|
||||
return numA.compareTo(numB);
|
||||
});
|
||||
|
||||
return thresholdList;
|
||||
} catch (e) {
|
||||
print('Error getting all threshold: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Save threshold (create atau update)
|
||||
static Future<bool> saveThreshold(ThresholdModel threshold) async {
|
||||
try {
|
||||
await _dbRef
|
||||
.child(kontrolPath)
|
||||
.child(threshold.id)
|
||||
.set(threshold.toJson());
|
||||
return true;
|
||||
} catch (e) {
|
||||
print('Error saving threshold: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete threshold
|
||||
static Future<bool> deleteThreshold(String id) async {
|
||||
try {
|
||||
await _dbRef.child(kontrolPath).child(id).remove();
|
||||
return true;
|
||||
} catch (e) {
|
||||
print('Error deleting threshold: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle threshold aktif/nonaktif
|
||||
static Future<bool> toggleThresholdAktif(String id, bool aktif) async {
|
||||
try {
|
||||
await _dbRef.child(kontrolPath).child(id).child('aktif').set(aktif);
|
||||
return true;
|
||||
} catch (e) {
|
||||
print('Error toggling threshold: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Duplicate threshold dengan ID baru
|
||||
static Future<String?> duplicateThreshold(ThresholdModel threshold) async {
|
||||
try {
|
||||
final allThresholds = await getAllThreshold();
|
||||
|
||||
// Cari ID tertinggi
|
||||
int maxId = 0;
|
||||
for (var t in allThresholds) {
|
||||
final num = int.tryParse(t.id.replaceAll('threshold_', '')) ?? 0;
|
||||
if (num > maxId) maxId = num;
|
||||
}
|
||||
|
||||
final newId = 'threshold_${maxId + 1}';
|
||||
final newThreshold = threshold.copyWith(
|
||||
id: newId,
|
||||
aktif: false, // Duplikat dimulai dengan nonaktif
|
||||
);
|
||||
|
||||
final success = await saveThreshold(newThreshold);
|
||||
return success ? newId : null;
|
||||
} catch (e) {
|
||||
print('Error duplicating threshold: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Get single threshold by ID
|
||||
static Future<ThresholdModel?> getThreshold(String id) async {
|
||||
try {
|
||||
final snapshot = await _dbRef.child(kontrolPath).child(id).get();
|
||||
|
||||
if (!snapshot.exists) return null;
|
||||
|
||||
final data = snapshot.value as Map<dynamic, dynamic>?;
|
||||
if (data == null) return null;
|
||||
|
||||
return ThresholdModel.fromJson(id, data);
|
||||
} catch (e) {
|
||||
print('Error getting threshold: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Watch semua threshold (real-time stream)
|
||||
static Stream<List<ThresholdModel>> watchAllThreshold() {
|
||||
return _dbRef.child(kontrolPath).onValue.map((event) {
|
||||
if (!event.snapshot.exists) return [];
|
||||
|
||||
final data = event.snapshot.value as Map<dynamic, dynamic>?;
|
||||
if (data == null) return [];
|
||||
|
||||
final thresholdList = <ThresholdModel>[];
|
||||
data.forEach((key, value) {
|
||||
if (key.toString().startsWith('threshold_') && value is Map) {
|
||||
try {
|
||||
final threshold = ThresholdModel.fromJson(
|
||||
key.toString(),
|
||||
Map<dynamic, dynamic>.from(value),
|
||||
);
|
||||
thresholdList.add(threshold);
|
||||
} catch (e) {
|
||||
print('Error parsing threshold $key: $e');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Sort by ID
|
||||
thresholdList.sort((a, b) {
|
||||
final numA = int.tryParse(a.id.replaceAll('threshold_', '')) ?? 0;
|
||||
final numB = int.tryParse(b.id.replaceAll('threshold_', '')) ?? 0;
|
||||
return numA.compareTo(numB);
|
||||
});
|
||||
|
||||
return thresholdList;
|
||||
});
|
||||
}
|
||||
|
||||
// Cek apakah ada range yang overlap dengan threshold lain
|
||||
static Future<bool> isRangeOverlap(
|
||||
String currentId,
|
||||
int batasBawah,
|
||||
int batasAtas,
|
||||
List<int> potAktif,
|
||||
) async {
|
||||
try {
|
||||
final allThresholds = await getAllThreshold();
|
||||
|
||||
for (var threshold in allThresholds) {
|
||||
// Skip threshold yang sama
|
||||
if (threshold.id == currentId) continue;
|
||||
|
||||
// Cek apakah ada pot yang sama
|
||||
final hasSamePot = threshold.potAktif.any((pot) => potAktif.contains(pot));
|
||||
if (!hasSamePot) continue;
|
||||
|
||||
// Cek apakah range overlap
|
||||
final rangeOverlap = !(batasAtas < threshold.batasBawah ||
|
||||
batasBawah > threshold.batasAtas);
|
||||
|
||||
if (rangeOverlap) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (e) {
|
||||
print('Error checking range overlap: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Get next available threshold ID
|
||||
static Future<String> getNextThresholdId() async {
|
||||
try {
|
||||
final allThresholds = await getAllThreshold();
|
||||
|
||||
if (allThresholds.isEmpty) return 'threshold_1';
|
||||
|
||||
// Cari ID tertinggi
|
||||
int maxId = 0;
|
||||
for (var t in allThresholds) {
|
||||
final num = int.tryParse(t.id.replaceAll('threshold_', '')) ?? 0;
|
||||
if (num > maxId) maxId = num;
|
||||
}
|
||||
|
||||
return 'threshold_${maxId + 1}';
|
||||
} catch (e) {
|
||||
print('Error getting next threshold ID: $e');
|
||||
return 'threshold_1';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
/**
|
||||
* Script untuk setup Firebase Threshold System
|
||||
* Run: node setup-firebase-threshold.js
|
||||
*/
|
||||
|
||||
require('dotenv').config();
|
||||
const admin = require('firebase-admin');
|
||||
|
||||
// Initialize Firebase Admin
|
||||
try {
|
||||
admin.initializeApp({
|
||||
credential: admin.credential.cert({
|
||||
projectId: process.env.FIREBASE_PROJECT_ID,
|
||||
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
|
||||
privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n'),
|
||||
}),
|
||||
databaseURL: process.env.FIREBASE_DATABASE_URL,
|
||||
});
|
||||
console.log('✅ Firebase Admin initialized');
|
||||
} catch (error) {
|
||||
console.error('❌ Firebase initialization failed:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const db = admin.database();
|
||||
|
||||
// Data threshold yang akan di-setup
|
||||
// Akan merge dengan data yang sudah ada (jadwal_1, jadwal_2, dll)
|
||||
const thresholdData = {
|
||||
threshold_1: {
|
||||
aktif: true,
|
||||
batas_bawah: 30, // Kelembaban minimum 30%
|
||||
batas_atas: 70, // Target kelembaban 70% (untuk smart mode)
|
||||
durasi: 600, // 10 menit (untuk fixed mode)
|
||||
smart_mode: true, // Smart mode: siram sampai mencapai batas_atas
|
||||
pot_aktif: [1, 2, 3], // Pot 1, 2, 3 pakai threshold ini
|
||||
pompa_air: true,
|
||||
pompa_pupuk: false
|
||||
},
|
||||
|
||||
threshold_2: {
|
||||
aktif: true,
|
||||
batas_bawah: 40, // Kelembaban minimum 40%
|
||||
batas_atas: 80, // Target kelembaban 80%
|
||||
durasi: 300, // 5 menit
|
||||
smart_mode: false, // Fixed mode: siram selama durasi saja
|
||||
pot_aktif: [4, 5], // Pot 4, 5 pakai threshold ini
|
||||
pompa_air: true,
|
||||
pompa_pupuk: true
|
||||
},
|
||||
|
||||
threshold_3: {
|
||||
aktif: false,
|
||||
batas_bawah: 25, // Untuk tanaman yang butuh kelembaban lebih tinggi
|
||||
batas_atas: 75,
|
||||
durasi: 480, // 8 menit
|
||||
smart_mode: true,
|
||||
pot_aktif: [3], // Hanya pot 3
|
||||
pompa_air: true,
|
||||
pompa_pupuk: false
|
||||
}
|
||||
};
|
||||
|
||||
async function setupThreshold() {
|
||||
try {
|
||||
console.log('\n📦 Starting Threshold System setup...');
|
||||
console.log('📍 Target path: /kontrol_1\n');
|
||||
|
||||
// Get existing data di kontrol_1
|
||||
const existingSnapshot = await db.ref('kontrol_1').get();
|
||||
const existingData = existingSnapshot.val() || {};
|
||||
|
||||
console.log('📋 Existing data keys:', Object.keys(existingData).join(', '));
|
||||
|
||||
// Merge threshold baru dengan data yang sudah ada
|
||||
const mergedData = {
|
||||
...existingData,
|
||||
...thresholdData
|
||||
};
|
||||
|
||||
// Push ke Firebase
|
||||
await db.ref('kontrol_1').set(mergedData);
|
||||
|
||||
console.log('\n✅ Threshold system setup completed!');
|
||||
console.log('\n📊 Summary:');
|
||||
console.log(` Total keys in kontrol_1: ${Object.keys(mergedData).length}`);
|
||||
console.log(` Jadwal nodes: ${Object.keys(mergedData).filter(k => k.startsWith('jadwal_')).length}`);
|
||||
console.log(` Threshold nodes: ${Object.keys(mergedData).filter(k => k.startsWith('threshold_')).length}`);
|
||||
|
||||
console.log('\n🎯 Threshold details:');
|
||||
Object.keys(thresholdData).forEach(key => {
|
||||
const t = thresholdData[key];
|
||||
console.log(` ${key}: ${t.aktif ? '✅' : '❌'} | ${t.batas_bawah}-${t.batas_atas}% | ${t.smart_mode ? 'Smart' : 'Fixed'} | Pot ${t.pot_aktif.join(',')}`);
|
||||
});
|
||||
|
||||
console.log('\n✨ Firebase updated successfully!');
|
||||
console.log('🔗 Check: https://console.firebase.google.com/u/0/project/YOUR_PROJECT/database');
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('\n❌ Setup failed:', error.message);
|
||||
console.error('Stack:', error.stack);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the setup
|
||||
setupThreshold();
|
||||
|
|
@ -787,7 +787,7 @@ setTimeout(async () => {
|
|||
let sensorCheckCounter = 0;
|
||||
|
||||
async function setupSensorMonitoring() {
|
||||
console.log('✅ Sensor Mode monitoring started');
|
||||
console.log('✅ Sensor Mode (Threshold System) monitoring started');
|
||||
|
||||
db.ref('data').on('value', async (snapshot) => {
|
||||
sensorCheckCounter++;
|
||||
|
|
@ -802,65 +802,98 @@ async function setupSensorMonitoring() {
|
|||
const configSnapshot = await fetchWithTimeout(db.ref(FIREBASE_PATHS.kontrol), 10000);
|
||||
const kontrolConfig = configSnapshot.val();
|
||||
|
||||
// Log sensor check (verbose hanya setiap 10 kali)
|
||||
if (sensorCheckCounter % 10 === 0) {
|
||||
console.log(`\n🌡️ SENSOR CHECK #${sensorCheckCounter} | Mode Otomatis: ${kontrolConfig?.otomatis ? '✅' : '❌'}`);
|
||||
}
|
||||
|
||||
if (!kontrolConfig || !kontrolConfig.otomatis) {
|
||||
// Sensor mode disabled
|
||||
if (!kontrolConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
const batasBawah = kontrolConfig.batas_bawah || 40;
|
||||
const batasAtas = kontrolConfig.batas_atas || 100;
|
||||
const durasiSensor = kontrolConfig.durasi_sensor || 60;
|
||||
const modeSensor = kontrolConfig.mode_sensor || 'fixed'; // 'fixed' or 'smart'
|
||||
// Detect all threshold_* nodes
|
||||
const allThresholds = Object.keys(kontrolConfig).filter(key => key.startsWith('threshold_'));
|
||||
|
||||
// Check each pot
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
const soilKey = `soil_${i}`;
|
||||
const soilValue = parseInt(sensorData[soilKey]) || 0;
|
||||
// Log sensor check (verbose hanya setiap 10 kali)
|
||||
if (sensorCheckCounter % 10 === 0) {
|
||||
console.log(`\n🌡️ SENSOR CHECK #${sensorCheckCounter} | Total Thresholds: ${allThresholds.length}`);
|
||||
}
|
||||
|
||||
if (soilValue < batasBawah) {
|
||||
const potKey = `pot_${i}`;
|
||||
const lastTime = lastWateringTime[potKey];
|
||||
if (allThresholds.length === 0) {
|
||||
// No thresholds configured
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce: minimum 2 menit antar penyiraman
|
||||
if (lastTime && Date.now() - lastTime < config.worker.sensorDebounce) {
|
||||
const remainingSeconds = Math.ceil((config.worker.sensorDebounce - (Date.now() - lastTime)) / 1000);
|
||||
console.log(`⏳ POT ${i}: Cooldown active (${remainingSeconds}s remaining)`);
|
||||
continue;
|
||||
}
|
||||
// Process each threshold
|
||||
for (const thresholdKey of allThresholds) {
|
||||
const threshold = kontrolConfig[thresholdKey];
|
||||
|
||||
// Skip if threshold is not active or invalid
|
||||
if (!threshold || !threshold.aktif) continue;
|
||||
|
||||
console.log(`\n🌡️ SENSOR TRIGGERED: POT ${i}`);
|
||||
console.log(` Soil moisture: ${soilValue}% < ${batasBawah}%`);
|
||||
console.log(` Mode: ${modeSensor}, Duration: ${durasiSensor}s`);
|
||||
const batasBawah = threshold.batas_bawah || 30;
|
||||
const batasAtas = threshold.batas_atas || 70;
|
||||
const durasi = threshold.durasi || 600;
|
||||
const smartMode = threshold.smart_mode === true;
|
||||
const potAktif = threshold.pot_aktif || [];
|
||||
const pompaAir = threshold.pompa_air === true;
|
||||
const pompaPupuk = threshold.pompa_pupuk === true;
|
||||
|
||||
const jobId = `sensor-pot-${i}-${Date.now()}`;
|
||||
await wateringQueue.add(
|
||||
`sensor-pot-${i}`,
|
||||
{
|
||||
type: 'sensor_threshold',
|
||||
potNumbers: [i],
|
||||
pompaAir: true,
|
||||
pompaPupuk: false, // No pupuk for sensor mode
|
||||
duration: durasiSensor,
|
||||
scheduleId: jobId,
|
||||
sensorData: { soilValue, batasBawah, batasAtas, mode: modeSensor },
|
||||
},
|
||||
{
|
||||
jobId,
|
||||
removeOnComplete: true,
|
||||
priority: 1, // Higher priority for sensor-triggered
|
||||
// Check each pot in this threshold
|
||||
for (const potNumber of potAktif) {
|
||||
if (potNumber < 1 || potNumber > 5) continue;
|
||||
|
||||
const soilKey = `soil_${potNumber}`;
|
||||
const soilValue = parseInt(sensorData[soilKey]) || 0;
|
||||
|
||||
// Check if below threshold
|
||||
if (soilValue < batasBawah) {
|
||||
const potKey = `pot_${potNumber}`;
|
||||
const lastTime = lastWateringTime[potKey];
|
||||
|
||||
// Debounce: minimum 2 menit antar penyiraman
|
||||
if (lastTime && Date.now() - lastTime < config.worker.sensorDebounce) {
|
||||
const remainingSeconds = Math.ceil((config.worker.sensorDebounce - (Date.now() - lastTime)) / 1000);
|
||||
if (sensorCheckCounter % 10 === 0) {
|
||||
console.log(`⏳ ${thresholdKey.toUpperCase()} - POT ${potNumber}: Cooldown (${remainingSeconds}s)`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
);
|
||||
|
||||
console.log(` 📌 Added to queue: ${jobId}`);
|
||||
console.log(`\n🌡️ THRESHOLD TRIGGERED: ${thresholdKey.toUpperCase()}`);
|
||||
console.log(` POT ${potNumber}: ${soilValue}% < ${batasBawah}%`);
|
||||
console.log(` Mode: ${smartMode ? 'Smart' : 'Fixed'}, Target: ${smartMode ? batasAtas + '%' : durasi + 's'}`);
|
||||
console.log(` Pumps: Air=${pompaAir}, Pupuk=${pompaPupuk}`);
|
||||
|
||||
const jobId = `${thresholdKey}-pot-${potNumber}-${Date.now()}`;
|
||||
await wateringQueue.add(
|
||||
`threshold-pot-${potNumber}`,
|
||||
{
|
||||
type: 'sensor_threshold',
|
||||
potNumbers: [potNumber],
|
||||
pompaAir: pompaAir,
|
||||
pompaPupuk: pompaPupuk,
|
||||
duration: durasi,
|
||||
scheduleId: jobId,
|
||||
thresholdId: thresholdKey,
|
||||
sensorData: {
|
||||
soilValue,
|
||||
batasBawah,
|
||||
batasAtas,
|
||||
mode: smartMode ? 'smart' : 'fixed'
|
||||
},
|
||||
},
|
||||
{
|
||||
jobId,
|
||||
removeOnComplete: true,
|
||||
priority: 1, // Higher priority for sensor-triggered
|
||||
}
|
||||
);
|
||||
|
||||
console.log(` 📌 Added to queue: ${jobId}`);
|
||||
|
||||
// Update last watering time to prevent rapid re-triggering
|
||||
lastWateringTime[potKey] = Date.now();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error in sensor monitoring:', error.message);
|
||||
console.error('❌ Error in threshold monitoring:', error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue