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 _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 createState() => _ControlSystemState(); } class _ControlSystemState extends State { 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? _fcmOnMessageSub; StreamSubscription? _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 _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 _initialize() async { await _initNotifications(); await _initFCM(); _listenFCM(); if (!mounted) return; _setupNodeRed(); await _loadSavedSettings(); } Future _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 _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 _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 _inputAmmoniaThreshold() async { final selected = await showDialog( context: context, builder: (dialogContext) => _AmmoniaThresholdInputDialog( initialValue: _ammoniaThreshold, ), ); if (!mounted || selected == null) return; setState(() => _ammoniaThreshold = selected); await AuthService.saveAmmoniaThreshold(selected); await nodeRedClient.setMaxParameter(selected); } Future _inputMurkyThreshold() async { final selected = await showDialog( 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 _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 _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 _inputVolumeLevel() async { final controller = TextEditingController(text: _volumeLevel.toString()); final selected = await showDialog( 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 _inputAquariumHeight() async { final controller = TextEditingController(text: _aquariumHeight.toString()); final selected = await showDialog( 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 _inputTimeManual(bool isStart) async { final current = isStart ? _timerStart : _timerEnd; final controller = TextEditingController(text: _formatTime(current)); final raw = await showDialog( 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 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(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'), ), ], ); } }