feat: Refactor automation and site document screens, add device management features, and implement Home Assistant API integration
This commit is contained in:
@ -1,74 +1,130 @@
|
|||||||
import { OFFICES_DATA } from '@/data/data';
|
import DeviceCard from '@/components/DeviceCard';
|
||||||
import type { OfficeItem } from '@/types/types';
|
import LoadingScreen from '@/components/LoadingScreen';
|
||||||
import { Activity, ChevronLeft, Lightbulb, Thermometer, Wifi, WifiOff, Zap, Plus } from 'lucide-react-native';
|
import { HaArea, HaEntity } from '@/types/types';
|
||||||
import React from 'react';
|
import { getHaEntitiesByArea, toggleHaEntity } from '@/utils/haApi';
|
||||||
import { useRouter, useLocalSearchParams } from 'expo-router';
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
import { ScrollView, Text, TouchableOpacity, View } from 'react-native';
|
import { ChevronLeft, ServerOff, WifiOff } from 'lucide-react-native';
|
||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { RefreshControl, ScrollView, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
|
||||||
export default function AutomationDetail() {
|
export default function AutomationDetailScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { id } = useLocalSearchParams<{ id: string }>();
|
const params = useLocalSearchParams();
|
||||||
|
const [area, setArea] = useState<HaArea | null>(null);
|
||||||
|
const [devices, setDevices] = useState<HaEntity[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
const selectedOffice: OfficeItem | undefined = OFFICES_DATA.find(o => o.id.toString() === id);
|
// Fetch dei devices dell'area
|
||||||
if (!selectedOffice) return <Text>Ufficio non trovato</Text>;
|
const fetchAreaDevices = useCallback(async (areaId: string, isRefreshing = false) => {
|
||||||
|
try {
|
||||||
|
if (!isRefreshing) setIsLoading(true);
|
||||||
|
|
||||||
|
// Fetch Documenti
|
||||||
|
const response = await getHaEntitiesByArea(areaId);
|
||||||
|
const filteredDevices = response.filter(device => device.name);
|
||||||
|
setDevices(filteredDevices);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Errore nel recupero dei dispositivi dell\'area:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (params.areaData) {
|
||||||
|
try {
|
||||||
|
const jsonString = Array.isArray(params.areaData) ? params.areaData[0] : params.areaData;
|
||||||
|
const parsedArea = JSON.parse(jsonString);
|
||||||
|
setArea(parsedArea);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Errore nel parsing dei dati del cantiere:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [params.areaData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (params.id) {
|
||||||
|
setIsLoading(true);
|
||||||
|
const areaId = Array.isArray(params.id) ? params.id[0] : params.id;
|
||||||
|
fetchAreaDevices(areaId);
|
||||||
|
}
|
||||||
|
}, [params.id, fetchAreaDevices]);
|
||||||
|
|
||||||
|
const onRefresh = () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
const areaId = Array.isArray(params.id) ? params.id[0] : params.id;
|
||||||
|
fetchAreaDevices(areaId, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onToggle = useCallback((entityId: string) => {
|
||||||
|
// Optimistic UI update
|
||||||
|
setDevices(prevDevices =>
|
||||||
|
prevDevices.map(d =>
|
||||||
|
d.entity_id === entityId
|
||||||
|
? { ...d, state: d.state === 'on' ? 'off' : 'on' }
|
||||||
|
: d
|
||||||
|
)
|
||||||
|
);
|
||||||
|
// Fire and forget the API call
|
||||||
|
toggleHaEntity(entityId).catch(() => {
|
||||||
|
// Revert on error
|
||||||
|
console.log("Toggle failed, reverting UI.");
|
||||||
|
const areaId = Array.isArray(params.id) ? params.id[0] : params.id;
|
||||||
|
if (areaId) fetchAreaDevices(areaId);
|
||||||
|
});
|
||||||
|
}, [fetchAreaDevices, params.id]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <LoadingScreen />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!area) {
|
||||||
|
return (
|
||||||
|
<View className="flex-1 items-center justify-center">
|
||||||
|
<WifiOff size={48} color="#9ca3af" />
|
||||||
|
<Text className="text-xl text-gray-600 font-bold mt-4">Area non trovata</Text>
|
||||||
|
<Text className="text-gray-400 text-center mt-2 mb-4">L'area che stai cercando non esiste o è stata rimossa.</Text>
|
||||||
|
<TouchableOpacity onPress={() => router.back()} className="mt-4 bg-[#099499] px-6 py-3 rounded-xl active:scale-95">
|
||||||
|
<Text className="text-white font-bold text-base">Torna Indietro</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex-1 bg-gray-50">
|
<View className="flex-1 bg-gray-50">
|
||||||
{/* Header Dettaglio */}
|
|
||||||
<View className="bg-white p-6 pt-16 shadow-sm flex-row justify-between items-center border-b border-gray-100">
|
<View className="bg-white p-6 pt-16 shadow-sm flex-row justify-between items-center border-b border-gray-100">
|
||||||
<View className='flex-row items-center'>
|
<View className='flex-row items-center gap-4'>
|
||||||
<TouchableOpacity
|
<TouchableOpacity onPress={() => router.back()} className='rounded-full active:bg-gray-100'>
|
||||||
onPress={() => router.back()}
|
|
||||||
className="mr-4 p-3 rounded-full bg-gray-50 active:bg-gray-200"
|
|
||||||
>
|
|
||||||
<ChevronLeft size={28} color="#4b5563" />
|
<ChevronLeft size={28} color="#4b5563" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<Text className="text-2xl font-bold text-gray-800">{selectedOffice.name}</Text>
|
<Text className="text-2xl font-bold text-gray-800">{area.name}</Text>
|
||||||
</View>
|
</View>
|
||||||
{/* Status Dot */}
|
<View className={`ms-auto w-3.5 h-3.5 rounded-full border-2 border-white shadow-sm bg-green-500`} />
|
||||||
<View className={`ms-auto w-4 h-4 rounded-full border-2 border-white shadow-sm ${selectedOffice.status === 'online' ? 'bg-green-500' : 'bg-red-500'}`} />
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<ScrollView contentContainerStyle={{ padding: 20, gap: 24 }} showsVerticalScrollIndicator={false}>
|
<ScrollView
|
||||||
<View className="flex-row gap-5">
|
contentContainerClassName={`flex-grow p-5 ${devices.length > 0 ? 'justify-start' : 'justify-center'}`}
|
||||||
{/* Lights Card Grande */}
|
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} colors={['#099499']} tintColor={'#099499'} />}
|
||||||
<TouchableOpacity className={`flex-1 p-6 rounded-3xl border-2 active:scale-95 ${selectedOffice.lights ? 'border-[#099499] bg-teal-50' : 'border-transparent bg-white shadow-sm'}`}>
|
>
|
||||||
<View className="flex-row justify-between items-start mb-6">
|
{devices.length > 0 ? (
|
||||||
<Lightbulb size={40} color={selectedOffice.lights ? '#099499' : '#d1d5db'} />
|
<View className="flex-row flex-wrap justify-between">
|
||||||
{/* Switch UI Grande - FIXED: Rimossa 'transition-colors' che causava il crash */}
|
{devices.map(device => (
|
||||||
<View className={`w-14 h-8 rounded-full p-1 ${selectedOffice.lights ? 'bg-[#099499]' : 'bg-gray-200'}`}>
|
<DeviceCard key={device.entity_id} device={device} onToggle={onToggle} />
|
||||||
<View className={`bg-white w-6 h-6 rounded-full shadow-sm ${selectedOffice.lights ? 'translate-x-6' : 'translate-x-0'}`} />
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<Text className="text-lg font-bold text-gray-800 mb-1">Luci</Text>
|
|
||||||
<Text className="text-gray-500 font-medium">{selectedOffice.lights ? 'Accese - 80%' : 'Spente'}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
{/* Temp Card Grande */}
|
|
||||||
<View className="flex-1 bg-white p-6 rounded-3xl border border-transparent shadow-sm">
|
|
||||||
<Thermometer size={40} color="#fb923c" className="mb-6" />
|
|
||||||
<Text className="text-lg font-bold text-gray-800 mb-1">Clima</Text>
|
|
||||||
<View className="flex-row items-end">
|
|
||||||
<Text className="text-4xl font-bold text-gray-800">{selectedOffice.temp}</Text>
|
|
||||||
<Text className="text-gray-500 mb-2 ml-1 text-lg">°C</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Chart Card Grande */}
|
|
||||||
<View className="bg-white p-6 rounded-3xl shadow-sm border border-gray-100">
|
|
||||||
<View className="flex-row items-center mb-6 gap-3">
|
|
||||||
<Activity size={28} color="#099499" />
|
|
||||||
<Text className="text-xl font-bold text-gray-700">Consumo Oggi</Text>
|
|
||||||
</View>
|
|
||||||
<View className="flex-row items-end justify-between h-48 gap-4">
|
|
||||||
{[40, 65, 30, 80, 55, 90, 45].map((h, i) => (
|
|
||||||
<View key={i} className="flex-1 bg-gray-100 rounded-t-xl relative overflow-hidden h-full justify-end">
|
|
||||||
<View style={{ height: `${h}%` }} className="w-full bg-[#099499] rounded-t-xl opacity-80" />
|
|
||||||
</View>
|
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
) : (
|
||||||
|
<View className="items-center">
|
||||||
|
<ServerOff size={48} color="#9ca3af" />
|
||||||
|
<Text className="text-lg text-gray-500 font-medium mt-4">Nessun dispositivo</Text>
|
||||||
|
<Text className="text-center text-gray-400 mt-2">
|
||||||
|
Non ci sono dispositivi in quest'area.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View className="h-20" />
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,71 +1,209 @@
|
|||||||
import { OFFICES_DATA } from '@/data/data';
|
import type { HaArea } from '@/types/types';
|
||||||
import type { OfficeItem } from '@/types/types';
|
import { getHaAreas, testHaConnection } from '@/utils/haApi';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { Activity, ChevronLeft, Lightbulb, Thermometer, Wifi, WifiOff, Zap, Plus } from 'lucide-react-native';
|
import { Archive, Briefcase, DoorOpen, Grid2X2, Lightbulb, Plus, Users, WifiOff } from 'lucide-react-native';
|
||||||
import React, { useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { ScrollView, Text, TouchableOpacity, View } from 'react-native';
|
import { RefreshControl, ScrollView, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
import LoadingScreen from '@/components/LoadingScreen';
|
||||||
|
|
||||||
|
const AREA_STYLES = [
|
||||||
|
{ icon: Users, bgColor: 'bg-indigo-100' },
|
||||||
|
{ icon: Briefcase, bgColor: 'bg-slate-100' },
|
||||||
|
{ icon: Lightbulb, bgColor: 'bg-yellow-100' },
|
||||||
|
{ icon: DoorOpen, bgColor: 'bg-green-100' },
|
||||||
|
{ icon: Archive, bgColor: 'bg-orange-100' },
|
||||||
|
{ icon: Grid2X2, bgColor: 'bg-sky-100' },
|
||||||
|
];
|
||||||
|
|
||||||
export default function AutomationScreen() {
|
export default function AutomationScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [selectedOffice, setSelectedOffice] = useState<OfficeItem | null>(null);
|
const [allAreas, setAllAreas] = useState<HaArea[]>([]);
|
||||||
|
const [connectionStatus, setConnectionStatus] = useState<{ success: boolean; message: string }>({ success: false, message: 'Connecting...' });
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
const [floors, setFloors] = useState<string[]>(['Tutti']);
|
||||||
|
const [selectedFloor, setSelectedFloor] = useState<string>('Tutti');
|
||||||
|
|
||||||
|
const initialize = useCallback(async () => {
|
||||||
|
const connResult = await testHaConnection();
|
||||||
|
setConnectionStatus(connResult);
|
||||||
|
|
||||||
|
if (connResult.success) {
|
||||||
|
const areasResult = await getHaAreas();
|
||||||
|
const areas = areasResult.map(area => ({
|
||||||
|
...area,
|
||||||
|
floor_name: area.floor_name || 'Non assegnato',
|
||||||
|
device_count: area.device_count || 0,
|
||||||
|
}));
|
||||||
|
setAllAreas(areas);
|
||||||
|
|
||||||
|
const uniqueFloors = Array.from(new Set(areas.map(area => area.floor_name || 'Non assegnato')));
|
||||||
|
setFloors(['Tutti', ...uniqueFloors]);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
await initialize();
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
load();
|
||||||
|
}, [initialize]);
|
||||||
|
|
||||||
|
const onRefresh = useCallback(async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
await initialize();
|
||||||
|
setRefreshing(false);
|
||||||
|
}, [initialize]);
|
||||||
|
|
||||||
|
const filteredAreas = useMemo(() => {
|
||||||
|
if (selectedFloor === 'Tutti') {
|
||||||
|
return allAreas;
|
||||||
|
}
|
||||||
|
return allAreas.filter(area => area.floor_name === selectedFloor);
|
||||||
|
}, [allAreas, selectedFloor]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <LoadingScreen />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!connectionStatus.success) {
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-gray-50">
|
||||||
|
<View className="bg-white pt-16 shadow-sm border-b border-gray-100">
|
||||||
|
<View className="px-6 pb-4 flex-row justify-between items-center">
|
||||||
|
<View>
|
||||||
|
<Text className="text-3xl font-bold text-gray-800 mb-1">Domotica</Text>
|
||||||
|
<Text className="text-base text-gray-500">Controlla gli ambienti</Text>
|
||||||
|
</View>
|
||||||
|
<View className={`px-4 py-2 rounded-xl border ${connectionStatus.success ? 'bg-green-100 border-green-200' : 'bg-red-100 border-red-200'}`}>
|
||||||
|
<Text className={`text-xs font-bold tracking-wide ${connectionStatus.success ? 'text-green-700' : 'text-red-700'}`}>
|
||||||
|
{connectionStatus.success ? 'ONLINE' : 'OFFLINE'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{connectionStatus.success && (
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerClassName="px-5 pb-4 gap-3">
|
||||||
|
{floors.map(floor => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={floor}
|
||||||
|
onPress={() => setSelectedFloor(floor)}
|
||||||
|
className={`px-5 py-2.5 rounded-xl ${selectedFloor === floor ? 'bg-[#099499]' : 'bg-gray-100 active:bg-gray-200'}`}
|
||||||
|
>
|
||||||
|
<Text className={`font-bold ${selectedFloor === floor ? 'text-white' : 'text-gray-600'}`}>{floor}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerClassName="flex-grow items-center justify-center pb-[100px]"
|
||||||
|
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} colors={['#099499']} tintColor={'#099499'} />}
|
||||||
|
>
|
||||||
|
<WifiOff size={48} color="#9ca3af" />
|
||||||
|
<Text className="text-lg text-gray-500 font-medium mt-4">Errore di connessione</Text>
|
||||||
|
<Text className="text-center text-gray-400 mt-2 px-10">
|
||||||
|
{connectionStatus.message}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity onPress={onRefresh} className="mt-6 bg-[#099499] px-6 py-3 rounded-lg active:scale-95">
|
||||||
|
<Text className="text-white font-bold">Riprova</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// --- LIST VIEW (INGRANDITA) ---
|
|
||||||
return (
|
return (
|
||||||
<View className="flex-1 bg-gray-50">
|
<View className="flex-1 bg-gray-50">
|
||||||
<View className="bg-white p-6 pt-16 shadow-sm flex-row justify-between items-center border-b border-gray-100">
|
<View className="bg-white pt-16 shadow-sm border-b border-gray-100">
|
||||||
<View>
|
<View className="px-6 pb-4 flex-row justify-between items-center">
|
||||||
<Text className="text-3xl font-bold text-gray-800 mb-1">Domotica</Text>
|
<View>
|
||||||
<Text className="text-base text-gray-500">Controlla gli ambienti</Text>
|
<Text className="text-3xl font-bold text-gray-800 mb-1">Domotica</Text>
|
||||||
</View>
|
<Text className="text-base text-gray-500">Controlla gli ambienti</Text>
|
||||||
<View className="bg-green-100 px-4 py-2 rounded-xl border border-green-200 mt-1">
|
</View>
|
||||||
<Text className="text-xs font-bold text-green-700 tracking-wide">ONLINE</Text>
|
<View className={`px-4 py-2 rounded-xl border ${connectionStatus.success ? 'bg-green-100 border-green-200' : 'bg-red-100 border-red-200'}`}>
|
||||||
|
<Text className={`text-xs font-bold tracking-wide ${connectionStatus.success ? 'text-green-700' : 'text-red-700'}`}>
|
||||||
|
{connectionStatus.success ? 'ONLINE' : 'OFFLINE'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
{connectionStatus.success && (
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerClassName="px-5 pb-4 gap-3">
|
||||||
|
{floors.map(floor => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={floor}
|
||||||
|
onPress={() => setSelectedFloor(floor)}
|
||||||
|
className={`px-5 py-2.5 rounded-xl ${selectedFloor === floor ? 'bg-[#099499]' : 'bg-gray-100 active:bg-gray-200'}`}
|
||||||
|
>
|
||||||
|
<Text className={`font-bold ${selectedFloor === floor ? 'text-white' : 'text-gray-600'}`}>{floor}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<ScrollView contentContainerStyle={{ padding: 20, gap: 20 }} showsVerticalScrollIndicator={false}>
|
<ScrollView
|
||||||
{OFFICES_DATA.map((office) => (
|
contentContainerClassName="p-5"
|
||||||
<TouchableOpacity
|
showsVerticalScrollIndicator={false}
|
||||||
key={office.id}
|
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} colors={['#099499']} tintColor={'#099499'} />}
|
||||||
onPress={() => router.push(`/automation/${office.id}`)}
|
>
|
||||||
className="bg-white rounded-3xl p-6 shadow-sm flex-row items-center justify-between border border-gray-100 active:border-[#099499]/30 active:scale-[0.98]"
|
{filteredAreas.length > 0 ? (
|
||||||
>
|
<View className="flex-row flex-wrap justify-between">
|
||||||
<View className="flex-row items-center gap-5">
|
{filteredAreas.map((area, index) => {
|
||||||
<View className={`p-5 rounded-2xl ${office.status === 'online' ? 'bg-teal-50' : 'bg-gray-100'}`}>
|
const { icon: IconComponent, bgColor } = AREA_STYLES[index % AREA_STYLES.length];
|
||||||
{office.status === 'online' ?
|
return (
|
||||||
<Wifi size={32} color="#099499" /> :
|
<TouchableOpacity
|
||||||
<WifiOff size={32} color="#9ca3af" />
|
key={area.id}
|
||||||
}
|
onPress={() => router.push({
|
||||||
</View>
|
pathname: '/(protected)/automation/[id]',
|
||||||
<View>
|
params: {
|
||||||
<Text className="font-bold text-gray-800 text-xl mb-2">{office.name}</Text>
|
id: area.name,
|
||||||
<View className="flex-row items-center gap-4">
|
areaData: JSON.stringify(area),
|
||||||
<View className="flex-row items-center gap-1.5">
|
}
|
||||||
<Thermometer size={16} color="#6b7280" />
|
})}
|
||||||
<Text className="text-sm font-medium text-gray-600">{office.temp}°C</Text>
|
className="bg-white rounded-3xl p-5 shadow-sm border border-gray-100 active:scale-[0.98] w-[48%] mb-4 aspect-square"
|
||||||
|
>
|
||||||
|
<View className="flex-1 justify-between">
|
||||||
|
<View className={`${bgColor} w-14 h-14 items-center justify-center rounded-2xl`}>
|
||||||
|
<IconComponent size={32} />
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<Text
|
||||||
|
className="text-xl font-bold text-gray-800 leading-tight"
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{area.name}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-base text-gray-400 font-medium mt-1">
|
||||||
|
{area.device_count} dispositivi
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View className="w-1 h-1 rounded-full bg-gray-300" />
|
</TouchableOpacity>
|
||||||
<View className="flex-row items-center gap-1.5">
|
)
|
||||||
<Zap size={16} color="#eab308" />
|
})}
|
||||||
<Text className="text-sm font-medium text-gray-600">{office.power}W</Text>
|
</View>
|
||||||
</View>
|
) : (
|
||||||
</View>
|
<View className="mt-20 items-center justify-center">
|
||||||
</View>
|
<WifiOff size={48} color="#9ca3af" />
|
||||||
</View>
|
<Text className="text-lg text-gray-500 font-medium mt-4">Nessuna area trovata</Text>
|
||||||
{/* Status Dot */}
|
<Text className="text-center text-gray-400 mt-2">
|
||||||
<View className={`w-4 h-4 rounded-full border-2 border-white shadow-sm ${office.status === 'online' ? 'bg-green-500' : 'bg-red-500'}`} />
|
{`Nessuna area per il piano "${selectedFloor}"`}
|
||||||
</TouchableOpacity>
|
</Text>
|
||||||
))}
|
</View>
|
||||||
{/* Spacer finale per la navbar */}
|
)}
|
||||||
<View className="h-20" />
|
<View className="h-20" />
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
{/* FAB */}
|
{/* <TouchableOpacity
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => alert('Aggiungi nuovo collegamento')}
|
onPress={() => alert('Aggiungi nuovo collegamento')}
|
||||||
className="absolute bottom-8 right-6 w-16 h-16 bg-[#099499] rounded-full shadow-lg items-center justify-center active:scale-90"
|
className="absolute bottom-8 right-6 w-16 h-16 bg-[#099499] rounded-full shadow-lg items-center justify-center active:scale-90"
|
||||||
>
|
>
|
||||||
<Plus size={32} color="white" />
|
<Plus size={32} color="white" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity> */}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { Download, FileText, Plus, Search, Calendar as CalendarIcon, ArrowLeft } from 'lucide-react-native';
|
import { Download, FileText, Plus, Search, Calendar as CalendarIcon, ChevronLeft } from 'lucide-react-native';
|
||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { RangePickerModal } from '@/components/RangePickerModal';
|
import { RangePickerModal } from '@/components/RangePickerModal';
|
||||||
import { Alert, RefreshControl, ScrollView, Text, TextInput, TouchableOpacity, View } from 'react-native';
|
import { Alert, RefreshControl, ScrollView, Text, TextInput, TouchableOpacity, View } from 'react-native';
|
||||||
@ -130,7 +130,7 @@ export default function SiteDocumentsScreen() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<View className="bg-white p-6 pt-16 shadow-sm border-b border-gray-100 flex-row items-center gap-4">
|
<View className="bg-white p-6 pt-16 shadow-sm border-b border-gray-100 flex-row items-center gap-4">
|
||||||
<TouchableOpacity onPress={() => router.back()} className="p-2 -ml-2 rounded-full active:bg-gray-100">
|
<TouchableOpacity onPress={() => router.back()} className="p-2 -ml-2 rounded-full active:bg-gray-100">
|
||||||
<ArrowLeft size={24} color="#374151" />
|
<ChevronLeft size={28} color="#4b5563" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<View className="flex-1">
|
<View className="flex-1">
|
||||||
|
|||||||
83
components/DeviceCard.tsx
Normal file
83
components/DeviceCard.tsx
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import { HaEntity } from '@/types/types';
|
||||||
|
import { Lightbulb, Power, Cpu, Lock, Zap, Fan, Cctv, CloudSun } from 'lucide-react-native';
|
||||||
|
import React from 'react';
|
||||||
|
import { Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
|
||||||
|
const DeviceCard = ({ device, onToggle }: { device: HaEntity; onToggle: (entityId: string) => void; }) => {
|
||||||
|
const getIcon = () => {
|
||||||
|
const domain = device.entity_id.split('.')[0];
|
||||||
|
|
||||||
|
switch (domain) {
|
||||||
|
case 'light':
|
||||||
|
return <Lightbulb size={32} color="#f59e0b" />;
|
||||||
|
case 'switch':
|
||||||
|
return <Power size={32} color="#3b82f6" />;
|
||||||
|
case 'sensor':
|
||||||
|
return <Cpu size={32} color="#ef4444" />;
|
||||||
|
case 'lock':
|
||||||
|
return <Lock size={32} color="#10b981" />;
|
||||||
|
case 'fan':
|
||||||
|
return <Fan size={32} color="#6b7280" />;
|
||||||
|
case 'camera':
|
||||||
|
return <Cctv size={32} color="#8b5cf6" />;
|
||||||
|
case 'weather':
|
||||||
|
return <CloudSun size={32} color="#3b82f6" />;
|
||||||
|
default:
|
||||||
|
return <Zap size={32} color="#8b5cf6" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isToggleable = ['light', 'switch', 'fan', 'input_boolean'].includes(device.entity_id.split('.')[0]);
|
||||||
|
const isOn = device.state === 'on';
|
||||||
|
|
||||||
|
const getDeviceState = (state: string) => {
|
||||||
|
switch (state) {
|
||||||
|
case 'on':
|
||||||
|
return 'Acceso';
|
||||||
|
case 'off':
|
||||||
|
return 'Spento';
|
||||||
|
case 'idle':
|
||||||
|
return 'Inattivo';
|
||||||
|
case 'locked':
|
||||||
|
return 'Bloccato';
|
||||||
|
case 'unlocked':
|
||||||
|
return 'Sbloccato';
|
||||||
|
case 'unavailable':
|
||||||
|
return 'Non disponibile';
|
||||||
|
case 'unknown':
|
||||||
|
return 'Sconosciuto';
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className={`bg-white rounded-3xl p-5 shadow-sm border border-gray-100 w-[48%] mb-4`}
|
||||||
|
style={{ aspectRatio: 1 }}
|
||||||
|
>
|
||||||
|
<View className="flex-1 justify-between">
|
||||||
|
<View className="flex-row justify-between items-start">
|
||||||
|
{getIcon()}
|
||||||
|
{isToggleable && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => onToggle(device.entity_id)}
|
||||||
|
className={`w-12 h-7 rounded-full p-1 ${isOn ? 'bg-[#099499]' : 'bg-gray-200'} active:scale-[0.9]`}
|
||||||
|
>
|
||||||
|
<View className={`bg-white w-5 h-5 rounded-full shadow-sm ${isOn ? 'translate-x-5' : 'translate-x-0'}`} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View>
|
||||||
|
<Text className="text-lg font-bold text-gray-800" numberOfLines={2}>{device.name}</Text>
|
||||||
|
<Text className="text-base text-gray-500 font-medium mt-1">
|
||||||
|
{getDeviceState(device.state)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DeviceCard;
|
||||||
@ -1,8 +0,0 @@
|
|||||||
import { OfficeItem } from '@/types/types';
|
|
||||||
|
|
||||||
export const OFFICES_DATA: OfficeItem[] = [
|
|
||||||
{ id: 1, name: "Ufficio Tecnico", status: "online", temp: 22, lights: true, power: 450 },
|
|
||||||
{ id: 2, name: "Sala Riunioni", status: "offline", temp: 19, lights: false, power: 0 },
|
|
||||||
{ id: 3, name: "Amministrazione", status: "online", temp: 24, lights: true, power: 320 },
|
|
||||||
{ id: 4, name: "Magazzino", status: "online", temp: 18, lights: false, power: 120 },
|
|
||||||
];
|
|
||||||
@ -35,15 +35,6 @@ export interface DocumentItem {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OfficeItem {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
status: 'online' | 'offline';
|
|
||||||
temp: number;
|
|
||||||
lights: boolean;
|
|
||||||
power: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TimeOffRequestType {
|
export interface TimeOffRequestType {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@ -72,4 +63,18 @@ export interface ConstructionSite {
|
|||||||
name: string;
|
name: string;
|
||||||
code: string;
|
code: string;
|
||||||
attachments_count: number;
|
attachments_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HaArea {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
floor_id: string | null;
|
||||||
|
floor_name: string | null;
|
||||||
|
device_count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HaEntity {
|
||||||
|
entity_id: string;
|
||||||
|
name: string;
|
||||||
|
state: string;
|
||||||
|
}
|
||||||
|
|||||||
142
utils/haApi.ts
Normal file
142
utils/haApi.ts
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import axios, { AxiosError } from 'axios';
|
||||||
|
import { HaArea, HaEntity } from '@/types/types';
|
||||||
|
|
||||||
|
// CONFIGURAZIONE
|
||||||
|
const HA_API_URL = process.env.EXPO_PUBLIC_HA_API_URL;
|
||||||
|
const HA_TOKEN = process.env.EXPO_PUBLIC_HA_TOKEN;
|
||||||
|
|
||||||
|
// Crea un'istanza di axios per Home Assistant
|
||||||
|
const haApi = axios.create({
|
||||||
|
baseURL: HA_API_URL,
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${HA_TOKEN}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
timeout: 5000, // 5 secondi di timeout
|
||||||
|
});
|
||||||
|
|
||||||
|
haApi.interceptors.request.use((config) => {
|
||||||
|
console.log(`[HOME ASSISTANT API REQUEST] ${config.method?.toUpperCase()} ${config.url}`);
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Connection test
|
||||||
|
*/
|
||||||
|
export const testHaConnection = async (): Promise<{ success: boolean; message: string }> => {
|
||||||
|
// Controlla se le variabili d'ambiente sono caricate
|
||||||
|
if (!HA_API_URL || !HA_TOKEN) {
|
||||||
|
console.error("Variabili d'ambiente per Home Assistant non trovate. Assicurati che EXPO_PUBLIC_HA_API_URL and EXPO_PUBLIC_HA_TOKEN siano definite nel file .env");
|
||||||
|
return { success: false, message: "Configurazione API per Home Assistant mancante." };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await haApi.get('/');
|
||||||
|
// Se la risposta è OK, HA restituisce un JSON con 'message'
|
||||||
|
if (response.status === 200 && response.data.message) {
|
||||||
|
return { success: true, message: response.data.message };
|
||||||
|
}
|
||||||
|
return { success: false, message: "Risposta inattesa dal server Home Assistant." };
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
const axiosError = error as AxiosError;
|
||||||
|
|
||||||
|
if (axiosError.code === 'ECONNABORTED' || axiosError.message.includes('timeout')) {
|
||||||
|
return { success: false, message: "Timeout: Il server non risponde (IP errato o server offline)." };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (axiosError.response) {
|
||||||
|
// Errori con una risposta dal server (es. 401, 404)
|
||||||
|
if (axiosError.response.status === 401) {
|
||||||
|
return { success: false, message: "Errore 401: Token non autorizzato. Controlla il Long-Lived Token." };
|
||||||
|
}
|
||||||
|
if (axiosError.response.status === 404) {
|
||||||
|
return { success: false, message: "Errore 404: Verifica la configurazione di Home Assistant." };
|
||||||
|
}
|
||||||
|
return { success: false, message: `Errore server: Status ${axiosError.response.status}` };
|
||||||
|
} else if (axiosError.request) {
|
||||||
|
// Errori di rete (la richiesta è partita ma non ha ricevuto risposta)
|
||||||
|
return { success: false, message: `Errore di rete: Impossibile raggiungere ${HA_API_URL}` };
|
||||||
|
} else {
|
||||||
|
// Errore generico
|
||||||
|
return { success: false, message: `Errore sconosciuto: ${axiosError.message}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch Home Assistant Areas
|
||||||
|
export const getHaAreas = async (): Promise<HaArea[]> => {
|
||||||
|
try {
|
||||||
|
const response = await haApi.post('/template', {
|
||||||
|
"template": `
|
||||||
|
[
|
||||||
|
{%- for area_id in areas() %}
|
||||||
|
{%- set area_name = area_name(area_id) %}
|
||||||
|
{%- set f_id = floor_id(area_name) %}
|
||||||
|
{%- set f_name = floor_name(f_id) %}
|
||||||
|
{
|
||||||
|
"id": "{{ area_id }}",
|
||||||
|
"name": "{{ area_name }}",
|
||||||
|
"floor_id": {% if f_id != None %}"{{ f_id }}"{% else %}null{% endif %},
|
||||||
|
"floor_name": {% if f_name != None %}"{{ f_name }}"{% else %}null{% endif %},
|
||||||
|
"device_count": {{area_devices(area_id)|count}}
|
||||||
|
}{% if not loop.last %},{% endif %}
|
||||||
|
{%- endfor %}
|
||||||
|
]
|
||||||
|
`
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = typeof response.data === 'string' ? JSON.parse(response.data) : response.data;
|
||||||
|
// console.log("Aree recuperate da Home Assistant:", data);
|
||||||
|
return data;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Errore recupero aree:", error);
|
||||||
|
return []; // Restituisce un array vuoto in caso di errore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch Home Assistant Entities by Area
|
||||||
|
export const getHaEntitiesByArea = async (areaId: string): Promise<HaEntity[]> => {
|
||||||
|
try {
|
||||||
|
const response = await haApi.post('/template', {
|
||||||
|
"template": `
|
||||||
|
[
|
||||||
|
{%- set area_entities = area_entities('${areaId}') %}
|
||||||
|
{%- for entity_id in area_entities %}
|
||||||
|
{
|
||||||
|
"entity_id": {{ entity_id | tojson }},
|
||||||
|
"name": {{ state_attr(entity_id, 'friendly_name') | tojson }},
|
||||||
|
"state": {{ states(entity_id) | tojson }}
|
||||||
|
}{% if not loop.last %},{% endif %}
|
||||||
|
{%- endfor %}
|
||||||
|
]
|
||||||
|
`
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = typeof response.data === 'string' ? JSON.parse(response.data) : response.data;
|
||||||
|
// console.log(`Entità recuperate per l'area ${areaId}:`, data);
|
||||||
|
return data;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Errore recupero entità per l'area ${areaId}:`, error);
|
||||||
|
return []; // Restituisce un array vuoto in caso di errore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toggle Home Assistant Entity
|
||||||
|
export const toggleHaEntity = async (entityId: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const domain = entityId.split('.')[0];
|
||||||
|
|
||||||
|
await haApi.post('/services/' + domain + '/toggle', {
|
||||||
|
entity_id: entityId
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Errore toggle ${entityId}:`, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
Reference in New Issue
Block a user