TKK_E32232198/lib/page2/Controlsystem.dart

1299 lines
39 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import '../http/node_red_client.dart';
import '../auth/auth_service.dart';
import '../page_auth/login.dart';
import 'History.dart';
Future<void> _initFCM() async {
await FirebaseMessaging.instance.requestPermission(
alert: true,
badge: true,
sound: true,
);
final token =
await FirebaseMessaging.instance.getToken();
debugPrint('====================');
debugPrint('FCM TOKEN');
debugPrint(token);
debugPrint('====================');
}
class ControlSystem extends StatefulWidget {
const ControlSystem({super.key});
@override
State<ControlSystem> createState() => _ControlSystemState();
}
class _ControlSystemState extends State<ControlSystem> {
final FlutterLocalNotificationsPlugin notificationsPlugin =
FlutterLocalNotificationsPlugin();
int _lastScheduleOnKey = -1;
int _lastScheduleOffKey = -1;
int _lastAmmoniaAlertMs = 0;
int _lastMurkyAlertMs = 0;
int _lastLampAlertMs = 0;
static const _alertCooldownMs = 1 * 60 * 1000;
static const _lampAlertCooldownMs = 5 * 1000;
StreamSubscription<RemoteMessage>? _fcmOnMessageSub;
StreamSubscription<RemoteMessage>? _fcmOnOpenSub;
late NodeRedClient nodeRedClient;
// Sensor values (update setiap 2 detik)
String _ammoniaValue = '0';
String _kekeruhanValue = '0';
String _waterLevelValue = '0';
// Amonia automation
double _ammoniaThreshold = 0.1;
// Murky water automation
double _murkyThreshold = 0.0;
// Timer light
TimeOfDay _timerStart = const TimeOfDay(hour: 12, minute: 0);
TimeOfDay _timerEnd = const TimeOfDay(hour: 16, minute: 0);
bool _timerLightEnabled = false;
bool _userModifiedTimer = false;
// Volume level (cm) saat pompa aktif mengganti air
int _volumeLevel = 90;
int _aquariumHeight = 40;
int _lastLocalHistorySavedMs = 0;
Future<void> _initNotifications() async {
const android = AndroidInitializationSettings('@mipmap/ic_launcher');
const settings = InitializationSettings(android: android);
await notificationsPlugin.initialize(settings);
final androidPlugin = notificationsPlugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>();
await androidPlugin?.createNotificationChannel(
const AndroidNotificationChannel(
'aquamon_channel',
'Aquamon Alerts',
description: 'Notifikasi monitoring aquascape',
importance: Importance.max,
),
);
await androidPlugin?.requestNotificationsPermission();
}
@override
void initState() {
super.initState();
_initialize();
}
Future<void> _initialize() async {
await _initNotifications();
await _initFCM();
_listenFCM();
if (!mounted) return;
_setupNodeRed();
await _loadSavedSettings();
}
Future<void> _loadSavedSettings() async {
try {
final ammoniaTh = await AuthService.getAmmoniaThreshold();
final murkyTh = await AuthService.getMurkyThreshold();
final timer = await AuthService.getTimerLight();
final volumeLevel = await AuthService.getVolumeLevel();
final aquariumHeight = await AuthService.getAquariumHeight();
if (!mounted) return;
setState(() {
_ammoniaThreshold = ammoniaTh;
_murkyThreshold = murkyTh.roundToDouble();
if (!_userModifiedTimer) {
_timerLightEnabled = timer['enabled'] as bool? ?? false;
}
_timerStart = _parseTime(timer['start'] as String? ?? '12:00');
_timerEnd = _parseTime(timer['end'] as String? ?? '16:00');
_volumeLevel = volumeLevel;
_aquariumHeight = aquariumHeight;
});
nodeRedClient.setMaxParameter(ammoniaTh);
nodeRedClient.setMurkyThreshold(murkyTh);
nodeRedClient.setVolumeLevel(volumeLevel);
nodeRedClient.setAquariumHeight(aquariumHeight);
await _sendTimerSchedule();
if (_timerLightEnabled) {
await nodeRedClient.setLampTimerStatus(true);
}
} catch (e) {
debugPrint('Gagal load settings: $e');
}
}
TimeOfDay _parseTime(String s) {
final parts = s.split(':');
if (parts.length >= 2) {
final h = int.tryParse(parts[0]) ?? 12;
final m = int.tryParse(parts[1]) ?? 0;
return TimeOfDay(hour: h.clamp(0, 23), minute: m.clamp(0, 59));
}
return const TimeOfDay(hour: 12, minute: 0);
}
String _formatTime(TimeOfDay t) =>
'${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}';
int _scheduleKey(DateTime now, int minuteOfDay) =>
(now.year * 10000 + now.month * 100 + now.day) * 1440 + minuteOfDay;
void _listenFCM() {
_fcmOnMessageSub?.cancel();
_fcmOnOpenSub?.cancel();
_fcmOnMessageSub = FirebaseMessaging.onMessage.listen((message) async {
if (!mounted) return;
debugPrint('=== FCM RECEIVED ===');
debugPrint('Title: ${message.notification?.title}');
debugPrint('Body: ${message.notification?.body}');
final title = message.notification?.title ?? 'Aquamon';
final body = message.notification?.body ?? '';
await _showNotification(title, body);
});
_fcmOnOpenSub = FirebaseMessaging.onMessageOpenedApp.listen((message) {
debugPrint(
'Notification Clicked: ${message.notification?.title}',
);
});
}
bool _shouldSkipDuplicateAlert(String title, String body) {
final text = '${title.toLowerCase()} ${body.toLowerCase()}';
final now = DateTime.now().millisecondsSinceEpoch;
if (text.contains('amonia') || text.contains('ammonia')) {
if (now - _lastAmmoniaAlertMs < _alertCooldownMs) return true;
_lastAmmoniaAlertMs = now;
return false;
}
if (text.contains('keruh') ||
text.contains('murky') ||
text.contains('kekeruhan') ||
text.contains('turbidity')) {
if (now - _lastMurkyAlertMs < _alertCooldownMs) return true;
_lastMurkyAlertMs = now;
return false;
}
if (text.contains('lampu') || text.contains('lamp')) {
if (now - _lastLampAlertMs < _lampAlertCooldownMs) return true;
_lastLampAlertMs = now;
return false;
}
return false;
}
void _maybeNotifyLampSchedule(DateTime now) {
final startMin = _timerStart.hour * 60 + _timerStart.minute;
final endMin = _timerEnd.hour * 60 + _timerEnd.minute;
final nowMin = now.hour * 60 + now.minute;
final onKey = _scheduleKey(now, startMin);
if (nowMin == startMin && _lastScheduleOnKey != onKey) {
_lastScheduleOnKey = onKey;
_showNotification('Lampu Aquascape', 'Lampu menyala (jadwal)');
}
final offKey = _scheduleKey(now, endMin);
if (nowMin == endMin && _lastScheduleOffKey != offKey) {
_lastScheduleOffKey = offKey;
_showNotification('Lampu Aquascape', 'Lampu mati (jadwal)');
}
}
String _truncateAmmoniaDecimal(String value) {
final parsed = double.tryParse(value);
if (parsed == null) return value;
final truncated = (parsed * 100).truncateToDouble() / 100;
if (truncated == truncated.truncateToDouble()) {
return truncated.toInt().toString();
}
return truncated.toStringAsFixed(2);
}
String _truncateOneDecimal(String value) {
final parsed = double.tryParse(value);
if (parsed == null) return value;
final truncated = (parsed * 10).truncateToDouble() / 10;
if (truncated == truncated.truncateToDouble()) {
return truncated.toInt().toString();
}
return truncated.toStringAsFixed(1);
}
String _formatThresholdDisplay(double value, {int decimals = 1}) {
if (decimals == 0 || value == value.truncateToDouble()) {
return value.toInt().toString();
}
return value.toStringAsFixed(decimals);
}
Future<void> _showNotification(
String title,
String body,
) async {
if (!mounted || body.isEmpty) return;
if (_shouldSkipDuplicateAlert(title, body)) return;
const androidDetails = AndroidNotificationDetails(
'aquamon_channel',
'Aquamon Alerts',
importance: Importance.max,
priority: Priority.high,
);
await notificationsPlugin.show(
DateTime.now().millisecondsSinceEpoch ~/ 1000,
title,
body,
const NotificationDetails(android: androidDetails),
);
}
void _setupNodeRed() {
nodeRedClient = NodeRedClient();
nodeRedClient.sensorDataStream.listen((data) {
try {
final ammonia = data['amonia']?.toString() ?? data['ph']?.toString() ?? '0';
final kekeruhan = data['kekeruhan']?.toString() ??
data['turbidity']?.toString() ??
data['ntu']?.toString() ??
'0';
final waterLevel = parseSensorWaterLevel(data);
_maybeNotifyLampSchedule(DateTime.now());
setState(() {
_ammoniaValue = _truncateAmmoniaDecimal(ammonia);
_kekeruhanValue = _truncateOneDecimal(kekeruhan);
_waterLevelValue = formatSensorWaterLevel(waterLevel);
});
final nowMs = DateTime.now().millisecondsSinceEpoch;
if (nowMs - _lastLocalHistorySavedMs >= 10000) {
_lastLocalHistorySavedMs = nowMs;
final ammoniaDouble = double.tryParse(ammonia) ?? 0.0;
final kekeruhanDouble = double.tryParse(kekeruhan) ?? 0.0;
final distanceDouble = double.tryParse(waterLevel) ?? 0.0;
AuthService.saveLocalHistorySample(
ammonia: ammoniaDouble,
kekeruhan: kekeruhanDouble,
distance: distanceDouble,
lampAuto: _timerLightEnabled,
);
}
} catch (e) {
debugPrint('Error parsing sensor data: $e');
}
});
// Polling setiap 60 detik
nodeRedClient.startPolling(intervalSeconds: 60);
nodeRedClient.setMaxParameter(_ammoniaThreshold);
}
static const _ammoniaThresholdMax = 100.0;
Future<void> _persistAmmoniaThreshold(double value) async {
final normalized = double.parse(
value.clamp(0.0, _ammoniaThresholdMax).toStringAsFixed(1),
);
setState(() => _ammoniaThreshold = normalized);
await AuthService.saveAmmoniaThreshold(normalized);
final sent = await nodeRedClient.setMaxParameter(normalized);
if (!mounted || sent) return;
}
void _updateAmmoniaThreshold(double delta) async {
await _persistAmmoniaThreshold(_ammoniaThreshold + delta);
}
void _updateMurkyThreshold(double delta) async {
setState(() {
_murkyThreshold = (_murkyThreshold + delta).clamp(0.0, 400.0);
});
await AuthService.saveMurkyThreshold(_murkyThreshold);
await nodeRedClient.setMurkyThreshold(_murkyThreshold);
}
Future<void> _inputAmmoniaThreshold() async {
final selected = await showDialog<double>(
context: context,
builder: (dialogContext) => _AmmoniaThresholdInputDialog(
initialValue: _ammoniaThreshold,
),
);
if (!mounted || selected == null) return;
setState(() => _ammoniaThreshold = selected);
await AuthService.saveAmmoniaThreshold(selected);
await nodeRedClient.setMaxParameter(selected);
}
Future<void> _inputMurkyThreshold() async {
final selected = await showDialog<double>(
context: context,
builder: (dialogContext) => _MurkyThresholdInputDialog(
initialValue: _murkyThreshold,
),
);
if (!mounted || selected == null) return;
setState(() => _murkyThreshold = selected.roundToDouble());
await AuthService.saveMurkyThreshold(selected.roundToDouble());
await nodeRedClient.setMurkyThreshold(selected.roundToDouble());
}
void _updateVolumeLevel(int delta) async {
final updated = (_volumeLevel + delta).clamp(0, 50);
if (updated == _volumeLevel) return;
setState(() => _volumeLevel = updated);
await AuthService.saveVolumeLevel(updated);
await nodeRedClient.setVolumeLevel(updated);
}
void _updateAquariumHeight(int delta) async {
final updated = (_aquariumHeight + delta).clamp(0, 100);
if (updated == _aquariumHeight) return;
setState(() => _aquariumHeight = updated);
await AuthService.saveAquariumHeight(updated);
await nodeRedClient.setAquariumHeight(updated);
}
/// Kirim jam mulai/selesai ke Node-RED (`api-schedule`).
Future<void> _sendTimerSchedule() async {
await AuthService.saveTimerLight(
start: _formatTime(_timerStart),
end: _formatTime(_timerEnd),
enabled: _timerLightEnabled,
);
await nodeRedClient.setTimerLight(
startHour: _timerStart.hour,
startMinute: _timerStart.minute,
endHour: _timerEnd.hour,
endMinute: _timerEnd.minute,
enabled: true,
);
}
/// Switch Auto Timer → `api-lampu` (`lampu`: on/off).
Future<void> _toggleTimerLight(bool value) async {
if (_timerLightEnabled == value) return;
_userModifiedTimer = true;
setState(() => _timerLightEnabled = value);
await AuthService.saveTimerLight(
start: _formatTime(_timerStart),
end: _formatTime(_timerEnd),
enabled: value,
);
await nodeRedClient.setLampTimerStatus(value);
}
Future<void> _inputVolumeLevel() async {
final controller = TextEditingController(text: _volumeLevel.toString());
final selected = await showDialog<int>(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Set Ketinggian air (cm)'),
content: TextField(
controller: controller,
keyboardType: TextInputType.number,
autofocus: true,
decoration: const InputDecoration(
labelText: '',
hintText: '0 - 100',
suffixText: 'cm',
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Batal'),
),
ElevatedButton(
onPressed: () {
final parsed = int.tryParse(controller.text.trim());
if (parsed == null) return;
Navigator.pop(context, parsed.clamp(0, 100));
},
child: const Text('Simpan'),
),
],
);
},
);
if (selected == null || selected == _volumeLevel) return;
setState(() => _volumeLevel = selected);
await AuthService.saveVolumeLevel(selected);
await nodeRedClient.setVolumeLevel(selected);
}
Future<void> _inputAquariumHeight() async {
final controller = TextEditingController(text: _aquariumHeight.toString());
final selected = await showDialog<int>(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Set Tinggi Aquarium (cm)'),
content: TextField(
controller: controller,
keyboardType: TextInputType.number,
autofocus: true,
decoration: const InputDecoration(
labelText: 'Tinggi Aquarium',
hintText: '0 - 100',
suffixText: 'cm',
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Batal'),
),
ElevatedButton(
onPressed: () {
final parsed = int.tryParse(controller.text.trim());
if (parsed == null) return;
Navigator.pop(context, parsed.clamp(0, 100));
},
child: const Text('Simpan'),
),
],
);
},
);
if (selected == null || selected == _aquariumHeight) return;
setState(() => _aquariumHeight = selected);
await AuthService.saveAquariumHeight(selected);
await nodeRedClient.setAquariumHeight(selected);
}
Future<void> _inputTimeManual(bool isStart) async {
final current = isStart ? _timerStart : _timerEnd;
final controller = TextEditingController(text: _formatTime(current));
final raw = await showDialog<String>(
context: context,
builder: (context) {
return AlertDialog(
title: Text(isStart ? 'Set Start Time' : 'Set End Time'),
content: TextField(
controller: controller,
keyboardType: TextInputType.datetime,
autofocus: true,
decoration: const InputDecoration(
labelText: 'Waktu',
hintText: 'HH:mm',
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Batal'),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, controller.text.trim()),
child: const Text('Simpan'),
),
],
);
},
);
if (raw == null || raw.isEmpty) return;
final parts = raw.split(':');
if (parts.length != 2) return;
final hour = int.tryParse(parts[0]);
final minute = int.tryParse(parts[1]);
if (hour == null || minute == null) return;
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return;
if (!mounted) return;
setState(() {
if (isStart) {
_timerStart = TimeOfDay(hour: hour, minute: minute);
} else {
_timerEnd = TimeOfDay(hour: hour, minute: minute);
}
});
await _sendTimerSchedule();
}
@override
void dispose() {
_fcmOnMessageSub?.cancel();
_fcmOnOpenSub?.cancel();
nodeRedClient.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xFF3A8DDF),
Color(0xFF6EB6FF),
Colors.white,
],
stops: [0.0, 0.5, 1.0],
),
),
),
Positioned(
top: 0,
left: 0,
right: 0,
child: Container(
height: 110,
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(60),
bottomRight: Radius.circular(60),
),
),
),
),
Positioned(
top: 42,
left: 0,
right: 0,
child: Center(
child: Text(
'MONITORING',
style: TextStyle(
fontSize: 34,
fontWeight: FontWeight.bold,
color: const Color(0xFF2476C7),
shadows: [
Shadow(
offset: const Offset(2, 2),
blurRadius: 4,
color: Colors.black.withOpacity(0.08),
),
],
),
),
),
),
Positioned.fill(
top: 120,
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Card Sensor Value
_buildCard(
title: 'Sensor Value',
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildCircleIndicator(
value: '${_ammoniaValue} mg/L',
label: 'Amonia',
valueColor: const Color(0xFF2476C7),
),
_buildCircleIndicator(
value: '${_kekeruhanValue} NTU',
label: 'Kekeruhan',
valueColor: const Color(0xFF8B6B5C),
),
],
),
const SizedBox(height: 20),
Center(
child: _buildCircleIndicator(
value: '${_waterLevelValue} cm',
label: 'Ketinggian Air',
valueColor: Colors.green,
),
),
],
),
),
// Card Amonia Automation
_buildCard(
title: 'Control Amonia',
child: Column(
children: [
_buildThresholdRow(
icon: Icons.science,
label: 'Batas Amonia (mg/L)',
value: _ammoniaThreshold,
valueDecimals: 1,
onMinus: () => _updateAmmoniaThreshold(-0.1),
onPlus: () => _updateAmmoniaThreshold(0.1),
onTapValue: () => _inputAmmoniaThreshold(),
),
const SizedBox(height: 14),
],
),
),
// Card Murky Water Automation
_buildCard(
title: 'Control Kekeruhan',
child: Column(
children: [
_buildThresholdRow(
icon: Icons.water_drop,
label: 'Batas Kekeruhan',
value: _murkyThreshold,
valueDecimals: 0,
onMinus: () => _updateMurkyThreshold(-1),
onPlus: () => _updateMurkyThreshold(1),
onTapValue: () => _inputMurkyThreshold(),
),
const SizedBox(height: 14),
],
),
),
// Card Water Level Control
_buildCard(
title: 'Control Ketinggian Air',
child: Column(
children: [
_buildVolumeRow(),
const SizedBox(height: 14),
_buildAquariumHeightRow(),
const SizedBox(height: 14),
],
),
),
// Card Timer Light
_buildCard(
title: 'Timer Light',
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildTimeInput(
value: _formatTime(_timerStart),
onTap: () => _inputTimeManual(true),
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: Text(
'-',
style: TextStyle(fontSize: 18),
),
),
_buildTimeInput(
value: _formatTime(_timerEnd),
onTap: () => _inputTimeManual(false),
),
],
),
_buildToggleRow(
icon: Icons.lightbulb,
label: 'Lampu Manual',
value: _timerLightEnabled,
onChanged: _toggleTimerLight,
),
const SizedBox(height: 14),
],
),
),
const SizedBox(height: 24),
],
),
),
),
],
),
bottomNavigationBar: _buildBottomNav(),
);
}
Widget _buildCard({required String title, required Widget child}) {
return Container(
margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 12,
offset: const Offset(0, 6),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
color: Color(0xFF2476C7),
),
),
const SizedBox(height: 14),
child,
],
),
);
}
Widget _buildCircleIndicator({
required String value,
required String label,
required Color valueColor,
}) {
return Column(
children: [
Container(
width: 96,
height: 96,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: const Color(0xFF2476C7), width: 3),
),
child: Center(
child: Text(
value,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
color: valueColor,
),
textAlign: TextAlign.center,
),
),
),
const SizedBox(height: 8),
Text(
label,
style: const TextStyle(
fontSize: 17,
color: Color(0xFF2476C7),
fontWeight: FontWeight.bold,
),
),
],
);
}
Widget _buildThresholdRow({
required IconData icon,
required String label,
required double value,
required VoidCallback onMinus,
required VoidCallback onPlus,
int valueDecimals = 1,
VoidCallback? onTapValue,
}) {
return Row(
children: [
Expanded(
child: Row(
children: [
Icon(icon, color: const Color(0xFF2476C7), size: 28),
const SizedBox(width: 8),
Flexible(
child: Text(
label,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: Color(0xFF2476C7),
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
const SizedBox(width: 6),
SizedBox(
width: 148,
child: Container(
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(14),
),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.remove, color: Color(0xFF2476C7), size: 22),
onPressed: onMinus,
splashRadius: 18,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
),
Expanded(
child: InkWell(
onTap: onTapValue,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 2),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
_formatThresholdDisplay(value, decimals: valueDecimals),
maxLines: 1,
softWrap: false,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF2476C7),
),
),
),
),
),
),
IconButton(
icon: const Icon(Icons.add, color: Color(0xFF2476C7), size: 22),
onPressed: onPlus,
splashRadius: 18,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
),
],
),
),
),
],
);
}
Widget _buildToggleRow({
required IconData icon,
required String label,
required bool value,
required ValueChanged<bool> onChanged,
}) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(icon, color: const Color(0xFF2476C7), size: 28),
const SizedBox(width: 8),
Text(
label,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: Color(0xFF2476C7),
),
),
],
),
Switch(
value: value,
onChanged: onChanged,
thumbColor: const WidgetStatePropertyAll<Color>(Colors.white),
trackColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return Colors.green;
}
return const Color(0xFFD32F2F);
}),
),
],
);
}
Widget _buildVolumeRow() {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
const Icon(Icons.waves, color: Color(0xFF2476C7), size: 28),
const SizedBox(width: 8),
const Text(
'Ketinggian Air',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: Color(0xFF2476C7),
),
),
],
),
Container(
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(14),
),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.remove, color: Color(0xFF2476C7)),
onPressed: () => _updateVolumeLevel(-1),
splashRadius: 20,
),
SizedBox(
width: 72,
child: InkWell(
onTap: _inputVolumeLevel,
borderRadius: BorderRadius.circular(8),
child: Text(
'$_volumeLevel cm',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Color(0xFF2476C7),
),
),
),
),
IconButton(
icon: const Icon(Icons.add, color: Color(0xFF2476C7)),
onPressed: () => _updateVolumeLevel(1),
splashRadius: 20,
),
],
),
),
],
);
}
Widget _buildAquariumHeightRow() {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
const Icon(Icons.height, color: Color(0xFF2476C7), size: 28),
const SizedBox(width: 8),
const Text(
'Tinggi Aquarium',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: Color(0xFF2476C7),
),
),
],
),
Container(
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(14),
),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.remove, color: Color(0xFF2476C7)),
onPressed: () => _updateAquariumHeight(-1),
splashRadius: 20,
),
SizedBox(
width: 57,
child: InkWell(
onTap: _inputAquariumHeight,
borderRadius: BorderRadius.circular(8),
child: Text(
'$_aquariumHeight cm',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Color(0xFF2476C7),
),
),
),
),
IconButton(
icon: const Icon(Icons.add, color: Color(0xFF2476C7)),
onPressed: () => _updateAquariumHeight(1),
splashRadius: 20,
),
],
),
),
],
);
}
Widget _buildTimeInput({required String value, required VoidCallback onTap}) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(12),
),
child: Text(
value,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
),
);
}
Widget _buildBottomNav() {
return Container(
decoration: const BoxDecoration(
color: Color(0xFF2476C7),
borderRadius: BorderRadius.only(
topLeft: Radius.circular(30),
topRight: Radius.circular(30),
),
),
height: 64,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
GestureDetector(
onTap: () {},
child: Container(
width: 48,
height: 48,
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
child: const Icon(
Icons.computer,
color: Color(0xFF2476C7),
size: 28,
),
),
),
GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const HistoryPage()),
);
},
child: const Icon(
Icons.history,
color: Colors.white,
size: 28,
),
),
GestureDetector(
onTap: () async {
await FirebaseAuth.instance.signOut();
await AuthService.logout();
if (mounted) {
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const Login()),
);
}
},
child: const Icon(
Icons.logout,
color: Colors.white,
size: 28,
),
),
],
),
);
}
}
class _AmmoniaThresholdInputDialog extends StatefulWidget {
final double initialValue;
const _AmmoniaThresholdInputDialog({required this.initialValue});
@override
State<_AmmoniaThresholdInputDialog> createState() =>
_AmmoniaThresholdInputDialogState();
}
class _AmmoniaThresholdInputDialogState
extends State<_AmmoniaThresholdInputDialog> {
late final TextEditingController _controller;
@override
void initState() {
super.initState();
_controller = TextEditingController(
text: widget.initialValue.toStringAsFixed(1),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _submit() {
final parsed = double.tryParse(
_controller.text.trim().replaceAll(',', '.'),
);
if (parsed == null) return;
final normalized = double.parse(
parsed.clamp(0.0, 100.0).toStringAsFixed(1),
);
Navigator.pop(context, normalized);
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Set Ambang Batas Amonia'),
content: TextField(
controller: _controller,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
autofocus: true,
decoration: const InputDecoration(
labelText: 'Ambang Batas',
hintText: '0 - 100',
suffixText: 'mg/L',
),
onSubmitted: (_) => _submit(),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Batal'),
),
ElevatedButton(
onPressed: _submit,
child: const Text('Simpan'),
),
],
);
}
}
class _MurkyThresholdInputDialog extends StatefulWidget {
final double initialValue;
const _MurkyThresholdInputDialog({required this.initialValue});
@override
State<_MurkyThresholdInputDialog> createState() =>
_MurkyThresholdInputDialogState();
}
class _MurkyThresholdInputDialogState extends State<_MurkyThresholdInputDialog> {
late final TextEditingController _controller;
@override
void initState() {
super.initState();
_controller = TextEditingController(
text: widget.initialValue.toStringAsFixed(1),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _submit() {
final parsed = int.tryParse(
_controller.text.trim().replaceAll(',', '.').split('.').first,
);
if (parsed == null) return;
final normalized = parsed.clamp(0, 400);
Navigator.pop(context, normalized.toDouble());
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Set Ambang Batas Kekeruhan'),
content: TextField(
controller: _controller,
keyboardType: TextInputType.number,
autofocus: true,
decoration: const InputDecoration(
labelText: 'Ambang Batas',
hintText: '0 - 400',
suffixText: 'NTU',
),
onSubmitted: (_) => _submit(),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Batal'),
),
ElevatedButton(
onPressed: _submit,
child: const Text('Simpan'),
),
],
);
}
}