1299 lines
39 KiB
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'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|