From 1224c8e8673df0c9cd9b74554639d2e12efc85e4 Mon Sep 17 00:00:00 2001 From: Wizznu Date: Mon, 16 Feb 2026 12:30:13 +0700 Subject: [PATCH] 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 --- lib/models/threshold_model.dart | 116 ++++ lib/screens/kontrol_page.dart | 5 +- lib/screens/pot_selection_page.dart | 30 +- lib/screens/threshold_form_page.dart | 593 +++++++++++++++++++++ lib/screens/threshold_management_page.dart | 438 +++++++++++++++ lib/services/threshold_service.dart | 215 ++++++++ railway-worker/setup-firebase-threshold.js | 108 ++++ railway-worker/worker.js | 127 +++-- 8 files changed, 1564 insertions(+), 68 deletions(-) create mode 100644 lib/models/threshold_model.dart create mode 100644 lib/screens/threshold_form_page.dart create mode 100644 lib/screens/threshold_management_page.dart create mode 100644 lib/services/threshold_service.dart create mode 100644 railway-worker/setup-firebase-threshold.js diff --git a/lib/models/threshold_model.dart b/lib/models/threshold_model.dart new file mode 100644 index 0000000..290b3d2 --- /dev/null +++ b/lib/models/threshold_model.dart @@ -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 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 json) { + List 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 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? 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, + ); + } +} diff --git a/lib/screens/kontrol_page.dart b/lib/screens/kontrol_page.dart index abe8775..658ce45 100644 --- a/lib/screens/kontrol_page.dart +++ b/lib/screens/kontrol_page.dart @@ -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 { Navigator.push( context, MaterialPageRoute( - builder: - (context) => - const PotSelectionPage(mode: 'Sensor'), + builder: (context) => const ThresholdManagementPage(), ), ); }, diff --git a/lib/screens/pot_selection_page.dart b/lib/screens/pot_selection_page.dart index 41eff18..e7d0114 100644 --- a/lib/screens/pot_selection_page.dart +++ b/lib/screens/pot_selection_page.dart @@ -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 { 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(), + ), + ); }, ), ], diff --git a/lib/screens/threshold_form_page.dart b/lib/screens/threshold_form_page.dart new file mode 100644 index 0000000..a6608cd --- /dev/null +++ b/lib/screens/threshold_form_page.dart @@ -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 createState() => _ThresholdFormPageState(); +} + +class _ThresholdFormPageState extends State { + final _formKey = GlobalKey(); + + late String _thresholdId; + late double _batasBawah; // 0-100 + late double _batasAtas; // 0-100 + late int _durasi; // dalam detik + late bool _smartMode; + late List _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 get _selectedPots { + final List 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 _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(Colors.white), + ), + ) + : Text( + _isEditMode ? 'Update Threshold' : 'Simpan Threshold', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ); + } +} diff --git a/lib/screens/threshold_management_page.dart b/lib/screens/threshold_management_page.dart new file mode 100644 index 0000000..3aca2a4 --- /dev/null +++ b/lib/screens/threshold_management_page.dart @@ -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 createState() => + _ThresholdManagementPageState(); +} + +class _ThresholdManagementPageState extends State { + List _thresholdList = []; + bool _isLoading = true; + bool _sensorModeEnabled = false; + + @override + void initState() { + super.initState(); + _loadThreshold(); + } + + Future _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 _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 _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 _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( + context, + MaterialPageRoute( + builder: (context) => ThresholdFormPage(threshold: threshold), + ), + ); + + if (result == true) { + _loadThreshold(); + } + } + + Future _showConfirmDialog(String title, String message) { + return showDialog( + 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, + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/services/threshold_service.dart b/lib/services/threshold_service.dart new file mode 100644 index 0000000..3c025f5 --- /dev/null +++ b/lib/services/threshold_service.dart @@ -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> getAllThreshold() async { + try { + final snapshot = await _dbRef.child(kontrolPath).get(); + + if (!snapshot.exists) { + return []; + } + + final data = snapshot.value as Map?; + if (data == null) return []; + + // Filter hanya yang key-nya dimulai dengan 'threshold_' + final thresholdList = []; + data.forEach((key, value) { + if (key.toString().startsWith('threshold_') && value is Map) { + try { + final threshold = ThresholdModel.fromJson( + key.toString(), + Map.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 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 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 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 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 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?; + 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> watchAllThreshold() { + return _dbRef.child(kontrolPath).onValue.map((event) { + if (!event.snapshot.exists) return []; + + final data = event.snapshot.value as Map?; + if (data == null) return []; + + final thresholdList = []; + data.forEach((key, value) { + if (key.toString().startsWith('threshold_') && value is Map) { + try { + final threshold = ThresholdModel.fromJson( + key.toString(), + Map.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 isRangeOverlap( + String currentId, + int batasBawah, + int batasAtas, + List 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 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'; + } + } +} diff --git a/railway-worker/setup-firebase-threshold.js b/railway-worker/setup-firebase-threshold.js new file mode 100644 index 0000000..437148c --- /dev/null +++ b/railway-worker/setup-firebase-threshold.js @@ -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(); diff --git a/railway-worker/worker.js b/railway-worker/worker.js index 843b0de..ea0ad1c 100644 --- a/railway-worker/worker.js +++ b/railway-worker/worker.js @@ -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); } }); }