feat: adicionar alternancia de tema

This commit is contained in:
Theo 2026-05-23 00:31:17 -03:00
parent 9b57320ff2
commit eeded6c20e

View file

@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { HeartPulse } from 'lucide-react'; import { HeartPulse, Moon, Sun } from 'lucide-react';
import { fetchClimaCuidaData } from './api/openMeteo'; import { fetchClimaCuidaData } from './api/openMeteo';
import { ErrorBanner } from './components/ErrorBanner'; import { ErrorBanner } from './components/ErrorBanner';
import { Forecast7Days } from './components/Forecast7Days'; import { Forecast7Days } from './components/Forecast7Days';
@ -16,10 +16,12 @@ import { calculateRiskScore } from './lib/riskScore';
import type { LocationOption, ProfileId, WeatherBundle } from './types'; import type { LocationOption, ProfileId, WeatherBundle } from './types';
const STORAGE_KEY = 'clima-cuida-preferences'; const STORAGE_KEY = 'clima-cuida-preferences';
type ThemeMode = 'light' | 'dark';
interface StoredPreferences { interface StoredPreferences {
profile?: ProfileId; profile?: ProfileId;
location?: LocationOption; location?: LocationOption;
theme?: ThemeMode;
} }
function loadPreferences(): StoredPreferences { function loadPreferences(): StoredPreferences {
@ -42,6 +44,7 @@ function savePreferences(preferences: StoredPreferences) {
export default function App() { export default function App() {
const initialPreferences = useMemo(() => loadPreferences(), []); const initialPreferences = useMemo(() => loadPreferences(), []);
const [profile, setProfile] = useState<ProfileId>(initialPreferences.profile ?? 'adult'); const [profile, setProfile] = useState<ProfileId>(initialPreferences.profile ?? 'adult');
const [theme, setTheme] = useState<ThemeMode>(initialPreferences.theme ?? 'light');
const [activeLocation, setActiveLocation] = useState<LocationOption>( const [activeLocation, setActiveLocation] = useState<LocationOption>(
initialPreferences.location ?? MOCK_WEATHER.location, initialPreferences.location ?? MOCK_WEATHER.location,
); );
@ -51,6 +54,7 @@ export default function App() {
const selectedProfile = USE_PROFILES.find((item) => item.id === profile) ?? USE_PROFILES[0]; const selectedProfile = USE_PROFILES.find((item) => item.id === profile) ?? USE_PROFILES[0];
const risk = useMemo(() => calculateRiskScore(weather.current, profile), [profile, weather.current]); const risk = useMemo(() => calculateRiskScore(weather.current, profile), [profile, weather.current]);
const isDarkTheme = theme === 'dark';
const loadWeather = useCallback( const loadWeather = useCallback(
async (location: LocationOption) => { async (location: LocationOption) => {
@ -60,19 +64,19 @@ export default function App() {
const nextWeather = await fetchClimaCuidaData(location); const nextWeather = await fetchClimaCuidaData(location);
setWeather(nextWeather); setWeather(nextWeather);
setActiveLocation(location); setActiveLocation(location);
savePreferences({ profile, location }); savePreferences({ profile, location, theme });
} catch { } catch {
setWeather({ setWeather({
...MOCK_WEATHER, ...MOCK_WEATHER,
fetchedAt: new Date().toISOString(), fetchedAt: new Date().toISOString(),
source: 'mock', source: 'mock',
}); });
setError('A API não respondeu agora. Exibindo dados de exemplo para manter a leitura do dia utilizável.'); setError('A API nao respondeu agora. Exibindo dados de exemplo para manter a leitura do dia utilizavel.');
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, },
[profile], [profile, theme],
); );
useEffect(() => { useEffect(() => {
@ -80,16 +84,24 @@ export default function App() {
}, []); }, []);
useEffect(() => { useEffect(() => {
savePreferences({ profile, location: activeLocation }); document.documentElement.dataset.theme = theme;
}, [profile, activeLocation]); }, [theme]);
useEffect(() => {
savePreferences({ profile, location: activeLocation, theme });
}, [profile, activeLocation, theme]);
function handleProfileChange(nextProfile: ProfileId) { function handleProfileChange(nextProfile: ProfileId) {
setProfile(nextProfile); setProfile(nextProfile);
} }
function handleToggleTheme() {
setTheme((currentTheme) => (currentTheme === 'light' ? 'dark' : 'light'));
}
function handleUseCurrentLocation() { function handleUseCurrentLocation() {
if (!navigator.geolocation) { if (!navigator.geolocation) {
setError('Este navegador não oferece geolocalização. Busque uma cidade pelo nome.'); setError('Este navegador nao oferece geolocalizacao. Busque uma cidade pelo nome.');
return; return;
} }
@ -98,7 +110,7 @@ export default function App() {
(position) => { (position) => {
const location: LocationOption = { const location: LocationOption = {
id: 'browser-location', id: 'browser-location',
name: 'Sua localização', name: 'Sua localizacao',
latitude: position.coords.latitude, latitude: position.coords.latitude,
longitude: position.coords.longitude, longitude: position.coords.longitude,
}; };
@ -106,7 +118,7 @@ export default function App() {
}, },
() => { () => {
setIsLoading(false); setIsLoading(false);
setError('Não foi possível acessar sua localização. Você pode pesquisar uma cidade manualmente.'); setError('Nao foi possivel acessar sua localizacao. Voce pode pesquisar uma cidade manualmente.');
}, },
{ enableHighAccuracy: false, timeout: 9000, maximumAge: 10 * 60 * 1000 }, { enableHighAccuracy: false, timeout: 9000, maximumAge: 10 * 60 * 1000 },
); );
@ -127,16 +139,27 @@ export default function App() {
onSelectLocation={loadWeather} onSelectLocation={loadWeather}
onUseCurrentLocation={handleUseCurrentLocation} onUseCurrentLocation={handleUseCurrentLocation}
/> />
<ProfileSelector profiles={USE_PROFILES} value={profile} onChange={handleProfileChange} /> <div className="topbar-actions">
<button
className="theme-toggle"
type="button"
aria-label={isDarkTheme ? 'Ativar modo claro' : 'Ativar modo escuro'}
onClick={handleToggleTheme}
>
{isDarkTheme ? <Sun size={17} aria-hidden="true" /> : <Moon size={17} aria-hidden="true" />}
<span>{isDarkTheme ? 'Modo Claro' : 'Modo Escuro'}</span>
</button>
<ProfileSelector profiles={USE_PROFILES} value={profile} onChange={handleProfileChange} />
</div>
</header> </header>
<main className="dashboard-shell" aria-busy={isLoading}> <main className="dashboard-shell" aria-busy={isLoading}>
{error && <ErrorBanner message={error} onRetry={() => loadWeather(activeLocation)} />} {error && <ErrorBanner message={error} onRetry={() => loadWeather(activeLocation)} />}
<div className="dashboard-intro"> <div className="dashboard-intro">
<p className="eyebrow">Orientação geral, não recomendação médica</p> <p className="eyebrow">Orientacao geral, nao recomendacao medica</p>
<p> <p>
Leitura combinada de clima, UV e qualidade do ar para decidir saída, exercício, deslocamento Leitura combinada de clima, UV e qualidade do ar para decidir saida, exercicio, deslocamento
e proteção diária. e protecao diaria.
</p> </p>
</div> </div>