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:
Wizznu 2026-02-16 12:30:13 +07:00
parent abef985a11
commit 1224c8e867
8 changed files with 1564 additions and 68 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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