feat: Refactor automation and site document screens, add device management features, and implement Home Assistant API integration

This commit is contained in:
2026-01-30 12:06:25 +01:00
parent 44d021891f
commit 9bb8279631
7 changed files with 543 additions and 127 deletions

View File

@ -1,74 +1,130 @@
import { OFFICES_DATA } from '@/data/data';
import type { OfficeItem } from '@/types/types';
import { Activity, ChevronLeft, Lightbulb, Thermometer, Wifi, WifiOff, Zap, Plus } from 'lucide-react-native';
import React from 'react';
import { useRouter, useLocalSearchParams } from 'expo-router';
import { ScrollView, Text, TouchableOpacity, View } from 'react-native';
import DeviceCard from '@/components/DeviceCard';
import LoadingScreen from '@/components/LoadingScreen';
import { HaArea, HaEntity } from '@/types/types';
import { getHaEntitiesByArea, toggleHaEntity } from '@/utils/haApi';
import { useLocalSearchParams, useRouter } from 'expo-router';
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 { 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);
if (!selectedOffice) return <Text>Ufficio non trovato</Text>;
// Fetch dei devices dell'area
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 (
<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='flex-row items-center'>
<TouchableOpacity
onPress={() => router.back()}
className="mr-4 p-3 rounded-full bg-gray-50 active:bg-gray-200"
>
<View className='flex-row items-center gap-4'>
<TouchableOpacity onPress={() => router.back()} className='rounded-full active:bg-gray-100'>
<ChevronLeft size={28} color="#4b5563" />
</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>
{/* Status Dot */}
<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 className={`ms-auto w-3.5 h-3.5 rounded-full border-2 border-white shadow-sm bg-green-500`} />
</View>
<ScrollView contentContainerStyle={{ padding: 20, gap: 24 }} showsVerticalScrollIndicator={false}>
<View className="flex-row gap-5">
{/* Lights Card Grande */}
<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">
<Lightbulb size={40} color={selectedOffice.lights ? '#099499' : '#d1d5db'} />
{/* Switch UI Grande - FIXED: Rimossa 'transition-colors' che causava il crash */}
<View className={`w-14 h-8 rounded-full p-1 ${selectedOffice.lights ? 'bg-[#099499]' : 'bg-gray-200'}`}>
<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>
<ScrollView
contentContainerClassName={`flex-grow p-5 ${devices.length > 0 ? 'justify-start' : 'justify-center'}`}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} colors={['#099499']} tintColor={'#099499'} />}
>
{devices.length > 0 ? (
<View className="flex-row flex-wrap justify-between">
{devices.map(device => (
<DeviceCard key={device.entity_id} device={device} onToggle={onToggle} />
))}
</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>
</View>
);

View File

@ -1,71 +1,209 @@
import { OFFICES_DATA } from '@/data/data';
import type { OfficeItem } from '@/types/types';
import type { HaArea } from '@/types/types';
import { getHaAreas, testHaConnection } from '@/utils/haApi';
import { useRouter } from 'expo-router';
import { Activity, ChevronLeft, Lightbulb, Thermometer, Wifi, WifiOff, Zap, Plus } from 'lucide-react-native';
import React, { useState } from 'react';
import { ScrollView, Text, TouchableOpacity, View } from 'react-native';
import { Archive, Briefcase, DoorOpen, Grid2X2, Lightbulb, Plus, Users, WifiOff } from 'lucide-react-native';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
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() {
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 (
<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>
<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="bg-green-100 px-4 py-2 rounded-xl border border-green-200 mt-1">
<Text className="text-xs font-bold text-green-700 tracking-wide">ONLINE</Text>
<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 contentContainerStyle={{ padding: 20, gap: 20 }} showsVerticalScrollIndicator={false}>
{OFFICES_DATA.map((office) => (
<TouchableOpacity
key={office.id}
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]"
>
<View className="flex-row items-center gap-5">
<View className={`p-5 rounded-2xl ${office.status === 'online' ? 'bg-teal-50' : 'bg-gray-100'}`}>
{office.status === 'online' ?
<Wifi size={32} color="#099499" /> :
<WifiOff size={32} color="#9ca3af" />
}
</View>
<View>
<Text className="font-bold text-gray-800 text-xl mb-2">{office.name}</Text>
<View className="flex-row items-center gap-4">
<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>
<ScrollView
contentContainerClassName="p-5"
showsVerticalScrollIndicator={false}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} colors={['#099499']} tintColor={'#099499'} />}
>
{filteredAreas.length > 0 ? (
<View className="flex-row flex-wrap justify-between">
{filteredAreas.map((area, index) => {
const { icon: IconComponent, bgColor } = AREA_STYLES[index % AREA_STYLES.length];
return (
<TouchableOpacity
key={area.id}
onPress={() => router.push({
pathname: '/(protected)/automation/[id]',
params: {
id: area.name,
areaData: JSON.stringify(area),
}
})}
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 className="w-1 h-1 rounded-full bg-gray-300" />
<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>
{/* Status Dot */}
<View className={`w-4 h-4 rounded-full border-2 border-white shadow-sm ${office.status === 'online' ? 'bg-green-500' : 'bg-red-500'}`} />
</TouchableOpacity>
))}
{/* Spacer finale per la navbar */}
</TouchableOpacity>
)
})}
</View>
) : (
<View className="mt-20 items-center justify-center">
<WifiOff size={48} color="#9ca3af" />
<Text className="text-lg text-gray-500 font-medium mt-4">Nessuna area trovata</Text>
<Text className="text-center text-gray-400 mt-2">
{`Nessuna area per il piano "${selectedFloor}"`}
</Text>
</View>
)}
<View className="h-20" />
</ScrollView>
{/* FAB */}
<TouchableOpacity
{/* <TouchableOpacity
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"
>
<Plus size={32} color="white" />
</TouchableOpacity>
</TouchableOpacity> */}
</View>
);
}