/
Daydya
/
Balagan
Обзор
Документация
Войти
/
Daydya
/
Balagan
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
App.js
1 667 строк
85 KB
Daydya
Файлы приложения
19 дек 2025, 21:05
19 дек 2025, 21:05
15bdbd0
Код
Авторство
О чём код?
// ПОЛНАЯ АДАПТАЦИЯ ИЗ АРТЕФАКТА - ВСЕ ФУНКЦИИ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { StyleSheet, Text, View, TextInput, TouchableOpacity, ScrollView, ActivityIndicator, Alert, RefreshControl, Modal, Dimensions, Switch, Platform, LayoutAnimation, UIManager, } from 'react-native'; // Включаем LayoutAnimation для Android if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) { UIManager.setLayoutAnimationEnabledExperimental(true); } import { SafeAreaView, SafeAreaProvider } from 'react-native-safe-area-context'; import { StatusBar, setStatusBarBackgroundColor, setStatusBarStyle } from 'expo-status-bar'; import * as NavigationBar from 'expo-navigation-bar'; import { Ionicons } from '@expo/vector-icons'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { LineChart } from 'react-native-chart-kit'; import Paho from 'paho-mqtt'; import { NavigationContainer } from '@react-navigation/native'; import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; import AlertsScreen from './src/screens/AlertsScreen'; const Tab = createBottomTabNavigator(); const { width: SCREEN_WIDTH } = Dimensions.get('window'); // MQTT Configuration const MQTT_CONFIG = { broker: 'broker.emqx.io', port: 8083, path: '/mqtt', clientIdPrefix: 'greenhouse_app_', }; // Map MQTT data format to app format const mapMQTTData = (mqttPayload) => { try { const data = typeof mqttPayload === 'string' ? JSON.parse(mqttPayload) : mqttPayload; return { // Основные датчики temperature: data.temperature || 0, humidity: data.humidity || 0, outdoorTemp: data.outdoor_temp || 0, outdoorHum: data.outdoor_hum || 0, lux: data.lux || 0, // Статусы датчиков sensorOk: data.sensor_status === 0, outdoorOk: true, // В MQTT формате нет отдельного поля, предполагаем OK luxOk: data.lux_status === 0, // Состояние оборудования windowPosition: data.window_position || 0, heating: data.heating || false, light: data.light || false, // Дополнительные данные manualWindow: false, // Нет в MQTT, используем значение по умолчанию humVentMode: data.hum_vent_mode || 0, calibrated: data.calibrated || false, // Время контроллера timeValid: data.time_valid || false, timeStr: data.datetime ? data.datetime.split(' ')[1] : new Date().toLocaleTimeString('ru-RU'), datetime: data.datetime || '', // Метаданные deviceId: data.device_id, firmware: data.firmware, wifiRssi: data.wifi_rssi, // Ночной режим (если есть) nightModeActive: false, currentShift: 0, targetShift: 0, }; } catch (error) { console.error('Error mapping MQTT data:', error); return null; } }; // Mock API const mockAPI = { getDeviceData: async (deviceId) => { await new Promise(r => setTimeout(r, 300)); if (deviceId.startsWith('HG-')) { return { temperature: 23.5 + Math.random() * 2 - 1, humidity: 65 + Math.random() * 10 - 5, outdoorTemp: 12.3 + Math.random() * 2 - 1, outdoorHum: 78 + Math.random() * 5 - 2.5, lux: 3500 + Math.random() * 500 - 250, windowPosition: 35 + Math.random() * 10 - 5, heating: false, light: true, sensorOk: true, outdoorOk: true, luxOk: true, manualWindow: false, humVentMode: 0, calibrated: true, calibOpenTime: 45000, calibCloseTime: 42000, timeValid: true, timeStr: new Date().toLocaleTimeString('ru-RU'), nightModeActive: false, currentShift: 0, targetShift: 0, }; } else { return { temperature: 4.2 + Math.random() * 1 - 0.5, humidity: 85 + Math.random() * 5 - 2.5, compressor: true, heater: false, fan: false, sensorOk: true, timeStr: new Date().toLocaleTimeString('ru-RU'), }; } }, sendCommand: async (deviceId, command, params) => { await new Promise(r => setTimeout(r, 300)); console.log('Command:', deviceId, command, params); return { success: true }; }, }; // HELPER FUNCTIONS const parseNumericInput = (value, fallback) => { // Разрешаем пустое значение для редактирования if (value === '' || value === null || value === undefined) { return ''; } const parsed = parseFloat(value); // Если не число - возвращаем fallback, иначе распарсенное значение return isNaN(parsed) ? fallback : parsed; }; // COMPONENTS function ToggleSwitch({ checked, onChange, label }) { return ( <View style={styles.toggleSwitch}> <Switch value={checked} onValueChange={onChange} trackColor={{ false: '#4B5563', true: '#10B981' }} thumbColor="#FFF" /> <Text style={styles.toggleLabel}>{label}</Text> </View> ); } function AccordionSection({ title, icon, isOpen, onToggle, children }) { return ( <View style={styles.accordion}> <TouchableOpacity style={styles.accordionHeader} onPress={onToggle} activeOpacity={0.7}> <View style={styles.accordionHeaderLeft}> {icon} <Text style={styles.accordionTitle}>{title}</Text> </View> <View style={{ transform: [{ rotate: isOpen ? '180deg' : '0deg' }] }}> <Ionicons name="chevron-down" size={20} color="#9CA3AF" /> </View> </TouchableOpacity> {isOpen && ( <View style={styles.accordionContent}> {children} </View> )} </View> ); } function InputField({ label, value, onChange, keyboardType = 'default' }) { return ( <View style={styles.inputField}> <Text style={styles.inputLabel} numberOfLines={2}>{label}</Text> <TextInput style={styles.input} value={String(value)} onChangeText={onChange} keyboardType={keyboardType} placeholderTextColor="#6B7280" /> </View> ); } function SensorCard({ icon, label, value, error }) { return ( <View style={[styles.sensorCard, error && styles.sensorCardError]}> <View style={styles.sensorCardHeader}>{icon}<Text style={styles.sensorCardLabel}>{label}</Text></View> <Text style={styles.sensorCardValue}>{value}</Text> </View> ); } // TABS function ReadingsTab({ data, deviceType }) { const ventModes = ['', 'Нагрев', 'Вентиляция', 'Охлаждение']; if (deviceType === 'greenhouse') { return ( <ScrollView style={styles.tabContent} contentContainerStyle={styles.tabContentContainer}> <View style={styles.section}> <Text style={styles.sectionTitle}>Климат в теплице</Text> <View style={styles.sensorGrid}> <SensorCard icon={<Ionicons name="thermometer" size={24} color="#EF4444" />} label="Темп." value={data.temperature.toFixed(1) + '°C'} error={!data.sensorOk} /> <SensorCard icon={<Ionicons name="water" size={24} color="#3B82F6" />} label="Влажн." value={data.humidity.toFixed(0) + '%'} error={!data.sensorOk} /> </View> </View> <View style={styles.section}> <Text style={styles.sectionTitle}>Улица</Text> <View style={styles.sensorGridThree}> <SensorCard icon={<Ionicons name="thermometer-outline" size={20} color="#A855F7" />} label="Темп." value={data.outdoorTemp.toFixed(1) + '°C'} error={!data.outdoorOk} /> <SensorCard icon={<Ionicons name="water-outline" size={20} color="#06B6D4" />} label="Влажн." value={data.outdoorHum.toFixed(0) + '%'} error={!data.outdoorOk} /> <SensorCard icon={<Ionicons name="sunny" size={20} color="#EAB308" />} label="Lux" value={Math.round(data.lux) + ' lx'} error={!data.luxOk} /> </View> </View> <View style={styles.section}> <Text style={styles.sectionTitle}>Форточка</Text> <View style={styles.sensorGrid}> <View style={styles.infoCard}><Text style={styles.infoCardLabel}>Положение</Text><Text style={styles.infoCardValue}>{data.windowPosition.toFixed(0)}%</Text></View> <View style={styles.infoCard}><Text style={styles.infoCardLabel}>Режим</Text><Text style={styles.infoCardValue}>{data.manualWindow ? 'Ручной' : 'Авто'}</Text></View> </View> {data.humVentMode > 0 && <View style={styles.warningBanner}><Text style={styles.warningText}>Режим выгонки влажности: {ventModes[data.humVentMode]}</Text></View>} </View> <View style={styles.section}> <Text style={styles.sectionTitle}>Оборудование</Text> <View style={styles.sensorGrid}> <View style={styles.equipmentCard}><Text style={styles.equipmentLabel}>Отопление</Text><Text style={[styles.equipmentStatus, data.heating && styles.equipmentStatusOn]}>{data.heating ? 'ВКЛ' : 'ВЫКЛ'}</Text></View> <View style={styles.equipmentCard}><Text style={styles.equipmentLabel}>Досветка</Text><Text style={[styles.equipmentStatus, data.light && styles.equipmentStatusOn]}>{data.light ? 'ВКЛ' : 'ВЫКЛ'}</Text></View> </View> </View> </ScrollView> ); } else { return ( <ScrollView style={styles.tabContent} contentContainerStyle={styles.tabContentContainer}> <View style={styles.section}> <Text style={styles.sectionTitle}>Климат в холодильнике</Text> <View style={styles.sensorGrid}> <SensorCard icon={<Ionicons name="thermometer" size={24} color="#3B82F6" />} label="Темп." value={data.temperature.toFixed(1) + '°C'} error={!data.sensorOk} /> <SensorCard icon={<Ionicons name="water" size={24} color="#06B6D4" />} label="Влажн." value={data.humidity.toFixed(0) + '%'} error={!data.sensorOk} /> </View> </View> <View style={styles.section}> <Text style={styles.sectionTitle}>Оборудование</Text> <View style={styles.sensorGridThree}> <View style={styles.equipmentCard}><Text style={styles.equipmentLabel}>Компрессор</Text><Text style={[styles.equipmentStatus, data.compressor && styles.equipmentStatusBlue]}>{data.compressor ? 'ВКЛ' : 'ВЫКЛ'}</Text></View> <View style={styles.equipmentCard}><Text style={styles.equipmentLabel}>Обогрев</Text><Text style={[styles.equipmentStatus, data.heater && styles.equipmentStatusOrange]}>{data.heater ? 'ВКЛ' : 'ВЫКЛ'}</Text></View> <View style={styles.equipmentCard}><Text style={styles.equipmentLabel}>Вентилятор</Text><Text style={[styles.equipmentStatus, data.fan && styles.equipmentStatusCyan]}>{data.fan ? 'ВКЛ' : 'ВЫКЛ'}</Text></View> </View> </View> </ScrollView> ); } } // Due to file size limits, continuing in next part... function GraphsTab({ history, selectedMetrics, toggleMetric, deviceType, primaryColor }) { const [timeRange, setTimeRange] = useState('1h'); const [fullscreen, setFullscreen] = useState(false); const timeRanges = { '1h': { label: '1ч', minutes: 60 }, '3h': { label: '3ч', minutes: 180 }, '1d': { label: '1д', minutes: 1440 }, '7d': { label: '7д', minutes: 10080 }, '30d': { label: '30д', minutes: 43200 } }; const filterHistoryByTime = () => { if (history.length === 0) return []; const now = Date.now(); const rangeMs = timeRanges[timeRange].minutes * 60 * 1000; return history.filter(point => (now - point.timestamp) <= rangeMs); }; const filteredHistory = filterHistoryByTime(); if (history.length === 0) { return <View style={styles.emptyGraphs}><Ionicons name="trending-up" size={64} color="#6B7280" /><Text style={styles.emptyGraphsText}>Накопление данных для графиков...</Text></View>; } // Проверка выбранных метрик const hasSelectedMetrics = deviceType === 'greenhouse' ? Object.values(selectedMetrics).some(v => v) : true; // Цвета для метрик const metricColors = { temperature: '#EF4444', outdoorTemp: '#A855F7', humidity: '#3B82F6', lux: '#EAB308', windowPosition: '#06B6D4', }; // Конвертация hex цвета в rgba const hexToRgba = (hex, opacity) => { const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); if (!result) return `rgba(16, 185, 129, ${opacity})`; return `rgba(${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(result[3], 16)}, ${opacity})`; }; // Создание datasets для всех выбранных метрик const createDatasets = () => { if (deviceType !== 'greenhouse') { return [{ data: filteredHistory.map(h => h.temperature), color: () => hexToRgba('#3B82F6', 1), strokeWidth: 2, }]; } const datasets = []; if (selectedMetrics.temperature) { datasets.push({ data: filteredHistory.map(h => h.temperature), color: () => hexToRgba(metricColors.temperature, 1), strokeWidth: 2, }); } if (selectedMetrics.outdoorTemp) { datasets.push({ data: filteredHistory.map(h => h.outdoorTemp), color: () => hexToRgba(metricColors.outdoorTemp, 1), strokeWidth: 2, }); } if (selectedMetrics.humidity) { datasets.push({ data: filteredHistory.map(h => h.humidity), color: () => hexToRgba(metricColors.humidity, 1), strokeWidth: 2, }); } if (selectedMetrics.lux) { datasets.push({ data: filteredHistory.map(h => h.lux / 100), color: () => hexToRgba(metricColors.lux, 1), strokeWidth: 2, }); } if (selectedMetrics.windowPosition) { datasets.push({ data: filteredHistory.map(h => h.windowPosition), color: () => hexToRgba(metricColors.windowPosition, 1), strokeWidth: 2, }); } return datasets; }; const chartConfig = { backgroundColor: '#1F2937', backgroundGradientFrom: '#1F2937', backgroundGradientTo: '#1F2937', backgroundGradientFromOpacity: 1, backgroundGradientToOpacity: 1, fillShadowGradient: '#1F2937', fillShadowGradientOpacity: 0, decimalPlaces: 1, color: (opacity = 1) => `rgba(156, 163, 175, ${opacity})`, labelColor: (opacity = 1) => `rgba(156, 163, 175, ${opacity})`, style: { borderRadius: 16, backgroundColor: '#1F2937' }, propsForDots: { r: '3', strokeWidth: '1' }, propsForBackgroundLines: { strokeDasharray: '', stroke: '#374151' }, }; const ChartComponent = () => ( <View style={styles.chartWrapper}> <LineChart data={{ labels: filteredHistory.length > 6 ? filteredHistory.filter((_, i) => i % Math.floor(filteredHistory.length / 6) === 0).map(h => h.time) : filteredHistory.map(h => h.time), datasets: createDatasets(), }} width={fullscreen ? SCREEN_WIDTH : SCREEN_WIDTH - 32} height={fullscreen ? Dimensions.get('window').height - 100 : 336} chartConfig={chartConfig} bezier style={{ borderRadius: 16, backgroundColor: '#1F2937' }} withInnerLines={true} withOuterLines={true} withVerticalLines={false} withHorizontalLines={true} withDots={false} fromZero={true} transparent={false} /> <TouchableOpacity style={styles.fullscreenButton} onPress={() => setFullscreen(!fullscreen)} > <Ionicons name={fullscreen ? "contract" : "expand"} size={20} color="#9CA3AF" /> </TouchableOpacity> </View> ); if (fullscreen) { return ( <Modal visible={fullscreen} transparent={false} animationType="fade"> <View style={styles.fullscreenContainer}> <View style={styles.fullscreenHeader}> <Text style={styles.fullscreenTitle}>График</Text> <TouchableOpacity onPress={() => setFullscreen(false)}> <Ionicons name="close" size={28} color="#E5E7EB" /> </TouchableOpacity> </View> <ChartComponent /> </View> </Modal> ); } return ( <ScrollView style={styles.tabContent} contentContainerStyle={styles.tabContentContainer}> <View style={styles.section}> <Text style={styles.sectionTitle}>Временной диапазон</Text> <View style={styles.timeRangeButtons}> {Object.entries(timeRanges).map(([key, value]) => ( <TouchableOpacity key={key} style={[styles.timeRangeButton, timeRange === key && { ...styles.timeRangeButtonActive, backgroundColor: primaryColor }]} onPress={() => setTimeRange(key)}> <Text style={[styles.timeRangeButtonText, timeRange === key && styles.timeRangeButtonTextActive]}>{value.label}</Text> </TouchableOpacity> ))} </View> </View> {deviceType === 'greenhouse' && ( <View style={styles.section}> <Text style={styles.sectionTitle}>Отображаемые параметры</Text> <View style={styles.metricsRow}> <TouchableOpacity style={[styles.metricButtonEqual, selectedMetrics.temperature && styles.metricButtonActive]} onPress={() => toggleMetric('temperature')}> <Ionicons name="thermometer" size={14} color={selectedMetrics.temperature ? '#EF4444' : '#9CA3AF'} /> <Text style={[styles.metricButtonText, selectedMetrics.temperature && { color: '#EF4444' }]}>Темп</Text> </TouchableOpacity> <TouchableOpacity style={[styles.metricButtonEqual, selectedMetrics.outdoorTemp && styles.metricButtonActive]} onPress={() => toggleMetric('outdoorTemp')}> <Ionicons name="thermometer-outline" size={14} color={selectedMetrics.outdoorTemp ? '#A855F7' : '#9CA3AF'} /> <Text style={[styles.metricButtonText, selectedMetrics.outdoorTemp && { color: '#A855F7' }]}>Улица</Text> </TouchableOpacity> <TouchableOpacity style={[styles.metricButtonEqual, selectedMetrics.humidity && styles.metricButtonActive]} onPress={() => toggleMetric('humidity')}> <Ionicons name="water" size={14} color={selectedMetrics.humidity ? '#3B82F6' : '#9CA3AF'} /> <Text style={[styles.metricButtonText, selectedMetrics.humidity && { color: '#3B82F6' }]}>Влаж</Text> </TouchableOpacity> <TouchableOpacity style={[styles.metricButtonEqual, selectedMetrics.lux && styles.metricButtonActive]} onPress={() => toggleMetric('lux')}> <Ionicons name="sunny" size={14} color={selectedMetrics.lux ? '#EAB308' : '#9CA3AF'} /> <Text style={[styles.metricButtonText, selectedMetrics.lux && { color: '#EAB308' }]}>Lux</Text> </TouchableOpacity> <TouchableOpacity style={[styles.metricButtonEqual, selectedMetrics.windowPosition && styles.metricButtonActive]} onPress={() => toggleMetric('windowPosition')}> <Ionicons name="cube-outline" size={14} color={selectedMetrics.windowPosition ? '#06B6D4' : '#9CA3AF'} /> <Text style={[styles.metricButtonText, selectedMetrics.windowPosition && { color: '#06B6D4' }]}>Окно</Text> </TouchableOpacity> </View> </View> )} {!hasSelectedMetrics ? ( <View style={styles.emptyGraphs}> <Ionicons name="bar-chart-outline" size={64} color="#6B7280" /> <Text style={styles.emptyGraphsText}>Выберите параметры для отображения</Text> </View> ) : filteredHistory.length < 2 ? ( <View style={styles.emptyGraphs}> <Ionicons name="time-outline" size={64} color="#6B7280" /> <Text style={styles.emptyGraphsText}>Накопление данных... ({filteredHistory.length}/2)</Text> </View> ) : ( <ChartComponent /> )} </ScrollView> ); } function SettingsTab({ settings, localSettings: propsLocalSettings, setLocalSettings: propsSetLocalSettings, hasUnsavedChanges: propsHasUnsavedChanges, setHasUnsavedChanges: propsSetHasUnsavedChanges, saveStatus: propsSaveStatus, setSaveStatus: propsSetSaveStatus, onSave, sendCommand, data, deviceId, deviceType, primaryColor }) { // openSections управляется локально внутри SettingsTab const defaultSections = deviceType === 'greenhouse' ? { window: true, heating: false, light: false, nightShift: false, calibration: false, alerts: false, firmware: false } : { cooling: true, heating: false, ventilation: false, alerts: false, firmware: false }; const [openSections, setOpenSections] = useState(defaultSections); // Загружаем сохранённые openSections при монтировании useEffect(() => { const loadOpenSections = async () => { try { const saved = await AsyncStorage.getItem(`openSections_${deviceId}`); if (saved) setOpenSections(JSON.parse(saved)); } catch (e) {} }; if (deviceId) loadOpenSections(); }, [deviceId]); // Функция переключения секции с анимацией const toggleSection = async (section) => { LayoutAnimation.configureNext({ duration: 300, update: { type: LayoutAnimation.Types.easeInEaseOut }, create: { type: LayoutAnimation.Types.easeInEaseOut, property: LayoutAnimation.Properties.opacity }, delete: { type: LayoutAnimation.Types.easeInEaseOut, property: LayoutAnimation.Properties.opacity }, }); const newOpenSections = {...openSections, [section]: !openSections[section]}; setOpenSections(newOpenSections); if (deviceId) { await AsyncStorage.setItem(`openSections_${deviceId}`, JSON.stringify(newOpenSections)); } }; // Используем props если переданы (для greenhouse), иначе локальное состояние (для fridge) const [fallbackLocalSettings, setFallbackLocalSettings] = useState(settings); const [fallbackHasUnsavedChanges, setFallbackHasUnsavedChanges] = useState(false); const [fallbackSaveStatus, setFallbackSaveStatus] = useState('idle'); const localSettings = propsLocalSettings || fallbackLocalSettings; const setLocalSettings = propsSetLocalSettings || setFallbackLocalSettings; const hasUnsavedChanges = propsHasUnsavedChanges !== undefined ? propsHasUnsavedChanges : fallbackHasUnsavedChanges; const setHasUnsavedChanges = propsSetHasUnsavedChanges || setFallbackHasUnsavedChanges; const saveStatus = propsSaveStatus || fallbackSaveStatus; const setSaveStatus = propsSetSaveStatus || setFallbackSaveStatus; // Синхронизировать с родительскими настройками только если нет несохраненных изменений // и используется fallback (для fridge) useEffect(() => { if (!propsLocalSettings && !hasUnsavedChanges) { setFallbackLocalSettings(settings); } }, [settings, hasUnsavedChanges, propsLocalSettings]); // Обработчик изменения настроек с отслеживанием изменений const handleSettingChange = (newSettings) => { setLocalSettings(newSettings); setHasUnsavedChanges(true); setSaveStatus('idle'); // Сброс статуса при изменении }; // Helper для обновления одного поля const updateSetting = (field, value) => { handleSettingChange({...localSettings, [field]: value}); }; // Сохранить настройки const handleSave = async () => { setSaveStatus('saving'); try { await onSave(localSettings); setHasUnsavedChanges(false); setSaveStatus('idle'); } catch (error) { setSaveStatus('error'); // Показываем красную кнопку на 4 секунды setTimeout(() => { setSaveStatus('idle'); }, 4000); } }; // Отменить изменения const handleCancel = () => { setLocalSettings(settings); setHasUnsavedChanges(false); setSaveStatus('idle'); }; if (deviceType === 'greenhouse') { return ( <ScrollView style={styles.tabContent} contentContainerStyle={styles.tabContentContainer}> <AccordionSection title="Настройки форточки" icon={<Ionicons name="cube-outline" size={20} color={primaryColor} />} isOpen={openSections.window} onToggle={() => toggleSection('window')}> <ToggleSwitch checked={!localSettings.manualWindow} onChange={(v) => updateSetting('manualWindow', !v)} label="Автоматический режим" /> <Text style={styles.subsectionTitle}>Температура</Text> <View style={styles.settingsRow}> <InputField label="Мин. температура (°C)" value={localSettings.tempMin} onChange={(v) => updateSetting('tempMin', parseNumericInput(v, 22))} keyboardType="numeric" /> <InputField label="Макс. температура (°C)" value={localSettings.tempMax} onChange={(v) => updateSetting('tempMax', parseNumericInput(v, 26))} keyboardType="numeric" /> </View> <Text style={styles.subsectionTitle}>Влажность (сброс)</Text> <View style={styles.settingsRow}> <InputField label="Мин. влажность (%)" value={localSettings.humMin} onChange={(v) => updateSetting('humMin', parseNumericInput(v, 50))} keyboardType="numeric" /> <InputField label="Макс. влажность (%)" value={localSettings.humMax} onChange={(v) => updateSetting('humMax', parseNumericInput(v, 80))} keyboardType="numeric" /> </View> <View style={styles.settingsRow}> <InputField label="Порог уличной температуры (°C)" value={localSettings.outdoorTempThreshold} onChange={(v) => updateSetting('outdoorTempThreshold', parseNumericInput(v, 10))} keyboardType="numeric" /> </View> <Text style={styles.infoText}>ℹ️ При сбросе влажности теплица нагревается на +1°C от мин. температуры отопления, затем форточка приоткрывается. Если на улице ниже порога — форточка откроется только на 20%.</Text> {localSettings.manualWindow && ( <View style={styles.controlButtons}> <TouchableOpacity style={[styles.controlButton, { backgroundColor: '#10B981' }]} onPress={() => sendCommand('window', {cmd: 'open'})}><Text style={styles.controlButtonText}>▲ Открыть</Text></TouchableOpacity> <TouchableOpacity style={[styles.controlButton, { backgroundColor: '#EAB308' }]} onPress={() => sendCommand('window', {cmd: 'stop'})}><Text style={styles.controlButtonText}>■ Стоп</Text></TouchableOpacity> <TouchableOpacity style={[styles.controlButton, { backgroundColor: '#EF4444' }]} onPress={() => sendCommand('window', {cmd: 'close'})}><Text style={styles.controlButtonText}>▼ Закрыть</Text></TouchableOpacity> </View> )} </AccordionSection> <AccordionSection title="Настройки отопления" icon={<Ionicons name="thermometer" size={20} color={primaryColor} />} isOpen={openSections.heating} onToggle={() => toggleSection('heating')}> <ToggleSwitch checked={!localSettings.manualHeating} onChange={(v) => updateSetting('manualHeating', !v)} label="Автоматический режим" /> {!localSettings.manualHeating ? ( <View style={styles.settingsRow}> <InputField label="Включить при (°C)" value={localSettings.heatOn} onChange={(v) => updateSetting('heatOn', parseNumericInput(v, 15))} keyboardType="numeric" /> <InputField label="Выключить при (°C)" value={localSettings.heatOff} onChange={(v) => updateSetting('heatOff', parseNumericInput(v, 18))} keyboardType="numeric" /> </View> ) : ( <View style={styles.controlButtons}> <TouchableOpacity style={[styles.controlButton, { backgroundColor: '#10B981' }]} onPress={() => sendCommand('heating', {cmd: 'on'})}><Text style={styles.controlButtonText}>Включить</Text></TouchableOpacity> <TouchableOpacity style={[styles.controlButton, { backgroundColor: '#EF4444' }]} onPress={() => sendCommand('heating', {cmd: 'off'})}><Text style={styles.controlButtonText}>Выключить</Text></TouchableOpacity> </View> )} </AccordionSection> <AccordionSection title="Настройки досветки" icon={<Ionicons name="sunny" size={20} color={primaryColor} />} isOpen={openSections.light} onToggle={() => toggleSection('light')}> <View style={[styles.statusBanner, localSettings.lightGlobalEnabled ? { backgroundColor: 'rgba(16, 185, 129, 0.2)', borderLeftColor: '#10B981' } : { backgroundColor: 'rgba(239, 68, 68, 0.2)', borderLeftColor: '#EF4444' }]}> <Text style={styles.statusBannerText}>{localSettings.lightGlobalEnabled ? 'Досветка включена' : 'Досветка отключена'}</Text> <View style={styles.statusBannerButtons}> <TouchableOpacity style={[styles.statusButton, { backgroundColor: '#10B981' }]} onPress={() => { updateSetting('lightGlobalEnabled', true); sendCommand('light', {global_enabled: true}); }}><Text style={styles.statusButtonText}>Включить</Text></TouchableOpacity> <TouchableOpacity style={[styles.statusButton, { backgroundColor: '#EF4444' }]} onPress={() => { updateSetting('lightGlobalEnabled', false); sendCommand('light', {global_enabled: false}); }}><Text style={styles.statusButtonText}>Отключить</Text></TouchableOpacity> </View> </View> {localSettings.lightGlobalEnabled && ( <> <ToggleSwitch checked={localSettings.lightAuto} onChange={(v) => updateSetting('lightAuto', v)} label="Авто режим (по люксам)" /> <Text style={styles.subsectionTitle}>Активные лампы</Text> <ToggleSwitch checked={localSettings.lightDNaT} onChange={(v) => updateSetting('lightDNaT', v)} label="ДНаТ (натриевые)" /> <ToggleSwitch checked={localSettings.lightLED} onChange={(v) => updateSetting('lightLED', v)} label="LED светодиодные" /> {localSettings.lightAuto ? ( <> <Text style={styles.subsectionTitle}>Автоматический режим</Text> <View style={styles.settingsRow}> <InputField label="Начало досвечивания" value={localSettings.lightAutoStart} onChange={(v) => updateSetting('lightAutoStart', v)} /> <InputField label="Конец досвечивания" value={localSettings.lightAutoEnd} onChange={(v) => updateSetting('lightAutoEnd', v)} /> </View> <InputField label="Порог люксометра (lx)" value={localSettings.lightLuxThreshold} onChange={(v) => updateSetting('lightLuxThreshold', parseNumericInput(v, 5000))} keyboardType="numeric" /> <Text style={styles.infoText}>ℹ️ Досветка включится если освещённость ниже порога в заданное время</Text> </> ) : ( <> <Text style={styles.subsectionTitle}>Ручной режим</Text> <Text style={styles.inputLabel}>Дневное досвечивание</Text> <View style={styles.settingsRow}> <InputField label="Начало" value={localSettings.lightManualDayStart} onChange={(v) => updateSetting('lightManualDayStart', v)} /> <InputField label="Конец" value={localSettings.lightManualDayEnd} onChange={(v) => updateSetting('lightManualDayEnd', v)} /> </View> <Text style={styles.inputLabel}>Вечернее досвечивание</Text> <View style={styles.settingsRow}> <InputField label="Начало" value={localSettings.lightManualEveStart} onChange={(v) => updateSetting('lightManualEveStart', v)} /> <InputField label="Конец" value={localSettings.lightManualEveEnd} onChange={(v) => updateSetting('lightManualEveEnd', v)} /> </View> <View style={styles.controlButtons}> <TouchableOpacity style={[styles.controlButton, { backgroundColor: '#10B981' }]} onPress={() => sendCommand('light', {cmd: 'force_on'})}><Text style={styles.controlButtonText}>Принудительно ВКЛ</Text></TouchableOpacity> <TouchableOpacity style={[styles.controlButton, { backgroundColor: '#EF4444' }]} onPress={() => sendCommand('light', {cmd: 'force_off'})}><Text style={styles.controlButtonText}>Принудительно ВЫКЛ</Text></TouchableOpacity> </View> </> )} <Text style={styles.subsectionTitle}>Защита от частых включений</Text> <View style={styles.settingsRow}> <InputField label="Мин. время ВКЛ (мин)" value={localSettings.lightMinOnTime} onChange={(v) => updateSetting('lightMinOnTime', parseNumericInput(v, 10))} keyboardType="numeric" /> <InputField label="Мин. время ВЫКЛ (мин)" value={localSettings.lightMinOffTime} onChange={(v) => updateSetting('lightMinOffTime', parseNumericInput(v, 10))} keyboardType="numeric" /> </View> <Text style={styles.infoText}>ℹ️ Досветка не будет включаться/выключаться чаще заданного времени</Text> </> )} </AccordionSection> <AccordionSection title="Ночной режим" icon={<Ionicons name="moon" size={20} color={primaryColor} />} isOpen={openSections.nightShift} onToggle={() => toggleSection('nightShift')}> <ToggleSwitch checked={localSettings.nsEnabled} onChange={(v) => updateSetting('nsEnabled', v)} label="Включить ночной режим" /> {localSettings.nsEnabled && ( <> <Text style={styles.infoText}>ℹ️ Ночной режим постепенно сдвигает температурные уставки для имитации естественного цикла день/ночь</Text> <Text style={styles.subsectionTitle}>Время переключения</Text> <View style={styles.settingsRow}> <InputField label="Начало дня" value={localSettings.nsDayStart} onChange={(v) => updateSetting('nsDayStart', v)} /> <InputField label="Начало ночи" value={localSettings.nsNightStart} onChange={(v) => updateSetting('nsNightStart', v)} /> </View> <Text style={styles.subsectionTitle}>Применить к</Text> <ToggleSwitch checked={localSettings.nsApplyToWindow} onChange={(v) => updateSetting('nsApplyToWindow', v)} label="Форточки" /> <ToggleSwitch checked={localSettings.nsApplyToHeating} onChange={(v) => updateSetting('nsApplyToHeating', v)} label="Отопление" /> <Text style={styles.subsectionTitle}>Параметры сдвига</Text> <View style={styles.settingsRow}> <InputField label="Величина сдвига (°C)" value={localSettings.nsShift} onChange={(v) => updateSetting('nsShift', parseNumericInput(v, 4))} keyboardType="numeric" /> <InputField label="Шаг сдвига (°C)" value={localSettings.nsStep} onChange={(v) => updateSetting('nsStep', parseNumericInput(v, 1))} keyboardType="numeric" /> </View> <InputField label="Интервал между шагами (мин)" value={localSettings.nsInterval} onChange={(v) => updateSetting('nsInterval', parseNumericInput(v, 30))} keyboardType="numeric" /> <Text style={styles.infoText}>ℹ️ Каждые {localSettings.nsInterval} мин температура будет меняться на {localSettings.nsStep}°C до достижения {localSettings.nsShift}°C</Text> {data && ( <View style={styles.statusCard}> <Text style={styles.statusCardTitle}>Текущее состояние</Text> <Text style={styles.statusCardText}>Статус: {data.nightModeActive ? 'Ночной режим' : 'Дневной режим'}</Text> <Text style={styles.statusCardText}>Текущий сдвиг: {data.currentShift ? data.currentShift.toFixed(1) : '0.0'}°C</Text> </View> )} </> )} </AccordionSection> <AccordionSection title="Калибровка форточки" icon={<Ionicons name="construct" size={20} color={primaryColor} />} isOpen={openSections.calibration} onToggle={() => toggleSection('calibration')}> <ToggleSwitch checked={localSettings.calibAutoMode} onChange={(v) => updateSetting('calibAutoMode', v)} label="Автоматическая калибровка" /> {localSettings.calibAutoMode ? ( data && ( <> <View style={[styles.statusBanner, data.calibrated ? { backgroundColor: 'rgba(16, 185, 129, 0.2)', borderLeftColor: '#10B981' } : { backgroundColor: 'rgba(239, 68, 68, 0.2)', borderLeftColor: '#EF4444' }]}> <Text style={styles.statusBannerText}>Статус: {data.calibrated ? 'Выполнена' : 'Не выполнена'}</Text> <Text style={styles.statusBannerText}>Время открытия: {data.calibOpenTime ? (data.calibOpenTime/1000).toFixed(1) : '--'} сек</Text> <Text style={styles.statusBannerText}>Время закрытия: {data.calibCloseTime ? (data.calibCloseTime/1000).toFixed(1) : '--'} сек</Text> </View> <View style={styles.controlButtons}> <TouchableOpacity style={[styles.controlButton, { backgroundColor: '#EAB308', flex: 1 }]} onPress={() => sendCommand('window', {cmd: 'calibrate'})}><Text style={styles.controlButtonText}>Запустить калибровку</Text></TouchableOpacity> <TouchableOpacity style={[styles.controlButton, { backgroundColor: '#EF4444', flex: 1 }]} onPress={() => sendCommand('window', {cmd: 'calibrate_stop'})}><Text style={styles.controlButtonText}>Остановить</Text></TouchableOpacity> </View> </> ) ) : ( <> <Text style={styles.subsectionTitle}>Ручная калибровка</Text> <View style={styles.settingsRow}> <InputField label="Время открытия (сек)" value={localSettings.calibManualOpenTime} onChange={(v) => updateSetting('calibManualOpenTime', parseNumericInput(v, 45))} keyboardType="numeric" /> <InputField label="Время закрытия (сек)" value={localSettings.calibManualCloseTime} onChange={(v) => updateSetting('calibManualCloseTime', parseNumericInput(v, 42))} keyboardType="numeric" /> </View> <Text style={styles.infoText}>ℹ️ Введите время полного открытия и закрытия форточки в секундах</Text> </> )} </AccordionSection> <AccordionSection title="Критические уведомления" icon={<Ionicons name="alert-circle" size={20} color={primaryColor} />} isOpen={openSections.alerts} onToggle={() => toggleSection('alerts')}> <Text style={styles.subsectionTitle}>Температура</Text> <View style={styles.settingsRow}> <InputField label="Макс. температура (°C)" value={localSettings.criticalTempMax} onChange={(v) => updateSetting('criticalTempMax', parseNumericInput(v, 35))} keyboardType="numeric" /> <InputField label="Мин. температура (°C)" value={localSettings.criticalTempMin} onChange={(v) => updateSetting('criticalTempMin', parseNumericInput(v, 5))} keyboardType="numeric" /> </View> <Text style={styles.subsectionTitle}>Влажность</Text> <View style={styles.settingsRow}> <InputField label="Макс. влажность (%)" value={localSettings.criticalHumMax} onChange={(v) => updateSetting('criticalHumMax', parseNumericInput(v, 90))} keyboardType="numeric" /> <InputField label="Мин. влажность (%)" value={localSettings.criticalHumMin} onChange={(v) => updateSetting('criticalHumMin', parseNumericInput(v, 30))} keyboardType="numeric" /> </View> <Text style={styles.infoText}>⚠️ При превышении этих значений вы получите критическое уведомление</Text> </AccordionSection> <AccordionSection title="Обновление прошивки (OTA)" icon={<Ionicons name="cloud-upload" size={20} color={primaryColor} />} isOpen={openSections.firmware} onToggle={() => toggleSection('firmware')}> <View style={styles.firmwareVersion}> <Text style={styles.firmwareVersionTitle}>Текущая версия прошивки</Text> <Text style={styles.firmwareVersionText}>v1.2.3 (Build 2025-12-09)</Text> </View> <Text style={styles.infoText}>⚠️ Внимание: Не отключайте питание устройства во время обновления прошивки!</Text> <TouchableOpacity style={[styles.saveButton, { backgroundColor: primaryColor }]} onPress={() => Alert.alert('OTA', 'Функция обновления прошивки будет доступна в следующей версии')}> <Ionicons name="cloud-upload" size={20} color="#FFF" /> <Text style={styles.saveButtonText}>Загрузить прошивку</Text> </TouchableOpacity> </AccordionSection> {hasUnsavedChanges && ( <View style={styles.unsavedChangesBanner}> <Ionicons name="alert-circle" size={20} color="#EAB308" /> <Text style={styles.unsavedChangesText}>У вас есть несохраненные изменения</Text> </View> )} <View style={[styles.saveButtonRow, { marginTop: 16 }]}> {hasUnsavedChanges && ( <TouchableOpacity style={[styles.saveButton, styles.cancelButton]} onPress={handleCancel}> <Text style={styles.cancelButtonText}>Отменить</Text> </TouchableOpacity> )} <TouchableOpacity style={[ styles.saveButton, { backgroundColor: saveStatus === 'error' ? '#EF4444' : (hasUnsavedChanges ? primaryColor : '#6B7280'), flex: 1 } ]} onPress={handleSave} disabled={!hasUnsavedChanges || saveStatus === 'saving'} > <Text style={styles.saveButtonText}> {saveStatus === 'saving' ? 'Сохранение...' : 'Сохранить'} </Text> </TouchableOpacity> </View> </ScrollView> ); } else { return ( <ScrollView style={styles.tabContent} contentContainerStyle={styles.tabContentContainer}> <AccordionSection title="Настройки охлаждения" icon={<Ionicons name="snow" size={20} color={primaryColor} />} isOpen={openSections.cooling} onToggle={() => toggleSection('cooling')}> <ToggleSwitch checked={!localSettings.manualCooling} onChange={(v) => updateSetting('manualCooling', !v)} label="Автоматический режим" /> <View style={styles.settingsRow}> <InputField label="Целевая температура (°C)" value={localSettings.targetTemp} onChange={(v) => updateSetting('targetTemp', parseNumericInput(v, 4))} keyboardType="numeric" /> <InputField label="Гистерезис (°C)" value={localSettings.tempHysteresis} onChange={(v) => updateSetting('tempHysteresis', parseNumericInput(v, 2))} keyboardType="numeric" /> </View> </AccordionSection> <AccordionSection title="Настройки обогрева" icon={<Ionicons name="thermometer" size={20} color={primaryColor} />} isOpen={openSections.heating} onToggle={() => toggleSection('heating')}> <ToggleSwitch checked={!localSettings.manualHeating} onChange={(v) => updateSetting('manualHeating', !v)} label="Автоматический режим" /> <View style={styles.settingsRow}> <InputField label="Включить при (°C)" value={localSettings.heaterOn} onChange={(v) => updateSetting('heaterOn', parseNumericInput(v, 0))} keyboardType="numeric" /> <InputField label="Гистерезис (°C)" value={localSettings.heaterHysteresis} onChange={(v) => updateSetting('heaterHysteresis', parseNumericInput(v, 2))} keyboardType="numeric" /> </View> </AccordionSection> {hasUnsavedChanges && ( <View style={styles.unsavedChangesBanner}> <Ionicons name="alert-circle" size={20} color="#EAB308" /> <Text style={styles.unsavedChangesText}>У вас есть несохраненные изменения</Text> </View> )} <View style={[styles.saveButtonRow, { marginTop: 16 }]}> {hasUnsavedChanges && ( <TouchableOpacity style={[styles.saveButton, styles.cancelButton]} onPress={handleCancel}> <Text style={styles.cancelButtonText}>Отменить</Text> </TouchableOpacity> )} <TouchableOpacity style={[ styles.saveButton, { backgroundColor: saveStatus === 'error' ? '#EF4444' : (hasUnsavedChanges ? primaryColor : '#6B7280'), flex: 1 } ]} onPress={handleSave} disabled={!hasUnsavedChanges || saveStatus === 'saving'} > <Text style={styles.saveButtonText}> {saveStatus === 'saving' ? 'Сохранение...' : 'Сохранить'} </Text> </TouchableOpacity> </View> </ScrollView> ); } } // DASHBOARD COMPONENTS function GreenhouseDashboard({ device }) { const [data, setData] = useState({ temperature: 0, humidity: 0, outdoorTemp: 0, outdoorHum: 0, lux: 0, sensorOk: true, outdoorOk: true, luxOk: true, windowPosition: 0, heating: false, light: false, manualWindow: false, humVentMode: 0, calibrated: false, timeValid: false, timeStr: '--:--:--', nightModeActive: false, currentShift: 0, targetShift: 0, }); const [history, setHistory] = useState([]); const [settings, setSettings] = useState({ tempMin: 22, tempMax: 26, humMin: 50, humMax: 80, outdoorTempThreshold: 10, heatOn: 15, heatOff: 18, lightGlobalEnabled: true, lightAuto: true, // Свитчи для ламп вместо режима lightDNaT: true, lightLED: true, // Авто режим досветки lightAutoStart: '06:00', lightAutoEnd: '22:00', lightLuxThreshold: 5000, // Ручной режим досветки lightManualDayStart: '06:00', lightManualDayEnd: '12:00', lightManualEveStart: '18:00', lightManualEveEnd: '22:00', lightMinOnTime: 10, lightMinOffTime: 10, // минуты nsEnabled: false, nsShift: 4, nsStep: 1, nsDayStart: '06:00', nsNightStart: '22:00', nsApplyToWindow: true, nsApplyToHeating: true, nsInterval: 30, // минуты между шагами calibAutoMode: true, calibManualOpenTime: 45, calibManualCloseTime: 42, manualWindow: false, manualHeating: false, criticalTempMax: 35, criticalTempMin: 5, criticalHumMax: 90, criticalHumMin: 30, }); const [selectedMetrics, setSelectedMetrics] = useState({ temperature: false, outdoorTemp: false, humidity: false, lux: false, windowPosition: false }); const [loading, setLoading] = useState(true); // Состояние черновика настроек - вынесено из SettingsTab для сохранения при переключении табов const [localSettings, setLocalSettings] = useState(null); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [saveStatus, setSaveStatus] = useState('idle'); const [showNotifications, setShowNotifications] = useState(false); const [notifications, setNotifications] = useState([ { id: 1, type: 'critical', message: 'Температура превысила критический порог (35°C)', time: '12:34', read: false }, { id: 2, type: 'info', message: 'Досветка включена', time: '11:20', read: false }, { id: 3, type: 'warning', message: 'Влажность ниже минимального порога', time: '10:15', read: true }, ]); const [mqttConnected, setMqttConnected] = useState(false); const [mqttClient, setMqttClient] = useState(null); // Инициализируем localSettings при загрузке settings useEffect(() => { if (localSettings === null && settings) { setLocalSettings(settings); } }, [settings]); // Синхронизируем с родительскими настройками только если нет несохраненных изменений useEffect(() => { if (!hasUnsavedChanges && settings) { setLocalSettings(settings); } }, [settings, hasUnsavedChanges]); useEffect(() => { loadSettings(); loadHistory(); loadSelectedMetrics(); setLoading(false); // Убираем экран загрузки сразу, не ждем MQTT // Создаем MQTT клиент const clientId = MQTT_CONFIG.clientIdPrefix + Math.random().toString(16).substr(2, 8); const client = new Paho.Client( MQTT_CONFIG.broker, MQTT_CONFIG.port, MQTT_CONFIG.path, clientId ); // Callback при получении сообщения client.onMessageArrived = (message) => { console.log('MQTT message:', message.destinationName, message.payloadString); const mappedData = mapMQTTData(message.payloadString); if (mappedData) { setData(mappedData); saveHistory(mappedData); } }; // Callback при потере соединения client.onConnectionLost = (responseObject) => { if (responseObject.errorCode !== 0) { console.log('MQTT Connection Lost:', responseObject.errorMessage); setMqttConnected(false); // Пытаемся переподключиться через 5 секунд setTimeout(() => connectMQTT(client), 5000); } }; // Функция подключения const connectMQTT = (mqttClient) => { console.log('Connecting to MQTT broker...'); mqttClient.connect({ onSuccess: () => { console.log('✓ MQTT Connected'); setMqttConnected(true); // Подписываемся на топик device/home_greenhouse/+/sensors (wildcard для всех устройств) mqttClient.subscribe('device/home_greenhouse/+/sensors', { onSuccess: () => console.log('✓ Subscribed to device/home_greenhouse/+/sensors'), onFailure: (err) => console.error('✗ Subscribe failed:', err), }); }, onFailure: (err) => { console.error('✗ MQTT Connection failed:', err.errorMessage); setMqttConnected(false); // Пытаемся переподключиться через 5 секунд setTimeout(() => connectMQTT(mqttClient), 5000); }, useSSL: false, timeout: 10, keepAliveInterval: 60, }); }; connectMQTT(client); setMqttClient(client); // Cleanup при размонтировании return () => { if (client && client.isConnected()) { client.disconnect(); } }; }, [device.id]); const loadData = async () => { // Функция оставлена для совместимости, но данные приходят через MQTT // Можно использовать для ручного обновления если нужно const result = await mockAPI.getDeviceData(device.id); setData(result); if (result) await saveHistory(result); }; const loadSettings = async () => { try { const result = await AsyncStorage.getItem(`settings_${device.id}`); if (result) setSettings(JSON.parse(result)); } catch (e) {} }; const loadHistory = async () => { try { const result = await AsyncStorage.getItem(`history_${device.id}`); if (result) setHistory(JSON.parse(result)); } catch (e) {} }; const loadSelectedMetrics = async () => { try { const result = await AsyncStorage.getItem(`selectedMetrics_${device.id}`); if (result) setSelectedMetrics(JSON.parse(result)); } catch (e) {} }; const saveHistory = async (newData) => { const timestamp = new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }); const dataPoint = { time: timestamp, timestamp: Date.now(), temperature: parseFloat(newData.temperature.toFixed(1)), humidity: parseFloat(newData.humidity.toFixed(1)), outdoorTemp: parseFloat(newData.outdoorTemp.toFixed(1)), lux: Math.round(newData.lux), windowPosition: parseFloat(newData.windowPosition.toFixed(1)) }; setHistory(prevHistory => { const updated = [...prevHistory, dataPoint].slice(-1000); AsyncStorage.setItem(`history_${device.id}`, JSON.stringify(updated)); return updated; }); }; const saveSettings = useCallback(async (newSettings) => { try { await AsyncStorage.setItem(`settings_${device.id}`, JSON.stringify(newSettings)); setSettings(newSettings); await mockAPI.sendCommand(device.id, 'settings', newSettings); } catch (error) { console.error('Error saving settings:', error); throw error; } }, [device.id]); const toggleMetric = useCallback(async (metric) => { setSelectedMetrics(prev => { const newMetrics = {...prev, [metric]: !prev[metric]}; AsyncStorage.setItem(`selectedMetrics_${device.id}`, JSON.stringify(newMetrics)); return newMetrics; }); }, [device.id]); const sendCommand = useCallback(async (command, params) => { await mockAPI.sendCommand(device.id, command, params); await loadData(); }, [device.id]); const primaryColor = '#10B981'; const markAllAsRead = () => { setNotifications(notifications.map(n => ({...n, read: true}))); }; if (loading || !data) { return <View style={styles.dashboardLoading}><ActivityIndicator size="large" color="#10B981" /><Text style={styles.loadingText}>Загрузка данных...</Text></View>; } return ( <View style={styles.dashboard}> <View style={[styles.dashboardHeader, { backgroundColor: primaryColor }]}> <View style={styles.dashboardHeaderContent}> <Text style={styles.dashboardHeaderTitle}>{device.name}</Text> <View style={styles.dashboardHeaderRight}> <Ionicons name={mqttConnected ? "wifi" : "wifi-outline"} size={20} color={mqttConnected ? "#10B981" : "#EF4444"} /> <Ionicons name="time-outline" size={20} color="#FFF" /> <Text style={styles.dashboardHeaderTime}> {data && data.timeStr ? data.timeStr : '--:--:--'} </Text> </View> </View> </View> <Tab.Navigator screenOptions={{ headerShown: false, tabBarActiveTintColor: primaryColor, tabBarInactiveTintColor: '#9CA3AF', tabBarStyle: { backgroundColor: '#374151', borderTopColor: '#4B5563', borderTopWidth: 1, height: 60, paddingTop: 8, paddingBottom: 0, }, tabBarShowLabel: false, cardStyle: { backgroundColor: '#1F2937' }, }} > <Tab.Screen name="Readings" options={{ tabBarIcon: ({ color, size }) => <Ionicons name="speedometer" size={24} color={color} />, }} > {() => <ReadingsTab data={data} deviceType="greenhouse" />} </Tab.Screen> <Tab.Screen name="Graphs" options={{ tabBarIcon: ({ color, size }) => <Ionicons name="bar-chart" size={24} color={color} />, }} > {() => <GraphsTab history={history} selectedMetrics={selectedMetrics} toggleMetric={toggleMetric} deviceType="greenhouse" primaryColor={primaryColor} />} </Tab.Screen> <Tab.Screen name="Alerts" options={{ tabBarIcon: ({ color, size }) => <Ionicons name="notifications" size={24} color={color} />, }} > {() => <AlertsScreen deviceId={device.id} userId="user123" />} </Tab.Screen> <Tab.Screen name="Settings" options={{ tabBarIcon: ({ color, size }) => <Ionicons name="settings" size={24} color={color} />, }} > {() => ( <SettingsTab settings={settings} localSettings={localSettings || settings} setLocalSettings={setLocalSettings} hasUnsavedChanges={hasUnsavedChanges} setHasUnsavedChanges={setHasUnsavedChanges} saveStatus={saveStatus} setSaveStatus={setSaveStatus} onSave={saveSettings} sendCommand={sendCommand} data={data} deviceId={device.id} deviceType="greenhouse" primaryColor={primaryColor} /> )} </Tab.Screen> </Tab.Navigator> <Modal visible={showNotifications} transparent animationType="slide"> <View style={styles.modalOverlay}> <View style={[styles.modalContent, { maxHeight: '80%' }]}> <View style={styles.notificationModalHeader}> <Text style={styles.modalTitle}>Уведомления</Text> {notifications.some(n => !n.read) && ( <TouchableOpacity onPress={markAllAsRead}> <Text style={styles.markAllReadText}>Прочитать все</Text> </TouchableOpacity> )} </View> <ScrollView style={{ maxHeight: 400, marginBottom: 16, backgroundColor: 'transparent' }}> {notifications.length === 0 ? ( <Text style={styles.emptyNotifications}>Нет уведомлений</Text> ) : ( notifications.map(notif => ( <View key={notif.id} style={[styles.notificationItem, !notif.read && styles.notificationItemUnread]}> <Ionicons name={notif.type === 'critical' ? 'alert-circle' : notif.type === 'warning' ? 'warning' : 'information-circle'} size={20} color={notif.type === 'critical' ? '#EF4444' : notif.type === 'warning' ? '#EAB308' : '#3B82F6'} /> <View style={styles.notificationContent}> <Text style={[styles.notificationText, !notif.read && styles.notificationTextUnread]}>{notif.message}</Text> <Text style={styles.notificationTime}>{notif.time}</Text> </View> </View> )) )} </ScrollView> <TouchableOpacity style={styles.closeButton} onPress={() => setShowNotifications(false)}><Text style={styles.closeButtonText}>Закрыть</Text></TouchableOpacity> </View> </View> </Modal> </View> ); } function FridgeDashboard({ device }) { const [data, setData] = useState({ temperature: 0, humidity: 0, sensorOk: true, compressor: false, heater: false, fan: false, timeStr: '--:--:--', }); const [history, setHistory] = useState([]); const [settings, setSettings] = useState({ targetTemp: 4, tempHysteresis: 2, heaterOn: 0, heaterHysteresis: 2, humMax: 90, humMin: 70, manualCooling: false, manualHeating: false, manualFan: false, criticalTempMax: 10, criticalTempMin: -2, criticalHumMax: 95, criticalHumMin: 60, }); const [loading, setLoading] = useState(true); useEffect(() => { loadData(); loadSettings(); loadHistory(); setLoading(false); // Убираем экран загрузки сразу const interval = setInterval(loadData, 3000); return () => clearInterval(interval); }, [device.id]); const loadData = async () => { const result = await mockAPI.getDeviceData(device.id); setData(result); if (result) await saveHistory(result); }; const loadSettings = async () => { try { const result = await AsyncStorage.getItem(`settings_${device.id}`); if (result) setSettings(JSON.parse(result)); } catch (e) {} }; const loadHistory = async () => { try { const result = await AsyncStorage.getItem(`history_${device.id}`); if (result) setHistory(JSON.parse(result)); } catch (e) {} }; const saveHistory = async (newData) => { const timestamp = new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }); const dataPoint = { time: timestamp, timestamp: Date.now(), temperature: parseFloat(newData.temperature.toFixed(1)), humidity: parseFloat(newData.humidity.toFixed(1)) }; setHistory(prevHistory => { const updated = [...prevHistory, dataPoint].slice(-1000); AsyncStorage.setItem(`history_${device.id}`, JSON.stringify(updated)); return updated; }); }; const saveSettings = useCallback(async (newSettings) => { try { await AsyncStorage.setItem(`settings_${device.id}`, JSON.stringify(newSettings)); setSettings(newSettings); await mockAPI.sendCommand(device.id, 'settings', newSettings); } catch (error) { console.error('Error saving settings:', error); throw error; } }, [device.id]); const sendCommand = useCallback(async (command, params) => { await mockAPI.sendCommand(device.id, command, params); await loadData(); }, [device.id]); const primaryColor = '#3B82F6'; if (loading || !data) { return <View style={styles.dashboardLoading}><ActivityIndicator size="large" color="#3B82F6" /><Text style={styles.loadingText}>Загрузка данных...</Text></View>; } return ( <View style={styles.dashboard}> <View style={[styles.dashboardHeader, { backgroundColor: primaryColor }]}> <View style={styles.dashboardHeaderContent}> <Text style={styles.dashboardHeaderTitle}>{device.name}</Text> <View style={styles.dashboardHeaderRight}> <Ionicons name="time-outline" size={20} color="#FFF" /> <Text style={styles.dashboardHeaderTime}>{data.timeStr}</Text> </View> </View> </View> <Tab.Navigator screenOptions={{ headerShown: false, tabBarActiveTintColor: primaryColor, tabBarInactiveTintColor: '#9CA3AF', tabBarStyle: { backgroundColor: '#374151', borderTopColor: '#4B5563', borderTopWidth: 1, height: 60, paddingTop: 8, paddingBottom: 0, }, tabBarShowLabel: false, cardStyle: { backgroundColor: '#1F2937' }, }} > <Tab.Screen name="Readings" options={{ tabBarIcon: ({ color }) => <Ionicons name="speedometer" size={24} color={color} />, }} > {() => <ReadingsTab data={data} deviceType="fridge" />} </Tab.Screen> <Tab.Screen name="Graphs" options={{ tabBarIcon: ({ color }) => <Ionicons name="bar-chart" size={24} color={color} />, }} > {() => <GraphsTab history={history} selectedMetrics={{ temperature: true, humidity: true }} toggleMetric={() => {}} deviceType="fridge" primaryColor={primaryColor} />} </Tab.Screen> <Tab.Screen name="Settings" options={{ tabBarIcon: ({ color }) => <Ionicons name="settings" size={24} color={color} />, }} > {() => ( <SettingsTab settings={settings} onSave={saveSettings} sendCommand={sendCommand} data={data} deviceId={device.id} deviceType="fridge" primaryColor={primaryColor} /> )} </Tab.Screen> </Tab.Navigator> </View> ); } // MODALS function AddDeviceModal({ visible, onClose, onAdd }) { const [deviceId, setDeviceId] = useState(''); const [password, setPassword] = useState(''); const [customName, setCustomName] = useState(''); const [loading, setLoading] = useState(false); const handleSubmit = async () => { if (!deviceId || !password) { Alert.alert('Ошибка', 'Заполните все обязательные поля'); return; } setLoading(true); const success = await onAdd(deviceId, password, customName); setLoading(false); if (success) { setDeviceId(''); setPassword(''); setCustomName(''); } }; return ( <Modal visible={visible} transparent animationType="fade"> <View style={styles.modalOverlay}> <View style={styles.modalContent}> <Text style={styles.modalTitle}>Добавить устройство</Text> <View style={styles.modalInputWrapper}> <Text style={styles.inputLabel}>Device ID</Text> <TextInput style={styles.input} value={deviceId} onChangeText={setDeviceId} placeholderTextColor="#6B7280" placeholder="HG-XXXXXX или FR-XXXXXX" /> </View> <View style={styles.modalInputWrapper}> <Text style={styles.inputLabel}>Пароль</Text> <TextInput style={styles.input} value={password} onChangeText={setPassword} placeholderTextColor="#6B7280" secureTextEntry /> </View> <View style={styles.modalInputWrapper}> <Text style={styles.inputLabel}>Название (необязательно)</Text> <TextInput style={styles.input} value={customName} onChangeText={setCustomName} placeholderTextColor="#6B7280" placeholder="Моя теплица" /> </View> <View style={styles.modalButtons}> <TouchableOpacity style={[styles.modalButton, styles.modalButtonCancel]} onPress={onClose}><Text style={styles.modalButtonTextCancel}>Отмена</Text></TouchableOpacity> <TouchableOpacity style={[styles.modalButton, styles.modalButtonSubmit]} onPress={handleSubmit} disabled={loading || !deviceId || !password}> {loading ? <ActivityIndicator color="#FFF" size="small" /> : <Text style={styles.modalButtonText}>Добавить</Text>} </TouchableOpacity> </View> </View> </View> </Modal> ); } function DeviceMenuModal({ visible, onClose, devices, onSelectDevice, onRemoveDevice, onAddDevice }) { return ( <Modal visible={visible} transparent animationType="slide"> <View style={styles.modalOverlay}> <View style={[styles.modalContent, { maxHeight: '80%' }]}> <Text style={styles.modalTitle}>Мои устройства</Text> <ScrollView style={{ maxHeight: 300, marginBottom: 16 }}> {devices.map(device => ( <View key={device.id} style={styles.deviceItem}> <TouchableOpacity style={styles.deviceItemContent} onPress={() => onSelectDevice(device)}> <Ionicons name={device.type === 'greenhouse' ? 'sunny' : 'snow'} size={20} color={device.type === 'greenhouse' ? '#10B981' : '#3B82F6'} /> <Text style={styles.deviceItemText}>{device.name}</Text> </TouchableOpacity> <TouchableOpacity style={styles.deviceItemDelete} onPress={() => onRemoveDevice(device.id)}> <Ionicons name="trash-outline" size={18} color="#EF4444" /> </TouchableOpacity> </View> ))} </ScrollView> <TouchableOpacity style={styles.addDeviceButton} onPress={onAddDevice}> <Ionicons name="add" size={20} color="#10B981" /> <Text style={styles.addDeviceButtonText}>Добавить устройство</Text> </TouchableOpacity> <TouchableOpacity style={styles.closeButton} onPress={onClose}><Text style={styles.closeButtonText}>Закрыть</Text></TouchableOpacity> </View> </View> </Modal> ); } // MAIN APP export default function App() { const [devices, setDevices] = useState([]); const [selectedDevice, setSelectedDevice] = useState(null); const [showAddDevice, setShowAddDevice] = useState(false); const [showDeviceMenu, setShowDeviceMenu] = useState(false); const [loading, setLoading] = useState(true); useEffect(() => { loadDevices(); }, []); // Установка цветов системных панелей Android useEffect(() => { if (Platform.OS === 'android') { // Навигационная панель (снизу) NavigationBar.setBackgroundColorAsync('#111827'); NavigationBar.setButtonStyleAsync('light'); // Статус-бар (сверху) setStatusBarBackgroundColor('#111827', true); setStatusBarStyle('light'); } }, []); const loadDevices = async () => { try { const savedDevices = await AsyncStorage.getItem('devices'); if (savedDevices) { const parsedDevices = JSON.parse(savedDevices); setDevices(parsedDevices); if (parsedDevices.length > 0) setSelectedDevice(parsedDevices[0]); } } catch (error) { console.error('Error loading devices:', error); } setLoading(false); }; const saveDevices = async (newDevices) => { try { await AsyncStorage.setItem('devices', JSON.stringify(newDevices)); setDevices(newDevices); } catch (error) { console.error('Error saving devices:', error); } }; const addDevice = async (deviceId, password, customName) => { try { const type = deviceId.startsWith('HG-') ? 'greenhouse' : 'fridge'; const newDevice = { id: deviceId, type: type, name: customName || (type === 'greenhouse' ? 'Теплица' : 'Холодильник'), addedAt: new Date().toISOString() }; const updated = [...devices, newDevice]; await saveDevices(updated); setShowAddDevice(false); setShowDeviceMenu(false); setSelectedDevice(newDevice); return true; } catch (error) { Alert.alert('Ошибка', error.message); return false; } }; const removeDevice = async (deviceId) => { Alert.alert('Удалить устройство?', 'Вы уверены?', [ { text: 'Отмена', style: 'cancel' }, { text: 'Удалить', style: 'destructive', onPress: async () => { const updated = devices.filter(d => d.id !== deviceId); await saveDevices(updated); if (selectedDevice?.id === deviceId) setSelectedDevice(updated.length > 0 ? updated[0] : null); }}, ]); }; const primaryColor = selectedDevice?.type === 'fridge' ? '#3B82F6' : '#10B981'; if (loading) { return ( <SafeAreaProvider> <View style={styles.loadingContainer}> <ActivityIndicator size="large" color="#10B981" /> <Text style={styles.loadingText}>Загрузка...</Text> </View> </SafeAreaProvider> ); } return ( <SafeAreaProvider> <SafeAreaView style={styles.container} edges={['top', 'left', 'right']}> <StatusBar style="light" backgroundColor="#111827" /> <View style={[styles.header, { backgroundColor: primaryColor }]}> <View style={styles.headerContent}> <Text style={styles.headerTitle}>Автоматизация</Text> {devices.length > 0 && ( <TouchableOpacity style={styles.deviceSelector} onPress={() => setShowDeviceMenu(true)}> <Ionicons name={selectedDevice?.type === 'greenhouse' ? 'sunny' : 'snow'} size={20} color="#FFF" /> <Text style={styles.deviceSelectorText} numberOfLines={1}>{selectedDevice?.name}</Text> <Ionicons name="chevron-down" size={18} color="#FFF" /> </TouchableOpacity> )} </View> </View> {devices.length === 0 ? ( <View style={styles.emptyState}> <Ionicons name="alert-circle-outline" size={64} color="#6B7280" /> <Text style={styles.emptyStateTitle}>Нет устройств</Text> <Text style={styles.emptyStateText}>Добавьте ваше первое устройство</Text> <TouchableOpacity style={[styles.addButton, { backgroundColor: primaryColor }]} onPress={() => setShowAddDevice(true)}> <Ionicons name="add" size={20} color="#FFF" /> <Text style={styles.addButtonText}>Добавить устройство</Text> </TouchableOpacity> </View> ) : selectedDevice ? ( <NavigationContainer> {selectedDevice.type === 'greenhouse' ? <GreenhouseDashboard device={selectedDevice} /> : <FridgeDashboard device={selectedDevice} />} </NavigationContainer> ) : null} <AddDeviceModal visible={showAddDevice} onClose={() => setShowAddDevice(false)} onAdd={addDevice} /> <DeviceMenuModal visible={showDeviceMenu} onClose={() => setShowDeviceMenu(false)} devices={devices} onSelectDevice={(device) => { setSelectedDevice(device); setShowDeviceMenu(false); }} onRemoveDevice={removeDevice} onAddDevice={() => { setShowDeviceMenu(false); setShowAddDevice(true); }} /> </SafeAreaView> </SafeAreaProvider> ); } // === STYLES === const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#111827' }, loadingContainer: { flex: 1, backgroundColor: '#111827', justifyContent: 'center', alignItems: 'center' }, loadingText: { color: '#D1D5DB', marginTop: 16, fontSize: 16 }, header: { paddingTop: 8, paddingBottom: 16, paddingHorizontal: 16, borderBottomLeftRadius: 20, borderBottomRightRadius: 20 }, headerContent: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, headerTitle: { fontSize: 24, fontWeight: 'bold', color: '#FFF' }, deviceSelector: { flexDirection: 'row', alignItems: 'center', backgroundColor: 'rgba(255, 255, 255, 0.2)', paddingHorizontal: 12, paddingVertical: 8, borderRadius: 12, gap: 8, maxWidth: 160, flexShrink: 1 }, deviceSelectorText: { color: '#FFF', fontWeight: '600', flex: 1 }, emptyState: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32 }, emptyStateTitle: { fontSize: 20, fontWeight: '600', color: '#E5E7EB', marginTop: 16, marginBottom: 8 }, emptyStateText: { fontSize: 16, color: '#9CA3AF', marginBottom: 24 }, addButton: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 24, paddingVertical: 12, borderRadius: 12, gap: 8 }, addButtonText: { color: '#FFF', fontSize: 16, fontWeight: '600' }, dashboard: { flex: 1, backgroundColor: '#1F2937', borderTopLeftRadius: 16, borderTopRightRadius: 16, overflow: 'hidden', marginTop: 8, marginLeft: 8, marginRight: 8 }, dashboardHeader: { padding: 16, borderBottomLeftRadius: 20, borderBottomRightRadius: 20 }, dashboardHeaderContent: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, dashboardHeaderTitle: { fontSize: 22, fontWeight: 'bold', color: '#FFF' }, dashboardHeaderRight: { flexDirection: 'row', alignItems: 'center', gap: 8 }, dashboardHeaderTime: { fontSize: 16, fontWeight: '600', color: '#FFF' }, dashboardLoading: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 48 }, tabContent: { flex: 1, backgroundColor: '#1F2937' }, tabContentContainer: { padding: 16, paddingBottom: 120 }, section: { marginBottom: 24 }, sectionTitle: { fontSize: 18, fontWeight: '600', color: '#E5E7EB', marginBottom: 12 }, subsectionTitle: { fontSize: 16, fontWeight: '600', color: '#D1D5DB', marginTop: 16, marginBottom: 8 }, sensorGrid: { flexDirection: 'row', gap: 12 }, sensorGridThree: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 }, sensorCard: { flex: 1, minWidth: '30%', backgroundColor: '#374151', borderRadius: 12, padding: 12, borderWidth: 1, borderColor: '#4B5563' }, sensorCardError: { borderColor: '#EF4444' }, sensorCardHeader: { flexDirection: 'row', alignItems: 'center', gap: 6, marginBottom: 8 }, sensorCardLabel: { fontSize: 11, color: '#9CA3AF' }, sensorCardValue: { fontSize: 20, fontWeight: 'bold', color: '#E5E7EB' }, infoCard: { flex: 1, backgroundColor: '#374151', borderRadius: 12, padding: 16, borderWidth: 1, borderColor: '#4B5563' }, infoCardLabel: { fontSize: 12, color: '#9CA3AF', marginBottom: 4 }, infoCardValue: { fontSize: 28, fontWeight: 'bold', color: '#E5E7EB' }, equipmentCard: { flex: 1, minWidth: '30%', backgroundColor: '#374151', borderRadius: 12, padding: 12, borderWidth: 1, borderColor: '#4B5563', alignItems: 'center' }, equipmentLabel: { fontSize: 11, color: '#9CA3AF', marginBottom: 6, fontWeight: '500', textAlign: 'center' }, equipmentStatus: { fontSize: 16, fontWeight: 'bold', color: '#EF4444' }, equipmentStatusOn: { color: '#10B981' }, equipmentStatusBlue: { color: '#3B82F6' }, equipmentStatusOrange: { color: '#F59E0B' }, equipmentStatusCyan: { color: '#06B6D4' }, warningBanner: { backgroundColor: 'rgba(234, 179, 8, 0.2)', borderLeftWidth: 4, borderLeftColor: '#EAB308', padding: 12, borderRadius: 8, marginTop: 12 }, warningText: { color: '#FDE047', fontSize: 14 }, emptyGraphs: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 48, backgroundColor: '#1F2937' }, emptyGraphsText: { color: '#9CA3AF', fontSize: 16, marginTop: 16, textAlign: 'center' }, timeRangeButtons: { flexDirection: 'row', gap: 8 }, timeRangeButton: { flex: 1, paddingVertical: 8, paddingHorizontal: 12, borderRadius: 8, backgroundColor: '#374151', alignItems: 'center' }, timeRangeButtonActive: { backgroundColor: '#10B981' }, timeRangeButtonText: { color: '#9CA3AF', fontSize: 14, fontWeight: '600' }, timeRangeButtonTextActive: { color: '#FFF' }, metricsGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 }, metricsRow: { flexDirection: 'row', gap: 6 }, metricButton: { paddingVertical: 8, paddingHorizontal: 12, borderRadius: 8, borderWidth: 2, borderColor: '#4B5563', backgroundColor: '#374151', flexDirection: 'row', alignItems: 'center', gap: 6 }, metricButtonEqual: { flex: 1, paddingVertical: 8, paddingHorizontal: 4, borderRadius: 8, borderWidth: 2, borderColor: '#4B5563', backgroundColor: '#374151', flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 4 }, metricButtonActive: { backgroundColor: 'rgba(16, 185, 129, 0.1)', borderColor: '#10B981' }, metricButtonText: { fontSize: 11, color: '#9CA3AF', fontWeight: '500' }, chartContainer: { backgroundColor: '#374151', borderRadius: 12, padding: 16, marginTop: 16 }, chartTitle: { fontSize: 16, fontWeight: '600', marginBottom: 12, textAlign: 'center' }, chart: { borderRadius: 16, backgroundColor: '#1F2937' }, chartCaption: { textAlign: 'center', color: '#9CA3AF', fontSize: 12, marginTop: 12 }, // Fullscreen graph styles chartWrapper: { position: 'relative', marginTop: 16, backgroundColor: '#1F2937', borderRadius: 16, overflow: 'hidden' }, fullscreenButton: { position: 'absolute', bottom: 8, right: 8, backgroundColor: 'rgba(0, 0, 0, 0.6)', padding: 8, borderRadius: 8, zIndex: 10 }, fullscreenContainer: { flex: 1, backgroundColor: '#1F2937', justifyContent: 'center' }, fullscreenHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', padding: 16, backgroundColor: '#374151' }, fullscreenTitle: { fontSize: 20, fontWeight: '600', color: '#E5E7EB' }, accordion: { backgroundColor: '#374151', borderRadius: 12, marginBottom: 12, overflow: 'hidden', borderWidth: 1, borderColor: '#4B5563' }, accordionHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', padding: 16 }, accordionHeaderLeft: { flexDirection: 'row', alignItems: 'center', gap: 12 }, accordionTitle: { fontSize: 16, fontWeight: '600', color: '#E5E7EB' }, accordionContent: { padding: 16, paddingTop: 0 }, toggleSwitch: { flexDirection: 'row', alignItems: 'center', gap: 12, marginBottom: 16 }, toggleLabel: { fontSize: 16, color: '#E5E7EB', fontWeight: '500' }, inputField: { flex: 1, marginBottom: 20 }, inputLabel: { fontSize: 14, fontWeight: '500', color: '#D1D5DB', marginBottom: 8, height: 36 }, input: { backgroundColor: '#4B5563', borderColor: '#6B7280', borderWidth: 1, borderRadius: 12, paddingHorizontal: 16, paddingVertical: 12, color: '#E5E7EB', fontSize: 16, minWidth: 0 }, settingsRow: { flexDirection: 'row', gap: 12, marginBottom: 12, alignItems: 'flex-start' }, controlButtons: { flexDirection: 'row', gap: 8, marginTop: 16 }, controlButton: { flex: 1, paddingVertical: 12, borderRadius: 12, alignItems: 'center' }, controlButtonText: { color: '#FFF', fontSize: 14, fontWeight: '600' }, statusBanner: { borderLeftWidth: 4, padding: 12, borderRadius: 8, marginBottom: 16 }, statusBannerText: { color: '#E5E7EB', fontSize: 14, marginBottom: 4 }, statusBannerButtons: { flexDirection: 'row', gap: 8, marginTop: 12 }, statusButton: { flex: 1, paddingVertical: 8, borderRadius: 8, alignItems: 'center' }, statusButtonText: { color: '#FFF', fontSize: 14, fontWeight: '600' }, radioOption: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingVertical: 8 }, radioLabel: { fontSize: 14, color: '#E5E7EB' }, infoText: { fontSize: 13, color: '#9CA3AF', marginTop: 8, lineHeight: 18 }, statusCard: { backgroundColor: '#4B5563', borderRadius: 12, padding: 16, marginTop: 16 }, statusCardTitle: { fontSize: 16, fontWeight: '600', color: '#E5E7EB', marginBottom: 12 }, statusCardText: { fontSize: 14, color: '#D1D5DB', marginBottom: 4 }, firmwareVersion: { backgroundColor: '#4B5563', borderRadius: 12, padding: 16, marginBottom: 16 }, firmwareVersionTitle: { fontSize: 14, fontWeight: '600', color: '#E5E7EB', marginBottom: 4 }, firmwareVersionText: { fontSize: 16, color: '#D1D5DB' }, saveButton: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8, paddingVertical: 14, paddingHorizontal: 16, borderRadius: 12, marginTop: 8 }, saveButtonText: { color: '#FFF', fontSize: 16, fontWeight: '600' }, modalOverlay: { flex: 1, backgroundColor: 'rgba(0, 0, 0, 0.7)', justifyContent: 'center', alignItems: 'center', padding: 16 }, modalContent: { backgroundColor: '#1F2937', borderRadius: 16, padding: 24, width: '100%', maxWidth: 400 }, modalTitle: { fontSize: 20, fontWeight: 'bold', color: '#E5E7EB', marginBottom: 20 }, modalInputWrapper: { marginBottom: 16 }, modalButtons: { flexDirection: 'row', gap: 12, marginTop: 16 }, modalButton: { flex: 1, paddingVertical: 12, borderRadius: 12, alignItems: 'center', justifyContent: 'center' }, modalButtonCancel: { backgroundColor: 'transparent', borderColor: '#4B5563', borderWidth: 1 }, modalButtonTextCancel: { color: '#D1D5DB', fontSize: 16, fontWeight: '600' }, modalButtonSubmit: { backgroundColor: '#10B981' }, modalButtonText: { color: '#FFF', fontSize: 16, fontWeight: '600' }, deviceItem: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', backgroundColor: '#374151', borderRadius: 12, padding: 12, marginBottom: 8 }, deviceItemContent: { flexDirection: 'row', alignItems: 'center', gap: 12, flex: 1 }, deviceItemText: { color: '#E5E7EB', fontSize: 16, fontWeight: '600' }, deviceItemDelete: { padding: 8 }, addDeviceButton: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8, paddingVertical: 12, borderRadius: 12, backgroundColor: 'rgba(16, 185, 129, 0.1)', marginBottom: 12 }, addDeviceButtonText: { color: '#10B981', fontSize: 16, fontWeight: '600' }, closeButton: { paddingVertical: 12, alignItems: 'center' }, closeButtonText: { color: '#9CA3AF', fontSize: 16 }, // Notification styles notificationBell: { position: 'relative' }, notificationBadge: { position: 'absolute', top: -4, right: -8, backgroundColor: '#EF4444', borderRadius: 10, minWidth: 18, height: 18, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 4 }, notificationBadgeText: { color: '#FFF', fontSize: 11, fontWeight: 'bold' }, notificationModalHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }, markAllReadText: { color: '#10B981', fontSize: 14, fontWeight: '600' }, notificationItem: { flexDirection: 'row', alignItems: 'flex-start', gap: 12, backgroundColor: '#374151', borderRadius: 12, padding: 12, marginBottom: 8, opacity: 0.7 }, notificationItemUnread: { opacity: 1, borderLeftWidth: 3, borderLeftColor: '#10B981' }, notificationContent: { flex: 1 }, notificationText: { color: '#D1D5DB', fontSize: 14, marginBottom: 4 }, notificationTextUnread: { color: '#E5E7EB', fontWeight: '600' }, notificationTime: { color: '#9CA3AF', fontSize: 12 }, emptyNotifications: { color: '#9CA3AF', fontSize: 16, textAlign: 'center', paddingVertical: 32 }, unsavedChangesBanner: { flexDirection: 'row', alignItems: 'center', gap: 8, backgroundColor: 'rgba(234, 179, 8, 0.15)', padding: 12, borderRadius: 12, marginTop: 12, borderLeftWidth: 3, borderLeftColor: '#EAB308' }, unsavedChangesText: { color: '#EAB308', fontSize: 14, fontWeight: '600' }, saveButtonRow: { flexDirection: 'row', gap: 12, marginTop: 0, justifyContent: 'center' }, cancelButton: { backgroundColor: '#374151', flex: 1 }, cancelButtonText: { color: '#E5E7EB', fontSize: 16, fontWeight: '600' }, });